Files
settled-reach/.config/hooks/pre-push
T
jpmschweitzerandClaude Opus 5 086d9ed56e fix(client): bake the version into the build, so an export can invalidate its cache (T-1241)
current_schema_version() line-scanned res://../project.yaml at runtime. That
resolves to the repo root in a dev run and to nothing in an exported build, so a
shipped game got the "?.?.?" fallback every time. Since that tag is the Atlas
disk cache's ONLY invalidation signal, every exported build stamped and compared
the same sentinel: a canvas cached by one build would be served by every later
build, forever. T-1239 is what that failure looks like once it happens.

loading_screen.gd carried a byte-for-byte copy of the same function, so the
version shown to the player was "?.?.?" in exactly the builds where a version
string is worth showing. Both call sites now share client/scripts/build_version.gd,
which reads application/config/version out of ProjectSettings — a value Godot
bakes into the PCK, identical in the editor and in an export by construction
rather than by luck. No file IO, no fallback branch.

project.yaml stays the source of truth (CLAUDE.md); client/project.godot mirrors
it. A mirror nobody checks would be worse than the bug it replaces -- the old
code failed loudly everywhere, a stale mirror fails silently -- so
tooling/check-client-version compares the two and the pre-push hook runs it
unconditionally. Not gated on "were those files in this push": drift persists on
main once introduced, and gating would let an existing drift ride along.

The test this replaces asserted that current_schema_version() did not return its
fallback, and passed -- in the one environment where the code under test worked.
Three tests now pin the property that actually matters: a real version, sourced
from the baked setting, matching project.yaml.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 23:35:33 +02:00

346 lines
16 KiB
Bash
Executable File

