Fix: Lefthook Not Working — Install, Staged Files, Glob Filters, Parallel Runs, and CI Skip
Part of: Docker, DevOps & Infrastructure
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. The binary is written in Go, ships as a single executable, and is intentionally thin — it does not patch Git internals, monkey-patch your shell, or install a daemon. That design is the source of both its speed and its sharp edges: anything that breaks the contract between Git, the on-disk hook scripts, and the YAML config silently produces a no-op. Git happily completes commits with no hook output and gives you no warning that the hook never ran.
Most failures map to one of four root causes. lefthook install wasn’t run. Without it, no .git/hooks/* scripts exist. The config is read only when those scripts call Lefthook, so a freshly cloned repo with lefthook.yml but no hook scripts is silent. {staged_files} is “files staged for THIS commit.” That means files added via git 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, and many linters interpret “no args” as “scan everything” or “do nothing,” both wrong here. glob uses extended glob syntax. *.{ts,tsx} (brace expansion) works in most Lefthook versions but *.ts,*.tsx (comma-separated) doesn’t unless you use glob: ["*.ts", "*.tsx"]. LEFTHOOK=0 env var disables everything. Useful for CI; needs to be set before git commit runs. If your CI runner inherits this from a parent shell or a .env file, hooks will silently no-op there too.
A subtler failure mode is hook collision. If another tool (Husky, simple-git-hooks, a custom Makefile target) also writes .git/hooks/pre-commit, only one wins — whichever was last to install. Lefthook’s install is non-destructive in some configurations and overwriting in others depending on the --force flag, so two tools fighting over the same hook usually means one of them is invisible. When debugging, always cat .git/hooks/pre-commit first to see which tool is actually wired.
How Other Tools Handle This
The Git hooks ecosystem has converged on a small set of tools, each with different tradeoffs. Knowing how each one behaves makes it easier to debug Lefthook by analogy and choose the right tool per project.
- pre-commit (Python). The original framework. Reads
.pre-commit-config.yaml, pins each hook to a Git ref of a remote repo, and runs hooks inside isolated language environments (virtualenvs, npm sandboxes, Ruby gems). Reliable and reproducible, but slow on first install because it bootstraps every hook’s environment. Best for polyglot repos where hook authors publish “official” pre-commit hooks. Equivalent of Lefthook’s{staged_files}is implicit — pre-commit passes staged files as positional args to each hook by default. - husky + lint-staged (Node). Husky writes shell scripts to
.husky/and points Git’score.hooksPathat that directory. lint-staged then reads staged files viagit diff --staged --name-only, filters by glob, and invokes commands. Two-tool stack: husky is the wiring, lint-staged is the file filtering and command runner. Lefthook collapses both jobs into one binary. - simple-git-hooks (Node). Reads a
simple-git-hookskey frompackage.jsonand writes the literal command into.git/hooks/pre-commit. No staged-files filtering, no parallel runs, no globs. Use when you want maximum simplicity and your hook is one command. - Raw
.git/hooks/*. You write shell scripts directly. Maximum control, zero install dance, but every contributor has to copy the scripts manually because.git/is not tracked. This is why Lefthook, Husky, etc. exist. - Lefthook. Go binary, YAML config, parallel commands, per-command globs,
{staged_files}and{files}placeholders,stage_fixedfor formatters. Fastest of the lot because no language runtime boot, and it does the lint-staged-style filtering itself.
When migrating from one to another, the same problems recur: hook scripts get out of sync, environment variables suppress runs, and editor saves trigger duplicate events. The fixes below apply most directly to Lefthook but the patterns translate. If you find Lefthook’s {staged_files} confusing, lint-staged’s --no-stash flag is the equivalent debugging hook.
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. - Hook runs locally but not in your editor’s commit UI. VS Code, JetBrains, Sourcetree all spawn Git from their own environment, often without your shell rc. The hook needs
lefthookon PATH. If you installed via Homebrew or asdf, the editor’s PATH may not include those prefixes. Either add a symlink to/usr/local/bin/lefthookor setGit → Git Pathin your editor’s settings to a wrapper script that sources your shell config. {staged_files}works on Linux but quotes wrong on Windows. Spaces in paths become two args when the shell expands them. Wrap with{staged_files|quote}if you’re on a recent Lefthook (templating support added in 1.5+), or pre-filter withxargs -d '\n'on Linux.lefthook runfrom inside agit commiteditor hangs. Some editors (Vim, Emacs from terminal) lock stdin. Hooks that prompt —npm audit fix, an interactive linter — never see input. Add</dev/nullto commands that might prompt, or use--no-interactiveflags.
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.