git
A pipe into git commit is a kill signal with a delay fuse
A sequel scar. Piping a merge commit into head -3 closed the pipe after three lines and SIGPIPE-killed lint-staged mid-run — orphaning its backup stash onto the shared stack and leaving the merge half-done. The worktrees post predicted this pile collects scars; today added one through a mechanism nobody had listed. Treat a hook-running commit stdout as a transaction log, not a stream to sample.
By the X-GIS team 5 min read
An earlier post, “Worktrees isolate your files, not your git state”,
took apart the one stash stack that every worktree of a repository shares, and
ended on a prediction: refs/stash is a repository-global pile that keeps
collecting the residue of every tool that shells out to git stash, and it
grows because nobody dares prune it. Today it collected a fresh entry —
through a mechanism that post never listed, because it is not a cross-worktree
race at all:
stash@{0}: lint-staged automatic backup (8f542761)One working copy. One commit. One | head. That was enough.
The wrong first move
The command looked harmless. Concluding a merge that reconciled our branch with main’s regenerated gap-matrix, we wanted a glance at the top of git’s output — the summary line, maybe a hook notice:
git commit --no-edit | head -3Piping into head -3 is the reflex for “just show me the first few lines.” On
an ordinary command it costs nothing. On git commit in this repo it is not an
ordinary command, because a pre-commit hook has turned it into something with a
running process and a live output stream inside it.
What actually happened
The hook runs bunx lint-staged, which — as the worktrees post dissected —
takes a backup stash before running any formatter (stash create + stash store), then drops it on the way out (stash drop). Between those two git
calls it prints live progress. So git commit here is a multi-second process
that emits a stream of lines over time, and holds an open git transaction
while it does.
head -3 reads its three lines and closes its end of the pipe. The next write
from the other end — lint-staged, mid-run — lands on a pipe with no reader. The
kernel’s default response is SIGPIPE, which terminates the writer; a Node
process like lint-staged sees it surfaced as an EPIPE on stdout and dies on
the unhandled error. Either way lint-staged was killed between two of its own
git calls. The output froze at exactly one line:
[STARTED] Running tasks for staged files...— and nothing after it. That is the delay fuse: the gap between head having
enough and lint-staged reaching its next write is milliseconds, but it is
enough to land the kill mid-transaction.
Mid-transaction meant mid-stash. lint-staged had already taken its backup —
stash create, stash store — but never reached the matching stash drop.
The backup was orphaned onto the shared stack. And the commit never concluded:
MERGE_HEAD was intact, the staged files were intact, and git status
reported the canonical half-done state,
All conflicts fixed but you are still merging.Recoverable, but not recovered. A convenience pipe had converted one commit into a wedged merge plus an orphaned stash.
The retry that worked
The fix for the immediate problem is the whole lesson in miniature: stop sampling the stream, capture the entire log. We re-ran with output redirected to a file instead of piped to a reader —
git commit --no-edit > commit.log 2>&1— then read the file. Nothing could close the pipe early because there was no
pipe. lint-staged ran to completion, took its backup, dropped it, and the merge
concluded as commit d1d43699. Redirect-then-read is not a workaround; it is
the correct way to consume the output of a process that mutates state while it
talks.
The scar that remains
The orphan from the killed run was still on the stack — 8f542761, lint-staged’s
backup, its cleanup stash drop never executed. The obvious tidy-up, git stash drop, was refused by our automation guardrail, which treats stash-drop as
irreversible deletion and declines to run it unattended. So the scar stayed
exactly where it landed, at the top of the shared stack:
stash@{0}: lint-staged automatic backup (8f542761)That is the worktrees post’s prediction coming true one entry at a time. It
warned that the pile grows because every hook that shells out to git stash
can leave residue, and that nobody prunes it. Today’s addition did not even
need the parallel-worktree race the post was about — a single working copy and
one | head sufficed. The shared stack does not require two writers to accrue
a scar; it only requires one interrupted one.
What generalizes
A hook turns git commit from an atomic-looking command into a long-running
process wrapping its own multi-step git transaction. The moment that is true,
its standard output stops being a value you may peek at and becomes a
transaction log — and you consume a transaction log by writing it down in full
and reading it afterward, never by sampling it through a reader that can close
the pipe early. | head, | grep -q, a | less you quit — any reader that
finishes before the writer is a kill signal aimed at whatever the writer was in
the middle of. Here the middle was a stash transaction, so the wound was an
orphaned backup and a half-finished merge.
The narrow rule: never pipe a hook-running, state-mutating git command into an early-exiting reader; redirect to a file, then read the file. The broad rule is the one the worktrees post keeps reaching from new directions — the shared, invisible layer punishes the convenience habits that are harmless everywhere else.
References
- “Worktrees isolate your files, not your git state”
— the repository-global
refs/stashstack, lint-staged’s backup-stash mechanism (stash create/storeon entry, a positionalstash dropon exit), and the prediction that the stack keeps collecting scars. This is the next one. - lint-staged,
lib/gitWorkflow.js— the automatic backup taken on entry and dropped on exit; kill the process between the two and the backup is orphaned, exactly as8f542761was. signal(7)/ SIGPIPE — writing to a pipe with no reader terminates the writer by default; the mechanism that turns an innocent| headinto an interrupt for whatever is still producing output.
Read next
git
8 min read
Worktrees isolate your files, not your git state
Six agents committed in parallel from six worktrees of one repo — and every pre-commit hook stashed into the single stack they all share, finding its entry by list index. What worktrees actually share, plus the orphaned-stash scar that proves the race.
debugging
5 min read
A failure with no witnesses: one query pipeline that silenced all three channels
A jq command over a saved MCP result printed nothing and exited 0 — no data, no error, no failure. Every one of those was manufactured by the caller: a wrong schema emptied stdout, a defensive 2>/dev/null ate the one diagnostic line, and | head laundered jq exit code 5 into 0. A Unix pipeline has three signal channels; habits acquired as defenses can close all three at once.
ci
5 min read
The second ratchet: one invariant with two authorities, and a gate we could not run
An icon-padding feature grew three compiler files past their LOC ceiling. The PR raised the ratchet it knew about and went green — then CI failed on a SECOND ratchet tracking the same three files with the same numbers. A repo that preaches single-authority had let one invariant grow two authorities, and a local gate nobody knew existed is a gate nobody can run.