Husky v9 Git Hooks with lint-staged and commitlint: Setup, Migration, Silent Failures

Key takeaways

Husky v9 is a tiny tool: it points Git's core.hooksPath at a folder in your repo and runs shell scripts from .husky/. Most problems come from the hook never being installed (no prepare run, wrong directory, Yarn Berry) or from hooks doing too much. This article covers the setup, lint-staged, commitlint, the v8 migration, and how to debug hooks that silently do nothing.

What Husky actually does

Git has supported hooks forever: executable scripts in .git/hooks/ that run on events such as pre-commit, commit-msg and pre-push. The problem is that .git/ is not version-controlled, so hooks cannot be shared through the repository. Every developer would have to copy them in by hand.

Since Git 2.9 there is a setting, core.hooksPath, that tells Git to look for hooks in another directory. Husky v9 is essentially a small wrapper around that setting:

  1. npm install runs the prepare script, which runs husky.
  2. husky runs git config core.hooksPath .husky/_ and writes small wrapper scripts into .husky/_/ (that folder has its own .gitignore containing *, so it is never committed).
  3. When Git fires a hook, the wrapper in .husky/_/ looks for a file with the same name in .husky/ (for example .husky/pre-commit) and runs it with sh -e, with node_modules/.bin prepended to PATH.

So the files you commit are plain shell scripts in .husky/, and the “installation” is one Git config value per clone. Almost every Husky problem is one of those two pieces being missing.


Installation

npm install --save-dev husky
npx husky init

husky init does three things: adds "prepare": "husky" to package.json, creates .husky/pre-commit containing npm test, and runs the install step once. You can confirm it worked:

git config core.hooksPath
# .husky/_

A hook file is just commands. No shebang, no sourcing line, no chmod +x needed in v9, because the wrapper invokes it with sh explicitly:

# .husky/pre-commit
npx lint-staged

The -e flag means the hook stops at the first failing command, and a non-zero exit aborts the Git operation. Husky prints husky - pre-commit script failed (code 1) when that happens, and adds husky - command not found in PATH=... for exit code 127, which is the message you see when a tool is not installed or the hook runs outside the project’s node_modules.

Migrating from Husky v8

Older tutorials (and the previous version of this article) show a different setup. In v8 the prepare script was husky install, hooks were created with husky add, and every hook file started with:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

For v9:

  • change "prepare": "husky install" to "prepare": "husky"
  • delete those two lines from every file in .husky/
  • husky add no longer exists; just create the file (echo "npx lint-staged" > .husky/pre-commit)
  • move anything in ~/.huskyrc to ~/.config/husky/init.sh

If you leave the old lines in, v9 still works but prints a husky - DEPRECATED message saying they will fail in v10.0.0.


pre-commit with lint-staged

Running eslint . and prettier --check . on every commit gets slow as the codebase grows, and it fails commits because of problems in files you did not touch. lint-staged runs commands only on the files in the Git index:

npm install --save-dev lint-staged
echo "npx lint-staged" > .husky/pre-commit
{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": ["eslint --fix --max-warnings=0", "prettier --write"],
    "*.{css,scss,json,md,yml}": "prettier --write"
  }
}

What happens on git commit:

  1. lint-staged saves a backup of the current state in a Git stash.
  2. It hides unstaged changes in partially staged files, so tools only see what is actually being committed.
  3. It runs each command with the matching staged file paths appended as arguments.
  4. If the commands modified files, it adds those changes to the commit automatically. (Old guides include git add in the task list; that has been unnecessary since lint-staged v10.)
  5. If any command fails, it restores the backup and the commit is aborted.

Because file paths are appended to the command, anything that is a whole-project operation does not belong here. The classic example is type checking:

{ "lint-staged": { "*.ts": "tsc --noEmit" } }

When tsc receives file names on the command line it does not use tsconfig.json (newer TypeScript releases refuse to run in that situation rather than silently ignoring the file). Strictness settings, path aliases and jsx options all disappear, and on older versions you get errors like:

error TS17004: Cannot use JSX unless the '--jsx' flag is provided.

Run tsc --noEmit directly in the hook (or better, in pre-push and CI), not through lint-staged. The same applies to test runners unless they have a “related files” mode (for example vitest related --run).

A related gotcha with ESLint 9: if lint-staged passes a file that your ESLint config ignores, ESLint warns File ignored because of a matching ignore pattern, and with --max-warnings=0 that warning fails the commit. Add --no-warn-ignored to the ESLint command.


commit-msg with commitlint

commitlint checks the commit message against a convention, usually Conventional Commits (type(scope): subject). That structure is what tools like semantic-release and changelog generators parse.

npm install --save-dev @commitlint/cli @commitlint/config-conventional
echo "export default { extends: ['@commitlint/config-conventional'] };" > commitlint.config.mjs
echo 'npx --no -- commitlint --edit $1' > .husky/commit-msg

The config file uses export default, so either name it .mjs or have "type": "module" in package.json; otherwise Node refuses to load it. $1 is the path to the file holding the message (.git/COMMIT_EDITMSG), and npx --no prevents npx from silently downloading commitlint if it is not installed.

The conventional preset already allows feat, fix, docs, style, refactor, perf, test, build, ci, chore and revert, and limits the header to 100 characters, so you only need rules for real deviations. A rejected message looks like this:

