#!/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. # # ## ADAPTED FOR THIS PROJECT # # The template's version runs `npx tsc --noEmit` and then `npx vitest run`. This # project has neither: no TypeScript, no tsconfig, no test runner, and every # source file is plain .jsx. Installed unchanged, that hook refuses every commit # on a project where nothing is wrong. # # So it runs the two things that exist: # # 1. The secret scan, on the staged diff. This is the reason the hook earns its # place here at all. The Zoho WebToLead form identifiers were hardcoded in # index.html and later src/pages/Contact.jsx and reached four commits of a # then-PUBLIC repository before anyone noticed. 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. # # 2. `npm run build`, when source is staged. Three steps — client bundle, SSR # bundle, then prerender across every route — and it fails on a broken # import, a syntax error, or a page component that cannot render in Node. # # **The second is a build, not a test.** It proves the imports resolve. A form # that posts to the wrong URL builds perfectly. Do not read a green hook as # "the change works"; `docs/qa/ClaudeQACoverage.md` is honest about what is # actually untested here, which is nearly everything. # # ## 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) # What is worth a build. src/ and server/ are the obvious ones; the other four # are here because each can break the build on its own — index.html is the Vite # entry, vite.config.js owns the alias resolution and the build inputs, # package.json can remove a dependency the bundle imports, and prerender.js runs # as the third build step against every route. touches_code=$(printf '%s\n' "$staged" \ | grep -cE '^(src|server|scripts)/|^(package\.json|index\.html|vite\.config\.js|tailwind\.config\.js|postcss\.config\.js)$' || true) # Credentials, before the commit exists. FIRST, and cheap: it reads the staged # diff only, and it runs on every commit including a docs-only one — a key # pasted into a markdown file is still a key. if [ -x scripts/secrets.sh ] || [ -f scripts/secrets.sh ]; then say "secrets…" if ! bash scripts/secrets.sh; then say "possible credential in the staged changes — commit refused." say "If it is real, ROTATE IT FIRST. Deleting the line does not remove it" say "from a commit that already exists. If it is not, --allow the path or" say "adjust the patterns in scripts/secrets.sh; do not silence the check." exit 1 fi else # Said out loud. A missing scanner and a clean scan look identical from here, # and this hook has exactly two jobs. say "WARNING: scripts/secrets.sh is missing — NOTHING was scanned for" say " credentials. That is not a pass." fi if [ "$touches_code" -gt 0 ]; then say "build…" if ! command -v npm >/dev/null 2>&1; then say "npm is not on PATH, so the build did not run — commit refused." say "This hook will not pass a commit it could not check." exit 1 fi if ! npm run build; then say "build failed — commit refused." exit 1 fi else say "no source staged — skipping the build." 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