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.

amont catching a commit and letting the fixed one through

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 list shows 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 say amont trust. No other hook manager puts a review gate between git clone and 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 stash and 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 uninstall removes 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. amont and amont-runtime are std-only, and scripts/check-no-deps.sh fails 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-rebase runs git ls-remote/git fetch against your upstream (off with git config amont.autoRebase false, see configuration), and the audit-* checks call cargo audit, npm audit, pip-audit and govulncheck.
  • 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, and cargo-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 the Drop that 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 PreToolUse hook for the mistake no git hook can reach, because it lives in the command string itself: git push … | tail -5 reports 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 asks amont agents-md --check whether 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

  1. Installing and activating — get the binary, turn hooks on in one repository (or every repository you ever clone), and turn them off again.
  2. The checks — what the thirty-nine built-ins do, and what each one needs before it fires.
  3. 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 can cat .git/hooks/pre-commit and see what runs, which is exactly the property that made us refuse core.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)

the amont-fleet dashboard scanning a fleet of repositories

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:

shapetyped how oftencovers
"prepare": "amont init" in package.jsonnever (npm types it)JS repositories, on npm install
amont enrollonce per machineevery future git clone and git init
amont initonce per repository per machinethat 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

  1. Each machine, once (onboarding doc, two lines):
    $ brew install fredericrous/tap/amont     # pick your installer
    $ amont enroll --conventions declared
    
  2. Each repository, once ever (committed, travels with the clone):
    • commit an amont.conf — empty declares; custom checks, committed policy (severity/skip lines for the built-ins) and tool pins can come later;
    • JS repositories additionally get "prepare": "amont init" so even an unenrolled machine is covered by npm install.
  3. Repositories cloned before enrollment: amont init in 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-verify still 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.0 in amont.conf makes 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 clippy disagrees 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-fmtcargo fmt --all -- --check
