#!/usr/bin/env bash
# Pre-push hook: run the FAST local checks before push.
#
# Runs the `ci-fast` task from pyproject.toml — lint, format-check, typecheck and
# the private-link guard over the working tree — then the same leak scan over every
# line the pushed commits add (see below). Seconds, not minutes.
#
# It deliberately does NOT run the test suite. Two reasons:
#
#   1. GitHub Actions runs the full matrix on every PR, so a multi-minute local
#      re-run buys little and is paid on every single push.
#   2. Correctness, not just speed: git opens the connection to the remote ~1s BEFORE
#      running this hook, and GitHub closes an idle git-receive-pack session after
#      ~5 minutes. A hook that outlasts that makes git write the pack to a dead
#      socket and die with exit 141 (SIGPIPE) — hook passes, nothing transferred,
#      no error printed. Full `task ci` measured 395s and failed 3/3; `ci-fast`
#      measures ~4.5s. See
#      docs/solutions/integration-issues/long-pre-push-hook-makes-git-push-to-github-fail-with-sigpipe.md
#
# Run `task ci` yourself before opening a PR.
#
# This is the versioned source of truth — the actual hook lives at
# .git/hooks/pre-push (per-clone), installed by scripts/install-git-hooks.sh.
#
# Bypass with `git push --no-verify` for emergencies (e.g. work-in-progress
# branch pushes for backup or sharing). Don't bypass before a PR merges.
#
# WHAT IS PUSHED, NOT WHAT IS CHECKED OUT. `task ci-fast` scans the working tree.
# A push publishes COMMITS: a leak committed and removed in a later commit, or on a
# branch that is not checked out (`git push origin other-branch`), is in every clone
# and invisible to a tree scan. So after ci-fast this hook scans what the pushed
# commits publish — added lines, added file names, commit messages — using the refs
# git passes on stdin:
#
#   <local_ref> <local_sha> <remote_ref> <remote_sha>
#
#   existing remote ref  -> <remote_sha>..<local_sha>
#   new remote ref       -> <local_sha> --not --remotes   (commits NO remote-tracking
#                           ref has — for a push to a second remote, like a fork, the
#                           history origin already has is not rescanned)
#   a tag                -> the same, plus the tag's name and annotated message
#   deletion             -> nothing is published, nothing to scan
#
# If <remote_sha> is not in this clone (someone else pushed since your last fetch),
# the new-ref form is used, which scans more, never less. NOT `--not --tags`: a tag
# created locally and never pushed would then hide the unpublished commits under it.
#
# Where the range still reaches history published before this scan existed (a clone
# with no remote-tracking refs at all), the known findings there are suppressed by
# the committed baseline — by exact commit identity, so no new commit can match one.
#
# THE SCANNER NEXT TO THIS HOOK is what runs, not the pushing worktree's copy. The hook
# is installed as a symlink into the main checkout, and a worktree branched before the
# scanner learned --range carries a scanner that refuses those arguments.

set -euo pipefail

say()  { printf '\n\033[1;36m== %s ==\033[0m\n' "$*"; }
ok()   { printf '  \033[1;32mOK\033[0m %s\n' "$*"; }
fail() { printf '  \033[1;31mFAIL\033[0m %s\n' "$*"; exit 1; }

# FIRST, before anything that might read stdin: the refs being pushed. `task ci-fast`
# inherits stdin, and a child that read it would leave this hook nothing to scan.
pushed_refs="$(cat)"

