OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-sync / lib / browser.js

274 lines · javascript ref:
const { spawn } = require('child_process')
const path = require('path')

/**
 * Git repo browser — reads files, commits, diffs from bare git repos
 * using native git commands. Ported from pear-git v1's GitBrowser.
 *
 * Not a standalone class — methods are mixed into HyperSync.
 */

function execGit (repoPath, args, timeout) {
  return new Promise((resolve, reject) => {
    const proc = spawn('git', ['-C', repoPath, ...args], {
      stdio: ['ignore', 'pipe', 'pipe']
    })

    const chunks = []
    const errorChunks = []

    const timer = setTimeout(() => {
      proc.kill('SIGTERM')
      reject(new Error('git command timed out: git ' + args.join(' ')))
    }, timeout || 30000)

    proc.stdout.on('data', (chunk) => chunks.push(chunk))
    proc.stderr.on('data', (chunk) => errorChunks.push(chunk))

    proc.on('close', (code) => {
      clearTimeout(timer)
      if (code !== 0) {
        const errMsg = Buffer.concat(errorChunks).toString().trim()
        reject(new Error(errMsg || 'git exited with code ' + code))
      } else {
        resolve(Buffer.concat(chunks).toString())
      }
    })

    proc.on('error', (err) => {
      clearTimeout(timer)
      reject(err)
    })
  })
}

/**
 * Like execGit but resolves the raw stdout Buffer instead of a UTF-8 string.
 * Required for binary blobs (images, etc.) — toString() corrupts them.
 */
function execGitBuffer (repoPath, args, timeout) {
  return new Promise((resolve, reject) => {
    const proc = spawn('git', ['-C', repoPath, ...args], {
      stdio: ['ignore', 'pipe', 'pipe']
    })

    const chunks = []
    const errorChunks = []

    const timer = setTimeout(() => {
      proc.kill('SIGTERM')
      reject(new Error('git command timed out: git ' + args.join(' ')))
    }, timeout || 30000)

    proc.stdout.on('data', (chunk) => chunks.push(chunk))
    proc.stderr.on('data', (chunk) => errorChunks.push(chunk))

    proc.on('close', (code) => {
      clearTimeout(timer)
      if (code !== 0) {
        const errMsg = Buffer.concat(errorChunks).toString().trim()
        reject(new Error(errMsg || 'git exited with code ' + code))
      } else {
        resolve(Buffer.concat(chunks))
      }
    })

    proc.on('error', (err) => {
      clearTimeout(timer)
      reject(err)
    })
  })
}

/**
 * Browser methods to mix into HyperSync.prototype
 */
