#!/bin/sh # pre-push — refuse a push that carries a broken gate. (2026-08-02, R-29 leg (b) first half.) # # Runs this repo's ONE gate entry point in --fast mode: only checks that touch no network and no # container runtime, so a push stays a push and never pulls images or starts containers. The slow # gates stay deliberate periodic runs; a hook that takes minutes gets bypassed within a week and # the bypass becomes the habit. # # BOTH LINES BELOW ARE DELIBERATE. An absent log line is not evidence a hook ran — a silent pass is # equally consistent with "gates green" and "hook never fired", so a passing push says so out loud. # # HONEST LIMITS, stated so this is not mistaken for enforcement it cannot provide: # * per-clone — core.hooksPath is local config and a clone does not carry it. Arm a clone once: # git config core.hooksPath .githooks # Any manual entry-point run WARNS when the clone is unarmed. # * 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 ` `, one line per ref, # four whitespace-separated fields. Observed directly: # ordinary push refs/heads/master refs/heads/master # FIRST push refs/heads/master refs/heads/master 0000000000000000000000000000000000000000 # two refs two lines, one per ref # deletion (delete) 0000000000000000000000000000000000000000 refs/heads/side # 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) || { echo "pre-push: FAIL - cannot resolve the repo root (git rev-parse --show-toplevel)." >&2 exit 1 } cd "$root" || exit 1 # ── WORKSPACE-ROOT ASSERTION (2026-08-05, R-204 rider) ─────────────────────────────────────────── # Refuse a push from a clone outside the felhom workspace. # # WHY THIS IS A HOOK AND NOT A LINE IN A DOCUMENT: the workspace root is ALREADY written down, in # documentation/runbooks/workspace-CLAUDE.md and in the workspace-root CLAUDE.md ("stay inside it"), # and work drifted into a home directory anyway. A rule that has failed once as a reminder is not # fixed by writing it down again — it has to be asserted where it can bite. # # A PUSH IS THE RIGHT TRIGGER, deliberately: throwaway clones under /tmp for probes and red-proofs # never push, so nothing legitimate breaks. Reads and builds elsewhere stay unaffected. # # Symlinks are resolved on BOTH sides before comparison, so a symlinked path neither falsely passes # nor falsely fails. If the workspace root does not exist on this machine the check is SKIPPED, not # failed — this hook must not brick a legitimate clone on a different host. # # The only bypass is the documented `git push --no-verify`, whose use is already reportable. FELHOM_WORKSPACE_ROOT=/mnt/5_hdd/felhom.eu if [ -d "$FELHOM_WORKSPACE_ROOT" ]; then ws_real=$(cd "$FELHOM_WORKSPACE_ROOT" 2>/dev/null && pwd -P) || ws_real="" root_real=$(pwd -P) || root_real="" if [ -n "$ws_real" ] && [ -n "$root_real" ]; then case "$root_real/" in "$ws_real"/*) : ;; # inside the workspace — proceed *) echo "pre-push: PUSH REFUSED - this clone is OUTSIDE the felhom workspace." >&2 echo " clone: $root_real" >&2 echo " expected: under $ws_real (repos live in $ws_real/git/)" >&2 echo " Work in the workspace clone, or bypass with 'git push --no-verify'" >&2 echo " and state that you did in the session report." >&2 exit 1 ;; esac fi fi if ! command -v python3 >/dev/null 2>&1; then echo "pre-push: FAIL - python3 not found, so the gates CANNOT run. This is a failure, never a" >&2 echo " pass by default. Install python3, or push with --no-verify and say so." >&2 exit 1 fi # ── 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 echo " 'git push --no-verify' and state that you did in the session report." >&2 else echo "pre-push [felhom.eu]: gates OK - push proceeding." fi exit $rc