import subprocess from 'bare-subprocess'
import path from 'bare-path'
import os from 'bare-os'
/**
* Git Repository Browser
*
* Reads files, commits, and directory structure directly from bare git repositories
* using native git commands.
*/
export class GitBrowser {
constructor(options = {}) {
this.gitReposPath = options.gitReposPath || path.join(os.homedir(), 'git-repos')
}
/**
* Get the path to a bare repository.
* Validates that the resolved path stays within gitReposPath (prevents path traversal).
*/
getRepoPath(repoName) {
const resolved = path.resolve(this.gitReposPath, `${repoName}.git`)
const base = path.resolve(this.gitReposPath)
if (!resolved.startsWith(base + path.sep) && resolved !== base) {
throw new Error('Invalid repository name')
}
return resolved
}
/**
* Execute git command in repository
*/
async execGit(repoPath, args) {
return new Promise((resolve, reject) => {
const proc = subprocess.spawn('git', ['-C', repoPath, ...args], {
stdio: ['ignore', 'pipe', 'pipe']
})
const chunks = []
const errorChunks = []
const timeout = setTimeout(() => {
proc.stdout.removeAllListeners()
proc.stderr.removeAllListeners()
proc.kill('SIGTERM')
reject(new Error(`Git command timed out after 60s: git ${args.join(' ')}`))
}, 60000)
proc.stdout.on('data', (chunk) => chunks.push(chunk))
proc.stderr.on('data', (chunk) => errorChunks.push(chunk))
// CRITICAL: Use 'close' instead of 'exit' to ensure all streams are finished
// The 'exit' event can fire before stdout/stderr have been fully consumed in bare-subprocess
proc.on('close', (code) => {
clearTimeout(timeout)
if (code === 0) {
const output = Buffer.concat(chunks).toString('utf-8')
resolve(output)
} else {
const error = Buffer.concat(errorChunks).toString('utf-8')
reject(new Error(`Git command failed: ${error}`))
}
})
proc.on('error', (err) => {
clearTimeout(timeout)
reject(err)
})
})
}
/**
* List files in a directory at a specific commit
*/
async listFiles(repoName, ref = 'HEAD', dirPath = '') {
const repoPath = this.getRepoPath(repoName)
const treePath = dirPath ? `${ref}:${dirPath}` : ref
try {
const output = await this.execGit(repoPath, ['ls-tree', '-l', '--', treePath])
const lines = output.trim().split('\n').filter(l => l)
return lines.map(line => {
// Format: <mode> <type> <hash> <size> <name>
// Example: 100644 blob abc123... 1234 README.md
const parts = line.split(/\s+/)
const mode = parts[0]
const type = parts[1]
const hash = parts[2]
const sizeOrDash = parts[3]
const name = parts.slice(4).join(' ')
// Determine type: tree=directory, commit=submodule, blob=file
let itemType = 'file'
if (type === 'tree') {
itemType = 'directory'
} else if (type === 'commit') {
itemType = 'submodule'
}
return {
name,
type: itemType,
hash,
size: sizeOrDash === '-' ? null : parseInt(sizeOrDash),
mode,
path: dirPath ? `${dirPath}/${name}` : name,
isSubmodule: type === 'commit'
}
})
} catch (err) {
console.error('List files error:', err.message)
return []
}
}
/**
* Read file contents at a specific commit
*/
async readFile(repoName, filePath, ref = 'HEAD') {
const repoPath = this.getRepoPath(repoName)
try {
const content = await this.execGit(repoPath, ['show', `${ref}:${filePath}`])
return content
} catch (err) {
throw new Error(`Failed to read file: ${err.message}`)
}
}
/**
* Get commit history
*/
async getCommits(repoName, ref = 'HEAD', limit = 20) {
const repoPath = this.getRepoPath(repoName)
try {
// Use --format to get structured output
const format = '%H%n%an%n%ae%n%at%n%s%n%b%n--END--'
const output = await this.execGit(repoPath, [
'log',
`--max-count=${limit}`,
`--format=${format}`,
ref,
'--'
])
const commits = []
const entries = output.split('--END--\n').filter(e => e.trim())
for (const entry of entries) {
const lines = entry.trim().split('\n')
if (lines.length >= 5) {
commits.push({
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()
})
}
}
return commits
} catch (err) {
console.error('[GitBrowser] Get commits error:', err.message)
return []
}
}
/**
* Get single commit details
*/
async getCommit(repoName, hash) {
const commits = await this.getCommits(repoName, hash, 1)
return commits[0] || null
}
/**
* Get branches
*/
async getBranches(repoName) {
const repoPath = this.getRepoPath(repoName)
try {
const output = await this.execGit(repoPath, ['branch', '-a'])
const lines = output.trim().split('\n').filter(l => l)
return lines.map(line => {
const name = line.replace(/^\*?\s+/, '').trim()
const isCurrent = line.startsWith('*')
return { name, current: isCurrent }
})
} catch (err) {
return []
}
}
/**
* Get tags
*/
async getTags(repoName) {
const repoPath = this.getRepoPath(repoName)
try {
const output = await this.execGit(repoPath, ['tag', '-l'])
return output.trim().split('\n').filter(t => t)
} catch (err) {
return []
}
}
/**
* Get repository statistics
*/
async getStats(repoName) {
const repoPath = this.getRepoPath(repoName)
try {
const [commitCount, branches, tags] = await Promise.all([
this.execGit(repoPath, ['rev-list', '--count', 'HEAD']).catch(() => '0'),
this.getBranches(repoName),
this.getTags(repoName)
])
return {
commits: parseInt(commitCount.trim()),
branches: branches.length,
tags: tags.length
}
} catch (err) {
return { commits: 0, branches: 0, tags: 0 }
}
}
/**
* Search files by name
*/
async searchFiles(repoName, query, ref = 'HEAD') {
const repoPath = this.getRepoPath(repoName)
try {
const output = await this.execGit(repoPath, [
'ls-tree',
'-r',
'--name-only',
'--',
ref
])
const allFiles = output.trim().split('\n').filter(f => f)
const results = allFiles.filter(f =>
f.toLowerCase().includes(query.toLowerCase())
)
return results.slice(0, 50) // Limit results
} catch (err) {
return []
}
}
/**
* Get README content
*/
async getReadme(repoName, ref = 'HEAD') {
const files = await this.listFiles(repoName, ref, '')
const readme = files.find(f =>
f.name.toLowerCase() === 'readme.md' ||
f.name.toLowerCase() === 'readme' ||
f.name.toLowerCase() === 'readme.txt'
)
if (!readme) return null
try {
const content = await this.readFile(repoName, readme.name, ref)
return {
name: readme.name,
content,
isMarkdown: readme.name.toLowerCase().endsWith('.md')
}
} catch {
return null
}
}
/**
* Get diff between commits
*/
async getDiff(repoName, fromCommit, toCommit = 'HEAD') {
const repoPath = this.getRepoPath(repoName)
try {
const output = await this.execGit(repoPath, [
'diff',
'--',
fromCommit,
toCommit
])
return output
} catch (err) {
throw new Error(`Failed to get diff: ${err.message}`)
}
}
/**
* Check if file is binary
*/
async isBinary(repoName, filePath, ref = 'HEAD') {
const repoPath = this.getRepoPath(repoName)
try {
// Git returns exit code 0 if text, 1 if binary
await this.execGit(repoPath, ['diff', '--no-index', '/dev/null', `${ref}:${filePath}`])
return false
} catch {
return true
}
}
/**
* Get submodules from .gitmodules file
*/
async getSubmodules(repoName, ref = 'HEAD') {
const repoPath = this.getRepoPath(repoName)
try {
const gitmodulesContent = await this.execGit(repoPath, ['show', `${ref}:.gitmodules`])
const submodules = []
// Parse .gitmodules (INI format)
const lines = gitmodulesContent.split('\n')
let currentSubmodule = null
for (const line of lines) {
const trimmed = line.trim()
// New submodule section
if (trimmed.startsWith('[submodule')) {
if (currentSubmodule) {
submodules.push(currentSubmodule)
}
const match = trimmed.match(/\[submodule "(.+)"\]/)
currentSubmodule = {
name: match ? match[1] : '',
path: '',
url: ''
}
} else if (currentSubmodule && trimmed) {
// Parse properties
if (trimmed.startsWith('path =')) {
currentSubmodule.path = trimmed.replace('path =', '').trim()
} else if (trimmed.startsWith('url =')) {
currentSubmodule.url = trimmed.replace('url =', '').trim()
}
}
}
if (currentSubmodule) {
submodules.push(currentSubmodule)
}
return submodules
} catch (err) {
// No .gitmodules file
return []
}
}
/**
* Get info for a specific submodule path
*/
async getSubmoduleInfo(repoName, submodulePath, ref = 'HEAD') {
const submodules = await this.getSubmodules(repoName, ref)
return submodules.find(sub => sub.path === submodulePath) || null
}
/**
* Get commit details with diff
* Returns commit info and full diff showing what changed
*/
async getCommitDiff(repoName, commitHash) {
const repoPath = this.getRepoPath(repoName)
try {
// Get commit info with parent hash
const format = '%H%n%P%n%an%n%ae%n%at%n%s%n%b'
const [infoOutput, diffOutput, statOutput] = await Promise.all([
// Get commit info
this.execGit(repoPath, ['log', '-1', `--format=${format}`, '--', commitHash]),
// Get full diff (--root handles initial commits with no parent)
this.execGit(repoPath, ['diff-tree', '-p', '--root', '-M', '-C', commitHash]),
// Get stats (files changed, insertions, deletions)
this.execGit(repoPath, ['diff-tree', '--stat', '--root', '-M', '-C', commitHash])
])
// Parse commit info
const lines = infoOutput.trim().split('\n')
const info = {
hash: lines[0] || commitHash,
shortHash: (lines[0] || commitHash).slice(0, 7),
parents: (lines[1] || '').split(' ').filter(Boolean),
author: lines[2] || '',
email: lines[3] || '',
timestamp: parseInt(lines[4], 10) * 1000 || Date.now(),
date: new Date(parseInt(lines[4], 10) * 1000).toLocaleString(),
subject: lines[5] || '',
body: lines.slice(6).join('\n').trim()
}
// Parse stat summary from last line
const statLines = statOutput.trim().split('\n')
const statSummary = statLines[statLines.length - 1] || ''
const statMatch = statSummary.match(/(\d+) files? changed(?:, (\d+) insertions?\(\+\))?(?:, (\d+) deletions?\(-\))?/)
const stats = {
filesChanged: statMatch ? parseInt(statMatch[1], 10) : 0,
additions: statMatch && statMatch[2] ? parseInt(statMatch[2], 10) : 0,
deletions: statMatch && statMatch[3] ? parseInt(statMatch[3], 10) : 0
}
return {
info,
diff: diffOutput,
stats
}
} catch (err) {
console.error('[GitBrowser] Get commit diff error:', err.message)
throw new Error(`Failed to get commit diff: ${err.message}`)
}
}
/**
* Get file content at a specific commit (for showing file in commit context)
*/
async getFileAtCommit(repoName, filePath, commitHash) {
const repoPath = this.getRepoPath(repoName)
try {
const content = await this.execGit(repoPath, ['show', `${commitHash}:${filePath}`])
return content
} catch (err) {
// File might not exist at this commit (new file or deleted)
return null
}
}
/**
* Extract repo name from git URL
*/
parseRepoNameFromURL(url) {
// Handle various URL formats:
// git://tabertron.local/holepunchto-kb.git → holepunchto-kb
// https://github.com/evbogue/ANProto.git → ANProto
// git@github.com:user/repo.git → repo
const match = url.match(/\/([^/]+?)(?:\.git)?$/)
if (match) {
return match[1]
}
return null
}
}