EngineeringCode Quality

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 hookWhen it runsWhat to run
pre-commitAfter git commit, before commit is createdLint + format on staged files only
commit-msgAfter message is enteredValidate commit message format
pre-pushBefore git push sends dataType-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 init

This 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-staged

3. 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.

Install commitlint:

pnpm add -D @commitlint/cli @commitlint/config-conventional

Create 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 typecheck

This 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 commitlint

Tools & Templates

Full .husky/ directory structure

.husky/
  _/
    husky.sh         # Husky bootstrap (auto-generated, do not edit)
  pre-commit         # lint-staged
  commit-msg         # commitlint
  pre-push           # typecheck

Complete 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

TypeWhen to use
featA new feature
fixA bug fix
choreTooling, deps, no production code change
docsDocumentation only
refactorCode restructure, no behavior change
testAdding or updating tests
perfPerformance improvement
ciCI/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.