#!/usr/bin/env bash # # The repo's own guards, before a commit rather than after it. # # ## Why this exists in the repository and not in .git/hooks # # `.git/hooks` is not versioned, so a hook living there protects exactly one # checkout and silently protects nothing anywhere else. This directory is # committed, and `core.hooksPath` points at it: # # git config core.hooksPath .githooks # # That one line is the only setup, and it is in the README beside the test # command. # # ## What it checks, and why in this order # # Typecheck first: it takes about two seconds and catches the class of mistake # that is most annoying to discover later — a type change whose consumers were # never visited. Then the suite, which is where the real guarantees live: the # conformance tests that keep registries total, the sentinel tests that keep # secrets out of logs, and the mutation-tested guards. # # Both are already the rule for finishing work here. A hook is just the version # that does not depend on somebody remembering. # # ## It warns about unstaged changes rather than failing on them # # Both commands run against the **working tree**, not against the index. So a # clean run proves the working tree is good, which is only the same thing as the # commit being good when nothing is left unstaged. # # That distinction matters in this checkout specifically: it is edited by more # than one person at a time, and commits are staged by explicit path. A green # hook beside three unstaged files has verified something other than what is # about to be committed, and the honest thing is to say so rather than imply a # guarantee that was not made. # # ## Escape hatch # # SKIP_GUARDS=1 git commit ... skips both, loudly # git commit --no-verify ... skips the hook entirely, silently # # The first is preferred: it leaves a line in the terminal saying the guards did # not run, which is the difference between a deliberate exception and a habit. set -uo pipefail cd "$(git rev-parse --show-toplevel)" || exit 1 say() { printf '\033[1mpre-commit:\033[0m %s\n' "$*" >&2; } if [ -n "${SKIP_GUARDS:-}" ]; then say "SKIP_GUARDS set — typecheck and tests did NOT run for this commit." exit 0 fi # Nothing staged is not this hook's problem; git will refuse on its own. if git diff --cached --quiet; then exit 0 fi # Only worth running when source or tests changed. A commit that touches docs or # migrations alone still gets the typecheck, because a migration can be # referenced from a test, but it should not wait on the whole suite. # # The four files after the `|` are not source, and they are here because of what # `tests/version.test.ts` guards: package.json, the Dockerfile, and the two # image pins in README.md and docker-compose.example.yml must all name the same # version. With `src|tests` alone, a commit that hand-edits only the README pin # — precisely the drift that left those pins six versions stale — would get the # typecheck and skip the one test that would have caught it. The guard has to # run on the commits it exists to police. staged=$(git diff --cached --name-only) touches_code=$(printf '%s\n' "$staged" \ | grep -cE '^(src|tests)/.*\.(ts|tsx)$|^(package\.json|Dockerfile|README\.md|docker-compose\.example\.yml)$' || true) say "typecheck…" if ! npx tsc --noEmit; then say "typecheck failed — commit refused." exit 1 fi # Credentials, before the commit exists. # # First, and cheap: it reads the staged diff only. A secret caught here costs a # `git reset`; the same secret caught after a push costs a rotation, because # deleting the line does not remove it from a commit that already exists. if [ -x scripts/secrets.sh ] || [ -f scripts/secrets.sh ]; then if ! bash scripts/secrets.sh; then say "possible credential in the staged changes — commit refused." say "If it is real, rotate it. If it is not, --allow the path or adjust" say "the patterns; do not silence the check." exit 1 fi fi if [ "$touches_code" -gt 0 ]; then say "tests…" # TEST_DATABASE_URL is picked up from the environment when it is set. Without # one the database suites skip themselves rather than fail, which is the # existing behaviour in tests/setup.ts — so this hook is useful on a machine # with no Postgres and stricter on one with it. # # But "not set" and "not available" are different, and this used to treat them # the same: every commit on this machine ran ~900 of 1,600 tests while a test # database sat listening on 55432, because nothing looked. A commit hook # should not *refuse* over a missing database — that belongs to the release # gate, which now does — but it should use one that is there. # Set these two together in the repository that adopts this hook — the port # it listens on, and the URL that reaches it. Leaving them unset is fine and # simply means the database suites skip, which is the right default on a # machine with no Postgres. TEST_DB_PORT="${TEST_DB_PORT:-}" TEST_DB_URL="${TEST_DB_URL:-}" if [ -z "${TEST_DATABASE_URL:-}" ] && [ -n "$TEST_DB_PORT" ] && [ -n "$TEST_DB_URL" ] \ && (exec 3<>/dev/tcp/127.0.0.1/"$TEST_DB_PORT") 2>/dev/null; then exec 3<&- 2>/dev/null || true export TEST_DATABASE_URL="$TEST_DB_URL" say "using the local test database on ${TEST_DB_PORT} — the full suite will run." elif [ -z "${TEST_DATABASE_URL:-}" ]; then say "no test database; the database suites will skip. The release gate is stricter." fi if ! npx vitest run --reporter=dot; then say "tests failed — commit refused." exit 1 fi else say "no .ts/.tsx staged — skipping the suite." fi # Said last so it is the thing still on screen when the editor opens. if ! git diff --quiet; then say "NOTE: unstaged changes are present. The guards ran against the working" say " tree, so they did not verify this commit in isolation." fi exit 0