Code review catches bugs, but it doesn’t catch the boring stuff — trailing whitespace, import order violations, forgotten console.log statements, or a TypeScript error introduced in the last five lines. Those things waste reviewers’ time and clutter PR diffs with style-only changes.

Pre-commit hooks run automatically when you git commit. If they fail, the commit doesn’t happen. Your CI pipeline stops being the first line of defense; your local machine is.

Git Hooks: The Foundation

Git has a built-in hook system. A pre-commit hook is a script at .git/hooks/pre-commit that runs before every commit:

#!/bin/sh
# .git/hooks/pre-commit
npm run lint
npm run type-check

But .git/hooks isn’t committed to the repository. Every developer has to set this up manually. That’s the problem.

Husky: Hooks That Ship With Your Code

Husky makes git hooks part of your project. They live in version control and are installed automatically.

# Install
npm install --save-dev husky

# Initialize
npx husky init

# Creates .husky/pre-commit (a committed file, not in .git/)
# .husky/pre-commit
#!/bin/sh
npm run lint
npm run type-check

Every developer who clones the repo and runs npm install gets the hooks. No manual setup.

lint-staged: Only Check Changed Files

Running ESLint on 50,000 lines of code on every commit is slow. lint-staged runs linters only on staged (changed) files:

npm install --save-dev lint-staged
// package.json
{
  "lint-staged": {
    "*.{ts,tsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{json,md,yml}": [
      "prettier --write"
    ]
  }
}
# .husky/pre-commit
#!/bin/sh
npx lint-staged

Now ESLint and Prettier only run on the files you actually changed. A 50k-line project runs lint-staged in 2-3 seconds instead of 30.

A Complete Setup

Here’s a production-ready pre-commit setup for a TypeScript project:

1. Install dependencies

npm install --save-dev husky lint-staged prettier eslint typescript

2. Configure lint-staged

// package.json
{
  "scripts": {
    "prepare": "husky",
    "type-check": "tsc --noEmit"
  },
  "lint-staged": {
    "*.{ts,tsx}": [
      "eslint --fix --max-warnings 0",
      "prettier --write"
    ],
    "*.{js,jsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{json,md,yaml,yml}": [
      "prettier --write"
    ]
  }
}

3. Configure Husky hooks

# .husky/pre-commit
#!/bin/sh
npx lint-staged
npx tsc --noEmit
# .husky/commit-msg (optional: enforce commit message format)
#!/bin/sh
npx --no -- commitlint --edit "$1"

4. Enforce commit message format (optional)

npm install --save-dev @commitlint/cli @commitlint/config-conventional
// commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional'],
};

Now commit messages must follow Conventional Commits:

git commit -m "feat: add user search endpoint"
git commit -m "fix: handle null user correctly"
git commit -m "WIP stuff" hook rejects it

Adding Tests to Hooks

Running your full test suite on every commit is too slow. Run a targeted subset:

# .husky/pre-commit
#!/bin/sh
npx lint-staged

# Run only tests related to staged files
npx jest --passWithNoTests --findRelatedTests $(git diff --cached --name-only)

Jest’s --findRelatedTests flag identifies which test files cover the changed source files. You only run what’s relevant.

Pre-push Hooks

Some checks are too slow for pre-commit but still belong before pushing:

# .husky/pre-push
#!/bin/sh
npm run type-check
npm run test -- --coverage

Pre-push is a good place for the full TypeScript check and coverage report.

Bypassing Hooks When Necessary

Sometimes you need to commit quickly (WIP, emergency, broken tooling):

# Bypass pre-commit hooks
git commit -m "WIP: do not review" --no-verify

--no-verify is a safety valve. Don’t make it a habit, but don’t lock it down either — developers need an escape hatch.

Sharing Prettier Config

Pre-commit hooks only help if everyone’s Prettier config is the same:

// .prettierrc
{
  "semi": true,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "es5",
  "printWidth": 100
}
// .prettierignore
node_modules/
dist/
.next/
coverage/

The CI Safety Net

Pre-commit hooks run locally and can be bypassed. CI is the authoritative check. Think of hooks as the fast, friendly feedback loop — not as the final gate.

The workflow:

  1. Pre-commit hook: lint + format staged files (2–5 seconds, local)
  2. Pre-push hook: type-check + tests (30–60 seconds, local)
  3. CI pipeline: full build + all tests + coverage + security scan (the real gate)

Hooks catch 80% of issues instantly, before they ever leave your machine. CI catches the rest.

Key Takeaways

  • Pre-commit hooks enforce quality automatically, before code reaches the repository
  • Husky makes hooks part of your project and installs them with npm install
  • lint-staged runs linters only on changed files — keeps hooks fast
  • Use pre-commit for formatting and lint, pre-push for type-check and tests
  • --no-verify is the bypass valve — don’t lock it out, don’t abuse it
  • Hooks complement CI; they don’t replace it