Skip to content

Fix: Git Hooks Not Running — Husky Not Working, pre-commit Skipped, or lint-staged Failing

FixDevs ·

Quick Answer

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.

The Problem

Git hooks are configured but never run when you commit:

git commit -m "fix: update config"
# Expect: pre-commit hook runs lint-staged
# Actual: commit goes through without any linting

Or Husky shows an error after installing:

git commit -m "test"
# .husky/pre-commit: line 1: husky: command not found
# OR:
# hint: The '.husky/pre-commit' hook was ignored because it's not set as executable.
# hint: You can disable this warning with `git config advice.ignoredHook false`.

Or lint-staged runs but the commit still fails:

 Preparing lint-staged...
 Running tasks for staged files...
 src/index.ts 1 file
 eslint --fix [FAILED]
 Reverting to original state because of errors...
# Error output is truncated — hard to debug

Or the pre-commit Python tool isn’t running hooks:

pre-commit run --all-files
# ERROR: Cannot find command: `node`

Why This Happens

Git hooks fail for a small set of predictable reasons:

  • Hook file not executable — Git ignores hook files that don’t have the executable bit set. This is a Unix permission issue. Files created on Windows or cloned without permission bits may not have chmod +x.
  • Husky not initialized — Husky v9 requires husky init or prepare script in package.json. Without the prepare script running after npm install, the .husky hooks directory isn’t registered with Git.
  • core.hooksPath not set — Husky sets core.hooksPath = .husky in your Git config. If this isn’t set, Git looks in .git/hooks/ by default and ignores your .husky/ directory.
  • lint-staged config mismatch — lint-staged file patterns that don’t match any staged files silently succeed but do nothing. A misconfigured eslint call (wrong working directory, missing config file) causes the staged changes to be reverted.
  • PATH differences in GUI apps — when committing from a GUI client (VS Code Source Control, GitHub Desktop, Sourcetree), the shell used for hooks may have a different PATH than your terminal, causing “command not found” for node, npx, or other tools.

Fix 1: Set Up Husky v9 Correctly

Husky v9 changed the setup significantly from v8. Start from scratch if you’re hitting issues:

# Install Husky
npm install --save-dev husky

# Initialize — creates .husky/ and sets core.hooksPath
npx husky init

# This creates:
# .husky/pre-commit (with a sample command)
# Adds "prepare": "husky" to package.json scripts

Verify the prepare script is in package.json:

{
  "scripts": {
    "prepare": "husky"
  },
  "devDependencies": {
    "husky": "^9.0.0"
  }
}

The prepare script runs automatically on npm install, which sets up the Git hooks for any developer who clones the repo.

Write hook files correctly for Husky v9:

# .husky/pre-commit
npx lint-staged
# .husky/commit-msg
npx --no -- commitlint --edit $1
# .husky/pre-push
npm test

Note: Husky v9 hook files must not have a shebang line or source ~/.nvm/nvm.sh. They run directly through sh. Keep them minimal — one command per hook.

Fix for CI environments:

{
  "scripts": {
    "prepare": "husky || true"
  }
}

The || true prevents prepare from failing in CI where .git may not exist (like in some Docker build contexts).

Fix 2: Fix Hook File Permissions

On macOS and Linux, hook files must be executable:

# Check permissions
ls -la .husky/

# Fix — make executable
chmod +x .husky/pre-commit
chmod +x .husky/commit-msg
chmod +x .husky/pre-push

