Fix: Lefthook Not Working — Install, Staged Files, Glob Filters, Parallel Runs, and CI Skip
Quick Answer
How to fix Lefthook errors — hooks not running after install, {staged_files} empty for new files, glob filter not matching, parallel: true ordering, LEFTHOOK=0 to skip in CI, and lefthook-local.yml overrides.
The Error
You install Lefthook but git commit doesn’t run anything:
$ git commit -m "test"
[main abc1234] test
# No lefthook output. Hooks ignored.Or {staged_files} is empty even though you have changes:
# lefthook.yml
pre-commit:
commands:
lint:
run: oxlint {staged_files}$ git commit -m "..."
# Runs: oxlint
# Files arg is empty — lints nothing.Or the glob filter rejects all your files:
pre-commit:
commands:
lint:
glob: "*.{ts,tsx}"
run: oxlint {staged_files}$ git commit
# No files matched glob "*.{ts,tsx}" — even though you have TS files staged.Or hooks run in CI when you don’t want them to:
# CI installs deps and runs lefthook install — every commit now triggers hooks.Why This Happens
Lefthook reads lefthook.yml in the repo root and wires real Git hooks (.git/hooks/pre-commit etc.) that delegate to the Lefthook binary. Most failures map to one of:
lefthook installwasn’t run. Without it, no.git/hooks/*scripts exist. The config is read only when those scripts call Lefthook.{staged_files}is “files staged for THIS commit.” That means files added viagit add. New files that haven’t been added show up as untracked, not staged. Also,{staged_files}returns an empty string when zero match — your command then runs without args.globuses extended glob syntax.*.{ts,tsx}(brace expansion) works in most Lefthook versions but*.ts,*.tsx(comma-separated) doesn’t unless you useglob: ["*.ts", "*.tsx"].LEFTHOOK=0env var disables everything. Useful for CI; needs to be set beforegit commitruns.
Fix 1: Run lefthook install
After adding lefthook.yml, install the Git hooks:
npm install -D lefthook # or: brew install lefthook / go install ...
npx lefthook installlefthook install writes .git/hooks/pre-commit, .git/hooks/pre-push, etc. — small shell scripts that invoke lefthook run <hook>. Without these files, Git has no hooks at all.
For monorepos, run install from the root. Lefthook walks up from the working dir to find lefthook.yml.
To verify:
cat .git/hooks/pre-commit
# Should show a shell script calling lefthook.Pro Tip: Wire lefthook install to a postinstall script so contributors get hooks without manual setup:
{
"scripts": {
"postinstall": "lefthook install"
}
}For Go projects, add to your Makefile’s bootstrap target.
Fix 2: Use {staged_files} Correctly
{staged_files} is the list of files that would be in this commit. To check what Lefthook sees:
pre-commit:
commands:
debug:
run: 'echo "staged: {staged_files}"'git add some-file.ts
git commit -m "test"
# Should print: staged: some-file.tsIf the list is empty:
- You didn’t
git addthe files yet (Lefthook only sees staged changes). - The
globfilter excluded them all (see Fix 3). - The previous command in the chain consumed them via
filesinstead ofstaged_files.
For commands that need all tracked files (not just staged):
pre-commit:
commands:
typecheck:
run: tsc --noEmit # No file arg — checks the whole projectFor commands that need only changed files in the working tree (not necessarily staged):
pre-commit:
commands:
lint:
run: oxlint {files}{files} is changed files; {staged_files} is staged-for-commit files; {all_files} is everything tracked.
Common Mistake: Putting a transformation command after a lint that expects the original content. If prettier --write {staged_files} fixes formatting, the lint runs on the new content but the old version is still staged. Re-add with stage_fixed: true:
pre-commit:
commands:
format:
glob: "*.{ts,tsx,js,jsx}"
run: prettier --write {staged_files}
stage_fixed: truestage_fixed: true re-runs git add on the modified files so the formatted version makes it into the commit.
Fix 3: Glob and File Filters
Two patterns:
# Single pattern (brace expansion works in most versions):
glob: "*.{ts,tsx}"
# Array form (safest, always works):
glob:
- "*.ts"
- "*.tsx"
- "src/**/*.tsx"glob filters {staged_files} (and {files}, {all_files}) to those matching. Files that don’t match are excluded from the command.
For monorepo-style filtering by path:
pre-commit:
commands:
backend:
glob: "backend/**/*.go"
run: go vet ./backend/...
frontend:
glob: "frontend/**/*.{ts,tsx}"
run: cd frontend && pnpm lintglob matches against the staged file paths from repo root. backend/server.go matches backend/**/*.go but server.go doesn’t.
For excluding patterns, use exclude:
pre-commit:
commands:
lint:
glob: "*.{ts,tsx}"
exclude:
- "*.test.{ts,tsx}"
- "src/generated/**"
run: oxlint {staged_files}Common Mistake: Putting glob: "**/*.ts" and expecting it to match src/index.ts. The pattern matches **/*.ts (any depth) — but Lefthook’s globber treats ** correctly only in recent versions. For older versions, list specific paths.
Fix 4: Skip Conditions
Skip hooks entirely:
LEFTHOOK=0 git commit -m "skip hooks"Or per-command:
pre-commit:
commands:
expensive-test:
skip:
- merge
- rebase
run: npm testskip accepts:
- Operations:
merge,rebase. - Conditions via
ref:skip: { ref: main }. - Run commands:
skip: "[ -n \"$SKIP_TESTS\" ]".
For CI environments:
pre-commit:
commands:
lint:
skip:
- run: '[ "$CI" = "true" ]'
run: oxlint {staged_files}Or simply set LEFTHOOK=0 in your CI config (GitHub Actions, GitLab CI). The env var has the same effect as commenting out the hook.
Pro Tip: CI should run lint/test as their own jobs, not via Lefthook hooks. Hooks are for fast local feedback; CI is for trusted, isolated checks.
Fix 5: Parallel vs Sequential
By default, commands within a hook run sequentially. For independent commands, enable parallel:
pre-commit:
parallel: true
commands:
lint:
glob: "*.{ts,tsx}"
run: oxlint {staged_files}
typecheck:
run: tsc --noEmit
test:
run: vitest run --changedAll three run concurrently. The hook waits for all to finish; if any fails, the commit aborts.
Note: Don’t parallel: true commands that modify the same files. prettier --write and eslint --fix in parallel race and may corrupt files. Run formatters sequentially before linters.
For ordered phases:
pre-commit:
commands:
format:
glob: "*.{ts,tsx}"
run: prettier --write {staged_files}
stage_fixed: true
priority: 1
lint:
glob: "*.{ts,tsx}"
run: oxlint {staged_files}
priority: 2priority orders execution (lower runs first). Same priority can run in parallel if parallel: true.
Fix 6: Local Overrides for Per-Developer Config
Some hooks make sense for the team; some are personal. Use lefthook-local.yml (gitignored) for personal overrides:
# lefthook-local.yml — gitignored
pre-commit:
commands:
# Disable the team's slow integration test for local commits:
integration-test:
skip: trueLefthook merges lefthook.yml + lefthook-local.yml. The local file takes precedence per command.
Add to .gitignore:
lefthook-local.ymlCommon Mistake: Committing lefthook-local.yml by accident. Then everyone gets one developer’s overrides. Always gitignore.
Fix 7: Pre-Push and Commit-Msg
Pre-commit is fast, runs often. For slower checks, use pre-push:
pre-push:
commands:
test:
run: npm test
typecheck:
run: tsc --noEmit
audit:
run: npm audit --omit=devPre-push runs only when you git push, not on every commit. Good for tests that take seconds.
For commit message linting:
commit-msg:
commands:
commitlint:
run: npx commitlint --edit {1}{1} is the first positional argument, which for commit-msg is the path to the message file Git wrote. commitlint reads it and validates against your conventional-commits rules.
Fix 8: Debugging
# Print what Lefthook will do for a hook:
lefthook run pre-commit --commands lint
# Verbose output:
LEFTHOOK_VERBOSE=1 git commit -m "test"
# Dry run (don't actually execute commands):
lefthook run pre-commit --commands lint --files src/index.tsWhen a hook hangs, Ctrl-C and look at which command was running. Often it’s an interactive prompt (npm audit, an editor opening) that needs --no-confirm or similar.
For “it works locally but not for a teammate”:
- Check their
lefthook installran. - Compare
lefthook.ymlversions (in case they’re on a stale branch). - Check their
LEFTHOOKenv var isn’t set to0.
Still Not Working?
A few less-obvious failures:
- Hook runs but exits 0 even on lint errors. A command in the chain swallows the exit code. Check that each
run:propagates non-zero exits (don’t pipe totee,cat, etc. withoutset -o pipefail). Permission deniedon.git/hooks/pre-commit. Filesystem stripped execute bit. Runchmod +x .git/hooks/*andlefthook installagain.- Multiple
lefthookbinaries on PATH. Repo-local node_modules vs global install. Usenpx lefthookto disambiguate, or pinwhich lefthookin your CONTRIBUTING docs. {staged_files}includes deleted files. When yougit rma file, it shows up in staged_files until the commit. Filter with--diff-filter=AMif the underlying command can’t handle missing files:run: 'git diff --staged --diff-filter=AM --name-only | xargs oxlint'.- Husky and Lefthook conflict. Both want to write
.git/hooks/*. Remove Husky (npm uninstall husky) and delete.husky/before runninglefthook install. - GUI Git client (Sourcetree, Tower) bypasses hooks. Some clients have an option to skip hooks. Check the client’s settings and re-enable.
- Hook works for first commit, fails on subsequent. A previous command left a partial state (e.g. prettier wrote files but didn’t
stage_fixed). Now you have unstaged changes and Lefthook sees an inconsistent state. Clean up and rerun. - Windows: bash scripts in
run:don’t execute. Lefthook spawns the user’s shell. On Windows without WSL, use PowerShell-compatible commands or wrap withpwsh -Command.
For related Git workflow and hook issues, see Pre-commit not working, Git hooks not running, Git permission denied publickey, and Git credential helper not working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Git Keeps Asking for Username and Password
How to fix Git repeatedly prompting for credentials — credential helper not configured, HTTPS vs SSH, expired tokens, macOS keychain issues, and setting up a Personal Access Token.
Fix: gh CLI Not Working — Auth Scopes, Multiple Accounts, PR Create Errors, and Enterprise Hosts
How to fix GitHub CLI errors — gh auth login token scopes missing, multiple accounts switching, gh pr create permission denied, GHE host auth, gh repo clone vs git clone, and API rate limits.
Fix: Git Hooks Not Running — Husky Not Working, pre-commit Skipped, or lint-staged Failing
How to fix Git hooks not executing — Husky v9 setup, hook file permissions, lint-staged configuration, pre-commit Python tool, lefthook, and bypassing hooks in CI.
Fix: SSL certificate problem: unable to get local issuer certificate
How to fix 'SSL certificate problem: unable to get local issuer certificate', 'CERT_HAS_EXPIRED', 'ERR_CERT_AUTHORITY_INVALID', and 'self signed certificate in certificate chain' errors in Git, curl, Node.js, Python, Docker, and more. Covers CA certificates, corporate proxies, Let's Encrypt, certificate chains, and self-signed certs.