Skip to content

Fix: Lefthook Not Working — Install, Staged Files, Glob Filters, Parallel Runs, and CI Skip

FixDevs · (Updated: )

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’s core.hooksPath at that directory. lint-staged then reads staged files via git 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-hooks key from package.json and 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_fixed for 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 install

lefthook 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.ts

If the list is empty:

  • You didn’t git add the files yet (Lefthook only sees staged changes).
  • The glob filter excluded them all (see Fix 3).
  • The previous command in the chain consumed them via files instead of staged_files.

For commands that need all tracked files (not just staged):

pre-commit:
  commands:
    typecheck:
      run: tsc --noEmit  # No file arg — checks the whole project

For 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: true

stage_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 lint

glob 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 test

skip 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 --changed

All 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: 2

priority 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: true

Lefthook merges lefthook.yml + lefthook-local.yml. The local file takes precedence per command.

Add to .gitignore:

lefthook-local.yml

Common 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=dev

Pre-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.ts

When 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 install ran.
  • Compare lefthook.yml versions (in case they’re on a stale branch).
  • Check their LEFTHOOK env var isn’t set to 0.

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 to tee, cat, etc. without set -o pipefail).
  • Permission denied on .git/hooks/pre-commit. Filesystem stripped execute bit. Run chmod +x .git/hooks/* and lefthook install again.
  • Multiple lefthook binaries on PATH. Repo-local node_modules vs global install. Use npx lefthook to disambiguate, or pin which lefthook in your CONTRIBUTING docs.
  • {staged_files} includes deleted files. When you git rm a file, it shows up in staged_files until the commit. Filter with --diff-filter=AM if 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 running lefthook 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 with pwsh -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 lefthook on 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/lefthook or set Git → Git Path in 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 with xargs -d '\n' on Linux.
  • lefthook run from inside a git commit editor hangs. Some editors (Vim, Emacs from terminal) lock stdin. Hooks that prompt — npm audit fix, an interactive linter — never see input. Add </dev/null to commands that might prompt, or use --no-interactive flags.

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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles