How this is tested¶
Three layers, and a review.
Each one catches what the one before it cannot.
Unit, through the real scripts¶
npm test runs on Node's built-in test runner. There are no dev dependencies.
The hook tests do not call a function and inspect its return value. They read hooks/hooks.json, spawn the exact command it configures, feed it a payload on stdin, and read the JSON it prints. A test that passes proves the wiring as well as the logic.
RECOVERABLE's tests run against a real git work tree holding each type of file the hook has to tell apart. Tracked and clean. Tracked with an uncommitted edit. Untracked. Ignored. DONE-GATE's run against transcripts built in Claude Code's own shape. FLAG-PROBE's run against real scripts on disk.
Tests that were made to fail¶
A test that cannot fail proves nothing, and it looks exactly like one that can. So each guard's test was run once with the guard removed, to watch it go red:
| Test | Guard removed | Result |
|---|---|---|
core.fsmonitor is never executed |
the fsmonitor pin | red |
| a clean filter is never executed | the filter blanking | red |
./find in the working directory never runs |
the absolute path to the system find |
red |
a failing run piped into tail is caught |
the pipe rule | red |
| a negated claim is not a claim | the negation check | red |
| a read script may be probed | the read history | red, four tests |
| a trailing heredoc goes to the last pipeline stage | the last-stage rule | red |
| every leaked name is reported, not only the first | (two planted leaks) | both reported |
| a private word is caught in every spelling, and nothing else is | 14 planted spellings (dotted, underscored, capitalised, with digits, and the three shapes), and 3 harmless neighbours | 14 red, and the 3 stay green |
| a commit message is scanned like a file | one planted message | red, naming the commit |
| git's pins reach it on git 2.30 | pins moved back off the command line | red |
| no repository hook can run | the core.hooksPath pin, checked on every call |
red |
a partial clone's transport never runs (core.sshCommand, uploadpack) |
both transport pins | red, both transports |
| both transport pins reach every git call | protocol.allow or GIT_NO_LAZY_FETCH, each removed alone |
red, one mutation each |
| a broken repository config is a failure, not silence | any git error read as "not a repository" | red |
a required filter does not kill git status |
required=false beside the blank |
red |
a commented-out --help handler does not count |
comment stripping | red |
| a trailing comment or a usage line is not a handler | the quote-aware comment cut, the anchored case arm | red, one mutation each |
a script with no #! is still a script |
interpreter files, executable text, the binary sniff | red, one mutation each |
a delete inside .git is named as git's own data |
the exemption, the work tree above, the sweep, the lock list | red, one mutation each |
| a grep pattern is not a read | per-tool operand parsing | red |
| a local read does not vouch for a remote script | matching by path and host | red |
Now all tests pass. is a claim |
whole-word negation | red, three tests |
pytest; echo done hides the exit status |
"not the last command" | red, two tests |
| an admission never excuses a failed run | the failed-run check | red |
| a failed push after passing tests is not a failed test run | the exit status read only when it is the test's own | red, two tests |
an edit to .gitignore or a licence needs no test |
the furniture list | red, six tests |
pnpm vitest run, bazel test, sbt test and five more are test runs |
the runner vocabulary | red, ten tests |
| "The tests were not run" is an admission, "I did not run into any issues" is not | the admission vocabulary and its guard | red, six tests |
| a stray error after the answer still exits 0 | the synchronous write and exit | red, two tests |
| a git older than 2.26 is a probe failure, never a guess | the version floor | red |
cat > app.py <<EOF, tee and git apply are edits, tee test-output is not |
shell writes, source extensions only | red, one mutation each |
./gradlew --help and Django's manage.py --help pass, a file called manage.py does not |
the two wrapper calls | red, two tests |
within-floor shapes a review found (pipe-to-shell, here-string, xargs/find -exec running a shell, flock/builtin/env -C/taskset) |
the dispatcher's wrapper, shell, xargs and find-exec routing | in the loud list |
safe commands a second review showed the routing must not ask about (cd "$(mktemp -d)" && rm -rf *, xargs git restore --staged, git rm --cached) |
biasing every unknowable case to silence | in the quiet list |
| several files deleted on the main thread | the one-file exemption | red |
| a magic pathspec judges the whole tree | pathspec magic | red |
deleting .git counts commits on no remote |
the history check | red |
| a symlinked working directory still asks | resolving the real directory | red |
| a save made through a symlinked directory keeps what it saved | comparing a save's paths as they were spelled | red |
a glob pathspec (git checkout -- "*.py") judges the whole tree |
the glob as an open-ended set | red, four tests |
find wip -print -delete is dry-run cleanly |
dropping the printing predicates | red, five tests |
| an unresolvable line in an xargs list file is judged | where it runs | red |
time { rm -rf wip; } and a redirection's $( … ) hide no command |
the reserved word blanked, redirections walked | red, three tests |
| a command the grammar cannot parse is not cleared | the parse-error check, in each hook | red, three tests |
git clean -d deletes when clean.requireForce is false |
reading the setting, and -c / --config-env |
red, two tests |
--git-dir, --work-tree and GIT_DIR= are judged in the repository they name |
the location passed to every git call | red, five tests |
| a commit on a detached HEAD or a tag counts as history | every ref, not only branches | red |
| the stash is not counted twice | refs/stash excluded from the commit count |
red |
| deleting a linked worktree names no lost commits | history tied to the git directory, not the top | red |
git worktree remove --force on a dirty worktree |
worktree among the discarding subcommands |
red |
Node starts with every flag in hooks.json |
(a flag Node rejects, planted) | red |
.. after a link is the parent of where the link points |
the walk through the disk, in both of its halves | red, one mutation each |
the shell's own cd .. goes by name |
every cd made physical |
red, two tests |
cd -P and set -P go where the link points, in the shells that inherit them |
the flag, the setting, its scope | red, fifteen mutations |
| a find through a link names the files where they are | the victims' real directory | red, four tests |
| a printed name is a name and not a pattern | the expansion switched off for what find prints and xargs is handed | red, one mutation each |
| a submodule's work is read from above it, however deep | the index listing, and the read | red, one mutation each |
| a clone in an ignored directory is found | the walk | red, four tests |
| the walk stops at two thousand directories or twenty thousand entries, and thirty-three repositories ask unread | each bound | red, one mutation each |
a .git is found without reading for it, whatever order the filesystem lists in |
the lookup when a listing is cut short | red |
| one git directory's history is counted once | the count per git directory | red |
| an xargs item that names nothing is nothing to lose | the older rule, that every missing item is unread | red, four tests |
a wrapper's option takes its value (timeout -k, flock -w, exec -a, env --chdir=) |
each option | red, one mutation each |
| fifteen payloads deep is followed, sixteen is not | the depth raised, the depth lowered | red, one mutation each |
five silent losses a round-3 reviewer drove (flags in a variable, a later git add, a file a find feeds a shell, an unreadable env -C, a submodule whose directory is gone) |
each fix | red, one mutation each |
| a program held in a variable is the program it holds, through a wrapper and in each branch | the expansion, the second peel, each value, the wrapper's prefix, what before records |
red, one mutation each |
a save in the delete's own pipeline, or in an & job nobody waited for, saves nothing |
the stage rule, the job rule, wait and wait $! |
red, one mutation each |
past eight possible directories after cds, a delete is judged across the directory they share |
the overflow, and ending it on a sure cd |
red, one mutation each |
| function calls stop fifteen deep, as payloads do, and say so | the depth check | red, two tests |
a directory whose .git git will not open is read from the repository around it |
the climb, for a junk file and a dangling gitdir: |
red, one mutation each |
echo wip \| xargs rm asks about no directory |
the -r rule on fed items |
red |
| a repository is named one way, however the delete reaches it | the name from the repository the command runs in | red, one mutation each |
| each git question is asked once per call | the per-call memo | red: three git status calls in the trace, and 150 deletes over 1.5 s |
| a submodule's unrecorded commits are no loss above it | reading it as the submodule | red |
env -S 'rm\_-rf\_wip' is an rm, and a string env refuses runs nothing |
the env -S decoder |
red, five mutations |
numactl, unshare, strace and chrt are wrappers, and unshare -w moves the command |
the table rows, the directory and environment options | red, one mutation each |
CDPATH is followed, first entry first |
the search along it | red, one mutation each |
a cd into a directory it cannot search may fail |
the search permission check | red |
DONE-GATE, fixed-width and lib/done-gate.mjs are names, not claims |
the name boundary on each side | red, one mutation each |
bash -o pipefail -c 'pytest \| tail' owns its exit status |
the shell's own pipefail | red, three mutations |
cp, mv, install and a write into .env are edits |
the copy rule and the dotenv rule | red, one mutation each |
One of them lied first.
The clean-filter test passed with the filter blanking removed. Its fixture changed the file's size, and git spotted the change from the file's size alone, without hashing it, so it never needed the filter. A same-size edit forces the hash. The rewritten test goes red without the guard and green with it.
Others lied later.
The pipe rule's test stayed green with the rule deleted, because the rule was dead code: a piped test always had a command after it. command -v rm stayed silent with the lookup rule removed, because an rm with no operand deletes nothing. A test named for a sweep above every repository was rooted at the repository itself, and the mutant it was written for survived. Each was found by a mutant that lived, or by a reviewer who ran one.
Mutants are run again after every change. A new second path to the same answer let two old ones live, and both times the second path was counting one loss twice.
The loud list and the quiet list¶
A guard that fails closed asks about everything. That would pass any test that only checks it asks about dangerous commands. So every loud case must be refused by a judge, with a reason that says what would be lost, and there is a quiet list beside it: rm -rf build, git status, grep -rn "rm -rf" ., a commit message that mentions find wip -delete, ((rm -rf wip)). No broken guard can stay silent on those.
A review before release¶
Before anything was published, six Claude models read the code, each hunting a different class of flaw. One was told to break the consensus. A non-Claude model read it too, because Claude models share blind spots. A seventh Claude weighed their findings against the code and ran the disputed ones.
A second round read the finished tree: a bench of five, one model alone with forty minutes, one pass by a model from a different family. About ninety findings. Thirty were driven against the real hook before anything was fixed. Twenty-eight held as stated.
Each fix got a test that fails without it. Sixteen did not at first: the paths worked when driven by hand and nothing in the suite drove them. They have their tests now, and a mutant each.
A vanilla Claude Code¶
npm run e2e builds a stock container. It holds the npm Claude Code, git, and an empty home: no CLAUDE.md, no settings, no other hooks. The repository's committed tree is served to it as a git remote. It is added through a marketplace, the same way a GitHub source installs. Claude Code's own CLI does the install.
Then every scenario runs through claude -p. Each one is judged from what really happened, never from what the model says it did: the tool calls and their results, the transcripts, including any subagent's, and the files left on disk.
It needs Docker on the machine that runs it, and a token from claude setup-token. The token is read from CLAUDE_CODE_OAUTH_TOKEN, or from ~/.config/jord0-hooks-e2e/token, and reaches the container as an environment variable only. Every scenario is a real session. Billed to that token.