Migrating the hooks to a single Rust binary
Status: complete — all four phases. See Outcome for what the plan got wrong.
Phase 4 is done, not "optional and not started" as this line said for a while.
There is no .zsh file anywhere in the repository; the "19 zsh suites" it
described porting ARE the cargo integration tests under crates/*/tests/, and
Windows CI runs the same suite every other platform does rather than a smoke.
The one thing left of the shell era is the five sh shims, and they are shims
on purpose.
This document is history, kept for its reasoning. Everything below is in the tense it was written in: the argument for a phase order, for a safety rule, for a distribution shape, is worth more than a description of the result. Where the plan and the code diverged, the divergence is called out where it happens.
Why
Two requirements the current design cannot meet:
1. No unconditional runtime dependency. Three hooks run on every commit in every repo, whatever the language:
commit-msg— nodeprepare-commit-msg— zsh- the
pre-commit/pre-pushdispatchers — zsh
So a pure-Python repository needs node and zsh installed to make a commit — not because it uses either, but because the hook framework does. Per-hook scoping cannot fix this: the dependency is in the entrypoint, before any scoping runs.
2. Windows. 17 of 20 hooks are #!/bin/zsh. Git for Windows ships MSYS2
bash, not zsh, so those hooks do not execute at all. Re-shebanging is not enough
— they use zsh-only syntax throughout (23 ${0:a:h}-style modifier expressions,
45 bare-word arrays, 5 (N) glob qualifiers, 2 setopt).
A single static binary answers both: nothing to install alongside it, and Windows is a build target rather than a rewrite.
What this is NOT for. The nine linter-orchestration hooks shell out to
eslint / prettier / ruff / pyright / yamllint / kubeconform / kube-linter / argo.
Rust does not remove those, and should not: they are opt-in per repo already
(each hook no-ops unless the tool's config is present), which is exactly the
"only the tools I actually use" behaviour we want. A Python repo pulls ruff,
not eslint. That part of the design is already right.
Scope
| group | files | lines | why it moves |
|---|---|---|---|
| entrypoints + always-on | pre-commit, pre-push, commit-msg, prepare-commit-msg, ban-terms, branch-pattern, usual-name, pull-rebase, run-tests-js | ~700 | runs regardless of repo language — the actual dependency |
| linter orchestration | 9 × pre-commit-*.zsh | ~576 | only for Windows parity; otherwise optional |
| tests | 15 × tests/*.zsh | ~859 | see below — these are an asset, not a cost |
The thing that de-risks this
The existing test suite is interface-level. Every test invokes the hook by
file path and asserts on exit code and stdout; none reaches inside. So once a
hook file becomes a shim that execs the binary, all 14 suites keep working
unchanged against a Rust implementation.
This only holds under a constraint that is easy to get wrong, so state it as a
contract: every hook keeps a file at its current path, forever. The tests
resolve templates/hooks/<name> from their own filename — pre-push-branch- pattern.test.zsh runs templates/hooks/pre-push-branch-pattern.zsh,
pre-commit-ban-terms.test.zsh runs the .js. Porting a hook therefore means
replacing its body with a shim, never deleting the file. Delete it and the
unmodified test cannot run at all, and the safety property this whole plan rests
on evaporates.
That is not just a testing concern: the dispatcher discovers sub-hooks by
globbing <hook-name>-* in its own directory, and hook.skip filters that glob
by substring on the path. Keeping every path alive keeps both working, unchanged,
throughout the migration. It is ~20 shims, not 4.
Renaming the shims (dropping the now-misleading .zsh / .js suffixes) is a
tidy-up for after the migration, and costs a test edit plus a propagation
sweep. Not worth bundling into it.
That means a behavioural regression net for the whole migration, on Linux and
macOS via the CI added in #12, before writing a single line of Rust test. Port a
hook, run make test, and the same assertions that guarded the zsh version now
guard the Rust one. Port the tests to cargo test later, or never.
Treat this as a hard rule: a hook is not ported until its existing .zsh test
passes against the binary, unmodified.
The two genuinely hard parts
1. Shim resolution (the part that will bite)
Git requires an executable file at each of the four entrypoints (commit-msg,
pre-commit, pre-push, prepare-commit-msg), and — per the contract above —
every sub-hook keeps its path too, so every ported hook becomes a shim of this
shape:
#!/bin/sh
exec git-hooks pre-commit "$@"
The trap: git hooks do not inherit an interactive shell's PATH. GUI clients
(VS Code, Tower, SourceTree, JetBrains) launch git with a login-ish environment
that frequently lacks ~/.local/bin or ~/.cargo/bin. A shim that only does
exec git-hooks works in the terminal and fails silently in the GUI — the worst
possible failure shape for something standing between a person and their commit.
Resolve in this order, and bake the install path in at make install time:
$GIT_HOOKS_BIN(escape hatch, and how the tests point attarget/debug)- the absolute path written into the shim by
make install command -v git-hooks(PATH, last resort)- fail loudly — never silently skip a check
Windows: the #!/bin/sh shim runs under Git for Windows' bundled sh, which is
always present, and MSYS resolves git-hooks to git-hooks.exe. Keep the shim
rather than installing a bare .exe as the hook file.
2. Distribution
A PLAN NOT TAKEN. None of the release pipeline below was built, and
make installdoes not work the way this describes. What ships is:make installrunscargo build --releaseand thenamont install, which writes the binary and bakes the shims — building from source is the ONLY path, so the "contributor fallback" became the whole story and themake install-from-sourcetarget this bullet list asks for never existed.make install-fleetdoes the same for the dashboard.The section is kept because the reasoning still applies the day somebody wants binary releases: five targets, musl for the static Linux build, checksums, and a source path for unlisted triples are all still the right list. It is a backlog item nobody has needed, not a description of today.
This is the real new cost, and it is permanent machinery we do not have today.
- Build 5 targets in CI:
aarch64-apple-darwin,x86_64-apple-darwin,x86_64-unknown-linux-musl(static — no glibc-version coupling),aarch64-unknown-linux-musl,x86_64-pc-windows-msvc. - Publish on tag, with checksums.
cargo-distgenerates most of this. make installfetches the artifact for the host triple, verifies the checksum, installs the binary, then writes the shims with the resolved path.- Contributor fallback:
cargo install --path .. - Keep a
make install-from-sourcepath so a machine without network (or on an unlisted triple) is not stuck.
Offsetting win: updating today means copying N files into 96 repos (done twice this week). After this, it is replacing one binary — the shims change almost never. Net, install gets simpler, not harder.
Behaviour that must be preserved
Verified against the current dispatcher — easy to lose in a rewrite:
-
The two dispatchers behave DIFFERENTLY, and both behaviours are load-bearing:
pre-commitbackgrounds every sub-hook, waits for all of them, then reports every failure as a list. Running it serially would be a visible slowdown on each commit; stopping at the first failure would hide the rest, so you'd fix one lint error, commit, and immediately meet the next.pre-pushruns sub-hooks serially and exits on the first failure, naming just that hook (Error raised by hook <path>). That is correct for push: the steps are ordered and expensive (branch-pattern, then pull-rebase, then the full test suite), and there is no point running tests after a rebase conflict.
A Rust port must reproduce both, including the two distinct message formats, and should have a test for each — a single shared "run all sub-hooks" helper is the obvious accidental way to lose the distinction.
-
git config --get-all hook.skipfilters by substring match against the hook path.git -c hook.skip=package-lock commitmust keep working. -
CHERRY_PICK_HEADshort-circuit: the dispatcher exits 0 during a cherry-pick. -
HOOKS_FORCE_GREPexists only to exercise the grep fallback in tests; it disappears with the shell hooks, along with therg/grepsplit itself — the binary uses theregexcrate and needs neither.
Phases
Each phase ships and leaves the repo working. Nothing is a flag day.
Phase 0 — scaffold, no behaviour change.
Cargo crate, CI build job, shim mechanism, make install writing shims.
The Rust dispatchers do nothing themselves yet: they glob and run the existing
pre-commit-* / pre-push-* script files, honouring hook.skip — pre-commit
in parallel collecting every failure, pre-push serially stopping at the first,
each with its existing message format.
Done when: all 14 zsh suites pass unchanged with shims installed.
Phase 1 — the always-on set. In this order (cheapest correctness first):
branch-pattern → usual-name → prepare-commit-msg → pull-rebase →
commit-msg → ban-terms → run-tests-js.
Each: implement in Rust, replace the script with a shim (do not delete it),
make test must stay green.
Phase 1 also moves the config gating into the dispatcher. Today every
pre-commit-*.zsh is spawned unconditionally and each decides for itself
whether to no-op — which means a Python repo still starts a zsh process for the
eslint hook purely to have it exit. The Rust dispatcher should evaluate the same
signals (an eslint config, [tool.ruff], pyrightconfig, staged .yaml, …)
and not spawn what cannot apply. Fewer processes per commit, and it is what
makes the dependency claim below true rather than nearly true.
Done when: the unconditional dependency is gone — a repo whose staged files trigger no linter hook needs neither zsh nor node to commit.
Note precisely what this does not yet deliver: a repo that does trigger a
still-zsh linter hook (a Python repo with [tool.ruff] reaching
pre-commit-ruff.zsh) continues to need zsh. Full removal is Phase 3. Requirement
1 is met for the framework; it is met for every repo only after Phase 3.
ban-terms earns a real parser here. Its comment/string blanker is explicitly
"not a parser" and mis-handles a regex literal containing an escaped slash; a
proper tokenizer removes that whole class of false negative.
(Written of the JS implementation. The Rust port gained a Regex state that
handles escaped slashes and character classes, so the escaped-slash defect was
gone by Phase 1 — this paragraph outlived it. The defect that actually
survived was a different one: see the tokenizer note below.)
Phase 2 — Windows. Add windows-latest to CI, running the suites for the
ported hooks only.
Be clear about what this proves and what it does not: a real end-to-end
git commit on Windows still fails while any applicable hook is zsh. Phase 1's
gating helps — a repo triggering no linter hook commits fine — but a Python repo
with [tool.ruff] reaches a zsh script that Windows cannot execute. So Phase 2
demonstrates the always-on path, and Phase 3 is a prerequisite for Windows
being genuinely usable, not an optional extra, if the goal is "work on Windows"
rather than "the framework is portable".
Done when: the ported hooks are green on windows-latest, and the doc says
plainly which hooks a Windows user cannot yet run.
Phase 3 — linter orchestration. Port the nine pre-commit-*.zsh. Optional
only if Windows is a "commit hygiene" goal; mandatory if a Windows user must
be able to commit to a repo that uses any of these linters (see Phase 2).
Done when: zsh is absent from the repo entirely, and requirement 1 holds for
every repo rather than for the framework.
Phase 4 — retire the shell test suite (optional). Port tests/*.zsh to
cargo test. Deliberately last: those tests are the migration harness, and
rewriting them early would remove the net while walking the wire.
Done, and it stopped being optional the moment Windows mattered. The suites
are crates/amont/tests/*.rs and crates/amont-fleet/tests/*.rs, each
Repo an isolated temp repository so they run in parallel — which the old
one-repo-per-suite runner could not, and which is why several old cases depended
on state left by earlier ones. Windows CI runs the full suite rather than a
smoke, because the tests no longer need an interpreter Git for Windows does not
ship.
Rollout to existing repos
96 local repos hold copies from git init time. The established recipe
(compare each installed copy against every historical blob of that file, replace
only exact matches so a customised copy is never clobbered) applies unchanged to
the shims — see the propagation note in the ban-terms memory. Survey found zero
customised copies, twice.
After Phase 0 the shims are stable, so subsequent phases need no repo sweep: updating the binary updates every repo at once.
Decisions to make before starting
-
Binary name.
git-hookscollides conceptually withgit hooks(git may resolvegit-hooksas a subcommand). Prefer something unambiguous. -
One binary or one per hook? One, with the hook name as argv[1]. Five binaries would mean five downloads and five things to keep in sync.
-
Config format. The zsh hooks read
git config(hook.skip). Keep that, or introduce a.amont.toml? Keepinggit configavoids inventing a second source of truth and preservesgit -c hook.skip=… push. -
MSRV and dependency budget.
regexandignore(ripgrep's own crates) cover matching and file walking. Resist more. -
Does Windows need the linter hooks? Determines whether Phase 3 is optional or mandatory — see Phase 2. If a Windows user must commit to a repo using ruff/eslint/prettier, Phase 3 is part of the Windows story, not a follow-up, and the honest total cost is ~1,500 lines rather than ~700.
-
Do the shims keep their
.zsh/.jssuffixes? RESOLVED: no, they were dropped after Phase 4.The rest of this answer was wrong, and is corrected rather than deleted because somebody will otherwise rely on it. It said "the suffix STRIPPING in
main.rsstays permanently — a repo seeded before the rename still passes the old filename through". There is no such stripping, and there never was.registry::lookupis an exact match againstENTRYPOINTSand the check names; a repo still holdingpre-commit-ruff.zshhands the binary a name it does not know, and getsunknown hookand exit 2.That is deliberate and it is fine, because the repair path is the fleet, not a compatibility shim in the binary.
amont-fleet fix/installis what moves the 96 repos, andRepo::stale_ours— "our files that we no longer ship" — is exactly the field that finds those leftovers and turns them into a removal. The binary and the repos DO move independently; the mechanism is a sweep with a plan you can read first, not silent name rewriting inside the hook that runs on every commit.
Honest cost
~700 lines of shell/JS to port for Phase 1, plus a release pipeline that must be
maintained forever. Against that: the framework stops imposing node and zsh on
every commit, Windows becomes a build target, and the BSD/GNU divergence class
disappears — that class produced two of the three bugs CI found on its first two
runs (realpath -s, and the \w-vs-POSIX-class dialect split).
If Phase 1 alone lands, the framework stops imposing anything and Phase 4 stays optional. Phases 2-3 are optional only if Windows is not a real target; if it is, budget for Phase 3 too — roughly 1,500 lines rather than 700.
Outcome
All 20 hooks are sh shims over one dependency-free binary (~430 KB). A repo
that triggers no linter needs neither zsh nor node to commit or push, which
was the requirement. Phase 2 (Windows CI) and Phase 3 (the nine linter hooks)
landed too, so zsh is gone from the hook set entirely.
What the plan got wrong
"All 14 zsh suites passing is Phase 0 done." They were necessary but not sufficient: every suite invokes a SUB-HOOK directly, so the dispatchers — the exact code Phase 0 replaced — had no coverage at all. Phase 0 had to bring its own tests.
The safety rule contradicted itself. "Existing tests pass unchanged" only holds if every hook keeps a file at its original path; the plan said Phase 1 would delete the scripts. Corrected before implementation: porting replaces the body with a shim, never deletes the file. ~20 shims, not 4.
"Phase 1 delivers no zsh." The dispatcher still globs and spawns
pre-commit-* scripts, which need zsh before they can no-op. Phase 1 removes
the UNCONDITIONAL dependency; full removal needed Phase 3.
Phase 3 was listed as optional. It is a prerequisite for Windows being usable, not an extra — a real commit there fails while any applicable hook is zsh.
What the method actually caught
Seven bugs in code that had already shipped:
- four hooks used
rgunguarded — a missingrgmakes! rg …true, sobranch-patternrejected EVERY branch name (silent wrong answers, not errors); make testhad never run on a clean macOS (realpath -sis GNU-only);- CI could not go red (
make test | teewithout pipefail); []is truthy in JS, sorun-tests-jsselected every package regardless of what changed;prepare-commit-msgappended a danglingIssue: #id(tested$?after a pipeline ending inhead -n 1);pull-rebaseread the ahead-count withhead -c 1, so 12 printed as "1";- 54 of 96 installed
commit-msgcopies matched no known template blob and were silently skipped by the propagation sweep.
Plus five in the ports themselves, each caught by the existing suite before
merge, and three tests that could not fail (the dispatchers had none;
pull-rebase's dirty-tree case was satisfied by an unrelated early exit; my own
Windows smoke read a commit subject without first asserting the commit existed).
Two bugs were found only by the Windows leg, on its first two runs — and
both were silent-wrong rather than loud. which missed .exe, so resolve_tool
would have skipped the repo's PINNED linter for an ambient one. And Windows has
no shebang support, so every sub-hook the dispatcher spawned failed with
%1 is not a valid Win32 application.
Rules worth keeping
- A hook is not ported until its existing test passes untouched — then break the implementation once to confirm the test can fail. Vacuous tests are the characteristic failure here.
- "Verified against the original first" is only as good as the environment it was verified in. A kubeconform test asserting silence encoded the machine it was written on; CI has neither tool, hits the gate first, and warns.
- A sweep reporting "0 customised" is not proof of a clean fleet. Check the DISTINCT-BLOB count per hook; consistent means 1.
- For hooks that REWRITE (
commit-msg) or filter (ban-terms), diff the old and new implementations over the same inputs. Both were byte-identical across 11 and 14 cases.
Still open
-
Phase 4 — port the 19 zsh suites toDone. They arecargo test.crates/*/tests/*.rs, and Windows runs them all. -
TheDone. The documented defect (escaped slash in a regex) turned out to be already fixed by the port'sban-termstokenizer.Regexstate — the note was stale. Probing 20 hard constructs found the real survivor: template literal substitutions were blanked as string content, so any banned call written inside${…}was missed. Now handled as code, with a DEPTH STACK because substitutions nest.Differential over 39,378 real fleet files: blanking changed on 10,567 of them, with 0 new alarms and 0 dropped detections — the path is heavily exercised and the stricter rule costs nothing in false positives.
-
The rename.Done. Shims carry no extension;scripts/propagate.shswept all 96. The sharp edge was the dispatcher's<hook>-*GLOB: a repo left holding bothpre-commit-ruff.zshandpre-commit-ruffruns ruff TWICE, silently. Removal and installation therefore happen per repo, in that order, which is why the sweep is a script and not a typed loop. -
Done — it self-scopes on a pyright config, so installing it everywhere changes nothing for the 90 repos without one.pre-commit-pyrightin 6 of 96 reposgovernance-tshad it for months and it never fired.
Checks moved in-process (PR #36)
Git invokes exactly four hook names — pre-commit, pre-push, commit-msg,
prepare-commit-msg. The other 16 files in every .git/hooks were our own
dispatcher's business: byte-identical sh shims whose only job was to re-exec
the binary and tell it their own filename. One commit cost 27 processes to run
work the binary already had in a table.
Fleet-wide 1920 files → 384; steady-state pre-commit ~383 ms → ~293 ms. Most of a commit is the linters, so 24% is the whole timing prize; the structural wins are larger: order is declared rather than emerging from a lexicographic filename glob, the Windows shebang emulation is deleted, and stdin is read once.
That last one fixed a silent bug. git feeds pre-push its ref list on stdin,
consumable once. As separate processes with INHERITED stdin, whichever check
ran first drained it — two repos carried a pre-push-branch-protect.sh sorting
before pre-push-run-tests-js, so the JS gate saw EOF and ran nothing, for as
long as both existed.
branch-protect is now built in, first in pre-push, protecting main/master
everywhere; it matches the REMOTE ref and treats a delete as a write.
Rust checks (PR #38)
| check | dispatcher | command |
|---|---|---|
pre-commit-cargo-fmt | pre-commit | cargo fmt --all -- --check |
pre-commit-clippy | pre-commit | cargo clippy --workspace --all-targets --all-features -- -D warnings |
pre-push-cargo-test | pre-push | cargo test --workspace --all-features |
Split by cost as the other languages are: fmt/clippy with ruff and pyright, test
with run-tests-js. Three separate checks so hook.skip disables them
individually. Scoping runs on the nearest ancestor Cargo.toml, not the repo
root, so a Rust component in a subdirectory works; --workspace covers the
members from there. Cargo.toml/Cargo.lock count as touching Rust for clippy.
Two bugs the tests caught:
-
Availability was probed with
cargo <sub> --version. That works for rustfmt and clippy, which are separately installable components, butcargo test --versionis "unexpected argument" — so the probe reported test as unavailable and the gate silently passed. It would never have run a test in any repo. Built-in subcommands need no probe; only components do. -
git exports
GIT_DIR/GIT_INDEX_FILE/GIT_WORK_TREEto every hook, and they OVERRIDE the working directory. Running a project's test suite from a hook therefore hands it the hook's repository. This repo's own suite creates throwaway repos and commits to them — so the first real run of the gate put a stray commit, authored by the test fixture, onto a live branch and pushed it.strip_git_envnow clears them before spawning any tool, npm included; a suite must behave exactly as it does when run by hand.
propagate.sh is gone (PR #47)
Replaced by amont-fleet fix, after a differential proved the Rust plan
removes exactly the same set on a fixture covering every actionable case. That
comparison is what earned the deletion; the expectation it produced is kept as a
golden test, because deleting the gate along with the script would have thrown
away the only evidence the rewrite was faithful.
make propagate now runs the Rust path. Dry-run by default, APPLY=1 to write.
Why the dependency guard exists (corrected)
scripts/check-no-deps.sh asserts that amont resolves to nothing outside
the workspace. Its original comment said the migration existed because a Python
repo needed node and zsh to commit, so a dependency tree "would undo it".
That reasoning was wrong and is corrected in the script. node and zsh were
RUNTIME requirements — they had to exist on PATH in every repo or the hook
failed. A Rust crate is compiled in and statically linked; adding serde would
make nobody install anything. The two are not the same problem.
The guard is still worth having, for reasons that survive scrutiny:
- Supply chain. The binary runs on every commit in 96 repos, with the
developer's credentials, reading every staged file. Every transitive crate is
code executing in that position. This argument is specific to THIS binary —
amont-fleetpulls ratatui and a dozen crates without concern, because it is opt-in. - Offline reproducibility. A std-only crate builds without a registry, indefinitely. Real but modest.
- A forcing function. It makes each dependency in the commit path an argued decision rather than a default.
So it is a strong default to argue with, not a wall. If the commit path ever needs a real parser, weigh that crate's tree against the code it replaces. The answer today is still "hand-roll the twenty lines", because the config format under discussion is trivial — not because a dependency is forbidden.