amont
Catch the bad commit before it exists — and take the whole thing back out in one command.
One Rust binary, no runtime, no config file to write. Install it, run
amont install in a repository, and your next commit is checked by thirty-nine
language-aware checks that already know when to stay out of the way.

Why this one
- Useful in the first minute. Other hook managers install empty and wait
for you to write YAML. amont ships thirty-nine checks — commit-message
conventions, merge-conflict markers, linters and formatters for the languages
your repository actually uses, branch rules, your test suite — and each one
fires only where the repository has opted into its tool.
amont listshows you what runs here, and why the rest will not. - Nothing on the commit path but
std. The hook binary links no external crates, and CI fails any build that changes that. What runs on every commit, with your credentials, has the smallest supply chain this project could arrange: none. - A cloned repository cannot run code on your machine. Repositories declare
their own checks in a committed
amont.conf— and those declarations are inert until you review them and sayamont trust. No other hook manager puts a review gate betweengit cloneand running the repository's commands. The trust model. - Your uncommitted work is never collateral. Checks run against exactly
what you staged; unstaged work is held aside without
git stashand restored even if a check panics. The design that makes that true is the most carefully argued part of the codebase. - Leaving is one command.
amont uninstallremoves exactly the six shims install wrote — a hook you or another tool put there is named and left alone. A gate you cannot exit cleanly is a gate you were right not to enter.
How that stacks up against pre-commit, lefthook and husky, feature by feature: how it compares.
Why you can let this near your commits
A prompt theme is cosmetic. This blocks commits and pushes, reads every staged file, and runs with your credentials while nobody is watching — so the claim it has to earn is not "delightful", it is "harmless".
- The commit path links no external crates.
amontandamont-runtimeare std-only, andscripts/check-no-deps.shfails a build that changes that — fails closed, so a cargo error or an unreachable registry is a failure rather than a reassuring green tick. - No telemetry, no update checks. With the commit path std-only there is
not even an HTTP client linked to phone home with. The network a push does
touch is git's own and the tools you opted into:
pull-rebaserunsgit ls-remote/git fetchagainst your upstream (off withgit config amont.autoRebase false, see configuration), and theaudit-*checks callcargo audit,npm audit,pip-auditandgovulncheck. - Over a thousand tests, run on Linux, macOS and Windows, alongside
cargo fmt --check,clippy -D warnings, an MSRV floor of 1.74 compiled for the commit path, andcargo-audit. - v1.0.0 followed a full security review, and each finding landed with a committed reproduction — a drive-by RCE via a relative path in the shim, a held-store format that let a repository plant a symlink outside the worktree, a trust prompt a repository could conceal declarations from.
- Your uncommitted work is the thing that must never be lost. The release
profile deliberately omits
panic = "abort"so theDropthat restores unstaged work still runs when a check panics — with a test asserting on the manifest, because no behavioural test could catch that regression.
Threat model and private reporting: SECURITY.md.
The repositories around it
Three companions, each its own repository because it runs somewhere amont deliberately does not:
- amont-agent — a Claude
Code
PreToolUsehook for the mistake no git hook can reach, because it lives in the command string itself:git push … | tail -5reports tail's exit status, so a rejected push reads as success. The guard judges the pipeline before it runs. Independent by design — no shared code, and neither needs the other; they meet in one optional place, where its session notice asksamont agents-md --checkwhether the guidance block an agent is about to believe has gone stale. - attest — the CI half of
amont.attest: when every pre-push block gate passed locally, amont leaves a signed note on the tree it tested, and this single-purpose verifier lets CI skip work provably already done. Fail-open by construction, and separate precisely so that amont itself never runs in CI — the reasoning. - amont-pack-java — the worked example of a pack: how checks amont deliberately does not build in get shipped anyway.
Start here
- Installing and activating — get the binary, turn hooks on in one repository (or every repository you ever clone), and turn them off again.
- The checks — what the thirty-nine built-ins do, and what each one needs before it fires.
- Opting out — skip one check, downgrade a whole trigger, bypass a single commit, or remove the hooks entirely.
Then, as you need them: where the hooks fit in your flow · commit and branch conventions · configuration · custom checks · the trust model.
How the documentation is organised
The pages under Using amont are for anybody who has installed it or is deciding whether to.
The pages under Design records are for maintainers: the arguments behind the current behaviour, kept because "why is it like this" is a question that comes back. Do not start there.
Installing and activating
Two separate acts, and keeping them separate is deliberate. Installing the binary puts a program on your machine. Activating turns hooks on in a repository. Nothing about the first does the second.
1. Get the binary
Linux and macOS:
curl -fsSL https://raw.githubusercontent.com/fredericrous/amont/main/install/install.sh | sh
Windows, in PowerShell — the line above is POSIX sh, so on Windows it runs
only under Git Bash:
irm https://raw.githubusercontent.com/fredericrous/amont/main/install/install.ps1 | iex
The installer resolves the latest release, downloads the archive for your
platform, verifies it against the published SHA256SUMS, and writes the binary
to $AMONT_BIN_DIR (default ~/.local/bin) by atomic rename, so a
half-copied amont never exists.
It refuses to guess. If the checksum does not match it exits rather than
installing; if SHA256SUMS is missing or there is no sha256 tool on the
machine it says loudly that the download was not verified rather than
staying quiet about it. Verifying what this binary is before putting it in a
position to read every staged file is the argument the project makes about its
own dependencies, applied to itself.
The checksum is not the whole story, though. SHA256SUMS comes from the same
release as the archives it describes, so it proves the download is intact —
not that the release was built by this repository. Every release artifact,
SHA256SUMS included, also carries a GitHub build attestation, and that is
the check the installer does not run for you, because it needs the gh
CLI or a Sigstore client:
gh attestation verify amont-<version>-<target>.tar.gz --repo fredericrous/amont
See SECURITY.md for what each check does and does not prove.
~/.local/bin is not an arbitrary default: it is a candidate in the shim's
own resolution order, so a binary there is found even by a shim whose baked
path is wrong — and it is the same convention systemd, pipx and uv observe.
Where the binary ends up
amont install copies the running binary somewhere stable and bakes that
path into every shim it writes — unless the binary is already on your
PATH, in which case it bakes it where it is and copies nothing.
That split matters for package managers. ./target/release/amont install
must copy, because cargo clean deletes that directory and the shims would
stop resolving. A binary from brew install, cargo install or a distro
package must not be copied: the copy is a second, unmanaged binary that the
package manager will never update again, so brew upgrade amont refreshes
one file while every repository stays baked to a frozen one. That is the same
staleness the copy exists to prevent, arrived at from the other side.
The path baked for a package-managed binary is the one PATH exposes, not
the resolved one. Homebrew's /usr/local/bin/amont is a symlink into
/usr/local/Cellar/amont/<version>/bin/, and that versioned directory is
deleted on the next upgrade — baking it would pin every repository to a path
about to stop existing. The same is true of nix, asdf and mise.
Installing somewhere else
$AMONT_BIN_DIR moves the binary, and install bakes wherever it landed
into the shims it writes — so a custom location resolves through the baked path
and needs nothing further.
The exception is the setup where shims are deliberately left unbaked:
init.templateDir pointed at a checkout. Those shims resolve at run time from
~/.local/bin, which is a constant inside a POSIX sh file. Choose both and
they stop composing — nothing baked a path, and the one path the shims know is
not where the binary went. amont install says so when it sees the
combination, and offers the two ways out: link the binary where the shims look,
or set $GIT_HOOKS_BIN.
$AMONT_BIN_DIR is an install-time setting only; the shim never reads it.
The runtime override is $GIT_HOOKS_BIN — one variable able to redirect which
binary executes on every commit is enough surface.
Pin a version, or install elsewhere:
AMONT_VERSION=v1.0.0 AMONT_BIN_DIR=~/bin \
curl -fsSL https://raw.githubusercontent.com/fredericrous/amont/main/install/install.sh | sh
Without the installer
Download an archive and its checksum from
Releases. Prebuilt
targets: x86_64-unknown-linux-gnu, x86_64-unknown-linux-musl,
aarch64-unknown-linux-gnu, x86_64-apple-darwin, aarch64-apple-darwin,
x86_64-pc-windows-msvc.
Or build it:
git clone https://github.com/fredericrous/amont.git
cd amont && cargo build --release
./target/release/amont install
Or from crates.io, which builds the same source:
cargo install amont
Or with Homebrew:
brew tap fredericrous/tap
brew trust fredericrous/tap
brew install amont
The brew trust line is Homebrew's policy for every tap outside its own core,
not something specific to this one — a formula is code, and Homebrew now asks
you to decide about running it explicitly. Which is the same argument this tool
makes about a repository's declared checks.
As a project dependency (npm)
For a JavaScript project, the binary can travel with the repository instead of with the machine:
npm i -D amont # or: pnpm add -D amont
npm pkg set scripts.prepare="amont init"
npm install # prepare runs; the hooks appear
Anyone who then clones the repository and runs npm install gets the hooks.
That is §2 done for them, by the package manager, once —
which is the whole point of the shape.
Six prebuilt platform packages are declared as optionalDependencies
(@amont-hooks/darwin-arm64, @amont-hooks/linux-x64-gnu, …), each carrying
os, cpu and libc, so npm and pnpm install exactly one and skip the other
five. They live under a scope so that adding a target later cannot be refused
by npm's spam heuristic, which rejects unscoped names on their shape alone —
it turned down an unscoped amont-agent-win32-x64 while accepting
amont-agent-linux-x64-musl beside it. There is
no postinstall, deliberately: npm ci --ignore-scripts is a normal
hardening choice, and a package that quietly installs nothing under it would
fail much later, as amont: not found from a git hook.
The bin npm links is a small JS shim, because a linked bin has to live
inside the package it belongs to. It is not on the hook path: amont init
bakes the native binary's path into .git/hooks, so node runs once during
prepare and never on a commit.
If your platform is not one of the six, npm install will succeed and the shim
will say so the first time it runs. cargo install amont builds the same
source anywhere Rust does.
init and install are different verbs
amont init wires up one repository and nothing else. It does not copy a
binary into ~/.local/bin, does not touch ~/.config/git/git-templates, and
never prompts — all three of which install does, and all three of which are
wrong for something that runs on every teammate's npm install. The trust
prompt is the sharp one: it reads /dev/tty, so in a terminal it would block,
and npm install would hang on a question about a manifest nobody has read.
Outside a git repository init exits 0 in silence, because npm install
legitimately runs from a tarball, inside a Docker build and in CI. It stays loud
about everything else.
If anything ever installs without your dev dependencies
npm runs prepare on npm ci too — including npm ci --omit=dev, which is
the usual second stage of a Dockerfile. amont is a dev dependency, so it
is not there, and prepare fails on a command that does not exist. The install
fails with it, and a broken image build is a confusing way to learn this.
Guard it the same way husky's own documentation does:
"prepare": "amont init || true"
Only where you need it. A repository with no production-install path should
keep the bare amont init, so a real failure — a hook it may not overwrite, a
core.hooksPath another tool owns — is loud rather than swallowed. || true
buys nothing there and hides something.
init already handles the other half of a Docker build on its own: the
builder stage has a package.json and no .git, which is the silent exit 0
above.
On turning hooks on from a package manager
The installer says, and means, that it does not turn any
hooks on. A prepare script plainly does. The two are not in tension, and the
distinction is worth stating rather than leaving to be reconciled:
- the machine still decides nothing implicitly. Installing this tool, by any route, activates nothing anywhere;
- the repository opts in, once, by committing a line to its own
package.json— a reviewable change, in the open, that a reader can see; - what arrives is still five legible files in
.git/hooks. A colleague who has never heard of this tool cancat .git/hooks/pre-commitand see what runs, which is exactly the property that made us refusecore.hooksPath.
Requirements
Git 2.31+. git rev-parse --path-format=absolute landed in 2.31, and three
places depend on it: install.rs resolving the hooks directory,
hooks/common.rs finding the git common dir, and hooks/python_tools.rs
locating a linked worktree's main .venv. On an older git those return nothing
rather than failing loudly, which is the worst possible shape for a version
floor — the tool appears to work and quietly resolves the wrong paths.
Nothing else. Each check brings its own tool requirement only where you have
opted into that check: a repository with no ruff.toml never needs ruff.
2. Turn hooks on
Per repository — the default
cd <your-repo> && amont install
amont list # what would run here, and why not
That writes six shims into .git/hooks — pre-commit, pre-push,
commit-msg, prepare-commit-msg, post-commit, post-rewrite — each of
which resolves the binary at run time and dispatches into it. post-rewrite
only starts a background rehearsal after a rebase, and only where
amont.rehearseOnCommit asks for one. post-commit is the bookkeeping
half of moving a gate entry to commit time:
it records that the moved check actually ran, so the push gate can trust the
event rather than the declaration. Nothing runs in any repository you did not do this
in.
Across many repositories at once:
amont-fleet install --root ~/Developer
amont-fleet # report the fleet
amont-fleet tui # the dashboard
amont-fleet fix --root ~/Developer # what drifted (dry run)

