Git hooks don't run for teammates because .git/hooks/ is never committed, so every clone starts with no hooks at all. Husky, lefthook, and pre-commit all solve this the same way: the hook definitions live in the repo, and an install step points Git at them. Pick husky if your repo is already a Node project, lefthook if you want one fast binary with no runtime dependency, and pre-commit if your team is Python-heavy or wants a large catalog of ready-made hooks.
Why do hooks work on my machine but not on a teammate's?
The symptom is boring and easy to miss: a teammate pushes code that your pre-commit lint would have rejected, and nobody sees an error, because nothing ran. There is no failure to find.
Git only executes hooks from the directory in core.hooksPath, which defaults to .git/hooks. That directory is inside .git, so it is not part of the repository content. Anything you put there stays on your machine. You can check what a clone is actually using:
git config --get core.hooksPath # empty output means the default .git/hooks
ls .git/hooks | grep -v '\.sample$'
If the second command prints nothing, no hooks are installed in that clone. A fresh clone always looks like this.
The first fix people try is committing a scripts/hooks/ folder and asking everyone to symlink it. That works until someone forgets, which is the whole problem. The tools below automate the one step that humans skip.
Takeaway: hooks are local state, so any hook setup that depends on a person remembering to run a command will eventually be missing on someone's machine.
How do husky, lefthook, and pre-commit install themselves?
All three need an install step to run once per clone. They differ in how automatic they can make it.
| Husky | Lefthook | pre-commit | |
|---|---|---|---|
| Runtime | Node | Single Go binary | Python |
| Config | Shell scripts in .husky/
|
lefthook.yml |
.pre-commit-config.yaml |
| Auto-install trigger |
prepare script in package.json
|
lefthook install (a prepare script or a manual step) |
pre-commit install (manual or a setup script) |
| Staged-files filtering | Add a tool such as lint-staged | Built in ({staged_files}, glob) |
Built in (hooks receive matching files) |
| Parallel execution | Not built in | Built in (parallel: true) |
Not by default for different hooks; it runs hooks serially |
| Hook catalog | Bring your own commands | Bring your own commands | Large community catalog of hook repos |
Husky's trick is that npm install runs the prepare script automatically. As of its v9 line, setup is npx husky init, which creates a .husky/ directory and a prepare entry that runs husky. That command sets core.hooksPath to a path inside .husky/.
{
"scripts": {
"prepare": "husky"
}
}
# .husky/pre-commit
npx lint-staged
Lefthook looks like this:
# lefthook.yml
pre-commit:
parallel: true
commands:
lint:
glob: "*.{js,ts,tsx}"
run: npx eslint {staged_files}
format-check:
glob: "*.{js,ts,tsx,json,md}"
run: npx prettier --check {staged_files}
And pre-commit:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-merge-conflict
Check the rev against the project's latest tag before you copy it; pre-commit autoupdate rewrites it for you.
Takeaway: the real difference between these tools is not the hook syntax, it's what triggers the install on a fresh clone.
What goes wrong even after you adopt one?
These are the failure modes I'd check first when "we have husky, but it still isn't running."
The install step never ran. Husky depends on prepare. If someone installs with npm ci --ignore-scripts, or a package manager is configured to skip lifecycle scripts, prepare doesn't run and no hooks get wired up. The same applies to Docker images and CI, where this is usually what you want, but it also applies to a laptop with a hardened npm config. Verify with git config --get core.hooksPath.
Another tool already owns core.hooksPath. pre-commit refuses to install when it's set, with a message along the lines of "Cowardly refusing to install hooks with core.hooksPath set." If you're migrating from husky to pre-commit, unset it first:
git config --unset-all core.hooksPath
pre-commit install
Git GUIs and PATH problems. A hook that calls npx or node can fail under a GUI client that launches with a different PATH than your shell, especially with version managers like nvm. Lefthook's single binary sidesteps part of this, though the commands it runs still need their own tools on PATH.
Someone passed --no-verify. git commit --no-verify skips pre-commit and commit-msg hooks by design. This is not a bug and you can't prevent it locally.
Takeaway: if hooks stop running, check core.hooksPath and whether the install script executed, in that order, before blaming the hook tool.
Should hooks be the only enforcement?
No. Hooks are a convenience for fast feedback, not a gate. Anyone can skip them, and a contributor on a fork has never run your install step. Run the same checks in CI so the merge button is the actual enforcement.
pre-commit makes this easy: pre-commit run --all-files in a CI job runs the same hook set you run locally. With husky or lefthook, call the same underlying commands (lint, format check) directly from your CI workflow rather than invoking the hook runner. Keep the local hook fast, ideally only the staged files, and let CI do the slow, complete checks.
A drawback worth stating for each: husky needs a Node toolchain and adds nothing for non-JS repos; lefthook has a smaller ecosystem of shared recipes, so you write more commands yourself; pre-commit creates its own isolated environments for hooks, which makes the first run slow and adds cache management to CI.
Takeaway: treat local hooks as a speed-up and CI as the rule, because only CI applies to everyone.
FAQ
Why are my git hooks not running after cloning a repo?
Hooks live in .git/hooks, which is not versioned, so a fresh clone has none. You need a tool or script that installs them, such as npm install triggering husky's prepare, lefthook install, or pre-commit install.
How do I check whether a hook is installed?
Run git config --get core.hooksPath and list the target directory (.git/hooks if the config is empty). A hook file must exist there without the .sample suffix and be executable.
Can I enforce git hooks for everyone on the team?
Not locally, since --no-verify and skipped install steps bypass them. Run the same checks in CI and require them to pass before merging.
Bottom line
If your repo already has a package.json, husky plus a staged-files runner is the least friction, because npm install wires it up for free. If you want parallel hooks and no language runtime, lefthook is the one to try. If your team writes Python or wants ready-made hooks for YAML, secrets, and merge conflicts, pre-commit is the strongest fit. Whichever you choose, mirror the checks in CI.
Top comments (0)