Skip to content

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

FixDevs ·

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 install wasn’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 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.
  • 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.

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.

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