/** * ───────────────────────────────────────────── * SPB Git — Personal Git Platform * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/git/repo.mjs * Purpose : Bare-repo model — refs, trees, blobs, log, diffs, blame * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { execFile, spawn } from 'node:child_process'; import { promisify } from 'node:util'; import { readdirSync, existsSync, statSync, renameSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { isValidRepoName, safeJoin, isValidTreePath } from '../lib/util.mjs'; const execFileAsync = promisify(execFile); const MAX_BUFFER = 64 * 1024 * 1024; const FS = '\x01'; const RS = '\x02'; /** Hard ceiling for diff rendering (lines) before truncation. */ const DIFF_MAX_LINES = 20000; /** Commits walked when computing "last commit per path" before falling back. */ const TREE_LOG_WALK_CAP = 600; /** * Repository model rooted at `gitRoot`. All read operations shell out to the * real `git` binary — parsing porcelain/plumbing output, never buffering * packfiles in memory. */ export class Repos { /** * @param {string} gitRoot directory containing the bare `.git` repos * @param {string} trashDir soft-delete destination */ constructor(gitRoot, trashDir) { this.gitRoot = gitRoot; this.trashDir = trashDir; } /** @param {string} name @returns {string} absolute path of the bare repo */ dir(name) { if (!isValidRepoName(name)) throw new Error(`invalid repo name: ${name}`); return safeJoin(this.gitRoot, `${name}.git`); } /** @param {string} name @returns {boolean} */ exists(name) { if (!isValidRepoName(name)) return false; return existsSync(join(this.dir(name), 'HEAD')); } /** @returns {string[]} sorted repo names found on disk */ list() { let entries; try { entries = readdirSync(this.gitRoot); } catch { return []; } return entries .filter((e) => e.endsWith('.git') && !e.startsWith('.')) .map((e) => e.slice(0, -4)) .filter((name) => isValidRepoName(name) && this.exists(name)) .sort(); } /** * Run git in a repo, returning stdout as a string. * @param {string} name * @param {string[]} args * @returns {Promise} */ async git(name, args) { const { stdout } = await execFileAsync('git', args, { cwd: this.dir(name), maxBuffer: MAX_BUFFER, encoding: 'utf8', }); return stdout; } /** Same as {@link Repos#git} but stdout stays a Buffer (blob content). */ async gitBuffer(name, args) { const { stdout } = await execFileAsync('git', args, { cwd: this.dir(name), maxBuffer: MAX_BUFFER, encoding: 'buffer', }); return stdout; } /** git that returns null instead of throwing (missing ref/path lookups). */ async tryGit(name, args) { try { return await this.git(name, args); } catch { return null; } } /** * Initialize a new bare repository with HEAD on `main`. * @param {string} name * @param {string} [defaultBranch] */ async create(name, defaultBranch = 'main') { if (!isValidRepoName(name)) throw new Error('invalid repo name'); if (this.exists(name)) throw new Error('repo already exists'); const dir = this.dir(name); await execFileAsync('git', ['init', '--bare', '--initial-branch', defaultBranch, dir]); } /** * Soft-delete: move the bare repo into the trash with a timestamp suffix. * @param {string} name * @returns {string} the trash path */ softDelete(name) { const src = this.dir(name); if (!existsSync(src)) throw new Error('repo not found'); mkdirSync(this.trashDir, { recursive: true }); const stamp = new Date().toISOString().replaceAll(/[:.]/g, '-'); const dest = join(this.trashDir, `${name}-${stamp}.git`); renameSync(src, dest); return dest; } /** @returns {Promise} sha of HEAD, or null for an empty repo */ async head(name) { const out = await this.tryGit(name, ['rev-parse', '--verify', 'HEAD']); return out ? out.trim() : null; } /** @returns {Promise} short name of the default branch */ async defaultBranch(name) { const out = await this.tryGit(name, ['symbolic-ref', '--short', 'HEAD']); return out ? out.trim() : 'main'; } /** @param {string} branch set HEAD to refs/heads/ */ async setDefaultBranch(name, branch) { await this.git(name, ['symbolic-ref', 'HEAD', `refs/heads/${branch}`]); } /** * Resolve any ref-ish (branch, tag, sha, sha-prefix) to a commit sha. * @returns {Promise} */ async resolveRef(name, ref) { if (!/^[\w./@^~-]+$/.test(ref) || ref.startsWith('-')) return null; const out = await this.tryGit(name, ['rev-parse', '--verify', `${ref}^{commit}`]); return out ? out.trim() : null; } /** * @returns {Promise>} */ async branches(name) { const out = await this.tryGit(name, [ 'for-each-ref', '--sort=-committerdate', `--format=%(refname:short)${FS}%(objectname)${FS}%(committerdate:iso-strict)${FS}%(contents:subject)`, 'refs/heads', ]); if (!out) return []; const def = await this.defaultBranch(name); return out .split('\n') .filter(Boolean) .map((line) => { const [branch, sha, date, subject] = line.split(FS); return { name: branch, sha, date, subject: subject ?? '', isDefault: branch === def }; }); } /** * @returns {Promise>} */ async tags(name) { const out = await this.tryGit(name, [ 'for-each-ref', '--sort=-creatordate', `--format=%(refname:short)${FS}%(*objectname)%(objectname)${FS}%(creatordate:iso-strict)${FS}%(contents:subject)`, 'refs/tags', ]); if (!out) return []; return out .split('\n') .filter(Boolean) .map((line) => { const [tag, sha, date, subject] = line.split(FS); return { name: tag, sha: sha.slice(0, 40), date, subject: subject ?? '' }; }); } /** * Given the wildcard part of a URL (`/`) figure out which * prefix is the ref — supports branch names containing slashes. * @param {string} name repo * @param {string} splat e.g. `feature/x/src/index.js` * @returns {Promise<{ref: string, sha: string, path: string}|null>} */ async resolveRefAndPath(name, splat) { const clean = String(splat ?? '').replace(/^\/+|\/+$/g, ''); if (clean === '') { const def = await this.defaultBranch(name); const sha = await this.resolveRef(name, def); return sha ? { ref: def, sha, path: '' } : null; } const segments = clean.split('/'); const refNames = [ ...(await this.branches(name)).map((b) => b.name), ...(await this.tags(name)).map((t) => t.name), ]; for (let take = segments.length; take >= 1; take -= 1) { const candidate = segments.slice(0, take).join('/'); if (refNames.includes(candidate)) { const sha = await this.resolveRef(name, candidate); const path = segments.slice(take).join('/'); if (sha && (path === '' || isValidTreePath(path))) return { ref: candidate, sha, path }; } } // Fall back to first segment as a sha / sha prefix. const sha = await this.resolveRef(name, segments[0]); const path = segments.slice(1).join('/'); if (sha && (path === '' || isValidTreePath(path))) return { ref: segments[0], sha, path }; return null; } /** * List one directory level of a tree. * @returns {Promise|null>} */ async tree(name, sha, path = '') { if (path !== '' && !isValidTreePath(path)) return null; const spec = path === '' ? sha : `${sha}:${path}`; let out; try { const { stdout } = await execFileAsync('git', ['ls-tree', '-l', '-z', spec], { cwd: this.dir(name), maxBuffer: MAX_BUFFER, encoding: 'utf8', }); out = stdout; } catch { return null; } const entries = out .split('\0') .filter(Boolean) .map((record) => { const tab = record.indexOf('\t'); const [mode, type, entrySha, sizeRaw] = record.slice(0, tab).split(/\s+/); const entryName = record.slice(tab + 1); return { mode, type, sha: entrySha, size: sizeRaw === '-' ? null : Number(sizeRaw), name: entryName, path: path === '' ? entryName : `${path}/${entryName}`, }; }); entries.sort((a, b) => { if (a.type !== b.type) return a.type === 'tree' ? -1 : 1; return a.name.localeCompare(b.name); }); return entries; } /** * Read a blob at `:`. * @returns {Promise<{content: Buffer, size: number, binary: boolean}|null>} */ async blob(name, sha, path) { if (!isValidTreePath(path)) return null; try { const content = await this.gitBuffer(name, ['cat-file', 'blob', `${sha}:${path}`]); const probe = content.subarray(0, 8000); const binary = probe.includes(0); return { content, size: content.length, binary }; } catch { return null; } } /** Type of the object at `:` — 'blob' | 'tree' | null. */ async objectType(name, sha, path) { if (path === '') return 'tree'; if (!isValidTreePath(path)) return null; const out = await this.tryGit(name, ['cat-file', '-t', `${sha}:${path}`]); return out ? out.trim() : null; } /** * Paginated commit log. * @param {string} name * @param {string} sha resolved commit * @param {{page?: number, perPage?: number, path?: string}} [opts] * @returns {Promise<{commits: object[], hasNext: boolean}>} */ async log(name, sha, opts = {}) { const page = Math.max(1, opts.page ?? 1); const perPage = opts.perPage ?? 40; const args = [ 'log', `--skip=${(page - 1) * perPage}`, `--max-count=${perPage + 1}`, `--format=${RS}%H${FS}%h${FS}%an${FS}%ae${FS}%aI${FS}%s${FS}%b`, sha, ]; if (opts.path) args.push('--', opts.path); const out = await this.tryGit(name, args); if (out == null) return { commits: [], hasNext: false }; const records = out.split(RS).filter((r) => r.trim() !== ''); const commits = records.map((record) => { const [full, short, authorName, authorEmail, date, subject, body] = record.replace(/^\n/, '').split(FS); return { sha: full, shortSha: short, authorName, authorEmail, date, subject, body: (body ?? '').trim(), }; }); const hasNext = commits.length > perPage; const pageCommits = commits.slice(0, perPage); await this.#attachStats(name, sha, pageCommits, opts); return { commits: pageCommits, hasNext }; } /** Attach filesChanged/additions/deletions to a page of commits. */ async #attachStats(name, sha, commits, opts) { if (commits.length === 0) return; const args = [ 'log', `--skip=0`, `--max-count=${commits.length}`, `--shortstat`, `--format=${RS}%H`, commits[0].sha, ]; if (opts.path) args.push('--', opts.path); const out = await this.tryGit(name, args); if (out == null) return; const bySha = new Map(); for (const chunk of out.split(RS)) { const lines = chunk.trim().split('\n'); const chunkSha = lines[0]?.trim(); const stat = lines.slice(1).join(' '); const files = /(\d+) files? changed/.exec(stat); const add = /(\d+) insertions?\(\+\)/.exec(stat); const del = /(\d+) deletions?\(-\)/.exec(stat); if (chunkSha) { bySha.set(chunkSha, { filesChanged: files ? Number(files[1]) : 0, additions: add ? Number(add[1]) : 0, deletions: del ? Number(del[1]) : 0, }); } } for (const commit of commits) { Object.assign(commit, bySha.get(commit.sha) ?? { filesChanged: 0, additions: 0, deletions: 0 }); } } /** @returns {Promise} total commits reachable from sha */ async commitCount(name, sha) { const out = await this.tryGit(name, ['rev-list', '--count', sha]); return out ? Number(out.trim()) : 0; } /** @returns {Promise<{behind: number, ahead: number}>} vs base */ async aheadBehind(name, base, branch) { const out = await this.tryGit(name, ['rev-list', '--left-right', '--count', `${base}...${branch}`]); if (!out) return { behind: 0, ahead: 0 }; const [behind, ahead] = out.trim().split('\t').map(Number); return { behind: behind || 0, ahead: ahead || 0 }; } /** @returns {Promise} on-disk size in bytes (packed + loose) */ async sizeBytes(name) { const out = await this.tryGit(name, ['count-objects', '-v']); if (!out) return 0; let kb = 0; for (const line of out.split('\n')) { const m = /^(size|size-pack): (\d+)$/.exec(line.trim()); if (m) kb += Number(m[2]); } return kb * 1024; } /** @returns {Promise} ISO date of the most recent commit on any ref */ async lastPushDate(name) { const out = await this.tryGit(name, [ 'for-each-ref', '--sort=-committerdate', '--count=1', '--format=%(committerdate:iso-strict)', 'refs/heads', ]); const trimmed = out?.trim(); return trimmed || null; } /** * Single commit with parsed diff. * @returns {Promise} */ async commit(name, sha) { const resolved = await this.resolveRef(name, sha); if (!resolved) return null; const metaOut = await this.tryGit(name, [ 'show', '-s', `--format=%H${FS}%h${FS}%an${FS}%ae${FS}%aI${FS}%cn${FS}%ce${FS}%cI${FS}%P${FS}%s${FS}%b`, resolved, ]); if (!metaOut) return null; const [full, short, authorName, authorEmail, authorDate, committerName, committerEmail, committerDate, parents, subject, body] = metaOut.trim().split(FS); const patch = await this.tryGit(name, ['show', '--format=', '--patch', '-M', '--no-color', resolved]) ?? ''; const files = parseUnifiedDiff(patch); const additions = files.reduce((n, f) => n + f.additions, 0); const deletions = files.reduce((n, f) => n + f.deletions, 0); return { sha: full, shortSha: short, authorName, authorEmail, date: authorDate, committerName, committerEmail, committerDate, parents: (parents ?? '').split(' ').filter(Boolean), subject, body: (body ?? '').trim(), files, additions, deletions, }; } /** * Blame a file — hunks grouped by commit. * @returns {Promise<{hunks: object[]}|null>} */ async blame(name, sha, path) { if (!isValidTreePath(path)) return null; const out = await this.tryGit(name, ['blame', '--porcelain', sha, '--', path]); if (out == null) return null; const commits = new Map(); const lines = []; const rows = out.split('\n'); let i = 0; while (i < rows.length) { const header = /^([0-9a-f]{40}) (\d+) (\d+)(?: (\d+))?$/.exec(rows[i]); if (!header) { i += 1; continue; } const [, commitSha, , finalLine] = header; i += 1; if (!commits.has(commitSha)) commits.set(commitSha, { sha: commitSha }); const info = commits.get(commitSha); while (i < rows.length && !rows[i].startsWith('\t')) { const [key, ...rest] = rows[i].split(' '); const value = rest.join(' '); if (key === 'author') info.authorName = value; else if (key === 'author-mail') info.authorEmail = value.replace(/^<|>$/g, ''); else if (key === 'author-time') info.date = new Date(Number(value) * 1000).toISOString(); else if (key === 'summary') info.subject = value; i += 1; } if (i < rows.length && rows[i].startsWith('\t')) { lines.push({ line: Number(finalLine), text: rows[i].slice(1), sha: commitSha }); i += 1; } } // Group consecutive lines that share a commit into hunks. const hunks = []; for (const line of lines) { const info = commits.get(line.sha); const last = hunks[hunks.length - 1]; if (last && last.sha === line.sha && last.endLine === line.line - 1) { last.endLine = line.line; last.lines.push(line); } else { hunks.push({ sha: line.sha, shortSha: line.sha.slice(0, 7), authorName: info?.authorName ?? '', authorEmail: info?.authorEmail ?? '', date: info?.date ?? '', subject: info?.subject ?? '', startLine: line.line, endLine: line.line, lines: [line], }); } } return { hunks }; } /** * For each entry of a directory, find the most recent commit touching it. * One streamed `git log --name-only` walk, capped, with `git log -1` * fallback for stragglers. * @param {string} name * @param {string} sha * @param {string} dirPath '' for root * @param {string[]} entryNames names (not paths) of the directory entries * @returns {Promise>} */ async lastCommits(name, sha, dirPath, entryNames) { const remaining = new Set(entryNames); const result = {}; const prefix = dirPath === '' ? '' : `${dirPath}/`; const args = ['log', `--format=${RS}%H${FS}%aI${FS}%s`, '--name-only', sha]; if (dirPath !== '') args.push('--', dirPath); await new Promise((resolvePromise) => { const child = spawn('git', args, { cwd: this.dir(name) }); let buffer = ''; let commitsSeen = 0; let current = null; const processLine = (line) => { if (line.startsWith(RS)) { commitsSeen += 1; if (commitsSeen > TREE_LOG_WALK_CAP || remaining.size === 0) { child.kill('SIGTERM'); return; } const [commitSha, date, subject] = line.slice(1).split(FS); current = { sha: commitSha, date, subject }; return; } if (!current || line === '') return; const path = unquoteGitPath(line); if (!path.startsWith(prefix)) return; const rest = path.slice(prefix.length); const entry = rest.split('/')[0]; if (remaining.has(entry)) { result[entry] = current; remaining.delete(entry); } }; child.stdout.setEncoding('utf8'); child.stdout.on('data', (chunk) => { buffer += chunk; let nl; while ((nl = buffer.indexOf('\n')) !== -1) { processLine(buffer.slice(0, nl)); buffer = buffer.slice(nl + 1); } }); child.on('close', () => { if (buffer) processLine(buffer); resolvePromise(); }); child.on('error', () => resolvePromise()); }); // Fallback for anything the capped walk missed. for (const entry of remaining) { const path = prefix + entry; const out = await this.tryGit(name, ['log', '-1', `--format=%H${FS}%aI${FS}%s`, sha, '--', path]); if (out && out.trim()) { const [commitSha, date, subject] = out.trim().split(FS); result[entry] = { sha: commitSha, date, subject }; } } return result; } /** * Locate the README blob in the root tree (case-insensitive, md first). * @returns {Promise<{path: string, content: Buffer}|null>} */ async readme(name, sha) { const entries = await this.tree(name, sha, ''); if (!entries) return null; const candidates = entries.filter((e) => e.type === 'blob' && /^readme(\.(md|markdown|rst|txt))?$/i.test(e.name)); candidates.sort((a, b) => { const rank = (n) => (/\.(md|markdown)$/i.test(n) ? 0 : /\.rst$/i.test(n) ? 1 : /\.txt$/i.test(n) ? 2 : 3); return rank(a.name) - rank(b.name); }); if (candidates.length === 0) return null; const blob = await this.blob(name, sha, candidates[0].path); if (!blob || blob.binary) return null; return { path: candidates[0].path, content: blob.content }; } /** * Detect a license from root LICENSE/COPYING files. * @returns {Promise<{name: string, path: string}|null>} */ async license(name, sha) { const entries = await this.tree(name, sha, ''); if (!entries) return null; const file = entries.find((e) => e.type === 'blob' && /^(license|licence|copying)(\.(md|txt))?$/i.test(e.name)); if (!file) return null; const blob = await this.blob(name, sha, file.path); if (!blob || blob.binary) return null; const text = blob.content.toString('utf8', 0, 2000); const detections = [ [/MIT License/i, 'MIT'], [/Apache License,?\s+Version 2\.0/i, 'Apache-2.0'], [/GNU AFFERO GENERAL PUBLIC LICENSE.*Version 3/is, 'AGPL-3.0'], [/GNU GENERAL PUBLIC LICENSE\s+Version 3/i, 'GPL-3.0'], [/GNU GENERAL PUBLIC LICENSE\s+Version 2/i, 'GPL-2.0'], [/GNU LESSER GENERAL PUBLIC LICENSE/i, 'LGPL'], [/Mozilla Public License,?\s+v(ersion)?\.?\s*2\.0/i, 'MPL-2.0'], [/BSD 3-Clause|Redistribution and use in source and binary forms.*neither the name/is, 'BSD-3-Clause'], [/BSD 2-Clause/i, 'BSD-2-Clause'], [/ISC License/i, 'ISC'], [/This is free and unencumbered software released into the public domain/i, 'Unlicense'], ]; for (const [re, id] of detections) { if (re.test(text)) return { name: id, path: file.path }; } return { name: 'License', path: file.path }; } /** * Full recursive file listing with sizes — feeds language stats. * @returns {Promise>} */ async allFiles(name, sha) { const out = await this.tryGit(name, ['ls-tree', '-r', '-l', '-z', sha]); if (!out) return []; return out .split('\0') .filter(Boolean) .map((record) => { const tab = record.indexOf('\t'); const [, type, , sizeRaw] = record.slice(0, tab).split(/\s+/); if (type !== 'blob') return null; return { path: record.slice(tab + 1), size: sizeRaw === '-' ? 0 : Number(sizeRaw) }; }) .filter(Boolean); } /** All commit timestamps+subjects on the default branch (for the heatmap). */ async commitTimestamps(name) { const head = await this.head(name); if (!head) return []; const out = await this.tryGit(name, ['rev-list', '--format=%ct', '--no-commit-header', head]); if (!out) return []; return out.split('\n').filter(Boolean).map(Number); } } /** * Unquote a git-quoted path ("dir/\303\251t\303\251.txt" style). * @param {string} raw * @returns {string} */ export function unquoteGitPath(raw) { if (!raw.startsWith('"') || !raw.endsWith('"')) return raw; const inner = raw.slice(1, -1); const bytes = []; for (let i = 0; i < inner.length; i += 1) { if (inner[i] !== '\\') { bytes.push(inner.charCodeAt(i)); continue; } const next = inner[i + 1]; if (/[0-7]/.test(next)) { bytes.push(parseInt(inner.slice(i + 1, i + 4), 8)); i += 3; } else { const map = { n: 10, t: 9, r: 13, '\\': 92, '"': 34, a: 7, b: 8, f: 12, v: 11 }; bytes.push(map[next] ?? next.charCodeAt(0)); i += 1; } } return Buffer.from(bytes).toString('utf8'); } /** * Parse a unified diff (git show/diff output) into structured files + hunks. * @param {string} text * @returns {Array} */ export function parseUnifiedDiff(text) { const files = []; if (!text) return files; const lines = text.split('\n'); let file = null; let hunk = null; let oldLine = 0; let newLine = 0; let totalLines = 0; const pushFile = () => { if (file) files.push(file); file = null; hunk = null; }; for (const line of lines) { if (totalLines > DIFF_MAX_LINES) { if (file) file.truncated = true; break; } if (line.startsWith('diff --git ')) { pushFile(); const m = /^diff --git (?:"?a\/)(.*?)"? (?:"?b\/)(.*?)"?$/.exec(line); file = { oldPath: m ? unquoteGitPath(m[1].startsWith('"') ? m[1] : m[1]) : '', newPath: m ? m[2] : '', status: 'modified', binary: false, additions: 0, deletions: 0, hunks: [], truncated: false, }; hunk = null; continue; } if (!file) continue; if (line.startsWith('new file mode')) file.status = 'added'; else if (line.startsWith('deleted file mode')) file.status = 'deleted'; else if (line.startsWith('rename from ')) { file.status = 'renamed'; file.oldPath = unquoteGitPath(line.slice('rename from '.length)); } else if (line.startsWith('rename to ')) { file.newPath = unquoteGitPath(line.slice('rename to '.length)); } else if (line.startsWith('Binary files ') || line === 'GIT binary patch') { file.binary = true; } else if (line.startsWith('--- ')) { /* path already known */ } else if (line.startsWith('+++ ')) { /* path already known */ } else if (line.startsWith('@@')) { const m = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@ ?(.*)$/.exec(line); if (m) { oldLine = Number(m[1]); newLine = Number(m[3]); hunk = { header: line, context: m[5] ?? '', lines: [] }; file.hunks.push(hunk); } } else if (hunk && (line.startsWith('+') || line.startsWith('-') || line.startsWith(' ') || line === '')) { totalLines += 1; if (line.startsWith('+')) { hunk.lines.push({ type: 'add', old: null, new: newLine, text: line.slice(1) }); newLine += 1; file.additions += 1; } else if (line.startsWith('-')) { hunk.lines.push({ type: 'del', old: oldLine, new: null, text: line.slice(1) }); oldLine += 1; file.deletions += 1; } else if (line.startsWith(' ') || line === '') { hunk.lines.push({ type: 'ctx', old: oldLine, new: newLine, text: line.slice(1) }); oldLine += 1; newLine += 1; } } else if (line.startsWith('\\ No newline')) { if (hunk) hunk.lines.push({ type: 'meta', old: null, new: null, text: line }); } } pushFile(); return files; } /** * Repo directory size on disk (recursive) — used by /healthz only. * @param {string} dir * @returns {number} bytes */ export function dirSizeBytes(dir) { let total = 0; let entries; try { entries = readdirSync(dir); } catch { return 0; } for (const entry of entries) { const full = join(dir, entry); let st; try { st = statSync(full); } catch { continue; } if (st.isDirectory()) total += dirSizeBytes(full); else total += st.size; } return total; }