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 makes pre-commit feel sluggish on anything but small codebases. Lefthook addresses all of those things in one go.
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, which feels manageable at first but starts to sprawl once you have more than one or two hooks. Beyond the file-per-hook structure, a few other friction points tend to surface:
- Node.js dependency.
huskyis an npm package, so in a polyglot monorepo, or any project that isn’t primarily JavaScript, you’re pulling in Node just to run Git hooks. - Sequential by default. Your pre-commit hook runs linting, then type-checking, then tests, one after another, and on a large codebase that sequencing really adds up.
- No parallelism primitives. You can hack parallel execution with
&andwaitin shell, but it’s manual, easy to get wrong, and fragile to maintain. - Per-hook files. With no central config, there’s no single place to look at what runs where and in what order.
What lefthook does differently
Lefthook is a single binary written in Go, which means 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, and lefthook handles this without any configuration. It respects the CI environment variable, so if that’s set, lefthook install is a no-op.
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, so add this to your shell config:
alias lh=lefthook
Then lh run pre-commit replays the hook without making an actual commit, and that becomes 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, so 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 because they need the whole codebase, so those belong 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, and you’re waiting for bytes to travel to a remote server, so a few seconds of local checking before that happens doesn’t feel disruptive. The only real bar is: don’t make it unreasonably long.
That’s why tool choice matters here. tsc --noEmit on a large project can take 20-30 seconds, while 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, where 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 reaching for --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, and scoped globs so commands only run when relevant files change. For most projects it feels like a straight upgrade: faster hooks with noticeably less configuration sprawl.
If you’re starting a new project, skip husky entirely. If you’re maintaining an existing one, the migration is about 20 minutes of work.