#!/usr/bin/env bash
# push-guard.sh - Mechanical guards against verifications that PASS when the
# thing they verify never happened.
#
# WHY THIS EXISTS
# ---------------
# Three independent incidents, one shape:
#   1. An agent's "PUSH VERIFIED" step compared local HEAD to the remote ref and
#      reported success. The commit had ABORTED, so local trivially equalled
#      remote. The guard could not distinguish "pushed" from "committed nothing".
#   2. An agent chained a push after a FAILED commit, ran on stale state, and
#      pushed an EMPTY commit carrying an unrelated commit's message.
#   3. An agent committed unresolved conflict markers into 70 generated files and
#      pushed invalid JSON to a default branch.
#
# All three are the same defect: a check whose success condition is satisfied by
# the null case. This script asserts the POSITIVE fact (a new object exists, the
# remote MOVED, the content parses) rather than the absence of an error.
#
# DESIGN RULES (do not relax these)
#   * Every check exits NON-ZERO and names what failed. There is deliberately no
#     --warn-only / --soft flag: a guard that can degrade to a warning is the
#     fail-open we are trying to remove.
#   * Checks are fail-CLOSED. `set -euo pipefail` means an unexpected git or
#     network error aborts non-zero rather than falling through to "OK".
#   * Nothing is skipped silently. A check with no applicable inputs says so on
#     stdout instead of contributing a quiet green.
#
# EXIT CODES
#   0   all requested checks passed
#   2   conflict markers present, or the index has unmerged paths
#   3   staged JSON does not parse
#   4   push verification failed (remote did not move, or moved elsewhere)
#   5   nothing staged / empty commit — the null case, refused
#   6   NO JSON DECISION — no --json-path and no usable .push-guard.json
#   64  usage error
#
# Dependencies: bash 4.4+, git, python3.

set -euo pipefail

readonly EX_OK=0
readonly EX_CONFLICT=2
readonly EX_JSON=3
readonly EX_PUSH=4
readonly EX_NULL=5
readonly EX_CONFIG=6
readonly EX_USAGE=64

# Repo-level configuration. Its ABSENCE is refused, not defaulted — see
# resolve_json_config() for why.
readonly CONFIG_FILE=".push-guard.json"

# Conflict-marker detection.
#
# We match `<<<<<<<`, `>>>>>>>` and the diff3 `|||||||` marker, each anchored at
# column 0 and required to be followed by a space or end-of-line (real markers
# are exactly seven characters plus an optional ref label).
#
# We deliberately do NOT match a bare `=======` line. It is the one marker that
# collides with ordinary prose — reStructuredText section underlines and ASCII
# rules use it constantly — and a guard that fires on documentation gets switched
# off, which costs more than the miss. Every genuine conflict git writes contains
# `<<<<<<<` and `>>>>>>>` as well, so requiring those loses no real detection.
readonly MARKER_PATTERNS=(
    -e '^<<<<<<<( |$)'
    -e '^>>>>>>>( |$)'
    -e '^[|]{7}( |$)'
)

log()  { printf '%s\n' "$*"; }
fail() {
    local code="$1"; shift
    printf '\n' >&2
    printf 'PUSH-GUARD FAILED: %s\n' "$1" >&2
    shift
    local line
    for line in "$@"; do printf '  %s\n' "$line" >&2; done
    printf '\n' >&2
    exit "$code"
}

