// git.ts — the ONE git-subprocess helper for the harness (KEY: AUTOPILOT-121). // // THE FAILURE CONTRACT (stated here and NOWHERE ELSE): a git call that cannot // be spawned OR exits non-zero returns `null` — a missing repo, a missing ref, // and "the cwd is not a git repository at all" are all NORMAL NEGATIVES, never // exceptions. stderr is ALWAYS captured into a pipe and never printed, so a // probe of a cwd outside a repo (a session started in ~/work) is silent. An // EMPTY STRING is a distinct, SUCCESSFUL result — `cat-file -e` and // `update-ref` print nothing on success, and callers test success with // `!== null` — so empty is NOT folded into null. // // THE SHAPES (the boundary, stated here and NOWHERE ELSE): the harness makes // exactly TWO shapes of git call and both live in this file. (1) "give me // stdout" → `git()`, `string | null`. (2) "give me the exit code" → // `gitExitCode()`, `number | null` — for `merge-base --is-ancestor`, where exit // 1 ("not an ancestor") is a NORMAL NEGATIVE that `git()` would fold into its // null and so cannot express. Both capture/ignore stderr; neither ever prints. // There is deliberately NO third shape: a caller that wants a raw spawn result // (`git cherry`) uses `git()` — its trimmed stdout is exactly what such a call // needs, and non-zero/timeout is the same "unanswerable" null. Any NEW shape a // future author reaches for MUST set an explicit `stdio` — `execFileSync`'s // DEFAULT inherits stderr and IS the original leak (a bare // `execFileSync("git", …)` prints git's `fatal: not a git repository …` to the // operator's TUI). test/framework/git-stderr-policy.test.ts fails the build // over any git spawn in src/ that omits `stdio` or lives outside this file. // // WHY THIS EXISTS: five local helpers with this exact signature plus a sixth // under another name, all doing the same spawn, had drifted — three had learned // to capture stderr and three had not. A caller that caught the throw and // carried on with `null` still let git's `fatal: not a git repository (or any // of the parent directories): .git` hit the operator's TUI, invisible to logs, // on every session started outside a repo. The bug was the duplication, not a // missing line, so the spawn now lives here once. // // NOT HERE, DELIBERATELY: the repo-redirecting env strip (`GIT_DIR` and // friends). The actuator's `git-env.ts#gitInRepo` drops those before every // spawn (ACTUATOR-43); the harness does not. That is a separate defect, not // this de-duplication — raised in AUTOPILOT-121's report, not silently widened. import { execFileSync } from "node:child_process"; export interface GitOptions { /** Bound the child; a timed-out spawn is a failure (null), exactly like a * non-zero exit. A hung/huge repo must not stall a sweep. */ timeoutMs?: number; } /** Run `git ` in `repo` (the param is the `repo`/`cwd` the CALLERS pass, * whichever name they used); returns the trimmed stdout, or null on any * spawn failure or non-zero exit. Never prints. */ export function git(repo: string, args: string[], options: GitOptions = {}): string | null { try { return execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...(options.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), }).trim(); } catch { return null; } } /** Run `git ` in `repo` for its EXIT CODE, not its output: 0/1/128… for a * completed child, or null when the child could not run at all (git missing, * timed out). Unlike `git` above, a NON-ZERO exit is a real answer — that is * the whole point: `merge-base --is-ancestor` exits 1 for "not an ancestor" (a * normal negative) and 128 for "bad object / not a repo", and a string return * cannot tell those apart. stderr is captured into a pipe and never printed, * exactly like `git`. */ export function gitExitCode(repo: string, args: string[], options: GitOptions = {}): number | null { try { execFileSync("git", args, { cwd: repo, stdio: ["ignore", "pipe", "pipe"], ...(options.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), }); return 0; } catch (e) { // a non-zero exit arrives as a throw carrying `.status`; a spawn failure or // a timeout has no numeric status → null ("git could not run") const status = (e as { status?: unknown }).status; return typeof status === "number" ? status : null; } }