It answers the questions a directory full of repositories accumulates: which
repos are covered, which shims went stale after an upgrade, and which
repository is quietly carrying a hook.skip somebody forgot. Design record:
the fleet dashboard.
amont-fleet is a separate binary on purpose: it pulls ratatui, crossterm and
serde, and keeping the two apart is what stops "I wanted the dashboard" from
becoming "every commit now depends on a TUI library". The one-line installers
put both binaries in place; from crates.io it is its own
cargo install amont-fleet.
When another tool already owns the hooks
core.hooksPath redirects hook dispatch, and husky sets it. In a repository
that runs husky, git reads .husky/_ and never looks at .git/hooks at all —
so an install that wrote there would produce six files git never runs, and one
that wrote to .husky/_ would hand them to a directory husky's own prepare
regenerates on the next npm install.
Both are refused, by name:
✗ git dispatches hooks from /repo/.husky/_, not /repo/.git/hooks
`core.hooksPath` is set, so husky owns the hooks here. Shims
written to either directory would be overwritten or never run.
Hand dispatch back first: git config --unset core.hooksPath
--force does not move it. That flag means "that file is mine to replace"; it
has never meant "write where git does not look".
This is not a blanket objection to core.hooksPath. A repository that
deliberately keeps its hooks in, say, tooling/hooks under version control has
chosen a location, not handed dispatch to something that will overwrite it, and
is installed into exactly as before. The refusal needs evidence: either the
destination belongs to a hook manager that regenerates it, or our shims are
already sitting in the repository's own hooks directory — which means amont was
installed here and something later took dispatch away.
amont uninstall deliberately does not refuse. Versions before this check
wrote shims into whatever core.hooksPath named, so it has to be able to reach
files that are already there.
--force, and what it will not do
amont install --force replaces a hook the installer would otherwise
refuse:
- one that is present but carries no marker of ours — somebody else's hook, or one you wrote;
- one that is a symlink, where writing normally would rewrite whatever it points at.
Without the flag, install names every such file and writes none of them.
--force is how you say the file is yours to replace, and the output then
names what it took rather than only counting what it wrote.
Two refusals --force does not override:
- A tracked file is never written. That is source belonging to a checkout, and it is the guard this project got wrong twice.
- A path that is a directory or a device is refused whatever you pass. That
is not "a hook that is there", it is a sign something else is going on, and
refusing costs you one
rm.
Neither ever deletes a hook it did not write. A pre-commit-* or pre-push-*
file in .git/hooks without our marker is reported and left exactly where it
is. amont-fleet --remove-unrecognized opts into removing them, and is
spelled that way rather than --remove-stale because "stale" means our own
retired shims, which are a different thing entirely.
Everywhere, forever — a real opt-in
$ amont enroll
One command: it puts the binary somewhere stable, populates the template
directory, and points init.templateDir at it — refusing, loudly, if that
key already points somewhere else. Add --conventions declared on a machine
that also clones other people's projects, so those get only the safety net;
the full story is Rolling out to a team. The manual
spelling, for anyone who prefers to see every write:
mkdir -p ~/.config/git
git clone https://github.com/fredericrous/amont.git ~/.config/git/git-templates
git config --global init.templateDir ~/.config/git/git-templates/templates
git config --global commit.template ~/.config/git/git-templates/message
Git copies that directory into .git/hooks on every init and every
clone, so from then on every repository you clone runs these hooks without
being asked again. That is the convenience, and it is worth having: you never
forget to install, and amont-fleet never shows you an uncovered repo.
It is also a standing grant, and worth stating in full. A cloned repository can
declare its own checks in amont.conf. With this key set, those
declarations are present in every repository you clone — including one you
cloned only to read — and are one amont trust away from running. They do
not run before that; see the trust model. But if you set this
key, trust deliberately, rather than letting installation be the moment you
decided:
amont trust # show what this repo declares, and accept it
amont trust --show # what is trusted here
Full reasoning: index fidelity and run modes §0.
3. Turn them off again
amont uninstall # this repository
amont uninstall --binary # …and remove the binary from ~/.local/bin
amont-fleet uninstall --root ~/Developer
Uninstall removes our six shims and nothing else. A hook you wrote
yourself is left alone and named in the output, whatever it is — a hook it
cannot even read is named too, rather than passed over in silence. hook.skip
and amont.severity are never touched, because those are your statements
about your repository, not ours.
It does forget its own bookkeeping, and says which parts it forgot: the
gate stamps and attestation notes, the bypass ledger, the version-skew
marker, the known-identity memo — each of which only ever said "amont
checked this", which stops being true once the hooks are gone — and the
amont.conf trust record. That last one is a deliberate choice: trust is
consent for a committed file to run commands, and uninstalling is a request
to stop running them, so a later reinstall asks again rather than silently
re-honouring a review from a year ago. Re-granting is one amont trust,
which shows you the file. amont-fleet uninstall now does the same sweep in
every repository it disarms, instead of leaving that bookkeeping behind in
all of them.
What no uninstall ever touches is work you have not committed: an
interrupted run's parked changes stay in $GIT_DIR/amont-held, and
amont restore keeps working after the hooks are gone.
It also takes the shims back out of the template directory, and — if
init.templateDir is still set — says so loudly, with the command to unset it.
Without that, an uninstall you believed had finished would leave every future
git clone re-installing the hooks. A template directory that is itself a
checkout of this repository is never deleted from; those files are tracked
source and belong to the checkout.
This is also why the documentation never tells you to run
rm $(git rev-parse --git-dir)/hooks/*. That glob deletes every hook in the
directory — including ones other tools installed and ones you wrote — in order
to remove six files that belong to us. amont uninstall exists precisely
so that removing our hooks never means removing yours.
For bypassing a single commit, or disabling one check without uninstalling anything, see opting out.
4. Keeping it up to date
Ordinary binary updates need nothing per repository: every shim points at the one binary, so replacing the binary reaches every repo at once.
curl -fsSL https://raw.githubusercontent.com/fredericrous/amont/main/install/install.sh | sh
Re-installing is only needed when the shim set changes — a hook added, removed or renamed:
cd <your-repo> && amont install # re-bake the shims here
amont-fleet fix --root ~/Developer # or see what the whole fleet needs
amont-fleet fix --apply --root ~/Developer
Windows
Everything works, with one setup difference: there is no symlink.
(The amont trust prompt reads the console directly — CONIN$, the
platform's /dev/tty — and Ctrl-C mid-check restores your parked
unstaged changes before dying, both same as unix.)
The one-liner for this platform is PowerShell:
irm https://raw.githubusercontent.com/fredericrous/amont/main/install/install.ps1 | iex
It writes amont.exe and amont-fleet.exe into
%USERPROFILE%\.local\bin, which is where the shims look — they try both
amont and amont.exe there, so a binary in that directory resolves
even in a shim whose path was never baked. CI runs this script on a real
Windows runner against a real published release, because a documented install
path nobody executes is one you find out about from a bug report.
To build it yourself instead — Git for Windows ships bash and coreutils but
not make:
cargo build --release
./target/release/amont install
On macOS and Linux ~/.config/git/git-templates is usually a symlink to the
checkout, so init.templateDir can point at a stable XDG path. Windows does
not create symlinks without Developer Mode or elevation, so point git straight
at the checkout:
git config --global init.templateDir 'C:/path/to/amont/templates'
Nothing else changes. The shims never need the symlink: they resolve the binary
at run time, trying $GIT_HOOKS_BIN, the baked path, ~/.local/bin/amont
and ~/.local/bin/amont.exe, then PATH. The installer detects the .exe
suffix on its own.
Rolling out to a team
Hooks only protect the machines that installed them. One person with amont has one protected machine; a team has sixty, plus new hires, plus the laptop that got reimaged on Tuesday. This page is the whole story of closing that gap, and it is honest about the one thing git will not do.
The constraint nobody gets to skip
Git deliberately runs nothing on git clone. A repository cannot install
its own hooks into your machine, and any tool that made it look otherwise
would be a supply-chain attack with good ergonomics. So "the repo declares
it, the clone self-installs" needs a machine-side grant somewhere —
the only question is what shape the grant takes and how often somebody
has to type it.
Three shapes exist, and they compose:
| shape | typed how often | covers |
|---|---|---|
"prepare": "amont init" in package.json | never (npm types it) | JS repositories, on npm install |
amont enroll | once per machine | every future git clone and git init |
amont init | once per repository per machine | that one clone |
The machine grant: amont enroll
$ brew install fredericrous/tap/amont # or the curl installer, or cargo
$ amont enroll --conventions declared
enroll does three things amont install has always done one repository
at a time — puts the binary somewhere stable, populates the template
directory, and (the part install deliberately left to the user) points
init.templateDir at it. From then on every git clone and every
git init on that machine arrives with the shims already in
.git/hooks, resolving whatever amont binary the machine has. Upgrading
the binary upgrades every repository at once; nothing is re-run per
clone.
It refuses to overwrite an init.templateDir that already points
somewhere else — something else installs hooks on that machine, and
silently disabling it is the husky failure one level up. And it is
idempotent: re-running it is a no-op that says so.
Two lines in the onboarding doc — install the binary, amont enroll —
replace a per-clone ritual forever. Repositories cloned before the
grant are the one thing it does not reach: amont init wires one,
amont-fleet install --root ~/work wires all of them.
The repository declaration: amont.conf, and --conventions declared
The objection to a standing grant is real: the same machine clones the team's services and upstream open-source projects, and those did not agree to your commit-subject shape, your branch naming, or your auto-rebase. A grant that imposes house rules on somebody else's repository is a grant people revoke.
--conventions declared (or git config --global amont.conventions declared by hand) splits the checks in two:
- The safety net runs everywhere: merge-conflict markers, leaked
secrets, oversized files, and debug leftovers (
debugger,dbg!(…),breakpoint()) in the diff you are committing. These are mistakes in any codebase, with near-zero false positives, and catching them in an upstream clone is a favour to the upstream. - The conventions wait for a declaration: commit-message shape,
branch patterns, lint and format gates, test suites, audits,
auto-rebase. They run only in a repository that has committed an
amont.conf— even an empty one:
$ echo "# this repository subscribes to amont" > amont.conf
$ git add amont.conf && git commit -m "chore: declare amont"
Presence is the declaration; presence executes nothing, so it needs no
trust decision. What the file says — declared checks, tool pins — stays
trust-gated exactly as before. A held-back stage says so in
one line (N convention check(s) held back … the safety net still runs)
rather than silently doing less, and amont list reports the state in
text and in --json ("conventions_apply").
The default is everywhere: nothing changes for anyone until a machine
opts into declared.
The team recipe
- Each machine, once (onboarding doc, two lines):
$ brew install fredericrous/tap/amont # pick your installer $ amont enroll --conventions declared - Each repository, once ever (committed, travels with the clone):
- commit an
amont.conf— empty declares; custom checks, committed policy (severity/skiplines for the built-ins) andtoolpins can come later; - JS repositories additionally get
"prepare": "amont init"so even an unenrolled machine is covered bynpm install.
- commit an
- Repositories cloned before enrollment:
amont initin one,amont-fleet install --root <dir>for all of them.
New hire day one: install, enroll, clone — protected. No per-clone step, no per-repo step, nothing to forget.
Step 0, before any of it: trial it at warn
The recipe above imposes thirty-odd opinions your team did not pick, and the
honest risk is not that a check is wrong — it is that two of them are wrong
for you, people start reaching for --no-verify, and the habit generalises
to the checks that mattered. A guard that teaches its own bypass is worse than
no guard.
So measure first. On your own machine, in a repository you work in daily:
git config amont.severity.pre-commit warn
Everything runs and reports; nothing blocks. A fortnight later, amont list
tells you what a rollout would have felt like:
problems that did not block
pre-commit-usual-name 61 last 2h ago
pre-commit-ban-terms 4 last 1d ago
66 events over 41 commits, since 12d ago
Then decide per check, with evidence, before anyone else is affected —
amont.severity.<check> warn keeps the ones you want advisory. A check firing
sixty times in a fortnight is a conversation to have with your team, not a
default to inflict on them.
The ledger is local to the machine that recorded it, so this measures your habits, not the team's. That is a real limit of the no-telemetry promise, and the workable version is to ask two or three colleagues to run the same trial and compare — not to look for a fleet-wide number that deliberately does not exist.
What this does not solve
- Hooks remain advisory.
--no-verifystill works, deliberately, and is counted rather than prevented. The guarantee lives in CI, not on laptops; put the same checks there and the hook becomes the fast feedback, not the enforcement — the CI backstop ships copyable workflow templates for exactly that. - Version skew. Enrolled machines resolve whatever binary they have;
two teammates on different amont versions run different check sets until
a shim is newer than a binary (which warns). Commit the floor:
set minVersion 1.11.0inamont.confmakes every older binary say so on each commit — warn-only, but no longer silent. Pin the exact version in your installer of choice if you need more than a floor. - Machines that never enrolled. The fleet dashboard sees one machine's checkouts. A teammate who skipped onboarding is invisible — which is one more reason the real backstop belongs in CI.
The CI backstop
Hooks are advisory by construction: --no-verify and hook.skip are each
one command away, deliberately, and a teammate whose machine never enrolled
runs nothing at all. So the question "what actually stops a bad commit
reaching the default branch" has one honest answer, and it is not a hook —
it is CI, where nothing can be skipped from a laptop.
amont does not run in CI — on purpose
The obvious move would be an amont run step, or a marketplace action.
Deliberately not. amont's job is local ergonomics: one binary
orchestrating many tools with scoped selection, index fidelity, live
progress, and escape hatches a human sitting at the keyboard is entitled
to. CI wants none of that — it wants the real tools, called directly:
- failures attribute to the tool that found them, in the tool's own words, with the platform's log folding and annotations;
- each tool gets first-class caching, matrices, and versions pinned by the workflow, not by whatever binary a runner happens to have;
- there is no second opinion to keep in sync: when
cargo clippydisagrees between laptop and CI, that is a toolchain version question, not an amont question.
The checks that exist only inside amont — ban-terms, the secrets scan,
large-files, merge-conflict, the commit-message and branch-name
conventions, pull-rebase — are deliberately not reproduced in CI. They
are local ergonomics (catch it before it exists) or they have a better
server-side answer (GitHub's own push protection and 100 MB limit,
platform merge tooling). Losing them in CI loses nothing the tools below
don't already guard: a debugger; that slips past the local hook still
has to survive the test suite and review.
One thing this repository points CI at does run there, and it is deliberately
not amont: attest, a
single-purpose verifier that reads a signed note and reports which checks it
covers. It runs no checks and has no opinions to keep in sync — it verifies a
document about work that already happened, which is the opposite of the
second-opinion problem above. It lives in its own repository precisely so this
rule can stay written as it is; amont is still not installed on any runner.
The templates
Copy the file for your stack into .github/workflows/ (GitHub) or
.forgejo/workflows/ (Forgejo), then prune the steps your repository does
not use. Each step is annotated with the amont check it mirrors, so the
local and CI stories stay legible against each other.
Or fetch one directly:
$ curl -fsSL https://raw.githubusercontent.com/fredericrous/amont/main/templates/ci/github/rust.yaml \
-o .github/workflows/checks.yaml
What maps where
| amont check (local) | CI step |
|---|---|
pre-commit-cargo-fmt | cargo fmt --all -- --check |
pre-commit-clippy | cargo clippy --workspace --all-targets --all-features -- -D warnings |
pre-push-cargo-test | cargo test --workspace --all-features |
pre-push-audit-rust | cargo audit |
pre-commit-lint-js | npx --no-install eslint --max-warnings 0 . |
pre-push-run-tests-js | npm run typecheck / test:unit / test --if-present |
pre-push-audit-js | npm audit / pnpm audit, per lockfile directory |
pre-commit-ruff / pre-commit-pyright | ruff check . / pyright --warnings |
pre-push-pytest | pytest |
pre-push-audit-python | pip-audit -r requirements.txt |
pre-commit-gofmt / pre-commit-go-vet | test -z "$(gofmt -l .)" / go vet ./... |
pre-push-go-test | go test ./... |
pre-push-audit-go | govulncheck ./... |
ban-terms, secrets, large-files, merge-conflict, commit/branch conventions, pull-rebase | deliberately local-only — see above |
One shape carries over exactly: the audits. Locally they warn on a branch
push and block a v* tag push; the templates express the same split
natively with continue-on-error: ${{ !startsWith(github.ref, 'refs/tags/v') }} — advisory red on branches, a hard failure when a
release is leaving the building. That mirrors what this repository's own
release workflow enforces for itself.
Keeping the two in step
The hook is the fast feedback; CI is the same verdict, slower and
unskippable. When they disagree, it is almost always a tool version —
pin the versions your workflow installs, and consider a
tool pin in amont.conf so the
laptop warns when it drifts from what CI runs.
Skipping what pre-push already proved
"CI is the backstop" does not require CI to repeat work it can verify
happened. When pre-push has just run the suite against the pushed tree
and every block gate passed, re-running the identical suite on the
identical tree buys a second copy of the same answer — real money on a
resource-constrained runner fleet. So amont can leave a receipt:
ssh-keygen -t ed25519 -N "" -f ~/.ssh/amont-attest # once, per machine
git config amont.attest true # per repository
With the toggle on, a push whose pre-push block gates all passed writes a
note on each pushed tip in refs/notes/amont-attest and pushes that ref
alongside the branch. The note is a four-line payload plus an SSH
signature over exactly those bytes:
amont-attest-v2
tree <the tree the gates ran against>
gates pre-push-cargo-test pre-push-audit-rust
platform aarch64-macos
amont 1.9.0
-----BEGIN SSH SIGNATURE-----
…
-----END SSH SIGNATURE-----
This does not repeal "amont does not run in CI". CI still never
executes amont — the templates' attest step is
fredericrous/attest, which needs
nothing but git and ssh-keygen, and skips a test step only when all four
hold:
-
the signature verifies against
.forgejo/allowed_signers(or your platform's path), a file committed in the repository, whose entry is pinned to theamont-attestnamespace:you@example.com namespaces="amont-attest" ssh-ed25519 AAAA… -
the attested tree is byte-for-byte the tree CI checked out — not the commit hash: a reword keeps its attestation, a single changed byte loses it, and a PR merge commit whose tree drifted from the tested tip never skips;
-
the step's own gate is named in
gates. The list holds only checks that PASSED —WarnedandUnavailablenever appear, because "could not run" is not "passed" — so a CI step with no local mirror (an e2e suite, an image build) is never skippable by construction; -
the attestation's platform is the platform asking. A pass is a pass on something.
A matrix asks the fourth question for you
cargo test green on an arm64 Mac is no evidence at all about the Windows
leg of a matrix — and a mechanism that let one laptop retire three
platforms' CI would be the unsound version of this whole idea. So the note
records where the suite ran, and amont attest covered defaults to
requiring that platform to equal the verifier's own:
| leg | attestation says | outcome |
|---|---|---|
macos-latest | platform aarch64-macos | skips — that suite really ran here |
ubuntu-latest | platform aarch64-macos | runs |
windows-latest | platform aarch64-macos | runs |
No per-leg configuration: every leg runs the same one-liner and only the
matching one finds anything covering it. amont's own CI is the worked
example — a laptop push retires the macOS leg's cargo test and leaves
the other two exactly as they were.
For a suite whose result genuinely does not depend on where it ran — a pure-JS unit run, most pytest suites — the workflow says so, once, in a line that is committed and reviewed like any other:
- id: attest
uses: fredericrous/attest@v1
with:
platform: any
That is a claim about the suite, so make it where the suite is defined rather than on the signing side: the machine holding the key should not get to decide that its results travel.
Reading the result
The action publishes two outputs. Use gates:
- run: cargo test --workspace
if: ${{ !contains(fromJSON(steps.attest.outputs.gates), 'pre-push-cargo-test') }}
gates is a JSON array, and contains over an array matches elements.
The other output, covered, is the same names as a string, where contains
matches substrings — so a gate named pre-push-cargo-test-slow satisfies a
check for pre-push-cargo-test and skips the real suite. covered is kept
only for workflows written against the older inline templates; new ones should
not use it.
Everything is fail-open, and the action says why it found nothing rather than leaving you guessing:
attest: attested on aarch64-macos, this leg is x86_64-linux
attest: no attestation found for tree 9f2a1c…
If you would rather not depend on an action, the same verifier is a single
shell script — verify.sh
— and amont attest covered still does the same job locally for CI that is
neither GitHub nor Forgejo. Both answer to the format in
SPEC.md.
Origin is the source of truth. amont attest covered treats the local
refs/notes/amont-attest as a mirror of origin's: deleting that ref on
origin revokes every attestation it held — the next covered in any clone
deletes its copy and prints how to undo that — and an origin that cannot be
reached covers nothing, with the reason on stderr. The spec in
.github/attest-inputs may mark a path with a leading ? (?build.rs): it
may be absent, and the gate re-runs the day it appears.
What signs is the machine that ran the tests, so the trust statement is
exactly "whoever holds amont.attestKey vouches for this tree" — the same
trust you extend by pushing at all when you are the only committer. On a
team, that key is a shared authority: hand one to each developer (one
allowed_signers line each) or accept that any holder can mint "tests
passed". And the failure doctrine is the gate stamps' own, one direction
only: no note, an unknown format version, a foreign or tampered note, a
mismatched tree, a signer CI never heard of — every one of them reads as
"no attestation", and no attestation means CI runs the tests. The
mechanism can only ever save a redundant run; it cannot skip a check that
did not happen. --no-verify skips pre-push entirely, mints nothing, and
CI quietly does the full job — which is the backstop doing exactly what it
is for.
Skipping lint: tree gates
Test gates are signed from pre-push. Lint and format are different: the
pre-commit linters see staged files only, so their pass says nothing about
the whole tree. A tree gate (a tree line in amont.conf) runs the
whole-tree command at commit, beside the other checks, and its pass is
attested as tree-<name>:
- id: attest
uses: fredericrous/attest@v1
with:
anywhere: tree-eslint # formatting and lint do not depend on the platform
- name: Lint
if: ${{ !contains(fromJSON(steps.attest.outputs.gates || '[]'), 'tree-eslint') }}
run: npm run lint
Command parity
An attestation names a gate, and a gate is worth only the command behind it. So the CI step must run exactly the gate's normalized declaration:
- the declared command;
- with
{cache}removed; - then a dangling trailing
--removed; - then whitespace collapsed.
tree eslint eslint * attest npm run lint -- {cache} requires
run: npm run lint. Tool resolution is part of the command
(uvx ruff@0.16.0, uv run, npm run), so CI and the laptop resolve the
same tool. One gate proves one command: two CI steps (ruff check and
ruff format --check) are two gates.
amont tree-parity (and the pre-commit-tree-parity check) enforce this. It
fails closed on a gated step it cannot read with certainty:
- a block scalar, an anchor or alias, a quoted form with escapes, or
${{ }}inrun:; - a step-level
env:orshell:, or aworking-directory:the gate does not declare ascwd=; - any
env:ordefaults:the job or workflow hands down.
The one inherited setting it accepts is defaults: run: shell: bash, and only
for a simple command: no pipe, list, redirection or substitution. There,
bash's -e and pipefail cannot change the verdict. Run
amont tree-parity as an ungated CI step, so a drift committed with
--no-verify still fails.
The checks
Six git hooks are installed — pre-commit, commit-msg,
prepare-commit-msg, post-commit, post-rewrite, pre-push — and behind them thirty-nine
named checks, plus
any your repository declares in amont.conf.
This page is the catalogue. It is not the answer to "what will run in my repository" — for that, ask:
amont list # here, and why not
amont list --stage pre-push # one trigger
amont list --all # the inert ones too, one row each
amont list --json # the same, machine-readable
Most checks are inert in most repositories, by design. A check fires only
when the commit touches files it understands and the repository carries the
configuration that opts into that tool. A JavaScript repository never invokes
cargo; a repository with no ruff.toml never needs ruff.
That is why amont list leads with what actually runs and gives the rest a
line:
15 active here. 23 inert (Go, JavaScript, Kubernetes, Python, Rust) — amont list --all
Even in a repository amont serves well, about half the checks are inert; in one
built on a stack it does not cover yet, two thirds. Printing all of them
interleaved meant a Terraform shop read thirty rows naming Rust, Go, Python and
JavaScript — which says "this tool is for other people" when the truth is the
opposite: the checks that were running are secrets, large-files and
merge-conflict, the ones that prevent incidents in any repository at all.
--all prints the condition each inert check is waiting on, which is still the
answer to "why is clippy not running here". Skipped (⊘) and unusable (✗)
checks are never collapsed — somebody silenced the first and broke the second,
and a count is the wrong shape for either.
Reading the table
- id —
<trigger>-<name>. Any of the three spellings (full id, short name, trigger) can address it inhook.skipandamont.severity; see configuration. - fires when — the scope.
alwaysmeans it has no file condition. - fixes — the check can rewrite the file rather than only complain. Those rewrites are staged; see run modes.
- Linters with a warning class run at zero warnings: eslint gets
--max-warnings 0, yamllint--strict, pyright--warnings, and clippy has always run with-D warnings. A warning that exits 0 is a list nobody is forced to read — a human scrolls past it, an agent reads "passed" and moves on — so a finding either blocks or it does not exist. A repository that wants the old behaviour downgrades the check:git config amont.severity.lint-js warn. - Checks marked soft warn and skip when their tool is missing, rather than blocking a commit, because CI is the hard gate and not every developer has every toolchain installed.
--json, the machine contract
amont list --json is what an agent or a script reads instead of parsing the
human table, so its field names are a contract rather than an implementation
detail. Every document it prints declares which contract it is:
{"format": "amont-list-v1", "stage_filter": null, "pushed": false, "checks": [...]}
Assert that format before reading anything else, the same way this tool
refuses a gate stamp or an attestation whose version it does not know. The
version changes when a field's MEANING changes or a field is removed; adding
a field does not change it, which is why the top level is an object rather
than a bare array.
Envelope: format, stage_filter, pushed, checks, commit_style,
branch_style, bypasses, downgrades, conventions_apply.
Each entry of checks:
| field | what it says |
|---|---|
id | <trigger>-<name>, the full spelling |
short_name | the name without its trigger |
stage | which trigger runs it |
source | builtin, or declared for an amont.conf check |
declared_severity | what the check ships as |
effective_severity | what it is here, after overrides |
severity_overridden | whether those two differ |
severity_source | config or policy when overridden, else null |
fix | whether it can rewrite the file |
status | ready, inert, skipped, unavailable |
reason | why, in the words the text view uses |
scope_files | extensions it fires on ([] means always) |
scope_opt_in | files whose presence opts the repository in |
command | the command a declared check runs, else null |
A field named here and absent there — or the reverse — is a bug this page's
own test fails on, because a reader who guesses a field name gets null
rather than an error, and null reads as a perfectly plausible answer.
commit-msg
Validates the summary line and reformats the message. --no-verify skips it
(git's rule); no hook.skip or severity override names it.
Validates: a subject is present and at most 72 characters; it carries a
conventional type prefix; a description follows the
prefix; the description is at most 50 characters. Messages git itself writes
(Merge …, Revert "…", fixup!/squash!/amend!) pass through unjudged.
Formats: hard-wraps the body at 72 columns, groups the trailing footers with one blank line before them, and places the type's gitmoji wherever you asked for it — nowhere, by default.
Every number above and the gitmoji placement are amont.commit.* settings;
amont setup walks them. See
if the defaults do not fit.
prepare-commit-msg
Appends the issue id found in the branch name to the footer: JIRA first
(ABC-1234), else a bare Kanbanize id (1234).
Only for a commit you are authoring. -m, -t, a merge, a squash and
--amend all pass a source in $2 and are left alone.
The safety net vs the conventions
With git config --global amont.conventions declared — the mode
amont enroll offers for machines that also clone other
people's projects — the checks split in two. Five are the safety net
and run in every repository, declared or not: merge-conflict,
large-files, both secrets checks, and ban-terms — findings that are
mistakes in any codebase, with near-zero false positives. Everything else,
the commit-msg/prepare-commit-msg hooks included, is a convention
and runs only where the repository commits an amont.conf. The default
mode, everywhere, keeps the distinction inert.
pre-commit
All twenty-two run concurrently, and a panic in one is isolated so the other twenty-one still report.
| id | fires when | what it does |
|---|---|---|
pre-commit-agents-md | AGENTS.md/CLAUDE.md carry the amont markers | The generated guidance block is behind the amont that would generate it now — an agent reading it follows last release's instructions. Warns with the amont agents-md fix; with amont.fix true regenerates and re-stages. Silent without the markers (opt-in), and during merge/rebase/cherry-pick. Never blocks. fixes |
pre-commit-argo-lint | .yaml .yml + kustomization.yaml/.yml | Argo CD app lint. soft |
pre-commit-ban-terms | .js .jsx .ts .tsx .vue .rs .py | Refuses focused/debug leftovers in staged sources — describe.only, fit(, debugger in JS/TS, dbg!( in Rust, breakpoint() and pdb.set_trace() in Python. Scoped to what this commit touches, and re-checked against staged content with each language's comments and string literals blanked (a term named in prose is discussion, not code — and an f-string interpolation or template substitution is code, not prose). |
pre-commit-branch-pattern | always | Says at the first commit what pre-push-branch-pattern will refuse at push time, with the git branch -m fix — while renaming costs nothing. Quiet on a detached head, in a remoteless repository, and on any branch a remote already has. Never blocks. |
pre-commit-branch-protect | always | Says at commit time that a commit landing on main/master will be refused by pre-push-branch-protect, with the git switch -c fix — while moving it costs one command and nothing is stacked on it. Quiet on a detached head and in a remoteless repository. Never blocks. |
pre-commit-cargo-fmt | .rs + Cargo.toml | cargo fmt. fixes |
pre-commit-clippy | .rs + Cargo.toml | cargo clippy |
pre-commit-go-vet | .go + go.mod | go vet ./..., per touched module. |
pre-commit-gofmt | .go + go.mod | gofmt, handed exactly the staged files. fixes |
pre-commit-kube-linter | .yaml .yml + .kube-linter*.yaml/.yml | kube-linter. soft |
pre-commit-kubeconform | .yaml .yml + kustomization.yaml/.yml | Schema-validates rendered manifests. soft |
pre-commit-lint-js | .js .jsx .ts .tsx .vue + package.json | ESLint at zero warnings (--max-warnings 0), only in repos that carry an eslint config. |
pre-commit-lint-json-yaml | .json .yaml .yml | Parses staged JSON/YAML so a syntax error never reaches the repo. soft |
pre-commit-manifest-trust | amont.conf | Blocks a commit that changes amont.conf while the checks it declares are untrusted — otherwise they stand down as "could not run" for exactly the commit that introduces them. Names the fix (amont trust); never trusts anything itself. Not during a merge, rebase, cherry-pick or revert: a manifest arriving from another branch stays a gap. |
pre-commit-merge-conflict | always | Refuses staged files still carrying conflict markers. |
pre-commit-package-lock | package.json | Keeps package.json and its lockfile in step, scoped per directory — one project's lockfile does not satisfy another's in a monorepo, and a package.json with no lockfile beside it never demands one. |
pre-commit-prettier | a prettier config is present | Format check. fixes |
pre-commit-pyright | .py .pyi + pyrightconfig.json/.jsonc/pyproject.toml | Type check. |
pre-commit-ruff | .py .pyi + ruff.toml/.ruff.toml/pyproject.toml | Lint and format. fixes |
pre-commit-tree-parity | amont.conf, *.yml, *.yaml (repositories with amont.conf) | Every tree gate must be matched by the workflow step skipped on it: the step's run: equals the gate's normalized command. Fails closed on what it cannot read with certainty. See the CI backstop. |
pre-commit-usual-name | always | Warns the first time you commit under a given name/email, so a misconfigured user.name is noticed at commit one rather than commit twenty. Never blocks. |
pre-commit-hadolint | Dockerfile | Dockerfile lint. Matches that basename exactly — Dockerfile.dev and Dockerfile.prod do not, because scope name tokens are exact basenames. |
pre-commit-helm-lint | .yaml .yml .tpl + Chart.yaml | helm lint, once per chart directory the commit touched — resolved by walking up to the nearest Chart.yaml, not once per file. |
pre-commit-shellcheck | .sh .bash | Shell lint. No opt-in file: shellcheck's defaults are the reason to run it, unlike yamllint's. A script with a shebang and no extension is not matched. |
pre-commit-yamllint | .yaml .yml + .yamllint/.yamllint.yaml/.yml | Strict YAML lint, where a repo has opted in. |
Both Python checks prefer the repository's pinned tool over an ambient
latest, in this order: uv run --no-sync (the lockfile-pinned one CI runs) →
the worktree's .venv → the main worktree's .venv (a linked worktree has
none of its own) → PATH → uvx, which is unpinned latest and therefore warns,
because it flags issues the CI-pinned version does not.
Checks that are paused mid-operation
Most content checks do not run during a merge, rebase, cherry-pick or revert: half the tree is somebody else's work and you cannot fix it from inside the operation anyway.
merge-conflict and ban-terms are deliberately not paused. Those are
exactly the checks you want during a resolution commit — leaving a conflict
marker in the commit that resolves a merge is the bug, and importing a banned
term from the other branch is the other one.
pre-push
These run in sequence, cheapest and most decisive first: refuse a forbidden push before validating a name, and validate everything structural before paying for a test suite.
| id | fires when | what it does |
|---|---|---|
pre-push-branch-protect | always | Refuses a direct push to main or master. |
pre-push-branch-pattern | always | Requires prefix/branch-name (e.g. feat/3002-image-crop), unless the branch already exists on the remote. |
pre-push-pull-rebase | always | Rebases the branch onto its own upstream before pushing (then asks for a second push — the first one's refs predate the rebase), and warns — never acts — when the default branch has moved ahead. Never touches a dirty tree, aborts cleanly on conflict, and amont.autoRebase false makes it a pure, networkless advisor. |
pre-push-run-tests-js | .js .jsx .ts .tsx .vue + package.json | Runs each touched JS package's gate: typecheck, test:unit, test, whichever it defines, cheapest first. Skips any of those a pre-commit declaration already covers — see below. |
pre-push-cargo-test | .rs + Cargo.toml | cargo test. |
pre-push-go-test | .go + go.mod | go test ./..., per touched module, against the pushed tree. |
pull-rebase's constraints are load-bearing: rebasing onto the default
branch instead of the branch's own upstream, or autostashing a dirty tree to
do it, are exactly the ways a pre-push hook loses somebody's work — so it
does neither, ever.
The large-file guard — large-files
Git history never forgets a megabyte: an accidentally committed dataset or
bundle is paid for by every clone forever, even after deletion — deleting
adds a commit, it does not remove the bytes. At pre-commit, a staged file
over amont.largeFileWarn MB (default 10) gets a named warning — a large
asset can be deliberate, and this is the moment to decide — and one over
amont.largeFileBlock MB (default 100, GitHub's own refusal line) blocks
with the remedy named: git-lfs, or keep it out of history.
The Python test gate — pytest
cargo-test's contract for the third ecosystem: a repository declaring a
pytest setup (a pytest.ini or a conftest.py — a bare pyproject.toml
is not a promise to test) runs its suite at pre-push against the PUSHED
tree, per ref, for pushes that change Python. Missing pytest or an
unanswering git is Unavailable — loud, never green.
The secrets check — secrets, at both stages
A staged credential is a ten-second fix: unstage it. A PUSHED credential is
not a history problem, it is an incident — the secret is compromised the
moment it leaves the machine, and the remedy stops being git commit --amend and becomes rotation. So this check exists twice:
pre-commit-secretsscans the staged content and blocks — private key headers, cloud access key ids, the well-known API token prefixes (GitHub, Slack, Google, Stripe live keys, npm, OpenAI/Anthropic, Vault).pre-push-secretsscans every line every pushed commit ADDS — including commits made with--no-verify, from other tools, or three commits ago, and including a secret added and removed within the pushed range, because the history being published still carries it. The push is the last moment a secret is recoverable at all.
Detection is curated token shapes, not entropy — entropy heuristics are
where secret scanners get noisy, and a noisy blocker is a blocker people
learn to delete. A legitimate fixture opts out per line with the pragma
amont:allow-secret on the same line: visible in review, greppable, and
narrower than skipping the whole check. Binary files and files over 2 MB
are skipped.
Findings are redacted: the report names the kind and the place
(a private key at config/deploy.pem:1), never the matched text — a hook
that echoes a secret into scrollback and CI logs has widened the leak it
exists to prevent.
The dependency audits — audit-rust, audit-js, audit-python, audit-go
At pre-push, one vulnerability audit per ecosystem the repository uses:
cargo audit (opted in by a Cargo.lock), npm audit (package-lock.json)
and pnpm audit (pnpm-lock.yaml), each in every directory that tracks one,
pip-audit (requirements.txt, or a pyproject.toml project's virtualenv),
and govulncheck ./... (go.sum). No lockfile, no check — an audit without
a resolved tree audits a guess — and no check means nothing said: an audit
the repository never opted into is inert, exactly as amont list reports
it, not a check that "could not run".
The severity is the push's, not the finding's:
-
a branch push with known vulnerabilities gets a named warning — the advisory is information, tomorrow's retry is free, and it tells you now that it will block a release;
-
a push carrying a
v*tag (avfollowed by a digit —v1.2.3,v2; a tag merely starting with the letter v does not count) is a release leaving the building, and known vulnerabilities in what it SHIPS refuse it, with the tool's full report reprinted. A finding only the development tree carries — a build script's toolchain, a test runner — is named and does not refuse the tag, because nobody who installs the release installs it:- JS: the finding is audited again with
npm audit --omit=devorpnpm audit --prod; - Rust: each affected crate must be reached by one of the workspace's own
crates through
cargo tree -i <crate>@<version> -e normal,build --target all --all-features(dev-only only on cargo's own "nothing to print") — build dependencies count, since a dependency's build script runs wherever it compiles; - Python: a
requirements.txtis the production list by convention; a virtualenv's findings are matched againstuv export --frozen --no-dev --all-extras(an optional extra ships: a consumer who asks for it installs it); - Go:
govulncheck ./...runs without-testand reports only what the module's code reaches, so it already audits what ships.
Whatever cannot be attributed — a crate the report does not name, a tree or export that fails, a virtualenv without
uv.lock— still refuses the tag: an unknown is not a pass. An advisory that ships but that nobody can fix yet (no patched version, a path the project cannot replace) passes only under a waiver: a committed.amont-audit-waiversfile, one line per advisory,# id expires reason GHSA-vfj7-8cjw-p6xm 2026-12-31 braces via react-strict-dom; no patched versionreviewed like code, named on every release push it lets through, and void once past its date or when dated more than 90 days ahead — a waiver is a decision to revisit, never an exemption. It matches advisory ids (GHSA, RUSTSEC, GO, PYSEC, CVE, OSV), so a finding the tool reports without an id cannot be waived. Branch pushes never consult it: they never block;
- JS: the finding is audited again with
-
warning-class advisories (unmaintained, unsound) are named and never block, anywhere — a gate nothing can pass is a gate people learn to delete;
-
a tool that is missing or cannot reach its advisory database says so loudly and never blocks: a hook may be offline, and a push gate that fails on a captive portal teaches
--no-verify. If your releases must not ship unchecked, enforce that in CI, where the network is never in question — this repository's own release workflow does exactly that.
The tools' output decides, never the exit code alone: every one of these tools conflates "found vulnerabilities" with "could not fetch the database" in its exit status, and those mean opposite things.
audit-rust names the crate, and what reaches it. An advisory id says
nothing about whether it matters to you, so the warning reads
RUSTSEC-2026-0002 (lru → amont-fleet): the crate the advisory is against,
and which of this workspace's crates depend on it. A finding in an opt-in
tool is a different Monday from one on the commit path, and reading the id
alone meant running cargo tree --invert by hand to tell them apart.
Two details that are answers rather than omissions:
(<crate>, not in the build graph)means exactly that.cargo auditreadsCargo.lock, which records the resolved dependency set with no edge kinds — no dev, no optional, no per-feature — so it flags crates nothing ever compiles. An optional dependency of a feature nobody enabled gets reported, and this says so instead of implying you ship it.- Attribution is a better message, never a gate. A clean audit runs no extra
process at all; the
cargo treecalls happen once per affected crate, only when there is already something to say, and if one cannot run the advisory is still reported without it.
Moving a gate entry earlier
typecheck sits in the push gate because nothing checks it sooner. For some
repositories that is too late — a type error is cheapest to hear about at the
commit that caused it, not an hour later when you go to push.
Move it by declaring it in amont.conf under the name of
the script:
# stage name scope severity command
pre-commit typecheck *.ts,*.tsx block npm run typecheck
pre-push-run-tests-js then drops typecheck from its gate and says so:
✓ typecheck gated at commit instead — not repeating it here
This is the argument that already keeps lint out of the gate — pre-commit
lints staged files, so repeating it on push costs time and catches nothing —
applied to whatever a repository decides to move. It is not typecheck-specific:
test:unit and test work the same way.
And it is not npm-specific. A gate is a NAME declared at both stages —
the vocabulary is yours, not package.json's. Declare the same name at
pre-commit (severity block) and at pre-push, and the commit-time side
earns per-commit stamps the push-time side defers to:
# stage name scope severity command
pre-commit test *.rs block cargo test
pre-push test *.rs block cargo test
A BUILT-IN gate needs only the commit-time half. amont already owns the
push side of cargo-test, pytest, go-test and run-tests-js, so declare
the commit-time twin under the built-in's own short name and stop:
# stage name scope severity command
pre-commit cargo-test *.rs block cargo test
pre-push-cargo-test then defers to its stamps — no second declaration, no
hook.skip, and no repeating a command amont already knows how to run. The
name must be the SHORT one (cargo-test, not pre-push-cargo-test): it is
matched against what you wrote here, and a full id matches nothing.
This is worth reaching for when a suite is fast enough to run on every
commit. When it is not — a four-minute suite is not a per-commit cost anyone
accepts — leave it at push time and rehearse the push instead; see the
next section. The window that matters is the same either way: git opens its
connection to the remote before calling pre-push and holds it idle until
the gate finishes, and a remote may close it first (ssh keepalive does not
prevent that).
A commit-time gate is not repeated for the same tree either. pre-commit
records the gates that ran clean, bound to the tree the commit is about to
seal; post-commit turns that record into the stamp. When the commit never
reaches post-commit — commit-msg refused the subject, the editor was
closed on an empty message — the record is still there, and the next
attempt on the same staged tree reads it instead of running the suite
again:
✓ run-tests-js passed on this exact tree earlier — not repeating it here
The stamp on the tree answers the same way for a commit undone with
reset --soft and made again. Anything that changes the content — one
staged byte, the declaration's own line in amont.conf — is a different
tree and runs the gate; a gate that failed records nothing, so a
rejection is never reused. git config amont.commitStamps false turns the
reuse off.
Rehearsing the push gate
A push-time gate that passes stamps the tips it vouched for — the same
refs/notes/amont-gate record the commit-time gates use, keyed by tree, so
it survives a reword or a rebase that keeps the content. The next push of
that content skips the gate and says so:
✓ pre-push-run-tests-js passed on this exact tree earlier — not repeating it here
Two things fall out of one record:
- A retry after a dropped connection is instant. The gate passed, the
remote closed the idle session while it ran, the push died;
git pushagain sends the same tips, finds their stamps, and is on the wire in seconds. - The suite can run before git connects at all.
amont run pre-pushdrives the same dispatcher with no push in flight — on a branch that has never been pushed it measures againstorigin/HEAD,origin/mainororigin/master— and stampsHEADwhen every block gate passes. Thengit pushholds its connection open for the seconds the transport takes, not the minutes the suite does. An agent that runsamont run pre-pushbefore everygit pushnever meets the idle timeout;amont-agent'spush-preflightrule says so when a push is about to run without one.
Only scoped gates — test suites, whose verdict is a function of the tree —
are stamped or skipped; branch-protect, secrets and the other unscoped
checks ask questions about the push and always run. A stamp is written
only for content the suite actually tested:
- with
amont.testPushedTree, that is the tip itself, for each gate that actually ran in its checkout — the stamp is written from the record of which gates were handed the snapshot, never from the config flag — unless the snapshot could not be made. Aworktree addthat fails, or a preparation that fails (a refusedamont.snapshotCarry, an install, anamont.snapshotPreparethat exits non-zero), falls back to the working tree and says so; that tip is then stamped by nothing, because the suite that passed never saw its content; - in the default working-tree mode it is the tip only when
HEADis the tip and no tracked file was modified when the gate started — captured before any check runs, so a formatter or a snapshot-updating suite cannot disqualify the stamp for work it just did. Untracked files do not count, and that is a known gap rather than an oversight: the file was there while the suite ran and is not in the tree the stamp vouches for, but counting it would mean any repository whose gates leave an artefact (a log, a coverage directory) stopped earning stamps permanently — which puts the suite back inside the push, the failure stamping exists to prevent. Useamont.testPushedTreeto close it; - inside a rehearsal the checkout is the commit, because git made it, so
what preparation added to make it runnable is not a reason to distrust
it: dependencies installed from the commit's lockfile, and only
untracked carried files —
amont.snapshotCarryrefuses anything that would overwrite committed content.
git config amont.pushStamps false turns both the writing and the
honouring off.
Rehearsing in the background
amont run pre-push is still a wait somebody has to remember to start.
amont rehearse starts it for you and gets out of the way:
$ git commit -m "feat: the thing"
rehearsing the push gate in the background (`amont rehearse --status`)
$ git push
✓ pre-push-run-tests-js passed on this exact tree earlier — not repeating it here
With git config amont.rehearseOnCommit true, post-commit spawns a
detached worker — its own process group, output on
$GIT_DIR/amont-rehearsal.log, nothing for git to wait for. The worker
checks out HEAD into a throwaway worktree (the amont.testPushedTree
machinery) and runs the ordinary pre-push dispatcher there, with the branch
and its upstream as the ref line. The snapshot is what makes this safe while
you keep editing: the suite reads a tree nobody is touching, and the stamp
it earns is for exactly that tree. Only the test gates run — branch-protect,
secrets and the auto-rebase ask about a push that is not happening, and
run when it is. A fresh worktree has no node_modules, so the snapshot is
prepared first: the untracked files amont.snapshotCarry names, then each
lockfile's dependencies (amont.snapshotDeps: npm ci / pnpm install --frozen-lockfile / yarn install --frozen-lockfile (--immutable for
yarn 2+) by default, or a clone of your installed tree that the package
manager accepts; a bun lockfile is refused with the fix named), then
amont.snapshotPrepare if set. The worker
registers itself before preparing — --status reports preparing, a
push waits for it, a newer commit cancels it, installer included — and a
preparation that fails is recorded with its reason and fails the rehearsal
rather than leaving nothing behind.
A rebase rewrites every commit it replays, and a stamp vouches for one
commit: after git rebase, nothing covers the branch. git calls
post-commit for each replayed commit while the rebase is still in
progress, and the rehearsal stands down then — the commit being made is not
the one you will push. post-rewrite is the moment the rebased branch
exists whole, and with amont.rehearseOnCommit it starts one rehearsal of
the new tip. (git commit --amend needs nothing extra: its post-commit
already rehearsed.)
A push that arrives mid-rehearsal waits for it rather than starting the
suite over — but not forever. amont.rehearsalWait (default 300s, 0 for
no limit) bounds that wait, because it happens after git has opened its
connection to the remote: holding it open for a wedged worker is the same
failure the rehearsal exists to prevent. When the budget expires the gate
runs here, and the rehearsal is left alone to finish and stamp the tree for
next time.
The stamp is the whole hand-off; there is no second record to keep in step. What the state file beside the log adds is whether someone is still working on it, so a push can choose:
- stamped — the push skips the suite, as above;
- still running — the push waits for the verdict rather than starting
over (
⚠ a background rehearsal of this tree started 2m ago is still running — waiting for it rather than starting the suite over): less remaining work than a fresh run, and no extra CPU; - failed, or died without a verdict — said, with the log's path, and the gate runs again in the terminal you are looking at. A failed rehearsal is never honoured.
Latest wins. A worker that finds another one running on a different tree kills it — the whole process group, suite included — and removes its snapshot; a rebase replaying ten commits does not queue ten suites. One on the same tree is left alone. Nothing starts during a rebase, merge or cherry-pick: the commit being made is not the one that will be pushed.
amont rehearse by hand starts one for HEAD in the background;
--wait follows the running one, or runs it in the foreground if none is
(the shape an agent wants before git push); --status says which tree the
last one was for and how it ended; --stop cancels. Every path that goes
wrong ends in the gate running at push time exactly as it would have
without any of this: the background run can only remove work from the
push, never let it skip work nobody did.
The detached worker is Unix-only for now. On Windows a child process
inherits its parent's pipes, so a worker started from a hook whose output
is captured would hold the commit until the suite ended; there amont rehearse says so, and --wait runs the rehearsal in the foreground.
A push whose commits all carry the test stamp skips the pre-push line with
the same ✓ test gated at commit instead message; a --no-verify commit
brings it back with the same warning; and the dodge lands in the bypass
ledger under its own name. cargo test, pytest, go test — the contract
is identical because the machinery never looks at the command, only at the
name, the severity, the scope, and the stamps.
Only a declaration that would actually run, and actually cover the push, counts. All of these leave the push gate exactly as it was:
- an untrusted manifest, an unusable line, a
hook.skip, or a declaration on thepre-pushstage — a declaration that never runs is not a check; - an effective severity of
warn, whether declared or arrived at through anamont.severity.*override — a check that lets a failing commit through cannot stand in for one that blocks a failing push; - a push whose JS changes fall even partly outside the declaration's
scope —
*.ts,*.tsxabove says nothing about a.jschange, so a push carrying one runs the full gate for that ref; - every package but the repo root — the declared command runs at the root, so a monorepo sub-package's gate is never skipped on its account;
- a pushed commit with no stamp — see below. A declaration that qualifies on all the points above is still only a promise; the stamp is the proof it was kept.
The failure being avoided is the one worth stating plainly: a repository that
declared pre-commit typecheck, never trusted it, and had types checked at
neither end while both ends reported green.
The stamp is how the push gate trusts the event rather than the paper.
When the moved check runs at commit time, the post-commit hook records that
fact against the commit — a local notes ref, refs/notes/amont-gate, never
pushed, invisible to git log, garbage-collected with the commits it
annotates, and removed by amont uninstall. At push, the gate skips a script
only when every pushed commit inside the declaration's scope carries its
stamp. A commit created with git commit --no-verify, made by a client that
runs no hooks, made on a machine without amont, or whose hash a rebase or
amend rewrote has no stamp — and the push says so and runs the script itself:
⚠ typecheck is declared at commit time, but 1 pushed commit carries no record of it — running it here
Merge commits and cherry-picks never run post-commit, so they re-run the
gate the same way. Every failure mode points in one direction: a missing
stamp can only cost a redundant run, never skip a check that did not happen.
The residual trade of moving a gate entry earlier is therefore latency, not
safety — an unchecked commit makes the push slower, not greener.
The missing stamp is also counted. Every commit that a gate declaration
covered but that carries no record its gate ran appends one line per dodged
script to $(git rev-parse --git-common-dir)/amont-bypasses — a plain local
file, never a ref, never pushed, never sent anywhere; the no-telemetry
promise applies in full. --no-verify is only the commonest cause: a
blocked attempt retried with it, or a gate whose tool was missing, count the
same way, which is why amont list labels the tally unverified commits
rather than guessing at intent. A rising count is the first symptom of a
gate people have started routing around — a slow check, a flaky one — and
until it was counted, the hooks detected that signal on every commit and
threw it away. amont uninstall deletes the file;
git config amont.recordBypasses false stops the counting.
Trying it before you impose it
The question a team lead asks before adopting anything that can block a commit is will this annoy my team into switching it off? One line answers it:
git config amont.severity.pre-commit warn
Every blocking check still runs and still reports; nothing stops a commit. Work normally for a fortnight, then read what happened:
problems that did not block
pre-commit-usual-name 61 last 2h ago
pre-commit-ban-terms 4 last 1d ago
pre-commit-secrets 1 last 3d ago
66 events over 41 commits, since 12d ago
66 of them would have blocked — set amont.severity.<check> to keep one
advisory when you go back to block
That is a configuration worksheet: turn off the one or two checks your team
disagrees with before the rollout, rather than discovering them one angry
message at a time. Events and commits are different facts — forty commits
each tripping a check once is a check nobody agrees with, while one commit
tripping it forty times is one person losing an afternoon — so both are shown.
A check that ships as warn is marked (advisory) and excluded from the
would-have-blocked count: it was never going to block, and counting it would
inflate the only number being read.
The events land in $(git rev-parse --git-common-dir)/amont-downgrades, on
the same terms as the bypass ledger above: a plain local file, never a ref,
never pushed, never sent anywhere. It follows that this cannot aggregate
across a team — each developer's ledger is their own machine's, and a lead
runs the trial on their own checkout or asks people to paste. amont uninstall
deletes the file; git config amont.recordDowngrades false stops the counting.
A rehearsal (amont run, amont run --all-files) deliberately records
nothing: it is not a commit, and letting it count would make the number mean
something other than what it says. Neither does a check that actually blocked —
there is nothing to report about a problem that did its job.
What a push actually tests
By default pre-push runs your suite against the working tree, and says
so. That is fast and usually what you want, but it is not what you are pushing:
an uncommitted fix makes a broken commit look green.
git config amont.testPushedTree true
turns on the accurate answer — the suite runs in a throwaway checkout of the
commits being pushed, and your tree is not touched. It costs a second checkout
and a build that cannot reuse your target/ cache, which is why it is opt-in.
Telling CI about it
The gate stamps above stay local by design — an unsigned note is only as
honest as whoever can write the ref. Their signed successor can travel:
with git config amont.attest true, a push whose block gates all passed
leaves an ssh-keygen-signed note on each pushed tip in
refs/notes/amont-attest and sends that ref along, and CI may then skip
the test steps the attestation names — but only for exactly the attested
tree, and only after the signature verifies. The whole contract, including
what CI must check and why every failure mode falls back to running the
tests, lives in the CI backstop.
Tree gates
Besides the checks above, a repository may declare tree gates in
amont.conf (tree lines, see custom checks).
A tree gate runs a whole-tree lint or format command, the one CI runs, next
to the pre-commit checks. It never decides the commit. When it passes on
exactly the tree being committed, amont stamps that tree. The push then
attests tree-<name>, and CI skips its step
(the CI backstop).
Adding one
A check is a module plus one registry entry in
crates/amont-runtime/src/registry.rs; see
hook architecture and
CONTRIBUTING.md.
If the check belongs to your repository rather than to everybody's, declare it
in amont.conf instead — no fork required.
Where the hooks fit in your flow
One commit, start to finish, with every place a hook steps in.

You hit git commit
If you set commit.template, the
footer scaffold
opens in your editor to help you write something meaningful. Or you are in a
hurry and write git commit -m "Add to Cart", which is the interesting case,
because that is the one that gets stopped.
pre-commit
Git runs it before the commit exists. Every pre-commit check that applies here fans out concurrently, each reporting its own line, and a panic in one is isolated so the others still report.
On an interactive terminal you watch this happen: a live region shows one
spinner line per check still running (⠹ clippy 2.3s), shrinking as they
finish, while each finished check's full output lands above it as one
contiguous block — never interleaved with another check's, however many run
at once. Piped or in CI the region stays silent and only the blocks appear.
git config amont.progress false restores plain streaming output.
Most of them will say nothing, because most are inert in any given repository:
a check fires only when the commit touches files it understands and the
repository carries the configuration that opts into that tool. amont list
tells you which ones are live where you are standing.
Some checks fix rather than complain — cargo fmt, prettier, ruff —
and stage the result. What exactly they are allowed to touch, and how your
unstaged work survives it, is the subject of
index fidelity and run modes; it is the most
carefully argued part of this codebase, because the failure it guards against
is losing work you had not committed.
If a blocking check fails, the commit is aborted and nothing has happened.
commit-msg
Then git hands the message to commit-msg, which lints it against the
conventions, wraps the body at 72 columns and groups
the footers.
This is the hook that rejects Add to Cart: no type prefix. git commit -m "feat: a cart the checks agree with" passes.
--no-verify skips commit-msg along with pre-commit — that is git's
behaviour — but neither hook.skip nor a severity override names it, so
there is no way to turn it down and leave it on. So this is the one
hook whose rules are adjustable in themselves: amont setup sets the
subject and description limits, the body wrap, and where the type's gitmoji
goes (nowhere, by default).
git push
pre-push runs its checks in sequence, cheapest and most decisive
first — refuse a forbidden push before validating a branch name, and validate
everything structural before paying for a test suite.
It refuses a direct push to main or master; it requires a branch name of
the form feat/3002-image-crop, unless the branch is already on the remote; it
rebases your branch onto its own upstream, never onto the default branch,
and never when your tree is dirty; and then it runs the test suite of whatever
your commits actually touched.
By default that suite runs against your working tree, and says so — which
is fast, and is not what you are pushing. git config amont.testPushedTree true runs it against a throwaway checkout of the
commits being pushed instead. See the checks.
Where a check can only recommend rather than act, it recommends. pull-rebase
warns when the default branch has moved ahead of you; it does not go and do
anything about it.
Asking the same questions without committing
The hooks are not the only way to run the checks, and during adoption they are the wrong way:
amont run # would my commit pass? (the staged set)
amont run --all-files # does my working tree pass? (git ls-files)
amont run pre-commit-prettier
Those two questions differ on purpose. --all-files on a dirty tree reports on
content that is not committed and may never be — which is exactly what you want
when adopting a check into an existing repository, where git add . is not an
acceptable way to measure the mess.
amont rehearse --wait asks the push question early: it runs the push gate on
a snapshot of HEAD before git opens a connection and stamps the tree, so the
git push that follows skips the suite. amont restore brings back unstaged
work a killed hook left parked.
For coding agents
amont list --json is the same answer as amont list, machine-readable:
declared and effective severity, whether each check fires here and why not,
and the command if it is a declared external. --stage filters to one
trigger, --pushed scopes to what your next push would carry. The contract is
described in the checks.
amont agents-md writes that guidance into a generated block in AGENTS.md,
plus a CLAUDE.md signpost pointing at it; amont agents-md --check reports
drift only, and the pre-commit-agents-md check warns when the block is behind
the binary that would generate it now.
Before you type git commit at all
Everything above happens once the work is finished, staged, and described. That
is the latest possible moment to learn that line 7 has a debugger; in it.
amont check asks about files instead of about a commit:
amont check src/app.js # a path
amont check src/*.ts --format json # several, structured
amont check --stdin-filename src/app.js < buf # a buffer you have not saved
src/app.js:7:3: error: 'debugger' is a banned term here [ban-terms]
src/app.js:41: error: an AWS access key id — unstage it; once pushed it is not
history, it is an incident [secrets]
It is a read: no index, no staging, no stash, no writes. Exit 1 if anything blocking was found, 0 otherwise — a warning is not a failure.
Only the content checks answer here — ban-terms, secrets, merge-conflict,
large-files. branch-pattern and pull-rebase are not about a file, and
clippy, ruff and eslint already talk to your editor better than anything
proxied through amont could.
Wiring it to an editor
file:line:col: severity: message is the format every editor's error parser
already reads, so there is no amont plugin to install — anywhere.
Neovim, with nvim-lint:
require("lint").linters.amont = {
cmd = "amont",
stdin = true,
args = { "check", "--stdin-filename", function() return vim.fn.expand("%:p") end },
ignore_exitcode = true, -- exit 1 means "found something", not "broke"
-- `col` is optional: a whole-file finding (large-files) has no column, and
-- `secrets` reports a line without one.
parser = require("lint.parser").from_pattern(
"([^:]+):(%d+):?(%d*): (%w+): (.+)",
{ "file", "lnum", "col", "severity", "message" },
{ error = vim.diagnostic.severity.ERROR, warning = vim.diagnostic.severity.WARN }
),
}
require("lint").linters_by_ft = { javascript = { "amont" }, rust = { "amont" } }
VS Code, as a task with a problem matcher:
{
"label": "amont check",
"type": "shell",
"command": "amont check ${file}",
"problemMatcher": {
"owner": "amont",
"fileLocation": ["relative", "${workspaceFolder}"],
"pattern": {
"regexp": "^(.+?):(\\d+):?(\\d*): (error|warning): (.+)$",
"file": 1, "line": 2, "column": 3, "severity": 4, "message": 5
}
}
}
Anything else —
efm-langserver and Emacs
flycheck both take the same pattern. --format json (amont-check-v1) is
there if you would rather not parse a line.
Commit and branch conventions
Two vocabularies, kept in one place —
crates/amont-runtime/src/vocabulary.rs — with a test that fails unless
every name is either shared between them or declared an exception with a
reason.
That test exists because they drifted: 8 of 12 commit types were rejected as
branch prefixes, so docs/… and refactor/… could not be pushed even though
docs: and refactor: were valid commit types. It cost two branch renames
before anyone looked.
The commit subject
<type>[optional scope][optional !]: <description>
Enforced by commit-msg:
- a subject is present, and is at most 72 characters;
- it carries one of the types below, followed by a required colon and space;
- a description follows the prefix, and is at most 50 characters.
Messages git itself writes are passed through, not judged: Merge …,
Revert "…", Reapply "…", and the autosquash shapes fixup!, squash!
and amend! all carry no conventional type by design, and blocking them
would block git merge, git revert and git commit --fixup themselves.
An optional scope is a noun in parentheses naming a section of the codebase:
fix(parser): …. A ! before the colon marks a breaking change.
Both numbers are defaults, not laws — see if the defaults do not fit below.
If the defaults do not fit
commit-msg is the one hook hook.skip and amont.severity do not reach —
--no-verify skips it for a single commit (git's rule, and an emergency
exit, not a dial). So it is the one hook whose opinions
have to be adjustable in themselves, and they are — four git config keys,
walked by amont setup and listed in
configuration:
amont setup # ask me the four questions
git config amont.commit.descriptionMax 68 # or set one directly
68 is the number worth knowing if 50 feels tight. It is the longest description that still fits a 72-column subject after a short type and a colon, so it buys you eighteen characters without breaking the line-length convention that the 72 comes from.
By default nothing decorates your subject: you write feat: add a cart and
that is what is stored. If you want the type's gitmoji, choose where it goes:
git config amont.commit.gitmoji suffix
| stored as | |
|---|---|
none | feat: add a cart |
prefix | ✨ feat: add a cart |
suffix | feat: add a cart ✨ |
replace | ✨ add a cart |
Prefer suffix over replace unless you have decided otherwise on purpose:
it keeps a clean conventional subject at the start of the line, where
commitlint, changelog generators and git log --grep '^feat' look for it.
replace puts the emoji where the type word was, which is a real trade — an
emoji is not something conventional-commit tooling knows how to parse.
Write the bare subject with no emoji of your own. The limits measure what you wrote, so a gitmoji this hook adds never counts against your budget — but one you type yourself is yours, and does.
The types
This table is derived from COMMIT_TYPES in the source, which is the
authority — what you read here is what the hook enforces and prepends.
| icon | type | for |
|---|---|---|
| 👷 | build | the CI or build system |
| 🔧 | chore | configuration, auxiliary tooling, generated docs |
| 📝️ | docs | documentation only |
| ✨ | feat | a new feature |
| 🐛 | fix | a bug fix |
| ⚡️ | perf | a performance improvement |
| ♻️ | refactor | neither fixes a bug nor adds a feature |
| ⏪️ | revert | reverting; ideally via git revert |
| 🎨 | style | structure or formatting of the code |
| 🚨 | test | adding, updating or fixing tests |
| ➕ | add | adding files as part of a larger feature |
| ➖ | remove | the opposite of add |
The rest of the message
commit-msg also reformats what you wrote, rather than rejecting it for
whitespace:
- hard-wraps the body at 72 columns (
amont.commit.bodyWrap, or0to leave a pasted stack trace or a fenced code block exactly as it is); - ensures one blank line after the subject;
- groups the trailing footers, with one blank line before them.
Reformatting is idempotent: an amend, a rebase reword and a --no-verify
retry all hand the hook a message it wrote itself, and it gives the same one
back.
prepare-commit-msg appends an issue id found in the branch name — JIRA first
(ABC-1234), else a bare Kanbanize id (1234) — but only for a commit you are
authoring. -m, -t, a merge, a squash and --amend all pass a source in
$2 and are left alone.
The footer scaffold lives in
message:
git config --global commit.template ~/.config/git/git-templates/message
Branch names
pre-push-branch-pattern requires prefix/branch-name — for example
feat/3002-image-crop — unless the branch already exists on the remote, in
which case renaming it is nobody's idea of an improvement.
Prefixes: add, automation, build, chore, docs, feat, fix,
hotfix, perf, refactor, remove, revert, style, test.
Only chore/ allows dots, because they suit version-bump branches
(chore/duro-1.50.50) and would only be noise elsewhere. Git already rejects
the dangerous forms (.., a trailing .lock).
Two prefixes are deliberately not commit types, and the reason is recorded in the source rather than in anyone's memory:
hotfix— an urgency, not a kind of change. The commits inside it are stillfix:.automation— bot-authored branches; their commits carry their own types.
The rejection message is rendered from the same lists, so what you are told always matches what is enforced.
Where these come from
- https://git-scm.com/docs/git-commit
- https://www.conventionalcommits.org/en/v1.0.0/
- https://gitmoji.dev/
Configuration
Everything is git config. There is no config file of ours, no .amontrc,
nothing to keep in sync — the settings live where a git user already looks for
settings, and --local/--global mean what they always mean.
To bypass or disable rather than tune, see opting out.
Naming a check
Every check has an id, <trigger>-<name> — pre-commit-clippy. Three things
name it, and every config surface reads all three the same way:
| key | reaches |
|---|---|
pre-commit-clippy | that one check |
clippy | that check, on either trigger |
pre-commit | every check on that trigger |
Where several keys reach one check, the most specific wins: full id, then short name, then trigger. So you can downgrade a whole trigger and then exempt one check from that downgrade.
Nothing matches by substring. hook.skip e reaches nothing at all, and
skipping lint-js leaves lint-json-yaml alone — a skip can never silently
couple two checks whose names happen to share a prefix.
Where committed policy sits. A trusted amont.conf can carry
severity, skip, and set lines.
For one key, the ladder is: built-in default < system < global < policy <
local < worktree < command — the team's committed decision beats your global
preferences, and your local config in that repository beats the team. Between
different keys naming the same check, specificity (full id > short name >
trigger) decides regardless of source. Skips union across every source. On a
git too old for --show-scope (< 2.26) this degrades fail-safe to "all git
config beats policy". A set line reaches an allowlist of the keys on this
page — the thresholds, commit style, autoRebase, timeout,
testPushedTree, minVersion — and never fix, trusted,
conventions, or the observability opt-outs, which stay per-machine.
hook.skip — do not run it
Multi-valued; add as many as you need.
git config --add hook.skip pre-commit-clippy # that one check
git config --add hook.skip clippy # on either trigger
git config --add hook.skip pre-commit # the whole trigger
git config --unset-all hook.skip # start over
git config --get-all hook.skip # what is set here
A skipped check is announced on every commit. A config line nobody remembers writing cannot go on silently disabling things — that silence is how a repository ends up with a check everyone believes is running.
For one commit only, without touching config:
git -c hook.skip=clippy commit -m "fix: …"
amont.severity.<key> — run it, but do not block
Takes the same three spellings, and keeps the signal: the check still runs and still reports, it just stops failing the commit.
git config amont.severity.clippy warn # runs, reports, does not block
git config amont.severity.pre-commit warn # the whole trigger
git config amont.severity.clippy block # back to blocking
warn is usually the right first move when adopting a check into an existing
repository: you get the report immediately and pay down the backlog on your own
schedule, rather than choosing between a blocked commit and a hook.skip you
will forget to remove.
amont.commit.* — what a commit message must look like
Four keys, and amont setup walks you through all of them:
| key | default | means |
|---|---|---|
amont.commit.gitmoji | none | where the type's emoji goes |
amont.commit.subjectMax | 72 | longest the whole subject may be |
amont.commit.descriptionMax | 50 | longest the part after type: may be |
amont.commit.bodyWrap | 72 | column the body is hard-wrapped at; 0 never wraps |
These matter more than they look, because commit-msg is the one hook
hook.skip and amont.severity do not reach, and git exempts it from
--no-verify. Without these keys the only answers to "I do not want a gitmoji
in every subject" were to comply or to uninstall.
The four placements
git config amont.commit.gitmoji prefix
| stored as | ||
|---|---|---|
none | feat: add a cart | the default — your subject, untouched |
prefix | ✨ feat: add a cart | |
suffix | feat: add a cart ✨ | commitlint and changelog tools still see the type |
replace | ✨ add a cart | the emoji stands in for the type word |
You always write feat: add a cart, and it is always validated as that —
the placement only decides what gets stored. replace costs you interop, and
that is the trade it is: an emoji is not a type any conventional-commit tool
knows how to read.
Two things hold whatever you choose. The limits measure what you wrote, so the emoji never eats your description budget. And running the hook again over its own output — an amend, a rebase reword — changes nothing.
The limits
git config amont.commit.descriptionMax 68
git config amont.commit.bodyWrap 0
68 is the useful number if 50 feels tight: it still fits a 72-column subject
with a short type and no scope. bodyWrap 0 leaves the body exactly as
written, which is what keeps a pasted stack trace or a fenced code block
intact.
A value git cannot parse, or one outside 1..=1000, takes the shipped default
and says so on the commit it happened on — because a limit you believe you
raised and did not is the whole failure mode this project refuses to be quiet
about. A pairing that cannot do anything (a description budget the subject
limit can never accommodate) is reported by amont list, not by the hook:
the commit path says what is in effect, and the config-reading commands say
what makes no sense.
amont.conventions
$ git config --global amont.conventions declared
everywhere (the default) or declared. In declared mode the house
rules — commit-message shape, branch patterns, lint/format gates, test
suites, audits, auto-rebase — run only in repositories that commit an
amont.conf (an empty one declares), while the safety net (merge-conflict,
secrets, large-files, ban-terms) keeps running everywhere. This is what
makes a machine-wide standing grant (amont enroll, init.templateDir)
safe on a machine that also clones other people's projects. A held-back
stage announces itself in one line; amont list reports the state, and
--json carries it as "conventions_apply". An unrecognised value falls
back to everywhere, loudly.
amont.largeFileWarn / amont.largeFileBlock
$ git config amont.largeFileWarn 25
$ git config amont.largeFileBlock 500
The two thresholds of pre-commit-large-files, in
megabytes: a staged file over the first is named (default 10), one over the
second blocks (default 100 — GitHub's own refusal line).
amont.recordBypasses — whether a dodged gate is tallied
git config amont.recordBypasses false # default true
When a commit that a commit-time gate declaration covered lands without that
gate having run — git commit --no-verify, a blocked attempt retried with
it, a gate whose tool was missing — post-commit silently appends one line
per dodged script to $(git rev-parse --git-common-dir)/amont-bypasses.
amont list shows the tally as "unverified commits". The file is local:
never a ref, never pushed, never sent anywhere.
false stops the counting from now on. The switch exists because "my tool
counts my bypasses" can reasonably read as surveillance, and the answer to
that reading should be a documented off-switch rather than an argument —
though the count is also the first place a slow or flaky check becomes
visible as the thing people route around.
amont.recordDowngrades — whether a warning is tallied
git config amont.recordDowngrades false # default true
The companion to the switch above, for the other half of the signal. When a
check FAILS but the severity that applies says warn, the hook silently
appends one line to $(git rev-parse --git-common-dir)/amont-downgrades, and
amont list shows the tally as "problems that did not block".
That file is what makes a trial readable: set
amont.severity.pre-commit warn, work for a fortnight, then read which checks
your team would actually have fought. See
Trying it before you impose it.
Local on the same terms as the bypass ledger — never a ref, never pushed,
never sent anywhere — and false stops the counting from now on, for the same
reason its sibling has an off-switch.
Nothing is recorded by a rehearsal (amont run), and nothing by a check that
actually blocked: the first is not a commit, and the second has nothing to
report.
amont.progress — one check, one block
git config amont.progress false # default true
On (the default): everything a pre-commit check says — its own lines and its
tools' captured output — is buffered and emitted as ONE contiguous block when
the check finishes, so twenty concurrent checks stop shuffling their failure
output together. Blocks arrive in completion order. And when stderr is a
real terminal, a live region under the blocks shows one line per running
check — spinner, name, elapsed — so a slow cargo test is a ticking clock
instead of a frozen prompt. The region only ever paints on an interactive
terminal; piped or redirected output, TERM=dumb, and CI logs never see a
control code.
Off: raw streaming, exactly as before — every line lands the moment it is written, interleaved across whatever else is running. The honest cost of the default is that a long-running tool's output arrives when the check ends rather than as it happens; this key is the way back if you want to watch a test suite scroll.
amont.quiet — say it once, or once per check
git config amont.quiet never # auto (default) | never | always
A hook that passes prints one line per check, and on a clean run that is the whole output: fourteen lines to say nothing happened. At a terminal those lines are the reassurance that the gate ran at all. Captured — an agent's tool result, a CI log — they are read again on every later turn and say no more the tenth time than the first.
So the setting names who is reading:
| value | effect |
|---|---|
auto | the default: quiet when stderr is not a terminal, verbose when it is |
never | every check says it passed, whoever is reading |
always | quiet everywhere |
Only the success lines go. A failure, a warning, a check that could not
run, a repaired file and the blocked summary print under every setting.
Quiet is about the uneventful path and nothing else — it is not a way to
lose a refusal, and quiet_never_swallows_a_failure pins that.
In their place, one line:
✓ 14 check(s) passed
That count is not decoration. A run that says nothing at all is indistinguishable from a gate that never ran, which is the confusion this whole crate exists to prevent — so quiet gets quieter, never silent.
auto is the default because it is free for the reader it does not help.
At a terminal watching() is true, so committing by hand looks exactly as it
did. What changes is the reader who cannot skim — a captured log, an agent's
tool result — who was paying fourteen lines of nothing on every commit of
every session. Set never to have it back.
One consequence worth knowing: the ✓ N check(s) passed roll-up comes from a
full stage. Running a single check — amont run pre-commit-yamllint — has no
roll-up to print, so under quiet it succeeds in silence.
amont.pushStamps — remember what the push gate already proved
git config amont.pushStamps false # default true
A push-time gate that passes stamps the pushed tips in
refs/notes/amont-gate, keyed by tree, and the next push of the same content
skips it: the retry after a remote dropped the idle connection mid-suite,
and the git push after an amont run pre-push rehearsal. Only scoped
gates (test suites) are stamped or skipped, and only for content the suite
actually tested — see "Rehearsing the push gate" on the checks page. false
turns off both the writing and the honouring.
amont.order — attempt the push gates in the order the record justifies
git config amont.order evidence # default: declared
declared is the registry's order, and it is what every repository gets
unless it says otherwise. evidence orders the push gates by this
repository's own record of them (refs/notes/amont-gate, the same notes the
stamps live in): the gates that have actually failed in the last 90 days go
first, ordered by failures per unit of time, so a push that is going to be
refused is refused at minute two instead of minute twenty.
It is an ORDER and nothing else. Every gate still runs, none is assumed to pass, and only the scoped gates — the suites and audits — are permuted: branch-protect, branch-pattern, secrets and pull-rebase keep their positions, because discovering a protected branch after a test suite is the waste this is meant to remove. With no record the declared order is kept exactly. When the order does differ, the push says so.
Settable in a committed amont.conf (set order evidence) as well as by
git config, because which suite is worth attempting first is a property of
the project. It is the only set key that changes how checks are RUN rather
than what they are — safe on the terms above, and a local git config still
outranks it. See gate evidence.
amont.commitStamps — do not repeat a commit-time gate on the same tree
git config amont.commitStamps false # default true
A blocking commit-time gate that already ran clean against exactly the tree
being committed is not run again: the record pre-commit leaves for
post-commit is bound to the tree and survives a commit-msg refusal or a
closed editor, and the stamp post-commit writes on the tree answers for a
reset --soft and re-commit. The reuse is announced in the push gate's
words ("passed on this exact tree earlier"). A changed byte, a changed
declaration, or a gate that failed all run the gate. false turns the reuse
off; the record is still written. See "Moving a gate entry earlier" on the
checks page.
amont.rehearseOnCommit — run the push gate in the background after every commit
git config amont.rehearseOnCommit true # default false
post-commit starts amont rehearse for you: a detached worker checks out
HEAD into a throwaway worktree, runs the test gates there — the
push-shaped checks (branch-protect, secrets, the auto-rebase) wait for the
push — and stamps the tree when they pass. By the time you git push, the
suite has usually already run; if it is still running, the push waits for
it rather than starting over. A newer commit cancels a rehearsal of the
older tree, suite and snapshot included, so committing often does not queue
work. Off by default because every commit then costs a suite's worth of CPU
in the background. A finished git rebase starts one too (post-rewrite),
since it leaves no commit on the branch stamped. See "Rehearsing in the background" on the checks page for
what it prints and what amont rehearse --wait, --status and --stop do.
Needs amont.pushStamps (the default) — the stamp is the hand-off. Unix
only for now: on Windows a detached child would hold its parent's pipes
until the suite ended, so the hook says so instead and amont rehearse --wait runs the rehearsal in the foreground.
amont.rehearsalWait — how long a push waits for a running rehearsal
git config amont.rehearsalWait 300 # default 300; 0 waits indefinitely
A push that finds a rehearsal of its own tree still running waits for it:
less remaining work than a fresh run, and no extra CPU. That wait happens
after git has opened its connection to the remote, though, so it is the
same idle connection the rehearsal exists to keep short — and it used to be
bounded only by the worker's own amont.timeout, an hour by default and
unbounded at 0. Five minutes is under every idle timeout we have measured
(Forgejo's git timeout is six), so a wait that expires still leaves the
connection alive for the gate that follows it. When it expires, the gate
runs in the push and the rehearsal is left running — it may still finish and
stamp the tree for next time. amont rehearse --wait has no budget: nothing
is connected there.
amont.unstampedPush — refuse a push nothing has tested yet
git config amont.unstampedPush refuse # default run
When a check is declared at both stages and a pushed commit carries no
record of the commit-time run — rewritten by a rebase or an amend, or made
with --no-verify — the push runs the check itself, with git's connection
to the remote held open for as long as the suite takes. A long suite
outlives the forge's idle timeout and the push fails after passing.
refuse turns that push away at once instead, and says how to earn the
stamp with nothing waiting on it: amont rehearse --wait, then push again.
A rehearsal of the tree that is still running or has failed is reported the
same way. Pairs with amont.rehearseOnCommit, which usually earns the stamp
before you push.
amont.snapshotDeps — how a snapshot gets its JavaScript dependencies
git config amont.snapshotDeps install # default; or reuse, off
A worktree git just created is a checkout, not a workspace: no
node_modules, and a suite started there fails on Cannot find module
having tested nothing. Every snapshot — the background rehearsal's and
amont.testPushedTree's alike — is prepared before any suite runs, one
unit at a time: each directory holding a tracked package-lock.json,
pnpm-lock.yaml or yarn.lock, read from the snapshot's own index, so the
pushed commit decides, not HEAD and not what you have staged. A nested
project with a lockfile of its own is its own unit. A directory with more
than one manager's lockfile is settled by package.json's
packageManager, or refused.
A bun.lock or bun.lockb is a unit too, so that a push which does not
touch it is skipped like any other — but amont does not install bun
dependencies: a unit the push needs fails the preparation with the fix
named, amont.snapshotPrepare set to the install command, or
amont.snapshotDeps off. Failing there is deliberate: a gate run over a
checkout with no dependencies fails every workspace at once, and that reads
like a broken branch rather than a missing install.
installdoes what CI does:npm ci --prefer-offline,pnpm install --frozen-lockfile --prefer-offline,yarn install --frozen-lockfile --non-interactive --prefer-offlinefor yarn 1 andyarn install --immutablefor yarn 2+ ("berry"; the committedyarn.locksays which — berry's opens with a__metadata:block — so the pushed commit decides, and each flavour gets the spelling it accepts without a warning). Exact, and it refuses apackage.jsonits lockfile does not satisfy — so does the snapshot.reuse(pnpm only) clones the working tree'snode_modules(copy-on-write on APFS and btrfs: instant, no extra disk) — the root's and each workspace member's, aspnpm ls -rlists them — and keeps the clone only when it is laid out the way pnpm's isolated linker leaves it (every package a link; a stray directory is refused, since pnpm's own check ignores it and a suite could import it) andpnpm install --frozen-lockfile --offlineaccepts it — which also refuses a manifest the lockfile does not satisfy, and relinks small drift from the store. A lockfile that differs from the commit's, a link that resolves back into the working tree, or any refusal removes every directory cloned for that unit, says why, and installs instead. So does a file edited in place: pnpm does not check installed content, so amont refuses a tree holding any file newer than pnpm's install record (node_modules/.modules.yaml, rewritten as every install finishes), naming it —.bin/,.cache/and.vite*/aside, which installs and tools write afterwards. That rests on file timestamps, not content, which is whyreuseis still not the default. npm is never reused:npm lsanswers whether the dependency graph is valid, not whether the tree is the one the lockfile describes, and a real graph with peer-range conflictsnpm ciinstalls happily fails it on a fresh install — so it can vouch for nothing, and an npm unit installs underreusetoo, saying so. Neither is yarn: a frozen install checks the lockfile against the manifests, not the installed tree, andyarn checkis gone from both flavours, so a yarn unit installs underreusetoo.offprepares nothing.
Only the units the push touches are prepared: the root one, and each unit
that is the nearest enclosing unit of a file changed between the tip and
where it forked from its upstream. The rest are named in one line and
skipped — a repository with side projects of their own (spikes, examples)
no longer pays an install for each on every snapshot. A unit the push does
not touch cannot hold a failure the push introduced; a gate that reaches
into one anyway finds no dependencies and fails loudly, never falsely
passes, and amont.snapshotPrepare is the override. With no upstream to
measure against, every unit is prepared.
A failed install is the snapshot's failure; see amont.snapshotPrepare
below for what each caller then does. Nothing is needed for a Rust crate
(cargo resolves from the shared registry; the build is cold, which is the
cost the testPushedTree section describes).
The install runs lifecycle scripts, so a "prepare": "amont init" runs
inside the snapshot — and the snapshot is a linked worktree, sharing the
repository's hooks directory. To keep that init from baking every hook to
a binary in a temp directory deleted minutes later, the snapshot marks
itself with an amont-snapshot file in its own git admin dir
(git rev-parse --git-path amont-snapshot), and amont init writes nothing
where it finds one; if it cannot tell, it fails rather than guess. Both
halves must be a version carrying this — the amont that runs the rehearsal
writes the marker, and the amont the pushed commit's lockfile installs
reads it — so bumping only one of them is not enough. The install and
amont.snapshotPrepare also get AMONT_SNAPSHOT=1, for a custom prepare
script that wants to know; the marker, not the variable, is the guard.
amont.snapshotCarry — untracked files a snapshot needs
git config amont.snapshotCarry ".env .npmrc"
or committed, set snapshotCarry .env in amont.conf. The files a suite
reads and git does not track — a .env, an .npmrc with a registry token —
are copied from the working tree into the snapshot, before the
dependencies, so an install can use them. Only untracked content may be
carried: an entry that is tracked, holds tracked files or sits under a
tracked path would put your uncommitted copy where the commit's belongs, and
the stamp would vouch for a tree nobody committed. Refused too: absolute
paths, ., .. and .git components, and a symlink anywhere on the way —
in the working tree, the snapshot, or inside a carried directory. Every
refusal is listed in one message and fails the preparation; an entry that is
simply absent (no .env in CI) is skipped and said.
amont.snapshotPrepare — the escape hatch
git config amont.snapshotPrepare "pnpm prisma generate"
Or committed, so every clone has it:
set snapshotPrepare pnpm prisma generate
in amont.conf (see custom checks). A local git config still outranks the committed value.
For what amont cannot know — a code generator, a database client, a build
step. It runs through the shell inside the snapshot after the carry, with
$AMONT_SOURCE_WORKTREE naming the working tree the snapshot came from.
When it is set it owns the dependencies: amont.snapshotDeps stands
down, so a repository whose command already installs does not install twice.
A preparation that fails — carry, install or this command — is the
snapshot's failure, and each caller says so. At push time
(testPushedTree) the gate falls back to the working tree and stamps
nothing for that tip: the suite that then passes never saw the content the
stamp would have vouched for. In a rehearsal the worker records the failure
with its reason (amont rehearse --status shows it), exits 2, and the next
push treats it as a failed rehearsal.
amont.autoRebase — whether pre-push may sync a behind branch for you
git config amont.autoRebase false # default true
On (the default, and the behaviour every install so far has had):
pre-push-pull-rebase rebases a clean, behind, non-diverged branch onto its
own upstream — then stops the push and asks for a second one, because the
refs git handed the hook predate the rebase; the suite would otherwise judge
commits git is no longer pushing, and the server refuses the stale objects
regardless.
Off: the check becomes a pure advisor. It performs no network I/O at all (no
ls-remote, no advisory fetch — behind is judged from your last fetch) and
never runs a rebase you did not type; a behind branch stops the push with the
command to run. A hook that rewrites your branch is a bigger claim than most
teams want a "check" to make — this is the key that unmakes it.
amont.idleTimeout — how long a check may be silent
git config amont.idleTimeout 300 # seconds; default 120, 0 disables
A hung tool is silent — a captive portal, a deadlocked lock file, a prompt
nobody will answer. A slow tool talks: cargo test prints a line per test.
So the clock that decides "stuck" counts silence: a command that writes
nothing for this long is killed and the check fails, saying so and naming
this key. Two minutes catches a real hang faster than the old ten-minute wall
clock did, and lets a chatty twenty-five-minute suite finish.
Only commands whose output amont observes answer to this clock — every
check runs its tools that way by default. With amont.progress false the
tool inherits your terminal, nobody sees the bytes, and only the ceiling
below applies.
Busy is not stuck
Not every slow tool talks: vitest without a terminal prints its summary and
nothing before it, and a passing suite used to be killed as a hang. So on
Linux and macOS, once a check has been silent for a while (a third of this
budget, at most 30 seconds), amont also measures the CPU its process tree is
using — the command, its descendants, and the work of every child they have
already reaped. A check keeps its budget as long as that tree does at least
0.1 of a core of work; it is killed only when it has been silent and idle
for the whole budget. A quiet suite then runs on to amont.timeout, and the
progress line says it is busy (· quiet 2m10s · ~3.9 cores).
The trade-off is written down in ADR-0008: a silent tool that spins — a
busy loop, a polling watcher — now answers to the ceiling rather than this
budget, and the ceiling message says so. Work done by a daemon outside the
tree (a build daemon, a container engine) is not counted. On Windows, or with
amont.idleCpuCredit false, silence alone counts, and the messages say CPU
was not measured rather than claiming it was idle.
When sampling is on but a measurement is incomplete — the walk came back
partial on a loaded machine, or no sample has landed yet — the check answers
to the extended budget, this budget × amont.idleLoadScale (eight
minutes by default), never to the bare two minutes: a kill must rest on a
measurement somebody made. One partial snapshot no longer throws the
measurement away; the next complete one spans the gap, may count as busy,
and never as measured idle (ADR-0009).
git config amont.idleCpuCredit false # silence alone, everywhere
AMONT_CPU_TRACE=<file> appends the processes each measurement saw — a
diagnostic for when a check was kept alive, or killed, and you want to know
what amont was looking at. AMONT_CPU_MAX_PROCS=<n> caps the walk, which
forces an incomplete measurement on purpose.
Waiting is not stuck
A tool that is waiting on a lock says so and then says nothing: cargo
prints Blocking waiting for file lock on build directory while another
cargo — a second worktree, rust-analyzer, a build in another session —
holds it, and uv prints Waiting to acquire … lock for …. Those exact lines,
on stderr, pause this clock: the wait answers to amont.lockWait instead,
and the progress line says · cargo lock 1m30s/10m00s. A check that passes
after such a wait says how long it waited and for what.
A line that only looks like a wait (waiting for lock on …) does not pause
anything. If the silence budget kills a built-in check whose last line read
like that, amont runs it once more, under what is left of the same ceiling,
and says so; the first kill prints no failure. A declared check is never
retried: a command you wrote may not be safe to run twice.
The silence budget also stretches with the host's load: on a machine whose
one-minute load average is twice its core count, two minutes become four,
up to amont.idleLoadScale. The progress line and the kill message name
the stretched figure, the load and the key.
Worst case for a genuinely stuck, silent tool, at the defaults:
| situation | killed after |
|---|---|
| CPU measured idle, host not loaded | 2 minutes |
| CPU measured idle, host loaded | up to 8 minutes (idleLoadScale 4) |
| CPU could not be measured | 8 minutes, even with amont.timeout 0 |
| in a declared cargo or uv lock wait | 10 minutes (amont.lockWait) |
| retried after a wait-like last line | both attempts inside one amont.timeout |
| waiting for a host slot | up to amont.timeout, before it starts |
| anything | amont.timeout, one hour |
amont.timeout — the ceiling one check's command may run for
git config amont.timeout 900 # seconds; default 3600, 0 disables
The backstop behind idleTimeout: a tool that keeps printing and never
finishes is killed here, and the message says whether it was still printing
(slow — raise this) or had gone quiet (stuck — look at the tool). The default
was ten minutes when this was the only clock and had to catch hangs; with
silence doing that job, the ceiling can afford an hour.
The kill reaches the command itself; a grandchild it detached may survive, orphaned, but the commit is no longer hostage to it.
While a stage runs, a terminal shows a live line per check with its elapsed
time, a · quiet 45s/2m note once a check has been silent for half a minute
(· quiet 2m10s · ~3.9 cores instead while its CPU is busy,
· idle 40s/2m counting what the budget counts once busy work has pushed it
back, · cargo lock 1m30s/10m00s during a declared wait, and
· queued 42s (slots 2/2) while it waits for a host slot), and · 50m/1h once it is within 80% of the ceiling — the cliff, shown
before the fall. Piped (an agent, CI), the same information arrives as one
plain line a minute per running check: elapsed, time since its last output,
what its CPU is doing (busy ~3.9 cores, CPU idle 40s, CPU unmeasured),
and, the first time, both budgets.
The same clock bounds the push path's own network verbs: pull-rebase's
sync runs under the full budget, and the reachability probes
(ls-remote before a sync, the initial-push check) under the smaller of
this and 30 seconds — a probe answers in a second or two when the network
is there at all, and a captive portal must not get ten minutes to say
nothing. 0 disables these deadlines too.
amont.lockWait — how long a check may wait on a lock
git config amont.lockWait 900 # seconds; default 600, 0 = until the ceiling
How long a tool may sit in a declared lock wait (see "Waiting is not
stuck" above) before it is killed. The kill message names the lock and who
probably holds it. 0 leaves the wait to amont.timeout; with that off
too, the extended silence budget bounds it, so nothing waits forever.
Settable from amont.conf (set lockWait 900): how long a workspace's cold
build holds its lock is the project's to know.
Host keys: amont.idleLoadScale and amont.hostSlots
These two describe the machine, not the repository, so they are read from
--global or --system git config only. A value set in a repository is
ignored, with one warning naming the key and --global; amont.conf
cannot set them. Precedence, highest first: the environment variable
(AMONT_IDLE_LOAD_SCALE, AMONT_HOST_SLOTS), then --global, then
--system, then the default.
amont.idleLoadScale — how far a loaded host stretches the silence budget
git config --global amont.idleLoadScale 2 # default 4, 1 = never stretch, up to 16
The silence budget is multiplied by the host's one-minute load average over its core count, floored at 1 and capped at this. The same factor sets the extended budget a check answers to while its CPU could not be measured. Linux and macOS read the load; elsewhere the factor is 1.
amont.hostSlots — how many heavy checks one machine runs at once
git config --global amont.hostSlots 2 # default: a quarter of the cores, at least 1; 0 = off
Clippy, go vet, pyright and the test suites compile or execute the
product, and each already uses every core. Several worktrees, sessions or
agents running them at once thrash the same cores and the same cargo lock.
So a heavy check takes one of these slots before its first tool runs, and
waits — its clocks not yet started — while all are taken; the progress line
says · queued 42s (slots 2/2). A check that turns out to have nothing to
do never queues.
The slots are flock locks on files in /tmp/amont-slots-<uid>
($XDG_RUNTIME_DIR/amont-slots on Linux when set), a fixed path so every
session shares one queue whatever its $TMPDIR. amont refuses that
directory unless it is a real directory you own with mode 0700, and then
runs the check unqueued with one line saying why. A slot that never frees
within amont.timeout runs the check unqueued too, with a note: the queue
is a courtesy to the machine, not a gate on the code. Fairness is polling,
not first come first served. A held slot is released when the check ends,
and by the kernel if amont dies; an amont started by a check that holds a
slot (AMONT_HOST_SLOT set) does not queue. Windows has no slots.
amont.minVersion — the amont this repository means
git config amont.minVersion 1.11.0
Rarely set by hand — the committable spelling is set minVersion 1.11.0
in a trusted amont.conf,
which is the point: a binary one release behind answers every hook name
and simply lacks a check, silently, and nothing in the repository could
say which amont the team meant. A binary older than the floor gets one
warning line per stage naming both versions. Warn-only, deliberately:
blocking commits for being out of date teaches --no-verify, and a
binary too old to know this key cannot honour it anyway.
amont.knownIdentity — identities usual-name has vouched for
Written by the tool, not by you: when pre-commit-usual-name finds your
user.name <user.email> in history once, it records the identity here
(local config, never committed) and never walks the full history for it
again — git shortlog --all on every commit is milliseconds today and a
scale cliff on a long history. Multi-valued; amont uninstall removes it
with the rest of amont's bookkeeping. Delete a value to make the check walk
again.
amont.fix — let a check repair what it finds
git config amont.fix true
Off unless you ask. A hook that edits your files without being asked is a larger surprise than one that complains. See custom checks.
amont.testPushedTree — test what you are pushing
git config amont.testPushedTree true
By default pre-push runs your suite against the working tree, and says
so. That is fast and usually what you want, but it is not what you are pushing:
an uncommitted fix makes a broken commit look green.
With this set, the suite runs in a throwaway checkout of the commits being
pushed, and your tree is not touched. This covers every pre-push gate that
runs a suite — the built-ins and any pre-push line in your amont.conf
alike, each once per pushed ref. It costs a second checkout and a build
that cannot reuse your target/ cache, which is why it is opt-in rather than
the default. A checkout that needs a step before it can run anything — a
pnpm install, say — names it in amont.snapshotPrepare, above.
amont.attest / amont.attestKey — sign what pre-push proved, for CI
git config amont.attest true
Off by default. With it on, a push whose pre-push block gates all passed
leaves an ssh-keygen-signed note on each pushed tip in
refs/notes/amont-attest — binding the pushed tree, the names of the
gates that passed, and the amont version — and pushes that ref to the same
remote, so CI can verify the note and skip the test steps it names.
amont.attestKey points at the signing key; unset, it means
~/.ssh/amont-attest. The verifying side, the trust statement being made,
and why every failure falls back to CI running the tests are in
the CI backstop.
amont.trusted
Set by amont trust, read by everything that decides whether a declared
external may run. --local only, never committed. Do not set it by hand — see
the trust model.
commit.template
Not ours, but worth setting: it puts the footer scaffold in front of you when you write a commit.
git config --global commit.template ~/.config/git/git-templates/message
Environment variables
| variable | effect |
|---|---|
GIT_HOOKS_BIN | Absolute path to the binary a shim should use. First candidate in the shim's resolution order. |
AMONT_BIN_DIR | Where amont install and the installer script put binaries. Default ~/.local/bin. |
AMONT_VERSION | Pins the version the installer script fetches. |
AMONT_ATTEST_PUSH | Set by amont itself on the notes push amont.attest makes, so the recursive pre-push stands down. Not for humans. |
NO_COLOR | Honoured, as is a non-tty stdout. |
Repository-declared checks
A repository can add checks of its own without anybody forking anything, in a
committed amont.conf. They obey every control on this page, addressed the
same three ways, and they are inert until trusted.
Full reference: custom checks · the trust model.
Seeing the result
amont list # what would run here, and why not
amont list --json # the same, machine-readable
amont setup # walk the commit-style keys, with the current values
amont-fleet # the same, across every repository
amont list ends with the commit style in effect, and names the key and the
scope of anything you set:
commit style
gitmoji suffix amont.commit.gitmoji (global)
subject max 72
description max 68 amont.commit.descriptionMax (global)
body wrap off amont.commit.bodyWrap (local)
`amont setup` to change any of these
amont list reports the effective severity, after overrides — so a
check you downgraded three months ago is visible as downgraded rather than
having to be inferred from config. Across a fleet, amont-fleet shows
skips and severities per repository, with TRIGGER as its own column.
Gate evidence — reading the record the hooks already keep
A gate that stopped checking anything passes faster than one that works. That is the failure this page is about, and nothing inside the hook can see it: the exit code is 0, the output says what it always said, and the only thing that changed is the clock.
A fleet audit here on 2026-09-19 found four mechanisms in that state at once
— a lockfile audit run from a directory whose lockfiles were one level down,
a govulncheck built with a Go too old to read the vulnerability database,
uv invoked with no .venv to resolve against, and an npm that timed out
without saying so. Between them they hid 99 Python and 12 Go findings. Every
one of them had been green for weeks, and every one of them had gone from
minutes to milliseconds on the day it broke.
The hooks have been recording that number all along, one gate at a time, and nothing read it back.
The record
Every push writes one line per gate it ran into the note it already keeps in
refs/notes/amont-gate, keyed by the tree the gate ran against:
amont-gate-v1 pre-push-cargo-test
run 1726900000 pre-push-cargo-test pass 412391
run 1726903600 pre-push-audit-js fail 903
run <epoch> <gate> <outcome> <milliseconds>. Line one is the stamp, exactly
as it has always been — amont.pushStamps
reads that line and nothing else, so every version of amont that predates
these lines reads a note that carries them and sees no change at all. The
run lines are additive, optional, and are never consulted by any decision
about whether a check may be skipped.
That separation is deliberate and it is the safety property of the whole feature:
Evidence never gates. A stamp is a record that a check RAN on exactly this content; a
runline is a record of how it went. Forging arunline gets you a wrong row in a report. Nothing here can make a check be skipped, because skipping is decided by line one, which this feature does not touch.
Four things are worth knowing about the shape:
- Failures are recorded. The stamp deliberately has no opinion about a gate that failed — there is nothing to vouch for — but a dataset of only the pushes that succeeded could never produce a failure rate, so the blocked-push path records before it leaves.
- The key is the tree, so the fingerprint is free. Two runs filed under one tree read identical content. If they disagree, the content is not what changed.
- It is local. Notes in this ref are never pushed, never travel, and are
deleted by
amont uninstallalong with the rest of amont's own bookkeeping. Nothing here is a statement to anybody else's system — that is whatamont.attestis for, and its signed payload is a separate, versioned contract that this does not touch. - Only pre-push is recorded. Pre-commit checks run concurrently and every failure is reported, so neither of the questions below has an answer there; and the commit path's subprocess budget is guarded at 26 git spawns, which a note read and write on every commit in every repository would break for a report nobody is blocked on. A commit-time gate's twin is recorded when the push side runs it.
The report
amont-fleet gates # every repository under the scan root
amont-fleet gates --root . --depth 1 # just this one
amont-fleet gates --json
Per repository and gate: runs in the window, pass and fail counts, median and
last duration, how long ago it last ran — and the flags below. A gate that
has never run does not appear: this record knows what ran, and amont list
is the answer to what is declared.
no-op suspect
Two shapes of one failure, and both are needed.
- The collapse. The last run took less than 10% of the median, for a gate whose median is at least 30 s. Eleven minutes to four hundred milliseconds is the signature of a runner that found nothing to run.
- Born broken. The last run passed in under 1 s and no earlier run of that gate ever finished that fast. A gate misconfigured from its first day has no collapse to measure; what it has is a history that never once did real work.
Both are stated with their numbers attached, so the row can be argued with:
pre-push-audit-python NO-OP SUSPECT: last run 210 ms against a median of 41.3 s
(0% of it, threshold 10%)
flaky
Two runs against the same tree disagreed — one passed, one failed. The content could not have changed between them, so something outside it decided the verdict: a port, a clock, a shared fixture, a test that depends on another test's order.
stale
It has stopped running while its neighbours kept going: 10 later trees were judged by other gates and not by this one, or its last run is more than 30 days old while another gate ran more recently. A repository where nothing has run is quiet, not stale, and is reported as having no record.
Abstention
Under 5 verdicts in the window, no flag is computed and the row says so:
pre-push-cargo-test 2 runs 2 pass 0 fail … insufficient history (2 verdicts) — no flag computed
This is not politeness. A two-run history can be made to look like anything, and a column that cries wolf on one is a column people stop reading. The same rule covers a repository whose notes were pruned: no record is reported as no record, never as zero problems.
The thresholds are flags
Every number above is a default, printed at the top of every report and overridable per invocation:
| flag | default | decides |
|---|---|---|
--window <days> | 90 | how far back the report looks |
--min-runs <n> | 5 | verdicts below which nothing is flagged |
--noop-ratio <pct> | 10 | a last run under this share of the median |
--noop-median <secs> | 30 | …for a gate whose median is at least this |
--fast-pass <ms> | 1000 | a pass under this, never once seen before |
--stale-pushes <n> | 10 | later trees other gates judged and it did not |
--stale-days <days> | 30 | …or this long since it last ran |
gates reports; it does not gate. A finding is printed, not exited on — the
one non-zero exit is a scan that found no repositories at all, which is the
rest of the tool's rule.
Evidence ordering (opt-in)
git config amont.order evidence # default: declared
Pre-push runs its checks serially and stops at the first blocking failure (see hook architecture). Which means the ORDER decides how long a push that is going to fail takes to say so — and the registry's order is a fixed guess made once, for every repository.
With evidence, the push gates are ordered by this repository's own record:
the gates that have actually failed in the last 90 days are attempted first,
ordered by failures per unit of time — failures / runs / median duration — and everything else keeps its declared order behind them. The
ratio, rather than the failure rate alone, is what minimises the time a
failing push spends before it fails: a five-second audit that catches one
push in six is worth attempting before a twenty-minute suite that catches one
in three.
What it does not do, stated because an optimisation that quietly changes enforcement would be a much worse deal than a slow push:
- It never skips a check. The order is a permutation. Every gate that would have run still runs, and a gate is never assumed to pass because the record says it usually does. A prediction is not a run, and a check that does not run cannot be stamped or attested — the whole chain from the stamps to the attestation rests on a record of something that happened.
- It never moves the push-shaped checks. Only the scoped gates — the suites and audits, the ones whose verdict is a function of the content — are permuted, and only among the positions they already occupy. Branch-protect, branch-pattern, secrets and pull-rebase keep theirs absolutely: the registry orders them "cheapest and most decisive first", and discovering a protected branch after twenty minutes of tests is exactly the waste this feature exists to remove.
- With no record it is the declared order, exactly. A fresh clone, a pruned ref, a git that would not answer: all of them fall back, and the first push after turning the key on changes nothing.
- It is reported. When the order differs from the declared one, the push says so and names the order it took.
It can be set per machine (git config amont.order evidence) or committed
for the team (set order evidence in amont.conf, which is
trust-gated like every other policy line). A local git config
outranks the committed value, as it does for every key.
What this is not
- It is not a test selector. Nothing chooses which tests a suite runs; amont does not know what is inside one.
- It is not a prediction, and it does not act on one. Every flag above is a statement about runs that happened, and the only thing the ordering does with a prediction is decide what to try first.
- It does not replace looking.
no-op suspectis a suspicion, named as one. Its job is to put a number in front of somebody, not to conclude. The 2026-09-19 audit found its four mechanisms by hand, and the point of this page is that it should not have had to.
Opting out
Four different things get called "turning it off". They are listed here smallest first, because the right answer is usually the smallest one.
0. Tune it instead of turning it off
If what you want gone is a rule rather than a check — the gitmoji in every subject, the 50-character description budget, the body wrapping — none of the four answers below is the right one. Those are settings:
amont setup # walks you through them, with the current values
This matters most for commit-msg, which is the one hook hook.skip
genuinely cannot reach (see below). Changing what it asks for is
the only lever there is, and it is a real one. See
commit conventions.
1. One command: --no-verify
git commit --no-verify
git push --no-verify
git commit --no-verify skips pre-commit and commit-msg for that one
commit; git push --no-verify skips the whole pre-push stage.
prepare-commit-msg and post-commit still run — that is git's behaviour,
not ours, and it is why a bypassed commit still gets its message prepared and
still fails to earn a gate stamp.
Bypassing stays a supported escape hatch, and it is now counted: a commit
that dodged a commit-time gate adds a line to a local ledger
($(git rev-parse --git-common-dir)/amont-bypasses) that amont list
summarises as "unverified commits". Local means local — the file is never
pushed and never leaves the machine. git config amont.recordBypasses false
turns the counting off; amont uninstall, or deleting the file, erases it.
Neither commit-msg nor prepare-commit-msg takes hook.skip or a severity
override: they are entrypoints rather than checks, so the keys below do not
name them. To get a message past commit-msg without bypassing everything
else, fix the message — or change what it asks for, which is what §0 is
about.
2. One run, one check: -c
git -c hook.skip=clippy commit -m "fix: …"
git -c hook.skip=clippy -c hook.skip=prettier commit -m "fix: …"
Nothing is written to config, so there is nothing to remember to undo.
3. Permanently, in this repository
git config --add hook.skip pre-commit-clippy # that one check
git config --add hook.skip clippy # that check, either trigger
git config --add hook.skip pre-commit # every pre-commit check
These are exact names, not globs. hook.skip e reaches nothing at all, and
skipping lint-js leaves lint-json-yaml alone — a skip can never silently
couple two checks whose names happen to share a prefix.
To see and undo:
git config --get-all hook.skip
git config --unset-all hook.skip
A skipped check is announced on every commit, on purpose. A hook.skip
line nobody remembers writing is exactly how a repository ends up with a check
everyone believes is running.
Usually better: warn instead of skip
git config amont.severity.clippy warn
git config amont.severity.pre-commit warn
The check still runs and still reports; it just stops failing the commit. You
keep the signal, which is the thing hook.skip throws away. This is the right
first move when adopting a check into an existing repository with a backlog.
4. Remove the hooks entirely
amont uninstall # this repository
amont uninstall --binary # …and the binary from ~/.local/bin
amont-fleet uninstall --root ~/Developer
This removes our six shims and nothing else. A hook you wrote yourself is
left alone and named in the output, whatever it is; a hook it cannot even read
is named too, rather than passed over in silence. hook.skip and
amont.severity are never touched — those are your statements about your
repository.
If init.templateDir is still set, uninstall says so loudly and gives you the
command to unset it. Without that, an uninstall you believed had finished would
leave every future git clone re-installing the hooks:
git config --global --unset init.templateDir
Do not use rm .git/hooks/*
That glob deletes every hook in the
directory — including ones other tools installed and ones you wrote — in order
to remove six files that belong to us. amont uninstall exists precisely
so that removing our hooks never means removing yours.
A repository is asking to run its own checks
If amont list shows a check with:
declared in an untrusted amont.conf — review it, then `amont trust`
then that repository has declared checks and they are already not running.
There is nothing to opt out of. Opting in is amont trust, after reading
the file. See the trust model.
The trust model
A repository you clone cannot run code on your machine until you say it may.
That is the whole claim. This page is how it is enforced and where it stops.
The problem it solves
A repository can declare checks of its own in a committed
amont.conf, and those declarations are shell
commands. Committing the file is the point — it is how a team shares a check.
The consequence is that cloning a repository and committing to it would otherwise run commands that repository chose, and neither of those acts is one anybody performs as a decision about trust. Reviewing a diff before running it is such a decision. Nothing asked for that.
This matters most for people who set init.templateDir
(see install), because for them the hooks are already present in
every repository they clone — including one cloned only to read.
What happens instead
A manifest is inert until trusted. Its declared checks are listed by
amont list with the reason they will not fire:
declared in an untrusted amont.conf — review it, then `amont trust`
amont trust # show what this repo declares, and accept it
amont trust --show # what is trusted here
amont trust --revoke # forget it
amont trust prints the declarations before asking. That is not politeness:
"trust this file" is not a question anybody can answer without seeing it, and a
prompt that does not show the file is a prompt that trains people to press y.
amont trust outside a repository is refused rather than falling back to
.. Trust is recorded per repository, keyed by the root that resolves — so a
. fallback would let amont trust in ~ read ~/amont.conf, show
its declarations, and record trust against a repository that does not exist, in
a state no later --revoke would find.
Consent is bound to content, not to a path
The record is a fingerprint of the file, stored in --local git config
under amont.trusted — local, never committed, so a repository cannot
declare itself trusted.
The key is multi-valued: it holds every manifest you have accepted here,
most recent last, capped at sixteen. --local config is shared by all of a
repository's worktrees, so a single value made them fight — accepting one
checkout's manifest reported every other checkout on a different branch as
TRUSTED ONCE, AND CHANGED SINCE and stopped its declared checks, until
somebody re-accepted there and broke the first one. With a worktree per task
the record never settled. Since consent is keyed on content rather than on
place, a set is the honest shape: two worktrees with the same amont.conf
need one acceptance between them.
One consequence, stated rather than buried: reverting a manifest to bytes you accepted earlier no longer asks again. Those bytes were reviewed, and the cap bounds how far back that reaches — but it is a weaker guarantee than a single value gave.
Because it is keyed on content, a git pull that adds a command does not
inherit the consent given to the file before it. That state is reported
distinctly from "never trusted", because somebody changed it is a different
thing to tell a reader than you have not looked at this yet:
amont.conf changed since it was trusted — review it, then `amont trust`
That is a gap, not a block, when the change arrived from somewhere else:
the checks it declares show as "could not run" and your commit proceeds,
because a command that never ran has judged nothing. It is a block when
the commit at hand is the one changing amont.conf
(pre-commit-manifest-trust). You are the author of that content, and
without it every check the file declares would stand down for exactly the
commit that introduces them — letting everything else in that commit through
ungated. The check names the fix and trusts nothing itself; accepting is still
amont trust, and still yours. It stays quiet during a merge, rebase,
cherry-pick or revert, where a manifest arriving from another branch is the
pulled case. Downgrade it like any check:
git config amont.severity.pre-commit-manifest-trust warn.
Why git hash-object --no-filters
amont links no external crates (and CI enforces
that),
and the only hash in std is DefaultHasher — SipHash with a fixed key, not
collision-resistant, so a crafted manifest could be made to match a trusted
one's fingerprint. Hand-writing SHA-256 is a hundred lines nobody would review
as carefully as they should.
git is already a hard dependency of every path in this binary, and
git hash-object is the identity git itself uses for content. It is SHA-1 (or
SHA-256 in a repository configured for it) — not a guarantee against a
determined attacker with a chosen-prefix collision, but enormously better than
SipHash, it costs no dependency, and you can reproduce it by hand to check
what you trusted:
git hash-object --no-filters amont.conf
git config --local --get-all amont.trusted # every manifest accepted here
--no-filters is the load-bearing flag. Without it, git applies the clean
filter and eol conversion that the repository's own committed
.gitattributes asks for — so the repository would be choosing the transform
its consent is taken through, and two manifests the parser reads differently
could be given the same id. Consent is bound to the bytes that are parsed.
For the same reason, where a caller already holds the file's bytes, the state is decided about those bytes rather than by re-opening the path. Two reads of a file somebody is deciding about can disagree, and the decision would then be recorded about bytes nobody was shown.
Two windows that were closed, and how
Between showing and answering. amont install prints the manifest, then
blocks on a keypress — sometimes for several seconds — before recording
anything. Re-hashing at that point would trust whatever is on disk then,
which is not necessarily what was shown. So callers fingerprint what they show
before asking, and pass that same value back to be verified again once the
answer is in. If the file changed in the window, nothing is trusted and the
prompt says so.
Concealing a declaration inside the listing. Every field in that listing is repository-controlled text, and it is the text somebody is about to say yes to. It is sanitised before the column padding is computed: a terminal escape sequence is zero columns wide, so it would silently shift the alignment even if it did nothing worse — and a repository that can move the rendering can hide a line from the person consenting to it. A repository must not be able to pick how its own consent is rendered any more than it can pick how it is hashed.
A vendored pack is consented to like anything else
amont add (custom checks) copies
declarations from somebody else's repository into your amont.conf. It grants
no trust, and it gets no exemption:
- the append changes the file's content, so the fingerprint no longer matches
and every declared check goes inert — including ones you had already trusted
— and
pre-commit-manifest-trustblocks the commit that carries the append until you have reviewed it; - the pack's rows are shown by
amont trustalongside your own, in the same listing, with no marking that would invite skimming past them; - editing a vendored row by hand revokes consent exactly as editing a hand-written one does.
The commit id recorded in the block says where the text came from. It is provenance, not authority: it tells you the bytes are the ones that repository published, and says nothing about whether the commands are a good idea. Reading them is still the gate, and it is still yours.
What this does not protect against
Stated plainly, because a security boundary described only by what it stops is a marketing claim:
- It is not a sandbox. Once you trust a manifest, its commands run with your privileges. Trust is a review gate, not containment.
- It says nothing about the built-in checks. Those are code in the binary
you installed, and are governed by
hook.skipand severity, not by trust. - It does not protect a repository you wrote the manifest in. Your own
amont.confis trusted by you, once. - SHA-1 is the floor, in a repository using git's default object format. See above for why that trade was taken.
To report something this model gets wrong, see SECURITY.md.
Custom checks — amont.conf
A repository can declare checks of its own. Put a amont.conf at the
repository root, commit it, and the hooks run it alongside the built-ins.
# stage name scope severity command
pre-commit lint-shell *.sh block scripts/lint-shell.sh
pre-commit protos *.proto,*.pb block buf lint
pre-push smoke * warn make smoke
Five whitespace-separated fields, in file order. Alignment is cosmetic; tabs and single spaces parse identically.
Why the file is committed
.git/hooks is not committed, so a hook script dropped in there can never
actually be shared: every member of the team has to install it by hand, and
nothing tells them when it changes. A committed manifest is reviewed like any
other change, arrives with a git pull, and is visible in the fleet
dashboard.
The fields
stage — pre-commit or pre-push.
name — the short name. Together with the stage it forms the check's id,
<stage>-<name>, exactly as a built-in has: a line reading
pre-commit lint-shell … declares pre-commit-lint-shell.
That id is what amont list and the dashboard show, and either the id or the
short name addresses it in hook.skip and in a severity override — the same
three-way vocabulary the built-ins take:
git config hook.skip pre-commit-lint-shell # that check
git config hook.skip lint-shell # that check, on either stage
git config hook.skip pre-commit # every pre-commit check, declared ones included
The same name on both stages is two checks, and that is allowed:
show-unicorn on pre-commit and on pre-push gives you
pre-commit-show-unicorn and pre-push-show-unicorn, each separately skippable
and separately downgradable. See What a repository cannot do for the limits.
scope — * for every change, or a comma-separated list mixing *.<ext>
extensions and bare filenames: *.ts,package.json,.prettierrc. A bare
token matches the path's basename exactly, anywhere in the tree — an
extension list cannot say package.json without also matching
not-package.json, which is why this is its own kind of token.
A directory is a token of the form dir/**/*.ext: a path is in scope when
it sits under dir/ and ends with .ext. claude-plugin/**/*.md covers
claude-plugin/skills/x/SKILL.md and never claude-plugin-old/SKILL.md. The
directory is whole segments, with no glob characters, no empty segment and no
. or ..; the extension is a plain suffix. Anything else containing a /
or a glob (claude-*/**/*.md, claude-plugin/*.md, x/**/*) is refused as a
parse error. Evaluated against the files staged for a commit, or against the
range being pushed. This gate is real: a *.sh check does not
run on a commit that touches no shell.
A + adds the second half — what the repository must carry:
pre-commit rubocop *.rb+.rubocop.yml block rubocop
"a staged .rb, and this repository has a .rubocop.yml." Both sides are
comma-separated and each is an OR — any trigger, any one of the opt-ins —
while the two halves are an AND. With nothing on the left, +Gemfile reads
"any change, in a repository that carries a Gemfile".
The separator is the one amont list already prints between the halves:
○ rubocop (declared) inert here — needs .rb + .rubocop.yml
What you read in the listing is what you write in the file.
Why it exists. Every builtin has this: clippy stays inert without a
Cargo.toml, yamllint without a .yamllint.yaml. Declarations did not,
because the manifest was the opt-in — you typed the line, so you wanted the
check. amont add ended that: a vendored line is
in your file because you took a whole pack, and without a condition a packaged
rubocop fires on every .rb in a repository that never wanted it and just
errors.
A + that names no file (*.rb+) is refused rather than read as "no
condition" — that is the opposite of what it was reaching for. A filename
containing + is not expressible.
severity — block fails the stage; warn runs the check, prints whatever it
prints, and lets the commit through. It is your choice, per check.
command — the rest of the line, split on whitespace and executed directly
from the repository root, through the same program resolution the built-ins
use — so npx and friends work on Windows, where a bare Command::new cannot
start a .cmd. On pre-push it runs once per pushed ref, over that ref's
changed files, and — with amont.testPushedTree
— in a throwaway checkout of that ref's tip rather than your working tree,
exactly as the built-in test suites do.
Your command gets the file list
Two ways, both carrying exactly the paths the scope matched — the same set the
gate above judged, which a wrapper re-running git diff --cached can diverge
from (amont run --all-files overrides the set in-process):
$AMONT_FILES, always: the matched paths, newline-separated, relative to the repository root. Empty when the set would not fit in an environment variable (very large change sets) — treat empty as "derive it yourself".- the
filesmarker, opt-in: prefix the command withfilesand the matched paths are appended to the argv, the way a built-in hands its tool the staged list:
pre-commit lint-shell *.sh block files scripts/lint-shell.sh --strict
With files, a commit whose matched set is empty does not run the command at
all — most linters error on an empty argv, and a commit must not be blocked
over nothing.
-
the
docs-skipmarker, opt-in, pre-commit only: prefix the command withdocs-skipand the check does not run when the staged change only edits existing documentation. Documentation is a.md,.mdx,.rstor.adocfile, outsideadr/, that is a modification: an added, deleted or renamed file, a code file, a.txt(arequirements.txtis a dependency manifest) or a decision record always runs the check. The skip is printed asskipped — the commit only edits existing documentation. Adocs-skipon apre-pushline is a parse error.This is a trade, and the
adrline in amont's ownamont.conftakes it: a documentation edit that adds a link to a file that does not exist will pass commit time. A skipped check is not recorded as having passed: it earns no commit-time stamp, so a same-namedpre-pushdeclaration still runs and an attestation never tells CI the gate is covered. CI and the push-side checks see it.
There is no shell
No pipes, no redirection, no globbing, no quoting. make smoke works; find . | xargs foo does not — put that in a script and invoke the script.
Two reasons, and the second decided it. Windows has no sh, and every emulation
of one this project has tried has been a source of bugs. And a manifest line that
silently gained shell semantics would be a much larger thing to have introduced
than it looks.
Exit codes
0 passes. Anything else fails, and whether that stops you depends on the
severity column.
A command that cannot be started at all — a typo'd path, a tool nobody installed — is neither. It is reported as a gap:
⚠ amont.conf: lint-shell could not run scripts/lint-shel.sh — No such file
⚠ 1 check(s) could not run: lint-shell
It does not block, because a command that never ran has not judged anything; reporting it as a lint failure sends someone hunting for an error that does not exist. But it is never silent, because a check that has quietly never executed is the one failure this whole design is arranged against.
A line nobody can parse is not skipped
The same rule applies to the manifest itself. A malformed line still produces a check — one that reports on every commit and says which line and what was wrong:
⚠ amont.conf: oops — line 3: severity "LOUD" must be `block` or `warn`
Silently ignoring it would mean a check somebody committed months ago has never run once and nothing ever said so.
Repo policy — severity, skip, and set lines
The manifest can also carry the TEAM's decisions about the built-ins, so
"clippy is warn-only here" is a committed, reviewed line instead of sixty
people running the same git config incantation:
severity clippy warn # runs, reports, does not block — here
severity pre-push-pytest block # and this one always blocks
skip yamllint # never runs in this repository
Targets use the same three-way naming as hook.skip — full id, short name,
or a whole trigger — and a target that names no check here is reported once
per run, with its line number, rather than silently doing nothing.
Trust-gated, like everything else this file says. Untrusted policy is
inert and announced (amont.conf policy not applied: …) — a repository you
cloned to read cannot weaken your safety net until you consent, and the
trust prompt shows the policy lines you are consenting to.
Precedence is a specificity ladder, per key: built-in default < system
config < global config < policy < local config < worktree < command
(git -c). Your git config --global preferences yield to the team's
committed decision; your LOCAL config in that repository still beats it —
the developer owns their machine, and every documented escape hatch keeps
working. Between DIFFERENT keys naming the same check, key specificity
decides regardless of source: a policy severity pre-commit-clippy …
outranks a local amont.severity.pre-commit, and vice versa a local full id
outranks a policy trigger. Skips are a union of all sources — nothing can
un-skip, so there is no conflict to order.
(On a git too old for --show-scope, this degrades fail-safe: ALL git
config beats policy. See configuration.)
set — committed thresholds and commit style
The same file can carry the numeric and style knobs the checks consult:
set largeFileWarn 1 # warn above 1 MB, in this repository
set commit.subjectMax 50 # the whole team's subject budget
set <key> <value> takes the key exactly as you would write it after
git config amont. — matched case-insensitively, values parsed by git
itself, so set largeFileBlock 2k means what git config would mean by
it and a bad value complains the same way. The same ladder applies: a
policy value beats your global config, and your local config in that
repository still beats the policy.
Only these keys are settable: largeFileWarn, largeFileBlock,
commit.gitmoji, commit.subjectMax, commit.descriptionMax,
commit.bodyWrap, autoRebase, timeout, lockWait, testPushedTree,
snapshotPrepare, snapshotDeps, snapshotCarry, order, treeLint,
treeLintSlack, treeLintWait, treeLintRehearsalTimeout,
snapshotPrepareOutputs, and minVersion — the last being the team's version
floor: a binary older than set minVersion 1.11.0 says so once per stage,
warn-only, instead of silently lacking the checks the team added since.
The three snapshot* keys say how a checkout of the commit becomes
runnable, which the repository knows and every clone would otherwise have
to discover by hand: snapshotDeps how dependencies arrive (install,
reuse, off), snapshotCarry which untracked files are copied in, and
snapshotPrepare anything else. snapshotPrepare is the one whose value
is a whole command. It runs in the snapshot, through the shell, before any
suite, and consent covers it exactly as it covers a declared check's
command: policy binds only on a trusted manifest, the trust prompt prints
the command, and editing the line revokes the trust. See
configuration.
A set value runs to the end of the line, so a command with spaces and
flags is written plainly:
set snapshotPrepare pnpm prisma generate
set snapshotCarry .env
Any other
key is refused with its line number — most deliberately amont.fix,
because a committed file must not change what already-trusted commands
may DO to your working tree (see below). One caveat worth reading before
committing it: set timeout accepts the full configured range, including
86400 and 0 (which disables the per-check deadline entirely) — a
review of that line is a review of how long a hook may hang everyone.
What a repository cannot do
Take a built-in's id. pre-push branch-protect … is refused: it would
either shadow pre-push-branch-protect or silently lose to it, and a text file
should not be able to do either. The same name on the other stage is fine —
pre-commit branch-protect … is a different check and shadows nothing.
Write the stage into the name. pre-commit pre-commit-clippy … is refused.
It would declare a check whose short name is another check's full id, so a single
hook.skip pre-commit-clippy would silence both and no rule could pick between
them. So is a name that simply is a stage. The stage column already says which
one this is.
Declare the same id twice. The second is refused: it could not be addressed
by hook.skip or by a severity override, so it would run anonymously. Two lines
with the same name on different stages are two ids, and both run.
Grant its own trust, or reach the machine-level knobs. Policy stops at
severities, skips, and the allowlisted set keys: amont.fix (rewriting
your working tree), the trust decision itself, amont.conventions, and
the observability opt-outs stay per-machine — a committed file must not
change what already-approved commands are permitted to DO, which is a
different consent than "I read these commands".
Run before the built-ins. Externals are appended to each stage, always. A
third-party command must not be able to delay pre-push-branch-protect, and
appending is the only arrangement in which it cannot. On pre-push, which stops
at the first blocking failure, that means a built-in failure means your check
does not get a turn.
A manifest is inert until you trust it
A repository you cloned can declare checks, and running them is a decision you
make — not one git clone makes for you. So nothing in amont.conf runs
until somebody accepts it here:
amont trust # shows what it declares, then records it
amont trust --show # what it declares, and whether it is trusted
amont trust --revoke
amont install asks once, with the declarations in view. Declining still
installs the built-ins.
Until then the checks are reported, not dropped — the point is that you can see there is a decision waiting:
⚠ amont.conf: lint-shell — declared in an untrusted amont.conf …
⚠ 1 check(s) could not run: lint-shell
Acceptance is recorded against the file's CONTENT (git hash-object, which you
can run yourself), so a git pull that adds a command does not inherit the
trust you gave the file before it. When that happens the message says so —
changed since it was trusted — because "somebody edited this" is a different
thing to be told than "you have not looked at this yet".
This is a floor, not a ceiling. A built-in check still runs your
repository's own toolchain: prettier and eslint are taken from
node_modules/.bin when present, so a hostile node_modules needs no manifest
at all. That is the same exposure npm install already carries.
Pinning tool versions
The checks run whatever prettier, ruff or shellcheck this machine has —
and when two machines disagree, the hook "passes here, fails in CI", which
reads as flakiness and trains people toward --no-verify. A tool line
turns that skew into a printed fact:
# tool <program> <version-substring>
tool ruff 0.6.
tool shellcheck 0.10
Once per hook run (both stages), each pinned tool's --version first line is
checked for the substring; a mismatch or an unrunnable tool warns, naming
both sides. Warn-only, always — skew never blocks a commit, because the
fix is a human decision about which side to move.
A substring, not a semver range: 0.6. pins a minor, 0.6.3 a patch, and
the point is agreement between machines, not range arithmetic. Pins are
trust-gated like every declaration — verifying one executes
<program> --version for a name the repository chose, which is exactly the
consent amont trust collects.
Whole-tree gates — tree lines
A tree line declares a whole-tree check whose pass may be attested, so
CI skips the step that runs the same command (ADR-0024 in the fleet's
decisions, ci.attested-skip):
# tree name tool scope attest [options] command
tree eslint eslint * attest npm run lint -- {cache}
tree ruff-check ruff * attest uvx ruff@0.16.0 check packages
tree ruff-format ruff * attest uvx ruff@0.16.0 format --check packages
tree pyright pyright * attest cwd=services/api uv run pyright
A tree gate never decides a commit. It is not a pre-commit or pre-push
check, so it has no severity column: attest stands where the severity
would be, and says what the line is for. The commit is judged by the checks
it always was.
-
name — the gate's token in the attestation note is
tree-<name>, which is what CI'sif:names. It matches[A-Za-z0-9][A-Za-z0-9._-]*(59 characters at most) and, like every name here, must not start with a trigger. -
tool — one of
eslint,prettier,ruff,pyright,gofmt. It is declared, never guessed from the command:npm run lintdoes not say what it runs, and amont needs to know what{cache}expands to and which version the gate really executes. -
scope — the same column as a check's.
-
options — lowercase
key=valuetokens directly afterattest:cwd=<dir>: the directory the command runs in, relative to the repository root;inputs=<path>,...: extra paths that belong to the gate's cache namespace.
Paths are literal. A glob, an absolute path or a
..is refused. An uppercaseNAME=valueis not an option; it begins the command, soNODE_OPTIONS=… npm run lintis still expressible. -
command — the exact command the CI step runs.
{cache}is the only thing amont may add, and it is allowed once, foreslintandprettieronly. It expands to the tool's cache flags, which never change a verdict.
The command CI must run is the normalized declaration: {cache}
removed, then a dangling trailing --, then whitespace collapsed.
npm run lint -- {cache} becomes npm run lint. See
the CI backstop for how the two are kept
identical.
A tree line is trust-gated like every other line: until you trust the
manifest, no tree gate runs. A pack may not carry one.
When a tree gate runs, and what it proves
At commit, a tree gate starts beside the pre-commit checks. It gets at most
amont.treeLintSlack seconds (default 2) after those checks finish. It
starts only when its cache is warm: a full run already completed in the
gate's current namespace. The namespace is a hash of:
- the command;
- the version of the tool it runs;
- every lockfile and config-like file (
*.json,*.toml,*.yaml,*config*,.*rc*,.*ignore, by basename); - the gate's
inputs=.
A plugin upgrade therefore starts a new, cold namespace, and the old cache is
deleted. A warm gate also has to fit. amont remembers how long each gate's last
run took, and how long the repository's own declared commit checks took.
When nothing long is in scope, as on a docs-only commit where no test run
covers it, a gate that would outlast the slack is skipped, not started
and cancelled: the commit stays fast, and CI lints. It shows up as slow
in the evidence. A cold gate does not slow the commit either: amont starts
amont warm --worker in the background (log: .git/amont-warm.log), and the
next commit can prove it. amont warm does the same in the foreground. There
is no background warm-up on Windows, where tree gates do not run at all.
When it passes on exactly the committed tree, the commit and its tree are
stamped tree:<name>. The push then attests tree-<name>, and CI skips its
step. No gate starts, and nothing is stamped, when:
- tracked files have unstaged edits;
- an untracked file is present;
- an ignored file is present outside
snapshotPrepareOutputs, which admits tool caches (node_modules/,.venv/,__pycache__/, …) by default. Add the repository's own reproducible outputs, the ones CI recreates too, withset snapshotPrepareOutputs build/ .react-router/; - a merge, rebase, cherry-pick or revert is in progress.
A failing, slow or cancelled gate never blocks: it prints one line, and CI lints.
Letting a check fix what it finds
Prefix the command with fix and the check may rewrite files, with whatever it
changed re-staged:
pre-commit format *.js block fix npx prettier --write
Two conditions, both deliberate:
- Off unless you ask.
git config amont.fix true, per repository — and the consequence is worth stating in bold: withamont.fixoff, afix-declared check does not run at all, not even in check-only mode. It reports "not run" (a warn, never a block) on every commit, because the one command you declared is a rewriting command and running it would edit files nobody asked to have edited. A team that commits afixdeclaration gets zero enforcement from every member who has not personally opted in — if you need the check to always judge, declare a second, non-fixline with the tool's check mode. A hook that edits your files without being asked is a larger surprise than one that complains. - pre-commit only.
fixon apre-pushline is a parse error, reported on every commit like any other bad line. A pre-push hook must not modify the worktree or index: the pushed commit would then differ from the tree you are looking at.
Re-staging is safe because the pre-commit stage holds your unstaged changes aside first, so the tree contains what you staged and nothing else — anything a formatter touches is by definition part of this commit. Work you deliberately kept back is never swept in.
The built-in prettier check does this too, under the same amont.fix gate.
Turning one off
Exactly as for a built-in:
git config hook.skip lint-shell # do not run it
git config amont.severity.lint-shell warn # run it, do not let it block
Both surfaces read the same three names, and nothing matches by substring:
the full id (pre-commit-lint-shell), the short name (lint-shell, on either
trigger), and the trigger (pre-commit, meaning all of them). Three exact
comparisons, in runtime::names_check — hook.skip = e reaches nothing at all,
and skipping lint-js leaves lint-json-yaml alone.
Prefer the severity downgrade anyway. The two do different things:
| runs | reports | blocks | |
|---|---|---|---|
amont.severity.<key> warn | yes | yes | no |
hook.skip <key> | no | no (only that it was skipped) | no |
A downgrade keeps the check working and keeps you looking at what it finds; you
have decided the finding should not stop a commit, not that you no longer want
to know. A skip removes the signal along with the block, so the problem it was
watching for grows silently until somebody turns it back on. Reach for skip
when a check is genuinely inapplicable to a repository, and for severity when
it applies but should not be a gate.
Seeing what you declared
amont list
pre-commit
● merge-conflict
● lint-shell (declared)
✗ oops (declared) amont.conf line 3: severity "LOUD" …
pre-push
● branch-protect
● smoke (declared)
● runs here ○ inert ⊘ skipped via hook.skip ✗ declaration unusable
Across the fleet, amont-fleet has a DECL column — 2 for two declared
checks, 2!1 when one of them cannot run — and lists them per repository in the
detail pane.
Shipping a check — packs
A check you wrote could always be run; until amont add it could not be
shared, except by pasting a line into somebody's manifest or upstreaming it
into amont itself.
A pack is any git repository with an amont.pack at its root, written in
exactly the syntax above:
# amont-pack-java — Spotless for a JVM repository, Maven or Gradle
pre-commit spotless-maven *.java+pom.xml block mvn -q spotless:check
pre-commit spotless-gradle *.java+build.gradle,build.gradle.kts block ./gradlew -q spotlessCheck
$ amont add github:fredericrous/amont-pack-java@v1
github:fredericrous/amont-pack-java @ 2f5dbd9 declares:
pre-commit spotless-maven *.java+pom.xml block mvn -q spotless:check
pre-commit spotless-gradle *.java+build.gradle,build.gradle.kts block ./gradlew -q spotlessCheck
amont.conf changed — these commands cannot run until you review them:
amont trust
<source> is github:owner/repo, forgejo:host/owner/repo, or any git URL —
including a local path, which is all a test fixture needs. --dry-run shows
without writing.
That pack is real, and it is the worked example for everything below: fredericrous/amont-pack-java. Its README is the long-form version of this section.
What ships is text, not execution
This is the whole design, and it is what separates it from the ecosystem
model pre-commit built. pre-commit clones a repository and executes it,
building an isolated environment per hook. amont add copies rows into your
amont.conf, and stops:
- the rows land between
# amont:pack:start/# amont:pack:endmarkers with the pack's commit id, so what you got is recorded next to what you run; - the manifest's content changed, so its fingerprint no longer matches and every declared check — the pack's and your own — is inert until you trust it;
- editing a vendored row by hand revokes consent exactly like editing any other line. A pack's rows are not privileged.
Adding a pack is therefore never the moment anything becomes runnable. It is a way to avoid typing.
Pinning, and what "verify" means here
@v2 is a tag, and a tag moves. amont add resolves it with git ls-remote
before fetching, then refuses whatever arrives unless it is that commit —
so a ref that moves between the two steps is an error, not a surprise. The
commit id, never the tag, is what gets written into your manifest.
That is also why the transport is git and not HTTP. amont links no crates and has no TLS stack; git is already a hard dependency, it is content-addressed, and it brings SSH, private repositories and self-hosted forges with it for free.
Making a packaged check well-behaved
A pack's rows land in somebody else's repository, so gate them on that repository actually using the tool:
pre-commit rubocop *.rb+.rubocop.yml block rubocop
pre-commit terraform-fmt *.tf block terraform fmt -check
The first is inert in a Ruby repository with no rubocop config; the second
needs no opt-in because a .tf file in the diff already says everything.
Without that condition a pack is a promise to run somebody's linter on every
matching file whether or not they configured it — which is how a useful pack
becomes an uninstalled one.
What a pack may not carry
Checks, and nothing else. tool pins, severity, skip and set lines are
refused, and the whole pack with them.
Those lines are policy about the repository installing the pack — see What a
repository cannot do. A skip could silence your
secrets scan; a set could raise your large-file ceiling. A third party
proposing commands you will read is one thing; a third party quietly changing
what your existing checks do is another, and the second is not on offer.
A pack is refused whole: one bad row and nothing is written, because a half-applied pack is a manifest neither side asked for.
Publishing one
A pack repository is the amont.pack and nothing else — no schema to satisfy,
no registry to join, no build step. The example above is three files, and two
of them are the README and the licence:
amont-pack-java/
├── amont.pack
├── README.md
└── LICENSE
Test it before it goes anywhere. A local path is a source, so the whole loop is:
amont add ../my-pack --dry-run
Then tag it, and let people pin the tag:
git tag v1.0.0 && git push origin v1.0.0
git tag -f v1 && git push -f origin v1 # moving major alias, optional
A moving alias is safe here in a way it is not for a CI action, because what
lands in a consumer's manifest is the commit id and never the tag: @v1
resolves once, at install time, and then stops moving. The cost of that is
that a consumer does not get your fixes by pulling — they get them by running
amont add again, which is also a fresh amont trust. That is the trade the
whole design makes, and it is the right way round: nothing you publish later
can start running on somebody's machine without them reading it first.
Updating and removing
amont add the same source again and its block is replaced — not appended,
which would declare the same id twice and break the manifest. To remove a pack,
delete its block; it is plain text, and the markers say where it ends.
Why this format and not TOML
TOML would be nicer to write and costs a dependency tree that would then run on
every commit in ninety-six repositories. For four fields and a command, twenty
lines of std parsing wins. See scripts/check-no-deps.sh for the reasoning
behind that default; it is a judgement about the commit path's supply chain, not
a prohibition, and a genuinely rich format would be worth reopening it for.
Ideas, not a roadmap
Nothing here is planned, promised, or assigned. Ideas are kept with their objections attached, because a rejected-for-now idea with its reason is more useful than a list that quietly loses the ones somebody already thought about.
Already landed
- Lint more languages. Python (
ruff,pyright), Rust (cargo fmt,clippy,cargo test), YAML (yamllint) and Kubernetes (kubeconform,kube-linter, Argo) all have checks now. See the checks. - A repository declaring its own checks, which was the general answer to
most of the "add a hook for X" requests:
amont.confmeans a check that belongs to one repository does not need to be in everybody's binary. See custom checks.
Still open
post-commit: tag automatically when the package version is bumped. Noted at the time as possibly harmful depending on your CD workflow, and that objection has if anything got stronger: a tag that triggers a publish should not be created by a hook nobody invoked deliberately.pre-commit: check the other lockfiles —Gemfile.lock,Pipfile.lock,Cargo.lock,composer.lock— the waypre-commit-package-lockalready does for npm.pre-commit: check you are committing with the usual GPG key. Same shape aspre-commit-usual-name. Flagged as possibly slow, which is the thing to measure before building it: this runs on every commit.pre-push: require a JIRA id in the branch name.prepare-commit-msgalready extracts one when it is there; requiring it is a different, more opinionated thing, and probably belongs in a repository's ownamont.confrather than in the built-inbranch-pattern.pre-push: prevent a force-push to a remote branch with a different name. The original note asks "almost impossible?" and does not answer it.commit-msg: a message alias —"."expanding to"(previous prefix): more on <previous subject>".commit-msg: require a description of more than three words and a body of more than five. Word counts are a poor proxy for a meaningful message and are easy to satisfy meaninglessly; the current rules constrain shape (a type, a description, lengths) rather than trying to measure effort.
Out of scope
Functionality already covered by .gitattributes and .gitignore should not
be reimplemented as a hook.
Before proposing one
Ask whether it belongs in the binary at all. A check that is right for your
repository — a house lint, a schema check, a smoke test — can be declared in
amont.conf today, shared with your team by committing it, and skipped or
downgraded by the same hook.skip and amont.severity keys as any built-in —
or by committed severity/skip policy lines in the same file.
A built-in earns its place by being right for most repositories, and by being
inert in the rest.
Everything in the commit path also has to be paid for in dependencies, which is to say: in nothing. See CONTRIBUTING.md.
How it compares
The field is good. pre-commit, lefthook and husky are all mature, widely used and worth your time. This page is what you get by choosing amont instead — and what you give up.
At a glance
| amont | pre-commit | lefthook | husky | |
|---|---|---|---|---|
| Runtime it needs | none — one binary | Python (hooks bring their own environments) | none — one binary | Node.js — already present in the projects it targets |
| Useful before you write any config | 39 built-in checks, scoped to what the repo uses | starts empty | starts empty | starts empty |
| On the commit path | zero external crates, CI-enforced | Python + a managed environment per hook | Go binary | Node + node_modules |
| A cloned repo's committed config runs code… | only after you review it and amont trust | after pre-commit install, unreviewed | after lefthook install — often automatic via a package postinstall | after npm install — the prepare script activates it |
| Your unstaged work during a run | held aside without git stash, restored even if a check panics | git stash around the run | untouched — checks see the worktree, not the staged set | your problem — hooks are your scripts |
| Uninstall | removes exactly the five files it wrote, names everything else | pre-commit uninstall | lefthook uninstall | delete .husky/, unset core.hooksPath |
| Commit-message conventions | built in — validated, wrapped, limits and gitmoji configurable | via a separate hook | via commitlint etc. | via commitlint etc. |
| One view across all your repos | amont-fleet — bulk install, report, dashboard | per repo | per repo | per repo |
| Machine-readable state for coding agents | amont list --json, amont agents-md | no | no | no |
The three arguments
It works before you configure it. Every other manager on this list installs
a framework and hands you an empty file: nothing runs until you have decided
what a good commit looks like and written it down, per repository, in that
tool's YAML. amont ships the decision — conventional commit subjects, no
merge-conflict markers, no describe.only leftovers, the linters and
formatters for the languages your repository actually uses, a test-suite gate
on push — and scopes it automatically: a repository with no ruff.toml never
needs ruff, a JavaScript repository never invokes cargo. What is left for you
to configure is the exception, not the baseline, and every knob is plain
git config.
The commit path is the smallest attack surface in the field. A hook
manager runs on every commit, with your credentials, reading every staged
file. husky puts node_modules on that path; pre-commit puts a tree of
cloned hook repositories with their managed language environments there. The
amont binary links no external crates — std only, with a CI script that
fails any build that changes it — and the installer verifies its download
against published checksums before placing the binary. And where the others
run a cloned repository's committed config as a side effect of routine setup —
npm install, a postinstall, an unreviewed pre-commit install — amont
holds a repository's declared checks inert until you have read them and said
amont trust. Nobody else on this list has that gate.
It is built for the failure cases. What happens to your unstaged work when
a formatter rewrites files mid-commit — and when it panics mid-rewrite? What
happens when a check's tool is not installed, a config line is malformed, or a
hook.skip was set three years ago and forgotten? amont has a designed
answer to each: unstaged work is held aside without git stash and restored
even on panic; a check that cannot run is reported as a gap rather than passed
or silently dropped; a skipped check is announced on every commit. These cases
decide whether a hook manager is trusted or worked around, and most of this
project's engineering lives there.
What the others do better
Stated plainly, because a comparison that finds no trade-offs is an advertisement:
- pre-commit's ecosystem is enormous. Hundreds of ready-made hooks, and it bootstraps each hook's language environment for you. amont deliberately refuses to be a package manager — a check that cannot find its tool warns or fails loudly, and the tool stays your problem. For turnkey environments around many exotic tools, pre-commit is the right call.
- lefthook's config is very expressive — glob routing, piped commands,
scripts, tags, per-OS overrides — where
amont.confis deliberately five columns and no shell. - husky is nearly nothing, which is a real virtue: two lines of shell in a committed file and you are done. An all-Node team that reviews everything may never fall into the gaps husky leaves open.
- The built-ins carry opinions — conventional commits, branch naming, a set of commit types. Every check can be downgraded or skipped with exact, announced config, and the commit-message rules are themselves tunable — but a blank slate is the one thing this tool is not.
Credit where due
Ideas taken from the others, with the full record in index fidelity and run modes:
- From pre-commit: a repository declaring its own checks in a committed
file, so a team shares a check by committing it rather than by each member
installing it by hand — taken as
amont.conf, with a trust gate added in front, because a committed manifest is a committed command and cloning is not consent. - From all three: the observation that filename-prefixed
.git/hooks/pre-commit-*scripts can never be shared, because.git/hooksis not committed.
Also in the neighbourhood: overcommit [Ruby], lint-staged, commitlint, devmoji, git-fancy-message-prefix.
Hook architecture: one Check trait
Status: shipped. PRs 1–3 below are done, and docs/rust-migration.md
carried the result the rest of the way; what came after this document is
index-fidelity-and-run-modes.md. Kept as
the design record — the "what is wrong today" section describes the state
before this landed, not the state now.
What is wrong today
Four tables keyed by check name, kept in step by reconciliation tests:
| table | where | holds |
|---|---|---|
REGISTRY | amont-runtime/registry.rs | name → fn pointer |
PRE_COMMIT_CHECKS | same file | order, pre-commit |
PRE_PUSH_CHECKS | same file | order, pre-push |
LANGUAGES | amont-fleet/checks.rs | name → language scope |
Adding a check means editing three of them and writing a module. The tests that keep them aligned are good tests, but they exist to police a shape that should not be splittable in the first place.
Three entry-point signatures and eight differently-named entry points,
reconciled by closures in the registry. The signatures are
(&[OsString]), (&str, &[OsString]) and (&[PushRef]); the eight functions
not called run are ruff, pyright, argo_lint, kube_linter,
kubeconform, fmt, clippy and test.
Three shapes is not itself scandalous — the closures adapt them fine. It matters because a uniform signature is what lets a check be a value rather than a special case, and that is what makes an external check indistinguishable from a built-in to the dispatcher.
Two models of "does this apply here". Each check scopes itself internally
from staged files and the nearest manifest; the dashboard separately infers
applicability from root manifests, and checks.rs documents its own answer as
an approximation. Two implementations of one question, one of which admits it
is guessing.
No extension point at all. File-discovered sub-hooks were removed when checks moved in-process (they had two users in 96 repos). A third party now has no way to add a check without recompiling the binary.
Severity is implicit in a return value. Fifteen sites warn and then return 0. Those collapse two different situations, which is the finding that most changes this design — see below.
The pattern
One trait, two implementors. Strategy, with the metadata attached to the strategy rather than kept in a parallel table.
#![allow(unused)] fn main() { pub trait Check { fn name(&self) -> &str; fn stage(&self) -> Stage; // PreCommit | PrePush fn scope(&self) -> Scope; // declarative; see below fn severity(&self) -> Severity; // Block | Warn fn run(&self, ctx: &Ctx) -> Outcome; } }
Builtinwraps a fn pointer. Oneconstdescriptor per check carries name, stage, scope and severity beside the function.Externalruns a command declared in a committed manifest.
The dispatcher holds Vec<Box<dyn Check>> — built-ins in declared order, then
externals — and stops caring which is which.
What this removes: four tables become one declaration per check, and the
reconciliation tests become unnecessary rather than merely passing. That is
the win. Three signatures and eight entry-point names become one shape. scope() becomes authoritative, so the
dashboard asks the check instead of guessing, and the approximation caveat can
be deleted rather than documented.
Outcome distinguishes three things a check can mean
#![allow(unused)] fn main() { pub enum Outcome { Passed, Failed, // ran, found a problem Warned, // ran, found something non-blocking Fixed, // ran, found a problem, REPAIRED it Unavailable, // COULD NOT RUN } }
There are FIVE. Fixed arrived with Fix::Rewrite (see
docs/index-fidelity-and-run-modes.md §2): the check ran, found a problem, and
repaired it, and the commit proceeds with the repair staged. That is neither
Passed — something happened and the author's files changed, which they should
be told — nor Failed, since nothing is blocking. Reachable only from a
Stage::PreCommit declaration, which the compiler enforces: a pre-push hook
must not modify the worktree or index, or the pushed commit would differ from
the tree the developer is looking at.
Shipped without the detail / reason payloads the sketch carried. Every check
already prints its own diagnosis at the moment it has the context to phrase it;
threading the same string back for the dispatcher to print again produced two
messages about one problem. The variant is the whole signal.
Failed is NOT Default — there is no Default, deliberately. It was
Failed, to fill the slot of a check whose thread died, and check.rs records
why that was removed:
Deliberately NO
Default. It used to beFailed, to fill the slot of a check whose thread died — a real rule, butDefaultmeans "the neutral value" to every reader and to every#[derive(Default)]that might later contain one. The rule is now written where it applies, in the runner.
So the rule survives and only its location changed: dispatch::run_stage passes
Outcome::Failed explicitly as the fill value, under a comment saying "a check
whose thread died has not passed". Stated where the slot is filled, rather than
hidden in a trait impl that any future #[derive(Default)] would silently
inherit.
Unavailable is the important addition. Today ruff config found but no ruff/uvx binary prints a warning and returns 0, which is indistinguishable
from ruff running clean — to the dispatcher, and to the dashboard. A repo where
a check has silently never executed reads as a repo where it passes.
That is the same failure that hook.skip had before the dispatcher announced
skipped checks, and it cost three PRs to notice there. Modelling it means the
dashboard can show ran clean separately from never ran, which is the
difference between a green fleet and an unverified one.
Classifying the fifteen sites turned up an ordering bug it would not otherwise
have found. Three checks — yamllint, kube-linter, kubeconform — tested for their
binary BEFORE testing whether the repo had opted in, so a repo that never wanted
yamllint was told to install it, and under Outcome would have reported a gap it
did not have. One repo in the fleet configures yamllint; the nag reached the
other ninety-five. All three now test the opt-in first, and Unavailable means
what it says: this repo asked for the check and the tool was missing.
One site was reclassified in the other direction. An unpinned uvx ruff prints a
caveat about which ruff spoke — but it RAN, and a clean verdict from it is a
pass. The caveat is advice, not a gap.
Severity is declared, and choosable
Block fails the stage; Warn reports and continues. It lives on the check, so
a built-in and an external are governed the same way.
Two consequences worth stating:
Fail-fast applies only to Block. pre-push stops at the first failure
because later steps are expensive and their preconditions are gone. A Warn
check that finds something must not stop the chain — it has not invalidated
anything.
A severity override is a better escape hatch than hook.skip.
git config amont.severity.<check> warn downgrades a check instead of
disabling it. hook.skip is all-or-nothing and, as measured, invisible enough
that a one-line config edit could disable everything unnoticed. A downgrade keeps
the signal
and removes only the block, which is what people usually want when they reach
for --no-verify. I would ship this alongside, and expect it to become the
common case.
Severity::parse and Severity::as_str are the ONE mapping between the
configured words and the enum, and registry::effective_override is the one
answer to "what will the dispatcher apply here". There were four of the former
and two of the latter — the dashboard's copies being its PREDICTION of the
dispatcher, which is the one thing it must never get wrong. It did: the
dispatcher asks --get (last entry wins), the dashboard listed every entry from
--get-regexp and treated each as authoritative, so a global warn overridden
by a local block was reported as a live downgrade.
Shipped, with one property the sketch did not state: an unrecognised value falls
back to the declared severity rather than to warn. Git validates nothing here,
so a typo would otherwise be a silent disable — the exact failure this feature
exists to replace.
And the dashboard has to show it. A downgrade is quieter than a skip: the
check runs, prints its failure in red, and the commit passes. Nothing on screen
distinguishes it from enforcement, so the fleet view carries a WARN column and
a per-repo amont.severity block. That block separates three cases a config
line cannot: a real downgrade, an explicit block (the default written out), and
a line that changes nothing because the check name or the value is misspelt.
Scope, declared rather than reimplemented
Scoping is a conjunction, not a choice between alternatives:
ruff .py/.pyi AND ruff.toml | .ruff.toml | pyproject [tool.ruff]
yamllint .yaml/.yml AND .yamllint.yaml | .yamllint.yml | .yamllint
prettier js-ish AND .prettierrc | .prettierrc.json | …
clippy .rs AND Cargo.toml
So it is a struct, not an enum:
#![allow(unused)] fn main() { pub struct Scope { /// Extensions that trigger it. Empty = any change. files: &'static [&'static str], /// Config paths that opt the repo in. Empty = always on. opt_in: &'static [&'static str], } }
An earlier draft made these alternatives — StagedFiles(..) or
Manifest(..) — plus a Custom escape hatch for anything that fitted neither.
That was wrong twice over. Neither variant expresses "both", so every check with
an opt-in config would have fallen into Custom; an escape hatch that absorbs
most of the set leaves the dashboard knowing nothing, which is precisely the
guessing this trait exists to remove.
All twenty checks fit the struct. merge-conflict is files: [], opt_in: [].
package-lock is files: [], opt_in: ["package.json"]. kube-linter — one of
the cases Custom was invented for — reads repo-root .kube-linter*.yaml,
which is just an opt_in entry.
Coarse declaration, precise execution
rust_tools resolves the NEAREST ancestor Cargo.toml, which no static
declaration captures. Its Scope therefore says opt_in: ["Cargo.toml"],
meaning "somewhere in this repo" — coarser than what the check enforces when it
runs, and deliberately so.
That is safe because the two readers ask different questions. The dispatcher asks "does this apply to the staged files of this commit" and the check answers precisely, as it does today. The dashboard asks "would this ever fire here", for which the coarse answer is correct. One declaration, evaluated against staged files in one case and tracked files in the other.
Over-approximating is also the safe direction: showing a check as applicable
when it happens not to fire for a given commit is a small inaccuracy, while
Custom meant the dashboard could not answer at all.
External checks
A third party cannot add a Rust module without rebuilding the binary, so extension means declared commands.
The manifest is committed at the repo root, which is the point.
.git/hooks is not committed, so a team could never share a custom hook — a
worse flaw than the lexicographic ordering usually cited against the old
filename-prefix mechanism.
# amont.conf — stage name scope severity command
pre-commit shellcheck *.sh block scripts/lint-shell.sh
pre-push smoke * warn make smoke
Whitespace-delimited, order of file, ~20 lines of std parsing. Full reference in custom-checks.md.
Three rules the sketch left open, all decided the same way — by asking what a committed text file should be able to do to a hook chain:
- A built-in's name is refused. An external calling itself
pre-push-branch-protectwould either shadow the built-in or silently lose to it. - A duplicate ID is refused — the same name on two triggers is two checks
and both run; the same name twice on one trigger is the clash. It could be
addressed by neither
hook.skipnor a severity override, so it would run anonymously. - A line that cannot be parsed is not skipped. It becomes a check that runs
to
Unavailableand says which line and why, appearing in the same "could not run" roll-up as a missing binary. Dropping it silently would mean a check somebody committed months ago has never run and nothing ever said so.
On the format: this is a judgement, not a constraint. The dependency guard
(scripts/check-no-deps.sh) is a strong default about the commit path's supply
chain, not a prohibition — see its comment. TOML would be nicer to write and
costs a dependency tree running on every commit in 96 repos. For four fields I
take the twenty lines; for a genuinely rich format the trade is worth reopening.
Externals run after built-ins, and cannot be reordered ahead of them: a
third-party command should not be able to delay branch-protect.
Developer experience
amont list— every check, stage, scope, severity, and whether it would run here. Externals are listed too, marked(declared), and a line that could not be parsed gets its own glyph: correctly-inert, disabled, and unusable are three different things and none may look like another.- Adding a built-in: one module plus one descriptor; the compiler names what is missing.
- Adding an external: edit a committed file, no rebuild.
amont-fleetgains third-party checks in its views, which it cannot see today at all.
Named future: amont explain <check>
Not built. It was in the list above, among shipped DX, which made "why didn't prettier run" look like a question the tool answers.
Half of it exists: amont list says whether a check would run HERE and why
not — inert, skipped, or an unusable declaration, as three distinct glyphs. What
is missing is the other half, the retrospective one: why a check did or did not
fire on the commit you just made. Today that is a code-reading exercise.
Migration
PR 1 — the trait, no behaviour change. All 19 modules and the dispatcher. High mechanical risk, no user-visible payoff, so it lands alone, proved inert by a differential over hook output rather than by the test suite (tests will legitimately change shape).
PR 2 — Outcome and Severity. DONE. The 15 warn-and-return-0 sites are
classified one at a time: 12 Unavailable, 2 Warned (a waived-past lockfile, a
first-time author identity), 1 left Passed (unpinned ruff, which ran). Config
override shipped. Dashboard shows downgrades apart from enforcement.
PR 3 — external checks. DONE. Manifest, parser, External, amont list,
and the fleet views. Two things the sketch did not anticipate:
Scope had to gate externals at RUN time. For a built-in it is a declaration
the dashboard reads, because the check enforces its own scope in its first three
lines. A declared command cannot — it has no idea what was staged — so without a
gate here *.sh would have run on every commit and the column would have been
decoration. Which files it is judged against depends on the stage: what is staged
for a commit, what is in the range being pushed for a push.
Parsing had to be split in two. External holds a Scope, whose &'static
slices are leaked; that is fine in a hook process which reads one manifest and
exits, and wrong in a dashboard that reads ninety-six and may re-read them on
every refresh. parse_lines returns owned Lines and only External::from
leaks, with a test pinning the two to the same answers.
What came next
Built, in docs/: this document's plan shipped as #57–#67. The comparison
against pre-commit, lefthook and husky that followed is
index-fidelity-and-run-modes.md — four ideas
worth taking, one refused on the record, and one correctness gap this document
did not notice.
Open decisions
DoesRESOLVED: no, and the question was better than it looked. Checking what the checks actually key on showed theCustomscope survive?Scopeenum modelled alternatives where the truth is a conjunction, soCustomwould have swallowed most of the set rather than the one case it was written for.Scopeis a struct now and every check fits it.- Can an external check be
Blockat all? Decided: yes, severity is on the trait and the author chooses. Worth revisiting if a repo ever ships a hostile or flaky one. 2b.Can an external run before a built-in?RESOLVED: no, and not configurably. Externals are appended to each stage. - Does
amont listbelong in the hook binary or the fleet tool? The fleet tool has the nicer output; the hook binary is what is installed everywhere.
Index fidelity, fixes, and run modes
Status: mostly shipped. This began as a specification and said so — "Nothing here is built" — and stayed saying it for six sections after five of them landed. README links here twice as the authority on trust and on run modes, so a reader was being sent to a design document for an answer about behaviour they already had.
| § | what | status | where it lives |
|---|---|---|---|
| 0 | activation, uninstall, bulk install | shipped | install.rs, amont-fleet install/uninstall |
| 0b | manifest trust | shipped | trust.rs, amont trust [--show] |
| 1 | index fidelity (staged-only) | shipped | staged_only.rs, dispatch::pre_commit |
| 2 | stage_fixed | shipped as Fix::Rewrite + Outcome::Fixed | check.rs, hooks/common.rs::restage |
| 3 | not_during git-state conditions | shipped | check.rs::GitState, registry.rs::MID_OPERATION |
| 4 | amont run [--all-files] | shipped | main.rs, dispatch::run_named |
| 4b | amont check <paths…> | shipped | content.rs, finding.rs |
| 5 | shebang detection | not built | — |
§5 is the only one still a proposal. Everything below the numbered sections — What we are not taking and What this does not solve — is unchanged and still current: those are refusals and known gaps, not a backlog.
The prose in each section is kept in its original tense, because the argument for a thing is worth more than a description of it, and rewriting the reasoning into the past tense would lose why each decision went the way it did. Where the implementation ended up somewhere other than where the design pointed, that is called out in the section itself rather than quietly edited away.
Read against pre-commit, lefthook and husky, then re-read as somebody who
would have to get this through an adoption review. Four of their ideas are worth
taking, one is worth refusing on the record, the first is not a missing feature
at all — it is a correctness gap we have been describing as a trade-off — and
before any of them there is a trust problem that has nothing to do with the
three tools and everything to do with a decision we already shipped.
The last two sections are the ones an adoption review reads first: What we are not taking and What this does not solve. The second is a list of honest noes — pinned tool versions, CI enforcement, DCO — and it is deliberately not a backlog.
0. A cloned repository can run its own commands
Shipped. Per-repository activation is the default;
amont install/uninstallandamont-fleet install/uninstallare all real verbs.install.rscarries the routine, andcrates/amont/tests/ install.rsandcrates/amont-fleet/tests/uninstall.rscarry the guards.uninstallalso removes the shims from the template directory and says so loudly ifinit.templateDiris still set.
amont.conf is committed, which is the point: a team shares a check by
committing it. The consequence had not been written down.
git clone seeds .git/hooks from init.templateDir, so a fresh clone arrives
with our shims already installed. The manifest is then read from that repository
and its commands are executed. No prompt, no trust decision:
$ git clone hostile victim && cd victim
hooks present after clone: commit-msg post-commit pre-commit pre-push prepare-commit-msg
$ git commit -m "feat: an innocent commit"
>>> arbitrary code from the cloned repo <<<
Cloning a repository and committing to it is not an act of trust that anyone performs deliberately. Reviewing a diff before running it is; nothing here asks for that.
pre-commit has the same property. That is not a defence — it is a decade-old
known quantity with an ecosystem that has argued about it in public, and ours is
undocumented. docs/custom-checks.md presents externals purely as a
convenience. §2 of this document then proposes stage_fixed, which upgrades the
primitive from run a command to run a command that rewrites my files and
stages the result, and that must not ship into an untrusted manifest.
Where the exposure actually comes from
Not from the manifest, and not from the shims. From one line in our own README:
git config --global init.templatedir ~/.config/git/git-templates/templates
We never set that key in code — amont install writes files and touches no
config. The README asks the user to make every future clone on the machine
managed, and that ambient grant is what turns a committed manifest into a
drive-by.
It also quietly undermines the fleet's own model. managed vs unmanaged is
supposed to be a decision the dashboard reports; with init.templateDir set,
everything cloned since is managed and "unmanaged" means "cloned before I
configured this". A category that records the date you ran a git config
command is not a category.
The design: activation is the boundary, and templateDir opts out of it
Two modes, both supported, and the difference is what you granted.
Per repository is the default. Hooks run where somebody put them and nowhere else:
amont install # this repo
amont uninstall # this repo — remove shims, leave the binary
amont-fleet install # every managed-eligible repo under a root
amont-fleet uninstall
amont install already does the per-repo half. amont-fleet has scan,
fix and tui, with --apply behind fix — bulk activation exists but is
named after repair rather than intent, which is why nobody reaches for it when
they mean "set this up".
A clone is then inert until asked. The drive-by case is gone, not mitigated: there is no hook to run.
Everywhere is init.templateDir, and it stays supported as a deliberate
opt-in rather than being removed. Git copies the template into .git/hooks on
every init and every clone, so hooks are never forgotten and the fleet never
shows an uncovered repository. That is a real benefit and people who want it
should be able to say so.
What matters is that setting it is a standing grant, made once, for every repository you will ever clone — and it therefore opts out of activation being the trust boundary. It cannot be otherwise: the whole point of the key is that nobody is asked again.
So the honest statement is a conditional, and the README now carries it:
| mode | who decides a repo runs hooks | what closes the drive-by case |
|---|---|---|
| per repository | you, per repository | activation itself |
init.templateDir | you, once, for all future clones | only manifest trust |
That is not an argument against the key. It is an argument that §0b is not a second layer of defence for the people most likely to set it — it is the only one — which raises its priority rather than lowering it.
It is necessary and not sufficient
Worth being precise, because it is tempting to stop here.
Explicit installation removes the case where you clone something to read it.
It does not remove the case that matters most in open source: you clone a
stranger's repository because you intend to contribute, you run
amont install because you want your own checks while you work, and their
amont.conf runs on your first commit.
amont install means I want my hooks here. It does not mean I have read
this repository's committed commands and accept them. Those are two different
grants and only one of them was made.
So: one prompt, at the moment of the deliberate act
Shipped — this is §0b.
crates/amont-runtime/src/trust.rs, surfaced asamont trustandamont trust --show, withamont installoffering it interactively. The record is keyed on the manifest's CONTENT (viagit hash-object, because the binary links no crates andstd's only hash is a fixed-key SipHash a crafted manifest could collide), so agit pullthat adds a command does not inherit consent given to the file before it.crates/amont/tests/trust_display.rs.
Which is where activation-as-the-boundary improves on the direnv design rather
than replacing it. direnv must prompt lazily, on cd, because there is no
install step to hang the question from. We have one:
$ amont install
✓ installed /Users/me/.local/bin/amont
✓ baked 4 shims into .git/hooks
⚠ amont.conf declares 2 checks that would run on your commits:
shellcheck pre-commit *.sh block scripts/lint-shell.sh
smoke pre-push * warn make smoke
Trust them? [y/N]
One question, asked once, at a moment the user is already thinking about this
repository. Declining still installs the built-ins — the manifest simply stays
untrusted, and reports as Unavailable with a reason rather than being silently
skipped:
⚠ amont.conf declares 2 checks and is not trusted here — `amont trust`
⚠ 2 check(s) could not run: shellcheck, smoke
Trust records a hash of the file in git config, so a later edit re-arms it —
manifest changed since you trusted it — and a git pull that adds a command
cannot inherit the consent given to the file before it.
What none of this fixes
A built-in check still runs tool binaries the repository can influence:
resolve_tool prefers <root>/node_modules/.bin/<tool>, so a hostile
node_modules is executed by prettier or eslint with no manifest involved and
no trust prompt to decline. That is inherent to running a repository's own
toolchain — the same exposure npm install already carries — but it means both
halves above are a floor, not a ceiling, and the README should say so rather
than implying the manifest was the only door.
The cost of the per-repository mode, and why the fleet absorbs it
Not setting init.templateDir means a fresh clone has no checks at all until
somebody installs them. For a codebase whose whole argument is do not look
protected when you are not, that deserves stating rather than burying: the
failure mode moves from "a hostile repo ran code" to "my repo was never
covered", and the second is quieter.
It is also the failure the fleet dashboard already exists to catch — and this is
what makes the unmanaged column earn its place. With init.templateDir set it
is close to noise, because everything cloned since is managed and "unmanaged"
records the date you ran a git config command. Without it, the column is the
point of the tool: which of your ninety-six repositories are not covered, with
amont-fleet install as the fix.
Both modes are legitimate. They trade a quiet failure for a loud grant, and the tool should let you pick which one you would rather explain.
uninstall, which is missing regardless
We can disable a check (hook.skip), downgrade one (amont.severity) and
install everything. There is no supported way to take it off — a user who wants
out deletes five files by hand and leaves a stale binary in ~/.local/bin.
uninstall at both levels, and it must be honest about what it removes: shims
yes, the binary only when asked, hook.skip/severity config never, since those
are the user's statements about their own repository and not our artefacts.
Ordering
This lands before stage_fixed, and arguably before anything else here. It
is the only item on the list that is a security property rather than a
correctness or ergonomics one.
The two halves can ship separately and in this order: activation and
uninstall first, which is a README change plus two verbs and closes the
drive-by case on its own; then the trust prompt, which needs the install flow to
hang from and is much smaller once it exists.
1. We name the staged files and then read the unstaged ones
Shipped, as
crates/amont-runtime/src/staged_only.rs, wrapped around the wholepre-commitcheck stage indispatch::pre_commit. The mechanism is NOT the one designed below — see The design and The danger for what changed and why.crates/amont/tests/ index_fidelity.rsis its suite, including the Ctrl-C case.
staged_files() asks the index for the path list, which is right:
#![allow(unused)] fn main() { git::stdout(&["diff", "--diff-filter=d", "--cached", "--name-only"]) }
Those paths are then handed to a tool that opens them from the working tree:
#![allow(unused)] fn main() { pub fn run(root: &str, argv: &[String], extra: &[String]) -> bool // …cmd.args(extra).current_dir(root) ← `extra` is the path list; the tool reads the file }
So a partially-staged file is judged by content that is not being committed.
git add -p half of a.js, commit, and prettier reads the whole working-tree
file: it fails on lines you did not stage, or passes on lines you did.
This is systemic, not local. Of the fifteen pre-commit checks:
| checks | how | |
|---|---|---|
| reads the tree — affected | 11 | prettier, lint-js, lint-json-yaml, yamllint, ruff, pyright, argo-lint, kube-linter, kubeconform, cargo-fmt, clippy |
| reads the index — correct | 2 | merge-conflict (git grep --cached), ban-terms (git show :<file>) |
| reads no file content | 2 | package-lock (path names only), usual-name |
We already have the technique. ban_terms selects candidates with
git diff --cached and then reads each one with git show :<file> — the index
blob, never the tree. The two checks that get this right are the two that were
ported most carefully, which is a hint about the other eleven rather than a
coincidence.
That suggests a second possible fix, and it is worth saying why it is not the
one to take. git show :<file> is enough when a check only needs CONTENT, which
is why it works for ban-terms. It is not enough for a tool invoked on a path:
prettier resolves its config by walking up from the file, kubeconform needs the
kustomization directory around it, and ruff needs the file to sit where its
pyproject.toml can be found. Feeding those a temp file changes their answer.
The stash puts the right content at the right path, which is the only fix that
serves all eleven.
pre-push has the same bug, from the other end
pre-push has no index at all, so this looks like a pre-commit problem. It is
not. rust_tools::test and run_tests::run compute the changed file set from
the pushed refs — correct, that is what is being pushed — and then run the
suite with current_dir(dir), i.e. against the working tree:
#![allow(unused)] fn main() { let changed = crate::pushrefs::changed_files(refs); // what you are pushing let roots = cargo_roots(&root, changed.iter()…); // where to run each_root(&roots, None, &["test", …]) // runs in the WORKING TREE }
So the suite can pass on an uncommitted fix, or fail on an uncommitted experiment, and in neither case has it tested the commits being pushed.
The fix is not the same one. Stashing is wrong here: a push is not a staging
operation, and the honest question is "does the pushed tree pass", which means
running against the pushed commit — a worktree or git archive of the tip
rather than the developer's tree. That is more expensive and wants its own
decision, which is why it is named here and scheduled separately rather than
folded into the stash work.
rust_tools.rs:149 calls it out and then accepts it:
Note this inspects the WORKING TREE, not the index, so a partially-staged file is judged by its unstaged form too. Same trade-off cargo fmt gives everyone; scoping it to staged paths would need the edition resolved by hand.
The first sentence is true of all eleven. The second is the mistake:
it is not a trade-off cargo fmt gives everyone, it is one pre-commit removes
for everyone. Their wording is worth quoting because it names both failure
directions:
Running hooks on unstaged changes can lead to both false-positives and false-negatives during committing. pre-commit only runs on the staged contents of files by temporarily stashing the unstaged changes while running hooks.
The design
A guard around the whole pre-commit stage, not per check. Per-check stashing is wrong: twenty checks run concurrently and would fight over one working tree. It belongs around the whole fan-out.
That much shipped unchanged. The mechanism did not, and the difference is the
most important thing on this page, because the design below said git stash --keep-index and the implementation refused it — twice.
#![allow(unused)] fn main() { // WHAT WAS SPECIFIED, and is not what runs: struct StagedOnly { stash: Option<StashRef> } // `git stash --keep-index` }
#![allow(unused)] fn main() { // WHAT SHIPS — crates/amont-runtime/src/staged_only.rs pub struct StagedOnly { held: bool } impl StagedOnly { pub fn enter() -> Result<StagedOnly, String>; } impl Drop for StagedOnly { /* restore, ALWAYS */ } }
Saving is the easy half; restoring is the whole problem, and both stash-shaped mechanisms failed it:
git stash --keep-indexwas tried first.stash popMERGES into a tree that already holds the staged content, so it writes conflict markers into the author's file. Measured, not predicted.git diff+git applywas tried second — deterministic on Unix, and whatpre-commititself does. But it applies PATCH semantics to text, and Git for Windows converts line endings by default. Every restore test failed on Windows and passed everywhere else, which is the worst possible shape for the one routine here that can lose somebody's work.
So: byte-exact copies. Read the file, put it back. No patch to apply, no newline policy to agree about, and binary files need no special case. It costs a temporary copy of only the files that have unstaged changes.
The store is $GIT_DIR/amont-held/, and its layout is itself the result of an
incident:
$GIT_DIR/amont-held/
index NUL-delimited, one record per parked path:
format tag, then kind + path + (mode | symlink target)
files/<rel> the payloads, byte for byte
Metadata used to be encoded in the payload FILENAMES —
<name>.amont-absent, <name>.amont-symlink beside the copies — and a
repository is allowed to contain files with those names. A repo tracking both
notes and a modified notes.amont-absent had notes DELETED from the
working tree on restore, because a suffix strip turned one file's payload into a
statement about another. The symlink form was worse: the repo chose both the
link name and an arbitrary absolute target, so committing in it planted a
symlink pointing anywhere on the machine. Escaping cannot fix it, because
amont restore runs in a later process with only the filenames to go on. So
the metadata moved out of band into index, and everything repo-controlled
moved under files/, where it cannot collide with index whatever it is
called. The index carries each entry's KIND (modified / absent / symlink) and,
for a modified file, its working-tree FILE MODE — without which an unstaged
chmod +x deploy.sh came back non-executable, invisible to every content-based
assertion.
The danger, stated plainly
A stash that is taken and not restored loses uncommitted work. That is a worse failure than any this repository has had, including the two that overwrote tracked files, because there is nothing on disk to recover from.
Rules, all of which wanted tests and all of which now have them, in
crates/amont/tests/index_fidelity.rs:
- Nothing unstaged → do nothing, and silently. The common case must not touch the tree, and it is not a degraded run — there is no unstaged content for a check to be confused by.
- Restore in
Drop, so a panicking check (which we catch — #64) and an early return both restore.Dropruns on unwind. Dropdoes not run on a signal, and that is the likely case. Ctrl-C during a slow pre-commit — eslint over a large tree, a coldcargo fmt— kills the process without unwinding, and the parked work is orphaned. An interrupt is the most probable route to losing work, not the least, soStagedOnlyinstalls aSIGINT/SIGTERMhandler that restores and then dies BY the signal. Restoring from a thread of its own rather than from the interrupted call stack introduced a race withenter(), whichENTER_LOCKcloses; the test that found it isctrl_c_mid_run_still_restores_and_dies_by_the_signal. Windows has the same net viaSetConsoleCtrlHandler(Ctrl-C, Ctrl-Break, console closed) — the handler already runs on its own thread there, restores under the same lock, and then lets the default action terminate.- A recovery path for when even that fails:
amont restoreputs back what this tool parked. Belt and braces, because the handler can itself be interrupted. - Restore failure is fatal and loud: print the STORE'S PATH, do not swallow
it, block the commit. Not
git stash list— nothing here is a stash ref, so the work is findable as files on disk under$GIT_DIR/amont-held/. - Never park when the tree is already mid-operation — merge, rebase,
cherry-pick. §3's
GitStatepredicate is what answers this, which is why it landed first. - Conflicted paths abort the stage rather than being worked around. This
is now true, and it was not for a while.
StagedOnly::entertests for unmerged paths FIRST and returns anErrthatdispatch::pre_committurns into a printed message andVerdict::Block; the conflict test comes before the mid-operation test deliberately, because the other order made an ordinary conflicted merge take the mid-operation branch and warn instead of aborting. It is safe by construction: git itself refuses a commit with unmerged entries, so nothing that would have succeeded now fails.
Reproduced
$ git show :x.json # staged: {"a": 2} ← valid
$ cat x.json # working tree: { THIS IS NOT JSON
$ amont pre-commit
✗ Invalid JSON: x.json
🚨 Error raised by: pre-commit-lint-json-yaml
The commit that was about to be made is valid. The hook blocked it anyway. This is now a test rather than a transcript.
Decision
cargo fmt joins staged-only mode. Its scope is a crate, not a file list, but
that is exactly why the stage-level guard is the right fix: once unstaged
changes are held aside, cargo fmt --check sees staged content at the normal crate
paths and still resolves the manifest, edition and rustfmt config the same way
it does today. The misleading comment in rust_tools.rs should be deleted when
this lands; the trade-off was an implementation gap, not an inherent cargo
constraint.
Done. rust_tools.rs now records the correction in place of the claim.
Writes that land while the checks run
Two kinds of process write to the tree during the hold, and they are treated differently:
- An editor save is work. The restore compares each held file against the
content the checkout put there; a file that changed mid-run is KEPT, and the
held (pre-commit unstaged) version is parked in
$GIT_DIR/amont-preserved/with a printed pointer. Before this guard existed, the restore overwrote the save silently — the one way this module could destroy something. - A fixer (
amont.fix true) rewrites held files as its job, and telling its writes apart from an editor's is not possible from inside the restore. With fixing on, the guard stands down and the documented contract holds unchanged: the tree returns to your unstaged version, the fix lives in the index.amont.fixis an explicit opt-in; the guard protects everyone else.
The checkout that starts the hold is also scoped to exactly the held paths
(spelled :(literal) — a file named *.rs is a name, not a glob), so a
dirty commit costs git a walk of the changed files, not of the whole tree.
2. stage_fixed — a formatter that fixes should re-stage
Shipped, under different names: the declaration is
Fix::Rewriteon a check inregistry.rs, the opt-in isgit config amont.fix true, the re-staging ishooks::common::restage, and the result isOutcome::Fixed. Three checks declare it — prettier, ruff and cargo-fmt — and all three now genuinely repair; two of them declared the fix for a while without having any fixing code, whichamont list --jsonreported to agents as a capability.crates/amont/tests/fixing.rs.
From lefthook's job options: "automatically add modified files back to git staging".
Three of our checks currently print an instruction and stop:
✗ Prettier found unformatted files. Run prettier --write on:
You then run the command yourself and commit again. lefthook's users run
prettier --write in the hook and get the result staged.
This depends on §0 and §1, and must not ship before either. An untrusted manifest that can rewrite files and stage the result is a worse primitive than one that can only run a command, so the trust model is a hard precondition, not an ordering preference.
On §1: Without the stash, "re-stage what the formatter touched" re-stages unstaged work the author deliberately kept back. With the stash in place, the tree contains exactly the staged content, so anything the formatter changed is by definition part of this commit.
The design
Opt-in per check, declared, not global:
#![allow(unused)] fn main() { pub enum Fix { /// Reports only. Every check today. None, /// A command that rewrites files, and whose result should be staged. Rewrite { argv: fn(&Ctx) -> Vec<String> }, } }
Enabled by config, off by default — git config amont.fix true — because a
hook that edits your files without being asked is a bigger surprise than one
that complains. Reported as a new Outcome::Fixed, which is neither Passed
(something happened) nor Failed (the commit proceeds).
Candidates: prettier (--write), ruff (format + check --fix), cargo fmt.
Not eslint --fix: its fixes are semantic and occasionally wrong.
3. Declared skip conditions, replacing one ad-hoc guard
Shipped.
check::GitStatewith the five states,Scope::not_during, andregistry::MID_OPERATIONas the shared set applied to the checks that need it.lib.rs::git_states_in_progressdoes the detection anddispatch.rsconsults it for BOTH stages, closing the pre-push gap the section names.crates/amont/tests/git_state.rs.
lefthook:
pre-commit:
commands:
lint:
skip: [merge, rebase]
and skip: {ref: main}, and skip: {run: test "$NO_HOOK" -eq 1}.
We have exactly one of these, hard-coded, in one dispatcher:
#![allow(unused)] fn main() { // dispatch.rs if cherry_pick_in_progress(ctx.hooks_dir) { return Verdict::Proceed; } // …and, twelve lines down: // NB: no CHERRY_PICK_HEAD check here — the zsh pre-push had none either. }
That comment is an admission: pre-push has no such guard because the shell version had none, which is history rather than a decision.
The design
Scope already declares when a check applies to files. This is the missing
half — when it applies to repository state:
#![allow(unused)] fn main() { pub struct Scope { pub files: &'static [&'static str], pub opt_in: &'static [&'static str], /// Git operations during which this check does not run. pub not_during: &'static [GitState], // Merge | Rebase | CherryPick | Revert | Bisect } }
Detected from the files git writes into $GIT_DIR: MERGE_HEAD,
rebase-merge//rebase-apply/, CHERRY_PICK_HEAD, REVERT_HEAD,
BISECT_LOG. Not REBASE_HEAD: git leaves that one behind after
rebase --continue finishes, so it says a rebase happened rather than that
one is happening, and reading it paused every push gate forever in a
worktree that had once hit a conflict. cherry_pick_in_progress becomes one arm of that, and its hard-won
comment about parent() being lexical while join("..") is not moves with it.
Only not_during, not lefthook's full set. ref: conditions duplicate
hook.skip, which is already per-repo and already visible in the dashboard;
run: conditions are a shell escape hatch in a design that has deliberately
refused shells (see amont.conf). Taking the useful third is not a failure
to copy the other two.
4. amont run [--all-files]
Shipped, exactly as specified, plus
--hooks-dir.amont run,amont run --all-files,amont run <check>.crates/amont/tests/run_mode.rs.
pre-commit run --all-files runs every hook over the whole repository rather
than the staged set. Two uses, both of which we currently cannot serve:
- Adopting a check in an existing repo — you want to know how big the mess
is before you turn it on, and
git add .is not an acceptable way to find out. - CI parity — running the same checks in CI over the whole tree.
We have amont list (would it run here?) and amont <check> (run one,
staged). We have no "run everything, over everything".
Scope::matches already answers against an arbitrary path list, so the file
selection is done. The work is:
amont run # every applicable check, staged files (what a commit does)
amont run --all-files # …over `git ls-files` instead
amont run <check> # one check, either way
--all-files skips the §1 stash: there is no staged/unstaged distinction to
protect when the answer is "all of it".
4b. amont check <paths…> — the read that is not a rehearsal
Shipped.
crates/amont/tests/check_verb.rs,amont-runtime/src/content.rs,amont-runtime/src/finding.rs.
run answers "is this commit ready?" — a question about the index. It is
entitled to everything in §1 and §2: the staged-only hold, the stash, the
re-staging of fixes, and restore as the way back.
An editor asks a different question — "what is wrong with this buffer?" — and
the buffer is not staged, may not match HEAD, and via --stdin-filename may
never have been written to disk at all. Serving that from run would drag
index fidelity into what is a read-only lookup, and an editor asking about a
buffer would inherit a stash. So it is a separate verb with a separate
contract:
amont check src/app.js # a path
amont check src/*.ts --format json # several, structured
amont check --stdin-filename src/app.js < buf # a buffer never saved
It is a read. No index, no staging, no stash, no writes —
checking_never_touches_the_index_or_the_worktree asserts the repository is
byte-identical afterwards, index included.
Findings, and why positions had nowhere to live
Outcome has five variants and no payload — correct for git, which needs
proceed-or-block, and the reason a report could only name the file:
✗ Unwanted terms found
The following files contains 'debugger' in them:
- app.js
The line was always known. ban_terms blanks comments and strings preserving
length and line count precisely so offsets stay valid, and secrets::scan has
always returned line numbers — there was simply nowhere to put them. Finding
is that place, and the hooks improved on the way past:
✗ Unwanted terms found
app.js:7:3 — 'debugger' is a banned term here
file:line:col: severity: message [check] is the output format because every
editor's error parser already reads it and every modern terminal makes it
clickable — which is what lets efm-langserver, nvim-lint, a VS Code
problemMatcher or flycheck consume amont with no editor-side code in this
repository. --format json (amont-check-v1) is there for anything that
would rather not parse a line.
What is deliberately not in it
Only the checks that are about a file's content: ban-terms, secrets,
merge-conflict, large-files. branch-pattern, branch-protect and
pull-rebase are not about files; package-lock is about a relationship
between two; and clippy, ruff and eslint already have editor
integrations of their own that are better than anything proxied through here.
What remains is exactly the set docs/ci.md says CI deliberately does not
reproduce — the checks only amont has are the ones only amont can surface
early, which is the whole argument for the verb.
Positions are a reporting concern and never a decision input: a check
decides pass or fail exactly as it did before, and a finding says where. Where
a position cannot be pinned down — large-files is about the file, not a place
in it — line is None and every renderer degrades to naming the file, which
is what it did for everything until now.
5. Shebang detection
The one section still unbuilt.
Scope::filesis suffix-only today —matches()incheck.rscallspath.ends_with(ext)and nothing reads a file head. Nothing is waiting on it; it is here because it is the gapamont.confinherits fromScope, not because it is next.
pre-commit classifies files with identify, which reads shebangs, so an
extensionless scripts/deploy starting #!/bin/sh is a shell file.
Our Scope.files is suffix-only, and amont.conf's *.sh inherits that —
a repository whose scripts have no extension cannot scope an external check onto
them at all.
Smallest useful version: Scope.files accepts a #! pattern.
#![allow(unused)] fn main() { Scope::files(&[".sh", "#!/bin/sh", "#!/usr/bin/env bash"]) }
Reading file heads costs an open per extensionless staged file, so it happens only when a scope actually asks for a shebang, and only for files with no matching suffix.
Lowest value on this list. Listed because it is the gap our own manifest format inherits, not because anything is waiting on it.
What we are not taking, and why
core.hooksPath (husky). Husky sets one config key and ships no per-repo
hook files. Our entire drift model — amont-fleet apply, BakeState, the
SHIMS column, recover_baked — exists because we copy five files into
ninety-six repositories. A global core.hooksPath deletes that problem class
outright.
Refused, and the reason matters more than the refusal: core.hooksPath is
all-or-nothing per repository. "Managed vs unmanaged", which the fleet view is
built around, becomes unexpressible; a repository with hooks of its own silently
loses them; and a colleague who has never heard of this tool can read
.git/hooks/pre-commit and see what runs. That legibility is worth five files.
And the failure is not hypothetical — we were on the receiving end of it.
Eleven repositories on the author's machine ran husky, so core.hooksPath was
.husky/_, so git rev-parse --git-path hooks answered .husky/_ and
install cheerfully baked four shims into a directory husky's own prepare
regenerates. They were gone by the next npm install. Every one of those
repositories reported as merely "drifted" in the fleet view while running no
checks at all, and a direct push to a protected branch went through
unchallenged for as long as it lasted. That is exactly "a repository with hooks
of its own silently loses them", arrived at from the other direction, and it is
now refused by name.
None of which the npm packaging contradicts. amont init writes the same five
files to the same .git/hooks; what a prepare script changes is who types
the command, not where the hooks live or whether they can be read.
repo + rev pinning, per-hook language isolation, autoupdate (pre-commit).
These solve distributing hooks to strangers. We compile checks in and distribute
one binary through the fleet — the same problem, already solved differently.
Adopting the mechanism would mean adopting the problem.
remotes: (lefthook). The fleet's job.
piped:, priority: (lefthook). pre-commit runs concurrently and reports
every failure; pre-push runs serially and stops at the first. Those two shapes
are load-bearing and documented as such. A configurable ordering invites a third
shape nobody has asked for.
What this does not solve
Named because an adoption review asks these first, and an honest "no" is worth more than silence.
Tool versions are not pinned. §1 fixes which content is checked and leaves
which tool open. resolve_tool prefers <root>/node_modules/.bin/<tool> and
falls back to PATH, so two developers and CI can run three prettier versions
and disagree about the same commit. pre-commit solves this with rev pinning
per hook repository, which is dismissed above as "distribution to strangers" —
that is its mechanism, not its value. Its value is determinism, and we do not
have that. Fixing content fidelity while leaving tool fidelity open is half a
reproducibility story, and the half we have is the less visible one.
Nothing here enforces anything. Hooks are advisory by construction:
--no-verify and hook.skip are each one command away, deliberately. So the
question "what stops an unformatted commit reaching the default branch" has no
answer in this document. An earlier draft of this paragraph proposed making
amont run --all-files CI-grade — exit-code contract, SARIF, JUnit. The
decision went the other way: amont deliberately does not run in CI at all.
CI wants the real tools, called directly, with the platform's own caching
and attribution — see the CI backstop, which ships copyable
workflow templates saying exactly that.
No DCO / Signed-off-by check. commit-msg enforces a gitmoji prefix and
length rules, which are house style. Any project that requires a Developer
Certificate of Origin needs a different check, and today it would have to be an
external — which lands it squarely in §0. It is a good candidate for a built-in
precisely because it is a policy many organisations cannot adopt the tool
without.
musl is untested. CI covers ubuntu, macOS and Windows. A glibc-dynamic binary does not start in the Alpine containers a lot of pipelines use. Probably a one-line target addition; worth knowing before somebody finds out from a pipeline rather than from here.
Order
All of this shipped, in this order. It is left as written rather than
converted to a changelog: the argument for each ordering — why trust had to
precede stage_fixed, why not_during had to precede index fidelity — is the
part worth keeping, and it reads as advice only in the future tense.
PR 0a — activation and uninstall (§0). Add uninstall at both levels and
name bulk activation amont-fleet install rather than hiding it behind
fix --apply. The README presents per-repository activation as the default and
init.templateDir as a stated opt-in with its consequence spelled out. Closes
the drive-by case for the default mode and makes the fleet's unmanaged column
mean something for anyone in it.
PR 0b — the trust prompt (§0). Small once 0a exists, because it hangs off
the install flow. Must precede stage_fixed, which cannot ship into an
untrusted manifest — and it is the only thing standing between a cloned
repository and your shell for anyone who set init.templateDir, which is the
convenient mode and therefore the popular one.
PR 1 — amont run [--all-files]. Small, useful immediately, no risk, and
it gives the later work a way to be exercised over a whole repository.
PR 2 — not_during git-state conditions. Generalises the CHERRY_PICK_HEAD
special case and closes the pre-push gap. Independently useful, and §1 needs its
predicate.
PR 3 — index fidelity. The correctness gap. Alone, no fixing.
PR 4 — stage_fixed. Only after 3 is proven in the fleet for a while: this
is the first feature that would write to someone's index, and it should not be
the change that also introduces the stash.
PR 5 — pre-push runs against the pushed commits, not the working tree. Its
own decision: a worktree or git archive of the tip is more expensive than
anything else here, and the cost is the whole question.
Shipped as pushed_tree.rs, and the cost is what made it OPT-IN:
git config amont.testPushedTree true. The instrument is
git worktree add --detach <tip>, not the stash — a push is not a staging
operation, so the difference that matters is tree-versus-the-commit-you-are-
sending, which includes staged-but-uncommitted work too. Holding all of that
aside for the length of a test suite would leave the developer looking at a tree
that is not theirs for minutes at a time.
Shebang detection is unscheduled. So is everything under What this does not solve, which is a list of known gaps rather than a backlog.
Decisions
-
--all-filesimplies no stash. There is no staged/unstaged distinction to protect when the input set isgit ls-files, so taking a stash would be surprising extra mutation with no correctness upside. If a future explicit--no-stashflag exists for diagnostics,amont run --all-files --no-stashshould be accepted as redundant rather than rejected.Corollary, stated because it is the inverse of §1: on a dirty tree,
--all-filesreports on content that is not committed and may never be. That is correct — the question it answers is "does my working tree pass", not "would my commit pass" — but §1 spends a page arguing that judging unstaged content is a bug, and a reader who meets this without warning is entitled to think one of the two is wrong. They are different questions; the mode's help text should say which one it answers. -
Outcome::Fixedis invalid inpre-push. A pre-push hook must not modify the worktree or index: silently proceeding after a write would make the pushed commit differ from the tree the developer is now looking at.Refused where every other bad declaration is refused, rather than at push time. A
pre-pushline declaring a fix is aParseError, alongsideNameTakenandDuplicate— reported on every commit, named, located, and visible in the dashboard'sDECLcolumn. A "hook contract violation" raised at push time would be the same fact discovered later, by fewer people, in the one place where blocking is most expensive.A built-in cannot express it at all:
Fix::Rewriteis reachable only from aStage::PreCommitdeclaration, which the compiler enforces. -
The stash applies to the
pre-commitcheck stage only.commit-msgreads and rewrites the message file Git passes as$1;prepare-commit-msgappends to that same message file based on the branch name and commit source. Neither hook selects paths from the index or asks tools to read repository files, so wrapping them inStagedOnlywould add stash risk without fixing a real fidelity problem.pre-pushis excluded from the stash and NOT from the problem. It has the same bug by a different route (§1) — the pushed refs choose the files and the working tree supplies the content. Stashing is the wrong instrument there, so it gets its own item rather than an exemption. Saying "pre-commit only" and stopping is precisely the move §3 criticises: pre-push has no cherry-pick guard today because the zsh version had none, and nobody wrote down that it was a choice.
Interactive hook.skip management
Status: shipped, with one section kept as history because the reasoning is worth more than the design it produced.
Extends docs/fleet-dashboard.md, which listed the s toggle as a v2 item
without saying what it should do.
Naming a check
A check's id is <trigger>-<name> — pre-commit-clippy. Exactly three
things name it, and both config surfaces resolve all three identically:
| written | means | example |
|---|---|---|
| the full id | that one check | pre-commit-clippy |
| a trigger | every check on that trigger | pre-commit |
| a short name | that check, on any trigger | clippy |
Three exact comparisons. No substring. So:
git config --add hook.skip pre-commit-clippy # one check
git config --add hook.skip clippy # that check, either trigger
git config --add hook.skip pre-commit # all fifteen pre-commit checks
git config amont.severity.clippy warn # same vocabulary, other surface
hook.skip e matches nothing. Where several severity keys reach one check,
the most specific wins — full id > short name > trigger — so you can downgrade a
whole trigger and then exempt one check from it.
Declared checks in amont.conf have ids too, so hook.skip pre-commit
covers them. See docs/custom-checks.md.
History: the problem this was written to solve
Kept because the sequence — measure, ship the cheap safety net, then find the real fix — is the reasoning, and a rewritten doc that hid it would read as though the right answer had been obvious.
hook.skip used to match by substring: check_name.contains(skip_value).
Measured against the 20 checks of the day:
hook.skip value | suppressed |
|---|---|
pre-commit-clippy | 1 / 20 |
cargo | 2 / 20 |
lint | 5 / 20 |
pre-commit | 15 / 20 |
t | 19 / 20 |
e | 20 / 20 |
None of those are adversarial. t is a plausible shorthand for "tests"; pre
for "prettier". Either silently disabled the whole suite, in a config file nobody
reads, with no output at commit time saying so.
Worse, no value could express "this check only": pre-commit-lint-js is a prefix
of pre-commit-lint-json-yaml, so skipping the first unavoidably skipped the
second. The full id was not a safe value either, and an earlier revision of this
document was wrong to imply it was.
Two things followed, in order.
First, the announcement, because a UX review against usage traces changed the
priority. 1 of 96 repos had any skip at all, and its value (run-tests-js) was
hand-written at a terminal. Nobody reached for a dashboard, because editing
config takes ten seconds — while the consequence landed on every commit. So the
dispatcher started saying what it skipped:
! 15 checks skipped by hook.skip: pre-commit-argo-lint, pre-commit-ban-terms, …
Silent when nothing is skipped. It reaches every skip however it was created,
needs no dashboard, and turned hook.skip = e from invisible into unmissable
within one commit.
Then the vocabulary, which removed the hazard rather than reporting it. It
was prompted by a different bug: hook.skip matched by substring and
amont.severity.<key> matched exactly, on the same identifiers, so
hook.skip clippy worked and amont.severity.clippy warn silently did
nothing. Fixing the disagreement meant picking one rule, and the only rule that
serves both is exact naming.
Three parts of this document were built and are now retired by that change:
- The typed confirmation for a skip reaching more than one check. It existed because a value could silently take four when you asked for one. An id names exactly one check, so the dashboard's toggle can no longer over-reach and the gate had become unreachable code.
- The fragment diagnosis — recognising
run-tests-jsas a fragment and offeringpre-push-run-tests-jsinstead. It is not a fragment; it is the short name, and it means what its author meant. - "Over-broad" as a warning. A value reaching fifteen checks now means somebody wrote a trigger, which is a thing you can only do on purpose. The detail view says so plainly instead of raising an alarm.
What replaced them is one predicate worth alarming about: inert. Under substring matching almost any string hit something, so "matches nothing" was rare; under exact naming it is the normal shape of a typo.
Non-goals
- Not a replacement for
--no-verify. A one-off bypass is git's job.hook.skipis for a persistent decision, and the UI should not blur them. - Not a scheduler. No expiry dates, no "skip until Friday". A skip that expires silently is a different surprise, not a smaller one.
- Not fleet-wide bulk writes. See Scope — for a fleet-wide skip the correct mechanism is one global config entry, not 96 local ones.
Rules
1. The UI writes an id, never a short name.
pre-commit-clippy, not clippy. Both work, but the short name is a bet on
every future check name: a clippy check added to pre-push later would widen
an existing skip without anyone touching it. The id cannot widen.
2. Lead with what protection is lost, not with a count.
"Suppresses 19 of 20" is an aggregate, and aggregates do not move people the way
a named consequence does. It also treats every check as interchangeable, which
they are not: pre-commit-yamllint reformats, pre-push-branch-protect is what
stops a push to main. Name them, most consequential first; the count is a
subtitle.
3. A skip that reaches nothing is reported as a finding.
The UI cannot stop someone writing hook.skip = clipy by hand, so it surfaces
it. A value matching no check appears in the repo detail view saying exactly
that, and a value naming a trigger is shown with its expansion — informative, not
alarming:
hook.skip
pre-commit-clippy local -> pre-commit-clippy
pre-commit local -> the whole pre-commit trigger — 15 checks
clipy global -> ! matches no check
4. Removal is exact, and the exit code is not evidence.
git config --unset hook.skip <value> takes a value-pattern that is a regex,
not a literal. Measured:
--unset hook.skip 'pre-commit-lint.js'removespre-commit-lint-js— the.is a wildcard. Any value the UI does not escape can over-match.- When the pattern matches more than one value, git prints
warning: hook.skip has multiple values, removes nothing, and exits 5. An earlier revision of this document said it exits 0 and called that a silent no-op; that was a mis-measurement —$?was read after a pipe, so it reported the last command in the pipeline rather than git. git does signal the refusal. --unset-allwith the same pattern removes both, which is the opposite surprise, and duplicates are legal so an anchored pattern can match twice.
So the UI passes an anchored, escaped pattern (^pre-commit-clippy$) and then
re-reads the config to confirm. Not because the exit code lies, but because a
status code reports what the command believes it did while a re-read reports what
is true. Config is small and the read is cheap; there is no reason to prefer the
weaker evidence.
5. Prefer undo to confirmation.
A toggle writes immediately and offers u to take it back. An undo helps when
the user was wrong, where a confirmation only interrupts when they were right
(Nielsen's third heuristic; the Undo Send precedent).
The draft required typing the check name whenever a skip reached more than one. That gate is gone with the substring rule that made it necessary — see History.
The UI never refuses. Showing the consequence is the intervention; refusing invites working around the tool by hand, which is worse and unobservable.
6. Idempotent.
--add on a value already present creates a duplicate that must then be unset
twice. The UI checks first and reports "already skipped" rather than writing.
Scope: local, or global
Two scopes, and the distinction matters more than it looks.
- Local (
git config --add, writes.git/config) — this repo only. The default, because it is the reversible one and it is where a repo-specific decision belongs. - Global (
git config --global --add) — every repo on the machine, including ones cloned later.
What ships: the UI writes LOCAL, only. skips::plan builds
git config --add / --unset with no --global, so every toggle lands in that
repository's .git/config. And it goes further than not offering global — it
REFUSES to touch an entry that is not local. Toggling a check whose skip came
from ~/.gitconfig produces
that entry is global, not local — edit it where it lives
rather than a write. That refusal is the right default: a keystroke in a
per-repository detail pane should not silently change every repository on the
machine, and an --unset aimed at the wrong file is exactly the kind of write
this tool has already been burned by.
The argument for global is still a good one, and it is a stated gap. A fleet-wide skip should be one global entry, not a loop over 96 local writes: one entry is one thing to find and one thing to undo, while 96 entries are a migration in their own right, and the tool has already learned what a half-applied sweep costs. Nothing in the UI offers that today. Whoever builds it should treat "affects every repository, present and future" as text the user must read, not a mode they can fall into — which is why it was not bolted onto the existing local toggle.
configured_skips reads via plain git config --get-all, so global and local
entries are already merged with no way to tell them apart at dispatch time. The
detail view shows which scope each value came from
(git config --show-origin --get-all hook.skip), or a developer deletes a repo's
.git/config line and is baffled that the check is still skipped. This part is
real: SkipEntry carries a scope field of Local | Global | Other { origin },
resolved by skips::scope_of from --show-origin, and Other exists precisely
so a system config, an include or a worktree config is not silently relabelled
as one of the two the UI can reason about.
Where it lives in the UI
Repo detail (Enter, then s) — the check list is selectable, and s
toggles the highlighted check for that repo. This is the common case: "not in
this repo, not right now". u takes it back.
Hook view (h) — the transposed matrix, one row per check, with TRIGGER as
its own column. Answers "where does this check actually apply?", which the CHECK
column alone could not once two checks could share a short name.
Data model
SkipEntry {
value : String // as written in config
scope : Local | Global | Other { origin }
suppresses : Vec<&'static str> // resolved against the check registry
}
SkipPlan {
check : &'static str // the check being toggled
action : Add | Remove
command : Vec<String> // exactly the argv that will run
suppresses : Vec<&'static str> // what changes as a result
refuse : Option<String> // why not, in words the user reads
}
SkipPlan carries no repo or scope: the repository is the argument to
plan() rather than a field of its result, and there is no scope to choose
because every plan is local — see Scope above. refuse is a plain String
rather than a Refusal enum because every refusal is shown to a human and none
is branched on: "already skipped", "already covered by a broader entry", "that
entry is global, not local".
suppresses is resolved through amont_runtime::names_check, the same
function the dispatcher uses. Reimplementing the match differently is how a UI
comes to claim a check is active while the dispatcher skips it.
Tests
The important ones are about honesty, not mechanics:
- A value naming no check is reported as reaching nothing — not silently dropped.
- A trigger value is expanded to the checks it covers, by name.
- The written value is always an id — falsified by writing a short name and asserting the test fails.
- Adding an existing skip is refused, not duplicated.
- Removal uses an anchored pattern and is verified by re-reading config.
- A global skip is labelled as global in the detail view, with its origin.
- The resolver agrees with
dispatch::selectedfor the same inputs.
How we would know it worked
- Zero inert skips. Measurable from
amont-fleet --jsonat any time. A skip that names nothing is a developer who believes a check is off when it is not. - Time-to-discover a skip. The announcement drives this to one commit; before it, the honest answer was "possibly never".
Rendering
Inherits the dashboard's constraints and must not quietly drop them: the preview
is legible under NO_COLOR, degrades below 100 and 60 columns rather than
scrolling sideways, and encodes nothing by colour alone.
Open questions
Should the UI ever refuse a write?RESOLVED: no. Show the consequence. Refusing protects against a mis-keystroke but invites working around the tool by hand, which is both worse and unobservable.ShouldRESOLVED: a count in the table, the values with their reach in the detail view. A count alone hid an over-broad entry back when one could happen by accident; the detail view now carries the expansion.skipsin the fleet table show a count or the values?- Is
--show-originfast enough per repo at fleet scale? It is one extragit configinvocation across ~96 repos. Measure before adding it to the scan rather than the detail view.
amont-fleet — a TUI for the 96-repo hook fleet
Status: built. PRs #41-#47 implement v1 and v2; scripts/propagate.sh is
gone, replaced by amont-fleet fix.

The recording is real: real repositories, shims written by the release binary,
scanned by the shipped dashboard — rebuilt any time with
assets/fleet-demo.sh.
The s toggle for hook.skip is BUILT. This paragraph called it
deliberately unbuilt for longer than it was true. s toggles the highlighted
check in the repo detail pane and u takes it back; it writes to that
repository's .git/config and refuses to touch an entry that is global or from
another config file. It is specified in
hook-skip-management.md, which is also where the
one genuine gap is recorded — there is no way to write a FLEET-WIDE skip, and a
loop over 96 local writes is not one.
It was specified as a safety feature rather than an ergonomic one, back when
hook.skip matched by SUBSTRING and hook.skip = e silently disabled all 20
checks. Matching is exact now: a value names a check by its full id, its
trigger, or its short name, and e reaches nothing. The friction the original
design carried — typing a check's name whenever a skip reached more than one —
went with the rule that made it necessary.
Unbuilt, and named rather than half-built: the : command palette, r
(rescan), ? (a key sheet), and f (fix from inside the TUI). f is
deliberate rather than pending: the dry-run/--apply split on the fix CLI
already enforces the property f was designed to give, and does it without a
modal confirmation. See Interaction model.
Why this exists
scripts/propagate.sh prints a text summary. That summary has misled its author
twice, on the same day:
- It reported 192 removals per hook name across 96 repos that hold one copy each. Two overlapping loop conditions were both claiming the same files. The number is arithmetically impossible, and it still read as plausible.
- A consistency sweep reported
0 copies / 0 distinctfor every hook. The-maxdepthwas wrong, so it matched nothing. Zero findings and a broken check produce identical output.
Both are the same failure: a scalar with no denominator, printed with the same confidence whether it measured everything or nothing. Neither is fixed by writing the script more carefully — the next one will make a third version of it. It is fixed by rendering the fleet as a grid you can look at, where "this column is empty because nothing was scanned" cannot look like "this column is empty because everything is clean".
That is the entire justification. If the dashboard does not make those two states visually distinct, it has failed and should not be built.
Non-goals
- Not part of the commit path. The hook binary stays dependency-free; see
"Packaging". A TUI in
pre-commitwould break GUI clients, CI, and piped output, and would seize the alternate screen during a commit. - Not a wrapper around the old migration scripts. The scanner, previewer,
and fixer are Rust code.
scripts/propagate.shis historical reference only and should disappear once the Rust path can prove the same removals/writes. - Not a git client. No staging, committing, diffing.
lazygitandtigexist and are better at it. - Not a linter runner. It reports on hook installation and configuration, never on the content of your code.
- Not a remote/CI dashboard. Local
.git/hooksstate only.gh dashcovers the remote side.
Prior art worth stealing from
| Project | What to take |
|---|---|
| k9s | Resource table as the primary object; / to filter, : for a command palette; a context-sensitive hotkey footer that changes with the selected row. |
| lazygit | Contextual keymap always visible; panels that own their own keys; ? for a full sheet. |
| gh-dash | The closest analogue — many remote items, sectioned, with per-row status glyphs and a detail pane. |
| broot | Incremental fuzzy filter that narrows as you type, with the match count always shown. |
| btop / bottom | Dense status rendering that stays legible at small widths. |
| helix | Selection → action ordering, and which-key style discoverability after a prefix. |
| delta | Restraint. Colour carries meaning, never decoration. |
Design principles, and where they come from
Overview first, zoom and filter, then details on demand. Shneiderman's
visual information-seeking mantra (The Eyes Have It, 1996) is the literal
structure of this tool: a fleet summary band, a filterable repo table, a detail
pane on Enter. Do not invert it — no screen should open on a single repo.
Visibility of system status (Nielsen heuristic 1). A fleet scan takes ~8s
(2.9s to enumerate 98 .git directories, 4.9s to hash 96 shims). That is well
past the ~400ms Doherty threshold at which interaction stops feeling immediate,
so the scan MUST stream: rows appear as they are discovered, with a live
scanned N/98 counter. A spinner over a blank screen is not acceptable.
Recognition over recall (Nielsen heuristic 6). The 20 check names are not memorable. Anything the user must type — a check name, a repo path — is offered as a filterable list first.
Every count carries its denominator. This is the local rule that follows
directly from the two failures above. Never render 0 drifted; render
0 drifted / 96 scanned. A table with no rows renders as an explicit
diagnostic state, never as an empty success.
Preattentive encoding, redundantly. Colour and position are processed
pre-attentively (Ware, Information Visualization), which is what makes a grid
scannable at all. But roughly 8% of men have red-green colour vision deficiency,
so state is encoded twice — glyph and colour — and never by colour alone.
NO_COLOR must produce a fully usable screen.
Data-ink ratio (Tufte). No box-drawing around every cell, no gradients, no progress bars where a number is clearer. Chrome competes with the data for the same 80 columns.
Data model
One row per repository. Collected read-only; the dashboard never writes without an explicit action.
FleetScan {
root : PathBuf
depth : usize
git_dirs_found : usize
hook_dirs_seen : usize
managed_seen : usize
unmanaged_seen : usize
unreadable : Vec<PathBuf>
hooks_outside_seen : usize // repos whose hooks resolve somewhere we will not touch
excluded_dirs : usize
dirs_visited : usize // how much of the tree was actually walked
repos : Vec<Repo>
}
Repo {
path : PathBuf // displayed repo-relative to the scan root
managed : bool // ≥1 shim dispatches to the binary
shims : Vec<ShimState> // one per git-invoked hook, in DISPATCHERS order
baked : BakeState // which binary path the shims point at
stale_ours : Vec<String> // OUR old shims that are no longer shipped
foreign_subs : Vec<String> // hand-written pre-commit-* / pre-push-* sub-hooks
hook_pkgjson : bool // a vestigial hooks/package.json from the node era
languages : Vec<String> // manifests at the repo root — DISPLAY ONLY
applicable : Vec<String> // checks that would ever fire here, from each Scope
skips : Vec<SkipEntry> // hook.skip, resolved: what it hits and where it came from
severities : Vec<SeverityOverride> // amont.severity.*, and which one git applies
declared : Vec<DeclaredCheck> // this repo's own amont.conf checks
trusted : Option<bool> // None when there is no manifest at all
agents_md : AgentsMdState // UpToDate | Missing | Drifted | Malformed
aval_hook : AvalHook // { state, ignored: Vec<String> } — see below
hooks_dir : HooksDir // where the hooks are, and whether we may touch them
shares_hooks_with : Option<PathBuf> // a repo already seen that owns this hooks dir
}
SkipEntry { value, scope: Local|Global|Other{origin}, suppresses: Vec<&str> }
SeverityOverride { check, value, level, scope, effective: bool }
DeclaredCheck { name, stage, state: Usable{severity, exts} | unusable }
ShimState = Ok // installed bytes match the expected baked template
| Drifted // present, readable text, not the expected baked template
| Missing
| Symlink{target} // a link — writing here would rewrite something else
| Unreadable{why} // a binary, a directory, a permissions error, a hard link
AvalHookState = NoCorpus // no .adr.yaml — not applicable, and nothing was spawned
| NoAval // a corpus, and no `aval` on PATH to judge it with
| Current // `aval hook install --check` exited 0
| Stale // …exited 1: the hook is behind the installed aval
| Unknown{why} // aval answered something it did not promise
BakeState = Current // == installed binary path
| Stale(path) // points somewhere else — the GUI-client failure mode
| Unbaked // __AMONT_BIN__ placeholder intact
| Mixed // shims disagree with each other
HooksDir = In{path} // inside the repo's worktree, or its git common dir
| Outside{path} // reported, never created, never written to
| Unknown{why} // git would not say — not a state to guess out of
FixPlan {
repo : PathBuf
repo_abs : PathBuf
intent : "repair" | "activate"
hooks : HooksDir
refuse : Vec<Refusal> // suppresses the WHOLE repo
warn : Vec<Warning> // printed; suppresses NOTHING
remove : Vec<Removal>
write : Vec<WriteShim>
write_agents_md : Option<WriteAgentsMd> // --agents-md only
install_aval_hook : Option<InstallAvalHook> // --aval-hook only; runs aval, renders nothing
}
Refusal = unmanaged | unreadable_hooks | tracked{path}
| tracked_unknown{path, why} // git could not answer — never read as "no"
| foreign_hook{names} | agents_md_malformed{path} | unbakeable_binary{binary}
| hooks_dir_outside_repo{path} | hooks_dir_unknown{why}
Warning = unrecognized_sub_hook{path} // a hook we did not write. NOT deleted.
| hooks_dir_outside_repo{path}
| aval_hook_ignored{paths} // .gitignore drops the session hook. NOT edited.
| aval_hook_unjudged{why} // --aval-hook asked, no aval (or an odd answer)
Four of these fields deserve a sentence, because each exists to make something that was invisible visible:
languagesis display only, andapplicableis the real answer. Language detection used to double as "would a check fire here", which made it a fourth copy of a rule that lives in each check'sScope.applicablenow evaluates those scopes against the repo's tracked files;languagesis a column a human reads.skipsisVec<SkipEntry>, notVec<String>. Bare strings hid both halves of what a reader needs: a value need not be a check id (a trigger silences fifteen), and oncegit config --get-allhas merged local and global they are indistinguishable.severitiesexists because a downgrade leaves no trace on screen. A skipped check is announced on every commit; a check downgraded towarnruns, prints its failure, and lets the commit through, so a repository that enforces nothing reads exactly like one that enforces everything.effectivematters too —--get-regexplists every entry while the dispatcher asks--getand takes the last, so listing both as authoritative reported a downgrade git does not apply.declaredandtrustedare the manifest. A repo could be running a command on every commit that no column mentioned; andtrusted: None(there is no manifest) must read differently fromSome(false)(it declared something and that something is not running).
Two channels, and why a warning is not a weak refusal
A refusal suppresses the whole repository: a half-applied fix is how a repo
ends up with both pre-commit-ruff.zsh and pre-commit-ruff, running ruff
twice. A warning prints and changes nothing about what gets done — a
stranger's pre-push-mine.sh must not block repairing five broken dispatchers
in the same directory.
Folding the two together forces a choice between "say nothing" and "do nothing",
and this tool has been on both sides of it. hooks_dir_outside_repo is the one
condition on both channels, deliberately: the warning names the directory so
somebody can go and look at their core.hooksPath, the refusal is what stops
the write.
foreign_subs is REPORTED, not removed
install and fix --apply used to delete every .git/hooks file matching
pre-commit-* / pre-push-* that did not carry our marker, in repositories
they had never touched, reported only as a number. That was inherited wholesale
from a one-time migration sweep in scripts/propagate.sh (see
git show 90b0d30^:scripts/propagate.sh, around lines 82-87) and then pinned as
golden by tests/parity.rs.
They are now Warning::UnrecognizedSubHook and are left exactly where they are.
--remove-unrecognized puts them back on the removal list, and is deliberately
not spelled --remove-stale: "stale" means stale_ours, which IS ours and is
still removed by default, and reusing that word for other people's files is
what would get somebody to type it casually.
hooks_dir and shares_hooks_with
hooks_dir exists because a scanned repository's core.hooksPath may be an
absolute path anywhere on the disk, and activation used to create_dir_all it
and write five 0o755 files into it. The fleet now refuses anything that does not
resolve inside the repository's own worktree or its git common directory.
Note the deliberate asymmetry: per-repo amont install keeps honouring its
own repository's core.hooksPath, absolute or not — you are standing in that
repository and configured it yourself. The fleet refuses, because it is walking
ninety-six repositories it did not configure.
shares_hooks_with exists because a submodule's and a linked worktree's hooks
live in the superproject/main repo. Both now appear in repos (their .git is a
FILE, which the walk used to skip outright, making every submodule on the machine
invisible), so one hooks directory is reachable from two rows. The first
repository seen in the walk's sorted order owns it; the others plan nothing and
display as covered-by.
languages matters because a check that never fires is not the same as a check
that is broken: pre-commit-clippy in a Python repo is correctly inert. The
dashboard must distinguish inert from failing, or it will manufacture 90
false problems out of pre-commit-cargo-fmt.
Shim comparison is against the rendered template for the intended binary path,
not the raw tracked template. A correctly baked shim must never be reported as
drifted merely because __AMONT_BIN__ was replaced.
Screen 1 — Fleet overview (default)
┌ amont fleet ──────────────────── scanning 98/98 · 8.1s ── /Users/me/Developer ┐
│ 96 managed · 2 unmanaged (skipped) · 384 shims · 20 checks │
│ consistency commit-msg 96/1 ✓ pre-commit 96/1 ✓ pre-push 96/1 ✓ │
│ prepare-commit-msg 96/1 ✓ ← copies/distinct blobs │
├─────────────────────────────────────────────────────────────────────────────────┤
│ REPO SHIMS BAKE LANG SKIPS WARN STATE │
│ ▸ Perso/homelab ●●●● current k8s js – – ✓ ok │
│ Perso/application-landscape ●●●● current js – – ✓ ok │
│ Perso/git-templates ●●●● current rust – – ✓ ok │
│ Perso/trade-agents ●●●○ current python – – ✗ missing 1 │
│ Perso/frontjutsu/mvp ●●●● stale js 2 1 ! stale bake │
│ Volkswagen/pos-fr-services ●●●● current js – – ✓ ok │
│ … │
├─────────────────────────────────────────────────────────────────────────────────┤
│ 96 rows · 0 filtered ↑↓ move ⏎ detail / filter : command h hooks ? help │
└─────────────────────────────────────────────────────────────────────────────────┘
- The consistency band is the headline, because
copies/distinctis the one number that actually proves fleet health, and it is the number the text script got wrong. It is alwaysN/M, never a bare adjective. SHIMSis five glyphs, one per dispatcher, in fixed order.●ok,◐drifted,○missing,!a symlink,?unreadable. Position encodes which hook without spending a column on its name.!and?are deliberately OFF the ●◐○ scale: those three run from healthy to absent, and a dispatcher that is a link to a tracked file is not "somewhat installed" — writing there rewrites the other file, and it must not read as a milder◐.STATEis a redundant text summary of the same information, so the screen survivesNO_COLORand CVD. It also carries the two states that are not about the shims at all:covered by <path>(a linked worktree or submodule whose hooks another row owns) and! hooks elsewhere(acore.hooksPathwe will not follow), both of which would otherwise read as! unmanagedand send somebody looking for a missing install.- A repository whose dispatchers are SYMLINKS no longer counts as
managed, even when the link points at one of our own shims. That is the intended trade:fixreports it instead of writing through the link. DECLcounts the checks a repository declares inamont.conf, and reads2!1when one of those lines cannot be parsed — a check somebody committed that has never once run.2and2!1describing the same repository is the distinction the column exists for.WARNcounts only the override git would actually APPLY.--get-regexplists every configured entry, but the dispatcher asks--get, which returns the last — so a localblockbeats a globalwarn. Counting every entry made the column report a downgrade that never happens; the shadowed entry is still shown in the detail pane, markedoverridden, because somebody wrote it. A trustedamont.confcontributes rows too — its skips and severity lines appear with originamont.conf, folded into the same ladder the dispatcher uses (policy above global, below local), so a policywarna local config re-blocks is shown but not counted.SKIPSandWARNare deliberately two columns, not one total. A skipped check does not run; a downgraded one runs, prints its failure, and lets the commit through. Summing them would hide the second — which is the one that looks like enforcement and is not.WARNcounts only overrides that actually weaken something: an explicitblock, a misspelt check name and an unrecognised value all change nothing, and inflating the count with them is how a column stops being read. The detail pane names all three.BYPASSis not folded intoWARNfor the same reason: a downgrade is a policy statement about the repository, a bypass is an event that happened. It counts unverified commits — commits carrying no record their commit-time gate ran — read from the runtime's local ledger through the runtime's own parser. The count is a floor once the runtime compacts the ledger; the tint follows recency (an event in the last 30 days), because three dodges this month and three from two years ago must not paint alike. A rate needs a denominator (commits over the same window,rev-list --countat scan time); that is deliberately v-next rather than half-implemented here.
The empty state, which is the point
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ No repositories found under /Users/me/Dev │
│ │
│ Scanned 0 directories in 0.1s. This is a SCAN FAILURE, not a clean fleet. │
│ • is --root correct? (currently: /Users/me/Dev) │
│ • is --depth deep enough? (currently: 6) │
│ │
An empty table must never render as a calm empty list. Zero scanned is stated as a failure in words. This single screen is the reason the tool exists.
Screen 2 — Repo detail (Enter)
┌ Perso/trade-agents ─────────────────────────────────────────────────────────────┐
│ /Users/me/Developer/Perso/trade-agents python · managed · bake current │
├─────────────────────────────────────────────────────────────────────────────────┤
│ DISPATCHERS │
│ ✓ commit-msg blob 7914a85 matches template │
│ ✓ pre-commit blob 7914a85 matches template │
│ ✓ pre-push blob 7914a85 matches template │
│ ✗ prepare-commit-msg MISSING → `amont fleet fix` │
│ │
│ CHECKS (20) pre-commit │
│ ● ban-terms ● merge-conflict ● package-lock ● usual-name │
│ ● lint-json-yaml ● yamllint ○ lint-js ○ prettier │
│ ● ruff ● pyright ○ cargo-fmt ○ clippy │
│ ○ argo-lint ○ kube-linter ○ kubeconform │
│ pre-push │
│ ● branch-protect ● branch-pattern ● pull-rebase ○ run-tests-js │
│ ○ cargo-test │
│ │
│ ● will run here ○ inert (no matching manifest) ⊘ skipped via hook.skip │
├─────────────────────────────────────────────────────────────────────────────────┤
│ ⏎ back s toggle skip f fix this repo o open y copy path ? help │
└─────────────────────────────────────────────────────────────────────────────────┘
The ● / ○ / ⊘ distinction is the important one. A Python repo showing ○ clippy is healthy; the same glyph must never be used for "broken". Three states,
three glyphs, three words in the legend — no colour required to read it.
The detail pane also carries, above the dispatchers, the resolved hooks dir
(always, not only when something is wrong with it — a reader who cannot see the
directory cannot tell a repo we declined to touch from one that had nothing to
do) and, when set, shares hooks covered by <path>.
Below them, leftovers are TWO blocks, not one:
│ LEFTOVERS OF OURS (nothing dispatches these — fix removes them) │
│ pre-commit-ruff │
│ │
│ NOT OURS (left alone — nothing dispatches these either) │
│ pre-push-branch-protect.sh │
They shared a heading for two releases while fix --apply silently deleted the
second list. Same word, opposite fates.
Screen 3 — Hook-centric view (h)
Transposes the matrix. Answers "where does pre-commit-pyright actually apply,
run, get skipped, or stay inert?" Checks are no longer installed as per-repo
files; the view is about runtime applicability, not file presence.
│ CHECK APPLICABLE ACTIVE SKIPPED INERT │
│ ▸ pre-commit-ban-terms 96 96 0 0 │
│ pre-commit-ruff 11 11 0 85 │
│ pre-commit-pyright 11 11 0 85 │
│ pre-commit-clippy 3 3 0 93 │
│ pre-push-run-tests-js 41 39 2 55 │
Rows sum across; APPLICABLE = ACTIVE + SKIPPED. A row where APPLICABLE is 0
is highlighted, because a check that can never fire anywhere is either dead or
misconfigured — and that is invisible in today's text output.
Interaction model
Modal and keyboard-first, following k9s and helix. No mouse dependency; mouse scroll may be supported but nothing is mouse-only.
The keys that exist. This table was aspirational and is now a list of what
tui.rs actually binds; the footer of each screen shows the same set.
| Key | Where | Action |
|---|---|---|
↑↓ j k | everywhere | move selection |
Enter | fleet | detail of the selected repo |
Esc | everywhere | back, or clear the filter |
/ then text, Backspace | fleet | incremental filter — match count always shown |
h | fleet | toggle fleet ↔ hook-centric view |
s | detail | toggle hook.skip for the highlighted check |
u | detail | take that toggle back |
f | fleet, detail | show the repair for the selected repo — what would be removed and written |
y / Enter | sync preview | carry that repair out |
Esc | sync preview | discard it, nothing written |
q | everywhere | quit — including during a scan |
And the ones that do not, removed from this table rather than left as
promises: : (a command palette), r (rescan), ? (a key sheet). All three
are still reasonable ideas.
Destructive actions are diff-first. In the TUI, f never writes: it builds
the same FixPlan the CLI's fix preview prints, for the one selected
repository, and puts it on screen — removals, writes, the shims that are
already current, and anything worth noting. y applies exactly that value, and
apply re-verifies every refusal at the moment of writing, so a tree that
changed between the preview and the keypress is refused rather than acted on.
f uses the safe defaults only — Repair, no AGENTS.md, nothing deleted
that this tool did not write; the two opt-in flags below stay CLI-only because
each is a materially bigger action than restoring four untracked shims. A
repository that is not ours, or whose hooks are somewhere we will not touch, is
refused in place with a one-line reason, no modal. On the command line the same
split is enforced by the verb rather than a prompt:
amont-fleet fix # DRY RUN — prints the plan, writes nothing
amont-fleet fix --apply # carries it out
amont-fleet install # implies applying; named after intent
amont-fleet fix --apply --agents-md # opt in, per invocation
amont-fleet fix --apply --aval-hook # opt in, per invocation
amont-fleet fix --apply --remove-unrecognized
fix with no --apply is the preview, built from the same typed FixPlan the
apply consumes, so the preview cannot drift from the act. The TUI's f / y
pair is the same property in a different shape: y is only bound while a plan
is on screen, and it applies that plan and no other, so there is no keystroke
that skips the preview and "yes" can only ever mean what was last shown.
install is the one verb that implies --apply, because requiring both would
be ceremony over an unambiguous intent; it has no TUI equivalent, since
adopting a repository is not a repair.
Three flags are opt-in per invocation and never bundled into a plain
--apply: --agents-md, which writes into a tracked file; --aval-hook,
which does the same for another tool's tracked files; and
--remove-unrecognized, which deletes pre-commit-* / pre-push-* files this
tool did not write. The last is spelled that way rather than --remove-stale
on purpose — "stale" means our own retired shims, which are removed by default
and are a different thing entirely. --binary <path> chooses what the shims are
baked to point at, defaulting to $HOME/.local/bin/amont.
--aval-hook keeps aval's session hook
current across every repository that keeps an .adr.yaml. aval hook install
writes .claude/hooks/aval-heads.sh and a SessionStart entry in
.claude/settings.json, so an agent session opens with the decision heads and
adopted rules in front of it; the script's bytes are its version, and every
aval release that changes it leaves every corpus behind until somebody re-runs
the command there. The fleet does not render that script — the template is
aval's, and a copy here would be a second way to be stale. It runs aval hook install --check per corpus and reads the exit code (0 current, 1 behind),
plans aval hook install for the stale ones, and re-asks at the moment of
writing. A repository without .adr.yaml is never asked; one with a corpus and
no aval on PATH is not judged — a warning, not "current", because a
missing binary is exactly how "up to date" would be faked by silence. What git
would do with the files is reported alongside from git check-ignore: a
.gitignore that excludes .claude/ wholesale makes the hook work for whoever
ran the command and ship to nobody, and the fleet names the negations to add
(.claude/* then !.claude/settings.json, !.claude/hooks/,
.claude/hooks/*, !.claude/hooks/aval-heads.sh) rather than editing an
ignore file it does not own.
Given make install has destroyed tracked source twice in this repo's
history, the dashboard's write path gets the same fail-closed treatment: it
refuses any path git reports as tracked — and any path git will not answer
about, which is a distinct state (tracked_unknown) and not a "no". fatal: detected dubious ownership is what git says about every repository owned by
another uid, which is every repository inside a container bind mount, and a
guard that reads that as "untracked" fails open in exactly the environment where
the user cannot see what happened.
Every write and every remove — in fix, in install, and in uninstall — goes
through amont_runtime::hookfile, the single owner of "is this ours, and may
we touch it?". It never follows a symlink, never treats an unreadable file as
absent, and stages each write to a sibling temporary that is renamed into
place, so replacing a link is the only thing that can happen and writing through
one is not a code path that exists.
Performance
- Stream, never batch. Enumerate
.gitdirectories first (2.9s) and paint rows immediately inscanningstate; hash shims on a worker pool and update rows in place. Sorting is stable so rows do not jump under the cursor. - Never block the UI thread. A 16ms frame budget; scanning runs on threads
and posts results over a channel.
qmust work during a scan. - The plain commands stream too.
scan,fix,installanduninstallshare the walk, and the same rule applies to them: a live status line on stderr carrying elapsed time, the two counts, and the path being looked at right now. It is the path that matters when a scan stalls — a frozen count says something is slow, and only the path says what. Gated on stderr being a terminal, and erased before the report prints, so a piped or redirected run emits exactly the bytes it always did. - Bounded work. Respect
--depth(default 6) and skipnode_modules,target,.venv,vendor— the same exclusions the existing sweep uses. - No cache in v1. An 8s scan does not justify a cache and its invalidation bugs. Revisit only if the fleet grows past a few hundred repos.
Accessibility and degradation
NO_COLOR(andTERM=dumb) → glyph-and-text rendering, fully usable.- CVD-safe: state never encoded by colour alone; every colour is paired with a distinct glyph and a word in the legend.
- Narrow terminals: below 100 columns drop
LANG,SKIPS,WARN,DECLandBYPASStogether; below 60, fall back to a single-column list. Never horizontal scrolling. - Screen readers do not meaningfully work with TUIs. The accessible path is
therefore
amont fleet --json, emitting the full data model for scripting and assistive tooling. This is a first-class output, not a debug flag, and the TUI is a renderer over it.
Packaging
A cargo workspace, so the commit path keeps its posture:
crates/
amont-runtime/ # registry + hook logic. std-only.
amont/ # the hook binary. ZERO external dependencies.
amont-fleet/ # scanner, fixer, JSON, TUI. ratatui + crossterm.
amont must not gain an external dependency, directly or transitively; that
property is the reason the Rust migration happened at all. The TUI is a separate
artifact that a developer opts into, and make install continues to install
only the hook binary.
Extracting amont-runtime has an independent benefit: there is currently no
lib target, which is why cargo test --lib fails outright. Unit tests for hook
logic would move into a library where they belong, without letting TUI
dependencies into the commit path.
Version plan
v1 — Rust-native fleet truth
Build the minimum useful tool, but build it in the final architecture:
- Workspace split into
amont-runtime,amont, andamont-fleet. - Rust scanner for
--rootand--depth, with explicit counters for found git dirs, seen hook dirs, managed/unmanaged repos, unreadable paths, and excluded directories. - Rust shim inspection: five dispatcher states, baked path state, stale managed
files, foreign sub-hooks, vestigial hook
package.json, languages, skips. amont-fleet --json, emittingFleetScan.- Default TUI overview and repo detail views.
- Rust
amont-fleet fix [repo|--all] --dry-run, producing aFixPlan. Shipped inverted, and better: dry run is the DEFAULT and--applyis the flag, so the writing form is the one you have to type. - Rust apply path for that exact
FixPlan, with a second confirmation in the TUI and tracked-file refusal before any remove/write. The tracked-file refusal shipped. The TUI confirmation did not, because there is nof— the CLI split does the same job; see Interaction model. - Tests for broken root, too-shallow depth, baked-template comparison, managed detection, stale file classification, tracked-file refusal, and dry-run/apply parity.
v1 may omit the hook-centric view and skip toggling if they slow down the first
usable release. It may not shell out to propagate.sh.
v2 — operational dashboard
Add the workflows that make the grid more than a safer propagation report:
- Hook-centric view (
h) over applicability, active, skipped, and inert counts. - Detail drilldown from a check row to the matching repos.
stoggle forhook.skip, backed by Rustgit configcalls and a preview of the exact config mutation.- Command palette entries for
:root,:depth,:rescan,:export json, and:fix. - Optional activity signal after measuring cost: last commit date or another cheap recency marker, never on the initial UI thread.
- Remove
scripts/propagate.shonce v1/v2 fix coverage has replaced its last practical use.
Shipped alongside — amont-fleet gates
Not in either list above, because the question it answers was not one this
design knew to ask. The scan says whether a repository's hooks are
installed; it cannot say whether the checks they run still check anything.
A fleet audit on 2026-09-19 found four mechanisms that had silently stopped
— a lockfile audit run one directory too high, a govulncheck built with an
old Go, uv with no .venv, an npm that timed out — all of them green,
all of them fast.
amont-fleet gates reads the run record the hooks keep in each repository's
refs/notes/amont-gate and reports, per repository and gate: runs in the
window, pass/fail, median and last duration, last-run age, and the flags
no-op suspect, flaky and stale. Same shape as the rest of this tool —
read-only, --json as a first-class output, every threshold on the command
line, and an honest abstention (insufficient history (2 verdicts)) rather
than a guess. The statistics belong to amont_runtime::gate_evidence; this
crate contributes the Serialize coat and the table, for the same reason
downgrades defers to the runtime's parser.
It is a CLI table today and deliberately not a TUI screen: the overview grid is one row per repository, and this is one row per gate per repository with a sentence attached to each flag. A screen for it is worth designing once somebody has read the table for a few weeks — see gate evidence.
Decisions
- Scan root. Use a CLI flag in v1. Add persistent config only after using the tool enough to know where it belongs.
- Fix path. Native Rust. Do not shell out to
propagate.sh. - Delivery target. Build through v2. v1 is the first shippable slice, not the end state.
- Activity signal. Sorting by last-commit date would surface "the repos you
actually use are drifted" — but it costs a
git logper repo. Measure first.
Delivery plan
Seven PRs. The ordering is not cosmetic: the destructive code is written only after a differential has proven the model it destroys with, and the TUI is last because it is the least risky part and the least useful if the data beneath it is wrong.
1. Workspace split, zero behaviour change. amont-runtime (lib,
std-only) and amont (bin), no features. The proof is that all 174 tests
pass UNCHANGED, plus a differential running every hook over the same fixtures
before and after, comparing bytes and exit codes — the technique that caught
four bugs during the zsh port. This PR must also land the zero-dependency CI
guard (cargo tree for amont shows nothing external); without it the
packaging rule is a comment, and ratatui arrives transitively three PRs later.
Highest mechanical risk, no feature value, therefore alone.
2. Scanner and --json, no TUI. FleetScan with every counter. This
already replaces the ad-hoc find/hash-object pipelines that misled twice, so
it is useful before any UI exists. Tests: broken root, too-shallow depth,
excluded directories counted, unreadable paths recorded.
3. Shim classification. Expected shim = the embedded template rendered for the intended binary path. The trap is symmetrical and silent in both directions: compare against the RAW template and all 96 repos report drifted, so the tool cries wolf and gets ignored; compare too loosely and real drift is never seen. Both directions get a test.
4. FixPlan dry-run and the parity gate. A differential over all 96 repos:
the Rust plan against propagate.sh --dry-run, normalised and compared. This is
what earns the right to delete the shell script, and it is the long pole of the
project — not the TUI.
5. Apply path, fail closed. Refuses tracked paths, unmanaged repos and unreadable hook directories. Dry-run/apply parity plus idempotence: applying twice must produce an empty second plan. Written only after 4 proves the model.
6. TUI v1 — overview and detail. ratatui's TestBackend renders into an
assertable buffer, so the success criterion becomes an automated test: a
deliberately broken scan must render SCAN FAILURE. Same for NO_COLOR and the
60/100-column fallbacks. A success criterion nobody can run is a wish.
7. v2, then remove propagate.sh. Hook-centric view, s skip toggle,
command palette. The script is deleted once 4 and 5 have replaced its last
practical use.
Two details settled before starting
- Invocation is
amont-fleet, notamont fleet. A subcommand would require the hook binary to locate and exec the TUI binary, coupling the commit path to a tool it must never know about. The screens'amont fleetprompt is shorthand for the separate binary. - Templates are embedded with
include_str!against the workspace root, so the tool reports correctly from any directory rather than only inside a checkout. All five dispatcher templates are currently ONE blob, so this is a single embedded string, not five.
Success criteria
The tool is worth building only if, on a deliberately broken scan (wrong root, wrong depth), the screen says scan failure rather than showing a clean, empty, green fleet. Everything else is convenience.
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.