#!/usr/bin/env bash
# Pre-commit gate on staged ADDED lines:
#  (1) §4.1/§4.2 trace scanner — AI-authorship traces / vendor names (trace-blocklist.txt)
#  (2) secret scanner        — API keys / tokens / private keys       (secret-blocklist.txt)
#  (3) private-path scanner  — this machine's $HOME + .private-terms.txt (§4.3)
# Skipping requires an explicit request per §4.5 (git commit --no-verify).
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
BL="$HERE/trace-blocklist.txt"
SL="$HERE/secret-blocklist.txt"

# Repo-root exception lists (optional): a pattern appearing here as a FULL line is SKIPPED.
# These files are NOT part of the payload; they are specific to their own repo.
GITROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
AL="";  [ -n "$GITROOT" ] && [ -f "$GITROOT/.trace-allowlist.txt" ]  && AL="$GITROOT/.trace-allowlist.txt"
SAL=""; [ -n "$GITROOT" ] && [ -f "$GITROOT/.secret-allowlist.txt" ] && SAL="$GITROOT/.secret-allowlist.txt"

# Only added content lines (start with +, excluding the +++ file header).
#
# The added lines go to FILES, and every scan greps a file. Never `printf "$ADDED" | grep -q`: grep -q exits on
# the first match, the pipe closes, printf dies of SIGPIPE (141), and `set -o pipefail` turns that into a failed
# `if` — so a match in a LARGE staged diff would silently count as no match. A gate that only works on small
# commits is worse than no gate.
TRACE_F="$(mktemp)"; SECRET_F="$(mktemp)"
# Cleanup runs on EXIT; the SIGNAL handlers additionally EXIT. A bare `trap '...' EXIT INT TERM` looks like it
# handles all three, but bash returns to the script after a signal handler that does not exit — so TERM removed
# the temp files this hook is still reading from and then let it carry on, printing `grep: /tmp/tmp.X: No such
# file or directory` for the rest of the run. Measured here: after a TERM the hook exited 0, not 143, which is
# the signature — a process killed by a signal cannot exit 0.
#
# That is how a slow commit becomes a STUCK one. A wrapper with a two-minute timeout sends TERM, the hook
# swallows it, git keeps running, and `.git/index.lock` stays behind holding the repository. Every later git
# command then fails with "Another git process seems to be running", which names nothing about the real cause.
# 143 = 128 + SIGTERM, the exit status a shell reports for a process the signal actually stopped.
_csk_cleanup(){ rm -f "$TRACE_F" "$SECRET_F" ${PRIV_F:+"$PRIV_F"} ${PTERM_F:+"$PTERM_F"}; }
trap _csk_cleanup EXIT
trap '_csk_cleanup; exit 143' INT TERM

# Secrets are scanned EVERYWHERE. A token pasted into .claude/settings.json is still a token.
# The kit's own blocklist files are excluded — they definitionally contain the patterns (circular), and since
# 1.8.0 they also carry their own `#test:` cases. Matched by NAME rather than by the installed path: the same
# files live at .claude/hooks/ in a project, claude-starter/hooks/ in the kit's own repo and hooks/ in the
# plugin build, and a path-anchored exclusion silently stopped applying in every layout but the first.
git diff --cached --unified=0 -- . \
  ':(top,exclude,glob)**/trace-blocklist.txt' ':(top,exclude,glob)**/secret-blocklist.txt' \
  ':(top,exclude)trace-blocklist.txt' ':(top,exclude)secret-blocklist.txt' \
  | grep -E '^\+' | grep -Ev '^\+\+\+' > "$SECRET_F" || true

# Traces (§4.1/§4.2) are scanned in the PROJECT's own files only. `.claude/` is the kit's tree: it configures the
# assistant, it legitimately names the tool it configures, and an update overwrites it — it is not an artifact the
# project authored. Teams that share `.claude/` would otherwise be unable to commit it at all.
# The blocklist is excluded by name for the same circularity reason as above: in an installed project it already
# falls under .claude/, but in the kit's own repo it sits at claude-starter/hooks/ where that exclusion misses it.
git diff --cached --unified=0 -- . ':(top,exclude).claude' \
  ':(top,exclude,glob)**/trace-blocklist.txt' ':(top,exclude)trace-blocklist.txt' \
  | grep -E '^\+' | grep -Ev '^\+\+\+' > "$TRACE_F" || true

