Skip to main content

max / synckit

10.3 KB · 213 lines History Blame Raw
1 #!/bin/bash
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.
7 #
8 # Activate in a fresh clone (one-time):
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.
11 #
12 # Bypass for a work-in-progress commit: git commit --no-verify
13 #
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.
25 set -euo pipefail
26
27 ROOT="$(git rev-parse --show-toplevel)"
28 cd "$ROOT"
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
36 # --- secret scan (gitleaks) -------------------------------------------------
37 # Independent guardrail: blocks a commit whose staged changes contain a secret,
38 # regardless of whether a human judged the value "safe". Shared ruleset lives at
39 # ~/Code/.gitleaks.toml. Degrades gracefully if gitleaks is not installed (the
40 # astra pre-receive hook is the backstop that always runs). Task: infra 97ffeda0.
41 if command -v gitleaks >/dev/null 2>&1; then
42 GL_CFG=""
43 if [ -f "$ROOT/.gitleaks.toml" ]; then
44 GL_CFG="$ROOT/.gitleaks.toml"
45 elif [ -f "$HOME/Code/.gitleaks.toml" ]; then
46 GL_CFG="$HOME/Code/.gitleaks.toml"
47 fi
48 gl_args=(git --staged --no-banner --redact)
49 [ -n "$GL_CFG" ] && gl_args+=(-c "$GL_CFG")
50 if ! gitleaks "${gl_args[@]}"; then
51 echo "pre-commit: gitleaks found a secret in the staged changes."
52 echo " remove it (or allowlist a false positive), then restage."
53 echo " bypass: git commit --no-verify."
54 exit 1
55 fi
56 echo "pre-commit: gitleaks clean."
57 else
58 echo "pre-commit: gitleaks not installed; skipping secret scan (astra gates on push)."
59 fi
60
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.
125 SKIP_PATHS="${SKIP_PATHS:-}"
126
127 staged="$(git diff --cached --name-only --diff-filter=ACMR -- '*.rs')"
128 if [ -n "$SKIP_PATHS" ]; then
129 staged="$(printf '%s\n' "$staged" | grep -Ev "$SKIP_PATHS" || true)"
130 fi
131
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"
144
145 crates="$(printf '%s' "$crates" | sort -u)"
146
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
160 fi
161 echo "pre-commit: rustfmt gate clean."
162 fi
163
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
213