Pre-Commit Hooks
Automated quality checks that run before code reaches the repository.
Overview
Pre-commit hooks are scripts that execute automatically at specific points in the git workflow — before a commit is created, before a push lands, or when a commit message is written. They act as a local gate: problems caught here never reach the remote, never slow down a CI pipeline, and never appear in a pull request for reviewers to comment on.
The key distinction from CI is speed and locality. Hooks run on the changed files only, in under a few seconds, with immediate feedback in the terminal. CI runs the full suite against the full codebase, taking minutes. Both are necessary; they are not substitutes for each other.
This page covers the hook infrastructure and what to run at each stage. For lint configuration itself, see Linting Standards.
Why It Matters
Fast feedback loops. Catching a lint error or a failing type-check at commit time costs seconds. The same error caught in CI — after a push, a pipeline queue, and a reviewer's attention — costs minutes to hours, plus context-switching overhead.
Clean history by default. When hooks prevent bad commits from being created, the git log stays meaningful. You avoid "fix lint" or "oops forgot types" commits that dilute the history without adding signal.
Reduced CI noise. Teams that rely solely on CI for quality gates often see pipelines fail on trivial, auto-fixable issues. This degrades trust in CI: when the pipeline is red half the time for reasons unrelated to logic, engineers start ignoring red signals.
No config drift. Hooks are committed to the repo. Everyone on the team runs the same checks, regardless of their editor setup or personal preferences.
Standards & Best Practices
Hooks by stage
| Git hook | When it runs | What to run |
|---|---|---|
pre-commit | After git commit, before commit is created | Lint + format on staged files only |
commit-msg | After message is entered | Validate commit message format |
pre-push | Before git push sends data | Type-check, full test suite (if fast) |
pre-commit is the highest-frequency hook — it runs on every commit. Keep it fast (under 5 seconds). Run only on staged files, not the whole codebase.
commit-msg enforces a message convention (e.g. Conventional Commits). This is the right place for it — not a custom wrapper around git commit.
pre-push is the right place for slower checks: the TypeScript compiler, unit tests. It runs less frequently and tolerates a longer runtime.
Only run what you'd run in CI
The hook suite should be a subset of CI, not a superset. If a check isn't in CI, it doesn't belong in hooks. If a check is in hooks but not CI, the repo can still receive commits that fail it (via --no-verify or direct pushes). Both environments should agree on what "passing" means.
Hooks must be fast
If pre-commit takes more than ~10 seconds, engineers start using --no-verify routinely. That defeats the entire purpose. Use lint-staged to run checks only on staged files. Avoid full-repo scans in pre-commit.
Do not auto-commit fixes
Some setups auto-fix lint errors and then include the fixes in the commit. This is surprising behavior: the commit contains changes the engineer didn't author or review. Auto-fix during the hook, abort the commit, and let the engineer stage the fixes themselves before re-committing.
Document the bypass and its cost
Bypassing hooks with git commit --no-verify is sometimes legitimate (e.g. emergency hotfix, committing to a WIP branch). Don't make it impossible — make it explicit. Document in the README that --no-verify skips local hooks, and that CI will still catch violations.
How to Implement
1. Install Husky and lint-staged
pnpm add -D husky lint-staged
pnpm exec husky initThis creates a .husky/ directory and adds a prepare script to package.json that installs hooks after pnpm install. Every engineer who runs pnpm install gets the hooks automatically.
2. Configure the pre-commit hook
Edit .husky/pre-commit:
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm exec lint-staged3. Configure lint-staged
Add to package.json:
{
"lint-staged": {
"*.{ts,tsx,js,jsx}": ["eslint --fix", "prettier --write"],
"*.{json,md,mdx,css}": ["prettier --write"]
}
}lint-staged passes only the staged files to each command. ESLint runs on two files instead of two thousand. This is what keeps the hook fast.
4. Add a commit-msg hook (optional but recommended)
Install commitlint:
pnpm add -D @commitlint/cli @commitlint/config-conventionalCreate commitlint.config.js:
export default {
extends: ['@commitlint/config-conventional'],
};Edit .husky/commit-msg:
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm exec commitlint --edit "$1"Valid commit message format: type(scope): description — e.g. feat(auth): add OAuth2 login.
5. Add a pre-push hook for type-checking
Edit .husky/pre-push:
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm typecheckThis runs tsc --noEmit before every push, catching type errors that don't show up in lint.
6. Verify the setup
# Trigger pre-commit manually
pnpm exec lint-staged
# Trigger commit-msg manually
echo "bad message" | pnpm exec commitlint
echo "feat: valid message" | pnpm exec commitlintTools & Templates
Full .husky/ directory structure
.husky/
_/
husky.sh # Husky bootstrap (auto-generated, do not edit)
pre-commit # lint-staged
commit-msg # commitlint
pre-push # typecheckComplete package.json additions
{
"scripts": {
"prepare": "husky"
},
"lint-staged": {
"*.{ts,tsx,js,jsx}": ["eslint --fix", "prettier --write"],
"*.{json,md,mdx,css}": ["prettier --write"],
"*.py": ["ruff check --fix", "ruff format"]
},
"devDependencies": {
"@commitlint/cli": "^19.0.0",
"@commitlint/config-conventional": "^19.0.0",
"husky": "^9.0.0",
"lint-staged": "^15.0.0"
}
}Commitlint types reference
| Type | When to use |
|---|---|
feat | A new feature |
fix | A bug fix |
chore | Tooling, deps, no production code change |
docs | Documentation only |
refactor | Code restructure, no behavior change |
test | Adding or updating tests |
perf | Performance improvement |
ci | CI/CD configuration changes |
Common Pitfalls
Hooks not installing for new team members. If prepare isn't in package.json scripts, hooks don't install automatically after pnpm install. New engineers run without hooks until they notice. Always use husky init which adds prepare automatically.
Running the full linter in pre-commit. Running eslint . on the whole project in pre-commit is slow and scales poorly. Use lint-staged. After 6 months and a few thousand files, a hook that took 2 seconds now takes 45 — and everyone starts using --no-verify.
Putting type-checking in pre-commit. TypeScript's compiler operates on the whole project, not individual files. It can't be scoped to staged files. Putting it in pre-commit makes every commit slow. It belongs in pre-push.
Silently fixing and committing. If your hook auto-fixes files and adds them back to the commit (git add), the commit now contains changes the engineer didn't review. This surprises people and hides problems. Fix and abort; let the engineer re-stage.
Treating --no-verify as forbidden. Banning bypass entirely doesn't work — people find workarounds or get frustrated during legitimate emergencies. Document it clearly, ensure CI catches what hooks miss, and trust the team.