import { spawnSync, type SpawnSyncOptions } from 'node:child_process'; // Running git, and reading what it knows: remotes, branches, config. export class GitError extends Error { readonly status: number | null; readonly stderr: string; constructor(args: string[], status: number | null, stderr: string) { super(`git ${args.join(' ')} failed${stderr ? `: ${stderr.trim()}` : ''}`); this.name = 'GitError'; this.status = status; this.stderr = stderr; } } /** Run git and return its stdout, trimmed. Throws on a non-zero exit. */ export function git(args: string[], options: SpawnSyncOptions = {}): string { const result = spawnSync('git', args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, ...options }); if (result.error) throw new Error(`could not run git: ${result.error.message}`); if (result.status !== 0) throw new GitError(args, result.status, String(result.stderr ?? '')); return String(result.stdout ?? '').replace(/\n$/, ''); } /** Run git, and return undefined rather than throw. */ export function gitMaybe(args: string[], options: SpawnSyncOptions = {}): string | undefined { try { return git(args, options); } catch { return undefined; } } /** Run git attached to the terminal; returns its exit status. */ export function gitInteractive(args: string[], options: SpawnSyncOptions = {}): number { const result = spawnSync('git', args, { stdio: 'inherit', ...options }); if (result.error) throw new Error(`could not run git: ${result.error.message}`); return result.status ?? 1; } export function insideWorkTree(cwd?: string): boolean { return gitMaybe(['rev-parse', '--is-inside-work-tree'], cwd ? { cwd } : {}) === 'true'; } export function currentBranch(cwd?: string): string | undefined { return gitMaybe(['symbolic-ref', '--quiet', '--short', 'HEAD'], cwd ? { cwd } : {}); } export interface Remote { name: string; fetch: string; push: string; } export function remotes(cwd?: string): Remote[] { const output = gitMaybe(['remote', '-v'], cwd ? { cwd } : {}); if (!output) return []; const found = new Map(); for (const line of output.split('\n')) { const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(line); if (!match) continue; const [, name, url, kind] = match; const remote = found.get(name!) ?? { name: name!, fetch: url!, push: url! }; if (kind === 'fetch') remote.fetch = url!; else remote.push = url!; found.set(name!, remote); } // The usual suspects first, like gh: upstream, then origin, then the rest. const rank = (name: string) => name === 'upstream' ? 0 : name === 'origin' ? 1 : 2; return [...found.values()].sort((a, b) => rank(a.name) - rank(b.name)); } export function configGet(key: string, { global = false, cwd }: { global?: boolean; cwd?: string } = {}): string | undefined { return gitMaybe(['config', ...(global ? ['--global'] : []), '--get', key], cwd ? { cwd } : {}); } export function configGetAll(key: string, { global = false, cwd }: { global?: boolean; cwd?: string } = {}): string[] { const value = gitMaybe(['config', ...(global ? ['--global'] : []), '--get-all', key], cwd ? { cwd } : {}); return value === undefined ? [] : value.split('\n'); } /** Whether HEAD has commits not on `upstream`. */ export function hasCommitsAhead(base: string, head = 'HEAD', cwd?: string): boolean { const count = gitMaybe(['rev-list', '--count', `${base}..${head}`], cwd ? { cwd } : {}); return Number(count) > 0; } /** Run git with `input` on stdin and its output on the terminal; returns its exit status. */ export function gitFeed(args: string[], input: string, options: SpawnSyncOptions = {}): number { const result = spawnSync('git', args, { input, stdio: ['pipe', 'inherit', 'inherit'], ...options }); if (result.error) throw new Error(`could not run git: ${result.error.message}`); return result.status ?? 1; }