#!/usr/bin/env bash # # The repo's own guards, before a commit rather than after it. # # ## Why this lives 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 per clone. See the README. # # ## Adapted from the template's version # # The template's pre-commit runs `npx tsc --noEmit` and `npx vitest`. Period is # a Kotlin/Android project with no npm anywhere, so the two guard commands are # Gradle instead. Everything else — the ordering, the secret scan, the # unstaged-changes warning, SKIP_GUARDS — is unchanged, because the arguments # for those are not language-specific. # # ## What it checks, and why in this order # # 1. The secret scan, first and cheapest: it reads the staged diff only. A # keystore caught here costs a `git reset`; the same keystore caught after a # push cannot be undone at all — an upload key cannot be rotated once the app # is published. # 2. The JVM unit tests, when Kotlin is staged. They are the prediction # acceptance cases from PRODUCT_PLAN.md §51 and they run on the JVM in about # a second, which is the whole reason :domain:* is not an Android module. # 3. The Room schema guard, when a database entity or the schema export is # staged. It asks git whether an already-committed schema file changed, which # is the ONLY thing that can see that failure — Room overwrites the export # during the build, so the unit tests are green over it. See the script's # header for the proof. # # Compilation of the Android modules is deliberately NOT run here. It needs the # SDK and takes half a minute, and a pre-commit hook people disable is worse # than one that checks less. `./gradlew assembleRelease` belongs to the release # checklist — docs/security/SECURITY_CHECKLIST.md — which is where it is. # # ## It warns about unstaged changes rather than failing on them # # Both commands run against the **working tree**, not the index. 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. Saying so is more honest than # implying 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. # # ## Exit codes # # 0 the guards that applied ran and passed # 1 a guard failed, or refused — the commit is stopped 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 — the secret scan 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 # Credentials, before the commit exists. if [ -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. Deleting the line does not remove it from a" say "commit that already exists. If it is not, --allow the path or adjust" say "the patterns; do not silence the check." exit 1 fi else # Said out loud rather than passed over: a missing scanner reads exactly like # a scanner that found nothing. say "note: scripts/secrets.sh is not here, so nothing scanned the staged diff." fi # Only worth running when Kotlin or the build changed. A docs-only commit does # not need the suite; a change to a build file does, because that is how a # module boundary gets widened without anyone reading a Kotlin line. staged=$(git diff --cached --name-only) touches_code=$(printf '%s\n' "$staged" \ | grep -cE '\.(kt|kts)$|^gradle/libs\.versions\.toml$|^gradle\.properties$' || true) if [ "$touches_code" -gt 0 ]; then say "JVM unit tests…" # :domain:* only. These are pure-JVM modules, so this needs no Android SDK and # no emulator, and it is the suite that guards the product's core claim. if ! ./gradlew --quiet --console=plain :domain:cycle:test :domain:prediction:test; then say "tests failed — commit refused." exit 1 fi else say "no .kt/.kts or build file staged — skipping the suite." fi # A Room entity may not change without the version changing with it. Only git # can see this: Room rewrites the schema export during compilation, so both # sides of any in-process comparison agree by construction. touches_schema=$(printf '%s\n' "$staged" \ | grep -cE '^core/database/(schemas/|src/main/.*(entity|Entities|PeriodDatabase)).*' || true) if [ "$touches_schema" -gt 0 ]; then if [ -f scripts/schema-guard.sh ]; then say "Room schema guard…" bash scripts/schema-guard.sh rc=$? # 2 is "nothing was checked", which is never a pass. if [ "$rc" -ne 0 ]; then say "schema guard exited $rc — commit refused." exit 1 fi else say "note: scripts/schema-guard.sh is missing, so schema drift was not checked." fi 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