/** * Git command-runner seam (saga j8dtju.5 — Workspace domain foundation, phase 1). * * The single, injectable place where `git` is actually executed. The resolver and * classifier NEVER spawn Git directly — they go through a {@link GitRunner}, so tests inject a * hermetic, scripted runner and the real one is confined to this module's default * implementation. * * Security invariants (load-bearing): * - **argv-only.** Git is invoked with a `string[]` argv array passed straight to the * process spawn API. There is NO shell (`sh -c`) anywhere in this stack, so a ref/path * that *looks* like a shell metacharacter string — ``foo; rm -rf /`` — is one argv * token, never interpreted. The runner cannot be made to shell out; there is no code * path that builds a shell string. * - **bounded runtime.** Every run has a timeout; on expiry the process is killed and the * outcome is classified `timeout` (never a hang). * - **bounded output.** Captured stdout is truncated at {@link GitRunOptions.maxOutputBytes} * so a runaway `git log` cannot OOM the daemon. Truncation is recorded, not hidden. * - **classified failures.** Outcomes are tagged so callers can distinguish a *non-Git * cwd* (`not-a-repo`, exit 128 + `not a git repository`) from a git-executable-missing * spawn failure, a timeout, a generic nonzero exit, or malformed output — the classifier * acts on `not-a-repo` specifically instead of treating any failure as "not Git". * * The runner does NOT mutate Git state. Every command it runs is a probe (`rev-parse`, * `symbolic-ref`, `worktree list`, `merge-base`, `check-ref-format`). Discover only. */ import { spawn, type ChildProcessByStdio } from 'node:child_process'; import { existsSync } from 'node:fs'; import { createHash } from 'node:crypto'; import type { Readable } from 'node:stream'; /** The default git executable name/path used by {@link defaultGitRunner}. */ export const DEFAULT_GIT_EXECUTABLE = 'git'; /** Default per-command runtime bound (ms). Generous; rev-parse/worktree-list are fast. */ export const DEFAULT_GIT_TIMEOUT_MS = 10_000; /** Default captured-output bound (bytes). Porcelain -z over a huge repo stays bounded. */ export const DEFAULT_GIT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024; /** A git command to run. `argv` is the git arg vector EXCLUDING the `git` executable. */ export interface GitRunOptions { argv: readonly string[]; cwd: string; timeoutMs?: number; maxOutputBytes?: number; env?: NodeJS.ProcessEnv; } /** Classified runner failure kinds. `not-a-repo` is the cwd-classification signal. */ export type GitRunErrorKind = /** Git exited 128 reporting the cwd is not a repository — the canonical "non-Git cwd". */ | 'not-a-repo' /** The `git` executable could not be found / could not be spawned (ENOENT etc). */ | 'executable-missing' /** Bounded runtime exceeded; the process was killed. */ | 'timeout' /** A generic nonzero exit that is not a recognized "not-a-repo" signal. */ | 'nonzero-exit' /** Spawn threw synchronously for an unexpected reason. */ | 'spawn-error'; /** A classified runner failure. */ export interface GitRunError { kind: GitRunErrorKind; message: string; /** Exit code when the process ran to completion. */ exitCode?: number; /** Captured stderr (bounded) when the process ran. */ stderr?: string; } /** A successful runner result. Truncation, if any, is recorded on `truncated`. */ export interface GitRunResult { stdout: string; stderr: string; exitCode: number; durationMs: number; /** True when stdout exceeded the bound and was truncated. */ truncated: boolean; } /** A runner outcome: success or a classified failure. */ export type GitRunOutcome = | { ok: true; result: GitRunResult } | { ok: false; error: GitRunError }; /** Injectable command-runner seam. Implementations: {@link defaultGitRunner} (real) or a test script. */ export interface GitRunner { run(opts: GitRunOptions): Promise; } /** * Classify a completed-process outcome as a {@link GitRunErrorKind}. Non-Git cwd detection is * the load-bearing branch: git rev-parse on a non-repo exits 128 with `not a git repository` * (or `not a gitdir`) on stderr. Every other nonzero exit is generic. Pure. */ export function classifyRunError(exitCode: number | null, stderr: string): GitRunErrorKind { const lower = stderr.toLowerCase(); if ( exitCode === 128 && (lower.includes('not a git repository') || lower.includes('not a gitdir')) ) { return 'not-a-repo'; } return 'nonzero-exit'; } /** * Classify an `ENOENT` from `spawn` as either a missing cwd or a missing executable. Node * surfaces BOTH as `ENOENT` on the spawn path; a missing cwd is NOT a missing git binary, so * the cwd is probed (sync) first — when it does not exist the failure is `spawn-error` with a * cwd-specific message, otherwise the git executable itself is missing. Pure-over-`existsSync`. */ function classifySpawnEnoent(cwd: string | undefined): { kind: GitRunErrorKind; message: string } { if (cwd !== undefined && !existsSync(cwd)) { return { kind: 'spawn-error', message: `git cwd does not exist: ${cwd}` }; } return { kind: 'executable-missing', message: 'git executable could not be found' }; } /** * The real, injectable runner. Spawns `git` with an argv array via `child_process.spawn` (no * shell, no interpolation), enforces a bounded runtime, and bounds captured stdout. Exposed so * a test can wrap/inspect it; production uses {@link defaultGitRunner}. */ export class ProcessGitRunner implements GitRunner { constructor( private readonly executable: string = DEFAULT_GIT_EXECUTABLE, private readonly defaultTimeoutMs: number = DEFAULT_GIT_TIMEOUT_MS, private readonly defaultMaxOutputBytes: number = DEFAULT_GIT_MAX_OUTPUT_BYTES, ) {} async run(opts: GitRunOptions): Promise { const start = Date.now(); const argv = [...opts.argv]; if (argv.length === 0) { return { ok: false, error: { kind: 'spawn-error', message: 'empty argv' } }; } const timeoutMs = opts.timeoutMs ?? this.defaultTimeoutMs; const maxBytes = opts.maxOutputBytes ?? this.defaultMaxOutputBytes; // Force the C locale so Git's non-repo / bad-ref diagnostics emit the stable English // messages `classifyRunError` pattern-matches (`not a git repository`). A translated // locale would emit a different phrase and silently erode non-Git-cwd detection. // Caller-supplied `env` wins for every other variable; only the locale is pinned. const baseEnv = opts.env ?? process.env; const env: NodeJS.ProcessEnv = { ...baseEnv, LC_ALL: 'C', LANG: 'C' }; let child: PipedChild; try { child = spawn(this.executable, argv, { cwd: opts.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], // No shell: argv tokens are passed verbatim, never interpreted. shell: false, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (/ENOENT/i.test(message)) { const cls = classifySpawnEnoent(opts.cwd); return { ok: false, error: { kind: cls.kind, message: cls.message } }; } return { ok: false, error: { kind: 'spawn-error', message } }; } return await runChild(child, timeoutMs, maxBytes, start, opts.cwd); } } /** The piped-child shape returned by `spawn` with stdio `['ignore','pipe','pipe']`. */ type PipedChild = ChildProcessByStdio; /** Drain + cap a child's stdout/stderr against a byte budget, racing a runtime bound. */ function runChild( child: PipedChild, timeoutMs: number, maxBytes: number, start: number, cwd: string | undefined, ): Promise { return new Promise((resolve) => { let stdoutBuf = ''; let stderrBuf = ''; let truncated = false; let killed = false; let settled = false; const finish = (outcome: GitRunOutcome) => { if (settled) return; settled = true; clearTimeout(timer); try { child.kill('SIGKILL'); } catch { /* already exited */ } resolve(outcome); }; const timer = setTimeout(() => { killed = true; finish({ ok: false, error: { kind: 'timeout', message: `git timed out after ${timeoutMs} ms` }, }); }, timeoutMs); child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); child.stdout.on('data', (chunk: string) => { if (!truncated && stdoutBuf.length + chunk.length > maxBytes) { stdoutBuf += chunk.slice(0, maxBytes - stdoutBuf.length); truncated = true; } else if (!truncated) { stdoutBuf += chunk; } }); child.stderr.on('data', (chunk: string) => { // Bound stderr too (bounded, but generous: errors carry diagnostics). if (stderrBuf.length < maxBytes) stderrBuf += chunk.slice(0, maxBytes - stderrBuf.length); }); child.on('error', (err: NodeJS.ErrnoException) => { if (err.code === 'ENOENT') { const cls = classifySpawnEnoent(cwd); finish({ ok: false, error: { kind: cls.kind, message: cls.message } }); return; } finish({ ok: false, error: { kind: 'spawn-error', message: err.message } }); }); child.on('close', (code: number | null) => { if (killed) return; // timeout already settled const durationMs = Date.now() - start; if (code === 0) { finish({ ok: true, result: { stdout: stdoutBuf, stderr: stderrBuf, exitCode: 0, durationMs, truncated }, }); return; } const kind = classifyRunError(code, stderrBuf); const error: GitRunError = { kind, message: `git exited ${code}`, }; if (code !== null) error.exitCode = code; if (stderrBuf) error.stderr = stderrBuf; finish({ ok: false, error }); }); }); } /** Convenience path-friendly default instance used by the resolver/classifier. */ export const defaultGitRunner: GitRunner = new ProcessGitRunner(); /** * A sha256 fingerprint (first 16 hex chars) of a canonical path. Used by the resolver to * produce the stable dedup keys on repository/worktree identity without leaking the full path * into logs. Pure. */ export function fingerprintPath(canonicalPath: string): string { return createHash('sha256').update(canonicalPath).digest('hex').slice(0, 16); }