⧗   --- input ---
update stuff
✖   subject may not be empty [subject-empty]
✖   type may not be empty [type-empty]

✖   found 2 problems, 0 warnings

husky - commit-msg script failed (code 1)

Note that pre-commit runs before commit-msg. If lint-staged reformatted files and then commitlint rejects the message, the formatted changes stay staged; just fix the message and commit again.


pre-push for slower checks

pre-commit should take a couple of seconds at most. Type checking and tests fit better in pre-push:

# .husky/pre-push
npm run typecheck
npm test

Each line must succeed for the push to proceed (because of sh -e). Keep in mind that pre-push runs even when you push a branch that only contains a documentation change, so a full test suite here can take long enough that people start reaching for --no-verify.


Why hooks do not run

The failure mode I have run into most with Husky is silent: a teammate commits code that fails lint, and it turns out their hooks were never installed at all. Nothing errors, Git just has no core.hooksPath and runs no hooks. When someone says “the hook didn’t catch this”, my first question now is what git config core.hooksPath prints on their machine. The common causes:

  • Dependencies not installed yet, or installed with npm install --ignore-scripts (some security-conscious setups set ignore-scripts=true in .npmrc). Run npx husky once, or npm run prepare.

  • Yarn 2+ (Berry) does not run the prepare lifecycle script. The Husky docs suggest "postinstall": "husky" for private projects instead (for published packages, postinstall would run for your users too, so it needs to be disabled on publish).

  • package.json is not at the Git root, for example a repo with frontend/ and backend/ folders. husky checks for .git in the current directory and prints .git can't be found, and it also refuses paths containing ... Use the documented pattern:

    { "scripts": { "prepare": "cd .. && husky frontend/.husky" } }

    and start each hook with cd frontend, because hooks run from the repository root.

  • Another tool overwrote core.hooksPath, or a developer set a global core.hooksPath to use their own hooks. The repository-level value wins over the global one, but a second hook manager in the same repo will fight Husky for the setting.

  • GUI Git clients and Node version managers. Clients like some IDE Git panels do not load your shell profile, so npx or node from nvm/fnm is not on PATH and the hook fails with command not found. Husky sources ~/.config/husky/init.sh before every hook; put your version manager setup there (for example export NVM_DIR="$HOME/.nvm" and . "$NVM_DIR/nvm.sh").

To see exactly what a hook is doing, run the Git command with HUSKY=2, which turns on set -x tracing in the wrapper.

Production installs where Husky is not present

If your Docker build or deployment runs npm ci --omit=dev, the prepare script still runs but husky is a devDependency and is not installed, so the install fails with husky: not found. Options: set HUSKY=0 in that environment (the command exits immediately), or use "prepare": "husky || true" as the Husky docs suggest.


Useful hook scripts

Hooks are plain sh, so anything you can script works. Two that come up often:

Block TODO/FIXME in added lines:

# .husky/pre-commit
if git diff --cached -U0 | grep -qE '^\+.*(TODO|FIXME)'; then
  echo "Staged changes contain TODO or FIXME. Resolve them or commit with --no-verify."
  exit 1
fi
npx lint-staged

Reject large files before they enter history (removing them later requires rewriting history):

# .husky/pre-commit
max_kb=500
git diff --cached --name-only --diff-filter=AM | while IFS= read -r file; do
  size_kb=$(( $(wc -c < "$file") / 1024 ))
  if [ "$size_kb" -gt "$max_kb" ]; then
    echo "$file is ${size_kb}KB (limit ${max_kb}KB). Use Git LFS or keep it out of the repo."
    exit 1
  fi
done || exit 1
npx lint-staged

The while read loop handles file names with spaces, which a for file in $(...) loop would split. --diff-filter=AM skips deleted files, which no longer exist on disk. The || exit 1 is needed because the loop runs in a subshell on the right side of the pipe, so its exit does not end the hook by itself.

A hook that blocks direct commits to main is also popular, but it is easy to bypass and does nothing for pushes from other clients. Branch protection rules on the Git server are the real control; a local hook is at most a friendly reminder.


Hooks are a convenience, CI is the enforcement

Every hook can be skipped: git commit --no-verify, git push --no-verify, HUSKY=0, or simply never installing dependencies. That is by design; there are legitimate reasons (a WIP commit on a local branch, a broken hook during an emergency). The consequence is that hooks catch mistakes early but cannot guarantee anything, so CI should run the same checks:

- run: npm ci
- run: npx prettier . --check
- run: npx eslint . --max-warnings=0
- run: npm run typecheck
- run: npm test

For commit messages, npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} checks every commit in a pull request (the checkout needs enough history, for example fetch-depth: 0).

The design principle that has worked best for me is to keep the hook a strict subset of CI: fast, focused on the files being changed, and never the only place a check runs. When a pre-commit hook grows to include tests and a full build, commits take a minute, people learn --no-verify, and the hook ends up protecting nothing.


Which check belongs in which hook

HookGood fitKeep it
pre-commitlint-staged: ESLint --fix and Prettier on staged filesunder a few seconds
commit-msgcommitlintinstant
pre-pushtype check, unit testsunder a minute or so

If a hook regularly takes longer than its budget, people start committing with --no-verify, and the hook stops protecting anything; move the slow check one step later (to pre-push or CI) rather than keeping it where it gets skipped.

Resources: Husky documentation, lint-staged README, commitlint documentation, Git hooks reference.