#!/bin/sh
# FBMCPF-520 — local CI gate. GitHub Actions minutes are exhausted (private
# repo), so the checks that used to run remotely run here, before every push.
#
# Enable once per clone:   git config core.hooksPath .githooks
# Skip once (emergency):   FB_SKIP_CI=1 git push
#
# Runs `npm run ci` (scripts/ci.mjs) = syntax check -> full test suite -> build
# preflight -> repo-invariant, then records the run on the board (test_runs.md)
# and files a bug on red — see scripts/ci.mjs (FBMCPF-521).
#
# FBMCPB-102515 — three things this hook must now do, learned the hard way.
# On 2026-10-19 this hook ran the suite, the suite committed its fixtures onto
# the real `main` (it inherited GIT_DIR from THIS hook), and the hook pushed a
# tree with 895 files and 254718 lines deleted to GitHub. So:
#
#   1. UNEXPORT the repo-pointing GIT_* variables before running anything. Git
#      sets them for every hook; npm and node pass them to every child; the test
#      suite's git fixtures then address this repository instead of their own
#      temp ones. This is the root cause, and this is where it enters.
#   2. BLAST-RADIUS GUARD, before the gate: refuse a push to a protected branch
#      whose diff deletes more than a configured number of files or lines. This
#      alone would have stopped the incident at the door, in under a second,
#      without running anything.
#   3. SELF-CHECK: a gate that can mutate the repository it is gating is not a
#      gate. Record the ref state before the gate and refuse the push if it
#      moved. scripts/ci.mjs checks the same invariant in more detail; this is
#      the version that still holds if someone runs the gate a different way.
#
# DESIGN NOTE, recorded deliberately: running the FULL suite in pre-push is the
# wrong shape and should go. A pre-push hook is a last-moment safety check; it
# should be seconds, read-only, and impossible to bypass by accident. A suite
# that spawns hundreds of processes, writes to temp dirs and shells out to git
# is none of those, and it is only bearable here because CI minutes ran out.
# The right end state is: this hook keeps the blast-radius and invariant guards
# (cheap, read-only, genuinely protective) and the full suite moves back to a
# real CI runner on the server side, where it cannot touch a developer's repo
# and cannot gate a push by mutating it. Until then, FB_PUSH_GATE=light runs
# only the guards.

# --- 1. never let this hook's git environment reach a child -----------------
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_OBJECT_DIRECTORY \
      GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_NAMESPACE GIT_PREFIX GIT_SUPER_PREFIX \
      GIT_CONFIG_PARAMETERS GIT_INDEX_VERSION GIT_REFLOG_ACTION

TOPLEVEL=$(git rev-parse --show-toplevel 2>/dev/null) || exit 1
cd "$TOPLEVEL" || exit 1

ZERO=0000000000000000000000000000000000000000

# --- 2. blast-radius guard --------------------------------------------------
# Configurable, with a documented override:
#   git config fb.push.protectedBranches "main master release"   (default: main master)
#   git config fb.push.maxDeletedFiles   250                     (default: 100)
#   git config fb.push.maxDeletedLines   20000                   (default: 10000)
#   FB_ALLOW_BIG_DELETE=1 git push          — one-off override, prints what it allowed
PROTECTED=$(git config --get fb.push.protectedBranches || echo "main master")
MAX_DEL_FILES=$(git config --get fb.push.maxDeletedFiles || echo 100)
MAX_DEL_LINES=$(git config --get fb.push.maxDeletedLines || echo 10000)

# A guard configured with junk must fall back to its defaults, not switch off.
# An empty PROTECTED protects nothing; a non-numeric limit makes `[ x -gt y ]`
# an ERROR, which the `if` below reads as "not over the limit" and allows.
[ -n "$PROTECTED" ] || PROTECTED="main master"
case $MAX_DEL_FILES in ''|*[!0-9]*) MAX_DEL_FILES=100;; esac
case $MAX_DEL_LINES in ''|*[!0-9]*) MAX_DEL_LINES=10000;; esac

