Fix: Git Hooks Not Running — Husky Not Working, pre-commit Skipped, or lint-staged Failing
Part of: JavaScript & TypeScript Errors
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, but the symptom — silent commits going through — looks the same regardless of the cause. The first instinct is usually to chmod +x the hook file, but that fix only resolves one of at least five distinct failure modes. The other four cause the same outcome and are harder to spot.
The deeper issue is that Git hooks sit at the intersection of three independently moving targets: your shell environment, your repo’s hook configuration, and the tooling that wraps them (Husky, lefthook, pre-commit). A change in any of those three breaks the chain without surfacing an error. The hook directory may be wrong, the executable bit may be missing, the prepare script may not have run, the shell PATH may be stripped down, or the wrapped tool may exit zero on a misconfigured pattern.
- 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.
Diagnostic Timeline
A real debugging session for “Husky is configured but never runs” usually unfolds like this:
Minute 0 — first guess: missing executable bit. You see the warning about a hook not being executable, run chmod +x .husky/pre-commit, commit again. Nothing happens. The warning is gone but the hook still doesn’t fire. The chmod fix only helps if Git was explicitly warning about permissions; silence after the fix usually means the hooks directory itself isn’t being read.
Minute 3 — check core.hooksPath. Run git config --get core.hooksPath. If the output is empty, Git is reading from .git/hooks/, not .husky/. That happens when npx husky init was never run, or when the repo was freshly cloned and npm install didn’t trigger the prepare script. Set it manually with git config core.hooksPath .husky to confirm — if the hook now runs, the root cause is a missing prepare script.
Minute 6 — verify the prepare script. Open package.json. If "prepare": "husky" is missing, the hooks directory will never be registered after npm install. Add it, then run npm install again. In CI environments, the script needs || true because the .git directory may not exist in build contexts.
Minute 10 — sub-shell PATH issues. The hook now runs from the terminal but fails from VS Code Source Control with husky: command not found or node: command not found. GUI clients launch the hook with a stripped PATH that excludes nvm-installed Node. The fix is to source your version manager at the top of .husky/pre-commit, or switch to fnm / mise which install Node into a globally discoverable path.
Minute 15 — CI silently skips hooks. Hooks never run in CI because client-side hooks are by design only enforced on developer machines. CI must run the same checks via a separate workflow step, not rely on the commit hook. If Husky’s prepare script is failing the CI install, switch to husky || true.
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 rootHook fires once then is silently disabled — Git refuses to run hooks whose mode bits change after the warning was first dismissed in newer Git versions. If you ran git config advice.ignoredHook false to suppress the warning, the hook will be quietly skipped even after you set the executable bit. Re-enable the advice (git config --unset advice.ignoredHook) to confirm whether Git is still flagging it, then re-apply the executable bit with git update-index --chmod=+x .husky/pre-commit so the mode is recorded in the index, not just on the local filesystem.
Worktree commits skip the hook — git worktree creates a secondary checkout whose .git is a file pointing back to the main repo. If core.hooksPath was set in a per-worktree config rather than the shared config, the worktree won’t find your hooks. Run git config --worktree --get core.hooksPath from inside the worktree. If empty, set the path with --worktree scope or move the setting to the shared config so all worktrees inherit it.
Hook runs but doesn’t see staged files — lint-staged passes only files that match its glob patterns. If you stage files from a subdirectory and your config uses absolute patterns like /src/**/*.ts, no matches happen even though changes exist. Switch to relative patterns (src/**/*.ts) and confirm with npx lint-staged --debug which prints the resolved file list before running tasks.
For related tooling issues, see Fix: pre-commit Not Working, Fix: ESLint Flat Config Not Working, Fix: VS Code ESLint Not Working, and Fix: Biome 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.