Husky and Hooks in JavaScript Projects
Git hooks are scripts that Git runs automatically at specific points in your workflow — before a commit is created, after a merge finishes, before a push leaves your machine, and more. They live in the .git/hooks directory, which is never tracked by Git itself, so a hook you write locally never reaches a teammate who clones the repository. Husky is a small, widely used npm package that solves exactly this problem for JavaScript and TypeScript projects: it stores your hook scripts as ordinary files inside your project so they travel with the repository, then quietly reconfigures Git to run them. The result is that every contributor who runs npm install gets the same linting, testing, and commit-message checks running on their machine, with no manual setup.
Overview: how Git hooks and Husky work
Every Git repository has a .git/hooks folder created the moment you run git init. Open it and you’ll find files like pre-commit.sample — harmless example scripts that Git ignores until you rename them (dropping .sample) and mark them executable. Once a hook file with the right name exists there and is executable, Git runs it automatically at the matching point in its workflow, and if the script exits with a non-zero status, Git aborts whatever operation triggered it.
The catch is that .git is local bookkeeping, not versioned content. When you clone a repository, Git rebuilds .git/hooks from scratch with only the harmless samples — any hook a teammate wrote never comes along for the ride. This is the exact gap Husky closes. Git actually lets you point it at a hooks directory of your choosing via the core.hooksPath config setting, instead of the default .git/hooks. Husky sets core.hooksPath to a folder inside your project (.husky), which is tracked by Git like any other file. Husky also writes a prepare script into package.json; npm runs any prepare script automatically after npm install, so the moment a teammate clones the repo and installs dependencies, Husky re-applies the core.hooksPath setting on their machine too. That one mechanism is the whole trick: hooks become shareable simply by being ordinary tracked files, wired up automatically by a lifecycle script everyone already runs.
Husky’s current major version (v9) keeps hook files as plain as possible: a file named .husky/pre-commit just contains the shell command(s) you want to run, with no boilerplate. Internally, Husky writes small wrapper scripts into a hidden, gitignored .husky/_ directory; each wrapper is what core.hooksPath actually points Git at, and it simply looks for and executes the correspondingly named file directly inside .husky/ (e.g. .husky/pre-commit) if one exists and is executable. You never need to touch .husky/_ yourself.
What “exit code” means here
A hook is judged purely by its exit status. If the last command in a hook file exits 0, Git proceeds. If it exits non-zero, Git stops: for pre-commit, that means no commit object is ever written and the branch pointer never moves, so a broken snapshot is never recorded in history at all; your staged changes remain staged so you can fix the problem and try again.
Syntax
Creating or editing a Husky-managed hook is just working with a normal file:
echo "npm test" > .husky/pre-commit
chmod +x .husky/pre-commit
Commit that file, and every clone of the repository gets the same hook after their next npm install. The table below lists the hooks you’ll use most often in a JavaScript project:
| Hook name | Runs when | Typical use |
|---|---|---|
pre-commit |
After git commit is invoked, before the commit is written |
Lint/format staged files, run fast unit tests |
commit-msg |
After you finish composing a commit message, before the commit is written | Enforce a commit message format (e.g. Conventional Commits) |
prepare-commit-msg |
Before the commit message editor opens | Pre-fill a commit message template |
post-commit |
After a commit is successfully created | Notifications, non-blocking side effects |
pre-push |
Before git push sends anything to the remote |
Run the full test suite or a production build |
post-merge |
After git merge (including a git pull) completes |
Reinstall dependencies if package-lock.json changed |
Examples
Example 1: installing Husky and blocking a commit with failing tests
npm install --save-dev husky
npx husky init
Output:
added 1 package, and audited 234 packages in 2s
husky - created .husky/pre-commit
husky - added "prepare": "husky" to package.json scripts
husky init installs Husky, adds the prepare script, and writes a starter hook. Its generated .husky/pre-commit file contains a single line:
npm test
Now stage a change and commit while a test is failing:
git add src/login.js
git commit -m "feat: add login form validation"
Output:
> project@1.0.0 test
> jest
FAIL src/login.test.js
✕ validates required fields (12 ms)
Tests: 1 failed, 4 passed, 5 total
Test Suites: 1 failed, 1 total
husky - pre-commit hook exited with code 1 (error)
Because npm test exited non-zero, the pre-commit hook failed, and Git refused to create the commit. src/login.js is still staged — nothing was lost, you just need to fix the test and commit again.
Example 2: scoping checks to staged files with lint-staged
Running the whole test suite (or linting the entire codebase) on every commit gets slow fast. lint-staged restricts checks to only the files you actually staged.
npm install --save-dev lint-staged eslint prettier
Add a lint-staged block to package.json:
{
"lint-staged": {
"*.js": ["eslint --fix", "prettier --write"]
}
}
Then point the pre-commit hook at it instead of the full test command:
npx lint-staged
Commit a couple of JavaScript files:
git add src/login.js src/utils/validate.js
git commit -m "refactor: simplify validate() branching"
Output:
[STARTED] Preparing lint-staged...
[STARTED] Running tasks for staged files...
[STARTED] *.js — eslint --fix
[STARTED] *.js — prettier --write
[COMPLETED] *.js — eslint --fix
[COMPLETED] *.js — prettier --write
[COMPLETED] Running tasks for staged files...
[COMPLETED] Applying modifications from tasks...
[main 4f2a9d1] refactor: simplify validate() branching
2 files changed, 14 insertions(+), 9 deletions(-)
Only the two staged files were linted and formatted — untouched files elsewhere in the repo were never scanned, so the hook stays fast even on a large codebase.
Example 3: enforcing Conventional Commits with commitlint
npm install --save-dev @commitlint/cli @commitlint/config-conventional
Create a config file:
module.exports = {
extends: ["@commitlint/config-conventional"],
};
Then add a commit-msg hook that hands the drafted message to commitlint:
npx --no -- commitlint --edit "$1"
Git passes the path to a temporary file holding the commit message as $1 to any commit-msg hook, which is exactly what commitlint expects to read and validate. Try a bad message, then a valid one:
git commit -m "fixed stuff"
git commit -m "fix: correct null pointer in auth check"
Output:
⧗ input: fixed stuff
✖ subject may not be empty [subject-empty]
✖ type may not be empty [type-empty]
✖ found 2 problems, 0 warnings
husky - commit-msg hook exited with code 1 (error)
⧗ input: fix: correct null pointer in auth check
✔ found 0 problems
[main 9c1d3aa] fix: correct null pointer in auth check
1 file changed, 3 insertions(+), 1 deletion(-)
The first message has no type: prefix and no real subject, so commitlint rejects it and the commit is never created. The second follows the Conventional Commits format (type: description), passes, and the commit succeeds.
How it works step by step
Putting the pieces together, here is what actually happens from install to commit:
- You (or a teammate) run
npm install. npm executes thepreparescript, which runs thehuskycommand. huskysetscore.hooksPathin your repository’s local Git config to.husky/_, and writes wrapper scripts there for every hook name Git supports.- You run
git commit. Git’s commit machinery checkscore.hooksPath, finds thepre-commitwrapper in.husky/_, and executes it. - The wrapper execs your tracked
.husky/pre-commitfile, running whatever command you put there (e.g.npx lint-staged). - If that command exits
0, Git continues: it opens your editor (or uses-m) for the commit message, runs thecommit-msghook the same way, then writes a new commit object pointing at a tree built from your index, moves the current branch pointer to that new commit, and updatesHEAD. - If any hook exits non-zero, Git prints the hook’s output and stops: no commit object is written, the branch pointer doesn’t move, and your staged changes are untouched in the index.
Common Mistakes
Mistake 1: using the old Husky v4–v8 hook format under v9. Older Husky versions required every hook file to source a helper script:
#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"
npm test
That _/husky.sh helper no longer exists in Husky v9, so a hook written this way fails with a “no such file or directory” error. The fix is to strip it down to just the command: a v9 .husky/pre-commit file should contain only npm test (or whatever you want to run), nothing else.
Mistake 2: forgetting the executable bit. If you create a hook file by hand in an editor rather than via a shell redirect, it may not be marked executable, and Git will silently skip a non-executable hook. Always confirm with chmod +x .husky/pre-commit after creating or editing a hook file manually.
Mistake 3: habitually skipping hooks with --no-verify.
git commit -m "wip: skip broken hook" --no-verify
Using --no-verify to push past a failing lint or test hook defeats the entire point of having it, and lets broken code slip into shared history. Treat it as an escape hatch for rare, deliberate cases (like a private WIP commit on your own branch), not a routine workaround — if you’re reaching for it often, fix the check or the code, not the flag.
Mistake 4: running expensive checks on every commit. Putting the full test suite or a whole-repo lint inside pre-commit makes every commit slow, which pushes people toward skipping hooks altogether. Scope pre-commit to the files you staged with lint-staged, and move heavier, slower checks (full test suite, production build) to pre-push or to CI instead.
Best Practices
- Keep
pre-commitfast (seconds, not minutes) by scoping it to staged files with lint-staged. - Push slower checks — the full test suite, a production build — to
pre-pushor your CI pipeline. - Use commitlint with the Conventional Commits config so commit history is consistent and changelog tools can parse it automatically.
- Always commit the
.huskydirectory; never add it to.gitignore, or your team loses the whole benefit. - Never treat hooks as your only safety net — they can be bypassed with
--no-verifyor skipped in some Git clients, so mirror the same checks in CI. - Keep hook files themselves thin (one or two commands); put real logic in an npm script or a dedicated config file so it’s easy to run the same check manually.
- Document what each hook does (and how to bypass it in a genuine emergency) in your project’s
CONTRIBUTING.md.
Practice Exercises
- In a fresh Node project, install Husky, run
npx husky init, and edit.husky/pre-committo runnpm run lintinstead ofnpm test. Break the linter deliberately (add an unused variable) and confirmgit commitis refused. Expected end state: the commit fails and the fix isn’t staged for you — you have to correct the lint error yourself and retry. - Add lint-staged so only staged
.js/.tsfiles are formatted with Prettier and linted with ESLint before each commit, leaving unstaged files untouched. Hint: you’ll edit bothpackage.json(thelint-stagedconfig) and.husky/pre-commit. - Add a
commit-msghook using commitlint’s conventional config. Try committing with the message"fixed stuff"(expect a rejection listing the missingtypeand emptysubject), then with"fix: correct null pointer in auth check"(expect success).
Summary
- Native Git hooks live in
.git/hooks, which is never tracked or shared through cloning. - Husky repoints Git’s
core.hooksPathat a tracked.huskydirectory, and re-applies that setting on everynpm installvia apreparescript — that’s how hooks reach every contributor. - In Husky v9, a hook file is just the plain command(s) to run — no shebang or sourcing boilerplate like older versions required.
- A non-zero exit from any hook aborts the corresponding Git operation; nothing is written, and your staged work is preserved.
- Pair
pre-commitwith lint-staged for fast, scoped checks; use commitlint for consistent commit messages; and always keep the same checks running in CI, since hooks can be bypassed locally.