const browserMethods = {
  _repoPath (name) {
    const resolved = path.resolve(this._reposDir, name + '.git')
    if (!resolved.startsWith(path.resolve(this._reposDir))) {
      throw new Error('Invalid repository name')
    }
    return resolved
  },

  async listFiles (name, ref, dirPath) {
    ref = ref || 'HEAD'
    dirPath = dirPath || ''
    const repoPath = this._repoPath(name)
    const treePath = dirPath ? ref + ':' + dirPath : ref

    const output = await execGit(repoPath, ['ls-tree', '-l', '--', treePath])
    return output.trim().split('\n').filter(Boolean).map(line => {
      const parts = line.split(/\s+/)
      const type = parts[1]
      return {
        name: parts.slice(4).join(' '),
        type: type === 'tree' ? 'directory' : type === 'commit' ? 'submodule' : 'file',
        hash: parts[2],
        size: parts[3] === '-' ? null : parseInt(parts[3]),
        mode: parts[0],
        path: dirPath ? dirPath + '/' + parts.slice(4).join(' ') : parts.slice(4).join(' ')
      }
    })
  },

  async readFile (name, filePath, ref) {
    ref = ref || 'HEAD'
    const repoPath = this._repoPath(name)
    return execGit(repoPath, ['show', ref + ':' + filePath])
  },

  // Raw blob bytes (Buffer) — for binary files like images that must not be
  // coerced through toString(). Mirrors readFile but preserves exact bytes.
  async readFileRaw (name, filePath, ref) {
    ref = ref || 'HEAD'
    const repoPath = this._repoPath(name)
    return execGitBuffer(repoPath, ['show', ref + ':' + filePath])
  },

  async getCommits (name, ref, limit) {
    ref = ref || 'HEAD'
    limit = limit || 20
    const repoPath = this._repoPath(name)
    const format = '%H%n%an%n%ae%n%at%n%s%n%b%n--END--'

    const output = await execGit(repoPath, [
      'log', '--max-count=' + limit, '--format=' + format, ref, '--'
    ])

    return output.split('--END--\n').filter(e => e.trim()).map(entry => {
      const lines = entry.trim().split('\n')
      if (lines.length < 5) return null
      return {
        hash: lines[0],
        author: lines[1],
        email: lines[2],
        timestamp: parseInt(lines[3]),
        subject: lines[4],
        body: lines.slice(5).join('\n').trim(),
        shortHash: lines[0].substring(0, 7),
        date: new Date(parseInt(lines[3]) * 1000).toISOString()
      }
    }).filter(Boolean)
  },

  async getCommit (name, hash) {
    const commits = await this.getCommits(name, hash, 1)
    return commits[0] || null
  },

  async getBranches (name) {
    const repoPath = this._repoPath(name)
    try {
      const output = await execGit(repoPath, ['branch', '-a'])
      return output.trim().split('\n').filter(Boolean).map(line => ({
        name: line.replace(/^\*?\s+/, '').trim(),
        current: line.startsWith('*')
      }))
    } catch { return [] }
  },

  async getTags (name) {
    const repoPath = this._repoPath(name)
    try {
      const output = await execGit(repoPath, ['tag', '-l'])
      return output.trim().split('\n').filter(Boolean)
    } catch { return [] }
  },

  async getStats (name) {
    const repoPath = this._repoPath(name)
    try {
      const [commitCount, branches, tags] = await Promise.all([
        execGit(repoPath, ['rev-list', '--count', 'HEAD']).catch(() => '0'),
        this.getBranches(name),
        this.getTags(name)
      ])
      return { commits: parseInt(commitCount.trim()), branches: branches.length, tags: tags.length }
    } catch { return { commits: 0, branches: 0, tags: 0 } }
  },

  async getReadme (name, ref) {
    ref = ref || 'HEAD'
    const files = await this.listFiles(name, ref, '')
    const readme = files.find(f =>
      f.name.toLowerCase() === 'readme.md' ||
      f.name.toLowerCase() === 'readme' ||
      f.name.toLowerCase() === 'readme.txt'
    )
    if (!readme) return null
    const content = await this.readFile(name, readme.name, ref)
    return { name: readme.name, content }
  },

  async getDiff (name, fromCommit, toCommit) {
    toCommit = toCommit || 'HEAD'
    const repoPath = this._repoPath(name)
    return execGit(repoPath, ['diff', fromCommit, toCommit])
  },

  async getCommitDiff (name, commitHash) {
    const repoPath = this._repoPath(name)
    return execGit(repoPath, ['diff-tree', '-p', '--root', commitHash])
  },

  async searchFiles (name, query, ref) {
    ref = ref || 'HEAD'
    const repoPath = this._repoPath(name)
    const output = await execGit(repoPath, ['ls-tree', '-r', '--name-only', '--', ref])
    const allFiles = output.trim().split('\n').filter(Boolean)
    return allFiles.filter(f => f.toLowerCase().includes(query.toLowerCase())).slice(0, 50)
  },

  async isBinary (name, filePath, ref) {
    ref = ref || 'HEAD'
    try {
      const content = await this.readFile(name, filePath, ref)
      const sample = content.substring(0, 8192)
      const nullCount = (sample.match(/\0/g) || []).length
      return nullCount / sample.length > 0.01
    } catch { return false }
  },

  /**
   * Handle git smart HTTP info/refs request
   */
  async handleInfoRefs (repoPath, service) {
    if (service !== 'git-upload-pack' && service !== 'git-receive-pack') {
      throw new Error('Invalid service: ' + service)
    }
    // git-upload-pack and git-receive-pack are standalone binaries, not git subcommands
    const output = await new Promise((resolve, reject) => {
      const proc = spawn(service, ['--stateless-rpc', '--advertise-refs', repoPath], {
        stdio: ['ignore', 'pipe', 'pipe']
      })
      const chunks = []
      proc.stdout.on('data', (c) => chunks.push(c))
      proc.on('close', (code) => {
        if (code !== 0) reject(new Error(service + ' exited with code ' + code))
        else resolve(Buffer.concat(chunks).toString())
      })
      proc.on('error', reject)
    })
    // Prepend service announcement per smart HTTP protocol
    const announcement = '# service=' + service + '\n'
    const lenHex = (announcement.length + 4).toString(16).padStart(4, '0')
    return lenHex + announcement + '0000' + output
  },

  /**
   * Spawn git service process for smart HTTP pack exchange
   */
  spawnGitService (repoPath, service) {
    if (service !== 'git-upload-pack' && service !== 'git-receive-pack') {
      throw new Error('Invalid service: ' + service)
    }
    return spawn(service, ['--stateless-rpc', repoPath], {
      stdio: ['pipe', 'pipe', 'pipe']
    })
  }
}

module.exports = { browserMethods, execGit }