Most pre-commit hooks I meet are formatting machines. They make the diff prettier, which is useful, but they do not catch many bugs.
The opposite setup is worse: run the entire repository’s release gate on every commit until everyone learns --no-verify by muscle memory.
A useful hook catches likely mistakes in a few seconds. The rest belongs before push or in CI.
Give the hook a time budget
I aim for five seconds and tolerate ten. Past that, I start moving work to pre-push.
That budget changes what belongs where:
| Stage | Good candidates |
|---|---|
| Pre-commit | Staged-file lint, format check, fast type check, related unit tests |
| Pre-push | Full unit suite, dependency audit, source scan, production build when affordable |
| CI | Clean install, complete test matrix, integration tests, deployment checks |
The layers can repeat important checks. Pre-commit optimizes feedback. CI protects the shared branch from a missing hook, different machine, or creative bypass.
That is the practical point behind running checks locally first: local feedback and independent CI confirmation are partners, not rivals.
A Lefthook starting point
I use Lefthook because the configuration is direct, it can run independent checks in parallel, and it gives each command the staged file list.
# lefthook.yml
pre-commit:
parallel: true
commands:
typecheck:
run: pnpm exec tsc --noEmit
glob: "*.{ts,tsx}"
lint:
run: pnpm exec eslint {staged_files}
glob: "*.{ts,tsx,js,jsx}"
format:
run: pnpm exec prettier --check {staged_files}
glob: "*.{ts,tsx,js,jsx,json,css,md}"
test-related:
run: pnpm exec vitest related --run {staged_files}
glob: "*.{ts,tsx}"
Lefthook documents how {staged_files}, globs, and command splitting work in its run configuration reference. Treat this as a shape, not a drop-in promise. Commands and file patterns need to match the repository.
Why each check earns the wait
Type-check the project graph
A changed function signature can break an importer you did not stage. Type-checking only the edited file misses the reason TypeScript is useful.
Run the project or affected workspace when it fits the budget. In a large monorepo, use project references or the package graph to scope it. If a cold type check takes a minute, move it to pre-push instead of teaching people to hate commits.
Lint only what is entering the commit
Staged-file lint catches new unsafe patterns without turning a small fix into a cleanup of every historical warning.
Be careful with partially staged files. A linter reads the working-tree file by default, which may include unstaged edits. Teams that rely heavily on partial staging should test the hook against that workflow or use a tool that temporarily isolates the staged snapshot.
Check formatting instead of rewriting silently
I prefer prettier --check in the hook. It fails with a clear command to run and does not modify the working tree during git commit.
Auto-format-and-restage can work, but it needs deliberate handling for partially staged files. The magical version is delightful until it commits a line you meant to leave out.
Run tests connected to the diff
Vitest’s related-test mode follows the import graph from changed source files. It is a good middle ground between “no tests” and “run 900 tests because one utility changed.”
Related tests are only as good as the dependency graph and test coverage. A route contract, configuration file, database migration, or generated client can affect behavior without a direct import edge. Those changes should trigger a broader command.
Add rules for risky files
File-aware hooks are more valuable than a universal pile of commands.
Examples:
- If
package.jsonchanges, require the expected lockfile to be staged. - If a migration changes, run the schema or migration validator.
- If an API schema changes, regenerate clients and fail on a dirty diff.
- If a workflow changes, run its linter.
- If a secrets-shaped file is staged, stop and require review.
The test should describe the invariant, not grep for one exact formatting accident. A lockfile rule needs to understand the package manager and workspace layout instead of assuming every manifest change rewrites one root file.
Keep the escape hatch
git commit --no-verify should remain available.
Sometimes you need a local checkpoint on a broken branch. Sometimes the hook itself is broken after a tool upgrade. Removing the escape hatch does not create discipline; it creates new ways to disable the hook.
The shared boundary is CI. If bypassing a local check can merge bad code, the branch protection is incomplete.
What matters is that bypasses are exceptional. If people use --no-verify every day, measure which command is slow or flaky and fix that command.
Measure it like developer-facing software
Run the hook with no matching files, with one staged file, and with the largest ordinary commit. Record the slow command. Check it on macOS, Linux, and Windows if the team uses all three.
I also test these failure cases:
- A filename containing spaces
- A deleted or renamed file
- A partially staged file
- No related tests
- The package manager unavailable from a GUI Git client
- A formatter or linter returning warnings without a nonzero exit
Hooks are part of the product developers use every day. Give their error messages the same care as a form error in the application.
The setup I want
My ideal commit fails quickly for a real reason and prints the exact command needed to reproduce it. A normal commit finishes before I reach for another tab.
Then pre-push runs the slower repository checks, including SiteCMD’s optional local quality gate. CI repeats the trust-boundary checks from a clean checkout. The pre-launch checklist covers the broader release evidence that no Git hook can provide.
Fast feedback catches bugs. A 60-second ritual catches bypasses.