usage() {
    cat <<'EOF'
Usage:
  push-guard.sh check-staged [--json-path <pathspec>]...
                             [--allow-invalid-json <pathspec>]...
  push-guard.sh push --remote <remote> --branch <branch> [--since-head <sha>]
                     [-- <extra git push args>...]
  push-guard.sh --help

Subcommands:
  check-staged   Run pre-commit content guards against the STAGED index:
                   (a) no unmerged index paths, no conflict markers
                   (c) staged JSON under the nominated paths parses
                 Refuses to pass when nothing is staged (exit 5).
                 Check (c) requires an EXPLICIT decision — either --json-path,
                 or .push-guard.json. Saying nothing is refused (exit 6).

  push           Perform a push and prove it HAPPENED:
                   - HEAD is a non-empty commit (differs from its parent)
                   - with --since-head: HEAD advanced past that sha
                   - the remote ref MOVED, and now equals local HEAD
                 Capturing the remote ref BEFORE the push is what makes
                 "remote == local" meaningful; without it the assertion is
                 satisfied by having pushed nothing.

Options:
  --json-path <pathspec>           Git pathspec of generated/serialized JSON to
                                   parse-check, e.g. 'data/**/*.json'. Repeatable.
                                   Opt-in on purpose: many real .json files are
                                   JSONC (tsconfig) or templates and do NOT parse
                                   strictly. Nominate the generated ones. Satisfies
                                   the decision requirement on its own; overrides a
                                   config opt-out (loudly).
  --allow-invalid-json <pathspec>  Exempt a path from the JSON parse check
                                   (for deliberate malformed-input fixtures).
                                   Exemptions are printed, never silent.
  --since-head <sha>               HEAD before your commit step. Asserts a new
                                   commit object was actually created.
  --remote <name>                  Remote to push to.
  --branch <name>                  Branch to push.

Repo config (.push-guard.json, at the repository root):
  {"json_paths": ["data/**/*.json"], "allow_invalid_json": ["fixtures/*"]}
    — nominate generated JSON for parse-checking.
  {"json_check": "none", "reason": "no generated JSON in this repo"}
    — opt out. The reason is REQUIRED and is printed on every run.

An opt-out is only accepted from this file, never from a command-line flag: a
committed reason can be read in a diff, an absent flag cannot be reviewed at all.
An invalid config is refused outright rather than treated as absent.

There is no flag to downgrade a failure to a warning. That is intentional.
EOF
}

require_repo() {
    git rev-parse --show-toplevel >/dev/null 2>&1 \
        || fail "$EX_USAGE" "not inside a git repository"
    cd "$(git rev-parse --show-toplevel)"
}

# ---------------------------------------------------------------- check-staged

