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.