R-404: block the push that can create the golden debt, notify the one that cannot
push_scope.py classifies a push as code or documents from an ALLOW-LIST of document paths - everything else, including any new top-level directory, is code. Every uncertainty (first push, force-push, merge commit, empty range, unreadable stdin) answers code: guessing 'documents' would hand out the exemption by accident. repo_gates.py gains a fifth GATES field and --scope=code|docs. On a documents-only push a golden-currency CONVICTION prints as ADVISORY in its own block and does not refuse; every other gate still refuses every push, and golden-currency still refuses a push touching code. The gate itself is UNCHANGED - its verdict, exit codes and wording are byte-identical. What changed is who is refused. Measured on git 2.47.3: a pre-push hook receives <local ref> <local sha> <remote ref> <remote sha> on stdin, one line per ref; a first push carries an all-zero remote sha and a deletion an all-zero local sha. Both land on code.
This commit is contained in:
+48
-2
@@ -16,11 +16,35 @@
|
||||
# * skippable — `git push --no-verify` bypasses this entirely. That is on purpose: an escape
|
||||
# hatch that cannot be reached is one that gets removed the first time it is
|
||||
# inconvenient. USING IT MUST BE STATED IN THE SESSION REPORT.
|
||||
# * ONE GATE IS ADVISORY ON A DOCUMENTS-ONLY PUSH (R-404, 2026-09-01) — golden-currency, and
|
||||
# only it. On a push whose whole range touches documents, the register, STATUS,
|
||||
# reports or drill evidence, a golden-currency CONVICTION is printed loudly as
|
||||
# ADVISORY and does not refuse the push. Every other gate still refuses every
|
||||
# push, and golden-currency still refuses a push that touches code.
|
||||
# WHY: the gate never looks at the push — it compares the controller's newest
|
||||
# CHANGELOG heading against this repo's bake evidence, so it returns the same
|
||||
# verdict whatever you are pushing. The controller's code is in one repo and its
|
||||
# register lives here, so EVERY controller change produces a documents-only push
|
||||
# here; and the push that PAYS the debt (a bake record under documentation/tests/)
|
||||
# is itself documents-only, so blocking here blocked the cure. `--no-verify` had
|
||||
# been used thirteen times, each with a recorded reason. This removes the reason,
|
||||
# not the hatch.
|
||||
# The scope is decided by scripts/push_scope.py, which fails closed to `code`.
|
||||
# The half that is neither per-clone nor skippable is CI — felhom.eu OPEN-ITEMS.md R-168.
|
||||
#
|
||||
# Measured 2026-08-02 (git 2.47.3): a relative core.hooksPath resolves correctly and the hook's cwd
|
||||
# is the repo root whether `git push` is issued from the root or from any subdirectory. The
|
||||
# explicit rev-parse below does not depend on that.
|
||||
#
|
||||
# Measured 2026-09-01 (git 2.47.3, throwaway local remote, probe removed): git hands this hook its
|
||||
# ref updates on STDIN as `<local ref> <local sha> <remote ref> <remote sha>`, one line per ref,
|
||||
# four whitespace-separated fields. Observed directly:
|
||||
# ordinary push refs/heads/master <new> refs/heads/master <old>
|
||||
# FIRST push refs/heads/master <new> refs/heads/master 0000000000000000000000000000000000000000
|
||||
# two refs two lines, one per ref
|
||||
# deletion (delete) 0000000000000000000000000000000000000000 refs/heads/side <old>
|
||||
# The all-zero cases are exactly why the classifier fails closed: a first push has no range to diff
|
||||
# and a deletion has no content, so neither can be exempted.
|
||||
set -u
|
||||
|
||||
root=$(git rev-parse --show-toplevel 2>/dev/null) || {
|
||||
@@ -70,8 +94,30 @@ if ! command -v python3 >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pre-push [felhom.eu]: running scripts/repo_gates.py --fast ..."
|
||||
python3 "scripts/repo_gates.py" --fast
|
||||
# ── SCOPE (R-404) ────────────────────────────────────────────────────────────────────────────────
|
||||
# Read git's ref updates from stdin and ask the classifier what kind of push this is. EVERY failure
|
||||
# path here answers `code`, which is today's behaviour — this can make the hook stricter than
|
||||
# intended, never looser. The classifier prints its reasoning on stderr, so a surprising verdict is
|
||||
# arguable rather than mysterious.
|
||||
#
|
||||
# STDIN IS CONSUMED EXACTLY ONCE, here, into a variable. A second reader would get nothing and the
|
||||
# classifier would answer `code` for a reason that has nothing to do with the push.
|
||||
refs=$(cat)
|
||||
|
||||
scope=code
|
||||
if [ ! -f "scripts/push_scope.py" ]; then
|
||||
echo "pre-push [felhom.eu]: scripts/push_scope.py is ABSENT - treating this push as CODE." >&2
|
||||
else
|
||||
# stdout is the verdict word; the classifier's reasoning goes to stderr and is left visible on
|
||||
# purpose, so a surprising verdict can be argued with instead of guessed at.
|
||||
scope=$(printf '%s\n' "$refs" | python3 "scripts/push_scope.py" --prepush-stdin) || scope=code
|
||||
fi
|
||||
# Anything that is not exactly "docs" takes the strict path. This is the fail-closed hinge: an empty
|
||||
# variable, a crashed classifier, a typo and an unexpected word all land on `code`.
|
||||
[ "$scope" = "docs" ] || scope=code
|
||||
|
||||
echo "pre-push [felhom.eu]: running scripts/repo_gates.py --fast --scope=$scope ..."
|
||||
python3 "scripts/repo_gates.py" --fast --scope="$scope"
|
||||
rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "pre-push [felhom.eu]: PUSH REFUSED - gates exited $rc. Fix the finding above, or bypass with" >&2
|
||||
|
||||
Reference in New Issue
Block a user