The first version of our pre-commit hooks ran the full test suite, the full linter, and a type check on every commit, and within three weeks roughly half the team had quietly started using git commit --no-verify as a default habit rather than an emergency escape hatch. The hooks weren't wrong to check what they checked. They were slow and strict enough that following the rules cost more than most commits were worth, and the team voted with its feet.
What actually killed adoption
Two separate problems compounded. The full test suite took upwards of ninety seconds on a warm cache and well over three minutes cold, which is an eternity to wait between finishing a thought and committing it — long enough that people started batching several changes into one commit just to pay that cost less often, which defeated the entire point of small, frequent commits. The second problem was stricter: the linter was configured to fail the commit on warnings, not just errors, and a warning about an unused import in a file you hadn't touched — pre-existing, unrelated to your change — blocked your commit just as hard as an actual bug in your new code.
- Slow feedback loop: a check that takes minutes trains people to avoid triggering it, not to fix what it finds.
- Blocking on pre-existing, unrelated issues: punishing a commit for a warning in code you didn't touch feels arbitrary and erodes trust in the check generally, even for the warnings that are genuinely relevant.
- No visibility into why it's slow or what specifically failed — a wall of test output with no clear "here's the one line that matters" summary makes even a legitimate failure feel like a chore to interpret.
The redesign: fast checks only, scoped to changed files
The fix wasn't removing the hooks — it was rebuilding them around a different rule: a pre-commit hook only runs checks that complete in a few seconds and that only touch files actually being committed, never the whole codebase. Anything slower or broader moved to CI, which runs asynchronously and doesn't block the act of committing at all.
// package.json
{
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.php": ["./vendor/bin/pint", "php -l"],
"*.{js,ts}": ["eslint --fix", "prettier --write"]
}
}
lint-staged is doing the specific job that made this fast: it only runs the listed commands against files that are actually staged for the current commit, not the entire repository. A commit touching two files runs the linter against two files, in well under a second, regardless of how large the rest of the codebase is. That single change — scoping to staged files instead of the whole project — accounted for most of the speed difference between the version people bypassed and the version that stuck.
Moving the slow, broad checks to CI where they belong
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: composer install
- run: php artisan test
- run: npm run typecheck
The full test suite and type check still run on every push, they just don't block the local act of committing. This distinction matters more than it sounds like it should: a slow check on push doesn't interrupt someone's flow the way the same check blocking a commit does, because pushing is naturally a point where you're already stepping away momentarily, while committing happens constantly, often several times in a focused ten-minute stretch.
The warnings-vs-errors line, drawn explicitly
The second fix was a deliberate policy decision, not a tooling change: pre-commit hooks fail only on things that are objectively broken — a syntax error, a failing type check on the specific file changed — never on style preferences or warnings about code the current commit didn't touch. Auto-fixable style issues (formatting, import ordering) get fixed automatically and silently by the hook itself, via --fix flags, rather than blocking the commit and asking a human to fix them manually.
// Auto-fix formatting silently — no decision required from the developer
"*.{js,ts}": ["eslint --fix", "prettier --write"],
// Only block on things that are genuinely broken, not stylistic
"*.php": ["php -l"] // syntax check only — Pint runs separately with --fix
This distinction — auto-fix what can be auto-fixed, only block on what's actually broken — removed almost all of the friction that made the original version feel punitive. A developer whose commit gets silently reformatted barely notices. A developer whose commit gets rejected over an unrelated warning notices immediately, and remembers it the next time they're deciding whether to add --no-verify to the command.
A holdout case: the type check that was too slow to scope down
Not every check can be meaningfully scoped to just the changed files — a TypeScript type check, in particular, often needs to understand the whole project's type graph to correctly validate even a single file, because a change in one file's exported type can affect how a completely different file type-checks. Running the type checker only against staged files, in our case, produced false negatives — files that would fail in CI passed locally because the checker wasn't seeing the full picture.
Rather than force a slow, whole-project type check into the pre-commit hook, this specific check moved to a pre-push hook instead — one level looser than pre-commit, running less often (once per push rather than once per commit) while still catching the issue before it reaches CI:
// .husky/pre-push
#!/bin/sh
npm run typecheck
This is a genuinely useful middle tier that a lot of pre-commit-hook guides skip entirely: not every check fits neatly into "fast enough for every commit" or "slow enough to leave entirely to CI." A pre-push hook is a reasonable home for checks that are too broad to scope down to individual files but still valuable to catch before code leaves your machine.
Measuring whether it actually stuck
Six months after the redesign, we checked commit history for --no-verify usage by searching reflog and commit metadata where available, and found it had dropped to something close to its original intended use — a genuine emergency bypass, not a routine habit. The actual signal that mattered most wasn't a formal metric at all: nobody complained about the hooks anymore, which was a real and noticeable change from the constant low-grade grumbling the original slow version had generated.
- Scope every pre-commit check to staged files only, never the whole repository — this is the single highest-leverage change for speed.
- Auto-fix what's fixable, block only on what's genuinely broken. A hook that silently corrects formatting is invisible; a hook that rejects a commit over someone else's unrelated warning is memorable, for the wrong reasons.
- Not everything fits pre-commit. A pre-push tier exists for checks too broad to scope down but still worth catching before code leaves a machine, and CI is the right home for anything broader still.
If your team is also standardizing the rest of the local setup around this same "make the right thing the easy thing" philosophy, setting up a productive local dev environment from a blank laptop covers the broader onboarding version of the same idea, and if a hook like this one is what originally caught a bug worth digging into further, a real Xdebug session finding an off-by-one bug picks up exactly where a passing lint check leaves off.
Handling the "it works on my machine" version of hook failures
A separate friction point showed up a few weeks into the redesign: a hook that ran fine for most of the team failed consistently for one developer on Windows, because a shell script written and tested exclusively on macOS assumed Unix line endings and a Bash-compatible shell that Windows doesn't provide by default. The failure looked, from the outside, exactly like the kind of unreliable, annoying hook behavior that had killed adoption the first time around — except this time it was a genuine environment gap, not a scope or speed problem.
# .husky/pre-commit — cross-platform-safe version
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged
Using husky's own generated hook scripts, rather than hand-rolled Bash, and running everything through npx rather than assuming specific binaries were already on the system path, resolved the cross-platform gap without needing separate hook logic per operating system. The broader lesson carried forward: a hook that's fast and well-scoped can still quietly fail adoption if it only works reliably on the operating system it was originally written and tested on, and a team with any platform diversity at all needs to actually test hook setup across those platforms before assuming a fix that worked on one machine generalizes to everyone's.
What we explicitly decided not to enforce in a hook
Not every check we considered adding actually made it into the pre-commit hook, and the reasoning behind leaving some out is worth being explicit about, since the instinct to keep adding "just one more check" is exactly what caused the original problem. A commit message format checker was proposed and rejected — enforcing it at commit time meant a developer could lose an entire in-progress commit message to a formatting rejection, which is a worse failure mode than a slightly inconsistent commit log. That check moved to a CI-only warning instead, informational rather than blocking. Similarly, a check for TODO comments left in new code was rejected as a hard block, since a TODO is sometimes a legitimate, deliberate marker for follow-up work rather than a mistake — it became a soft warning surfaced in code review instead, where a human can judge context a hook can't.
- Blocking checks are for things that are unambiguously broken — a syntax error, a failing type check on the changed file.
- Everything context-dependent belongs in code review or a non-blocking CI warning, not a hook, because a hook has no way to distinguish a legitimate exception from a genuine mistake.
- When genuinely unsure whether a check belongs in the hook, the safer default is leaving it out — the original failure mode of this whole story was a hook that did too much, not too little.
No comments yet.
Be the first visitor to add a thoughtful comment on this article.