Skip to content

git-exec

The seam that turns a git invocation into a truthful result — and, when it fails, into a failure that still says WHICH kind it was, so no caller can mistake "git could not run" for "git ran and said no".

raw source

Everything in this repository reads truth through git. So the one thing this seam may never do is hand back a failure whose CAUSE has been overwritten: callers routinely branch on whether git answered "no" or never ran at all, and those two mean opposite things. A missing branch is a fact about the repository; a git that cannot be executed is a fact about the machine, and answering the first when the second happened is a silent lie in the layer every other answer is built on.

expanded spec

git-exec runs one git child and resolves its output, or rejects with an error that carries three things: the stream contents, the signal if one arrived, and — load-bearing — the failure's OWN cause. A child that never started still emits close, and the code it reports there is the negated errno (EACCES arrives as -13). That number is not an exit status and must never be written over the spawn error's 'EACCES'/'ENOENT', because the classification above this seam is exactly typeof code === 'number' ? 'exit' : 'spawn': overwrite it and a failure to RUN git is delivered as a git that ran and exited.

The executable is resolved from the child environment's PATH before the first spawn and reused only while that exact PATH still names an executable at the cached location. All sync and async paths consume this one resolved path, so one materialize with many small Git queries does not repeat the shell's whole PATH search for every child. The cache key is the PATH itself, not process-global identity: a caller that supplies a Git wrapper through a different child environment receives that wrapper, and removing a cached executable makes the next call resolve the current PATH again. Resolution failure remains a spawn-class machine failure, never a Git negative answer.

The consequence was not theoretical. Callers separating the two read the mislabelled failure as a real git answer — the freshness content batch concluded "the anchor commit object is unreadable" and marked the anchor gone, and session creation concluded "the candidate branch does not exist" — so on a machine with a missing or unexecutable git, SpexCode would quietly report that the thing being asked about was absent. A test asserting that a spawn failure must be loud had been failing on trunk long enough to be filed as a known red, which is the shape this project treats as most expensive: the guard that would have caught it was itself the thing reported as broken.

Overflow is the one case that legitimately replaces the code, because exceeding the buffer is this seam's own verdict rather than the child's, and the kill it performs would otherwise surface as an unrelated signal. Timeout marks itself separately for the same reason. Everything else keeps whatever cause it arrived with.

The synchronous text and buffer entrances share one explicit output budget large enough for repository-wide Git projections. They never inherit Node's smaller default: a valid ls-tree or history answer crossing that default is still Git truth, not a failed command. Crossing SpexCode's explicit budget remains a loud overflow, while only the timeout marker may be described as a timeout; the SIGKILL used to enforce either boundary is an implementation detail and cannot collapse the two diagnoses.

who reads this classification

Seven call sites branch on it, across two packages and in BOTH polarities, so the cause this seam preserves is not a local concern:

spec-cli/src/sessions.ts   1718 · 2660 · 2692 · 2693 · 2792   `failure !== 'exit'`
spec-eval/src/freshness.ts 183                                `failure !== 'exit'`
spec-eval/src/sessioneval.ts 410                              `failure === 'exit'`

The reversed-polarity one is where this classification is easiest to misread, including by me. Both of its branches raise the same error type and differ only in the sentence, and the exit sentence is "base X is not an ancestor of head Y; use the session merge-base as base" — an instruction the reader will act on. An earlier version of this body claimed a mislabelled spawn failure produced it. That was wrong twice over, and measuring is what showed it: an unexecutable git never reaches the gate at all, because impactCommit resolves both revisions first and fails first; and merge-base --is-ancestor answers "not an ancestor" precisely BY exiting 1, so on the exit path that sentence is a correct diagnosis rather than a false one.

What measurement did surface there is a different defect, still unfixed: --is-ancestor exits 0 for ancestor, 1 for not-an-ancestor, and 128 for a hard error (fatal: Not a valid commit name). All three non-zero outcomes classify as exit, so a 128 is reported to the reader as "your base is not an ancestor; use the session merge-base" — a confident, actionable, wrong instruction. Telling 1 from 128 needs the numeric status this seam currently discards, which is a widening of its result shape rather than a one-line change, so it is filed rather than patched here.

Neither branch has a test. That is worth knowing before the next change to this seam: five of the seven sites are reached only on a machine where git is missing, unexecutable, or timing out, so ordinary runs never exercise the distinction — the guard is the freshness loud-failure test alone, and it had itself been failing on trunk long enough to be filed as a known red.