# staged_files_z <array-name> [pathspec...]
#
# THE FAIL-OPEN THIS FUNCTION EXISTS TO REMOVE:
#   mapfile -d '' -t files < <(git diff ...)
# observes MAPFILE's exit status, never the producer's. When git diff dies -- a
# bad pathspec, a corrupt index -- mapfile cheerfully reads zero bytes and
# succeeds, and the caller then reports that silence as "nothing to check".
#
# `set -o pipefail` does NOT cover this. pipefail applies to PIPELINES; a process
# substitution is an asynchronous child whose status is never collected. That is
# the precise reason every `cmd | cmd` in this file is safe and both `< <(cmd)`
# constructs were not -- a distinction worth stating, because "we set pipefail"
# reads like whole-file coverage and is not.
#
# Both callers now route through here, so the category is gone rather than the
# two known instances being patched.
staged_files_z() {
    local -n _out="$1"; shift
    local tmp err status=0
    tmp="$(mktemp)"; err="$(mktemp)"
    if (( $# )); then
        git diff --cached --name-only --diff-filter=ACM --no-renames -z \
            -- "$@" >"$tmp" 2>"$err" || status=$?
    else
        git diff --cached --name-only --diff-filter=ACM --no-renames -z \
            >"$tmp" 2>"$err" || status=$?
    fi
    if (( status != 0 )); then
        local msg; msg="$(tr '\n' ' ' <"$err")"
        rm -f "$tmp" "$err"
        fail "$EX_CONFIG" \
            "CANNOT ENUMERATE STAGED FILES — git diff exited $status" \
            "git said: ${msg:-(no message)}" \
            "" \
            "Refusing to report a result. An enumeration that failed returns no" \
            "files, which is indistinguishable from a clean tree — reporting that" \
            "as OK would be a silent pass on an unexamined index."
    fi
    _out=()
    mapfile -d '' -t _out <"$tmp"
    rm -f "$tmp" "$err"
}

check_unmerged() {
    local unmerged
    unmerged="$(git ls-files --unmerged | awk '{print $4}' | sort -u)"
    if [[ -n "$unmerged" ]]; then
        mapfile -t paths <<<"$unmerged"
        fail "$EX_CONFLICT" \
            "the index has UNMERGED paths — a merge/rebase is still in progress" \
            "${paths[@]}" \
            "" \
            "Resolve the conflict and 'git add' the result. Do not commit past this."
    fi
    log "  ok  index has no unmerged paths"
}

check_conflict_markers() {
    local -a files=("$@")
    local hits
    # git grep --cached searches the INDEX (what will actually be committed),
    # not the working tree.
    #
    # -a (treat every blob as text), NOT -I (skip binary). -I honours git's
    # binary classification, which includes an explicit `.gitattributes` `binary`
    # attribute. A repo that marks a path binary would have its staged conflict
    # markers silently skipped while the summary still counted the file as
    # scanned — a clean pass AND a false count. Verified reproducible. The cost
    # of -a is that a genuine binary blob could theoretically emit a noisy match
    # line; a false alarm is recoverable, a silent miss is not.
    # -E is load-bearing: git grep defaults to BASIC regex, in which '(', '|'
    # and '{7}' are literal characters. Without it these patterns match nothing
    # and the check reports a clean pass over a file full of markers. That
    # failure was caught by the needle, not by review.
    #
    # Exit status is discriminated, NOT swallowed. git grep returns 1 for "no
    # match" and >=2 for a real error (bad pathspec, unreadable index). A blanket
    # '|| true' would turn a broken scan into "ok, no conflict markers" — the
    # same fail-open in the guard's own error handling.
    local status=0
    hits="$(git grep --cached -a -n -E "${MARKER_PATTERNS[@]}" -- "${files[@]}")" || status=$?
    if (( status > 1 )); then
        fail "$EX_CONFLICT" \
            "git grep FAILED (exit $status) — the conflict scan did not run" \
            "Refusing to report a clean result from a scan that did not complete."
    fi
    if [[ -n "$hits" ]]; then
        mapfile -t lines <<<"$hits"
        fail "$EX_CONFLICT" \
            "staged content contains unresolved CONFLICT MARKERS" \
            "${lines[@]}" \
            "" \
            "These are staged and would be committed verbatim."
    fi
    log "  ok  no conflict markers in ${#files[@]} staged file(s)"
}

# JSON parse check.
#
# SCOPE IS OPT-IN, AND THAT IS A MEASURED DECISION, NOT LAZINESS.
# The first draft checked every staged *.json. Run against a real repository that
# turned out to be 17 of 119 tracked .json files failing a strict parse — all of
# them legitimate: every tsconfig*.json is JSONC (comments are legal there) and
# the CA templates are Go templates that merely carry a .json suffix. An
# on-by-default check would have fired on ~14% of this repo's JSON, and a guard
# that cries wolf gets routed around until the bypass is habitual — which is
# worse than no guard, because the bypass then also covers the true positives.
#
# So the caller nominates the generated/serialized paths this check is FOR, as
# git pathspecs (git, not bash, expands them — '**' works correctly).
#
# WHAT SAYING NOTHING NOW COSTS YOU
# ---------------------------------
# The first release announced "JSON check NOT REQUESTED" and continued. That was
# visible rather than silent, which is better — but it is still an ABSENCE, and
# an absence is not reviewable. Nobody reads a line that has printed correctly
# ten thousand times. A repo that needed the check and never wired it up stayed
# unprotected forever, and nothing ever failed.
#
# The decision is now MANDATORY and must come from one of two places:
#   * turning the check ON  — --json-path on the command line, or "json_paths"
#                             in .push-guard.json
#   * turning the check OFF — ONLY "json_check": "none" in .push-guard.json,
#                             which REQUIRES a non-empty "reason"
# Saying nothing at all is refused (exit 6).
#
# The asymmetry is deliberate. Turning a check on is safe from anywhere. Turning
# one OFF is confined to a committed file because that is the only form a human
# can review: you can read a reason in a diff, and you cannot review the fact
# that nobody typed a flag. An opt-out that lives in an ad-hoc command line is
# just the old fail-open with extra steps.
# The config parser, held as a string rather than an inline heredoc.
#
# A `<<'PY'` heredoc INSIDE a $( ) command substitution makes bash emit
#   warning: command substitution: 1 unterminated here-document
# on every single run. It still executed, every needle stayed green and the
# static linter stayed clean — the warning goes to stderr and broke no
# assertion. It surfaced only when the guard was run against a real
# repository. A tool built to refuse quiet output was quietly polluting
# stderr; needle 'z1' now asserts the guard emits no warnings at all.
#
# `read -d ''` returns non-zero at EOF without finding a NUL, which is the
# NORMAL path when slurping a heredoc — hence `|| true`. That is the one
# shape where it does not mask a real error, unlike the `git grep || true`
# this codebase already removed once.
IFS='' read -r -d '' CONFIG_PARSER <<'PY' || true
import json, sys

path = sys.argv[1]
try:
    with open(path) as fh:
        cfg = json.load(fh)
except Exception as exc:
    sys.stderr.write("does not parse: %s" % exc)
    sys.exit(2)

if not isinstance(cfg, dict):
    sys.stderr.write("must contain a JSON object, got %s" % type(cfg).__name__)
    sys.exit(2)

def clean_list(name, value):
    if not isinstance(value, list):
        sys.stderr.write('"%s" must be an array' % name)
        sys.exit(2)
    for item in value:
        if not isinstance(item, str) or not item.strip():
            sys.stderr.write('"%s" must contain only non-empty strings' % name)
            sys.exit(2)
        # Tabs/newlines would be mangled by the tab-delimited handoff below.
        # Reject them explicitly rather than silently truncating a pathspec.
        if "\t" in item or "\n" in item:
            sys.stderr.write('"%s" entry contains a tab or newline: %r' % (name, item))
            sys.exit(2)
    return value

mode = cfg.get("json_check")
paths = cfg.get("json_paths")
allow = cfg.get("allow_invalid_json", [])

if mode is not None and mode != "none":
    sys.stderr.write('"json_check" must be "none" if present, got %r' % (mode,))
    sys.exit(2)

if mode == "none":
    if paths:
        sys.stderr.write('"json_check": "none" and "json_paths" are mutually exclusive')
        sys.exit(2)
    reason = cfg.get("reason")
    if not isinstance(reason, str) or not reason.strip():
        sys.stderr.write('"json_check": "none" REQUIRES a non-empty "reason"')
        sys.exit(2)
    print("MODE\tnone")
    print("REASON\t%s" % reason.strip().replace("\t", " ").replace("\n", " "))
    sys.exit(0)

if paths is None:
    sys.stderr.write(
        'states no decision — needs "json_paths", '
        'or "json_check": "none" with a "reason"'
    )
    sys.exit(2)

clean_list("json_paths", paths)
if not paths:
    sys.stderr.write('"json_paths" must not be empty')
    sys.exit(2)
clean_list("allow_invalid_json", allow)

print("MODE\tcheck")
for item in paths:
    print("JPATH\t%s" % item)
for item in allow:
    print("ALLOW\t%s" % item)
PY

resolve_json_config() {
    local cfg out status=0
    cfg="$(git rev-parse --show-toplevel)/$CONFIG_FILE"

    if [[ ! -f "$cfg" ]]; then
        CFG_MODE="absent"
        return 0
    fi

    # A malformed config must REFUSE, never degrade to "treat as absent". That
    # fallback would rebuild precisely the fail-open this gate closes: a typo in
    # the config would silently disable the check it was written to enable.
    out="$(python3 -c "$CONFIG_PARSER" "$cfg" 2>&1)" || status=$?

    if (( status != 0 )); then
        fail "$EX_CONFIG" \
            "$CONFIG_FILE is INVALID — $out" \
            "" \
            "Refusing to run. A config that does not parse is not the same as no" \
            "config: treating it as absent would silently disable the very check" \
            "this file was written to enable."
    fi

    local key val
    while IFS=$'\t' read -r key val; do
        case "$key" in
            MODE)   CFG_MODE="$val" ;;
            REASON) CFG_REASON="$val" ;;
            JPATH)  CFG_PATHS+=("$val") ;;
            ALLOW)  CFG_ALLOW+=("$val") ;;
        esac
    done <<<"$out"

    # ------------------------------------------------------------------
    # THE ASYMMETRY, ENFORCED RATHER THAN INTENDED.
    # ON may come from anywhere: turning a check on needs no review.
    # OFF must come from a COMMITTED object, because the entire argument for
    # requiring a written reason was that a reason is reviewable in a diff
    # while an absence is not. An opt-out honoured from an untracked
    # working-tree file is not reviewable either -- it is an ad-hoc local
    # bypass wearing a config file's clothes, and it defeats the design.
    # So the OFF decision is re-read from HEAD and the working tree gets no
    # vote in it. A committed ON flipped to OFF locally is likewise refused.
    # ------------------------------------------------------------------
    [[ "$CFG_MODE" == "none" ]] || return 0

    local rel cmode cblob cout cstatus=0
    rel="$(git ls-files --full-name --error-unmatch -- "$cfg" 2>/dev/null)" || rel=""
    if [[ -z "$rel" ]]; then
        fail "$EX_CONFIG" \
            "OPT-OUT IS NOT REVIEWABLE — $CONFIG_FILE is not tracked in git" \
            "recorded reason was: ${CFG_REASON:-(none)}" \
            "" \
            "The opt-out requires a written reason precisely so it shows up in a" \
            "diff. An untracked file shows up in nobody's review, so honouring it" \
            "would rebuild the unreviewable bypass this design exists to prevent." \
            "" \
            "Commit $CONFIG_FILE, then run again. Turning the check ON needs no" \
            "commit; only turning it OFF does."
    fi

    # A COMMITTED SYMLINK (mode 120000) is a mutable target: the reviewed blob
    # is a path, and what it points at can change without any diff at all.
    cmode="$(git ls-tree HEAD -- "$rel" 2>/dev/null | awk '{print $1}')"
    if [[ -z "$cmode" ]]; then
        fail "$EX_CONFIG" \
            "OPT-OUT IS NOT REVIEWABLE — $CONFIG_FILE is staged but not yet in HEAD" \
            "recorded reason was: ${CFG_REASON:-(none)}" \
            "" \
            "A staged-but-uncommitted opt-out has not been through review either." \
            "Commit it first."
    fi
    if [[ "$cmode" == "120000" ]]; then
        fail "$EX_CONFIG" \
            "OPT-OUT IS NOT REVIEWABLE — $CONFIG_FILE is a committed SYMLINK" \
            "" \
            "The reviewed object would be a path, not a decision: what it points" \
            "at can be changed later without producing any diff. Commit the" \
            "config as a regular file."
    fi

    cblob="$(git show "HEAD:$rel" 2>/dev/null)" || cblob=""
    cout="$(printf '%s' "$cblob" | python3 -c "$CONFIG_PARSER" /dev/stdin 2>&1)" || cstatus=$?
    if (( cstatus != 0 )); then
        fail "$EX_CONFIG" \
            "COMMITTED $CONFIG_FILE is INVALID — $cout" \
            "" \
            "The working tree opts out, but the committed version does not parse," \
            "so there is no reviewable decision to honour."
    fi
    local cmode_val="" creason=""
    while IFS=$'\t' read -r key val; do
        case "$key" in
            MODE)   cmode_val="$val" ;;
            REASON) creason="$val" ;;
        esac
    done <<<"$cout"

    if [[ "$cmode_val" != "none" ]]; then
        fail "$EX_CONFIG" \
            "LOCAL-ONLY OPT-OUT — the working tree turns the JSON check OFF but" \
            "HEAD does not (committed decision: $cmode_val)" \
            "working-tree reason: ${CFG_REASON:-(none)}" \
            "" \
            "An uncommitted edit that disables a committed check is exactly the" \
            "unreviewable bypass this guard refuses. Commit the change if it is" \
            "real; revert it if it was a local convenience."
    fi

    # Print the COMMITTED reason, never the working tree's: the reason anyone
    # can actually review is the one in the object.
    CFG_REASON="$creason"
}

