diff --git a/packages/mosaic/framework/tools/git/push-guard.sh b/packages/mosaic/framework/tools/git/push-guard.sh new file mode 100644 index 00000000..bfa66fe9 --- /dev/null +++ b/packages/mosaic/framework/tools/git/push-guard.sh @@ -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 ]... + [--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 --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 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 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. + +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:-}" + + 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=() + +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 "$@"