git hooks
Git Hooks in Practice: The Three Gates That Keep Main Green
How the git hooks on a real production monorepo are split into three gates — a fast pre-commit, a scoped pre-push, and a full ship gate — with the measured timings behind each one.

TL;DR — Git hooks fail for one reason: they get slow, so people start passing
--no-verify. The fix is to split them into three gates with different budgets. A pre-commit hook that only runs static checks and finishes in about three seconds. A pre-push hook that runs lint, typecheck, build and tests, but only for the parts of the repo your change actually touched. And a ship gate that runs the whole battery, in parallel, before a pull request goes out. On the monorepo behind this site those three gates cost roughly 3 seconds, tens of seconds, and 45 to 50 seconds respectively — and the last one is a superset of what the pipeline will run anyway.
Why most git hooks get uninstalled
A git hook is just a script git runs at a fixed moment: before a commit is written, before a push leaves your machine, after a branch is checked out. There is nothing clever about the mechanism. The hard part is deciding what belongs in each moment.
Almost every team that abandons git hooks abandons them for the same reason. Someone wires the full test suite into pre-commit because that is the earliest possible moment, the suite takes ninety seconds, and committing four times an hour now costs six minutes of staring at a terminal. Within a fortnight everyone has learned the --no-verify flag, and the hook is worse than nothing: it is a control that everybody believes is running and nobody is actually subject to.
So the useful question is not what can we check but what is cheap enough to survive at this moment. Commits are frequent and local. Pushes are less frequent and have a blast radius. A pull request is rare and expensive to get wrong. Three moments, three budgets, three different sets of checks.
Gate one: pre-commit stays static
The pre-commit hook on this repo runs only static gates — shell and Python compile checks, lint on the staged paths, an OpenAPI spec check. It does not run tests and it does not run coverage.
That is a deliberate reversal. The gate used to run the full instrumented test suite of both applications on every single commit. Measured on 29 July 2026, that cost a median of 79 seconds per commit. After moving coverage out and leaving only the static checks, the median is 3 seconds. The number that matters is not 79 or 3 on its own; it is that one of them is compatible with committing whenever you have a coherent thought, and the other quietly teaches everyone to batch up giant commits or skip the hook.
What did we lose? Nothing that stayed lost. The coverage floor still has three owners: the ship gate below, the pipeline stage that runs on the main branch, and a nightly scheduled run. All three read the floor from one file, so there is no second copy of the number to drift. Moving a check to a later gate is not the same as deleting it — but it is only honest if you can name the gate that picked it up.
Gate two: pre-push runs the affected slice
Pushing is the first moment your work can affect someone else, so the pre-push hook earns a bigger budget. It runs lint, then typecheck, then a build of the affected app, then tests — but scoped. In a monorepo the build tool works out which workspaces a diff can possibly have broken and runs only those. An edit inside one application does not pay for the other application's test suite.
The important design decision here is not the scoping. It is that the pre-push hook and the pipeline run the same entry points. Every verification stage in the pipeline is a bare bun run ci:<name>; the local lint:changed, typecheck:changed and test:changed scripts are the same commands with one environment variable flipped, which changes exactly two things — the concurrency cap and where the diff base comes from, because a git hook is not handed one.
That parity was not free, and its absence had already cost us twice. The pipeline's lint stage ran one task while the pre-push hook ran two, which meant required CI never linted the repo's own scripts, end-to-end tests or Playwright config. And the local hook called the build tool directly, so it skipped a guard the pipeline had: when a diff touches no workspace, --affected selects nothing, runs zero tasks, and exits 0. Every pre-push run was printing "Packages in scope: //" and "No tasks were executed" — and then reporting PASS.
That is the real failure mode of git hooks, and it is much more dangerous than slowness. A slow hook annoys you. A hook that exits 0 having verified nothing hands you a false receipt. Both gates now fail loudly when a diff that touched application code produces zero executable tasks, and print the count so the log shows what actually ran.
Gate three: the ship gate, in parallel lanes
Before a pull request goes out, one command runs the full battery — every lint, every typecheck, both application builds, the guard tests, the blog content lint, the OpenAPI and metadata contract checks, and the instrumented test run that enforces the coverage floor.
Run as a straight serial list on a 14-core laptop with a cold cache, that battery cost 85 seconds — of which 77 seconds was four steps that had nothing to do with each other, while the machine sat mostly idle. The same battery now runs in four parallel lanes: same commands, nothing skipped or narrowed, only the schedule changed. Three measured runs came in at 45.3, 50.1 and 49.1 seconds cold, and 30.3 seconds warm. Under a deliberate low-power throttle it went from 118.2 to 76.2 seconds, which is what put it inside our 90-second budget for a local suite with headroom to spare.
Two details made the parallel version worth trusting. Each step's output is captured and replayed in the order the steps were declared, so a parallel transcript reads exactly like a serial one — you are never reading four interleaved logs. And each step runs in its own subshell, because without one a script ending in exit kills its whole lane and the remaining steps report failed having never run. A flag forces the old serial order when you need live output for a hang.
The headroom bought something concrete: six checks the local gate had never run at all, including a typecheck. Until then, a type error could only ever be discovered after the push.
Make parity a test, not a convention
The one rule I would carry into any repo: the ship gate must be a superset of required CI, and a test must prove it.
Here that test reads every stage in the pipeline definition, resolves the indirection to the underlying script, and fails unless each stage names a step in the resolved local plan — or records, explicitly, why it cannot run locally. Three stages qualify for that exemption: dependency install, and two database stages that need real project credentials or a live local stack. Everything else has a local owner, and adding a new pipeline stage fails the build until its local owner exists.
Without that test, parity is a promise in a document, and documents rot silently. With it, drift is a red check with a name.
Quick reference
| Gate | Moment | What runs | Budget |
|---|---|---|---|
| Pre-commit | Every commit | Static gates: compile checks, staged-path lint, spec check | ~3s median |
| Pre-push | Every push | Lint, typecheck, affected build, tests — scoped to the diff | Tens of seconds |
| Ship gate | Before a PR | Full battery in four parallel lanes, coverage floor enforced | 45–50s cold, ~30s warm |
The takeaway
Git hooks do not fail because developers are careless. They fail because a single hook is asked to carry every check at the cheapest moment, gets slow, and gets bypassed. Split the work by moment and give each moment a budget you would actually pay several times an hour.
Then hold the whole arrangement to one standard: a green hook must mean something ran. Measure the gates, print what executed, and make the parity between your local gate and your pipeline a test that fails rather than a paragraph nobody re-reads. That is the difference between automation you trust and a green tick you have quietly learned to ignore.
If you want the same discipline applied to a codebase you own, that is the kind of work described under service offerings, with worked examples in the case studies and the tool library.
Related paths