#!/bin/bash
set -e

# --- ref guard: only master and tags reach a public remote -------------------
# "Only master goes to Codeberg; feature branches move host-to-host over SSH"
# was model memory until 2026-08-17 (review PROC-0006). A stray branch on a
# public forge is published the moment it lands and cannot be retracted, so
# the rule belongs in a mechanism. Runs FIRST: a refusal must not cost the
# multi-minute lint+fast below.
#
# git passes the remote name as $1 and its URL as $2, and one line per ref on
# stdin: <local ref> <local sha> <remote ref> <remote sha>. stdin can be read
# exactly once and both guards below need those lines, so slurp them here and
# feed each guard from the variable (a here-string, not a pipe: a `while` on
# the right of a pipe runs in a subshell, where `exit 1` refuses nothing).
prepush_refs="$(cat)"
ref_guard_remote="${1:-}"
ref_guard_url="${2:-}"
prepush_public=false
if [ "$ref_guard_remote" = "origin" ] || printf '%s' "$ref_guard_url" | grep -qi 'codeberg\.org'; then
    prepush_public=true
fi

if [ "$prepush_public" = true ]; then
    while read -r _local_ref local_sha remote_ref _remote_sha; do
        [ -z "$remote_ref" ] && continue
        # An all-zero local sha is a branch DELETION — removing a stray branch
        # from the forge must stay possible, that is the remedy, not the harm.
        case "$local_sha" in *[!0]*) ;; *) continue ;; esac
        case "$remote_ref" in
            refs/heads/master) ;;
            refs/tags/*) ;;
            refs/heads/*)
                echo "pre-push: REFUSED — '$remote_ref' is not master." >&2
                # (guard-only mode, below, lets the selftest exercise every
                #  branch of this decision without the multi-minute gates)
                echo "pre-push: only master and tags go to $ref_guard_remote (public forge)." >&2
                echo "pre-push: move feature branches host-to-host instead, e.g." >&2
                echo "pre-push:   git -C <other-host-tree> fetch ssh://<host>/<path> <branch>:<branch>" >&2
                exit 1
                ;;
        esac
    done <<< "$prepush_refs"
fi

# --- Claude guard: Claude-specific files never reach a public remote ---------
# Second, independent layer under the same rule as `.gitignore`'s `.claude/`
# entry, because an ignore rule is advisory: `git add -f` defeats it, and by
# then the file is a committed object that only this hook still sees. What is
# published cannot be retracted, so this runs BEFORE the expensive gates too.
#
# The commits being pushed are judged, not the working tree: a file staged
# weeks ago is exactly the case an ignore rule already failed to stop.
#
# Public remote only. Host-to-host pushes between hamster and schneckenschreck
# are how these files are legitimately moved between machines, so they pass —
# same carve-out the ref guard makes.
if [ "$prepush_public" = true ]; then
    while read -r _local_ref local_sha remote_ref remote_sha; do
        [ -z "$remote_ref" ] && continue
        # Deletion: no content is being published, nothing to inspect.
        case "$local_sha" in *[!0]*) ;; *) continue ;; esac
        # An all-zero remote sha is a ref the forge does not have yet, so there
        # is no base to diff against — take the commits that no remote-tracking
        # ref contains, which is what this push actually adds.
        case "$remote_sha" in
            *[!0]*) claude_range=("$remote_sha..$local_sha") ;;
            *)      claude_range=("$local_sha" --not --remotes) ;;
        esac
        # --no-renames so a rename lists both names: renaming CLAUDE.md into
        # place must trip the guard just as adding it does.
        #
        # CLAUDE.md is matched by BASENAME PREFIX, not exact name: an editor
        # swap file (CLAUDE.md~), a merge leftover (CLAUDE.md.orig) or a copy
        # (CLAUDE.md.bak-mirror) holds the same policy text, and the exact-name
        # test let such a commit through to the public remote with exit 0
        # (measured 2026-08-17). The `(^|/)` prefix anchor is what keeps the
        # widening honest: only a path COMPONENT that begins with the name
        # matches, so an ordinary file that merely mentions it later in a
        # component — docs/how-we-use-CLAUDE.md-files.txt, src/claude_adapter.rs
        # — still passes. A file truly named `CLAUDE.md<suffix>` is refused;
        # that spelling is far more often a leaked copy than a wanted source
        # file, and the remedy for the rare exception is a rename.
        # `git log` failing is NOT "no hits". With a remote sha this clone has
        # never fetched (the forge moved; also: shallow clone, broken object)
        # the range cannot be listed at all, and until 2026-09-11 that error
        # vanished into the pipeline: empty output read as a clean range, push
        # passed (measured — a scratch repo with a committed CLAUDE.md and an
        # unknown remote sha went through with rc=0). So the listing is its own
        # step with its status checked, and an unlistable range fails CLOSED:
        # the guard cannot know the range is clean, so it refuses and says so.
        # The `|| true` stays only on the grep below, where empty genuinely
        # means "no matching path" — grep's rc=1 is its no-match verdict, not
        # a swallowed error.
        claude_list_rc=0
        claude_files="$(git -c core.quotepath=false log --format= --name-only \
            --no-renames "${claude_range[@]}" --)" || claude_list_rc=$?
        if [ "$claude_list_rc" -ne 0 ]; then
            echo "pre-push: REFUSED — cannot inspect what this push would publish." >&2
            echo "pre-push: git could not list the range (${claude_range[*]})," >&2
            echo "pre-push: so this guard cannot know whether Claude-specific files" >&2
            echo "pre-push: are in it. Usually the forge has moved and this clone" >&2
            echo "pre-push: has not fetched, so the base commit is missing locally:" >&2
            echo "pre-push:   git fetch $ref_guard_remote" >&2
            echo "pre-push: then push again." >&2
            exit 1
        fi
        claude_hits="$(printf '%s\n' "$claude_files" \
            | grep -Ei '(^|/)CLAUDE\.md|(^|/)\.mcp\.json$|(^|/)\.claude/' \
            | sort -u || true)"
        if [ -n "$claude_hits" ]; then
            echo "pre-push: REFUSED — '$remote_ref' carries Claude-specific files:" >&2
            printf '%s\n' "$claude_hits" | sed 's|^|pre-push:   |' >&2
            echo "pre-push: these never go to $ref_guard_remote (public forge)." >&2
            echo "pre-push: they move host-to-host instead, e.g." >&2
            echo "pre-push:   git -C <other-host-tree> fetch ssh://<host>/<path> <branch>:<branch>" >&2
            echo "pre-push: if one is already committed, rewrite it out of the range first." >&2
            exit 1
        fi
    done <<< "$prepush_refs"
fi

# --- tree guard: the gates below judge the tree, so the tree must be the push -
# Everything under this line — the pipeline lint, `just fast` — runs against
# the WORKING TREE, and nothing in this hook has ever compared that tree with
# the commit the push publishes. The verdict is therefore about whatever
# happened to be checked out, which is only the same thing by habit.
#
# Both directions are real. On 2026-09-11 a push of 081522b2 — gated green an
# hour earlier — was refused because a coder pass had left a half-finished
# record-log port in the tree. The opposite case costs more and is just as
# easy to reach: a dirty tree that happens to pass waves through a commit that
# nothing ever tested.
#
# Untracked files deliberately do not count. Every working tree has them
# (scratch logs, unignored build output, a note beside the code), they are in
# no commit, and refusing on them would make this hook fire on a state that is
# normal. A hook that fires on the normal state is a hook people push past
# with `--no-verify`, which switches off the guards above as well — the exact
# failure the removed Tier 2 gate documented at the end of this file.
#
# Both checks apply to every remote, not only the public one: a gate run that
# describes the wrong content is worthless wherever the commit is going.
prepush_dirty="$(git status --porcelain --untracked-files=no --ignore-submodules=untracked)"
if [ -n "$prepush_dirty" ]; then
    echo "pre-push: REFUSED — the working tree has uncommitted tracked changes:" >&2
    printf '%s\n' "$prepush_dirty" | sed 's|^|pre-push:   |' >&2
    echo "pre-push: the gates below would test THIS TREE, not the commit being pushed." >&2
    echo "pre-push: commit the changes, or push that sha from a clean checkout:" >&2
    echo "pre-push:   scripts/push-clean.sh <sha>" >&2
    exit 1
fi

# Same rule, other half: `just fast` can only speak for the commit at HEAD, so
# a push that would move master to anything else is ungated.
#
# refs/heads/master only. A tag, or a feature branch fetched from the other
# host, is routinely pushed from a tree standing somewhere else entirely, and
# refusing those would block the host-to-host path that exists so feature
# branches never reach the forge (see the ref guard at the top). master is
# where gated commits land, and it is the ref this hook's verdict is about.
prepush_head="$(git rev-parse HEAD)"
while read -r _local_ref local_sha remote_ref _remote_sha; do
    [ -z "$remote_ref" ] && continue
    # Deletion: nothing is being gated, and the ref guard already let it by.
    case "$local_sha" in *[!0]*) ;; *) continue ;; esac
    [ "$remote_ref" = "refs/heads/master" ] || continue
    [ "$local_sha" = "$prepush_head" ] && continue
    echo "pre-push: REFUSED — '$remote_ref' would get $local_sha," >&2
    echo "pre-push: but HEAD, the tree the gates below test, is $prepush_head." >&2
    echo "pre-push: whatever the gates say would be about a different commit." >&2
    echo "pre-push: push $local_sha from a clean checkout instead:" >&2
    echo "pre-push:   scripts/push-clean.sh $local_sha" >&2
    exit 1
done <<< "$prepush_refs"

# Testability affordance: the guards above are the only part of this hook a
# selftest can exercise cheaply — everything below costs minutes. With
# LEV_PREPUSH_GUARD_ONLY=1 the hook stops here, so scripts/check-prepush-guard.sh
# can drive every outcome — the six ref-guard ones (refuse / master / tag /
# delete / other-remote / multi-ref), the Claude-guard ones (hit / clean /
# host-to-host / unlistable range), and the three tree-guard ones (dirty /
# HEAD mismatch / clean) — against scratch repositories in about a second.
# `just fast` runs it. It is a test hook, never set in normal use.
[ "${LEV_PREPUSH_GUARD_ONLY:-0}" = "1" ] && exit 0

# git runs hooks with the login shell's PATH, not an interactive one, so
# ~/.local/bin — where woodpecker-cli is installed — is absent and the bare
# name resolved to nothing. Prepend it, then resolve the name explicitly:
# `command -v` failing is a different fact from the lint failing, and the
# hook should say which one happened. Not an absolute path, because the
# install location is per-machine and a wrong one here is a hook every
# clone has to edit.
export PATH="$HOME/.local/bin:$PATH"
if ! command -v woodpecker-cli >/dev/null 2>&1; then
    echo "pre-push: woodpecker-cli not found on PATH (looked in ~/.local/bin)." >&2
    echo "pre-push: install it from https://codeberg.org/woodpecker/woodpecker" >&2
    echo "pre-push: or set PATH so this hook can see it, then push again." >&2
    exit 1
fi

# Lint Woodpecker pipelines first — schema-check is sub-second, so
# bailing here saves the multi-minute Tier 0 below if a YAML is broken.
woodpecker-cli lint .woodpecker/

# Always run Tier 0 fast checks (transitively pulls in mvr)
just fast

# Record Tier 0 + mvr pass in results log (mvr is fast's dep)
mkdir -p ~/.local/state/leviculum-ci
echo "$(date -Iseconds) tier0+mvr GREEN" >> ~/.local/state/leviculum-ci/last-results.txt

# There was a Tier 2 staleness gate here until 2026-08-07. It blocked on a
# `tier2 GREEN` line that only scripts/run-tier2.sh writes, and nothing has
# run that script since the 12:30/18:30 timer was retired (install-ci.sh
# step 9, 2026-06-12). Last such line: 2026-06-22. The remedy it printed —
# `just extensive` — does not write the line either, so the block could not
# be cleared by doing what it said, and 502 commits reached master through
# `--no-verify`, which switches off this whole hook. Removed rather than
# repaired: an unsatisfiable gate does not merely fail to protect, it
# teaches everyone to disable the gates that work.
