Developer Productivity

Pre-Commit Hooks Developers Actually Keep Enabled

Which pre-commit hooks actually serve the developer rather than slowing them down. Concrete lint, format, and type-check setups that teams keep enabled.

Mohammed Saqib7 min read
A close-up of a laptop displaying code in a dimly lit room with a coffee mug nearby.
Photo by Daniil Komov on Pexels · Pexels License

I’ve watched teams adopt pre-commit hooks with enthusiasm, only to see them silently bypassed within weeks. The pattern is always the same: a hook that adds thirty seconds or more to every commit trains developers to type --no-verify as reflex, and a hook that lints the entire project instead of the staged diff invites frustration before it even runs once. If you want hooks that stay enabled, you have to optimize for speed and surgical precision.

Why Most Pre-Commit Hooks Get Disabled Within a Month

The root cause is almost always latency. A pre-commit hook that takes more than a second or two feels like a tax on every save-commit cycle. When that tax grows to 30 seconds—running a full test suite, type-checking every file, linting unchanged code—developers learn to bypass it. I’ve seen teams disable the hook entirely after one sprint, then quietly remove it from the repo’s setup docs. The second failure mode is scope creep: a hook that lints the entire project instead of the staged diff, or that runs a full test suite before allowing a commit. That kind of hook punishes everyone for changes they didn’t make, and it almost never survives the first “urgent hotfix” that gets committed with --no-verify.

The Three Hooks That Survive Production Use

Format-on-stage

A tool like lint-staged running Prettier or dprint only on the files you’ve git added. It applies formatting automatically so the diff stays clean, and the hook exits in under a second. My typical config:

// .lintstagedrc.json
{
  "*.{js,ts,tsx,jsx}": ["prettier --write"],
  "*.{json,md,yaml}": ["prettier --write"]
}

Coupled with Husky’s pre-commit script that calls npx lint-staged, this hook never feels like a bottleneck—it applies formatting in the time it takes to register the command. No manual fixing, no cascading warnings.

Lint-with-diff

Running ESLint or Biome on the entire project after a one-line change is wasteful. The better approach is to lint only the lines changed in the staged diff. Using lint-staged with a pattern like eslint --fix against the matching files already limits scope, but if you want per-line checking, pass --diff to ESLint (ESLint v9+ supports --report-unused-disable-directives and diff-aware checking via plugins). My preference:

{
  "*.{js,ts,tsx,jsx}": ["eslint --fix --max-warnings 0"]
}

This catches formatting and logic issues in the code you actually touched, without scanning the hundred other files that haven’t changed.

Type-check-changed-files

TypeScript’s tsc --noEmit is too slow for the whole project once it grows beyond a few hundred files. Instead, use a tool like tsc-files that type-checks only the staged files. You run:

{
  "*.{ts,tsx}": ["tsc-files --noEmit"]
}

This relies on a project-level tsconfig.json but only inspects the changed files and their transitive dependencies. Completion times drop from minutes to under two seconds for most commits. I’ve kept this hook enabled on a monorepo with 2,000+ TypeScript files for over a year. The key is caching: TypeScript’s tsconfig.build.tsbuildinfo means unchanged files skip parsing on subsequent checks. For a deeper look at managing TypeScript in large repos, see Large-Scale Refactors with jscodeshift and ts-morph.

Tooling: Husky vs Lefthook vs Pre-commit (Python)

Feature Husky Lefthook pre-commit (Python)
Setup npm install husky + npx husky init brew install lefthook or npm install -D lefthook pip install pre-commit + pre-commit install
Execution model Shell script per hook YAML config with parallel/piped tasks YAML config, manages virtualenvs per hook
Parallel execution Manual via & or external tooling Built-in (parallel: true) Sequential by default, parallel with -j flag
Language support Any (shell) Any (runs any binary) Python-first; non-Python hooks require extra config
Startup overhead ~50ms (shell) ~50ms (Go binary) ~500ms (Python virtualenv startup)
Common use case Node/JS monorepos Monorepos with mixed languages Python ecosystems

Husky remains the most common for Node projects. It’s simple—configure .husky/pre-commit as a shell script that calls npx lint-staged. The trade-off is that pure shell logic gets messy for complex workflows (parallel tasks, per-language hooks). Lefthook, written in Go, handles parallelism out of the box. In lefthook.yml you define groups:

# lefthook.yml
pre-commit:
  parallel: true
  commands:
    format:
      run: npx prettier --write {staged_files}
    lint:
      run: npx eslint --fix --max-warnings 0 {staged_files}
    typecheck:
      run: npx tsc-files --noEmit

This runs lint, format, and type check simultaneously. Better for monorepos with mixed languages. The Python pre-commit framework is popular in Python ecosystems. It manages hook environments and plugins, but the overhead of spawning a Python virtualenv for every commit adds roughly 500ms startup time. Worth it if your team already ships Python tools; for pure Node/TypeScript projects, it’s overkill.

