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 }