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 the prepare script, which runs the husky command.
  • husky sets core.hooksPath in 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 checks core.hooksPath, finds the pre-commit wrapper in .husky/_, and executes it.
  • The wrapper execs your tracked .husky/pre-commit file, 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 the commit-msg hook 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 updates HEAD.
  • 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-commit fast (seconds, not minutes) by scoping it to staged files with lint-staged.
  • Push slower checks — the full test suite, a production build — to pre-push or 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 .husky directory; 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-verify or 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

  1. In a fresh Node project, install Husky, run npx husky init, and edit .husky/pre-commit to run npm run lint instead of npm test. Break the linter deliberately (add an unused variable) and confirm git commit is 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.
  2. Add lint-staged so only staged .js/.ts files are formatted with Prettier and linted with ESLint before each commit, leaving unstaged files untouched. Hint: you’ll edit both package.json (the lint-staged config) and .husky/pre-commit.
  3. Add a commit-msg hook using commitlint’s conventional config. Try committing with the message "fixed stuff" (expect a rejection listing the missing type and empty subject), 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.hooksPath at a tracked .husky directory, and re-applies that setting on every npm install via a prepare script — 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-commit with 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.