Configuration That Avoids the 'Annoying Hook' Trap

Set fail_fast: true in Lefthook or similar concurrency limits in Husky so a failing format hook cancels the faster lint task immediately. The developer gets one clear failure message, not a cascade of errors from tasks that ran after the first one already failed. For ESLint and Prettier, skip --max-warnings and instead use --max-warnings 0—a warning is a signal you chose not to fix; allow zero or treat them as errors. This eliminates the “I have 200 warnings but only 1 error” noise that trains developers to ignore the output.

Store cached results for type checking: TypeScript’s incremental build (tsconfig.build.tsbuildinfo) means unchanged files skip parsing on the next commit. In your tsconfig.json, set "incremental": true and "tsBuildInfoFile": ".tsbuildinfo". The type-check hook then reuses that cache across commits. For monorepo setups, the caching strategy also applies to package management—see pnpm Workspaces: Filters, Catalogs, and CI Caching for how to extend this pattern to dependency resolution.

Failure Modes: What Breaks Hooks in Practice

Binary conflicts are the most common offboarding trigger. Tools like Prettier or ESLint that require a specific Node version will crash if a teammate has a different version managed by nvm. The hook either throws an error or produces different formatting. Pin the Node version via .nvmrc and add a hook check:

# .husky/pre-commit
if [ -f .nvmrc ]; then
  nvm use || (echo "Node version mismatch. Run 'nvm use'." && exit 1)
fi

Monorepo file globs cause another kind of pain. A lint-staged glob like *.{js,ts} accidentally matches files in node_modules or dist if not properly scoped. Explicitly scope globs to src/**/*.{js,ts,tsx,jsx} to avoid touching generated or third-party code.

Secret scanning hooks (e.g., git-secrets, truffleHog) produce too many false positives—commits with test data, sample configs, or API placeholder values get flagged. Teams disable them fast. Either tune the regex patterns aggressively or move secret scanning to the CI job, where you can afford slower analysis and false positives are less disruptive. For a deeper look at CI design that avoids these issues, see Designing GitHub Actions That Fail Fast and Explain Why.

Environment inconsistency between developer machines also breaks hooks. A teammate on Windows may have different line endings or binary paths than someone on macOS. Using Dev Containers or a reproducible environment script eliminates these discrepancies entirely—see Reproducible Dev Environments with Dev Containers and a Single Script for a setup that guarantees every hook runs in the same environment.

The One Hook That’s Not Worth Adding

Running the full test suite in pre-commit is a deal-breaker for any repo with more than 20 tests. It takes too long and prevents the quick-commit cycle that makes version control useful. I’ve seen teams try to fix this by running only tests for changed modules, but that’s still slow if the test runner has to discover modules, compile them, and bootstrap a test environment.

Instead, add a pre-push hook that runs a fast subset of tests (unit tests for changed modules) and keep the full suite for CI. This preserves the “safety net” trade-off without punishing every git commit. A minimal pre-push script:

# .husky/pre-push
npm run test:changed

Where test:changed uses a tool like jest --onlyChanged or vitest related to run tests for staged files. For commit message linting, which can also be a source of friction, check out Conventional Commits Without Hand-Written Changelogs for a pattern that adds zero latency.

Key takeaways

  • Keep every pre-commit hook under 1–2 seconds. If it takes longer, it will be bypassed.
  • Scope hooks to the staged diff: lint-staged for formatting and linting, tsc-files for type checking.
  • Use --max-warnings 0 and fail_fast to avoid cascading noise.
  • Pin Node versions in .nvmrc and check them in the hook to prevent binary conflicts.
  • Move slow tasks (full test suite, secret scanning) to CI or a pre-push hook.

Frequently asked questions

Should my pre-commit hook lint the whole project or just changed files?
The two most common setups are running hooks per-file (via lint-staged) or running them project-wide. Per-file is faster for large repos—only changed lines are checked, so a 5-file commit finishes in seconds. Project-wide hooks, especially type checking, can take minutes and get disabled immediately.
What makes a development team disable a pre-commit hook?
Dead hooks usually have one of two problems: they're too slow (a full test suite before commit) or they produce noisy output that doesn't map clearly to the diff you just wrote. If the hook gives you a wall of lint warnings from code you didn't touch, it gets bypassed with --no-verify inside a week.
Should I fail the commit on warnings or only on errors?
Only if your team is disciplined about fixing warnings. eslint --max-warnings 0 is the right call—failing on any warning forces the fix. But if you allow 50 warnings, that number creeps up, the hook produces noise, and developers start ignoring the output. Enforce severity thresholds, not arbitrary counts.
#git#productivity#tooling#eslint#monorepo
Share

Keep reading