173 lines
6.9 KiB
Bash
Executable File
173 lines
6.9 KiB
Bash
Executable File
#!/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.
|
|
#
|
|
# The two non-zero exits mean different things and the hook has to tell them
|
|
# apart, which it did not at first:
|
|
#
|
|
# 1 a credential shape was found — refuse, always.
|
|
# 2 NOTHING WAS SCANNED. Usually alarming, and *expected* for a commit that
|
|
# only deletes files, because a deletion has no added lines to look at.
|
|
#
|
|
# Treating 2 as a refusal made every deletion-only commit impossible, and said
|
|
# "possible credential in the staged changes" while doing it — a wrong and
|
|
# frightening message for a plain `git rm`. Found while removing a directory of
|
|
# build output that had been committed by accident.
|
|
#
|
|
# So a 2 is checked rather than trusted: if the staged diff adds any lines then
|
|
# the scanner had something to look at, and a 2 is a real problem.
|
|
if [ -f scripts/secrets.sh ]; then
|
|
bash scripts/secrets.sh
|
|
secrets_rc=$?
|
|
|
|
if [ "$secrets_rc" -eq 1 ]; 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
|
|
|
|
if [ "$secrets_rc" -eq 2 ]; then
|
|
added=$(git diff --cached --numstat | awk '{ if ($1 != "-") total += $1 } END { print total + 0 }')
|
|
if [ "$added" -gt 0 ]; then
|
|
say "the secret scan checked NOTHING while ${added} line(s) are being added."
|
|
say "That is not a pass — see docs/TOOLS.md on exit code 2."
|
|
exit 1
|
|
fi
|
|
say "no added lines to scan — this commit only removes content."
|
|
elif [ "$secrets_rc" -ne 0 ]; then
|
|
say "secrets.sh exited ${secrets_rc} — commit refused."
|
|
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
|