#!/usr/bin/env bash
# finbreak pre-push gate — run the SAME gate CI runs, before the push leaves the
# machine, so a red commit can't reach origin (and burn a CI failure email).
#
# Why this exists: .github/workflows/ci.yml runs scripts/ci-setup.sh then
# scripts/ci-local.sh — the identical script below — so "green locally" already
# means "green in CI". The only way a red push slipped through (e.g. the gitleaks
# false-positive on a docs-only cold-eyes commit, a0cc895) was the gate not being
# *run* before pushing. This hook removes that gap: `git push` runs it for you.
#
# Enable once per clone (already set for this repo):
#     git config core.hooksPath .githooks
#
# Genuine emergency bypass (discouraged — global rule §1): git push --no-verify
set -euo pipefail

cd "$(git rev-parse --show-toplevel)"

# The gate's tools (ruff, bandit, pip-audit, mypy, pytest) live in the project
# venv — note mypy: a green `pytest` alone is not a green gate. A
# `git push` from a shell without it activated would otherwise fail on missing
# tools rather than on a real finding — activate it here so the hook is faithful.
if [ -z "${VIRTUAL_ENV:-}" ] && [ -f .venv/bin/activate ]; then
    # shellcheck disable=SC1091
    . .venv/bin/activate
fi

# A tag-only push whose commits are ALREADY on the remote cannot introduce
# ungated code: the commit was gated by the push that put it there, so a second
# run can only re-prove the same result. That case is a release tag going up
# right behind its branch (cut-release Phase 5), and it
# used to cost a second full ~3-minute gate -- long enough that the habit grew
# of reaching for `git push --no-verify`, a bypass no project document
# sanctioned (FIBR-0290). Skipping it here removes the reason for the habit.
#
# Both conditions are required. A tag whose commit is NOT yet on the remote
# would publish ungated code, so it takes the gate like anything else, and any
# branch ref in the same push does too. Git supplies the ref updates on stdin
# as `<local ref> <local sha> <remote ref> <remote sha>`; with no stdin (a
# hand-run hook) nothing is known, so the gate runs.
ZERO=0000000000000000000000000000000000000000
# Read once: the tag check below and the docs check after it both need the list.
# Run by hand from a terminal there is no list, and reading the tty would wait
# for Ctrl-D (FIBR-0400); nothing is known, so the gate runs.
if [ -t 0 ]; then
    REFS=""
else
    REFS=$(cat)
fi

# Every commit this push adds is scanned for secrets, on EVERY push -- tag-only
# and documentation-only included (local-gate.md § 2.1 item 1). The gate's own
# gitleaks stage reads the files on disk, so a token committed and deleted again
# inside one push passed it and still reached the remote's history. Fails
# closed: no gitleaks, or a range git cannot resolve, and nothing is pushed.
REMOTE=${1:-origin}
if [ -n "$REFS" ]; then
    if ! command -v gitleaks >/dev/null 2>&1; then
        echo "== pre-push: gitleaks is not installed, so the pushed commits cannot be scanned ==" >&2
        exit 1
    fi
    leak_config=()
    if [ -f .gitleaks.toml ]; then leak_config=(--config .gitleaks.toml); fi
    while read -r _local_ref local_sha remote_ref remote_sha; do
        if [ -z "${remote_ref:-}" ] || [ "$local_sha" = "$ZERO" ]; then
            continue
        fi
        if [ "$remote_sha" = "$ZERO" ]; then
            range="$local_sha --not --remotes=$REMOTE"
        else
            range="$remote_sha..$local_sha"
        fi
        if ! gitleaks git --no-banner --redact "${leak_config[@]}" --log-opts="$range" . >&2; then
            echo "== pre-push: gitleaks found a secret in a commit being pushed (above), ==" >&2
            echo "== or could not read the range $range                              ==" >&2
            exit 1
        fi
    done <<< "$REFS"
fi
saw_ref=0
tags_only=1