# Fix all hooks at once
chmod +x .husky/*

Preserve permissions in Git:

# Check if Git is tracking permissions
git config core.fileMode

# If false (common on Windows), enable it
git config core.fileMode true

# After fixing permissions, update the index
git add .husky/pre-commit
git commit -m "fix: restore hook file permissions"

On Windows (WSL or Git Bash):

# Windows filesystems don't support Unix permissions natively
# If using WSL, ensure your project is on the Linux filesystem (/home/user/...)
# not the Windows filesystem (/mnt/c/...)

# Git for Windows workaround — mark as executable in the index
git update-index --chmod=+x .husky/pre-commit

Verify core.hooksPath is set:

git config --get core.hooksPath
# Should output: .husky

# If missing, set it manually
git config core.hooksPath .husky

# Or reset Husky
npx husky

Fix 3: Configure lint-staged

lint-staged only runs linters on staged (not all) files. Configuration belongs in package.json or .lintstagedrc:

// package.json
{
  "lint-staged": {
    "*.{ts,tsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{js,jsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{css,scss}": [
      "prettier --write"
    ],
    "*.{json,md,yaml,yml}": [
      "prettier --write"
    ]
  }
}

Debug lint-staged failures:

# Run lint-staged manually to see full output
npx lint-staged --verbose

# Run on all files (not just staged)
npx lint-staged --diff="HEAD"

# Test your glob patterns
npx lint-staged --debug

Common lint-staged mistakes:

// WRONG — ESLint exits non-zero if there are unfixable errors
// This reverts the staged changes and blocks the commit
{
  "lint-staged": {
    "*.ts": "eslint"  // No --fix — just reports errors, which fails
  }
}

// CORRECT — fix what you can, report what you can't
{
  "lint-staged": {
    "*.ts": "eslint --fix --max-warnings=0"
  }
}

// WRONG — running TypeScript type checking on individual files fails
{
  "lint-staged": {
    "*.ts": "tsc --noEmit"  // tsc needs the whole project, not individual files
  }
}

// CORRECT — run tsc on the project, not per-file
{
  "lint-staged": {
    "*.ts": [
      "eslint --fix",
      "prettier --write"
    ]
  }
}
// Add tsc to a separate pre-push hook instead

Handle monorepos:

// .lintstagedrc.js — dynamic config for monorepos
import { relative } from "path";

const config = {
  "*.{ts,tsx}": (filenames) => {
    // Convert absolute paths to relative for ESLint
    const files = filenames.map((f) => relative(process.cwd(), f)).join(" ");
    return [`eslint --fix ${files}`, `prettier --write ${files}`];
  },
};

export default config;

Fix 4: Fix PATH Issues in GUI Git Clients

GUI clients (VS Code, GitHub Desktop, Sourcetree) launch hooks with a minimal PATH that doesn’t include node, npm, or tools installed via nvm:

# .husky/pre-commit — add PATH fix at the top
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"

# Or if using fnm
eval "$(fnm env)"

# Or if using mise (formerly rtx)
eval "$(mise activate bash)"

# Then run your actual hook
npx lint-staged

Or specify the full path to node:

# Find node location in terminal
which node
# /usr/local/bin/node  or  /opt/homebrew/bin/node

# .husky/pre-commit — use absolute path
/usr/local/bin/npx lint-staged

Better approach — use a .nvmrc and check it in hooks:

# .husky/pre-commit
if command -v fnm &> /dev/null; then
  eval "$(fnm env)"
elif [ -f "$HOME/.nvm/nvm.sh" ]; then
  . "$HOME/.nvm/nvm.sh"
fi

npx lint-staged

Fix 5: Use the pre-commit Python Tool

The pre-commit framework (a Python tool, not to be confused with Git’s pre-commit hook) manages hooks declaratively across languages:

# Install
pip install pre-commit
# Or with pipx (recommended)
pipx install pre-commit

# Initialize config
touch .pre-commit-config.yaml
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-json
      - id: check-merge-conflict

  # ESLint
  - repo: https://github.com/pre-commit/mirrors-eslint
    rev: v8.56.0
    hooks:
      - id: eslint
        files: \.(js|ts|jsx|tsx)$
        additional_dependencies:
          - [email protected]
          - "@typescript-eslint/[email protected]"
          - "@typescript-eslint/[email protected]"

  # Prettier
  - repo: https://github.com/pre-commit/mirrors-prettier
    rev: v3.1.0
    hooks:
      - id: prettier
        files: \.(js|ts|jsx|tsx|json|css|md)$

  # Python
  - repo: https://github.com/psf/black
    rev: 23.12.1
    hooks:
      - id: black
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.1.9
    hooks:
      - id: ruff
        args: [--fix]
# Install the hooks into .git/hooks
pre-commit install

# Run manually on all files
pre-commit run --all-files

# Update hook versions
pre-commit autoupdate

# Skip hooks once
SKIP=eslint git commit -m "wip"

Fix “Cannot find command: node” in pre-commit:

# pre-commit uses its own isolated environments
# For Node hooks, specify the language and version
# .pre-commit-config.yaml
repos:
  - repo: local
    hooks:
      - id: eslint
        name: ESLint
        language: node
        entry: npx eslint --fix
        types: [javascript, ts]
        # pre-commit will install Node in a venv for this hook

Fix 6: Use Lefthook as an Alternative

Lefthook is a fast, language-agnostic hook manager that avoids the PATH and npm issues:

# Install
npm install --save-dev lefthook
# Or: brew install lefthook

# Generate config
npx lefthook install
# lefthook.yml
pre-commit:
  parallel: true
  commands:
    eslint:
      glob: "*.{js,ts,jsx,tsx}"
      run: npx eslint --fix {staged_files}
      stage_fixed: true  # Re-stage fixed files automatically

    prettier:
      glob: "*.{js,ts,json,css,md}"
      run: npx prettier --write {staged_files}
      stage_fixed: true

    typecheck:
      run: npx tsc --noEmit

commit-msg:
  commands:
    commitlint:
      run: npx commitlint --edit {1}

pre-push:
  commands:
    tests:
      run: npm test
# Install hooks (adds to .git/hooks)
npx lefthook install

# Run manually
npx lefthook run pre-commit

# Skip specific hooks
LEFTHOOK_EXCLUDE=typecheck git commit -m "wip"

Lefthook advantages over Husky:

  • Single binary, no Node.js dependency for the runner itself
  • Built-in parallel execution
  • stage_fixed: true automatically re-stages files modified by a formatter
  • Templating with {staged_files}, {push_files}, {all_files}
  • Works with any language (Rust, Go, Python, Ruby) without PATH issues

Still Not Working?

Hook runs in terminal but not in VS Code Git — VS Code uses a separate shell for Git operations. Add a shell profile fix to your hook, or set "git.terminalGitEditor": true and commit from the integrated terminal instead. Alternatively, set the full path to executables in the hook file.

--no-verify bypassing your hooksgit commit --no-verify (or -n) skips all client-side hooks. This is intentional for emergency commits, but if teammates are using it habitually, enforce quality gates in CI with the same linting/testing commands. Client-side hooks are a convenience, not a security gate — CI is the enforcer.

Hooks run but don’t block the commit — a hook blocks the commit only if it exits with a non-zero status code. If your linter finds errors but exits 0, the commit proceeds. Check: ESLint with --max-warnings=0 exits non-zero on any warning. Without that flag, ESLint exits 0 even when reporting errors (unless there are actual errors, not just warnings). Verify exit codes with echo $? after running your lint command manually.

Husky hooks not running in a monorepo — Husky must be installed relative to the .git directory (the repo root), not a subdirectory. If your package.json is in a subdirectory, the prepare script won’t find .git. Either install Husky from the root, or pass the path explicitly:

# From repo root, if package.json is in a subdir
cd packages/app && npm install
# Then from root:
npx husky  # Finds .git at root

For related tooling issues, see Fix: ESLint Not Working and Fix: Prettier 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