check_staged_json() {
    local -a checked=() skipped=()
    local f err
    local -a pathspec=()

    resolve_json_config

    if (( ${#JSON_PATHS[@]} == 0 )); then
        case "$CFG_MODE" in
            absent)
                fail "$EX_CONFIG" \
                    "NO JSON DECISION — no --json-path, and no $CONFIG_FILE" \
                    "" \
                    "This guard will not run without an explicit decision about" \
                    "staged-JSON validation. Create $CONFIG_FILE with EITHER:" \
                    "" \
                    '  {"json_paths": ["data/**/*.json"]}' \
                    "" \
                    "or, if this repo genuinely has no generated JSON:" \
                    "" \
                    '  {"json_check": "none", "reason": "<why>"}' \
                    "" \
                    "The reason is mandatory so the opt-out is recorded and" \
                    "reviewable in the diff, instead of being an absence nobody sees."
                ;;
            none)
                log "  --  JSON check OPTED OUT in $CONFIG_FILE — recorded reason: $CFG_REASON"
                return 0
                ;;
            check)
                JSON_PATHS=(${CFG_PATHS[@]+"${CFG_PATHS[@]}"})
                ALLOW_INVALID_JSON+=(${CFG_ALLOW[@]+"${CFG_ALLOW[@]}"})
                log "  ..  JSON paths from $CONFIG_FILE: ${JSON_PATHS[*]}"
                ;;
        esac
    else
        # An explicit --json-path turns the check ON, so it satisfies the decision
        # requirement by itself. If the config opted out, the nomination WINS and
        # says so loudly — resolving a contradiction toward more checking is the
        # only safe direction, but silently ignoring a committed opt-out would
        # hide a real disagreement.
        case "$CFG_MODE" in
            none)
                log "  !!  $CONFIG_FILE opts out of the JSON check, but --json-path was"
                log "  !!  given — honouring the nomination and checking anyway."
                ;;
            check)
                JSON_PATHS+=(${CFG_PATHS[@]+"${CFG_PATHS[@]}"})
                ALLOW_INVALID_JSON+=(${CFG_ALLOW[@]+"${CFG_ALLOW[@]}"})
                ;;
        esac
    fi

    # Normalize to :(glob) magic. WITHOUT it, git's default pathspec matching
    # makes 'data/**/*.json' require at least one intermediate directory, so it
    # matches data/sub/b.json but SILENTLY SKIPS data/a.json. The obvious
    # spelling would then deliver partial coverage with no warning — a
    # quiet-underscan, which is the same family of defect as a quiet pass.
    # Pathspecs that already carry explicit magic (leading ':') are left alone.
    for f in "${JSON_PATHS[@]}"; do
        [[ "$f" == :* ]] && pathspec+=("$f") || pathspec+=(":(glob)$f")
    done
    for f in ${ALLOW_INVALID_JSON[@]+"${ALLOW_INVALID_JSON[@]}"}; do
        [[ "$f" == :* ]] && pathspec+=("$f") || pathspec+=(":(exclude,glob)$f")
        skipped+=("$f")
    done

    local -a files=()
    staged_files_z files "${pathspec[@]}"

    for f in ${files[@]+"${files[@]}"}; do
        [[ "$f" == *.json ]] || continue

        if ! err="$(git show ":$f" | python3 -c '
