Fix: Git Hooks Not Running — Husky Not Working, pre-commit Skipped, or lint-staged Failing
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 lintingOr 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 debugOr 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 initorpreparescript inpackage.json. Without thepreparescript running afternpm install, the.huskyhooks directory isn’t registered with Git. core.hooksPathnot set — Husky setscore.hooksPath = .huskyin 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
eslintcall (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
PATHthan your terminal, causing “command not found” fornode,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 scriptsVerify 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 testNote: 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-commitVerify 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 huskyFix 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 --debugCommon 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 insteadHandle 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-stagedOr 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-stagedBetter 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-stagedFix 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 hookFix 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: trueautomatically 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 hooks — git 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 rootFor related tooling issues, see Fix: ESLint Not Working and Fix: Prettier 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: esbuild Not Working — Plugin Errors, CSS Not Processed, or Output Missing After Build
How to fix esbuild issues — entry points, plugin API, JSX configuration, CSS modules, watch mode, metafile analysis, external packages, and common migration problems from webpack.
Fix: Turborepo Not Working — Cache Never Hits, Pipeline Not Running, or Workspace Task Fails
How to fix Turborepo issues — turbo.json pipeline configuration, cache keys, remote caching setup, workspace filtering, and common monorepo task ordering mistakes.
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: Webpack HMR (Hot Module Replacement) Not Working
How to fix Webpack Hot Module Replacement not updating the browser — HMR connection lost, full page reloads instead of hot updates, and HMR breaking in Docker or behind a proxy.