Every commit pauses for 30–45 seconds while the hook runs tsc over the whole project, then eslint ., then the unit tests; developers respond with git commit --no-verify, and a month later nobody has hooks installed. The checks were right; the placement was wrong. This page measures where hook time goes, moves each check to the cheapest stage that still catches its problems, and runs the remainder in parallel with lefthook, as part of pre-commit hooks and local quality gates.

A practical budget: pre-commit under about two seconds for a typical change, pre-push under about twenty. Anything slower belongs in CI.

Diagnostic

Time the current hook as a whole, then each command it runs, against a representative small change:

#!/usr/bin/env bash
set -euo pipefail
echo "// probe" >> web/src/cart.ts && git add web/src/cart.ts
/usr/bin/time -p .git/hooks/pre-commit 2>&1 | tail -3
for cmd in "npx tsc --noEmit -p web" "npx eslint web/src" "npx vitest run --root web" "gitleaks protect --staged --no-banner"; do
  s=$(date +%s%N); sh -c "$cmd" >/dev/null 2>&1 || true
  echo "$(( ($(date +%s%N) - s) / 1000000 )) ms  $cmd"
done | sort -rn
git restore --staged web/src/cart.ts && git checkout -- web/src/cart.ts

Expected bad output:

real 41.62
21480 ms  npx vitest run --root web
13950 ms  npx tsc --noEmit -p web
 5310 ms  npx eslint web/src
  220 ms  gitleaks protect --staged --no-banner

One staged file triggers a full test run, a whole-project type-check and a lint over every source file. The secret scan — the check that most needs to run before a commit exists — takes a fifth of a second.

Where a 41-Second Commit Hook Spends Time Bar chart of time per check in the original pre-commit hook. Where a 41-Second Commit Hook Spends Time unit tests 21.5 s tsc whole project 14 s eslint all files 5.3 s secret scan 0.2 s
Tests and type-checking dominate but catch little that a staged-files lint and CI would not.

Root cause

Slow hooks come from checks that scale with the repository instead of the change, placed at the most frequent stage. A commit happens many times an hour; a push a few times a day; CI once per push. Whole-project type-checking and test suites scale with the codebase, so running them per commit charges the full cost for a one-line change. Linting everything instead of staged files does the same. And sequential execution adds every check's time even when they are independent. None of these checks is wrong to have — they are wrong to have there. Moving each to the stage where its cost matches its value, and parallelising what remains, keeps the protection while restoring the speed that keeps hooks installed.

Resolution

  1. Assign each check to a stage by cost and by what it protects:

    • pre-commit (seconds, per commit): format and lint staged files, secret scan, whitespace and merge markers.
    • pre-push (tens of seconds, per push): type-check, tests related to changed files.
    • CI (minutes): full test suite, all-files lint, builds.
  2. Install lefthook and write the configuration with staged-file globs and parallel execution:

#!/usr/bin/env bash
set -euo pipefail
npm install --save-dev --save-exact [email protected]
npx lefthook install
pre-commit:
  parallel: true
  commands:
    prettier:
      glob: "web/**/*.{ts,tsx,json,css,md}"
      run: npx prettier --write {staged_files}
      stage_fixed: true
    eslint:
      glob: "web/**/*.{ts,tsx}"
      run: npx eslint --max-warnings=0 --cache {staged_files}
    ruff:
      glob: "api/**/*.py"
      run: ruff check --fix {staged_files} && ruff format {staged_files}
      stage_fixed: true
    secrets:
      run: gitleaks protect --staged --redact --no-banner

pre-push:
  parallel: true
  commands:
    typecheck:
      run: npx tsc --noEmit -p web --incremental --tsBuildInfoFile .cache/tsbuildinfo
    related-tests:
      run: npx vitest related --run --root web $(git diff --name-only origin/main...HEAD -- web/src | tr '\n' ' ')

