fix(git): push-guard.sh — required repo config (#975) + heredoc-in-substitution warning

This commit is contained in:
installer-7
2026-07-31 00:22:43 +00:00
committed by mos-claude
parent 63c5d2056f
commit 1036449b57
+211 -10
View File
@@ -32,6 +32,7 @@
# 3 staged JSON does not parse # 3 staged JSON does not parse
# 4 push verification failed (remote did not move, or moved elsewhere) # 4 push verification failed (remote did not move, or moved elsewhere)
# 5 nothing staged / empty commit — the null case, refused # 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 # 64 usage error
# #
# Dependencies: bash 4.4+, git, python3. # Dependencies: bash 4.4+, git, python3.
@@ -43,8 +44,13 @@ readonly EX_CONFLICT=2
readonly EX_JSON=3 readonly EX_JSON=3
readonly EX_PUSH=4 readonly EX_PUSH=4
readonly EX_NULL=5 readonly EX_NULL=5
readonly EX_CONFIG=6
readonly EX_USAGE=64 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. # Conflict-marker detection.
# #
# We match `<<<<<<<`, `>>>>>>>` and the diff3 `|||||||` marker, each anchored at # We match `<<<<<<<`, `>>>>>>>` and the diff3 `|||||||` marker, each anchored at
@@ -86,9 +92,10 @@ Usage:
Subcommands: Subcommands:
check-staged Run pre-commit content guards against the STAGED index: check-staged Run pre-commit content guards against the STAGED index:
(a) no unmerged index paths, no conflict markers (a) no unmerged index paths, no conflict markers
(c) staged JSON under --json-path parses (c) staged JSON under the nominated paths parses
Refuses to pass when nothing is staged (exit 5). Refuses to pass when nothing is staged (exit 5).
Check (c) is opt-in by path — see --json-path. 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: push Perform a push and prove it HAPPENED:
- HEAD is a non-empty commit (differs from its parent) - HEAD is a non-empty commit (differs from its parent)
@@ -103,8 +110,9 @@ Options:
parse-check, e.g. 'data/**/*.json'. Repeatable. parse-check, e.g. 'data/**/*.json'. Repeatable.
Opt-in on purpose: many real .json files are Opt-in on purpose: many real .json files are
JSONC (tsconfig) or templates and do NOT parse JSONC (tsconfig) or templates and do NOT parse
strictly. Nominate the generated ones. With no strictly. Nominate the generated ones. Satisfies
--json-path the check is announced as skipped. the decision requirement on its own; overrides a
config opt-out (loudly).
--allow-invalid-json <pathspec> Exempt a path from the JSON parse check --allow-invalid-json <pathspec> Exempt a path from the JSON parse check
(for deliberate malformed-input fixtures). (for deliberate malformed-input fixtures).
Exemptions are printed, never silent. Exemptions are printed, never silent.
@@ -113,6 +121,16 @@ Options:
--remote <name> Remote to push to. --remote <name> Remote to push to.
--branch <name> Branch to push. --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. There is no flag to downgrade a failure to a warning. That is intentional.
EOF EOF
} }
@@ -191,19 +209,197 @@ check_conflict_markers() {
# worse than no guard, because the bypass then also covers the true positives. # 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 # So the caller nominates the generated/serialized paths this check is FOR, as
# git pathspecs (git, not bash, expands them — '**' works correctly). When no # git pathspecs (git, not bash, expands them — '**' works correctly).
# 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 # WHAT SAYING NOTHING NOW COSTS YOU
# check and never wires it up is unprotected, and the printed line is the only # ---------------------------------
# thing standing between that state and going unnoticed. # 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"
}
check_staged_json() { check_staged_json() {
local -a checked=() skipped=() local -a checked=() skipped=()
local f err local f err
local -a pathspec=() local -a pathspec=()
resolve_json_config
if (( ${#JSON_PATHS[@]} == 0 )); then if (( ${#JSON_PATHS[@]} == 0 )); then
log " -- JSON check NOT REQUESTED (no --json-path) — no JSON was validated" 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 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 fi
# Normalize to :(glob) magic. WITHOUT it, git's default pathspec matching # Normalize to :(glob) magic. WITHOUT it, git's default pathspec matching
@@ -391,6 +587,11 @@ cmd_push() {
ALLOW_INVALID_JSON=() ALLOW_INVALID_JSON=()
JSON_PATHS=() JSON_PATHS=()
CFG_MODE="absent"
CFG_REASON=""
CFG_PATHS=()
CFG_ALLOW=()
main() { main() {
(( $# )) || { usage; exit "$EX_USAGE"; } (( $# )) || { usage; exit "$EX_USAGE"; }
local sub="$1"; shift local sub="$1"; shift