Git Hooks and Pre-commit Tools: Husky, Lefthook, and lint-staged
Git Hooks and Pre-commit Tools: Husky, Lefthook, and lint-staged
Git hooks are scripts that run automatically at specific points in the Git workflow -- before a commit, before a push, after a merge. They are the last line of defense against committing broken code, and the first thing developers disable when hooks are slow or annoying.
The tension is real: you want to catch problems before they reach CI, but you do not want every commit to take 30 seconds. The tools in this guide solve this by making hooks fast (running only on changed files), easy to configure (declarative config files instead of shell scripts), and consistent across the team (hooks are version-controlled with the project).
What Git Hooks Can Do
Git supports hooks at many stages. The most useful for developer workflows:
| Hook | When It Runs | Common Use |
|---|---|---|
pre-commit |
Before a commit is created | Lint, format, type-check staged files |
commit-msg |
After commit message is written | Enforce conventional commit format |
pre-push |
Before pushing to remote | Run tests, check for secrets |
post-merge |
After a merge (including pull) | Reinstall dependencies if lockfile changed |
post-checkout |
After switching branches | Same as post-merge |
The problem with raw Git hooks is distribution. Hooks live in .git/hooks/, which is not version-controlled. Every developer has to set them up manually. The tools below solve this.
Husky: The JavaScript Ecosystem Standard
Husky is the most popular Git hook manager in the JavaScript/TypeScript ecosystem. Version 9 simplified setup dramatically -- hooks are just shell scripts in a .husky/ directory.
Setup
# Install
npm install --save-dev husky
# Initialize (creates .husky/ directory and configures Git)
npx husky init
This creates .husky/pre-commit with a default script. Edit it to run your tools:
# .husky/pre-commit
npx lint-staged
# .husky/commit-msg
npx --no -- commitlint --edit $1
# .husky/pre-push
npm test
That is the entire setup. Husky works by configuring Git's core.hooksPath to point at .husky/, so hooks are automatically active for anyone who runs npm install (via the prepare script in package.json).
How It Works
// package.json
{
"scripts": {
"prepare": "husky"
},
"devDependencies": {
"husky": "^9.0.0"
}
}
The prepare script runs automatically after npm install, setting up Git to use the .husky/ directory for hooks. No manual setup required for new team members.
Strengths: Simple mental model (hooks are just shell scripts), automatic setup via prepare script, widely adopted in the JavaScript ecosystem, minimal configuration.
Weaknesses: JavaScript/npm ecosystem only (relies on package.json prepare script), hooks are sequential shell scripts (no built-in parallelism), no built-in support for running tasks only on changed files (you need lint-staged for that).
lint-staged: Run Tools Only on Staged Files
lint-staged is not a hook manager -- it is a companion tool that runs commands only on files staged for commit. This is what makes pre-commit hooks fast. Instead of linting your entire codebase, you lint only the files you changed.
Configuration
// package.json
{
"lint-staged": {
"*.{js,ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{css,scss}": [
"stylelint --fix"
],
"*.{json,md,yml}": [
"prettier --write"
]
}
}
Or in a dedicated config file:
// lint-staged.config.js
export default {
"*.{js,ts,tsx}": (filenames) => {
// Run type-checking on the whole project (not per-file)
const typeCheck = "tsc --noEmit";
// Run eslint only on staged files
const eslint = `eslint --fix ${filenames.join(" ")}`;
return [typeCheck, eslint];
},
"*.css": "stylelint --fix",
};
The function form is important for TypeScript projects. tsc --noEmit needs to check the whole project (type errors in one file can be caused by changes in another), so you run it once, not per-file. ESLint and Prettier, on the other hand, operate per-file.
Pairing with Husky
# .husky/pre-commit
npx lint-staged
This is the standard setup for JavaScript projects: Husky manages the hook, lint-staged manages what runs and on which files.
Strengths: Dramatic speed improvement (seconds instead of minutes), smart file filtering by glob pattern, supports any command-line tool, function config for complex scenarios.
Weaknesses: Only works on staged files (by design). If you need to run project-wide checks (like tsc), you need workarounds. Stash/unstash behavior during concurrent edits can occasionally cause confusing errors.
Lefthook: The Fast, Polyglot Alternative
Lefthook (from Evil Martians) is a Git hook manager written in Go. It is significantly faster than Husky + lint-staged, supports parallel task execution natively, and works with any language ecosystem -- not just JavaScript.
Setup
# Install
brew install lefthook # macOS
npm install lefthook --save-dev # or via npm
# Initialize
npx lefthook install
Configuration
Lefthook uses a single lefthook.yml file:
# lefthook.yml
pre-commit:
parallel: true
commands:
lint:
glob: "*.{js,ts,tsx}"
run: npx eslint --fix {staged_files}
stage_fixed: true
format:
glob: "*.{js,ts,tsx,css,json,md}"
run: npx prettier --write {staged_files}
stage_fixed: true
typecheck:
glob: "*.{ts,tsx}"
run: npx tsc --noEmit
test:
glob: "*.{test.ts,spec.ts}"
run: npx vitest related --run {staged_files}
commit-msg:
commands:
commitlint:
run: npx commitlint --edit {1}
pre-push:
parallel: true
commands:
test:
run: npm test
audit:
run: npm audit --audit-level=high
Key features in this config:
parallel: true: Lint, format, and type-check run simultaneously, not sequentially. This is Lefthook's biggest advantage over Husky + lint-staged.{staged_files}: Built-in variable that expands to the list of staged files matching the glob. No need for a separate lint-staged package.stage_fixed: true: Automatically re-stages files that were modified by fixers (like ESLint--fixor Prettier--write).glob: Only runs the command when staged files match the pattern.
Polyglot Projects
Lefthook shines in monorepos and polyglot projects:
# lefthook.yml for a monorepo
pre-commit:
parallel: true
commands:
frontend-lint:
root: apps/frontend/
glob: "*.{ts,tsx}"
run: npx eslint --fix {staged_files}
backend-lint:
root: apps/backend/
glob: "*.go"
run: golangci-lint run {staged_files}
python-lint:
root: services/ml/
glob: "*.py"
run: ruff check --fix {staged_files}
terraform:
root: infra/
glob: "*.tf"
run: terraform fmt {staged_files}
Each command has its own root directory and glob pattern. A frontend change only triggers frontend linting; a Go change only triggers Go linting. Combined with parallel execution, hooks run in seconds even in large monorepos.
Strengths: Parallel execution (dramatically faster), built-in staged file filtering (no lint-staged needed), polyglot support, stage_fixed auto-restaging, single config file, Go binary (fast startup).
Weaknesses: Smaller community than Husky, YAML configuration is more verbose than Husky's shell scripts for simple cases, lefthook install needs to be run (though it can be automated via prepare script like Husky).
pre-commit Framework: The Python Ecosystem Standard
The pre-commit framework (confusingly named -- it handles all Git hooks, not just pre-commit) is the standard in Python-heavy environments. It uses a plugin ecosystem where each tool is a versioned dependency.
Setup
pip install pre-commit
Configuration
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-added-large-files
args: ['--maxkb=500']
- id: detect-private-key
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.13.0
hooks:
- id: mypy
additional_dependencies: [types-requests]
- repo: https://github.com/commitizen-tools/commitizen
rev: v4.1.0
hooks:
- id: commitizen
- repo: local
hooks:
- id: pytest
name: Run tests
entry: pytest tests/ -x --timeout=30
language: system
pass_filenames: false
stages: [pre-push]
# Install hooks (after creating config)
pre-commit install
pre-commit install --hook-type commit-msg
pre-commit install --hook-type pre-push
# Run on all files (first time or CI)
pre-commit run --all-files
# Update hook versions
pre-commit autoupdate
How It Differs
The pre-commit framework manages tool versions itself. Each hook specifies a Git repository and revision, and pre-commit creates isolated environments for each. This means:
- Tools are pinned to specific versions (reproducible across the team)
- No need to install tools globally or in your project dependencies
pre-commit autoupdatebumps all hooks to their latest versions
Strengths: Version-pinned hooks (reproducible), massive plugin ecosystem (hundreds of hooks available), language-agnostic hook execution, autoupdate for easy maintenance, isolated environments (no global installs).
Weaknesses: Python dependency (requires pip), first run is slow (downloads and installs all hook environments), the hook isolation model means you cannot easily access your project's installed dependencies, YAML configuration can get long for projects with many hooks.
Performance Comparison
On a medium-sized TypeScript project (500 files, 5 staged files), typical pre-commit hook execution times:
| Setup | Time (5 staged files) | Time (50 staged files) |
|---|---|---|
| Husky + lint-staged (ESLint + Prettier) | ~3.5s | ~6s |
| Lefthook parallel (ESLint + Prettier) | ~2s | ~4s |
| pre-commit framework (equivalent hooks) | ~4s (warm) / ~30s (cold) | ~8s |
| Raw shell script (eslint + prettier on all files) | ~15s | ~15s |
Lefthook's parallel execution gives it a consistent edge. The pre-commit framework's "cold" run (first time, downloading environments) is slow, but subsequent runs are comparable. Running tools on all files instead of just staged files is dramatically slower, which is why lint-staged and Lefthook's {staged_files} exist.
Recommendations
JavaScript/TypeScript projects (simple): Use Husky + lint-staged. It is the established convention, every tutorial and blog post assumes this setup, and it works well for projects with straightforward linting needs. Setup takes under 5 minutes.
JavaScript/TypeScript projects (monorepo or performance-sensitive): Use Lefthook. Parallel execution and built-in staged file filtering make it faster than Husky + lint-staged, and the single lefthook.yml is easier to maintain than Husky's multiple shell scripts plus a lint-staged config.
Python projects: Use the pre-commit framework. It is the community standard, the plugin ecosystem is extensive, and version-pinned hooks solve the "works on my machine" problem.
Polyglot monorepos: Use Lefthook. Its root directory support, glob-based filtering, and language-agnostic design handle mixed-language repositories better than any alternative.
General advice: Start with pre-commit hooks that are fast and non-blocking -- formatting and basic linting. Add type checking and tests to pre-push hooks where the slightly longer wait is more acceptable. Never put slow operations (full test suite, container builds) in pre-commit. Developers will disable hooks entirely, and then you have nothing.