Save as lefthook.yml. {staged_files} limits each tool to the staged files matching its glob; stage_fixed re-stages files the formatter changed; --cache and --incremental make repeated ESLint and TypeScript runs much faster.

  1. Keep CI as the full gate with the complete test suite and all-files lint, so nothing moved out of the commit hook is lost.

  2. Re-measure with the diagnostic script on the same probe change.

Checks Placed by Cost Layers showing which checks run at pre-commit, pre-push and in CI after rebalancing. Checks Placed by Cost pre-commit prettier, eslint staged, ruff, secrets pre-push tsc incremental, related tests CI full tests, all-files lint, build
Each check moves to the least frequent stage that still catches its problems early.

Expected output

$ git commit -m "fix(cart): keep totals const"
╭────────────────────────────────────╮
│ 🥊 lefthook v1.7.15  hook: pre-commit │
╰────────────────────────────────────╯
┃  secrets ❯ no leaks found
┃  eslint ❯
┃  prettier ❯ web/src/cart.ts 12ms
summary: (done in 1.74 seconds)
✔️  eslint  ✔️  prettier  ✔️  secrets
$ git push
summary: (done in 9.81 seconds)
✔️  typecheck  ✔️  related-tests

The commit hook finishes in under two seconds and the push hook in about ten, while the full suite still runs in CI.

The distribution of time also changed shape: instead of one long pause on every commit, developers pay a short pause per commit and a moderate one per push, which matches how often each happens. In most teams that is enough to get hooks reinstalled voluntarily, and the number of CI failures caused by formatting and lint drops sharply within a week because the checks are finally running.

Prevention

  1. Set a time budget and check it in CI. A small job that runs lefthook run pre-commit against a fixed probe change and fails above three seconds catches slow additions before they reach developers.

  2. Require a glob on every pre-commit command in review; a command without one runs on the whole repository.

  3. Watch --no-verify usage indirectly through CI failures that a hook would have caught. A rise usually means a hook became slow again.

Hook Time as Checks Moved Timeline of pre-commit duration as each optimisation was applied. Hook Time as Checks Moved baseline 41.6 s, all sequential tests to CI 20.1 s tsc to pre-push 6.2 s staged-only lint 3.0 s parallel run 1.7 s
Each step is independently useful; together they took the hook from 41 s to under 2 s.

Platform caveats

Windows (native): lefthook is a single binary and runs natively; commands like $(git diff ...) in run use the configured shell, so set lefthook.yml's commands to portable forms or run them through sh from Git for Windows.

macOS: GUI Git clients may not have npx on PATH; lefthook reads ~/.lefthook.rc to prepend the toolchain manager's shims.

Apple Silicon (ARM64): lefthook ships native arm64 builds through npm, Homebrew and Go.

Monorepos: add root: to commands so tools run from their package directory, and keep globs scoped to that package.

Rollback

Uninstall lefthook's hooks and reinstall the previous manager, or keep lefthook and move a check back to pre-commit if a class of problems starts slipping through:

#!/usr/bin/env bash
set -euo pipefail
npx lefthook uninstall
npm uninstall lefthook
pre-commit install 2>/dev/null || echo "reinstall the previous hook manager if needed"

Frequently Asked Questions

Is it safe to move tests out of the pre-commit hook?

Yes, as long as they run at pre-push and in CI. Pre-commit is the wrong place for anything whose cost scales with the codebase; the goal is fast feedback on the change, with full verification later.

Why lefthook instead of the pre-commit framework?

lefthook runs commands in parallel natively, is a single fast binary, and uses the project's installed tools directly. The pre-commit framework has a larger ecosystem of ready-made hooks. Either works if checks are placed by cost.

Does running hooks in parallel cause conflicts?

Formatters that modify the same files can conflict. Scope globs so no two fixing commands touch the same file type, or run fixers sequentially and checkers in parallel with separate groups.

What if someone still bypasses the hooks?

CI runs the complete checks regardless, so bypassing only delays feedback. Keeping hooks fast is the best way to keep them used.