Husky is the default choice for Git hooks in JavaScript projects. It works, but it carries assumptions that start to chafe in modern setups: Node.js as a runtime requirement, a shell script per hook, and a sequential execution model that can make pre-commit feel slow. Lefthook fixes all of that.
What’s wrong with husky
Husky works by writing shell scripts into .husky/. Each hook is a file like .husky/pre-commit that you edit by hand. That’s fine for a single repo, but:
- Node.js dependency.
huskyis an npm package. In a polyglot monorepo, or any project that isn’t primarily JavaScript, pulling in Node just to run Git hooks is overhead. - Sequential by default. Your pre-commit hook runs linting, then type-checking, then tests, one after another. On a large codebase this is slow.
- No parallelism primitives. You can hack parallel execution with
&andwaitin shell, but it’s manual and fragile. - Per-hook files. Managing multiple hooks means multiple files, with no central view of what runs where.
What lefthook does differently
Lefthook is a single binary written in Go. No Node, no npm install in CI just to get hooks. Drop it anywhere, macOS, Linux, Windows, Docker, and it works.
Configuration lives in a single lefthook.yml:
pre-commit:
parallel: true
commands:
lint:
glob: "*.{js,ts}"
run: npx eslint {staged_files}
format:
glob: "*.{js,ts,css}"
run: npx prettier --check {staged_files}
pre-push:
commands:
typecheck:
run: tsc --noEmit
parallel: true runs all commands concurrently. On a project where lint + format used to run sequentially, they finish in parallel. Type checking moves to pre-push where it belongs; more on that below.
Migration from husky
-
Remove husky:
npm uninstall husky rm -rf .huskyRemove the
preparescript frompackage.jsonif you added one for husky. -
Install lefthook:
# npm npm install --save-dev lefthook # or install the binary directly (no Node required) brew install lefthook -
Create
lefthook.ymlat the repo root and migrate your hooks. A typical huskypre-committhat runs lint-staged becomes:pre-commit: parallel: true commands: lint-staged: run: npx lint-stagedOr skip lint-staged entirely. Lefthook has
globand{staged_files}built in, so you can pass only changed files to each command natively. -
Install the Git hooks:
npx lefthook installThis writes the actual hook files into
.git/hooks/. Commitlefthook.yml; the hook files themselves don’t need to be committed.
Monorepo and polyglot projects
Lefthook’s binary model shines in monorepos. You can scope commands to subdirectories:
pre-commit:
parallel: true
commands:
api-lint:
root: "packages/api/"
glob: "**/*.go"
run: golangci-lint run {staged_files}
web-lint:
root: "packages/web/"
glob: "**/*.ts"
run: npx eslint {staged_files}
Each command only runs if files matching its glob are staged. The Go linter doesn’t fire when you only touch TypeScript files.
CI considerations
In CI you usually don’t want Git hooks to run. Lefthook respects the CI environment variable; if it’s set, lefthook install is a no-op. No configuration needed.
If you want to run the same checks in CI that lefthook runs locally, you can invoke them directly:
npx lefthook run pre-commit
This executes the hook commands without needing a Git event, so your CI pipeline and local hooks stay in sync from the same config file.
Tips
Alias lefthook to lh. You’ll type it constantly when debugging. Add this to your shell config:
alias lh=lefthook
Then lh run pre-commit replays the hook without making an actual commit. This is the fastest way to iterate on your config: change lefthook.yml, run lh run pre-commit, see what happens.
Put file-aware commands in pre-commit, whole-codebase commands in pre-push. This is the most important split to get right.
Commands like formatting and linting can accept a list of files. Run them at commit time against only staged files, fast, focused, no wasted work:
pre-commit:
parallel: true
commands:
format:
glob: "*.{js,ts,css}"
run: prettier --write {staged_files}
lint:
glob: "*.{js,ts}"
run: eslint {staged_files}
Type checking and full test suites can’t meaningfully scope to staged files; they need the whole codebase. Put those in pre-push instead:
pre-push:
parallel: true
commands:
typecheck:
run: tsc --noEmit
test:
run: bun test
pre-push is fine for heavier checks. git push is already a network operation. You’re waiting for bytes to travel to a remote server, so a few seconds of local checking before that happens isn’t disruptive. The bar is just: don’t make it unreasonably long.
Which is why tool choice matters here. tsc --noEmit on a large project can take 20-30 seconds. The TypeScript Go port (tsgo) runs the same check in a fraction of the time. Similarly, bun test is significantly faster than Jest for most test suites; cold start alone is an order of magnitude quicker.
If your pre-push hook runs in under 10 seconds, people will leave it on. If it runs in 45 seconds, they’ll start using --no-verify.
Tests can live in both hooks. A reasonable split:
pre-commit: run tests only for files related to what’s staged (if your test runner supports it)pre-push: run the full suite
pre-commit:
commands:
test-related:
glob: "**/*.{ts,tsx}"
run: bun test --testPathPattern={staged_files}
pre-push:
commands:
test-all:
run: bun test
This gives you fast feedback at commit time without waiting for every test, and a full safety net before anything leaves your machine.
The result
One config file. Parallel execution by default. No Node dependency if you install the binary. Scoped globs so commands only run when relevant files change. For most projects this is a straight upgrade, faster hooks with less configuration sprawl.
If you’re starting a new project, skip husky entirely. If you’re maintaining an existing one, the migration takes 20 minutes.