is_protected() {
  branch=${1#refs/heads/}
  for p in $PROTECTED; do [ "$branch" = "$p" ] && return 0; done
  return 1
}

has_update=0
blast_refused=0
updates=""

while read -r local_ref local_sha remote_ref remote_sha; do
  [ -n "$local_sha" ] || continue
  [ "$local_sha" = "$ZERO" ] && continue   # delete-only push: nothing to gate
  has_update=1
  updates="$updates$local_ref $local_sha $remote_ref $remote_sha
"

  # A push with no remote ref cannot be judged; say so rather than skipping.
  if [ -z "$remote_ref" ]; then
    echo "pre-push: no remote ref for ${local_ref} — REFUSED (FBMCPB-102515); push an explicit refspec"
    blast_refused=1
    continue
  fi
  is_protected "$remote_ref" || continue
  [ "$remote_sha" = "$ZERO" ] && continue                 # brand-new branch

  # EVERY failure below refuses. The incident's push was exactly the shape this
  # guard exists for, so "I could not measure it" must never read as "it is fine".
  if ! git cat-file -e "$remote_sha^{commit}" 2>/dev/null; then
    echo "pre-push: ${remote_sha} is not in this clone — REFUSED (FBMCPB-102515)."
    echo "  Fetch first (git fetch origin), or FB_ALLOW_BIG_DELETE=1 git push if you are certain."
    if [ "${FB_ALLOW_BIG_DELETE:-0}" = "1" ]; then
      echo "pre-push: FB_ALLOW_BIG_DELETE=1 — allowing ${remote_ref#refs/heads/} without a blast-radius measurement"
    else
      blast_refused=1
    fi
    continue
  fi

  del_files=$(git diff --name-only --diff-filter=D "$remote_sha" "$local_sha" 2>/dev/null | wc -l | tr -d ' ')
  del_lines=$(git diff --numstat "$remote_sha" "$local_sha" 2>/dev/null | awk '{ if ($2 ~ /^[0-9]+$/) s += $2 } END { print s + 0 }')

  # An empty measurement means git or awk failed; ${x:-0} would turn that into
  # "zero deletions" and wave the push through.
  if [ -z "$del_files" ] || [ -z "$del_lines" ]; then
    echo "pre-push: could not measure the blast radius of ${remote_ref#refs/heads/} (git/awk failed) — REFUSED."
    if [ "${FB_ALLOW_BIG_DELETE:-0}" = "1" ]; then
      echo "pre-push: FB_ALLOW_BIG_DELETE=1 — allowing ${remote_ref#refs/heads/} unmeasured"
    else
      blast_refused=1
    fi
    continue
  fi

  if [ "$del_files" -gt "$MAX_DEL_FILES" ] || [ "$del_lines" -gt "$MAX_DEL_LINES" ]; then
    if [ "${FB_ALLOW_BIG_DELETE:-0}" = "1" ]; then
      echo "pre-push: FB_ALLOW_BIG_DELETE=1 — allowing ${remote_ref#refs/heads/} to delete ${del_files} file(s) / ${del_lines} line(s)"
    else
      echo ""
      echo "pre-push: BLAST-RADIUS GUARD — push to ${remote_ref#refs/heads/} REFUSED (FBMCPB-102515)."
      echo "  ${remote_sha} -> ${local_sha}"
      echo "  deletes ${del_files} file(s)   (limit ${MAX_DEL_FILES}, git config fb.push.maxDeletedFiles)"
      echo "  deletes ${del_lines} line(s)   (limit ${MAX_DEL_LINES}, git config fb.push.maxDeletedLines)"
      echo ""
      echo "  Inspect it first:  git diff --stat ${remote_sha} ${local_sha}"
      echo "  Intentional?       FB_ALLOW_BIG_DELETE=1 git push"
      blast_refused=1
    fi
  fi
done

[ "$blast_refused" = "1" ] && exit 1
[ "$has_update" = "1" ] || exit 0

if [ "${FB_SKIP_CI:-0}" = "1" ]; then
  echo "pre-push: FB_SKIP_CI=1 — skipping local CI gate (blast-radius guard still ran)"
  exit 0
fi

# --- 3. the gate, with a self-check around it -------------------------------
# Record git's REFUSALS as content, not as emptiness: two failed snapshots
# compare equal and would report the repository untouched having looked at
# nothing. (`git show-ref` exits 1 on a legitimately ref-less repo, so the exit
# code is recorded rather than treated as fatal.)
snapshot_refs() {
  git show-ref 2>&1 | sort || echo "show-ref-failed:$?"
  git worktree list --porcelain 2>&1 | sort || echo "worktree-failed:$?"
}
REFS_BEFORE=$(snapshot_refs)

if [ "${FB_PUSH_GATE:-full}" = "light" ]; then
  echo "pre-push: FB_PUSH_GATE=light — guards only, suite not run"
  exit 0
fi

echo "pre-push: running local CI (npm run ci) — set FB_SKIP_CI=1 to bypass"
FB_CI_TRIGGER=hook npm run --silent ci
CI_STATUS=$?

REFS_AFTER=$(snapshot_refs)
if [ "$REFS_BEFORE" != "$REFS_AFTER" ]; then
  echo ""
  echo "pre-push: THE GATE MUTATED THIS REPOSITORY — push REFUSED (FBMCPB-102515)."
  echo "Refs and/or registered worktrees changed while 'npm run ci' was running."
  # NB: in a linked worktree `.git` is a FILE, so write beside the real git dir.
  GITDIR=$(git rev-parse --git-dir 2>/dev/null)
  if [ -n "$GITDIR" ] && [ -d "$GITDIR" ]; then
    # Redirections are applied before 2>/dev/null would take effect, so group them.
    { echo "$REFS_BEFORE" > "$GITDIR/fb-prepush-refs-before.txt"; } 2>/dev/null
    { echo "$REFS_AFTER"  > "$GITDIR/fb-prepush-refs-after.txt";  } 2>/dev/null
    echo "  diff: git diff --no-index $GITDIR/fb-prepush-refs-before.txt $GITDIR/fb-prepush-refs-after.txt"
  fi
  echo "Nothing is pushed. Find what wrote to the repo before trying again."
  exit 1
fi

if [ "$CI_STATUS" -eq 0 ]; then
  echo "pre-push: CI green — pushing"
  exit 0
fi

echo ""
echo "pre-push: CI FAILED — push refused. Fix the failure, or FB_SKIP_CI=1 git push to force."
exit 1