# `git commit -a` stages tracked changes as part of the commit. As a git hook that is invisible — git stages
# first, so --cached already contains them by the time this runs. Called AHEAD of the commit (the plugin
# edition's PreToolUse path, which has no core.hooksPath to hook into), it is not: the content about to be
# committed is still unstaged, and scanning only --cached would wave it through. The caller sets this when it
# sees -a/--all.
if [ "${CSK_SCAN_UNSTAGED:-0}" = 1 ]; then
  git diff --unified=0 -- . \
    ':(top,exclude,glob)**/trace-blocklist.txt' ':(top,exclude,glob)**/secret-blocklist.txt' \
    ':(top,exclude)trace-blocklist.txt' ':(top,exclude)secret-blocklist.txt' \
    | grep -E '^\+' | grep -Ev '^\+\+\+' >> "$SECRET_F" || true
  git diff --unified=0 -- . ':(top,exclude).claude' \
    ':(top,exclude,glob)**/trace-blocklist.txt' ':(top,exclude)trace-blocklist.txt' \
    | grep -E '^\+' | grep -Ev '^\+\+\+' >> "$TRACE_F" || true
fi

HIT=0
# (0) repo-bloat gate — scans the staged FILE LIST, not added lines: a binary produces no '^+' line, so this
#     must run BEFORE the added-text early-exit below or it would never fire on the very files it targets.
MAXBYTES="${CSK_MAX_FILE_BYTES:-5242880}"   # 5 MiB; override per-repo via env
BLOAT_DIRS='(^|/)(node_modules|bower_components|dist|build|out|\.next|\.nuxt|\.svelte-kit|target|bin|obj|vendor|__pycache__|\.venv|venv|coverage|\.gradle)/'
# COST NOTE, measured on a 373-file merge: the loop below used to spawn ~7 processes per file — three
# `printf | grep` pairs plus a `git cat-file` — for 2,644 processes in total. On Git Bash a process costs
# 20-50 ms and this hook ran for over twenty minutes on a real merge, which is how a gate turns into the thing
# people reach for --no-verify to avoid. The pattern scanning was never the expensive part: 23 patterns are one
# grep each. The file loop was. So the name tests are pure bash `case` now (no process at all) and the two
# object lookups are batched into one `git cat-file --batch-check` after the loop. Same rules, same verdicts.
BLOAT_CANDIDATES=""; BLOAT_QUERY=""
SIZE_TARGETS=""; SIZE_QUERY=""
NL='
' 
while IFS= read -r f; do
  [ -z "$f" ] && continue
  base="${f##*/}"
  # (F) secret-FILE gate — a file that is a secret by its very NAME. The content scan (2) can miss it: a private
  #     key is high-entropy, not a known prefix. .env.example/.sample/.template/.dist are committable templates.
  is_secret_file=0
  case "$base" in
    .env|.env.*)
      # Case-insensitive for THIS test only — the rule it replaces used `grep -qiE` here and plain `grep -qE`
      # everywhere else. Turning nocasematch on for the whole loop looked harmless and quietly widened a
      # different rule: `server.PEM` went from allowed to blocked. Arguably an improvement, but not one to make
      # inside a performance change, and not one to make without casing the "must not block" half.
      shopt -s nocasematch 2>/dev/null || true
      case "$base" in
        *.example|*.sample|*.template|*.dist|*.schema|*.md) : ;;
        *) is_secret_file=1 ;;
      esac
      shopt -u nocasematch 2>/dev/null || true ;;
  esac
  # Case-insensitive, and that IS a widening — the rule this replaced used a case-SENSITIVE `grep -qE`, so
  # `server.PEM`, `id.KEY` and `cert.P12` were committable while their lowercase twins were blocked. Left open
  # once already, on the grounds that widening a gate does not belong inside a performance change. It belongs
  # here: Windows and macOS filesystems are case-insensitive, so `server.PEM` and `server.pem` are the SAME
  # FILE, and a rule that blocks one spelling of a private key while allowing another has protected nothing.
  # Same class as the `chmod 777` vs `chmod 1777` hole and the `rm -rf` vs `rm -Rf` one this file already fixed.
  #
  # nocasematch is opened and closed around this block ALONE. Left on across the whole loop it silently changed
  # the .env test above, which is exactly how `server.PEM` moved from allowed to blocked as an invisible side
  # effect the first time. The must-not-block half is cased too: `.pem.example`, `key.md`, `monkey.ts`,
  # `KEYS.md` and `public.pub` all stay committable, and smoke-test pins them in both directions.
  shopt -s nocasematch 2>/dev/null || true
  case "$base" in
    *.example|*.sample|*.template|*.dist|*.md|*.pub) : ;;
    id_rsa|id_dsa|id_ecdsa|id_ed25519|.npmrc|.pypirc|.htpasswd|credentials.json) is_secret_file=1 ;;
    *.pem|*.pfx|*.p12|*.ppk|*.key|*.keystore|*.jks) is_secret_file=1 ;;
  esac
  shopt -u nocasematch 2>/dev/null || true
  if [ "$is_secret_file" = 1 ] && ! { [ -n "$SAL" ] && grep -qxF -- "$f" "$SAL"; }; then
    echo "SECRET-FILE: '$f' is a secrets / credentials / private-key file — never commit it (gitignore it; ship a .env.example instead)."
    HIT=1; continue
  fi
  # The PATTERN half only judges files that are NEW to the repository. A path already in HEAD is one the project
  # decided to keep, and refusing it means that file can never be edited again without --no-verify — the exact
  # shape of a gate that gets worked around. This repo hit it on its own `bin/cli.js`: `bin/` is build output in
  # .NET and Java, and the conventional home of a CLI entry point in Node, where package.json's `bin` field
  # points at it. The SIZE check below still applies to tracked files, so a tracked path cannot quietly grow.
  case "/$f" in
    */node_modules/*|*/bower_components/*|*/dist/*|*/build/*|*/out/*|*/.next/*|*/.nuxt/*|*/.svelte-kit/*|*/target/*|*/bin/*|*/obj/*|*/vendor/*|*/__pycache__/*|*/.venv/*|*/venv/*|*/coverage/*|*/.gradle/*)
      BLOAT_CANDIDATES="$BLOAT_CANDIDATES$f
"; BLOAT_QUERY="$BLOAT_QUERY${NL}HEAD:$f" ;;
  esac
  SIZE_TARGETS="$SIZE_TARGETS$f
"; SIZE_QUERY="$SIZE_QUERY${NL}:$f"
done < <(git diff --cached --name-only --diff-filter=AM -- . 2>/dev/null)

# Both object lookups, batched. `--batch-check` answers in input order, so the paths are pasted back on rather
# than looked up again — bash 3.2 (what macOS ships) has no associative arrays, and a second lookup per file
# would put back the cost this removes.
if [ -n "$BLOAT_CANDIDATES" ]; then
  while IFS='	' read -r f verdict; do
    [ -z "$f" ] && continue
    case "$verdict" in *missing*)
      echo "REPO-BLOAT: '$f' is a build/vendored artifact — gitignore it, don't commit it."
      HIT=1 ;;
    esac
  done < <(printf '%s\n' "${BLOAT_QUERY#$NL}" | git cat-file --batch-check 2>/dev/null \
             | paste -d'\t' <(printf '%s' "$BLOAT_CANDIDATES") - 2>/dev/null)
fi

if [ -n "$SIZE_TARGETS" ]; then
  while IFS='	' read -r f info; do
    [ -z "$f" ] && continue
    sz="${info##* }"
    case "$sz" in ''|*[!0-9]*) sz=0;; esac
    if [ "$sz" -gt "$MAXBYTES" ]; then
      echo "REPO-BLOAT: '$f' is $((sz/1024)) KiB (> $((MAXBYTES/1024)) KiB) — large blobs bloat history; use Git LFS or gitignore."
      HIT=1
    fi
  done < <(printf '%s\n' "${SIZE_QUERY#$NL}" | git cat-file --batch-check 2>/dev/null \
             | paste -d'\t' <(printf '%s' "$SIZE_TARGETS") - 2>/dev/null)
fi

# Nothing staged for the text scans AND the bloat gate is clean -> done.
{ [ -s "$TRACE_F" ] || [ -s "$SECRET_F" ] || [ "$HIT" -ne 0 ]; } || exit 0

# (1) trace scan — case-INsensitive (vendor / AI-authorship strings), project files only
if [ -f "$BL" ] && [ -s "$TRACE_F" ]; then
  while IFS= read -r pat || [ -n "$pat" ]; do
    pat="${pat%$'\r'}"                       # tolerate a CRLF blocklist (Windows/autocrlf checkout) — a trailing \r
    case "$pat" in ''|\#*) continue;; esac   # in the pattern never matches the LF-normalised diff, blinding the gate
    if [ -n "$AL" ] && grep -qxF -- "$pat" "$AL"; then continue; fi
    if grep -iqE -- "$pat" "$TRACE_F"; then
      echo "TRACE-SCANNER: forbidden expression in added code -> '$pat' (§4.1/§4.2)"
      HIT=1
    fi
  done < "$BL"
fi
# (2) secret scan — case-SENSITIVE (token prefixes are case-specific); prints the PATTERN, never the value
if [ -f "$SL" ] && [ -s "$SECRET_F" ]; then
  while IFS= read -r pat || [ -n "$pat" ]; do
    pat="${pat%$'\r'}"                       # tolerate a CRLF blocklist (Windows/autocrlf checkout)
    case "$pat" in ''|\#*) continue;; esac
    if [ -n "$SAL" ] && grep -qxF -- "$pat" "$SAL"; then continue; fi
    if grep -qE -- "$pat" "$SECRET_F"; then
      echo "SECRET-SCANNER: a staged line matches a secret pattern -> /$pat/"
      HIT=1
    fi
  done < "$SL"
fi

# (3) private-path scan — a path that only exists on THIS machine has no business in a shared artifact.
#
# Why this is not a blocklist regex: "is this path private?" cannot be answered by a pattern. `/Users/me` is a
# placeholder every README wants, `/Users/ada` is a real person's home, and no ERE separates them (and ERE has
# no negative lookahead to spell "not a placeholder"). So the terms are derived from the machine doing the
# commit — where the answer is knowable exactly — plus whatever the repo owner adds by hand.
#
# It exists because this failure is not hypothetical: a work project's absolute path, pasted from a terminal
# into a CHANGELOG entry, shipped in eight consecutive releases before anyone noticed. The paste is the vector,
# so the gate has to sit where pasted text becomes a commit.
#
#   automatic  : $HOME, in the three spellings the same directory gets written as on Windows
#                (/c/Users/x · C:/Users/x · C:\Users\x) — case-insensitively, since that filesystem is.
#   by hand    : .private-terms.txt at the repo root, one literal term per line ('#' comments). Put internal
#                project names, client names, and host names there. GITIGNORE IT — it is a list of the very
#                strings you do not want published.
#   escape     : .private-allowlist.txt, a term per full line, for the rare case a term is legitimately public.
PRIV_F="$(mktemp)"; PTERM_F="$(mktemp)"
PTF="";  [ -n "$GITROOT" ] && [ -f "$GITROOT/.private-terms.txt" ]     && PTF="$GITROOT/.private-terms.txt"
PAL="";  [ -n "$GITROOT" ] && [ -f "$GITROOT/.private-allowlist.txt" ] && PAL="$GITROOT/.private-allowlist.txt"
{
  # A home path shorter than this cannot be distinguishing (`/root`, `/`), and matching it would flag everything.
  if [ -n "${HOME:-}" ] && [ "${#HOME}" -ge 8 ]; then
    printf '%s\n' "$HOME"
    # Git Bash reports /c/Users/x for what Windows itself calls C:\Users\x — both spellings reach a file.
    case "$HOME" in
      /[a-zA-Z]/?*)
        d="$(printf '%s' "${HOME:1:1}" | tr 'a-z' 'A-Z')"; r="${HOME:3}"
        printf '%s\n' "$d:/$r" "$d:\\${r//\//\\}" ;;
    esac
  fi
  [ -n "$PTF" ] && sed -e 's/[[:space:]]*$//' -e '/^#/d' -e '/^$/d' "$PTF"
} > "$PTERM_F" 2>/dev/null || true
if [ -s "$PTERM_F" ] && [ -s "$SECRET_F" ]; then
  while IFS= read -r term || [ -n "$term" ]; do
    term="${term%$'\r'}"
    [ -n "$term" ] || continue
    if [ -n "$PAL" ] && grep -qxF -- "$term" "$PAL"; then continue; fi
    if grep -qiF -- "$term" "$SECRET_F"; then
      # The term is echoed back deliberately: unlike a secret, the author has to see WHICH private string to
      # replace, and it is already sitting in their own working tree.
      echo "PRIVATE-PATH SCANNER: a staged line contains a machine-private string -> '$term' (§4.3)"
      HIT=1
    fi
  done < "$PTERM_F"
fi
rm -f "$PRIV_F"

if [ "$HIT" -ne 0 ]; then
  echo "-----------------------------------------------------------"
  echo "Commit stopped. Remove the AI-authorship trace / vendor name / secret / bloated file from the staged changes."
  echo "False positive (trace/secret)? add the EXACT pattern line to .trace-allowlist.txt or .secret-allowlist.txt at the repo root."
  echo "Private path flagged but genuinely public? add that exact term to .private-allowlist.txt at the repo root."
  echo "Large file is intentional? raise CSK_MAX_FILE_BYTES, use Git LFS, or gitignore the build/vendored path."
  echo "Deliberate exception: 'git commit --no-verify' — only on an EXPLICIT request (§4.5)."
  exit 1
fi
exit 0