# Make sure we're using the project's Python environment so `task` resolves.
# The venv is a sibling of this script's repo root, so find it rather than
# requiring the caller to have activated it — an unactivated venv used to abort
# the push with a bare "task not found".
if ! command -v task >/dev/null 2>&1; then
  # TWO CANDIDATES, because `--show-toplevel` is the CURRENT worktree and a linked
  # worktree has no `.venv` — the venv lives in the main checkout. Measured from a
  # linked worktree: `--show-toplevel` gives the worktree, while `--git-common-dir`
  # gives `<main checkout>/.git`, whose parent is the checkout that owns the venv.
  # From the main checkout `--git-common-dir` is the relative `.git`, which resolves
  # the same way because git runs hooks from the top level.
  #
  # Without the second candidate this aborted every push made from a worktree with a
  # bare "task not found" — and worktrees are the workflow this repo asks agents to
  # use. The worktree is still tried FIRST, so a checkout with its own venv wins.
  hook_toplevel="$(git rev-parse --show-toplevel 2>/dev/null || true)"
  hook_common="$(git rev-parse --git-common-dir 2>/dev/null || true)"
  hook_main=""
  if [ -n "$hook_common" ]; then
    hook_main="$(cd "$hook_common/.." 2>/dev/null && pwd || true)"
  fi

  if [ -n "$hook_toplevel" ] && [ -x "$hook_toplevel/.venv/bin/task" ]; then
    PATH="$hook_toplevel/.venv/bin:$PATH"
    export PATH
  elif [ -n "$hook_main" ] && [ -x "$hook_main/.venv/bin/task" ]; then
    PATH="$hook_main/.venv/bin:$PATH"
    export PATH
  fi
fi

if ! command -v task >/dev/null 2>&1; then
  fail "task not found on PATH — activate the project venv (source .venv/bin/activate) and re-run, or install dev deps with: pip install -e .[dev]"
fi

say "Running fast local checks (task ci-fast)"
echo "  lint + format-check + typecheck + private-link guard, then a leak scan of the pushed commits."
echo "  Full suite runs in CI; run \`task ci\` yourself before opening a PR."
echo "  Bypass with \`git push --no-verify\` if you need to push WIP."
echo

if task ci-fast </dev/null; then
  ok "pre-push: fast checks passed locally"
else
  fail "pre-push: local checks failed — fix before pushing (or --no-verify for WIP)"
fi

say "Scanning what the pushed commits publish (leak scan)"
hook_dir="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
scanner="$hook_dir/../check-no-private-links.py"
if [ ! -f "$scanner" ]; then
  # A hook COPIED into .git/hooks rather than symlinked has no scanner beside it.
  scanner="$(git rev-parse --show-toplevel)/scripts/check-no-private-links.py"
  echo "  note: no scanner beside this hook; using the checked-out one ($scanner)"
fi
baseline="$(dirname "$scanner")/leak-scan-baseline.json"
baseline_args=()
if [ -f "$baseline" ]; then baseline_args=(--baseline "$baseline"); fi
scanned=0
while read -r local_ref local_sha remote_ref remote_sha; do
  [ -n "${local_sha:-}" ] || continue
  case "$local_sha" in *[!0]*) ;; *) continue ;; esac   # all zeros: a deletion
  case "$remote_sha" in
    *[!0]*)
      if git cat-file -e "${remote_sha}^{commit}" 2>/dev/null; then
        revs="${remote_sha}..${local_sha}"
      else
        revs="${local_sha} --not --remotes"
      fi ;;
    *) revs="${local_sha} --not --remotes" ;;
  esac
  tag_args=()
  case "$local_ref" in refs/tags/*) tag_args=(--tag "$local_ref") ;; esac
  echo "  ${local_ref} -> ${remote_ref}"
  rc=0
  # `${a[@]+...}`: an empty array under `set -u` is an error in bash before 4.4.
  python3 "$scanner" --no-tree --range "$revs" ${baseline_args[@]+"${baseline_args[@]}"} \
    ${tag_args[@]+"${tag_args[@]}"} </dev/null || rc=$?   # stdin is the ref list
  case "$rc" in
    0) ;;
    1) fail "pre-push: a pushed commit adds a leak — rewrite that commit before pushing (removing it in a later commit still publishes it)" ;;
    *) fail "pre-push: the leak scan could not run (exit $rc) — nothing was scanned, so nothing is known to be clean" ;;
  esac
  scanned=$((scanned + 1))
done <<EOF
$pushed_refs
EOF
ok "pre-push: ${scanned} pushed ref(s) scanned, no leaks in what they publish"
