Git hooks with Husky
July 12, 2026Git hooks run scripts around local Git events (pre-commit, commit-msg, pre-push, and others). They are a good place for fast checks - lint, format, and lightweight validation - before bad changes leave your machine. Hooks are not a substitute for CI; treat them as an early filter.
Husky wires those hooks through Git's core.hooksPath. Hook files live in .husky/ as plain shell scripts, and a prepare script installs them after npm install so every clone gets the same setup.
This post covers Husky v9 setup, common hooks, lint-staged, skipping hooks in CI, pitfalls, and a runnable demo that blocks messy commits.
Prerequisites
- Node.js version 26
- A Git repository (
git initif you are starting fresh) npm i -D husky- Optional but recommended:
npm i -D lint-stagedplus ESLint and Prettier
Setup
Install Husky and initialize it:
npm i -D huskynpx husky init
husky init creates .husky/pre-commit and adds a prepare script to package.json:
{"scripts": {"prepare": "husky"}}
prepare runs after local npm install, so teammates get hooks without a separate setup step. Replace the default pre-commit body with the checks you want.
Mental model
| Piece | Role |
|---|---|
prepare / husky | Points Git at .husky/ via core.hooksPath |
.husky/<hook> | Shell script Git runs for that event |
lint-staged | Runs tools only on staged files (typical pre-commit target) |
| CI | Still runs full lint/test; hooks are local only |
Husky does not invent a new hook system - it configures native Git hooks and keeps the scripts in the repo.
Pre-commit with lint-staged
Running eslint . or prettier --write on the whole tree from pre-commit gets slow. lint-staged limits work to staged files.
npm i -D lint-staged eslint prettier
// package.json{"lint-staged": {"*.{js,ts}": ["eslint --fix", "prettier --write"]}}
# .husky/pre-commitnpx lint-staged
On git commit, Git runs .husky/pre-commit → lint-staged → ESLint/Prettier on matching staged paths. Auto-fixed files are restaged. If a check exits non-zero, the commit is aborted.
Keep pre-commit fast and file-local. Put slow or whole-repo work in pre-push or CI.
Other useful hooks
commit-msg - validate the message file Git passes as $1:
# .husky/commit-msgmessage=$(cat "$1")if echo "$message" | grep -qE '^(WIP|wip)($|[^a-zA-Z])'; thenecho "WIP commits are blocked by the commit-msg hook"exit 1fi
pre-push - run a quicker subset of CI before a push:
# .husky/pre-pushnpm test
Create a hook by adding an executable script under .husky/ with the Git hook name. Prefer POSIX shell for Windows compatibility.
Skipping hooks
Skip once with Git's no-verify flag:
git commit -m "emergency fix" -n
Disable Husky for a command or environment:
HUSKY=0 git commit -m "skip hooks"
Set HUSKY=0 (or HUSKY: 0 in CI env) on CI/Docker so installs do not try to configure local hooks. If production installs omit devDependencies, use a safe prepare script so missing husky does not fail the install:
{"scripts": {"prepare": "husky || true"}}
Pitfalls
- Hooks are local - they do not run on the remote; CI still matters.
--no-verify/HUSKY=0- anyone can bypass hooks; do not rely on them for security.- GUI + Node version managers - GUI clients often lack the shell
PATHwherenvm/fnmput Node. Source your version manager from~/.config/husky/init.sh. - Nested package vs Git root - Husky will not install into a parent
../by default. In a monorepo, install Husky at the repo root (or adjustprepareas in the Husky how-to). - Slow pre-commit - full test suites belong in
pre-pushor CI; keep commit hooks on staged files.
When to use what
| Approach | Good for |
|---|---|
| Husky + lint-staged | Fast lint/format on staged files before commit |
Husky commit-msg | Lightweight message conventions locally |
Husky pre-push | Short tests before sharing a branch |
| CI only | Full lint, build, and test on every change |
Pair Husky with ESLint and Prettier for the usual Node.js quality gate on commit.