Skip to main content

max / makenotwork

Standardize the pre-commit gates on the canonical hook One file, byte-identical in every repo, installed from _private/infra/bootstrap/githooks by install-githooks.sh. Each gate selects itself from the repo's shape rather than from a per-repo edit: gitleaks always, migration immutability where migrations exist, frontend lint where a lint-frontend.sh exists, rustfmt where Rust is staged, openapi where a generated spec is committed. pre-push builds the test targets where a root Cargo.toml makes --workspace mean something. Closes the drift that let makeover-immediate 0.18.0 reach its release preflight unformatted and left eight violations on quasi's main. GoingsOn infra a33fdaab.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-14 14:35 UTC
Signed with PGP, not checked
Commit: ab87d87ebc03f38690f518f43846508f7c07e31a
Parent: 587f217
1 file changed, +145 insertions, -81 deletions
@@ -1,20 +1,38 @@
1 1 #!/bin/bash
2 - # rustfmt gate: blocks a commit whose staged Rust files are not formatted.
2 + # Canonical pre-commit gate. Byte-identical in every repo under ~/Code.
3 + #
4 + # DO NOT EDIT IN PLACE. The master is _private/infra/bootstrap/githooks/pre-commit
5 + # and install-githooks.sh --check reports any copy that has drifted from it. Edit
6 + # the master, re-run the installer, commit the repos it touched.
3 7 #
4 8 # Activate in a fresh clone (one-time):
5 9 # git config core.hooksPath scripts/githooks
10 + # clone-tree.sh does this for every repo it clones, so only a hand clone needs it.
6 11 #
7 12 # Bypass for a work-in-progress commit: git commit --no-verify
8 13 #
9 - # Only crates with staged .rs changes are checked, so the hook stays fast on a
10 - # large repo. Each file maps to the nearest enclosing Cargo.toml, and the check
11 - # runs as `cargo fmt` there, which picks up that crate's edition and any
12 - # rustfmt.toml rather than guessing.
14 + # Every gate below decides for itself whether it applies, from what is in the repo
15 + # and what is staged. That is what lets one file serve a library, an app and a
16 + # server: the repo's shape selects the gates rather than a per-repo edit, which is
17 + # the drift that let makeover-immediate 0.18.0 reach its release preflight
18 + # unformatted and left eight violations sitting on quasi's main (infra a33fdaab).
19 + #
20 + # NOT here, deliberately: clippy. It is slow enough that a commit-time gate is one
21 + # people bypass, so it stays in CI and the sweep.
22 + #
23 + # Genuinely repo-local extras go in scripts/githooks/pre-commit.local, which this
24 + # runs last if it exists.
13 25 set -euo pipefail
14 26
15 27 ROOT="$(git rev-parse --show-toplevel)"
16 28 cd "$ROOT"
17 29
30 + # git invoked from an editor, a cron job, or a non-interactive shell does not
31 + # source the profile that puts ~/.local/bin on PATH, and a hook that silently
32 + # cannot find gitleaks or cargo is worse than no hook. (Lesson from _private's
33 + # own hook, which is stricter still: it refuses to commit blind.)
34 + export PATH="$HOME/.local/bin:$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
35 +
18 36 # --- secret scan (gitleaks) -------------------------------------------------
19 37 # Independent guardrail: blocks a commit whose staged changes contain a secret,
20 38 # regardless of whether a human judged the value "safe". Shared ruleset lives at
@@ -41,108 +59,154 @@
41 59 fi
42 60
43 61 # --- migration immutability -------------------------------------------------
44 - # sqlx checksums a migration's whole file when it runs it and refuses one whose
45 - # bytes changed since. So editing a migration that has already been applied
46 - # anywhere breaks every deploy against that database with "previously applied
47 - # but modified" — for a comment edit, and for a line-ending change, exactly as
48 - # much as for a schema change.
62 + # Applies to any repo with migrations, which is why it is not MNW-local: sqlx
63 + # checksums a migration's whole file when it runs it and refuses one whose bytes
64 + # changed since, so editing an already-applied migration breaks every deploy
65 + # against that database with "previously applied but modified" -- for a comment
66 + # edit, and for a line-ending change, exactly as much as for a schema change.
49 67 #
50 - # The 2026-07-27 exorcise sweep rewrote comments in 29 applied migrations and
51 - # converted one from CRLF to LF. Nothing in the test suite checksums a
52 - # migration, so it stayed invisible while it blocked every server deploy for
53 - # three days. Hence a gate at commit time: a migration that already exists in
54 - # HEAD may not be modified, renamed, or deleted. Adding a new one is always fine.
68 + # The 2026-07-27 exorcise sweep rewrote comments in 29 applied MNW migrations and
69 + # converted one from CRLF to LF. Nothing in the test suite checksums a migration,
70 + # so it stayed invisible while it blocked every server deploy for three days.
71 + #
72 + # goingson and balanced_breakfast are in scope too: both kept their
73 + # `_sqlx_migrations` ledger verbatim through the 2026-08-07 rusqlite migration, so
74 + # an upgraded install still reads those rows and an edited file still contradicts
75 + # them. Adding a new migration is always fine; this only blocks M/D/R.
55 76 touched="$(git diff --cached --name-only --diff-filter=MDR -- '*migrations/*.sql')"
56 77 if [ -n "$touched" ]; then
57 78 echo "pre-commit: these already-committed migrations were modified, renamed, or deleted:"
58 79 while IFS= read -r m; do
59 80 [ -n "$m" ] && echo " $m"
60 81 done <<< "$touched"
61 - echo " A migration is immutable once applied; sqlx checksums the whole"
62 - echo " file, comments included. Write a new migration instead."
82 + echo " A migration is immutable once applied; the runner checksums the"
83 + echo " whole file, comments included. Write a new migration instead."
63 84 echo " Bypass ONLY if it has never been applied anywhere, including"
64 85 echo " prod, staging, and your dev database: git commit --no-verify."
65 86 exit 1
66 87 fi
67 - echo "pre-commit: migrations unmodified."
68 88
69 - # --- openapi.json staleness -------------------------------------------------
70 - # server/openapi.json is a committed artifact and `openapi::tests::
71 - # committed_spec_matches_generated` asserts it matches the generated spec. The
72 - # spec embeds CARGO_PKG_VERSION, so EVERY version bump invalidates it even when
73 - # no route changed.
89 + # --- frontend design-system lint --------------------------------------------
90 + # Runs any scripts/lint-frontend.sh the repo carries when the commit touches a
91 + # frontend asset. Each of those scripts resolves its own paths from its location,
92 + # so finding them is enough and no path knowledge belongs here. Both known scripts
93 + # live at repo root (goingson, balanced_breakfast) or one level down (MNW's is
94 + # server/scripts/lint-frontend.sh), hence the depth-2 search.
74 95 #
75 - # Nothing local caught that. The /deploy pre-push guard is a `cargo test
76 - # --no-run` compile check, and the spec is read at runtime by path rather than
77 - # include_str!, so a stale copy compiles fine. On 2026-08-06 the v0.11.8 bump
78 - # left the spec at 0.11.7, pushed clean to all three remotes, and killed Sando
79 - # run 38 about fifteen minutes in — two full remote build cycles for a one-line
80 - # diff in info.version.
81 - #
82 - # So: regenerate to stdout and compare against the STAGED copy (not the working
83 - # tree one — regenerating without restaging is the same bug wearing a hat).
84 - specish="$(git diff --cached --name-only --diff-filter=ACMR \
85 - -- 'server/Cargo.toml' 'server/src/*.rs' 'server/src/**/*.rs' 'server/openapi.json')"
86 - if [ -n "$specish" ]; then
87 - echo "pre-commit: checking openapi.json against the generated spec..."
88 - gen="$(mktemp)"
89 - trap 'rm -f "$gen"' EXIT
90 - if (cd "$ROOT/server" && cargo run --quiet --bin export-openapi -- --stdout) > "$gen" 2>/dev/null; then
91 - if ! git show :server/openapi.json 2>/dev/null | diff -q - "$gen" >/dev/null; then
92 - echo "pre-commit: server/openapi.json is stale (or regenerated but not staged)."
93 - echo " cd server && cargo run --bin export-openapi"
94 - echo " git add server/openapi.json"
95 - echo " Then vendor the same bytes into the OTHER repo, which this"
96 - echo " commit cannot carry and Sando will fail on:"
97 - echo " cp server/openapi.json ../synckit/synckit-client/tests/openapi.json"
98 - echo " Bypass: git commit --no-verify."
96 + # This sits ABOVE the rustfmt gate deliberately: that gate exits early when no .rs
97 + # files are staged, which is exactly the case where a frontend commit needs
98 + # checking. goingson's copy also runs the JS suite, which carries the CHRONIC-XSS
99 + # escaping gate.
100 + staged_fe="$(git diff --cached --name-only --diff-filter=ACMR -- '*.js' '*.css' '*.html')"
101 + if [ -n "$staged_fe" ]; then
102 + while IFS= read -r lint; do
103 + [ -n "$lint" ] || continue
104 + if ! fe_out=$(bash "$lint" 2>&1); then
105 + echo "$fe_out"
106 + echo "pre-commit: frontend lint failed ($lint)."
107 + echo " fix the rules above, then restage."
108 + echo " bypass: git commit --no-verify."
99 109 exit 1
100 110 fi
101 - echo "pre-commit: openapi.json current."
102 - else
103 - echo "pre-commit: could not build export-openapi; skipping spec check."
104 - echo " cargo_test in Sando is the backstop, 15 minutes into the build."
105 - fi
111 + echo "pre-commit: frontend lint clean ($lint)."
112 + done <<< "$(find . -maxdepth 3 -path ./target -prune -o \
113 + -path '*/scripts/lint-frontend.sh' -print 2>/dev/null | sort)"
106 114 fi
107 115
108 - # Paths the gate ignores (extended regex, matched against repo-relative paths).
109 - # Empty means check everything.
116 + # --- rustfmt ----------------------------------------------------------------
117 + # Blocks a commit whose staged Rust files are not formatted. Only crates with
118 + # staged .rs changes are checked, so the hook stays fast on a large repo. Each
119 + # file maps to the nearest enclosing Cargo.toml and the check runs as `cargo fmt`
120 + # there, which picks up that crate's edition and any rustfmt.toml rather than
121 + # guessing -- and is why this works unchanged in MNW, which has no root workspace.
122 + #
123 + # SKIP_PATHS is an extended regex of repo-relative paths to ignore. Empty means
124 + # check everything. Set it in pre-commit.local if a repo ever needs one.
110 125 SKIP_PATHS="${SKIP_PATHS:-}"
111 126
112 127 staged="$(git diff --cached --name-only --diff-filter=ACMR -- '*.rs')"
113 128 if [ -n "$SKIP_PATHS" ]; then
114 129 staged="$(printf '%s\n' "$staged" | grep -Ev "$SKIP_PATHS" || true)"
115 130 fi
116 - [ -n "$staged" ] || exit 0
117 131
118 - # Map each staged file to the directory of its nearest Cargo.toml.
119 - crates=""
120 - while IFS= read -r f; do
121 - [ -n "$f" ] || continue
122 - d="$(dirname "$f")"
123 - while [ "$d" != "." ] && [ ! -f "$d/Cargo.toml" ]; do
124 - d="$(dirname "$d")"
125 - done
126 - [ -f "$d/Cargo.toml" ] || continue
127 - crates="$crates$d"$'\n'
128 - done <<< "$staged"
132 + if [ -n "$staged" ]; then
133 + # Map each staged file to the directory of its nearest Cargo.toml.
134 + crates=""
135 + while IFS= read -r f; do
136 + [ -n "$f" ] || continue
137 + d="$(dirname "$f")"
138 + while [ "$d" != "." ] && [ ! -f "$d/Cargo.toml" ]; do
139 + d="$(dirname "$d")"
140 + done
141 + [ -f "$d/Cargo.toml" ] || continue
142 + crates="$crates$d"$'\n'
143 + done <<< "$staged"
129 144
130 - crates="$(printf '%s' "$crates" | sort -u)"
131 - [ -n "$crates" ] || exit 0
145 + crates="$(printf '%s' "$crates" | sort -u)"
132 146
133 - failed=0
134 - while IFS= read -r c; do
135 - [ -n "$c" ] || continue
136 - if ! (cd "$c" && cargo fmt --check >/dev/null 2>&1); then
137 - echo "pre-commit: rustfmt gate failed in $c"
138 - failed=1
147 + failed=0
148 + while IFS= read -r c; do
149 + [ -n "$c" ] || continue
150 + if ! (cd "$c" && cargo fmt --check >/dev/null 2>&1); then
151 + echo "pre-commit: rustfmt gate failed in $c"
152 + failed=1
153 + fi
154 + done <<< "$crates"
155 +
156 + if [ "$failed" -ne 0 ]; then
157 + echo "pre-commit: run 'cargo fmt' in the crates above, then restage."
158 + echo "pre-commit: commit aborted (use --no-verify to bypass)."
159 + exit 1
139 160 fi
140 - done <<< "$crates"
141 -
142 - if [ "$failed" -ne 0 ]; then
143 - echo "pre-commit: run 'cargo fmt' in the crates above, then restage."
144 - echo "pre-commit: commit aborted (use --no-verify to bypass)."
145 - exit 1
161 + echo "pre-commit: rustfmt gate clean."
146 162 fi
147 163
148 - echo "pre-commit: rustfmt gate clean."
164 + # --- openapi.json staleness -------------------------------------------------
165 + # Only fires in a repo that commits a generated spec, which today is MNW alone.
166 + #
167 + # server/openapi.json is a committed artifact and `openapi::tests::
168 + # committed_spec_matches_generated` asserts it matches the generated spec. The
169 + # spec embeds CARGO_PKG_VERSION, so EVERY version bump invalidates it even when no
170 + # route changed.
171 + #
172 + # Nothing local caught that. The /deploy pre-push guard is a `cargo test --no-run`
173 + # compile check, and the spec is read at runtime by path rather than include_str!,
174 + # so a stale copy compiles fine. On 2026-08-06 the v0.11.8 bump left the spec at
175 + # 0.11.7, pushed clean to all three remotes, and killed Sando run 38 about fifteen
176 + # minutes in -- two full remote build cycles for a one-line diff in info.version.
177 + #
178 + # So: regenerate to stdout and compare against the STAGED copy (not the working
179 + # tree one -- regenerating without restaging is the same bug wearing a hat).
180 + if [ -f "$ROOT/server/openapi.json" ]; then
181 + specish="$(git diff --cached --name-only --diff-filter=ACMR \
182 + -- 'server/Cargo.toml' 'server/src/*.rs' 'server/src/**/*.rs' 'server/openapi.json')"
183 + if [ -n "$specish" ]; then
184 + echo "pre-commit: checking openapi.json against the generated spec..."
185 + gen="$(mktemp)"
186 + trap 'rm -f "$gen"' EXIT
187 + if (cd "$ROOT/server" && cargo run --quiet --bin export-openapi -- --stdout) > "$gen" 2>/dev/null; then
188 + if ! git show :server/openapi.json 2>/dev/null | diff -q - "$gen" >/dev/null; then
189 + echo "pre-commit: server/openapi.json is stale (or regenerated but not staged)."
190 + echo " cd server && cargo run --bin export-openapi"
191 + echo " git add server/openapi.json"
192 + echo " Then vendor the same bytes into the OTHER repo, which this"
193 + echo " commit cannot carry and Sando will fail on:"
194 + echo " cp server/openapi.json ../synckit/synckit-client/tests/openapi.json"
195 + echo " Bypass: git commit --no-verify."
196 + exit 1
197 + fi
198 + echo "pre-commit: openapi.json current."
199 + else
200 + echo "pre-commit: could not build export-openapi; skipping spec check."
201 + echo " cargo_test in Sando is the backstop, 15 minutes into the build."
202 + fi
203 + fi
204 + fi
205 +
206 + # --- repo-local extras ------------------------------------------------------
207 + # The escape hatch for a gate that cannot be selected from the repo's shape. Keep
208 + # it small: anything a second repo wants belongs in the canonical file above,
209 + # guarded by its own detection.
210 + if [ -f "$ROOT/scripts/githooks/pre-commit.local" ]; then
211 + bash "$ROOT/scripts/githooks/pre-commit.local" || exit 1
212 + fi