while read -r _local_ref local_sha remote_ref _remote_sha; do
    if [ -z "${remote_ref:-}" ]; then
        continue
    fi
    saw_ref=1

    case "$remote_ref" in
        refs/tags/*) ;;
        *) tags_only=0; break ;;
    esac

    # A tag deletion pushes nothing new.
    if [ "$local_sha" = "$ZERO" ]; then
        continue
    fi

    if ! commit=$(git rev-parse --verify --quiet "${local_sha}^{commit}"); then
        tags_only=0
        break
    fi

    # Reachable from a branch of the remote being pushed to => already pushed,
    # already gated. Not any remote: a commit that reached another one with
    # --no-verify was never gated (FIBR-0400).
    if [ -z "$(git branch -r --contains "$commit" --list "$REMOTE/*" 2>/dev/null)" ]; then
        tags_only=0
        break
    fi
done <<< "$REFS"

if [ "$saw_ref" = 1 ] && [ "$tags_only" = 1 ]; then
    echo "== pre-push: tag-only push of commits already on the remote =="
    echo "== the gate ran when those commits were pushed; skipping =="
    exit 0
fi

# `ci-local.sh` reads the files on DISK, so this hook's verdict is about the
# working tree and not about the commits being pushed. Those are the same thing
# after a commit-then-push and different the moment anything is uncommitted --
# and the dangerous direction is real: a fix present in the tree but not in the
# commit makes the gate green about code that never leaves the machine
# (FIBR-0327).
#
# Refusing is the honest answer. Gating the pushed commit itself means building
# it in a throwaway worktree with its own venv, which costs more than the case
# is worth; stopping and saying the run would not be about your push does not
# pretend otherwise.
#
# A clean tree proves the files match HEAD, never that HEAD is what is being
# pushed: `git push origin side` from main would gate main's bytes. So every
# pushed tip, peeled to its commit, must BE HEAD (local-gate.md § 5.2).
# Untracked files count too: they are in no commit, yet the gate reads them from
# disk, so an untracked module can turn the local run green while CI -- a clean
# checkout -- goes red. Ignored files do not count.
HEAD_SHA=$(git rev-parse HEAD)
while read -r local_ref local_sha remote_ref _remote_sha; do
    if [ -z "${remote_ref:-}" ] || [ "$local_sha" = "$ZERO" ]; then
        continue
    fi
    if [ "$(git rev-parse --verify --quiet "${local_sha}^{commit}")" != "$HEAD_SHA" ]; then
        echo "== pre-push: $local_ref is not the checked-out commit (HEAD).        ==" >&2
        echo "== the gate reads the files on disk, so it would report on HEAD and  ==" >&2
        echo "== not on what you are pushing. Check it out, then push again.       ==" >&2
        exit 1
    fi
done <<< "$REFS"
dirty=$(git status --porcelain --untracked-files=all)
if [ -n "$dirty" ]; then
    echo "== pre-push: the working tree has uncommitted or untracked files:     ==" >&2
    printf '%s\n' "$dirty" | head -20 >&2
    echo "== the gate reads the files on disk, so it would report on those and ==" >&2
    echo "== not on the commits you are pushing. Commit, stash or remove them. ==" >&2
    echo "== Deliberately pushing anyway: git push --no-verify                 ==" >&2
    exit 1
fi

# A push whose every changed path ends in .md runs `ci-local.sh --docs`: the
# test suites that read prose, and gitleaks (FIBR-0373; local-gate.md § 6).
# The unit is every commit going up -- the remote's tip to ours, per ref -- so a
# code commit queued behind a doc commit still takes the full gate. It fails
# closed: a new branch (no remote tip to compare), a range git cannot resolve,
# an empty diff, or a push with no ref to classify all run everything.
docs_only=0
classified=0
if [ -n "$REFS" ]; then
    docs_only=1
    while read -r _local_ref local_sha remote_ref remote_sha; do
        if [ -z "${remote_ref:-}" ] || [ "$local_sha" = "$ZERO" ]; then
            continue
        fi
        if [ "$remote_sha" = "$ZERO" ] ||
            ! files=$(git diff --name-only --no-renames "$remote_sha" "$local_sha" 2>/dev/null) ||
            [ -z "$files" ]; then
            docs_only=0
            break
        fi
        classified=1
        while IFS= read -r path; do
            case "$path" in
                *.md) ;;
                *) docs_only=0; break ;;
            esac
        done <<< "$files"
        [ "$docs_only" = 1 ] || break
    done <<< "$REFS"
fi

if [ "$docs_only" = 1 ] && [ "$classified" = 1 ]; then
    echo "== pre-push: every changed path ends in .md -- running the documentation checks =="
    exec ./scripts/ci-local.sh --docs
fi

echo "== pre-push: running scripts/ci-local.sh (the CI gate) =="
exec ./scripts/ci-local.sh
