Speeding Up Slow Git Hooks With lefthook
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.
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
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.
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.
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.
Re-measure with the diagnostic script on the same probe change.
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
Set a time budget and check it in CI. A small job that runs
lefthook run pre-commitagainst a fixed probe change and fails above three seconds catches slow additions before they reach developers.Require a glob on every pre-commit command in review; a command without one runs on the whole repository.
Watch
--no-verifyusage indirectly through CI failures that a hook would have caught. A rise usually means a hook became slow again.
Platform caveats
Windows (native): lefthook is a single binary and runs natively; commands like
$(git diff ...)inrunuse the configured shell, so setlefthook.yml's commands to portable forms or run them throughshfrom Git for Windows.
macOS: GUI Git clients may not have
npxonPATH; lefthook reads~/.lefthook.rcto 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.