Configuration
Everything is git config. There is no config file of ours, no .amontrc,
nothing to keep in sync — the settings live where a git user already looks for
settings, and --local/--global mean what they always mean.
To bypass or disable rather than tune, see opting out.
Naming a check
Every check has an id, <trigger>-<name> — pre-commit-clippy. Three things
name it, and every config surface reads all three the same way:
| key | reaches |
|---|---|
pre-commit-clippy | that one check |
clippy | that check, on either trigger |
pre-commit | every check on that trigger |
Where several keys reach one check, the most specific wins: full id, then short name, then trigger. So you can downgrade a whole trigger and then exempt one check from that downgrade.
Nothing matches by substring. hook.skip e reaches nothing at all, and
skipping lint-js leaves lint-json-yaml alone — a skip can never silently
couple two checks whose names happen to share a prefix.
Where committed policy sits. A trusted amont.conf can carry
severity, skip, and set lines.
For one key, the ladder is: built-in default < system < global < policy <
local < worktree < command — the team's committed decision beats your global
preferences, and your local config in that repository beats the team. Between
different keys naming the same check, specificity (full id > short name >
trigger) decides regardless of source. Skips union across every source. On a
git too old for --show-scope (< 2.26) this degrades fail-safe to "all git
config beats policy". A set line reaches an allowlist of the keys on this
page — the thresholds, commit style, autoRebase, timeout,
testPushedTree, minVersion — and never fix, trusted,
conventions, or the observability opt-outs, which stay per-machine.
hook.skip — do not run it
Multi-valued; add as many as you need.
git config --add hook.skip pre-commit-clippy # that one check
git config --add hook.skip clippy # on either trigger
git config --add hook.skip pre-commit # the whole trigger
git config --unset-all hook.skip # start over
git config --get-all hook.skip # what is set here
A skipped check is announced on every commit. A config line nobody remembers writing cannot go on silently disabling things — that silence is how a repository ends up with a check everyone believes is running.
For one commit only, without touching config:
git -c hook.skip=clippy commit -m "fix: …"
amont.severity.<key> — run it, but do not block
Takes the same three spellings, and keeps the signal: the check still runs and still reports, it just stops failing the commit.
git config amont.severity.clippy warn # runs, reports, does not block
git config amont.severity.pre-commit warn # the whole trigger
git config amont.severity.clippy block # back to blocking
warn is usually the right first move when adopting a check into an existing
repository: you get the report immediately and pay down the backlog on your own
schedule, rather than choosing between a blocked commit and a hook.skip you
will forget to remove.
amont.commit.* — what a commit message must look like
Four keys, and amont setup walks you through all of them:
| key | default | means |
|---|---|---|
amont.commit.gitmoji | none | where the type's emoji goes |
amont.commit.subjectMax | 72 | longest the whole subject may be |
amont.commit.descriptionMax | 50 | longest the part after type: may be |
amont.commit.bodyWrap | 72 | column the body is hard-wrapped at; 0 never wraps |
These matter more than they look, because commit-msg is the one hook
hook.skip and amont.severity do not reach, and git exempts it from
--no-verify. Without these keys the only answers to "I do not want a gitmoji
in every subject" were to comply or to uninstall.
The four placements
git config amont.commit.gitmoji prefix
| stored as | ||
|---|---|---|
none | feat: add a cart | the default — your subject, untouched |
prefix | ✨ feat: add a cart | |
suffix | feat: add a cart ✨ | commitlint and changelog tools still see the type |
replace | ✨ add a cart | the emoji stands in for the type word |
You always write feat: add a cart, and it is always validated as that —
the placement only decides what gets stored. replace costs you interop, and
that is the trade it is: an emoji is not a type any conventional-commit tool
knows how to read.
Two things hold whatever you choose. The limits measure what you wrote, so the emoji never eats your description budget. And running the hook again over its own output — an amend, a rebase reword — changes nothing.
The limits
git config amont.commit.descriptionMax 68
git config amont.commit.bodyWrap 0
68 is the useful number if 50 feels tight: it still fits a 72-column subject
with a short type and no scope. bodyWrap 0 leaves the body exactly as
written, which is what keeps a pasted stack trace or a fenced code block
intact.
A value git cannot parse, or one outside 1..=1000, takes the shipped default
and says so on the commit it happened on — because a limit you believe you
raised and did not is the whole failure mode this project refuses to be quiet
about. A pairing that cannot do anything (a description budget the subject
limit can never accommodate) is reported by amont list, not by the hook:
the commit path says what is in effect, and the config-reading commands say
what makes no sense.
amont.conventions
$ git config --global amont.conventions declared
everywhere (the default) or declared. In declared mode the house
rules — commit-message shape, branch patterns, lint/format gates, test
suites, audits, auto-rebase — run only in repositories that commit an
amont.conf (an empty one declares), while the safety net (merge-conflict,
secrets, large-files, ban-terms) keeps running everywhere. This is what
makes a machine-wide standing grant (amont enroll, init.templateDir)
safe on a machine that also clones other people's projects. A held-back
stage announces itself in one line; amont list reports the state, and
--json carries it as "conventions_apply". An unrecognised value falls
back to everywhere, loudly.
amont.largeFileWarn / amont.largeFileBlock
$ git config amont.largeFileWarn 25
$ git config amont.largeFileBlock 500
The two thresholds of pre-commit-large-files, in
megabytes: a staged file over the first is named (default 10), one over the
second blocks (default 100 — GitHub's own refusal line).
amont.recordBypasses — whether a dodged gate is tallied
git config amont.recordBypasses false # default true
When a commit that a commit-time gate declaration covered lands without that
gate having run — git commit --no-verify, a blocked attempt retried with
it, a gate whose tool was missing — post-commit silently appends one line
per dodged script to $(git rev-parse --git-common-dir)/amont-bypasses.
amont list shows the tally as "unverified commits". The file is local:
never a ref, never pushed, never sent anywhere.
false stops the counting from now on. The switch exists because "my tool
counts my bypasses" can reasonably read as surveillance, and the answer to
that reading should be a documented off-switch rather than an argument —
though the count is also the first place a slow or flaky check becomes
visible as the thing people route around.
amont.recordDowngrades — whether a warning is tallied
git config amont.recordDowngrades false # default true
The companion to the switch above, for the other half of the signal. When a
check FAILS but the severity that applies says warn, the hook silently
appends one line to $(git rev-parse --git-common-dir)/amont-downgrades, and
amont list shows the tally as "problems that did not block".
That file is what makes a trial readable: set
amont.severity.pre-commit warn, work for a fortnight, then read which checks
your team would actually have fought. See
Trying it before you impose it.
Local on the same terms as the bypass ledger — never a ref, never pushed,
never sent anywhere — and false stops the counting from now on, for the same
reason its sibling has an off-switch.
Nothing is recorded by a rehearsal (amont run), and nothing by a check that
actually blocked: the first is not a commit, and the second has nothing to
report.
amont.progress — one check, one block
git config amont.progress false # default true
On (the default): everything a pre-commit check says — its own lines and its
tools' captured output — is buffered and emitted as ONE contiguous block when
the check finishes, so twenty concurrent checks stop shuffling their failure
output together. Blocks arrive in completion order. And when stderr is a
real terminal, a live region under the blocks shows one line per running
check — spinner, name, elapsed — so a slow cargo test is a ticking clock
instead of a frozen prompt. The region only ever paints on an interactive
terminal; piped or redirected output, TERM=dumb, and CI logs never see a
control code.
Off: raw streaming, exactly as before — every line lands the moment it is written, interleaved across whatever else is running. The honest cost of the default is that a long-running tool's output arrives when the check ends rather than as it happens; this key is the way back if you want to watch a test suite scroll.
amont.quiet — say it once, or once per check
git config amont.quiet never # auto (default) | never | always
A hook that passes prints one line per check, and on a clean run that is the whole output: fourteen lines to say nothing happened. At a terminal those lines are the reassurance that the gate ran at all. Captured — an agent's tool result, a CI log — they are read again on every later turn and say no more the tenth time than the first.
So the setting names who is reading:
| value | effect |
|---|---|
auto | the default: quiet when stderr is not a terminal, verbose when it is |
never | every check says it passed, whoever is reading |
always | quiet everywhere |
Only the success lines go. A failure, a warning, a check that could not
run, a repaired file and the blocked summary print under every setting.
Quiet is about the uneventful path and nothing else — it is not a way to
lose a refusal, and quiet_never_swallows_a_failure pins that.
In their place, one line:
✓ 14 check(s) passed
That count is not decoration. A run that says nothing at all is indistinguishable from a gate that never ran, which is the confusion this whole crate exists to prevent — so quiet gets quieter, never silent.
auto is the default because it is free for the reader it does not help.
At a terminal watching() is true, so committing by hand looks exactly as it
did. What changes is the reader who cannot skim — a captured log, an agent's
tool result — who was paying fourteen lines of nothing on every commit of
every session. Set never to have it back.
One consequence worth knowing: the ✓ N check(s) passed roll-up comes from a
full stage. Running a single check — amont run pre-commit-yamllint — has no
roll-up to print, so under quiet it succeeds in silence.
amont.pushStamps — remember what the push gate already proved
git config amont.pushStamps false # default true
A push-time gate that passes stamps the pushed tips in
refs/notes/amont-gate, keyed by tree, and the next push of the same content
skips it: the retry after a remote dropped the idle connection mid-suite,
and the git push after an amont run pre-push rehearsal. Only scoped
gates (test suites) are stamped or skipped, and only for content the suite
actually tested — see "Rehearsing the push gate" on the checks page. false
turns off both the writing and the honouring.
amont.order — attempt the push gates in the order the record justifies
git config amont.order evidence # default: declared
declared is the registry's order, and it is what every repository gets
unless it says otherwise. evidence orders the push gates by this
repository's own record of them (refs/notes/amont-gate, the same notes the
stamps live in): the gates that have actually failed in the last 90 days go
first, ordered by failures per unit of time, so a push that is going to be
refused is refused at minute two instead of minute twenty.
It is an ORDER and nothing else. Every gate still runs, none is assumed to pass, and only the scoped gates — the suites and audits — are permuted: branch-protect, branch-pattern, secrets and pull-rebase keep their positions, because discovering a protected branch after a test suite is the waste this is meant to remove. With no record the declared order is kept exactly. When the order does differ, the push says so.
Settable in a committed amont.conf (set order evidence) as well as by
git config, because which suite is worth attempting first is a property of
the project. It is the only set key that changes how checks are RUN rather
than what they are — safe on the terms above, and a local git config still
outranks it. See gate evidence.
amont.commitStamps — do not repeat a commit-time gate on the same tree
git config amont.commitStamps false # default true
A blocking commit-time gate that already ran clean against exactly the tree
being committed is not run again: the record pre-commit leaves for
post-commit is bound to the tree and survives a commit-msg refusal or a
closed editor, and the stamp post-commit writes on the tree answers for a
reset --soft and re-commit. The reuse is announced in the push gate's
words ("passed on this exact tree earlier"). A changed byte, a changed
declaration, or a gate that failed all run the gate. false turns the reuse
off; the record is still written. See "Moving a gate entry earlier" on the
checks page.
amont.rehearseOnCommit — run the push gate in the background after every commit
git config amont.rehearseOnCommit true # default false
post-commit starts amont rehearse for you: a detached worker checks out
HEAD into a throwaway worktree, runs the test gates there — the
push-shaped checks (branch-protect, secrets, the auto-rebase) wait for the
push — and stamps the tree when they pass. By the time you git push, the
suite has usually already run; if it is still running, the push waits for
it rather than starting over. A newer commit cancels a rehearsal of the
older tree, suite and snapshot included, so committing often does not queue
work. Off by default because every commit then costs a suite's worth of CPU
in the background. A finished git rebase starts one too (post-rewrite),
since it leaves no commit on the branch stamped. See "Rehearsing in the background" on the checks page for
what it prints and what amont rehearse --wait, --status and --stop do.
Needs amont.pushStamps (the default) — the stamp is the hand-off. Unix
only for now: on Windows a detached child would hold its parent's pipes
until the suite ended, so the hook says so instead and amont rehearse --wait runs the rehearsal in the foreground.
amont.rehearsalWait — how long a push waits for a running rehearsal
git config amont.rehearsalWait 300 # default 300; 0 waits indefinitely
A push that finds a rehearsal of its own tree still running waits for it:
less remaining work than a fresh run, and no extra CPU. That wait happens
after git has opened its connection to the remote, though, so it is the
same idle connection the rehearsal exists to keep short — and it used to be
bounded only by the worker's own amont.timeout, an hour by default and
unbounded at 0. Five minutes is under every idle timeout we have measured
(Forgejo's git timeout is six), so a wait that expires still leaves the
connection alive for the gate that follows it. When it expires, the gate
runs in the push and the rehearsal is left running — it may still finish and
stamp the tree for next time. amont rehearse --wait has no budget: nothing
is connected there.
amont.unstampedPush — refuse a push nothing has tested yet
git config amont.unstampedPush refuse # default run
When a check is declared at both stages and a pushed commit carries no
record of the commit-time run — rewritten by a rebase or an amend, or made
with --no-verify — the push runs the check itself, with git's connection
to the remote held open for as long as the suite takes. A long suite
outlives the forge's idle timeout and the push fails after passing.
refuse turns that push away at once instead, and says how to earn the
stamp with nothing waiting on it: amont rehearse --wait, then push again.
A rehearsal of the tree that is still running or has failed is reported the
same way. Pairs with amont.rehearseOnCommit, which usually earns the stamp
before you push.
amont.snapshotDeps — how a snapshot gets its JavaScript dependencies
git config amont.snapshotDeps install # default; or reuse, off
A worktree git just created is a checkout, not a workspace: no
node_modules, and a suite started there fails on Cannot find module
having tested nothing. Every snapshot — the background rehearsal's and
amont.testPushedTree's alike — is prepared before any suite runs, one
unit at a time: each directory holding a tracked package-lock.json,
pnpm-lock.yaml or yarn.lock, read from the snapshot's own index, so the
pushed commit decides, not HEAD and not what you have staged. A nested
project with a lockfile of its own is its own unit. A directory with more
than one manager's lockfile is settled by package.json's
packageManager, or refused.
A bun.lock or bun.lockb is a unit too, so that a push which does not
touch it is skipped like any other — but amont does not install bun
dependencies: a unit the push needs fails the preparation with the fix
named, amont.snapshotPrepare set to the install command, or
amont.snapshotDeps off. Failing there is deliberate: a gate run over a
checkout with no dependencies fails every workspace at once, and that reads
like a broken branch rather than a missing install.
installdoes what CI does:npm ci --prefer-offline,pnpm install --frozen-lockfile --prefer-offline,yarn install --frozen-lockfile --non-interactive --prefer-offlinefor yarn 1 andyarn install --immutablefor yarn 2+ ("berry"; the committedyarn.locksays which — berry's opens with a__metadata:block — so the pushed commit decides, and each flavour gets the spelling it accepts without a warning). Exact, and it refuses apackage.jsonits lockfile does not satisfy — so does the snapshot.reuse(pnpm only) clones the working tree'snode_modules(copy-on-write on APFS and btrfs: instant, no extra disk) — the root's and each workspace member's, aspnpm ls -rlists them — and keeps the clone only when it is laid out the way pnpm's isolated linker leaves it (every package a link; a stray directory is refused, since pnpm's own check ignores it and a suite could import it) andpnpm install --frozen-lockfile --offlineaccepts it — which also refuses a manifest the lockfile does not satisfy, and relinks small drift from the store. A lockfile that differs from the commit's, a link that resolves back into the working tree, or any refusal removes every directory cloned for that unit, says why, and installs instead. So does a file edited in place: pnpm does not check installed content, so amont refuses a tree holding any file newer than pnpm's install record (node_modules/.modules.yaml, rewritten as every install finishes), naming it —.bin/,.cache/and.vite*/aside, which installs and tools write afterwards. That rests on file timestamps, not content, which is whyreuseis still not the default. npm is never reused:npm lsanswers whether the dependency graph is valid, not whether the tree is the one the lockfile describes, and a real graph with peer-range conflictsnpm ciinstalls happily fails it on a fresh install — so it can vouch for nothing, and an npm unit installs underreusetoo, saying so. Neither is yarn: a frozen install checks the lockfile against the manifests, not the installed tree, andyarn checkis gone from both flavours, so a yarn unit installs underreusetoo.offprepares nothing.
Only the units the push touches are prepared: the root one, and each unit
that is the nearest enclosing unit of a file changed between the tip and
where it forked from its upstream. The rest are named in one line and
skipped — a repository with side projects of their own (spikes, examples)
no longer pays an install for each on every snapshot. A unit the push does
not touch cannot hold a failure the push introduced; a gate that reaches
into one anyway finds no dependencies and fails loudly, never falsely
passes, and amont.snapshotPrepare is the override. With no upstream to
measure against, every unit is prepared.
A failed install is the snapshot's failure; see amont.snapshotPrepare
below for what each caller then does. Nothing is needed for a Rust crate
(cargo resolves from the shared registry; the build is cold, which is the
cost the testPushedTree section describes).
The install runs lifecycle scripts, so a "prepare": "amont init" runs
inside the snapshot — and the snapshot is a linked worktree, sharing the
repository's hooks directory. To keep that init from baking every hook to
a binary in a temp directory deleted minutes later, the snapshot marks
itself with an amont-snapshot file in its own git admin dir
(git rev-parse --git-path amont-snapshot), and amont init writes nothing
where it finds one; if it cannot tell, it fails rather than guess. Both
halves must be a version carrying this — the amont that runs the rehearsal
writes the marker, and the amont the pushed commit's lockfile installs
reads it — so bumping only one of them is not enough. The install and
amont.snapshotPrepare also get AMONT_SNAPSHOT=1, for a custom prepare
script that wants to know; the marker, not the variable, is the guard.
amont.snapshotCarry — untracked files a snapshot needs
git config amont.snapshotCarry ".env .npmrc"
or committed, set snapshotCarry .env in amont.conf. The files a suite
reads and git does not track — a .env, an .npmrc with a registry token —
are copied from the working tree into the snapshot, before the
dependencies, so an install can use them. Only untracked content may be
carried: an entry that is tracked, holds tracked files or sits under a
tracked path would put your uncommitted copy where the commit's belongs, and
the stamp would vouch for a tree nobody committed. Refused too: absolute
paths, ., .. and .git components, and a symlink anywhere on the way —
in the working tree, the snapshot, or inside a carried directory. Every
refusal is listed in one message and fails the preparation; an entry that is
simply absent (no .env in CI) is skipped and said.
amont.snapshotPrepare — the escape hatch
git config amont.snapshotPrepare "pnpm prisma generate"
Or committed, so every clone has it:
set snapshotPrepare pnpm prisma generate
in amont.conf (see custom checks). A local git config still outranks the committed value.
For what amont cannot know — a code generator, a database client, a build
step. It runs through the shell inside the snapshot after the carry, with
$AMONT_SOURCE_WORKTREE naming the working tree the snapshot came from.
When it is set it owns the dependencies: amont.snapshotDeps stands
down, so a repository whose command already installs does not install twice.
A preparation that fails — carry, install or this command — is the
snapshot's failure, and each caller says so. At push time
(testPushedTree) the gate falls back to the working tree and stamps
nothing for that tip: the suite that then passes never saw the content the
stamp would have vouched for. In a rehearsal the worker records the failure
with its reason (amont rehearse --status shows it), exits 2, and the next
push treats it as a failed rehearsal.
amont.autoRebase — whether pre-push may sync a behind branch for you
git config amont.autoRebase false # default true
On (the default, and the behaviour every install so far has had):
pre-push-pull-rebase rebases a clean, behind, non-diverged branch onto its
own upstream — then stops the push and asks for a second one, because the
refs git handed the hook predate the rebase; the suite would otherwise judge
commits git is no longer pushing, and the server refuses the stale objects
regardless.
Off: the check becomes a pure advisor. It performs no network I/O at all (no
ls-remote, no advisory fetch — behind is judged from your last fetch) and
never runs a rebase you did not type; a behind branch stops the push with the
command to run. A hook that rewrites your branch is a bigger claim than most
teams want a "check" to make — this is the key that unmakes it.
amont.idleTimeout — how long a check may be silent
git config amont.idleTimeout 300 # seconds; default 120, 0 disables
A hung tool is silent — a captive portal, a deadlocked lock file, a prompt
nobody will answer. A slow tool talks: cargo test prints a line per test.
So the clock that decides "stuck" counts silence: a command that writes
nothing for this long is killed and the check fails, saying so and naming
this key. Two minutes catches a real hang faster than the old ten-minute wall
clock did, and lets a chatty twenty-five-minute suite finish.
Only commands whose output amont observes answer to this clock — every
check runs its tools that way by default. With amont.progress false the
tool inherits your terminal, nobody sees the bytes, and only the ceiling
below applies.
Busy is not stuck
Not every slow tool talks: vitest without a terminal prints its summary and
nothing before it, and a passing suite used to be killed as a hang. So on
Linux and macOS, once a check has been silent for a while (a third of this
budget, at most 30 seconds), amont also measures the CPU its process tree is
using — the command, its descendants, and the work of every child they have
already reaped. A check keeps its budget as long as that tree does at least
0.1 of a core of work; it is killed only when it has been silent and idle
for the whole budget. A quiet suite then runs on to amont.timeout, and the
progress line says it is busy (· quiet 2m10s · ~3.9 cores).
The trade-off is written down in ADR-0008: a silent tool that spins — a
busy loop, a polling watcher — now answers to the ceiling rather than this
budget, and the ceiling message says so. Work done by a daemon outside the
tree (a build daemon, a container engine) is not counted. On Windows, or with
amont.idleCpuCredit false, silence alone counts, and the messages say CPU
was not measured rather than claiming it was idle.
When sampling is on but a measurement is incomplete — the walk came back
partial on a loaded machine, or no sample has landed yet — the check answers
to the extended budget, this budget × amont.idleLoadScale (eight
minutes by default), never to the bare two minutes: a kill must rest on a
measurement somebody made. One partial snapshot no longer throws the
measurement away; the next complete one spans the gap, may count as busy,
and never as measured idle (ADR-0009).
git config amont.idleCpuCredit false # silence alone, everywhere
AMONT_CPU_TRACE=<file> appends the processes each measurement saw — a
diagnostic for when a check was kept alive, or killed, and you want to know
what amont was looking at. AMONT_CPU_MAX_PROCS=<n> caps the walk, which
forces an incomplete measurement on purpose.
Waiting is not stuck
A tool that is waiting on a lock says so and then says nothing: cargo
prints Blocking waiting for file lock on build directory while another
cargo — a second worktree, rust-analyzer, a build in another session —
holds it, and uv prints Waiting to acquire … lock for …. Those exact lines,
on stderr, pause this clock: the wait answers to amont.lockWait instead,
and the progress line says · cargo lock 1m30s/10m00s. A check that passes
after such a wait says how long it waited and for what.
A line that only looks like a wait (waiting for lock on …) does not pause
anything. If the silence budget kills a built-in check whose last line read
like that, amont runs it once more, under what is left of the same ceiling,
and says so; the first kill prints no failure. A declared check is never
retried: a command you wrote may not be safe to run twice.
The silence budget also stretches with the host's load: on a machine whose
one-minute load average is twice its core count, two minutes become four,
up to amont.idleLoadScale. The progress line and the kill message name
the stretched figure, the load and the key.
Worst case for a genuinely stuck, silent tool, at the defaults:
| situation | killed after |
|---|---|
| CPU measured idle, host not loaded | 2 minutes |
| CPU measured idle, host loaded | up to 8 minutes (idleLoadScale 4) |
| CPU could not be measured | 8 minutes, even with amont.timeout 0 |
| in a declared cargo or uv lock wait | 10 minutes (amont.lockWait) |
| retried after a wait-like last line | both attempts inside one amont.timeout |
| waiting for a host slot | up to amont.timeout, before it starts |
| anything | amont.timeout, one hour |
amont.timeout — the ceiling one check's command may run for
git config amont.timeout 900 # seconds; default 3600, 0 disables
The backstop behind idleTimeout: a tool that keeps printing and never
finishes is killed here, and the message says whether it was still printing
(slow — raise this) or had gone quiet (stuck — look at the tool). The default
was ten minutes when this was the only clock and had to catch hangs; with
silence doing that job, the ceiling can afford an hour.
The kill reaches the command itself; a grandchild it detached may survive, orphaned, but the commit is no longer hostage to it.
While a stage runs, a terminal shows a live line per check with its elapsed
time, a · quiet 45s/2m note once a check has been silent for half a minute
(· quiet 2m10s · ~3.9 cores instead while its CPU is busy,
· idle 40s/2m counting what the budget counts once busy work has pushed it
back, · cargo lock 1m30s/10m00s during a declared wait, and
· queued 42s (slots 2/2) while it waits for a host slot), and · 50m/1h once it is within 80% of the ceiling — the cliff, shown
before the fall. Piped (an agent, CI), the same information arrives as one
plain line a minute per running check: elapsed, time since its last output,
what its CPU is doing (busy ~3.9 cores, CPU idle 40s, CPU unmeasured),
and, the first time, both budgets.
The same clock bounds the push path's own network verbs: pull-rebase's
sync runs under the full budget, and the reachability probes
(ls-remote before a sync, the initial-push check) under the smaller of
this and 30 seconds — a probe answers in a second or two when the network
is there at all, and a captive portal must not get ten minutes to say
nothing. 0 disables these deadlines too.
amont.lockWait — how long a check may wait on a lock
git config amont.lockWait 900 # seconds; default 600, 0 = until the ceiling
How long a tool may sit in a declared lock wait (see "Waiting is not
stuck" above) before it is killed. The kill message names the lock and who
probably holds it. 0 leaves the wait to amont.timeout; with that off
too, the extended silence budget bounds it, so nothing waits forever.
Settable from amont.conf (set lockWait 900): how long a workspace's cold
build holds its lock is the project's to know.
Host keys: amont.idleLoadScale and amont.hostSlots
These two describe the machine, not the repository, so they are read from
--global or --system git config only. A value set in a repository is
ignored, with one warning naming the key and --global; amont.conf
cannot set them. Precedence, highest first: the environment variable
(AMONT_IDLE_LOAD_SCALE, AMONT_HOST_SLOTS), then --global, then
--system, then the default.
amont.idleLoadScale — how far a loaded host stretches the silence budget
git config --global amont.idleLoadScale 2 # default 4, 1 = never stretch, up to 16
The silence budget is multiplied by the host's one-minute load average over its core count, floored at 1 and capped at this. The same factor sets the extended budget a check answers to while its CPU could not be measured. Linux and macOS read the load; elsewhere the factor is 1.
amont.hostSlots — how many heavy checks one machine runs at once
git config --global amont.hostSlots 2 # default: a quarter of the cores, at least 1; 0 = off
Clippy, go vet, pyright and the test suites compile or execute the
product, and each already uses every core. Several worktrees, sessions or
agents running them at once thrash the same cores and the same cargo lock.
So a heavy check takes one of these slots before its first tool runs, and
waits — its clocks not yet started — while all are taken; the progress line
says · queued 42s (slots 2/2). A check that turns out to have nothing to
do never queues.
The slots are flock locks on files in /tmp/amont-slots-<uid>
($XDG_RUNTIME_DIR/amont-slots on Linux when set), a fixed path so every
session shares one queue whatever its $TMPDIR. amont refuses that
directory unless it is a real directory you own with mode 0700, and then
runs the check unqueued with one line saying why. A slot that never frees
within amont.timeout runs the check unqueued too, with a note: the queue
is a courtesy to the machine, not a gate on the code. Fairness is polling,
not first come first served. A held slot is released when the check ends,
and by the kernel if amont dies; an amont started by a check that holds a
slot (AMONT_HOST_SLOT set) does not queue. Windows has no slots.
amont.minVersion — the amont this repository means
git config amont.minVersion 1.11.0
Rarely set by hand — the committable spelling is set minVersion 1.11.0
in a trusted amont.conf,
which is the point: a binary one release behind answers every hook name
and simply lacks a check, silently, and nothing in the repository could
say which amont the team meant. A binary older than the floor gets one
warning line per stage naming both versions. Warn-only, deliberately:
blocking commits for being out of date teaches --no-verify, and a
binary too old to know this key cannot honour it anyway.
amont.knownIdentity — identities usual-name has vouched for
Written by the tool, not by you: when pre-commit-usual-name finds your
user.name <user.email> in history once, it records the identity here
(local config, never committed) and never walks the full history for it
again — git shortlog --all on every commit is milliseconds today and a
scale cliff on a long history. Multi-valued; amont uninstall removes it
with the rest of amont's bookkeeping. Delete a value to make the check walk
again.
amont.fix — let a check repair what it finds
git config amont.fix true
Off unless you ask. A hook that edits your files without being asked is a larger surprise than one that complains. See custom checks.
amont.testPushedTree — test what you are pushing
git config amont.testPushedTree true
By default pre-push runs your suite against the working tree, and says
so. That is fast and usually what you want, but it is not what you are pushing:
an uncommitted fix makes a broken commit look green.
With this set, the suite runs in a throwaway checkout of the commits being
pushed, and your tree is not touched. This covers every pre-push gate that
runs a suite — the built-ins and any pre-push line in your amont.conf
alike, each once per pushed ref. It costs a second checkout and a build
that cannot reuse your target/ cache, which is why it is opt-in rather than
the default. A checkout that needs a step before it can run anything — a
pnpm install, say — names it in amont.snapshotPrepare, above.
amont.attest / amont.attestKey — sign what pre-push proved, for CI
git config amont.attest true
Off by default. With it on, a push whose pre-push block gates all passed
leaves an ssh-keygen-signed note on each pushed tip in
refs/notes/amont-attest — binding the pushed tree, the names of the
gates that passed, and the amont version — and pushes that ref to the same
remote, so CI can verify the note and skip the test steps it names.
amont.attestKey points at the signing key; unset, it means
~/.ssh/amont-attest. The verifying side, the trust statement being made,
and why every failure falls back to CI running the tests are in
the CI backstop.
amont.trusted
Set by amont trust, read by everything that decides whether a declared
external may run. --local only, never committed. Do not set it by hand — see
the trust model.
commit.template
Not ours, but worth setting: it puts the footer scaffold in front of you when you write a commit.
git config --global commit.template ~/.config/git/git-templates/message
Environment variables
| variable | effect |
|---|---|
GIT_HOOKS_BIN | Absolute path to the binary a shim should use. First candidate in the shim's resolution order. |
AMONT_BIN_DIR | Where amont install and the installer script put binaries. Default ~/.local/bin. |
AMONT_VERSION | Pins the version the installer script fetches. |
AMONT_ATTEST_PUSH | Set by amont itself on the notes push amont.attest makes, so the recursive pre-push stands down. Not for humans. |
NO_COLOR | Honoured, as is a non-tty stdout. |
Repository-declared checks
A repository can add checks of its own without anybody forking anything, in a
committed amont.conf. They obey every control on this page, addressed the
same three ways, and they are inert until trusted.
Full reference: custom checks · the trust model.
Seeing the result
amont list # what would run here, and why not
amont list --json # the same, machine-readable
amont setup # walk the commit-style keys, with the current values
amont-fleet # the same, across every repository
amont list ends with the commit style in effect, and names the key and the
scope of anything you set:
commit style
gitmoji suffix amont.commit.gitmoji (global)
subject max 72
description max 68 amont.commit.descriptionMax (global)
body wrap off amont.commit.bodyWrap (local)
`amont setup` to change any of these
amont list reports the effective severity, after overrides — so a
check you downgraded three months ago is visible as downgraded rather than
having to be inferred from config. Across a fleet, amont-fleet shows
skips and severities per repository, with TRIGGER as its own column.