Skip to content

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.