import json, sys
try:
    json.load(sys.stdin)
except Exception as exc:
    sys.stderr.write(str(exc))
    sys.exit(1)
' 2>&1)"; then
            fail "$EX_JSON" \
                "staged JSON does not parse: $f" \
                "$err" \
                "" \
                "A serialization error from a generator is a STOP, not something to commit past."
        fi
        checked+=("$f")
    done

    for f in ${skipped[@]+"${skipped[@]}"}; do
        log "  --  JSON check EXEMPTED by --allow-invalid-json: $f"
    done
    if (( ${#checked[@]} == 0 )); then
        log "  --  no staged .json under ${JSON_PATHS[*]} — JSON check not applicable"
    else
        log "  ok  ${#checked[@]} staged .json file(s) under ${JSON_PATHS[*]} parse"
    fi
}

cmd_check_staged() {
    require_repo
    log "push-guard: checking staged content"

    check_unmerged

    # --no-renames is load-bearing, NOT cosmetic. Git detects renames by default
    # (diff.renames=true since 2.9), so a `git mv` plus a small edit is reported
    # as ONE 'R' entry — which --diff-filter=ACM DOES NOT MATCH. The renamed file
    # becomes invisible to every content check: staged conflict markers sail
    # through and the guard prints "staged content OK". Verified reproducible at
    # 97% similarity with an ordinary co-staged file present. --no-renames
    # decomposes each rename back into D + A so the new path is always seen.
    # (Adding R to the filter also works, but --name-only -z emits two paths for
    # a rename and is ambiguous to parse — this removes the category instead.)
    #
    # SECOND INSTANCE OF THE SAME DEFECT, found by sweeping for the construct
    # rather than fixing only the one that was reported. This one failed CLOSED
    # (zero files hit "NOTHING IS STAGED"), so it was never a security hole --
    # but it reported a confident WRONG DIAGNOSIS, sending anyone debugging it
    # at their index instead of at the failing git invocation.
    local -a files=()
    staged_files_z files
    if (( ${#files[@]} == 0 )); then
        fail "$EX_NULL" \
            "NOTHING IS STAGED" \
            "About to commit, but the index is identical to HEAD." \
            "" \
            "This is the null case: a commit here is empty, and any later" \
            "'verified' step would be asserting against content that does not exist."
    fi

    check_conflict_markers "${files[@]}"
    check_staged_json "${files[@]}"

    log "push-guard: staged content OK"
}

# ------------------------------------------------------------------------ push

remote_sha() {
    # Empty output means the branch does not exist on the remote yet.
    # A network/auth failure makes ls-remote non-zero, which set -e turns into an
    # abort — the guard never treats an unreachable remote as "unchanged".
    git ls-remote "$1" "refs/heads/$2" | awk 'NR==1{print $1}'
}

cmd_push() {
    require_repo
    local remote="" branch="" since_head=""
    local -a extra=()

    while (( $# )); do
        case "$1" in
            --remote) remote="${2:-}"; shift 2 ;;
            --branch) branch="${2:-}"; shift 2 ;;
            --since-head) since_head="${2:-}"; shift 2 ;;
            --) shift; extra=("$@"); break ;;
            *) fail "$EX_USAGE" "unknown argument to push: $1" ;;
        esac
    done
    [[ -n "$remote" && -n "$branch" ]] \
        || fail "$EX_USAGE" "push requires --remote and --branch"

    local head
    head="$(git rev-parse HEAD)"
    log "push-guard: verifying push of $head -> $remote/$branch"

    # (1) Did a new commit actually get created?
    if [[ -n "$since_head" ]]; then
        git rev-parse --verify --quiet "${since_head}^{commit}" >/dev/null \
            || fail "$EX_USAGE" \
                "--since-head is not a commit in this repository: $since_head" \
                "" \
                "Cannot verify advancement against a start point that does not exist."

        if [[ "$head" == "$since_head" ]]; then
            fail "$EX_NULL" \
                "HEAD DID NOT ADVANCE — no commit was created" \
                "HEAD is still $head" \
                "" \
                "Your commit step failed and execution continued on stale state." \
                "Pushing now would publish something you did not just build."
        fi
        # INEQUALITY IS NOT ADVANCEMENT. "different from where I started" is
        # satisfied by checking out any unrelated commit -- including one that
        # existed long before this session -- which would let pre-existing work
        # be published as something just built. The claim being made is that new
        # commits were created ON TOP OF the recorded start point, and only
        # ancestry states that.
        if ! git merge-base --is-ancestor "$since_head" "$head"; then
            fail "$EX_NULL" \
                "HEAD IS NOT A DESCENDANT of the recorded start point" \
                "start: $since_head" \
                "head : $head" \
                "" \
                "HEAD differs from the start point but does not build on it, so" \
                "this is a checkout of other history rather than work you just" \
                "created. Being different is not the same as having advanced."
        fi
        log "  ok  HEAD advanced ${since_head:0:9} -> ${head:0:9} (descendant)"
    fi

    # (2) Is that commit non-empty?
    # EXEMPTING BY PARENT COUNT WAS A FAIL-OPEN. An empty root commit sailed
    # through "emptiness check not applicable" and was then reported as a
    # CONFIRMED PUSH -- directly contradicting the non-empty requirement this
    # step exists to enforce. Root and merge commits do have well-defined
    # emptiness; the original code declined to define it, which is not the same
    # as it being undefined.
    local parents
    parents="$(git rev-list --parents -n 1 HEAD | wc -w)"
    if (( parents == 2 )); then   # sha + exactly one parent
        if git diff --quiet HEAD^ HEAD; then
            fail "$EX_NULL" \
                "HEAD IS AN EMPTY COMMIT — it changes nothing against its parent" \
                "commit $head" \
                "" \
                "An empty commit carrying a real message is indistinguishable" \
                "from real work in the log. Refusing to push it."
        fi
        log "  ok  HEAD is a non-empty commit"
    elif (( parents > 2 )); then
        # A merge is empty when its tree matches EVERY parent: it then carries
        # no content of its own and no integration either.
        local p all_same=yes
        for p in $(git rev-list --parents -n 1 HEAD | cut -d' ' -f2-); do
            git diff --quiet "$p" HEAD || { all_same=no; break; }
        done
        if [[ "$all_same" == yes ]]; then
            fail "$EX_NULL" \
                "HEAD IS AN EMPTY MERGE — its tree is identical to every parent" \
                "commit $head" \
                "" \
                "It integrates nothing and introduces nothing. Refusing to push it."
        fi
        log "  ok  HEAD is a non-empty merge commit"
    else
        # A root commit has no parent, so emptiness is measured against the
        # empty tree: it is empty when it adds no paths at all.
        if [[ -z "$(git diff-tree --root -r --name-only --no-commit-id HEAD)" ]]; then
            fail "$EX_NULL" \
                "HEAD IS AN EMPTY ROOT COMMIT — it introduces no files" \
                "commit $head" \
                "" \
                "A root commit is empty when it adds nothing against the empty" \
                "tree. Refusing to push it."
        fi
        log "  ok  HEAD is a non-empty root commit"
    fi

    # (3) Capture the remote BEFORE. This is the step whose absence made the
    #     original "PUSH VERIFIED" a false positive.
    local before after
    before="$(remote_sha "$remote" "$branch")"
    log "  ..  remote before: ${before:-<branch does not exist>}"

    git push "$remote" "HEAD:refs/heads/$branch" ${extra[@]+"${extra[@]}"}

    after="$(remote_sha "$remote" "$branch")"
    log "  ..  remote after:  ${after:-<absent>}"

    # (4) The remote must now equal local HEAD...
    if [[ "$after" != "$head" ]]; then
        fail "$EX_PUSH" \
            "REMOTE DOES NOT MATCH LOCAL HEAD after push" \
            "local HEAD:    $head" \
            "remote $branch: ${after:-<absent>}" \
            "" \
            "The push did not land what you are holding."
    fi
    # (5) ...AND it must have MOVED to get there. Without this, a push that
    #     transferred nothing passes step (4) trivially.
    if [[ "$after" == "$before" ]]; then
        fail "$EX_PUSH" \
            "REMOTE DID NOT MOVE — nothing was actually pushed" \
            "remote was already at $after before this push ran" \
            "" \
            "'remote == local' is satisfied by having pushed nothing. That is" \
            "the false positive this guard exists to catch: the work you think" \
            "you just published was already there, or was never committed."
    fi

    log "push-guard: PUSH CONFIRMED ${before:0:9}${before:+ }-> ${after:0:9}"
}

# ------------------------------------------------------------------------ main

ALLOW_INVALID_JSON=()
JSON_PATHS=()

CFG_MODE="absent"
CFG_REASON=""
CFG_PATHS=()
CFG_ALLOW=()

main() {
    (( $# )) || { usage; exit "$EX_USAGE"; }
    local sub="$1"; shift
    case "$sub" in
        check-staged)
            while (( $# )); do
                case "$1" in
                    --json-path)          JSON_PATHS+=("${2:-}"); shift 2 ;;
                    --allow-invalid-json) ALLOW_INVALID_JSON+=("${2:-}"); shift 2 ;;
                    *) fail "$EX_USAGE" "unknown argument to check-staged: $1" ;;
                esac
            done
            cmd_check_staged
            ;;
        push)        cmd_push "$@" ;;
        -h|--help)   usage ;;
        *)           usage; exit "$EX_USAGE" ;;
    esac
    # Explicit: the only way to reach here is with every requested check passed.
    exit "$EX_OK"
}

main "$@"
