fix(git): push-guard.sh — required repo config (#975) + heredoc-in-substitution warning
This commit is contained in:
@@ -32,6 +32,7 @@
|
||||
# 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.
|
||||
@@ -43,8 +44,13 @@ 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
|
||||
@@ -86,9 +92,10 @@ Usage:
|
||||
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
|
||||
(c) staged JSON under the nominated paths parses
|
||||
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:
|
||||
- HEAD is a non-empty commit (differs from its parent)
|
||||
@@ -103,8 +110,9 @@ Options:
|
||||
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.
|
||||
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.
|
||||
@@ -113,6 +121,16 @@ Options:
|
||||
--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
|
||||
}
|
||||
@@ -191,19 +209,197 @@ check_conflict_markers() {
|
||||
# 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.
|
||||
# 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"
|
||||
}
|
||||
|
||||
check_staged_json() {
|
||||
local -a checked=() skipped=()
|
||||
local f err
|
||||
local -a pathspec=()
|
||||
|
||||
resolve_json_config
|
||||
|
||||
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
|
||||
;;
|
||||
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
|
||||
@@ -391,6 +587,11 @@ cmd_push() {
|
||||
ALLOW_INVALID_JSON=()
|
||||
JSON_PATHS=()
|
||||
|
||||
CFG_MODE="absent"
|
||||
CFG_REASON=""
|
||||
CFG_PATHS=()
|
||||
CFG_ALLOW=()
|
||||
|
||||
main() {
|
||||
(( $# )) || { usage; exit "$EX_USAGE"; }
|
||||
local sub="$1"; shift
|
||||
|
||||
Reference in New Issue
Block a user