pre-commit-clippycargo clippy --workspace --all-targets --all-features -- -D warnings
pre-push-cargo-testcargo test --workspace --all-features
pre-push-audit-rustcargo audit
pre-commit-lint-jsnpx --no-install eslint --max-warnings 0 .
pre-push-run-tests-jsnpm run typecheck / test:unit / test --if-present
pre-push-audit-jsnpm audit / pnpm audit, per lockfile directory
pre-commit-ruff / pre-commit-pyrightruff check . / pyright --warnings
pre-push-pytestpytest
pre-push-audit-pythonpip-audit -r requirements.txt
pre-commit-gofmt / pre-commit-go-vettest -z "$(gofmt -l .)" / go vet ./...
pre-push-go-testgo test ./...
pre-push-audit-gogovulncheck ./...
ban-terms, secrets, large-files, merge-conflict, commit/branch conventions, pull-rebasedeliberately 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:

  1. the signature verifies against .forgejo/allowed_signers (or your platform's path), a file committed in the repository, whose entry is pinned to the amont-attest namespace:

    you@example.com namespaces="amont-attest" ssh-ed25519 AAAA…
    
  2. 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;

  3. the step's own gate is named in gates. The list holds only checks that PASSED — Warned and Unavailable never 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;

  4. 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:

legattestation saysoutcome
macos-latestplatform aarch64-macosskips — that suite really ran here
ubuntu-latestplatform aarch64-macosruns
windows-latestplatform aarch64-macosruns

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 ${{ }} in run:;
  • a step-level env: or shell:, or a working-directory: the gate does not declare as cwd=;
  • any env: or defaults: 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 in hook.skip and amont.severity; see configuration.
  • fires when — the scope. always means 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:

fieldwhat it says
id<trigger>-<name>, the full spelling
short_namethe name without its trigger
stagewhich trigger runs it
sourcebuiltin, or declared for an amont.conf check
declared_severitywhat the check ships as
effective_severitywhat it is here, after overrides
severity_overriddenwhether those two differ
severity_sourceconfig or policy when overridden, else null
fixwhether it can rewrite the file
statusready, inert, skipped, unavailable
reasonwhy, in the words the text view uses
scope_filesextensions it fires on ([] means always)
scope_opt_infiles whose presence opts the repository in
commandthe 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.

idfires whenwhat it does
pre-commit-agents-mdAGENTS.md/CLAUDE.md carry the amont markersThe 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/.ymlArgo CD app lint. soft
pre-commit-ban-terms.js .jsx .ts .tsx .vue .rs .pyRefuses 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-patternalwaysSays 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-protectalwaysSays 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.tomlcargo fmt. fixes
pre-commit-clippy.rs + Cargo.tomlcargo clippy
pre-commit-go-vet.go + go.modgo vet ./..., per touched module.
pre-commit-gofmt.go + go.modgofmt, handed exactly the staged files. fixes
pre-commit-kube-linter.yaml .yml + .kube-linter*.yaml/.ymlkube-linter. soft
pre-commit-kubeconform.yaml .yml + kustomization.yaml/.ymlSchema-validates rendered manifests. soft
pre-commit-lint-js.js .jsx .ts .tsx .vue + package.jsonESLint at zero warnings (--max-warnings 0), only in repos that carry an eslint config.
pre-commit-lint-json-yaml.json .yaml .ymlParses staged JSON/YAML so a syntax error never reaches the repo. soft
pre-commit-manifest-trustamont.confBlocks 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-conflictalwaysRefuses staged files still carrying conflict markers.
pre-commit-package-lockpackage.jsonKeeps 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-prettiera prettier config is presentFormat check. fixes
pre-commit-pyright.py .pyi + pyrightconfig.json/.jsonc/pyproject.tomlType check.
pre-commit-ruff.py .pyi + ruff.toml/.ruff.toml/pyproject.tomlLint and format. fixes
pre-commit-tree-parityamont.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-namealwaysWarns 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-hadolintDockerfileDockerfile 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.yamlhelm 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 .bashShell 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/.ymlStrict 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.

idfires whenwhat it does
pre-push-branch-protectalwaysRefuses a direct push to main or master.
pre-push-branch-patternalwaysRequires prefix/branch-name (e.g. feat/3002-image-crop), unless the branch already exists on the remote.
pre-push-pull-rebasealwaysRebases 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.jsonRuns 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.tomlcargo test.
pre-push-go-test.go + go.modgo 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-secrets scans 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-secrets scans 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 (a v followed 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=dev or pnpm 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.txt is the production list by convention; a virtualenv's findings are matched against uv export --frozen --no-dev --all-extras (an optional extra ships: a consumer who asks for it installs it);
    • Go: govulncheck ./... runs without -test and 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-waivers file, one line per advisory,

    # id                 expires     reason
    GHSA-vfj7-8cjw-p6xm  2026-12-31  braces via react-strict-dom; no patched version
    

    reviewed 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;

  • 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 audit reads Cargo.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 tree calls 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 push again 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-push drives the same dispatcher with no push in flight — on a branch that has never been pushed it measures against origin/HEAD, origin/main or origin/master — and stamps HEAD when every block gate passes. Then git push holds its connection open for the seconds the transport takes, not the minutes the suite does. An agent that runs amont run pre-push before every git push never meets the idle timeout; amont-agent's push-preflight rule 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. A worktree add that fails, or a preparation that fails (a refused amont.snapshotCarry, an install, an amont.snapshotPrepare that 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 HEAD is 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. Use amont.testPushedTree to 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.snapshotCarry refuses 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 the pre-push stage — a declaration that never runs is not a check;
  • an effective severity of warn, whether declared or arrived at through an amont.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,*.tsx above says nothing about a .js change, 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.

amont catching a commit and letting the fixed one through

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
nonefeat: add a cart
prefix✨ feat: add a cart
suffixfeat: 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.

icontypefor
👷buildthe CI or build system
🔧choreconfiguration, auxiliary tooling, generated docs
📝️docsdocumentation only
✨feata new feature
🐛fixa bug fix
⚡️perfa performance improvement
♻️refactorneither fixes a bug nor adds a feature
⏪️revertreverting; ideally via git revert
🎨stylestructure or formatting of the code
🚨testadding, updating or fixing tests
➕addadding files as part of a larger feature
➖removethe 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, or 0 to 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 still fix:.
  • 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

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:

keyreaches
pre-commit-clippythat one check
clippythat check, on either trigger
pre-commitevery 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:

keydefaultmeans
amont.commit.gitmojinonewhere the type's emoji goes
amont.commit.subjectMax72longest the whole subject may be
amont.commit.descriptionMax50longest the part after type: may be
amont.commit.bodyWrap72column 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
nonefeat: add a cartthe default — your subject, untouched
prefix✨ feat: add a cart
suffixfeat: add a cart ✨commitlint and changelog tools still see the type
replace✨ add a cartthe 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:

valueeffect
autothe default: quiet when stderr is not a terminal, verbose when it is
neverevery check says it passed, whoever is reading
alwaysquiet 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.

  • install does what CI does: npm ci --prefer-offline, pnpm install --frozen-lockfile --prefer-offline, yarn install --frozen-lockfile --non-interactive --prefer-offline for yarn 1 and yarn install --immutable for yarn 2+ ("berry"; the committed yarn.lock says 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 a package.json its lockfile does not satisfy — so does the snapshot.
  • reuse (pnpm only) clones the working tree's node_modules (copy-on-write on APFS and btrfs: instant, no extra disk) — the root's and each workspace member's, as pnpm ls -r lists 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) and pnpm install --frozen-lockfile --offline accepts 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 why reuse is still not the default. npm is never reused: npm ls answers whether the dependency graph is valid, not whether the tree is the one the lockfile describes, and a real graph with peer-range conflicts npm ci installs happily fails it on a fresh install — so it can vouch for nothing, and an npm unit installs under reuse too, saying so. Neither is yarn: a frozen install checks the lockfile against the manifests, not the installed tree, and yarn check is gone from both flavours, so a yarn unit installs under reuse too.
  • off prepares 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:

situationkilled after
CPU measured idle, host not loaded2 minutes
CPU measured idle, host loadedup to 8 minutes (idleLoadScale 4)
CPU could not be measured8 minutes, even with amont.timeout 0
in a declared cargo or uv lock wait10 minutes (amont.lockWait)
retried after a wait-like last lineboth attempts inside one amont.timeout
waiting for a host slotup to amont.timeout, before it starts
anythingamont.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

variableeffect
GIT_HOOKS_BINAbsolute path to the binary a shim should use. First candidate in the shim's resolution order.
AMONT_BIN_DIRWhere amont install and the installer script put binaries. Default ~/.local/bin.
AMONT_VERSIONPins the version the installer script fetches.
AMONT_ATTEST_PUSHSet by amont itself on the notes push amont.attest makes, so the recursive pre-push stands down. Not for humans.
NO_COLORHonoured, 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 run line is a record of how it went. Forging a run line 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 uninstall along with the rest of amont's own bookkeeping. Nothing here is a statement to anybody else's system — that is what amont.attest is 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.

  1. 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.
  2. 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:

flagdefaultdecides
--window <days>90how far back the report looks
--min-runs <n>5verdicts below which nothing is flagged
--noop-ratio <pct>10a last run under this share of the median
--noop-median <secs>30…for a gate whose median is at least this
--fast-pass <ms>1000a pass under this, never once seen before
--stale-pushes <n>10later 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 suspect is 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.

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-trust blocks the commit that carries the append until you have reviewed it;
  • the pack's rows are shown by amont trust alongside 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.skip and severity, not by trust.
  • It does not protect a repository you wrote the manifest in. Your own amont.conf is 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 files marker, opt-in: prefix the command with files and 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-skip marker, opt-in, pre-commit only: prefix the command with docs-skip and the check does not run when the staged change only edits existing documentation. Documentation is a .md, .mdx, .rst or .adoc file, outside adr/, that is a modification: an added, deleted or renamed file, a code file, a .txt (a requirements.txt is a dependency manifest) or a decision record always runs the check. The skip is printed as skipped — the commit only edits existing documentation. A docs-skip on a pre-push line is a parse error.

    This is a trade, and the adr line in amont's own amont.conf takes 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-named pre-push declaration 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's if: 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 lint does 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=value tokens directly after attest:

    • 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 uppercase NAME=value is not an option; it begins the command, so NODE_OPTIONS=… npm run lint is still expressible.

  • command — the exact command the CI step runs. {cache} is the only thing amont may add, and it is allowed once, for eslint and prettier only. 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, with set 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: with amont.fix off, a fix-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 a fix declaration gets zero enforcement from every member who has not personally opted in — if you need the check to always judge, declare a second, non-fix line 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. fix on a pre-push line 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:

runsreportsblocks
amont.severity.<key> warnyesyesno
hook.skip <key>nono (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:end markers 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.conf means 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 way pre-commit-package-lock already does for npm.
  • pre-commit: check you are committing with the usual GPG key. Same shape as pre-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-msg already extracts one when it is there; requiring it is a different, more opinionated thing, and probably belongs in a repository's own amont.conf rather than in the built-in branch-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

amontpre-commitlefthookhusky
Runtime it needsnone — one binaryPython (hooks bring their own environments)none — one binaryNode.js — already present in the projects it targets
Useful before you write any config39 built-in checks, scoped to what the repo usesstarts emptystarts emptystarts empty
On the commit pathzero external crates, CI-enforcedPython + a managed environment per hookGo binaryNode + node_modules
A cloned repo's committed config runs code…only after you review it and amont trustafter pre-commit install, unreviewedafter lefthook install — often automatic via a package postinstallafter npm install — the prepare script activates it
Your unstaged work during a runheld aside without git stash, restored even if a check panicsgit stash around the rununtouched — checks see the worktree, not the staged setyour problem — hooks are your scripts
Uninstallremoves exactly the five files it wrote, names everything elsepre-commit uninstalllefthook uninstalldelete .husky/, unset core.hooksPath
Commit-message conventionsbuilt in — validated, wrapped, limits and gitmoji configurablevia a separate hookvia commitlint etc.via commitlint etc.
One view across all your reposamont-fleet — bulk install, report, dashboardper repoper repoper repo
Machine-readable state for coding agentsamont list --json, amont agents-mdnonono

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.conf is 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/hooks is 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:

tablewhereholds
REGISTRYamont-runtime/registry.rsname → fn pointer
PRE_COMMIT_CHECKSsame fileorder, pre-commit
PRE_PUSH_CHECKSsame fileorder, pre-push
LANGUAGESamont-fleet/checks.rsname → 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;
}
}
  • Builtin wraps a fn pointer. One const descriptor per check carries name, stage, scope and severity beside the function.
  • External runs 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 be Failed, to fill the slot of a check whose thread died — a real rule, but Default means "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-protect would 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.skip nor a severity override, so it would run anonymously.
  • A line that cannot be parsed is not skipped. It becomes a check that runs to Unavailable and 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-fleet gains 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

  1. Does Custom scope survive? RESOLVED: no, and the question was better than it looked. Checking what the checks actually key on showed the Scope enum modelled alternatives where the truth is a conjunction, so Custom would have swallowed most of the set rather than the one case it was written for. Scope is a struct now and every check fits it.
  2. Can an external check be Block at 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.
  3. Does amont list belong 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.

§whatstatuswhere it lives
0activation, uninstall, bulk installshippedinstall.rs, amont-fleet install/uninstall
0bmanifest trustshippedtrust.rs, amont trust [--show]
1index fidelity (staged-only)shippedstaged_only.rs, dispatch::pre_commit
2stage_fixedshipped as Fix::Rewrite + Outcome::Fixedcheck.rs, hooks/common.rs::restage
3not_during git-state conditionsshippedcheck.rs::GitState, registry.rs::MID_OPERATION
4amont run [--all-files]shippedmain.rs, dispatch::run_named
4bamont check <paths…>shippedcontent.rs, finding.rs
5shebang detectionnot 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 / uninstall and amont-fleet install / uninstall are all real verbs. install.rs carries the routine, and crates/amont/tests/ install.rs and crates/amont-fleet/tests/uninstall.rs carry the guards. uninstall also removes the shims from the template directory and says so loudly if init.templateDir is 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:

modewho decides a repo runs hookswhat closes the drive-by case
per repositoryyou, per repositoryactivation itself
init.templateDiryou, once, for all future clonesonly 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 as amont trust and amont trust --show, with amont install offering it interactively. The record is keyed on the manifest's CONTENT (via git hash-object, because the binary links no crates and std's only hash is a fixed-key SipHash a crafted manifest could collide), so a git pull that 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 whole pre-commit check stage in dispatch::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.rs is 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:

checkshow
reads the tree — affected11prettier, lint-js, lint-json-yaml, yamllint, ruff, pyright, argo-lint, kube-linter, kubeconform, cargo-fmt, clippy
reads the index — correct2merge-conflict (git grep --cached), ban-terms (git show :<file>)
reads no file content2package-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-index was tried first. stash pop MERGES 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 apply was tried second — deterministic on Unix, and what pre-commit itself 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. Drop runs on unwind.
  • Drop does not run on a signal, and that is the likely case. Ctrl-C during a slow pre-commit — eslint over a large tree, a cold cargo 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, so StagedOnly installs a SIGINT/SIGTERM handler 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 with enter(), which ENTER_LOCK closes; the test that found it is ctrl_c_mid_run_still_restores_and_dies_by_the_signal. Windows has the same net via SetConsoleCtrlHandler (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 restore puts 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 GitState predicate 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::enter tests for unmerged paths FIRST and returns an Err that dispatch::pre_commit turns into a printed message and Verdict::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.fix is 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::Rewrite on a check in registry.rs, the opt-in is git config amont.fix true, the re-staging is hooks::common::restage, and the result is Outcome::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, which amont list --json reported 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::GitState with the five states, Scope::not_during, and registry::MID_OPERATION as the shared set applied to the checks that need it. lib.rs::git_states_in_progress does the detection and dispatch.rs consults 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::files is suffix-only today — matches() in check.rs calls path.ends_with(ext) and nothing reads a file head. Nothing is waiting on it; it is here because it is the gap amont.conf inherits from Scope, 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

  1. --all-files implies no stash. There is no staged/unstaged distinction to protect when the input set is git ls-files, so taking a stash would be surprising extra mutation with no correctness upside. If a future explicit --no-stash flag exists for diagnostics, amont run --all-files --no-stash should be accepted as redundant rather than rejected.

    Corollary, stated because it is the inverse of §1: on a dirty tree, --all-files reports 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.

  2. Outcome::Fixed is invalid in pre-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-push line declaring a fix is a ParseError, alongside NameTaken and Duplicate — reported on every commit, named, located, and visible in the dashboard's DECL column. 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::Rewrite is reachable only from a Stage::PreCommit declaration, which the compiler enforces.

  3. The stash applies to the pre-commit check stage only. commit-msg reads and rewrites the message file Git passes as $1; prepare-commit-msg appends 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 in StagedOnly would add stash risk without fixing a real fidelity problem.

    pre-push is 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:

writtenmeansexample
the full idthat one checkpre-commit-clippy
a triggerevery check on that triggerpre-commit
a short namethat check, on any triggerclippy

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 valuesuppressed
pre-commit-clippy1 / 20
cargo2 / 20
lint5 / 20
pre-commit15 / 20
t19 / 20
e20 / 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-js as a fragment and offering pre-push-run-tests-js instead. 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.skip is 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' removes pre-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-all with 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::selected for the same inputs.

How we would know it worked

  • Zero inert skips. Measurable from amont-fleet --json at 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

  1. 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.
  2. Should skips in the fleet table show a count or the values? RESOLVED: 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.
  3. Is --show-origin fast enough per repo at fleet scale? It is one extra git config invocation 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 amont-fleet dashboard over a small fleet: overview, repo detail, and the hook-centric view

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 distinct for every hook. The -maxdepth was 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-commit would 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.sh is historical reference only and should disappear once the Rust path can prove the same removals/writes.
  • Not a git client. No staging, committing, diffing. lazygit and tig exist 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/hooks state only. gh dash covers the remote side.

Prior art worth stealing from

ProjectWhat to take
k9sResource table as the primary object; / to filter, : for a command palette; a context-sensitive hotkey footer that changes with the selected row.
lazygitContextual keymap always visible; panels that own their own keys; ? for a full sheet.
gh-dashThe closest analogue — many remote items, sectioned, with per-row status glyphs and a detail pane.
brootIncremental fuzzy filter that narrows as you type, with the match count always shown.
btop / bottomDense status rendering that stays legible at small widths.
helixSelection → action ordering, and which-key style discoverability after a prefix.
deltaRestraint. 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:

  • languages is display only, and applicable is 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's Scope. applicable now evaluates those scopes against the repo's tracked files; languages is a column a human reads.
  • skips is Vec<SkipEntry>, not Vec<String>. Bare strings hid both halves of what a reader needs: a value need not be a check id (a trigger silences fifteen), and once git config --get-all has merged local and global they are indistinguishable.
  • severities exists because a downgrade leaves no trace on screen. A skipped check is announced on every commit; a check downgraded to warn runs, prints its failure, and lets the commit through, so a repository that enforces nothing reads exactly like one that enforces everything. effective matters too — --get-regexp lists every entry while the dispatcher asks --get and takes the last, so listing both as authoritative reported a downgrade git does not apply.
  • declared and trusted are the manifest. A repo could be running a command on every commit that no column mentioned; and trusted: None (there is no manifest) must read differently from Some(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/distinct is the one number that actually proves fleet health, and it is the number the text script got wrong. It is always N/M, never a bare adjective.
  • SHIMS is 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 ◐.
  • STATE is a redundant text summary of the same information, so the screen survives NO_COLOR and 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 (a core.hooksPath we will not follow), both of which would otherwise read as ! unmanaged and 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: fix reports it instead of writing through the link.
  • DECL counts the checks a repository declares in amont.conf, and reads 2!1 when one of those lines cannot be parsed — a check somebody committed that has never once run. 2 and 2!1 describing the same repository is the distinction the column exists for.
  • WARN counts only the override git would actually APPLY. --get-regexp lists every configured entry, but the dispatcher asks --get, which returns the last — so a local block beats a global warn. Counting every entry made the column report a downgrade that never happens; the shadowed entry is still shown in the detail pane, marked overridden, because somebody wrote it. A trusted amont.conf contributes rows too — its skips and severity lines appear with origin amont.conf, folded into the same ladder the dispatcher uses (policy above global, below local), so a policy warn a local config re-blocks is shown but not counted.
  • SKIPS and WARN are 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. WARN counts only overrides that actually weaken something: an explicit block, 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.
  • BYPASS is not folded into WARN for 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 --count at 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.

KeyWhereAction
↑↓ j keverywheremove selection
Enterfleetdetail of the selected repo
Esceverywhereback, or clear the filter
/ then text, Backspacefleetincremental filter — match count always shown
hfleettoggle fleet ↔ hook-centric view
sdetailtoggle hook.skip for the highlighted check
udetailtake that toggle back
ffleet, detailshow the repair for the selected repo — what would be removed and written
y / Entersync previewcarry that repair out
Escsync previewdiscard it, nothing written
qeverywherequit — 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 .git directories first (2.9s) and paint rows immediately in scanning state; 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. q must work during a scan.
  • The plain commands stream too. scan, fix, install and uninstall share 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 skip node_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 (and TERM=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, DECL and BYPASS together; 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, and amont-fleet.
  • Rust scanner for --root and --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, emitting FleetScan.
  • Default TUI overview and repo detail views.
  • Rust amont-fleet fix [repo|--all] --dry-run, producing a FixPlan. Shipped inverted, and better: dry run is the DEFAULT and --apply is 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 no f — 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.
  • s toggle for hook.skip, backed by Rust git config calls 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.sh once 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

  1. Scan root. Use a CLI flag in v1. Add persistent config only after using the tool enough to know where it belongs.
  2. Fix path. Native Rust. Do not shell out to propagate.sh.
  3. Delivery target. Build through v2. v1 is the first shippable slice, not the end state.
  4. Activity signal. Sorting by last-commit date would surface "the repos you actually use are drifted" — but it costs a git log per 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, not amont 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 fleet prompt 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 — node
  • prepare-commit-msg — zsh
  • the pre-commit / pre-push dispatchers — 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

groupfileslineswhy it moves
entrypoints + always-onpre-commit, pre-push, commit-msg, prepare-commit-msg, ban-terms, branch-pattern, usual-name, pull-rebase, run-tests-js~700runs regardless of repo language — the actual dependency
linter orchestration9 × pre-commit-*.zsh~576only for Windows parity; otherwise optional
tests15 × tests/*.zsh~859see 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:

  1. $GIT_HOOKS_BIN (escape hatch, and how the tests point at target/debug)
  2. the absolute path written into the shim by make install
  3. command -v git-hooks (PATH, last resort)
  4. 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 install does not work the way this describes. What ships is: make install runs cargo build --release and then amont 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 the make install-from-source target this bullet list asks for never existed. make install-fleet does 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-dist generates most of this.
  • make install fetches 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-source path 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-commit backgrounds 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-push runs 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.skip filters by substring match against the hook path. git -c hook.skip=package-lock commit must keep working.

  • CHERRY_PICK_HEAD short-circuit: the dispatcher exits 0 during a cherry-pick.

  • HOOKS_FORCE_GREP exists only to exercise the grep fallback in tests; it disappears with the shell hooks, along with the rg/grep split itself — the binary uses the regex crate 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

  1. Binary name. git-hooks collides conceptually with git hooks (git may resolve git-hooks as a subcommand). Prefer something unambiguous.

  2. 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.

  3. Config format. The zsh hooks read git config (hook.skip). Keep that, or introduce a .amont.toml? Keeping git config avoids inventing a second source of truth and preserves git -c hook.skip=… push.

  4. MSRV and dependency budget. regex and ignore (ripgrep's own crates) cover matching and file walking. Resist more.

  5. 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.

  6. Do the shims keep their .zsh / .js suffixes? 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.rs stays permanently — a repo seeded before the rename still passes the old filename through". There is no such stripping, and there never was. registry::lookup is an exact match against ENTRYPOINTS and the check names; a repo still holding pre-commit-ruff.zsh hands the binary a name it does not know, and gets unknown hook and 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 / install is what moves the 96 repos, and Repo::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 rg unguarded — a missing rg makes ! rg … true, so branch-pattern rejected EVERY branch name (silent wrong answers, not errors);
  • make test had never run on a clean macOS (realpath -s is GNU-only);
  • CI could not go red (make test | tee without pipefail);
  • [] is truthy in JS, so run-tests-js selected every package regardless of what changed;
  • prepare-commit-msg appended a dangling Issue: #id (tested $? after a pipeline ending in head -n 1);
  • pull-rebase read the ahead-count with head -c 1, so 12 printed as "1";
  • 54 of 96 installed commit-msg copies 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

  1. 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.
  2. "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.
  3. A sweep reporting "0 customised" is not proof of a clean fleet. Check the DISTINCT-BLOB count per hook; consistent means 1.
  4. 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 to cargo test. Done. They are crates/*/tests/*.rs, and Windows runs them all.

  • The ban-terms tokenizer. Done. The documented defect (escaped slash in a regex) turned out to be already fixed by the port's Regex state — 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.sh swept all 96. The sharp edge was the dispatcher's <hook>-* GLOB: a repo left holding both pre-commit-ruff.zsh and pre-commit-ruff runs 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.

  • pre-commit-pyright in 6 of 96 repos Done — it self-scopes on a pyright config, so installing it everywhere changes nothing for the 90 repos without one. governance-ts had 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)

checkdispatchercommand
pre-commit-cargo-fmtpre-commitcargo fmt --all -- --check
pre-commit-clippypre-commitcargo clippy --workspace --all-targets --all-features -- -D warnings
pre-push-cargo-testpre-pushcargo 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:

  1. Availability was probed with cargo <sub> --version. That works for rustfmt and clippy, which are separately installable components, but cargo test --version is "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.

  2. git exports GIT_DIR/GIT_INDEX_FILE/GIT_WORK_TREE to 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_env now 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:

  1. 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-fleet pulls ratatui and a dozen crates without concern, because it is opt-in.
  2. Offline reproducibility. A std-only crate builds without a registry, indefinitely. Real but modest.
  3. 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.