#!/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
