/** * Wrappers around the git CLI. Everything is captured; nothing may reach * stdout, which belongs to the remote-helper protocol. */ import { spawnSync } from 'node:child_process'; /** * @typedef {Object} GitResult * @property {number} status * @property {string} stdout * @property {string} stderr */ /** * @param {string[]} args * @returns {GitResult} */ export function git(args) { const result = spawnSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024, }); if (result.error) throw result.error; return { status: result.status ?? 1, stdout: result.stdout ?? '', stderr: result.stderr ?? '', }; } /** * @param {string[]} args * @param {string} what - short description for the error message * @returns {string} trimmed stdout */ function gitOrThrow(args, what) { const result = git(args); if (result.status !== 0) { throw new Error(`git ${what} failed: ${result.stderr.trim()}`); } return result.stdout.trim(); } /** * Resolve a rev to its object id without peeling, so an annotated tag ref * yields the tag object like a real git remote would advertise. * @param {string} rev * @returns {string|null} the object id, or null if the rev does not resolve */ export function revParse(rev) { const result = git(['rev-parse', '--verify', '--quiet', rev]); return result.status === 0 ? result.stdout.trim() : null; } /** * @param {string} sha * @returns {boolean} */ export function objectExists(sha) { return git(['cat-file', '-e', `${sha}^{object}`]).status === 0; } /** * One object's type and content from the local repository. * @param {string} sha * @returns {{type: string, data: Uint8Array}|null} null when the object is * absent from the object database */ export function catObject(sha) { const typeResult = git(['cat-file', '-t', sha]); if (typeResult.status !== 0) return null; const type = typeResult.stdout.trim(); const result = spawnSync('git', ['cat-file', type, sha], { stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 1024 * 1024 * 1024, }); if (result.error) throw result.error; if (result.status !== 0) return null; return { type, data: new Uint8Array(result.stdout) }; } /** * Object types for the given ids, resolved through one cat-file call. An * id absent from the object database is absent from the map. * @param {string[]} shas * @returns {Map} */ export function objectTypes(shas) { if (shas.length === 0) return new Map(); const result = spawnSync('git', ['cat-file', '--batch-check'], { encoding: 'utf8', input: `${shas.join('\n')}\n`, maxBuffer: 64 * 1024 * 1024, }); if (result.error) throw result.error; /** @type {Map} */ const types = new Map(); // One output line per input id, in order. A present object answers // " "; a missing one answers " missing". const lines = (result.stdout ?? '').split('\n'); shas.forEach((sha, i) => { const fields = (lines[i] ?? '').split(' '); if (fields.length === 3) types.set(sha, fields[1]); }); return types; } /** * Reduce object ids to a subset with the same reachable closure: * merge-base --independent drops every commit another one reaches. * Non-commit ids (annotated tag objects) all stay, since no commit * reaches a tag object. * @param {string[]} shas * @returns {string[]} */ export function independentHeads(shas) { const types = objectTypes(shas); const commits = shas.filter((sha) => types.get(sha) === 'commit'); const others = shas.filter((sha) => types.get(sha) !== 'commit'); if (commits.length < 2) return [...commits, ...others]; const independent = gitOrThrow( ['merge-base', '--independent', ...commits], 'merge-base --independent', ) .split('\n') .filter(Boolean); return [...independent, ...others]; } /** * @param {string} ancestor * @param {string} descendant * @returns {boolean} */ export function isAncestor(ancestor, descendant) { return ( git(['merge-base', '--is-ancestor', ancestor, descendant]).status === 0 ); } /** * Create a bundle at path containing the positives minus history reachable * from the negatives. Returns false when git refuses because the bundle * would be empty (everything already reachable from the negatives). * @param {string} path * @param {string[]} positives - ref names to include * @param {string[]} negatives - object ids to exclude * @returns {boolean} whether a bundle was written */ export function bundleCreate(path, positives, negatives) { const args = [ 'bundle', 'create', path, ...negatives.map((sha) => `^${sha}`), ...positives, ]; const result = git(args); if (result.status === 0) return true; if (/empty bundle/i.test(result.stderr)) return false; throw new Error(`git bundle create failed: ${result.stderr.trim()}`); } /** * Store a bundle's objects into the local repository's object database. * @param {string} path */ export function bundleUnbundle(path) { gitOrThrow(['bundle', 'unbundle', path], 'bundle unbundle'); } /** * @param {string} ref * @param {string} sha */ export function updateRef(ref, sha) { gitOrThrow(['update-ref', ref, sha], 'update-ref'); } /** @param {string} ref */ export function deleteRef(ref) { gitOrThrow(['update-ref', '-d', ref], 'update-ref -d'); }