The pre-push gate
A checked-in .githooks/pre-push runs the fast subset of CI before a push, so a
broken push costs seconds locally instead of minutes in CI. It is opt-in per
clone — enable it with:
git config core.hooksPath .githooksAn uninstalled clone has no gate. Git will not run a hook from a tracked directory without that config, so until you set it nothing below happens. There is deliberately no auto-install (ADR-0033 Alternative H).
What it runs
It stops at the first failure and names the step that failed:
| Step | Command |
|---|---|
| Doc links | node scripts/check-doc-links.mjs |
| Index rows | node scripts/check-index-rows.mjs |
| Index rows (self-test) | node scripts/check-index-rows.mjs --self-test |
| Backlog claims | node scripts/check-backlog-claims.mjs |
| Filter figures | node scripts/check-filter-figures.mjs |
| Comment hygiene | node scripts/check-comment-hygiene.mjs |
| Contents blocks | node scripts/toc.mjs --check |
| Contents blocks (self-test) | node scripts/toc.mjs --self-test |
| Reader prose | node scripts/check-reader-prose.mjs |
| Release tag | node scripts/check-release-tag.mjs |
| Release tag (self-test) | node scripts/check-release-tag.mjs --self-test |
| Release assets (self-test) | node scripts/check-release-assets.mjs --self-test |
| Translations | node scripts/check-translations.mjs |
| Translations (self-test) | node scripts/check-translations.mjs --self-test |
| System counts | node scripts/check-system-counts.mjs |
| Settings have files | node scripts/check-settings-have-files.mjs |
| Claude declarations | node scripts/check-claude-declarations.mjs |
| Claude declarations (self-test) | node scripts/check-claude-declarations.mjs --self-test |
| Gate carriers | node scripts/check-gate-carriers.mjs |
| Gate carriers (self-test) | node scripts/check-gate-carriers.mjs --self-test |
| Diffusion filter | python3 tools/sd-filter/test_sd_filter.py (skips with no python3; its end-to-end group skips when no built shot is newer than the last change to core/, standalone/, Cargo.toml or Cargo.lock to reach this checkout - see tools/sd-filter/README.md) |
| Studio typecheck | npm --prefix studio run typecheck (skips with no studio/node_modules) |
| Studio lint | npm --prefix studio run lint (same guard) |
| Studio tests | npm --prefix studio test (same guard) |
| Format | cargo fmt --all --check (only when the push moved a Rust-relevant path — below) |
| Lint | cargo clippy --workspace --all-targets -- -D warnings (same condition) |
| Rustdoc | cargo doc --workspace --no-deps under RUSTDOCFLAGS=-D warnings (same condition) |
| Tests | cargo nextest run --workspace -P fast (same condition, narrowed, and served from the suite ledger when it can be — below) |
Everything above the cargo steps runs on every push; the four cargo steps do not
(ADR-0237).
Git hands the hook one line per pushed ref, and the hook asks
node scripts/push-scope.mjs <remote sha> <local sha> about each range. The cargo steps run when
any range touches a path listed in scripts/push-scope.manifest.mjs — Rust source, a manifest or
the lockfile, the toolchain and cargo configuration, anything under a workspace crate’s directory,
presets/, and the few files outside the crates that a Rust test opens, such as
docs/configuration.md. When nothing does, one line names the ranges it read and the push is done:
pre-push: skipping cargo fmt, clippy, rustdoc and tests: 7f7ed2c..e3498a5 touches no Rust-relevant path (scripts/push-scope.manifest.mjs)When they run, the line names the first path that matched:
pre-push: running the cargo steps: push-scope: f2bf1dd..60f4c28 touches Cargo.lock (rule Cargo.lock): yesWhatever the hook cannot read runs all four, and the line says why: a ref that is new on the
remote (there is no range to compare against), an empty or unparseable stdin, a push of nothing but
deletions, a shallow clone, a sha this clone does not hold, or the hook run by hand from a terminal.
A new tag is a new ref too, so a push carrying a release tag — every close push made with
git push --follow-tags — runs the cargo steps even when the branch beside it moved no Rust; the
test step can still be served from the ledger.
The test step is served instead of run when the suite ledger already proves the tree. Before it,
node tools/conductor/suite-record.mjs asks the ledger
(ADR-0207)
whether this worktree’s tree, clean, has a green cargo nextest run --workspace. Who wrote the
record does not matter — a conductor gate, a review session, or your own full suite run through
tools/conductor/with-lock.mjs — and the line names it:
pre-push: serving cargo nextest run -P fast from the suite ledger: tree <tree> is green in the suite ledger, run by <writer> at <time>: <nextest summary>A dirty worktree, a tree whose only record is a -P fast run, and a tree whose newest full run was
red all run the step, after a line saying why the ledger did not serve it. So the saving lands on a tip a full suite already
proved — main after a conductor fast-forward — and not on every intermediate commit.
The path list is a judgement, and it will be wrong once: some build input nobody listed will let
a push skip a suite that would have gone red. CI runs every step unconditionally and is what catches
it. The repair is a line in the manifest and a case in scripts/fixtures/push-scope/cases.json,
checked by node scripts/push-scope.mjs --self-test.
The two guarded groups skip rather than fail, and say so. A clone with no
python3 on PATH and one that has never run npm --prefix studio ci are both
ordinary, so those four steps print a notice naming what is missing and the
command that would make them run, and the push continues
(ADR-0016). CI runs all four
unconditionally and is the backstop under both; so does the conductor’s gate,
which since
ADR-0218
reports each skip the same way instead of dropping the step in silence.
The Node steps come first because they are the cheapest (tens of milliseconds
between them): every relative markdown link in the repo must resolve, every row
inside a marked roster region must stay under 320 bytes
(ADR-0116),
every live design-backlog.md entry must carry a probe that still holds
(ADR-0108),
the diffusion filter’s cost figures must live in one file
(ADR-0122), no .rs
comment may carry a relative link or plan-relative narration
(ADR-0127),
every citation in a reader document must sit inside a link
(ADR-0168),
every generated contents block must still match the headings beneath it
(ADR-0163),
the version root Cargo.toml declares must carry an annotated tag on HEAD’s
history, because git push --follow-tags never sends a lightweight one
(ADR-0203),
every .ru.md translation must open with the translated-from: <sha> stamp
naming the commit its source was translated from
(ADR-0185),
and this table’s own Node steps must equal the ordered roster in
scripts/gates.manifest.mjs, as must CI’s links job and the conductor’s gate
(ADR-0217) —
so a gate added to one carrier and not the others is red here, at the push.
The four that could go green on a rule that had quietly stopped working — a roster
detector matching nothing, an anchor rule that is merely plausible, a tag-type check
that no longer looks, a stamp reader that finds no translations at all — carry a
--self-test beside their check. The roster gate carries one for a different
reason: its plain run cannot go vacuously green (a parser that stopped matching
reports an empty list against a roster that is not), so its self-test is there for
the reporting path and the three drift shapes instead.
A translation that has drifted is never a failure. The stamp check reports a
source that has moved as an advisory row and exits 0: nothing mechanical can judge
whether the Russian still says what the English now says, and hard-failing would
make that slice a hostage of every hotkey edit. A missing or malformed stamp is
the exit code, because that much is mechanical.
The release-tag step refuses a push, not only a release: from the moment a close
moves the version, every push fails until that version’s tag is annotated. CI’s
links job cannot read local tags, so it runs the same script with --remote on a
push to main instead, and asks origin.
If node is not on your PATH they all skip with a notice rather than
failing the push; nothing else here needs Node. That skip is about the hook only
— CI’s links job runs the same checks on ubuntu-latest, where they cannot
skip and are not bypassable.
What it costs
What a push costs depends on which of the three shapes above it takes. Measured on 2026-09-22,
one run each, the whole hook driven with a real ref line on stdin, on the Windows reference machine
(AMD Radeon integrated, DX12), in a worktree whose target/ was already built and which had no
studio/node_modules — so the studio’s three steps skipped, and add their 15.2 s wherever they run:
| Push | Wall time | What ran |
|---|---|---|
| A range touching no Rust-relevant path | 8.2 s | the Node roster and the Python suite; every cargo step skipped |
| A Rust range whose tree has a full-suite record | 12.1 s | the same, plus fmt, clippy and rustdoc with nothing to rebuild, and the ledger lookup; the test step served. The record was seeded into a scratch ledger for the reading, since no tree of the measuring lane had a real one |
| A Rust range whose tree has no record | 557.2 s | everything; the test step alone took 544.8 s, 1695 tests passed and 86 skipped |
The test step is the whole difference between the last two rows. On 2026-09-14 an earlier
~410 s reading of it on the same machine was about 165 s idle, and the reason is still true:
every test that asserts on wall-clock time runs alone: nextest waits for the running tests to
drain, runs it, and starts nothing beside it. So you will see the run pause on those tests.
.config/nextest.toml names them, and a guard in core/tests/suite/hygiene.rs holds that list to
the tests that read the clock
(ADR-0193). The hook excludes the nine
GPU-heavy suites that iterate every shipped preset or scene through a real
adapter. Which nine is not written here — since
ADR-0156
the list is the fast profile’s default-filter in .config/nextest.toml, and the hook and CI’s
check job both cite -P fast rather than restating it.
The test runner names the skipped binaries itself on every run, on the profile’s
authority, so the narrowing is never silent. CI runs all of them regardless, but on Windows
only. The check matrix runs -P fast on Windows, macOS and Ubuntu. Since
ADR-0073, the nine run in the coverage job alone,
on windows-latest, so one Windows job underwrites that promise.
One of the nine is golden, and on Windows it skips, because its baselines are lavapipe’s. So
check (ubuntu-latest) runs that one binary in a step of its own after -P fast, and it is the
only CI job that asserts the golden roster; a full run on a Linux box with lavapipe is the other
place it asserts. Locally, the full cargo nextest run --workspace runs them on any
machine, and it is green on Arch (see A fresh Arch Linux checkout).
The rustdoc step fails a broken or private intra-doc link in any of the five workspace
members before CI’s cargo doc --workspace job does. Scoping it to -p rlx-core left the other
four documented in CI and nowhere else — a rustdoc error in one of them was unreachable before a
push, and it fired twice in sixteen days, both times repaired after a release tag had been written
on top of the red
(ADR-0217).
On the Windows box, then the reference machine, the widened step measures
7.8 s warm after an edit to rlx-ring (the deepest crate, so every member re-documents), 0.5 s warm
with nothing changed, and 20.1 s after a cargo clean --doc — about two seconds more than the
scoped step it replaces.
cargo deny, doctests, Miri, and the coverage job are deliberately not in the
hook — they push it into minutes, and a gate that hurts gets disabled
(ADR-0033 Alternative F).
They are also the checks least likely to break from a local edit.
The npm audit gate is outside the hook for cargo deny’s reason: it asks the registry, and its
answer moves when an advisory is published, not when you commit. It runs in CI’s npm-audit job
and fails at high in the studio’s shipped graph (npm audit --omit=dev, the packages inside the
studio zip) and at critical in the full graphs of studio/ and site/
(ADR-0244).
An exception is an entry in npm-audit.allow.json at the repository root, a GHSA id with the reason
the studio or the site is not exposed; an entry without a reason fails the gate, and one whose
advisory has gone away is printed for deletion. Run it by hand before a push that bumps an npm
dependency:
node scripts/check-npm-audit.mjs # the three audits, against the registrynode scripts/check-npm-audit.mjs --self-test # the gate's own assertions, offlineThe spout compile job (ADR-0181) is outside the hook too, and for neither of those reasons.
It stages a third-party SDK over the network before it compiles anything, which does not fit the
hook’s budget at all — and it is the one CI gate that is likely to break from an ordinary local
edit, because code behind #[cfg(feature = "spout")] is not type-checked when the feature is off,
so every cargo build, clippy --all-targets and nextest run here is blind to it by
construction. If you are refactoring anything standalone/src/stream.rs reaches, compile it
yourself before pushing:
# Windows only: Spout is a Windows SDK, and the feature compiles nothing elsewherepowershell -File packaging/spout/fetch-sdk.ps1 # once per checkoutcargo check -p standalone --features spoutBypass once with git push --no-verify.
Built from f4f807a at version 0.178.0. This site tracks main and is not versioned per release.