← All articles
GIT Git Hooks and Pre-commit Tools: Husky, Lefthook, and... 2026-02-09 · 7 min read · git · hooks · pre-commit

Git Hooks and Pre-commit Tools: Husky, Lefthook, and lint-staged

Git 2026-02-09 · 7 min read git hooks pre-commit husky lefthook

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:

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:

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.