#!/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 ]... [--allow-invalid-json ]... push-guard.sh push --remote --branch [--since-head ] [-- ...] 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 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 Exempt a path from the JSON parse check (for deliberate malformed-input fixtures). Exemptions are printed, never silent. --since-head HEAD before your commit step. Asserts a new commit object was actually created. --remote Remote to push to. --branch 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 [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": ""}' \ "" \ "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:-}" git push "$remote" "HEAD:refs/heads/$branch" ${extra[@]+"${extra[@]}"} after="$(remote_sha "$remote" "$branch")" log " .. remote after: ${after:-}" # (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:-}" \ "" \ "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 "$@"