git-validator · branch-switch TOCTOU fix
The hook denied the exact workflow its own error message prescribes.
The bug — checked at PreToolUse time, true at commit time
session HEAD: main
command: git checkout -b feat/x -q && git commit -m "..." && git push
└────────┬────────┘
│ by the time git commit runs, HEAD is feat/x
hook: reads ctx.branch = "main" → BLOCKED
message: "Required workflow: 1. git checkout -b ..." ← the command DID that
Same class as #2363's worktree gap, on the branch axis instead of the directory axis. Two live false positives on 2026-08-02: the #3245 rebase retry and a branch+commit chain. The identical defect existed in the user-global block-direct-dev-main-push.sh and was fixed the same day with matching semantics.
The fix — judge the branch the mutation will run on
extractPreCommitSwitchTarget(cmd):
head = cmd up to the FIRST `git commit` / `git push`
return LAST `git checkout <b>` | `checkout -b/-B <b>` | `switch [-c] <b>` in head
(flags and file restores excluded)
if target exists and NOT protected → allow, with context
if target exists and protected → still block (checkout main && push)
if no switch in command → judge ctx.branch as before
The decision matrix (all 9 pinned as tests)
| command shape (session on main) | verdict |
|---|---|
| checkout -b feat/x && commit && push (incident 1) | ALLOW |
| checkout existing-branch && rebase && push (incident 2) | ALLOW |
| switch -c feat/y && commit | ALLOW |
| plain commit / plain push | BLOCK |
| checkout main && push (switch target protected) | BLOCK |
| checkout feat/x && checkout main && push (last switch wins) | BLOCK |
| checkout -- paths && commit (file restore ≠ switch) | BLOCK |
| checkout HEAD~1 -- file && commit | BLOCK |
| commit && checkout feat/x (switch AFTER mutation) | BLOCK |