feat(git): add push-guard.sh
Mechanical closure of 'a verification that passes when the thing it verifies never happened.'
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
#!/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
|
||||
# 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_USAGE=64
|
||||
|
||||
# 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 --json-path parses
|
||||
Refuses to pass when nothing is staged (exit 5).
|
||||
Check (c) is opt-in by path — see --json-path.
|
||||
|
||||
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. With no
|
||||
--json-path the check is announced as skipped.
|
||||
--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.
|
||||
|
||||
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
|
||||
|
||||
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). When no
|
||||
# path is nominated the check says so on every run rather than contributing a
|
||||
# silent green. That is an accepted, stated limitation: a repo that needs the
|
||||
# check and never wires it up is unprotected, and the printed line is the only
|
||||
# thing standing between that state and going unnoticed.
|
||||
check_staged_json() {
|
||||
local -a checked=() skipped=()
|
||||
local f err
|
||||
local -a pathspec=()
|
||||
|
||||
if (( ${#JSON_PATHS[@]} == 0 )); then
|
||||
log " -- JSON check NOT REQUESTED (no --json-path) — no JSON was validated"
|
||||
return 0
|
||||
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=()
|
||||
mapfile -d '' -t files < <(
|
||||
git diff --cached --name-only --diff-filter=ACM --no-renames -z \
|
||||
-- "${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.)
|
||||
mapfile -d '' -t files < <(
|
||||
git diff --cached --name-only --diff-filter=ACM --no-renames -z
|
||||
)
|
||||
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
|
||||
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
|
||||
log " ok HEAD advanced ${since_head:0:9} -> ${head:0:9}"
|
||||
fi
|
||||
|
||||
# (2) Is that commit non-empty?
|
||||
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
|
||||
log " -- HEAD is a merge commit — emptiness check not applicable"
|
||||
else
|
||||
log " -- HEAD is a root commit — emptiness check not applicable"
|
||||
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=()
|
||||
|
||||
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 "$@"
|
||||
Reference in New Issue
Block a user