Skip to content

Pre-push registry gate: run the three registry tests on every pushed commit - #438

Merged
seathatflowsinourveins merged 1 commit into
mainfrom
claude/registry-prepush-gate-20260927
Sep 27, 2026
Merged

seathatflowsinourveins merged 1 commit into
mainfrom
claude/registry-prepush-gate-20260927

Conversation

@seathatflowsinourveins

@seathatflowsinourveins seathatflowsinourveins commented Sep 27, 2026 •

Copy link
Copy Markdown
Collaborator

Scope

How it works

  • Activation: the same core.hooksPath=scripts/git-hooks that enables the pre-commit gitleaks gate.
  • What it tests: the tip commit of each pushed ref, checked out into a detached worktree under $TMPDIR.
    • Uncommitted, untracked or ignored files therefore cannot change the result.
    • Like CI, which tests one commit per pull request, it does not test the commits between a ref's old and new tip.
    • A commit pushed to several refs is tested once.
    • Deletions are skipped and need nothing installed.
  • Fail-closed:
    • A failure, a skip or an expected failure refuses the push.
    • So does a tip that lacks the three tests: an old tag, a backup/* branch, a notes ref or an orphan branch.
    • When there is a commit to test, zizmor must be on PATH, because the workflow-coverage class skips without it.
  • Override: git push --no-verify, which is explicit and visible.
  • Scratch: $TMPDIR, or /var/tmp when it is unset.
    • Why /var/tmp: the checkout is about 140 MB, and file-hierarchy(7) keeps /tmp, usually a tmpfs, for small files.
    • The push is refused when git's own discovery finds a repository above the scratch directory. The GIT_DIR that git exports to hooks is dropped first.
    • An empty .git, such as a sandbox mount point, is not a repository to git, so it is not refused.
  • Cleanup: runs on exit. HUP, INT, QUIT, PIPE and TERM all exit through it.
    • Only a signal the hook cannot or does not trap, such as SIGKILL, can leave a pre-push.* directory. Delete that directory first, then run git worktree prune.
  • Import path: the test runner puts the checked-out tree first on sys.path. So PYTHONSAFEPATH combined with a PYTHONPATH naming another checkout cannot supply the tests.

SOTA sources

  • githooks(5) for git 2.43.0, pre-push section:
    • the stdin line format;
    • a non-zero exit aborts the push;
    • "Environment variables, such as GIT_DIR, GIT_WORK_TREE, etc., are exported" to hooks.
  • git's templates/hooks--pre-push.sample L25-29: the all-zero oid and the read loop.
  • git-rev-parse(1) --absolute-git-dir.
  • git(1):
    • GIT_DIR replaces discovery.
    • GIT_CEILING_DIRECTORIES stops discovery; the tests use it to be hermetic.
  • file-hierarchy(7) (systemd): /var/tmp/ is "the place for larger and persistent temporary files", and /tmp/ is for "small ephemeral files".
  • Autoconf lib/autoconf/general.m4 L1381-1384 at 0c777e326bd9 (autotools-mirror/autoconf): the exit trap 0, plus traps on signals 1, 2, 13 and 15.
  • python3 -h: -P/PYTHONSAFEPATH, "don't prepend a potentially unsafe path to sys.path".
  • Pattern: the repository's own scripts/git-hooks/pre-commit and tests/test_pre_commit_gate.py.
  • Rejected alternative: the pre-commit framework. It needs its own core.hooksPath ownership, which this repository already assigns.

Evidence-class table

Claim Evidence class Command / result
Behaviour in 16 cases, with nothing left behind, whatever lies above the temporary root local integration test python3 -m unittest tests.test_pre_push_gate passes with TMPDIR unset (/tmp, where this host has an empty /tmp/.git) and with TMPDIR=/var/tmp/claude-prepush: Ran 16 tests ... OK both times, 0 skipped
The repair fixes what the review found fail-first, local Old hook, new tests, TMPDIR outside /tmp: FAILED (failures=10) (per finding: Review round below). With TMPDIR unset, the old guard refused 18 cases on the host's empty /tmp/.git
SIGPIPE cleanup (finding 3) native direct run Old hook under ... | head -n 1 leaked /var/tmp/pre-push.A6g03f and its registered worktree, removed by literal path. New hook: exit 141, worktree count unchanged, nothing left
Each guarded line is needed local mutation check 20 named mutations on the final hook (sha256 398be311): 16 caught, each by its targeted test (M1 read loop, M2 status overwrite, M3 expected failure, M4 checkout hooks, M5 sys.path, M6 QUIT, M7 PIPE, M8 GIT_DIR, M9 zizmor order, M10 /tmp default, M11 no guard, M12 skip, M13 HUP, M14 INT, M15 TERM, M16 no EXIT trap). M17-M20 are not caught (residual risks below)
POSIX shells local Whole module with the hook run by bash 3.2.57 invoked as sh (macOS's /bin/sh line, built from the GNU tarball, sha256-checked) and by bash --posix: Ran 16 tests ... OK each; dash is /bin/sh here
The hook gates a real push from a linked worktree native git push This branch's own push: pre-push: running the registry tests on 2113905d… in /var/tmp/pre-push.BABbAH/tree / Ran 3 tests / OK / (forced update). Nothing was left
A real push is refused, with git's exported GIT_DIR native git push, in a test test_a_native_push_from_a_linked_worktree_is_gated: the probe commit is refused, and the bare remote has no such ref
Registry tests and repository validation local 57 tests OK; scripts/validate.py passed after re-registration per docs/lanes.md
Lint local shellcheck 0.11.0 -s sh reports 4 findings, all present in the previous head and none a defect. SC2021 is copied verbatim from git's sample. SC2034 (×2) is the unused fields of githooks(5)'s line format. SC2329 is cleanup, which the trap calls

Review round (review-438; one review, one repair)

# Finding Disposition
1 The tests were not hermetic: under /tmp, with this host's /tmp/.git, 3 tests failed Confirmed: env -u TMPDIR gave 3 failures. Fixed: each hook run sets GIT_CEILING_DIRECTORIES above its temporary root, and the guard uses git's discovery (finding 2). Now 16 OK with TMPDIR unset
2 The guard took any .git as a repository; the message was inaccurate; a TMPDIR under /tmp was refused Fixed: git rev-parse --absolute-git-dir, with GIT_DIR, GIT_WORK_TREE and GIT_COMMON_DIR dropped first, and the message names the git directory. Native probe: an empty .git gives "not a git repository" (rc 128); a real repository above is found; an exported GIT_DIR bypasses discovery unless dropped
3 SIGPIPE and SIGQUIT skipped cleanup; the body's prune line was wrong Confirmed natively (the leak above). Fixed: QUIT and PIPE traps; cleanup ignores SIGPIPE and errors; the docs and body now say delete the directory, then prune
4 "each pushed commit" wording Fixed in the hook, step 5c, the anti-pattern row, the role-table cell, the commit message and this body: "the tip commit of each pushed ref"
5 zizmor was checked before stdin Fixed: stdin is parsed first, and a deletion needs no zizmor (tested)
6 Base commit and file count in the body Fixed: base bfdc99cb, 6 files, no generated reports
7a Refusal of old tags, backup/*, notes and orphans was undocumented Fixed in step 5c
7b The macOS python3 3.9 trap Fixed in step 5c: 3.9.6 on CI's macos-15, from that job's own log
7c The builder row was named as an enforcement point Fixed: the anti-pattern row now says the hook is the only mechanical check
8 PYTHONSAFEPATH Fixed: the runner inserts the tree at sys.path[0]; covered by the decoy test
Gaps Untested paths Added tests: expected failure, a last line without a newline, mixed results, HUP/INT/QUIT/TERM/PIPE cleanup, core.hooksPath=/dev/null, a native push with GIT_DIR, and cleanup on the unset-TMPDIR path (behavioural; it replaces the source-text check). Still untested: the testsRun == len(names) check (M19) and the TMPDIR redirect (M20)

Adoption and residual risks

  • Who it affects: every session on every host, once its branch contains this hook.
    • A push that tests a commit needs zizmor 1.30.1 on PATH and python3 3.11 or later first on PATH.
    • macOS's /usr/bin/python3 is 3.9, so a newer python3 must come first.
  • Cost: about 1.5 s per distinct tip commit, measured on WSL2.
  • What is tested: the pushed tips, while PR CI tests the merge commit. CI remains the full gate.
  • Not caught by any test (defensive lines):
    • M17: cleanup's trap '' PIPE
    • M18: cleanup's set +e
    • M19: the testsRun == len(names) check
    • M20: the tests' TMPDIR redirect
  • SIGKILL: an untrapped signal can still leave a pre-push.* directory under $TMPDIR. Delete it first, then run git worktree prune.
  • macOS CI: the macOS job installs no zizmor, so it runs only the three cases that need none. For the other cases, the bash 3.2 evidence is the local bash 3.2.57 run above.
  • zizmor version: only presence is checked, not the version.

Status

The review round is complete. Merge with gh pr merge 438 --squash --match-head-commit 2113905d… once CI is green, then announce the merge time to peers, since their next pushes will run the hook.

🤖 Generated with Claude Code

@seathatflowsinourveins seathatflowsinourveins added the lane:foundation Foundation lane: Claude/Codex setup, hosts, memory, RAG, research, workers label Sep 27, 2026
…ef's tip

On 2026-09-27 six PRs (#416, #425-#429) failed CI's validate job on three
registry checks that run in about a second locally:
- the OSV lockfile inventory;
- the blind-checkout label classification;
- the new-workflow security coverage.

scripts/git-hooks/pre-push runs those three test methods against the tip
commit of each pushed ref, in a detached worktree of exactly that commit, and
refuses the push unless all three run and pass. Like CI, which tests one
commit per pull request, it does not test the commits between a ref's old and
new tip. A skip or an expected failure counts as a failure, so the hook
refuses while zizmor is missing and there is a commit to test.
`git push --no-verify` is the visible override. The same core.hooksPath that
enables the pre-commit gitleaks gate activates it.

Scratch and cleanup:
- The scratch base defaults to /var/tmp. The checkout is about 140 MB, and
  file-hierarchy(7) keeps /tmp, usually a tmpfs, for small files.
- The inside-a-repository guard asks git's own discovery, after dropping the
  GIT_DIR that git exports to hooks. An empty .git mount point, as a sandbox
  leaves in /tmp on the WSL host, is not a repository, so it is not refused.
- HUP, INT, QUIT, PIPE and TERM exit through the EXIT trap, as Autoconf's
  configure traps 1, 2, 13 and 15. A reader that closes early
  (`git push 2>&1 | head -n 1`) no longer leaves a worktree behind.
- The runner puts the checked-out tree first on sys.path, so PYTHONSAFEPATH
  plus a PYTHONPATH naming another checkout cannot supply the tests.
- stdin is parsed before the zizmor check, so a deletion needs no zizmor.

Sources:
- githooks(5) for git 2.43.0: pre-push, and the exported GIT_DIR.
- git's templates/hooks--pre-push.sample: the all-zero oid and the read loop.
- git-rev-parse(1) --absolute-git-dir, and git(1) GIT_DIR and
  GIT_CEILING_DIRECTORIES.
- file-hierarchy(7).
- Autoconf lib/autoconf/general.m4 at 0c777e326bd9: traps 1, 2, 13 and 15.
- python3 -P and PYTHONSAFEPATH.
- The repository's pre-commit gate and its test, as the pattern.

tests/test_pre_push_gate.py has 16 tests, run on a shared clone with
plumbing-built commits. Each run sets GIT_CEILING_DIRECTORIES above its
temporary root, and one test pushes natively from a linked worktree to a
shared bare clone. Each test checks that no worktree or scratch is left behind.
Of twenty named mutations of the hook, the tests catch sixteen; the PR lists
the four defensive lines they miss.

Docs: docs/secret-storage.md step 5c (behaviour, prerequisites, override and
cleanup), docs/harness-defaults.md (an anti-pattern row), and the builder
row's Use cell in examples/claude-native/workflows/README.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lane:foundation Foundation lane: Claude/Codex setup, hosts, memory, RAG, research, workers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant