Git Hooks Automation — Husky, lint-staged, and Pre-Commit Quality Gates

Automate code quality with Git hooks, Husky, and lint-staged: run ESLint, Prettier, type checks, and tests on every commit without slowing down your workflow.

Why Git Hooks?

Code review catches bugs after they're committed and pushed. Git hooks catch them before. A well-configured pre-commit hook is a silent team member who checks every file before it enters the repository — no CI queue wait, no PR comment, no "oops, forgot to format".

The feedback loop: local hook (milliseconds) beats CI (minutes) beats code review (hours) every time. The goal is to shift quality checks as far left as possible.

Need to validate commit message formats? Use the DevKits Regex Tester to build and test your commit message patterns before writing the hook.

Native Git Hooks (No Dependencies)

Every Git repository has a .git/hooks/ directory with sample scripts. The catch: hooks in .git/ are not committed, so teammates don't automatically get them.

# .git/hooks/pre-commit  (chmod +x required)
#!/bin/sh
set -e

echo "Running pre-commit checks..."
npm run lint
npm run type-check

To share hooks with your team, tell Git to look in a committed directory instead:

# Configure repo-wide hooks directory
git config core.hooksPath .githooks

# Create the directory
mkdir -p .githooks

# .githooks/pre-commit
#!/bin/sh
set -e
npm run lint

Now commit .githooks/ and everyone on the team automatically gets the hooks after running git config core.hooksPath .githooks (you can add this to a setup.sh or Makefile).

Husky: Zero-Config Hook Management

Husky is the defacto standard for Node.js projects. It wires hooks to your package.json scripts and installs automatically via the prepare npm lifecycle.

Setup

npx husky init
# Creates .husky/ directory and adds "prepare": "husky" to package.json

The generated .husky/pre-commit file is a regular shell script committed to your repo. Husky 9+ simplified the format — no more npx husky add; just edit the files directly.

Example: pre-commit hook

#!/bin/sh
# .husky/pre-commit

# Run lint-staged (only checks changed files)
npx lint-staged

# TypeScript type-check (whole project)
npm run type-check

Example: commit-msg hook

Enforce Conventional Commits format (feat: add login, fix: crash on null):

#!/bin/sh
# .husky/commit-msg

# commitlint validates the message against conventional commits spec
npx --no -- commitlint --edit "$1"
npm install --save-dev @commitlint/cli @commitlint/config-conventional
// commitlint.config.js
export default {
  extends: ["@commitlint/config-conventional"],
  rules: {
    "type-enum": [
      2, "always",
      ["feat", "fix", "docs", "style", "refactor", "test", "chore", "revert"]
    ],
    "subject-max-length": [2, "always", 72],
  },
};

lint-staged: Only Check What Changed

Running ESLint across 50,000 files on every commit would be unbearable. lint-staged runs linters only against staged files — typically a handful on any given commit. This keeps the pre-commit hook under 2 seconds.

npm install --save-dev lint-staged
// package.json
{
  "lint-staged": {
    "*.{ts,tsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{js,jsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{json,md,yml,yaml}": [
      "prettier --write"
    ],
    "*.css": [
      "prettier --write"
    ]
  }
}

lint-staged also re-stages the auto-fixed files automatically — so if Prettier reformats a file, the reformatted version is what gets committed.

Advanced Pattern: Type-Check Only Affected Files

TypeScript's tsc --noEmit checks the whole project, which can be slow. A pragmatic approach is to run the full type-check asynchronously and only block the commit on ESLint:

#!/bin/sh
# .husky/pre-commit

# Fast: only staged files (blocks commit on failure)
npx lint-staged

# Slower: full type check (run in background, log to file)
# Uncomment if you want strict type-check on every commit:
# npm run type-check || { echo "Type errors found, commit blocked"; exit 1; }

echo "Pre-commit checks passed"

For a CI enforcement model, run the full type-check in your GitHub Actions workflow instead, and keep the local hook fast.

Python Projects: pre-commit Framework

For Python, the pre-commit tool is the ecosystem standard — similar to Husky but language-agnostic and driven by a YAML config:

pip install pre-commit
pre-commit install  # writes .git/hooks/pre-commit
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.3.0
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.9.0
    hooks:
      - id: mypy
        additional_dependencies: [types-requests]

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-json
      - id: check-merge-conflict
      - id: detect-private-key

Run pre-commit run --all-files to apply hooks to the entire codebase, or pre-commit autoupdate to upgrade all hook versions.

Bypassing Hooks (When Necessary)

Sometimes you need to commit work-in-progress without passing all checks:

# Skip pre-commit hook for one commit (use sparingly)
git commit --no-verify -m "wip: spike for performance testing"

# Skip specific lint-staged patterns
SKIP=eslint git commit -m "docs: update changelog"

Make skipping visible in your team's culture — treat --no-verify as a technical debt marker that should be followed by a cleanup commit.

Complete package.json Configuration

{
  "scripts": {
    "prepare": "husky",
    "lint": "eslint src --ext .ts,.tsx",
    "lint:fix": "eslint src --ext .ts,.tsx --fix",
    "format": "prettier --write .",
    "type-check": "tsc --noEmit",
    "test": "vitest run"
  },
  "lint-staged": {
    "*.{ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{json,md}": ["prettier --write"]
  },
  "devDependencies": {
    "husky": "^9.0.0",
    "lint-staged": "^15.0.0",
    "@commitlint/cli": "^19.0.0",
    "@commitlint/config-conventional": "^19.0.0",
    "eslint": "^8.0.0",
    "prettier": "^3.0.0"
  }
}

Common Pitfalls

Hooks not running for teammates

Husky hooks only install when npm install is run (via the prepare script). Teammates who installed dependencies before Husky was added need to run npm install again. Document this in your CONTRIBUTING guide.

Hook performance killing developer experience

If your pre-commit hook takes more than 5 seconds, developers will start using --no-verify habitually. Profile each step with time and move slow checks to CI. Lint-staged is essential — never run ESLint on the full project in a hook.

Windows line endings in hook scripts

Hook scripts must have Unix line endings (LF) on Windows too. Add *.sh text eol=lf to your .gitattributes to prevent Windows Git from converting LF to CRLF.

Summary

  • Use native hooks with core.hooksPath for language-agnostic projects with no Node.js dependency
  • Husky is the standard for Node.js — auto-installs via prepare, hooks are committed to the repo
  • lint-staged is non-negotiable for performance — never lint the whole project in a pre-commit hook
  • commitlint + Conventional Commits gives you auto-generated changelogs and semantic versioning for free
  • For Python, pre-commit framework with ruff and mypy covers lint, format, and type-check in one config