The cheapest defect to fix is the one that never reaches CI. The next cheapest is one that reaches CI but never reaches a reviewer. Pre-commit hooks are the layer that catches the first kind — in 0.2 seconds, on the developer’s machine, before a single minute of shared compute is spent.
Most teams either don’t use them or overload them until they take 90 seconds and developers start bypassing the hook with --no-verify. Here’s the stack we recommend, and the rules that keep it from sliding into either failure mode.

The five stages
1. Format (Prettier, gofmt, ruff format)
Auto-fix. Never reject. Formatting disputes should be impossible — the tool applies the canonical format on commit, and nobody argues about tabs vs spaces ever again. Should add <100ms.
2. Lint (ESLint, ruff, staticcheck)
Catches the obvious stuff — unused imports, missing dependencies in useEffect, fall-through switch cases. Run on staged files only, not the whole repo. Should add <1s.
3. Type check (tsc, mypy)
Only catches a class of errors that linters miss. Use tsc --noEmit --incremental with project references and you can keep this under 5 seconds on a typical SaaS codebase.
4. Unit tests (changed files only)
Run tests touched by the staged changes. vitest --changed, pytest -k, or your test runner’s equivalent. NEVER run the full suite in a pre-commit hook — that’s what CI is for. Should add <5s.
5. Secrets scan (gitleaks, trufflehog)
Stops AWS keys, API tokens, .env contents from sneaking into a commit. The cost of rotating a leaked credential dwarfs the cost of running the scan. Should add <500ms.
Rules that keep the stack from going bad
- Total budget: 10 seconds. If your hooks take longer, developers will bypass them. That is a behavioral certainty, not a maybe.
- Staged-files only, not full-repo.The hook should care about what you’re committing, not what’s in the rest of the codebase.
- Same checks in CI, but on the full repo.Pre-commit is fast & local; CI is thorough & remote. The same tool, run differently.
- Auto-fix where possible.Reject only when there’s no mechanical fix.
- One config file per tool, checked in.Everyone’s machine enforces the same rules.
The tooling: Husky + lint-staged (or pre-commit framework)
// package.json
{
"scripts": { "prepare": "husky install" },
"lint-staged": {
"*.{ts,tsx}": ["prettier --write", "eslint --fix"],
"*.{md,json,yaml,yml}": ["prettier --write"]
}
}
// .husky/pre-commit
#!/bin/sh
npx lint-staged
npx tsc --noEmit
npx vitest related --run --passWithNoTests $(git diff --cached --name-only --diff-filter=ACM)
gitleaks protect --staged --no-bannerWhat NOT to put in pre-commit
- Full test suite — that’s CI
- Integration tests that need Docker — that’s CI
- E2E tests — that’s CI (or staging)
- Linting the entire codebase — staged files only
- License headers, copyright checks, etc. — if you must, do them in CI
How we approach this
Every codebase we ship via SaaS Product Development comes pre-wired with this hook stack. It’s the single highest-leverage 30 minutes of setup you can do at the start of a project.
Takeaways
- Pre-commit catches in 0.2s what CI catches in 90s and review catches in 4 hours.
- Budget: 10s total. Staged files only.
- Auto-fix where you can. Reject only when you can’t.
- Same tools, broader scope, run in CI as a safety net.







