Skip to content
Русский

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:

Terminal window
git config core.hooksPath .githooks

An 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:

StepCommand
Doc linksnode scripts/check-doc-links.mjs
Index rowsnode scripts/check-index-rows.mjs
Index rows (self-test)node scripts/check-index-rows.mjs --self-test
Backlog claimsnode scripts/check-backlog-claims.mjs
Filter figuresnode scripts/check-filter-figures.mjs
Comment hygienenode scripts/check-comment-hygiene.mjs
Contents blocksnode scripts/toc.mjs --check
Contents blocks (self-test)node scripts/toc.mjs --self-test
Reader prosenode scripts/check-reader-prose.mjs
Release tagnode 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
Translationsnode scripts/check-translations.mjs
Translations (self-test)node scripts/check-translations.mjs --self-test
System countsnode scripts/check-system-counts.mjs
Settings have filesnode scripts/check-settings-have-files.mjs
Claude declarationsnode scripts/check-claude-declarations.mjs
Claude declarations (self-test)node scripts/check-claude-declarations.mjs --self-test
Gate carriersnode scripts/check-gate-carriers.mjs
Gate carriers (self-test)node scripts/check-gate-carriers.mjs --self-test
Diffusion filterpython3 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 typechecknpm --prefix studio run typecheck (skips with no studio/node_modules)
Studio lintnpm --prefix studio run lint (same guard)
Studio testsnpm --prefix studio test (same guard)
Formatcargo fmt --all --check (only when the push moved a Rust-relevant path — below)
Lintcargo clippy --workspace --all-targets -- -D warnings (same condition)
Rustdoccargo doc --workspace --no-deps under RUSTDOCFLAGS=-D warnings (same condition)
Testscargo 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): yes

Whatever 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:

PushWall timeWhat ran
A range touching no Rust-relevant path8.2 sthe Node roster and the Python suite; every cargo step skipped
A Rust range whose tree has a full-suite record12.1 sthe 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 record557.2 severything; 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:

Terminal window
node scripts/check-npm-audit.mjs # the three audits, against the registry
node scripts/check-npm-audit.mjs --self-test # the gate's own assertions, offline

The 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:

Terminal window
# Windows only: Spout is a Windows SDK, and the feature compiles nothing elsewhere
powershell -File packaging/spout/fetch-sdk.ps1 # once per checkout
cargo check -p standalone --features spout

Bypass once with git push --no-verify.

Built from f4f807a at version 0.178.0. This site tracks main and is not versioned per release.