| 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
|
| 40 |
58 |
|
echo "pre-commit: gitleaks not installed; skipping secret scan (astra gates on push)."
|
| 41 |
59 |
|
fi
|
| 42 |
60 |
|
|
| 43 |
|
- |
# Paths the gate ignores (extended regex, matched against repo-relative paths).
|
| 44 |
|
- |
# Empty means check everything.
|
|
61 |
+ |
# --- migration immutability -------------------------------------------------
|
|
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.
|
|
67 |
+ |
#
|
|
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.
|
|
76 |
+ |
touched="$(git diff --cached --name-only --diff-filter=MDR -- '*migrations/*.sql')"
|
|
77 |
+ |
if [ -n "$touched" ]; then
|
|
78 |
+ |
echo "pre-commit: these already-committed migrations were modified, renamed, or deleted:"
|
|
79 |
+ |
while IFS= read -r m; do
|
|
80 |
+ |
[ -n "$m" ] && echo " $m"
|
|
81 |
+ |
done <<< "$touched"
|
|
82 |
+ |
echo " A migration is immutable once applied; the runner checksums the"
|
|
83 |
+ |
echo " whole file, comments included. Write a new migration instead."
|
|
84 |
+ |
echo " Bypass ONLY if it has never been applied anywhere, including"
|
|
85 |
+ |
echo " prod, staging, and your dev database: git commit --no-verify."
|
|
86 |
+ |
exit 1
|
|
87 |
+ |
fi
|
|
88 |
+ |
|
|
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.
|
|
95 |
+ |
#
|
|
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."
|
|
109 |
+ |
exit 1
|
|
110 |
+ |
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)"
|
|
114 |
+ |
fi
|
|
115 |
+ |
|
|
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.
|
| 45 |
125 |
|
SKIP_PATHS="${SKIP_PATHS:-}"
|
| 46 |
126 |
|
|
| 47 |
127 |
|
staged="$(git diff --cached --name-only --diff-filter=ACMR -- '*.rs')"
|
| 48 |
128 |
|
if [ -n "$SKIP_PATHS" ]; then
|
| 49 |
129 |
|
staged="$(printf '%s\n' "$staged" | grep -Ev "$SKIP_PATHS" || true)"
|
| 50 |
130 |
|
fi
|
| 51 |
|
- |
[ -n "$staged" ] || exit 0
|
| 52 |
131 |
|
|
| 53 |
|
- |
# Map each staged file to the directory of its nearest Cargo.toml.
|
| 54 |
|
- |
crates=""
|
| 55 |
|
- |
while IFS= read -r f; do
|
| 56 |
|
- |
[ -n "$f" ] || continue
|
| 57 |
|
- |
d="$(dirname "$f")"
|
| 58 |
|
- |
while [ "$d" != "." ] && [ ! -f "$d/Cargo.toml" ]; do
|
| 59 |
|
- |
d="$(dirname "$d")"
|
| 60 |
|
- |
done
|
| 61 |
|
- |
[ -f "$d/Cargo.toml" ] || continue
|
| 62 |
|
- |
crates="$crates$d"$'\n'
|
| 63 |
|
- |
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"
|
| 64 |
144 |
|
|
| 65 |
|
- |
crates="$(printf '%s' "$crates" | sort -u)"
|
| 66 |
|
- |
[ -n "$crates" ] || exit 0
|
|
145 |
+ |
crates="$(printf '%s' "$crates" | sort -u)"
|
| 67 |
146 |
|
|
| 68 |
|
- |
failed=0
|
| 69 |
|
- |
while IFS= read -r c; do
|
| 70 |
|
- |
[ -n "$c" ] || continue
|
| 71 |
|
- |
if ! (cd "$c" && cargo fmt --check >/dev/null 2>&1); then
|
| 72 |
|
- |
echo "pre-commit: rustfmt gate failed in $c"
|
| 73 |
|
- |
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
|
| 74 |
160 |
|
fi
|
| 75 |
|
- |
done <<< "$crates"
|
| 76 |
|
- |
|
| 77 |
|
- |
if [ "$failed" -ne 0 ]; then
|
| 78 |
|
- |
echo "pre-commit: run 'cargo fmt' in the crates above, then restage."
|
| 79 |
|
- |
echo "pre-commit: commit aborted (use --no-verify to bypass)."
|
| 80 |
|
- |
exit 1
|
|
161 |
+ |
echo "pre-commit: rustfmt gate clean."
|
| 81 |
162 |
|
fi
|
| 82 |
163 |
|
|
| 83 |
|
- |
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
|