#!/usr/bin/env bash
# Pre-push hook: lint GDScript and Rust before pushing.
# Installed via: git config core.hooksPath .config/hooks
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel)"
ERRORS=0
FAILED_CHECKS=""
# Record a failed check BY NAME, not just as a tally.
#
# Why (2026-07-27): this hook used to increment a bare counter at 13 sites and
# end with "N check(s) failed. Fix the errors above." — which named nothing. Six
# of those sites (fmt, clippy, cargo test, deny, ruff, tooling) print no FAIL
# line at all, so a failure was only inferable from the ABSENCE of an "— OK".
# A real push aborted on `cargo fmt` and the verdict was indistinguishable from
# any other failure; finding it meant scrolling past thousands of lines of
# unrelated test-fixture output.
#
# The consequence that matters for tooling: the final block is now
# self-contained, so `tail` on this log always shows WHAT failed. Verbosity was
# never the problem — detail is exactly what you want when something breaks;
# the problem was that the verdict didn't say anything actionable.
fail_check() {
ERRORS=$((ERRORS + 1))
FAILED_CHECKS="${FAILED_CHECKS} - $1
"
}
echo "pre-push: running lint checks..."
# --- Detect which directories have changes vs remote ---
# Prefer origin/<branch> as the baseline (what the remote already has),
# but fall back to origin/main for first-push of a new branch — otherwise
# every check runs against nothing and the hook treats the whole repo as
# changed, spending tens of seconds on linters and JSON validation that
# have no diff to cover (e.g. pushing a wiki-only branch rebuilds GDScript
# and runs clippy + ruff + validates all 2762 JSON files).
BRANCH=$(git branch --show-current)
if git rev-parse --verify "origin/$BRANCH" >/dev/null 2>&1; then
REMOTE_REF="origin/$BRANCH"
elif git rev-parse --verify "origin/main" >/dev/null 2>&1; then
REMOTE_REF="origin/main"
else
REMOTE_REF=""
fi
if [ -n "$REMOTE_REF" ]; then
CLIENT_CHANGED=$(git diff --name-only "$REMOTE_REF"..HEAD -- client/ 2>/dev/null | wc -l)
SERVER_CHANGED=$(git diff --name-only "$REMOTE_REF"..HEAD -- server/ 2>/dev/null | wc -l)
TOOLING_CHANGED=$(git diff --name-only "$REMOTE_REF"..HEAD -- tooling/ pyproject.toml 2>/dev/null | wc -l)
else
# No remote at all (e.g. fresh clone before first fetch) — be safe, run everything
CLIENT_CHANGED=1
SERVER_CHANGED=1
TOOLING_CHANGED=1
fi
# --- GDScript parse check (headless Godot) ---
GODOT="${GODOT:-godot}"
if [ "$CLIENT_CHANGED" -eq 0 ]; then
echo "pre-push: no client/ changes — skipping GDScript checks"
elif command -v "$GODOT" >/dev/null 2>&1 && [ -d "$REPO_ROOT/client/.godot" ]; then
echo "pre-push: checking GDScript (parse)..."
SCRIPT_ERRORS=$("$GODOT" --headless --path "$REPO_ROOT/client" --quit 2>&1 | grep -ci "SCRIPT ERROR" || true)
if [ "$SCRIPT_ERRORS" -gt 0 ]; then
echo "pre-push: FAIL — $SCRIPT_ERRORS GDScript error(s) found"
"$GODOT" --headless --path "$REPO_ROOT/client" --quit 2>&1 | grep -i "SCRIPT ERROR"
fail_check "GDScript parse (startup)"
else
echo "pre-push: GDScript parse — OK"
fi
else
echo "pre-push: skipping GDScript parse (no .godot/ import — run Godot once to enable)"
fi
# --- GDScript lint (gdlint static analysis) — advisory only until codebase is clean ---
if [ "$CLIENT_CHANGED" -gt 0 ] && command -v gdlint >/dev/null 2>&1 && [ -d "$REPO_ROOT/client/scripts" ]; then
echo "pre-push: checking GDScript (gdlint — advisory)..."
LINT_COUNT=$(gdlint "$REPO_ROOT/client/scripts/" "$REPO_ROOT/client/ui/" 2>&1 | grep -c "Error:" || true)
if [ "$LINT_COUNT" -gt 0 ]; then
echo "pre-push: gdlint — $LINT_COUNT issue(s) (advisory, not blocking)"
else
echo "pre-push: gdlint — OK"
fi
fi
# --- GDScript format check (gdformat) — advisory only until codebase is clean ---
if [ "$CLIENT_CHANGED" -gt 0 ] && command -v gdformat >/dev/null 2>&1 && [ -d "$REPO_ROOT/client/scripts" ]; then
echo "pre-push: checking GDScript (gdformat — advisory)..."
FORMAT_COUNT=$(gdformat --check "$REPO_ROOT/client/scripts/" "$REPO_ROOT/client/ui/" 2>&1 | grep -c "would reformat" || true)
if [ "$FORMAT_COUNT" -gt 0 ]; then
echo "pre-push: gdformat — $FORMAT_COUNT file(s) need formatting (advisory, not blocking)"
else
echo "pre-push: gdformat — OK"
fi
fi
# --- GDScript parse sweep — blocking, and deliberately BEFORE the suite ---
# Every project .gd must parse. ~3.7s against the suite's ~135s, and it runs
# first because that ordering makes failures CHEAPER, not the gate slower: a
# script that does not parse is now caught in 4s instead of after two minutes
# of tests that could never have covered it. A clean push pays 3.7s; a broken
# one saves over two minutes.
#
# Not redundant with the suite. gdUnit4 reports the suites that DID load as a
# clean pass, so a file that fails to parse reads as success (guarded now in
# tests/run-godot, but only for test files). This covers all 226 scripts,
# including the ~half of the codebase no test ever loads. It is also the only
# gate that covers them at all: godot-cold-parse is skill-only (never invoked
# by this hook) and sees only the startup path regardless.
if [ "$CLIENT_CHANGED" -gt 0 ] && [ -x "$REPO_ROOT/tooling/godot-parse-sweep" ]; then
echo "pre-push: sweeping GDScript parse (tooling/godot-parse-sweep)..."
if ! "$REPO_ROOT/tooling/godot-parse-sweep"; then
echo "pre-push: parse sweep FAILED — a script does not parse"
fail_check "GDScript parse sweep"
else
echo "pre-push: parse sweep — OK"
fi
fi
# --- Godot client test suite (gdUnit4) — blocking (T-1065) ---
# The push gate is the only automatic verification (no CI). The suite is
# ~100s, 300s-capped, and was made fully green by the T-973 debt clearance;
# binary-dependent e2e suites self-skip when the server binary is absent.
if [ "$CLIENT_CHANGED" -gt 0 ] && [ -x "$REPO_ROOT/tests/run-godot" ]; then
echo "pre-push: running client test suite (tests/run-godot)..."
if ! "$REPO_ROOT/tests/run-godot"; then
echo "pre-push: client test suite FAILED"
fail_check "client test suite (gdUnit4)"
else
echo "pre-push: client tests — OK"
fi
fi
# --- Rust lint (clippy + fmt) ---
if [ "$SERVER_CHANGED" -eq 0 ]; then
echo "pre-push: no server/ changes — skipping Rust checks"
elif command -v cargo >/dev/null 2>&1 && [ -d "$REPO_ROOT/server" ]; then
# fmt only needs source files — always safe to run
echo "pre-push: checking Rust (fmt)..."
if ! (cd "$REPO_ROOT/server" && cargo fmt --check 2>&1); then
fail_check "cargo fmt"
else
echo "pre-push: fmt — OK"
fi
# clippy needs a build — skip if target/ doesn't exist (cold worktree).
# --all-targets lints tests + examples too (#967 closed the gap where test
# code accumulated clippy debt unchecked).
if [ -d "$REPO_ROOT/server/target" ]; then
echo "pre-push: checking Rust (clippy)..."
if ! (cd "$REPO_ROOT/server" && cargo clippy --all-targets -- -D warnings 2>&1); then
fail_check "cargo clippy"
else
echo "pre-push: clippy — OK"
fi
# cargo test — the ONLY automatic correctness gate: nothing else (no CI
# workflows exist) runs the suite, so without this a Rust regression
# reaches main unverified. Gated on server/ changes; shares the target/
# guard with clippy so a cold worktree isn't forced into a full build.
echo "pre-push: checking Rust (cargo test)..."
if ! (cd "$REPO_ROOT/server" && cargo test --quiet 2>&1); then
fail_check "cargo test"
else
echo "pre-push: cargo test — OK"
fi
else
echo "pre-push: skipping clippy + test (no target/ — run 'cargo build' once to enable)"
fi
# --- Rust dependency audit (cargo deny) — requires deny.toml config ---
if command -v cargo-deny >/dev/null 2>&1 && [ -f "$REPO_ROOT/server/deny.toml" ]; then
echo "pre-push: checking Rust (cargo deny)..."
if ! (cd "$REPO_ROOT/server" && cargo deny check 2>&1); then
fail_check "cargo deny"
else
echo "pre-push: cargo deny — OK"
fi
fi
else
echo "pre-push: WARNING — cargo not found or server/ missing, skipping Rust lint"
fi
# --- Python lint (ruff) ---
if [ "$TOOLING_CHANGED" -eq 0 ]; then
echo "pre-push: no tooling/ changes — skipping Python lint"
elif command -v ruff >/dev/null 2>&1 && [ -d "$REPO_ROOT/tooling" ]; then
echo "pre-push: checking Python (ruff)..."
if ! (cd "$REPO_ROOT" && ruff check tooling/ 2>&1); then
fail_check "ruff (python lint)"
else
echo "pre-push: ruff — OK"
fi
else
echo "pre-push: skipping Python lint (ruff not found — install with: pip install 'ruff>=0.9')"
fi
# --- Tooling test gate (T-1066) ---
# make test-tooling = planet-gen determinism guard (#963) + import_economics
# --dry-run validation against the committed DB. Only worth the ~90 s when the
# push actually touches tooling/ (or pyproject.toml), same scope as ruff above.
if [ "$TOOLING_CHANGED" -eq 0 ]; then
echo "pre-push: no tooling/ changes — skipping tooling tests"
elif command -v make >/dev/null 2>&1; then
echo "pre-push: running tooling tests (make test-tooling)..."
if ! (cd "$REPO_ROOT" && make test-tooling); then
fail_check "tooling tests (make test-tooling)"
else
echo "pre-push: tooling tests — OK"
fi
else
echo "pre-push: WARNING — make not found, skipping tooling tests"
fi
# --- JSON syntax validation ---
# Use the same REMOTE_REF the directory-change detection above settled on
# (origin/<branch> preferred, origin/main fallback). Without this, a first
# push of a new branch falls through to "validate every JSON in the repo,"
# which on this repo means 2762 Python parses — tens of seconds of churn
# for a push that might not have touched any JSON at all.
if [ -n "$REMOTE_REF" ]; then
JSON_FILES=$(git diff --name-only "$REMOTE_REF"..HEAD -- '*.json' 2>/dev/null || true)
else
JSON_FILES=$(git ls-files '*.json')
fi
if [ -n "$JSON_FILES" ]; then
echo "pre-push: checking JSON syntax..."
JSON_FAIL=0
while IFS= read -r f; do
if [ -f "$REPO_ROOT/$f" ] && ! python3 -m json.tool "$REPO_ROOT/$f" >/dev/null 2>&1; then
echo " FAIL: $f"
JSON_FAIL=$((JSON_FAIL + 1))
fi
done <<< "$JSON_FILES"
if [ "$JSON_FAIL" -gt 0 ]; then
echo "pre-push: FAIL — $JSON_FAIL JSON file(s) have syntax errors"
fail_check "JSON syntax"
else
echo "pre-push: JSON — OK ($(echo "$JSON_FILES" | wc -l) file(s))"
fi
else
echo "pre-push: no JSON changes — skipping"
fi
# --- systems.db stamp check (#857) ---
# If the branch touches server/data/systems.db and the meta stamp does not
# match current generator sources, reject the push. Prevents pushing a
# stale DB snapshot where generator source was modified but the DB was not
# regenerated.
#
# Runs whenever systems.db was modified in ANY branch commit vs. main —
# including on a branch's very first push (review T5: the previous version
# skipped the check for new branches because it compared against origin/$BRANCH,
# which didn't exist yet, leaving a gap where a stale DB could ship via the
# first push). We compare against origin/main — which always exists — so the
# check covers the first-push case.
DB_IN_PUSH=$(git diff --name-only origin/main...HEAD -- server/data/systems.db 2>/dev/null | wc -l)
if [ "$DB_IN_PUSH" -gt 0 ] && [ -f "$REPO_ROOT/tooling/check-systems-db-stamp" ]; then
echo "pre-push: checking systems.db stamp..."
rc=0
python3 "$REPO_ROOT/tooling/check-systems-db-stamp" || rc=$?
if [ "$rc" -eq 1 ]; then
# rc=1 means stale / unknown generator / missing source; message on stderr
echo " Fix: run 'make regen-db' then stage server/data/systems.db"
echo " Or use /pr-push — it handles regen automatically before pushing."
fail_check "systems.db stamp (stale)"
elif [ "$rc" -eq 2 ]; then
# rc=2 means no meta table — treat as unstamped, warn but don't block.
# This is legitimate immediately after the meta table is introduced;
# the next `make regen-db` will populate it (H4).
echo "pre-push: WARNING — systems.db has no meta stamp — run 'make regen-db' now if this DB was generated by you"
else
echo "pre-push: systems.db stamp — OK"
fi
else
echo "pre-push: systems.db not in push — skipping stamp check"
fi
# --- Client version mirror (T-1241) ---
# project.yaml is the version source of truth, but the client cannot read it at
# runtime (an exported build has no repo root), so it is mirrored into
# client/project.godot's `application/config/version`. The Atlas disk cache keys
# its ONLY invalidation signal on that value, so a stale mirror makes a build
# serve canvases generated by code it no longer runs — the T-1239 failure.
#
# Deliberately UNCONDITIONAL, unlike the systems.db check above. Drift is
# introduced by touching one file without the other, but it PERSISTS on main
# until someone notices, so gating on "was either file in this push" would let
# an existing drift ride along indefinitely. The check is two file reads.
if [ -f "$REPO_ROOT/tooling/check-client-version" ]; then
echo "pre-push: checking client version mirror..."
if python3 "$REPO_ROOT/tooling/check-client-version"; then
:
else
fail_check "client version mirror (drifted from project.yaml)"
fi
fi
# --- Clerk review (D-221) ---
# DISABLED 2026-05-23 (#965) pending rework. Two problems made it net-negative:
# 1. Non-exhaustive — a single run reports ~the first contradiction it finds
# and stops, so distinct real issues surfaced only on the 2nd/3rd/4th
# re-push. An APPROVED verdict therefore can't be trusted as "clean".
# 2. Re-reviews the entire origin/main..HEAD range on every push (token burn);
# no per-commit verdict cache.
# Off by default until reworked (exhaustive enumeration + holistic decisions/
# diff check + a SHA-keyed verdict cache invalidated on decisions/ change, with
# skip tracked in the cache rather than a commit-message trailer — see #965).
# Force-run with SR_RUN_CLERK=1.
if [ -x "$REPO_ROOT/tooling/clerk-review" ] && [ "${SR_RUN_CLERK:-0}" = "1" ]; then
echo "pre-push: running clerk review..."
CLERK_VERDICT=$("$REPO_ROOT/tooling/clerk-review" 2>&1 | tee /dev/stderr | tail -1)
if [ "$CLERK_VERDICT" = "APPROVED" ]; then
echo "pre-push: clerk — APPROVED"
elif [ "$CLERK_VERDICT" = "REJECTED" ]; then
echo "pre-push: clerk — REJECTED (see .cache/pre-push-review.md)"
fail_check "clerk review (REJECTED)"
elif [ "$CLERK_VERDICT" = "INCOMPLETE" ]; then
echo "pre-push: clerk — INCOMPLETE (some reviews didn't finish; NOT blocking)"
echo " See .cache/pre-push-review.md. For a full verdict: raise SR_CLERK_MAX_TURNS / SR_CLERK_TIMEOUT,"
echo " or add a 'Clerk-Skip:' trailer to bulk-content commits."
else
echo "pre-push: clerk — '$CLERK_VERDICT' unrecognized; treating as block (see .cache/pre-push-review.md)"
fail_check "clerk review (unrecognized verdict)"
fi
else
echo "pre-push: clerk review — DISABLED (#965; force-run with SR_RUN_CLERK=1)"
fi
if [ "$ERRORS" -gt 0 ]; then
echo ""
echo "=============================================================="
echo "pre-push: PUSH ABORTED — $ERRORS check(s) failed:"
printf '%s' "$FAILED_CHECKS"
echo ""
echo " Search the log above for the failing check's own output, e.g."
echo " grep -A20 'checking Rust (fmt)' <logfile>"
echo " cargo fmt is auto-fixable: cd server && cargo fmt"
echo "=============================================================="
exit 1
fi
echo "pre-push: all checks passed."