Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266/** * 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<GitRunOutcome>;}
/** * 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<GitRunOutcome> { 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<null, Readable, Readable>;
/** 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<GitRunOutcome> { 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);}