Somebody joins on Monday, clones the repo and starts committing. Every check you have written is sitting in that clone. None of them is running, and nothing tells either of you, because git hooks are not cloned and the setting that enables them is per machine.
So the honest description of local enforcement is that it works on the machines where somebody turned it on, and you cannot see which those are.
Two different promises
A local hook and a shared pipeline look like the same check written twice. They are answering different questions.
| a hook on a laptop | a job in the pipeline | |
|---|---|---|
| answers | did this machine check? | did anything reach the site unchecked? |
| feedback | instant, before the commit | a minute later, after the push |
| coverage | whoever configured it | everybody, always |
| can be turned off | quietly, per clone | by a reviewed change to a tracked file |
Both are worth having, for different reasons. The hook is for speed, and speed is what makes people keep a check rather than resent it. The pipeline is for truth, and it is the only one you can make a promise about.
The trap in writing it twice
A gate that runs before the action is only as good as the rule inside it, and the fastest way to rot the rule is to have two copies of it. Write the check once as a script and call that script from both places. The hook is four lines of shell around it. The pipeline job is a few lines of configuration around it.
If the hook and the pipeline ever disagree, you have the same failure as a rule in two directories: the local one passes, the shared one fails, and somebody starts arguing that the pipeline is broken.
The version worth copying is blunter than that. In
deskspace there is one script,
toolbox/verify, and both callers are one line:
# .github/workflows/verify.yml
- name: toolbox/verify --mutation
run: toolbox/verify --mutation
# .githooks/pre-push
"$REPO/toolbox/verify"
The pipeline has no steps of its own. Not because listing them would be hard, but because a list is a second definition of done, and the two would agree right up until somebody added a gate to one of them.
That script decides everything: the registries, formatting, lint, the architecture rules, the tests, coverage, the consumer contract, whether the generated client still matches the spec, and the evals that check the gates themselves. One answer to “is it green”, with one place to change it.
Check what changed, not everything
The one design decision that decides whether this survives is scope. This site carries an archive written years before half the current rules existed, and a pipeline that swept the whole tree would fail on every single run.
A check that is always red stops being information. People learn its colour by heart, stop reading it, and eventually somebody deletes it during an unrelated cleanup and nobody objects. So the job checks the posts in the push and leaves the archive alone, and the archive’s exemption is a fact about the range, not a list of filenames somebody maintains.
Where this leaves the series
Nine parts, and the same move in every one of them. Reason about something once, encode the result, then put the encoding somewhere it runs without anyone choosing to run it.
The order was deliberate: a prompt is the least durable place, a script is more durable than a document, a named sequence beats a remembered one, an index makes it findable, and a gate makes it unavoidable. Each rung stores the same decision somewhere slightly harder to lose. What is left over at the top, the part that never becomes a script, is the part worth your attention, and it is smaller than it looks before you start.
If your team’s practices hold on the machines of the people who wrote them and nowhere else, that’s the work I do.