One repository. Several coding agents. One branch that has to stay releasable.
That is the whole setup, and it is stranger than it reads. The agents run in parallel worktrees — one per pull request — and each can commit, sign, push, rebase, and, if it decides the local checks are in the way, skip them. They are fast. They are mostly right. And “mostly” is the whole problem: the failure you design for is not the agent that crashes, it is the one that is confidently wrong at 3 a.m. with nobody watching.
So you reach for git hooks
. Pre-commit lints. Pre-push runs the build. Commit-msg enforces the format. For a while it feels like governance. It isn’t. It is advice — and anything that can run git can decline the advice.
The hook you can skip is not a gate
Here is the honest description of a git hook: a script the client runs on your behalf, when it feels like it. --no-verify skips pre-commit and commit-msg in one flag. LEFTHOOK_EXCLUDE=lint drops a single command. Delete .git/hooks/ and every hook is gone; the repository never notices. An agent told once that “the pre-push eval is slow” will find --no-verify on its own. A gate that the actor can wave away is not a gate — it is a suggestion with good intentions.
The solo constraints sharpen the edge. There is no second human to review: you cannot approve your own pull request, and there is nobody else to. The local loop has a latency budget — if the pre-commit sweep costs ten seconds, the agents route around it, and a check nobody runs protects nothing. And the thing under guard is a branch that deploys, so a single bad push is a production event, not a bruised ego. I have watched the exact failure: a load-bearing fix stranded on a branch swap, silently dropped, the next build quietly missing it. No error. Just gone.
The Y-statement I committed to:
In the context of a solo developer running several coding agents against one deployable branch, facing actors that can bypass any local check and no second human to catch them, we decided to split enforcement into advisory client-side hooks for latency and a binding server-side ruleset for integrity, to achieve a branch that stays releasable without slowing the inner loop, accepting that the hooks are theater unless the server enforces the same invariants underneath.
Two layers, two jobs
The split is not redundancy. It is division of labour: the hooks buy speed, the ruleset buys the guarantee. Neither does the other’s job, and confusing the two is how repositories end up “protected” by a script every contributor has already learned to skip.
Client side, the hooks are advice you actually want to take. lefthook
fans the pre-commit sweep out in parallel — conventional-commit shape, secret scan, formatter, the project’s own lint — and clears a 258-file tree in 0.19s, a staged one-to-three-file commit in sub-50ms. Fast enough that nobody resents it, which is the only reason it survives. A pre-push hook runs the expensive check — a cross-host eval, 5-8s warm, 30-60s after a dependency bump — and refuses the two moves that cost the most to undo: a push straight to main, and a force-push that is not a fast-forward descendant. When history does get rewritten, a post-commit hook has already mirrored the old tip to refs/backup/<host>/<branch>, and the reflog holds dropped commits for 180d. Advice, with a net under it.
Server side, the ruleset is the law. Not “should,” not “please” — the server refuses the push. A GitHub repository ruleset
encodes the invariants that cannot bend: required_status_checks with strict up-to-date, required_linear_history, required_signatures
, non_fast_forward and no deletion, review-thread resolution, and — the line that reads like a sentence — bypass_actors: []. Nobody bypasses. Admins included.
The fitness function is not the ruleset. It is the check that the ruleset is still on the books:
# fitness gate — the law must still be enforced, not merely written down.
# Run it in CI: a ruleset that quietly flips to disabled is a hook nobody runs,
# except this failure mode you can actually detect.
gh api "repos/$REPO/rulesets/$RULESET_ID" --jq '
(.enforcement == "active")
and (.bypass_actors | length == 0) # nobody bypasses, admins included
and (.rules | map(.type) | contains([
"required_signatures",
"non_fast_forward",
"required_linear_history",
"pull_request",
"required_status_checks"
]))
' | grep -qx true || { echo "ruleset drifted — main is unguarded"; exit 1; }One caveat the docs bury: the ruleset’s required_status_checks must name only jobs your CI actually produces. List a check that never runs and the ruleset hard-locks main behind a status that stays forever pending — you have enforced a deadlock, not a policy.
The decisions, costed
Hooks advisory, never mandatory. Cost: bypassable, by construction. Benefit: they never lock you out of your own repository mid-incident, and they stay fast enough to keep. Reconsider: never — as long as the server enforces the same rule. The instant a hook is the only thing between an agent and main, it has already failed; you just haven’t been billed yet.
A ruleset, not the legacy branch-protection rules. Rulesets layer, they are reusable across an org, and they make the bypass list explicit. bypass_actors: [] is auditable prose. The old branch-protection UI hid “admins can override” inside a checkbox nobody re-read, which is exactly where the next incident lives.
Zero required approvals. This looks wrong until you say it aloud: a solo developer cannot approve their own pull request, so a rule demanding one human approval is a rule demanding you never merge. So the reviewer is the required status check, not a person — 0 approvals over 1. Cost: no human sign-off. Benefit: the merge actually happens, gated by a check that does not sleep, take holidays, or rubber-stamp at 3 a.m.
The anti-pattern threads through all three, and it has a name: --no-verify culture — treating the local hook as the gate, then acting surprised when something steps around it. Its cousin is the admin bypass, the bypass_actors entry you add “just this once” during an incident and never remove. Both quietly move the real boundary off the server and into a habit. Habits do not survive a tired night; a server-side refusal does.
What the hook cannot promise
Hooks lie, structurally — not out of malice, but because they run on the client, and the client is the thing you are trying to constrain. A signed commit proves who authored it. It does not prove the pre-commit ran. Only the server, refusing the push, proves anything the author could not have faked, which is the entire reason the server has to hold the invariants that matter.
Which is also why enforcement that fights the worker gets deleted. I once ran a pre-tool guard that blocked an agent from switching branches inside a worktree; it caught real mistakes and also blocked legitimate work, so it went, and the discipline it enforced moved into a written rule instead. That is the actual lesson, and it cuts against the reflex to add more hooks: a local guard hostile enough to be worth bypassing will be bypassed — or removed by the person it slows down, who is you. Put the invariants that cannot bend on the server, where bending them is not on the menu, and keep the local layer helpful enough that nobody wants to switch it off.
When is this overkill? A repository with one careful human and no agents does not need the ceremony — a pre-commit hook and plain branch protection are plenty. Defense-in-depth earns its keep precisely when the actor you are guarding against is fast, numerous, and occasionally wrong in a way no reviewer is awake to catch. That is the room an agent fleet puts you in.
The hooks make the good path fast. The ruleset makes the bad path impossible. Only one of those is negotiable — and it is not the one on the server.
Stack
- Local hooks: lefthook
parallel pre-commit fan-out + a handful of declarative git hooks (pre-push guard,
post-commitbackup mirror,prepare-commit-msg) - Commit format: Conventional Commits — enforced by
commit-msglocally, and re-checked as a required status check in CI so the local pass is never load-bearing - Signing: SSH-signed commits, enforced server-side via
required_signatures - Server law: GitHub repository ruleset —
enforcement: active,bypass_actors: [], required status checks (strict up-to-date), required linear history, no force-push, no deletion, review-thread resolution,0required approvals - Recovery:
refs/backup/<host>/<branch>mirror onpost-commit+ reflog retention (180d/90dfor unreachable objects) - CI: the required checks that are the actual merge gate — lint and format, dependency health, per-host eval