Skip to content

ops: a hook that cannot be forgotten, and a readback that cannot be skipped (C-340, #426) - #437

Merged
Polichinel merged 1 commit into
developmentfrom
ops/pre-push-hook-and-automerge
Aug 11, 2026
Merged

ops: a hook that cannot be forgotten, and a readback that cannot be skipped (C-340, #426)#437
Polichinel merged 1 commit into
developmentfrom
ops/pre-push-hook-and-automerge

Conversation

@Polichinel

Copy link
Copy Markdown
Contributor

Closes #426. Part of #421, Story 5.

Two mechanisms, two different fixes, because they fail differently.

Mechanism 2 — a git hook

#416 merged the instant CI went green. A follow-up commit was pushed to that branch, which by then had no open PR, and two pieces of work were simply not on development. git push reported success; the only signal was a PR showing one commit when two had been pushed.

scripts/git-hooks/pre-push refuses that push, names the PR and when it merged, and says what to do instead.

It is a hook rather than a checklist item deliberately. Everything else in this epic runs whether or not anyone remembers. A step someone has to recall would be the same class of defect the epic exists to remove.

It allows the push whenever it cannot answergh absent, unauthenticated, offline — and says why. A hook that blocks work when it does not know gets uninstalled within a day, and then it guards nothing. Same idiom as the deploy gates (C-320).

Mechanism 1 — a readback

gh pr merge --auto --<method> on an already-armed PR silently refuses to change the method: prints nothing, exits 0. scripts/arm_automerge.sh uses the GraphQL disable/enable pair, which does honour it, then reads auto_merge.mergeMethod back and exits non-zero on mismatch. Reading back is the point; arming is the easy part.

Drills — all against the real repository

merged PR (#436)          REFUSE exit 1   "PR #436 merged at 2026-08-11T14:56:42Z"
branch with no PR         ALLOW  exit 0
development               ALLOW  exit 0
gh absent from PATH       ALLOW  exit 0   "gh not installed — allowing"
gh unauthenticated        ALLOW  exit 0   "gh could not answer (auth or network) — allowing"
arm on a MERGED pr        exit 1          refuses to mutate
arm with bad arguments    exit 2

The gh-absent drill was wrong first. PATH=/usr/bin:/bin still found gh at ~/.local/bin, so it tested nothing and reported a refusal as though that were the expected result. Redone with a sandbox PATH containing git and bash but not gh, with the absence verified before the run. Sixth time this week a drill's setup silently did not happen.

Mechanism 1's decisive drill — done on this PR

Per #426 this is the most important one: arm one method, ask for another, confirm the readback catches what gh pr merge --auto silently would not. Recorded in a comment below, on this PR itself.

Guard

tests/test_git_hooks.py covers the three things that fail silently:

  • the executable bit — git ignores a non-executable hook with no error and no warning; the protection just stops
  • the fail-open paths — if those are ever removed the hook starts blocking work and gets uninstalled
  • the documented install stepcore.hooksPath is per-clone config git does not version, so an undocumented hook is a file nobody installs

Drilled by breaking each: three mutations, three catches, baseline green.

Residue — C-340 will be narrowed, not closed

As #426 predicted. The hook covers mechanism 2 completely. Mechanism 1 now has a helper that works, but nothing forces its use — gh pr merge --auto is still one keystroke away, and "read the value back rather than trusting the command" is a general habit no hook enforces. Story 7 (#428) should say that plainly rather than claim a clean resolution.

Verification

  • bash -n on both scripts → OK (shellcheck not installed here)
  • ruff check . → All checks passed
  • pytestPYTEST_EXIT=0, FAILED: 0
  • docs/validate_docs.sh → PASSED

🤖 Generated with Claude Code

…kipped (C-340, #426)

Two mechanisms, two different fixes, because they fail differently.

MECHANISM 2 gets a git hook. #416 merged the instant CI went green; a
follow-up commit was pushed to that branch, which by then had no open PR,
and two pieces of work were simply not on development. `git push`
reported success. scripts/git-hooks/pre-push refuses that push, names the
PR and when it merged, and says what to do instead.

It is a hook rather than a checklist item deliberately. Everything else
in this epic runs whether or not anyone remembers; a step someone has to
recall would be the same class of defect the epic exists to remove.

It ALLOWS the push whenever it cannot answer — gh absent, unauthenticated,
offline — and says why. A hook that blocks work when it does not know gets
uninstalled within a day and then guards nothing. Same idiom as the deploy
gates (C-320): skip where the environment cannot answer.

MECHANISM 1 gets a helper that reads the value back.
`gh pr merge --auto --<method>` on an already-armed PR silently refuses to
change the method — prints nothing, exits 0. scripts/arm_automerge.sh uses
the GraphQL disable/enable pair, which does honour it, then reads
auto_merge.merge_method back and exits non-zero on mismatch. Reading back
is the point; arming is the easy part.

Seven drills, all against the real repository:

  merged PR (#436)          REFUSE exit 1, names the PR and merge time
  branch with no PR         ALLOW  exit 0
  development               ALLOW  exit 0
  gh absent from PATH       ALLOW  exit 0 with the reason
  gh unauthenticated        ALLOW  exit 0 with the reason
  arm on a MERGED pr        exit 1, refuses to mutate
  arm with bad arguments    exit 2

The gh-absent drill was wrong first: PATH=/usr/bin:/bin still found gh at
~/.local/bin, so it tested nothing. Redone with a sandbox PATH containing
git and bash but not gh, with the absence verified before the run.

tests/test_git_hooks.py guards the three things that fail silently: the
executable bit (git ignores a non-executable hook with no warning), the
fail-open paths, and the documented install step. Drilled by breaking
each — three mutations, three catches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Polichinel
Polichinel enabled auto-merge August 11, 2026 15:11
@Polichinel
Polichinel disabled auto-merge August 11, 2026 15:11
@Polichinel
Polichinel enabled auto-merge (squash) August 11, 2026 15:11
@Polichinel

Copy link
Copy Markdown
Contributor Author

Mechanism 1, reproduced live on this PR

#426 called this the single most important drill in the story. Run against this pull request:

step 1: arm MERGE the ordinary way
  armed: MERGE

step 2: ask gh for SQUASH on the already-armed PR
  gh exit=0
  method now: MERGE          <- unchanged. No output, no error, exit 0.

step 3: the same request through the script
  PR #437: currently armed=MERGE, requested=SQUASH
  verified: PR #437 auto-merge armed SQUASH
  script exit=0
  method now: SQUASH

gh pr merge --auto --squash exited 0 and changed nothing. That is the defect exactly as C-340 describes it — and it is why the v1.10.0 promotion stayed armed squash after being re-armed --merge. A squash onto main rewrites the release SHAs and permanently breaks the ancestry the back-merge maintains; it would not have surfaced until a later release diffed against a base that never existed.

The GraphQL disable/enable pair does change it, and the script then reads the value back rather than trusting the call.

Worth being precise about what this drill does and does not show: it demonstrates the silent no-op and the fix. It does not demonstrate the mismatch branch of the script — the path that exits non-zero when the readback disagrees — because that path only fires if GitHub accepts the mutation and then reports something else, which cannot be produced on demand. That branch is exercised by inspection only, and this comment says so rather than implying full coverage.

@Polichinel
Polichinel merged commit 77281c8 into development Aug 11, 2026
5 checks passed
@Polichinel
Polichinel deleted the ops/pre-push-hook-and-automerge branch August 11, 2026 15:25
Polichinel added a commit that referenced this pull request Aug 12, 2026
…340) (#441)

`development` has been carrying a pre-push hook that does not work, and
`publishing_to_pypi.md` told you to install it — twice. Anyone running
`git config core.hooksPath scripts/git-hooks` got a hook that checks the
WRONG BRANCH (it read `git rev-parse HEAD` instead of the refs git
supplies on stdin) and permanently refuses branch names reused from old
pull requests. That is a trap, and it shipped in #437.

Four hook versions and three detector versions were written to close
C-340 mechanism 2; each was defeated by a different property of git, gh
or GitHub. The harm being guarded against is two orphaned commits in
~440 pull requests, both recovered by cherry-pick. The guards cost far
more than the failures, so there is no guard — and the guide now says so
plainly instead of pointing at one.

  removed  scripts/git-hooks/pre-push
  removed  tests/test_git_hooks.py  (vacuous: reconstructing the broken
           v1 hook passed all seven of its behavioural tests)
  guide    install instructions replaced by what to do if it happens

Mechanism 1 is unaffected: scripts/arm_automerge.sh stays, works, and
was drilled live.

Closes the last of epic #421, which is closed as a whole.

validate_docs PASSED | ruff clean | PYTEST_EXIT=0 | FAILED=0

Co-authored-by: Claude Opus 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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant