#!/usr/bin/env bash
# Pre-push hook for openaca. Installed by scripts/install-hooks.sh
# which sets `core.hooksPath = scripts/git-hooks`.
#
# CI-parity: runs the same lint / type / test commands as CI before any push,
# so local pushes fail on the same checks CI would reject. Iteration loop
# quality — bots and humans fix lint/type/test failures BEFORE pushing rather
# than declaring "done" and leaving a red PR for the next person to notice.
#
# The gates run against a clean `git archive` extraction of the commit being
# pushed, NOT the working tree. CI only ever sees committed files, so anything
# on disk that is not in the commit — an untracked fixture, a file matched by a
# gitignore rule (including a *global* ~/.config/git/ignore rule), a forgotten
# `git add` of a new module — must not be able to make this gate pass. That is
# not hypothetical: a test fixture named `.claude/settings.local.json` was
# excluded by a global ignore rule, so the suite passed here and failed in CI
# with `declares no agent`. Testing the extracted commit removes the whole
# class structurally instead of enumerating patterns to distrust.
#
# Bypass: git push --no-verify (use sparingly — typically when test
# infrastructure is broken locally and you're pushing the fix).

set -euo pipefail

REPO_ROOT="$(git rev-parse --show-toplevel)"
ZERO_SHA="0000000000000000000000000000000000000000"

WORKDIR=""
# `return 0` is load-bearing: this runs as an EXIT trap, and a trap's final
# status overrides the script's own exit status. Written as a bare
# `[ -n "$WORKDIR" ] && ...` chain it returns 1 whenever WORKDIR is empty, so
# the hook printed success and exited 1 — which git reports only as
# "failed to push some refs", with no hint that a hook rejected it.
cleanup() {
  if [ -n "$WORKDIR" ] && [ -d "$WORKDIR" ]; then
    rm -rf "$WORKDIR"
  fi
  return 0
}
trap cleanup EXIT

fail() {
  echo ""
  echo "  ❌ $1"
  echo ""
  echo "  To bypass (not recommended): git push --no-verify"
  echo ""
  exit 1
}

# Read pushed refs from stdin once. Pure deletions (local_sha is zero-SHA)
# skip the check — there's no code to validate.
PUSHED_SHAS=""
while read -r local_ref local_sha remote_ref remote_sha; do
  if [ "$local_sha" = "$ZERO_SHA" ]; then
    continue
  fi
  PUSHED_SHAS="$PUSHED_SHAS $local_sha"
done

if [ -z "${PUSHED_SHAS// /}" ]; then
  exit 0
fi

cd "$REPO_ROOT"

# `git push` exports GIT_DIR / GIT_INDEX_FILE / GIT_WORK_TREE / etc. into the
# hook's environment. Those leak into every grandchild process — pytest, then
# any test fixture that does `subprocess.check_call(["git", "-C", tmp_path,
# ...])`. The grandchild git inherits the parent repo's GIT_DIR and ignores
# `-C tmp_path`. Tests that pass when run directly silently fail when run
# from the hook. Unset the leakers before gates run.
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \
      GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_PREFIX GIT_INTERNAL_GETTEXT_SH_SCHEME

# Refuse on a dirty worktree. The gates below test the *committed* code, so a
# dirty tree can no longer produce a false pass — but pushing while holding
# uncommitted edits still means the thing you validated is not the thing you
# are working on, which is worth stopping for.
#
# One exception, and only inside a GitHub Actions job: claude-code-action
# rewrites the repo's agent-config files from origin/main before it starts
# ("Restoring .claude, .mcp.json, .claude.json, .gitmodules, .ripgreprc,
# CLAUDE.md, CLAUDE.local.md, .husky from origin/main (PR head is
# untrusted)") — a prompt-injection defense, since a PR head can otherwise
# hand the bot its own instructions. On any PR that legitimately edits one of
# those files, that restore leaves the worktree permanently dirty against the
# PR head, and the bot cannot clean it: the workflow grants it no `git
# checkout`/`git restore`/`git stash`. Every @claude run on such a PR then
# ends with the fix committed and unpushed (PR #181, and #180 before it,
# where the bot instead committed the restored file and silently reverted a
# branch change).
#
# Ignoring those paths costs nothing here: the gates below validate a `git
# archive` of the commit being pushed, never the worktree, so a dirty file
# cannot produce a false pass. The guard's real purpose — don't push while
# holding edits you meant to keep — still applies to every other path, and to
# every local push, where GITHUB_ACTIONS is unset and nothing is exempt.
RESTORED_BY_ACTION=(.claude .mcp.json .claude.json .gitmodules .ripgreprc
                    CLAUDE.md CLAUDE.local.md .husky)
if [ "${GITHUB_ACTIONS:-}" = "true" ]; then
  dirty="$(git diff --name-only HEAD -- . $(printf ":(exclude)%s " "${RESTORED_BY_ACTION[@]}"))"
else
  dirty="$(git diff --name-only HEAD)"
fi
if [ -n "$dirty" ]; then
  fail "working tree has uncommitted changes — the commit being pushed
    is not what you have on disk.
    Files:
$(echo "$dirty" | sed 's/^/      /')
    To fix: git commit (or git stash) before pushing."
fi
untracked="$(git ls-files --others --exclude-standard)"
if [ -n "$untracked" ]; then
  fail "untracked, gitignore-eligible files present — they won't be in
    the pushed commit.
    Files:
$(echo "$untracked" | sed 's/^/      /')
    To fix: git add+commit, remove the files, or add them to .gitignore."
fi

# Ignored files under tests/ are the specific trap this hook exists to surface:
# they are invisible to `--exclude-standard` above, and a test that reads one
# passes here and fails in CI. The clean-checkout gates below would catch the
# consequence as a test failure, but naming the cause up front turns a puzzling
# red suite into an obvious fix. A warning, not a failure — local scratch
# fixtures are legitimate, and build artifacts are filtered out.
ignored_tests="$(git ls-files --others --ignored --exclude-standard -- tests/ 2>/dev/null |
  grep -vE '(^|/)(__pycache__/|\.DS_Store$|.*\.pyc$)' || true)"
if [ -n "$ignored_tests" ]; then
  echo ""
  echo "  ⚠ gitignored files under tests/ — CI will NOT have these:"
  echo "$ignored_tests" | sed 's/^/      /'
  echo "    If a test reads one of these, it passes here and fails in CI."
  echo ""
fi

# ---------- CI-parity gates, against the pushed commit ----------

for sha in $PUSHED_SHAS; do
  WORKDIR="$(mktemp -d)"
  CLEAN="$WORKDIR/repo"
  mkdir -p "$CLEAN"
  if ! git archive "$sha" | tar -x -C "$CLEAN"; then
    fail "could not extract commit $sha for validation."
  fi

  short_sha="$(git rev-parse --short "$sha")"
  echo "  → validating $short_sha (clean checkout, not the working tree)"
  cd "$CLEAN"

  run_gate() {
    local label="$1" logfile="$2"
    shift 2
    echo "  → $label"
    if ! "$@" > "$logfile" 2>&1; then
      tail -40 "$logfile"
      return 1
    fi
  }

  run_gate "uv sync --frozen" /tmp/openaca-pre-push-sync.log \
    uv sync --frozen || fail "uv sync --frozen failed.
    uv.lock is missing or stale vs pyproject.toml in the pushed commit.
    To fix: uv lock (then commit uv.lock)"

  run_gate "ruff check" /tmp/openaca-pre-push-ruff-check.log \
    uv run ruff check . || fail "ruff check failed.
    To fix: uv run ruff check --fix . (then re-stage and re-push)"

  run_gate "ruff format --check" /tmp/openaca-pre-push-ruff-format.log \
    uv run ruff format --check . || fail "ruff format --check failed.
    To fix: uv run ruff format . (then re-stage and re-push)"

  run_gate "pyright" /tmp/openaca-pre-push-pyright.log \
    uv run pyright || fail "pyright failed.
    To fix: uv run pyright (address each error)"

  run_gate "pytest" /tmp/openaca-pre-push-pytest.log \
    uv run pytest -q || fail "pytest failed against the COMMITTED tree.
    If this passes in your working tree but fails here, a file the tests
    need is not committed — check the gitignored-files warning above.
    To fix: uv run pytest -v (debug failures, then re-stage and re-push)"

  if [ -d capabilities ] && [ "$(find capabilities -name '*.yaml' -print -quit)" ]; then
    run_gate "openaca lint capabilities/" /tmp/openaca-pre-push-cap-lint.log \
      uv run openaca lint capabilities/ || fail "openaca lint capabilities/ failed.
    To fix: uv run openaca lint capabilities/ (address each error)"
  fi

  if [ -d overlays ] && [ "$(find overlays -name '*.yaml' -print -quit)" ]; then
    run_gate "openaca lint overlays/" /tmp/openaca-pre-push-overlay-lint.log \
      uv run openaca lint overlays/ || fail "openaca lint overlays/ failed.
    To fix: uv run openaca lint overlays/ (address each error)"
  fi

  # CLI surface smoke, mirroring the assertions of CI's smoke-install job
  # without its wheel build. The build only catches dependency-resolution
  # problems; these steps catch behaviour changes that leave a user-facing
  # command emitting nothing — which is how a repo that declares no agent
  # (ADR-0044) broke `bom repo --output` and its `test -s` in CI.
  echo "  → cli smoke (scan repo, bom repo)"
  smoke_target="$WORKDIR/smoke-target"
  mkdir -p "$smoke_target"
  # `.mcp.json`, not `mcp.json`: a bare `mcp.json` is owned by no runtime
  # exclusively, so it declares no agent and the repo emits no document.
  cat > "$smoke_target/.mcp.json" <<'FIXTURE'
{"mcpServers": {"remote": {"type": "http", "url": "https://api.example.test/mcp"}}}
FIXTURE
  if ! uv run openaca scan repo --target "$smoke_target" --include-posture \
       --fail-on none > /tmp/openaca-pre-push-smoke.log 2>&1; then
    tail -40 /tmp/openaca-pre-push-smoke.log
    fail "openaca scan repo failed on the smoke fixture.
    To fix: run the command above and debug it."
  fi
  if ! uv run openaca bom repo --target "$smoke_target" \
       --output "$WORKDIR/smoke-bom.json" >> /tmp/openaca-pre-push-smoke.log 2>&1; then
    tail -40 /tmp/openaca-pre-push-smoke.log
    fail "openaca bom repo failed on the smoke fixture."
  fi
  if [ ! -s "$WORKDIR/smoke-bom.json" ]; then
    fail "openaca bom repo wrote no document for a target that declares an
    agent — CI's smoke-install job asserts \`test -s\` on this file.
    To fix: check discovery/emission for the declared path."
  fi

  cd "$REPO_ROOT"
  cleanup
  WORKDIR=""
done

echo "  ✓ pre-push checks ok"
exit 0
