Local Hook Configuration with Husky Jump to heading

Husky is the standard local orchestration layer within the broader Git Automation & CI/CD Hook Engineering discipline โ€” it intercepts Git lifecycle events on every developer machine before a commit or push can reach the remote, establishing a fast-feedback validation gate that operates entirely independent of server-side pipelines.

Prerequisites Jump to heading

Before starting, confirm the following are in place:


Husky Hook Execution PipelineDiagram showing the sequence: developer runs git commit, which triggers the pre-commit hook (lint-staged) and commit-msg hook (commitlint), then git push triggers the pre-push hook (tests + secret scan), and finally the commit reaches the remote repository.Developergit commitpre-commit.husky/pre-commitlint-stagedlint + formatcommit-msg.husky/commit-msgcommitlintmessage formatpre-push.husky/pre-pushtests + scanquality gateRemoterepoexit 1 โ†’ blocks commitexit 1 โ†’ blocks commitexit 1 โ†’ blocks pushEach hook exits 0 to continue or non-zero to abort โ€” Husky routes .git/hooks โ†’ .husky/

Step 1 โ€” Install Husky and Scaffold the Hook Directory Jump to heading

Initialize Husky with a single command. It creates .husky/ with a sample pre-commit script and registers the prepare lifecycle hook so every future npm install automatically installs hooks on fresh clones.

# Run from the repository root โ€” requires package.json to exist
npx husky init

# Stage the generated files so teammates get the hooks on clone
git add .husky/ package.json

Verify: Open package.json โ€” it should now include "prepare": "husky" in the scripts block. Running git config core.hooksPath should return .husky.

git config core.hooksPath
# Expected output: .husky

Husky v9 sets core.hooksPath automatically during husky init. This redirects Git away from the default .git/hooks/ directory, which is never committed, and toward .husky/, which is version-controlled and shared across the team.

Step 2 โ€” Author POSIX-Compliant Hook Scripts Jump to heading

Create the three baseline hooks that cover commit-time and push-time validation. Each script must use #!/usr/bin/env sh as the shebang โ€” this resolves sh through PATH rather than hardcoding a system path, ensuring the script runs identically on macOS, Linux, and Windows (Git Bash / WSL2).

#!/usr/bin/env sh
# .husky/pre-commit
# Runs before the commit object is created.
# Delegates staged-file analysis to lint-staged (see package.json "lint-staged" key).
npx lint-staged
#!/usr/bin/env sh
# .husky/commit-msg
# Validates the commit message against your project's convention (e.g. Conventional Commits).
# $1 is the path to the temporary file containing the commit message.
npx --no -- commitlint --edit "$1"
#!/usr/bin/env sh
# .husky/pre-push
# Runs before the push is transmitted to the remote.
# Keep this under 15 seconds โ€” slow hooks reduce adoption.
npm test -- --passWithNoTests

Verify: Make a test commit after authoring the hooks and confirm each hook fires:

# Force hook execution without changing files
git stash
git commit --allow-empty -m "test: verify hooks fire"
# Expect: pre-commit and commit-msg output appears; commit succeeds or fails based on your config
git stash pop

SAFETY WARNING: Never hardcode machine-specific paths such as /usr/local/bin/node or /opt/homebrew/bin/npx in hook scripts. These paths vary by OS and Node version manager (nvm, asdf, fnm). Always use #!/usr/bin/env sh and rely on PATH for tool resolution, or your hooks will silently break on teammatesโ€™ machines.

Step 3 โ€” Pin the Husky Version Jump to heading

Lock Husky to a major version in devDependencies so behavior does not drift when teammates run npm install at different times:

{
  "devDependencies": {
    "husky": "^9.0.0",
    "lint-staged": "^15.0.0",
    "@commitlint/cli": "^19.0.0",
    "@commitlint/config-conventional": "^19.0.0"
  }
}

Verify: Run npm ls husky โ€” the resolved version should match your pinned range.

npm ls husky
# Expected: [email protected] (exact patch will vary)
Why .git/hooks cannot be sharedFiles under .git/hooks are never transferred by clone, fetch or push, so a hook written there exists on exactly one machine. A versioned directory plus core.hooksPath is committed like any other code, so a fresh clone gets the same hooks after one install step..git/hooks/never cloned, never pushedone machine has the checkeveryone else has nothinga policy that exists for one person.husky/ + core.hooksPathtracked, reviewed, clonedone install step per clonechanges arrive with a pulla policy the whole team runs

Step 4 โ€” Configure lint-staged for Staged File Delegation Jump to heading

Hook scripts should remain thin wrappers. Embed linting logic in the lint-staged configuration inside package.json rather than in the hook script itself. This keeps hooks composable and easy to audit:

{
  "lint-staged": {
    "*.{js,ts,jsx,tsx}": ["eslint --fix", "prettier --write"],
    "*.{css,scss}": ["prettier --write"],
    "*.md": ["prettier --write --prose-wrap always"]
  }
}

The pre-commit hook calls npx lint-staged, which reads this configuration, filters to only the files currently staged, runs the commands, and re-stages any auto-fixed changes โ€” all without touching unstaged work. Full filtering and auto-fix routing patterns are covered in Lint-Staged & Formatting Automation.

SAFETY WARNING: Never make network calls (HTTP requests, package registry fetches, remote API calls) inside local hook scripts. Network latency is unpredictable and offline development environments will cause every commit to hang or fail for unrelated reasons. Keep hooks strictly local.

Step 5 โ€” Disable Hooks in CI Environments Jump to heading

Husky v9 detects the CI environment variable and skips husky install automatically when it is set. Most hosted CI platforms (GitHub Actions, GitLab CI, CircleCI) set CI=true by default, so no extra configuration is needed for them.

For Docker builds or CI runners that do not set CI, explicitly pass HUSKY=0:

# In your CI runner's install step or Dockerfile
HUSKY=0 npm ci

This prevents Husky from attempting to configure core.hooksPath in an environment where the .git/ directory may be shallow, absent, or irrelevant.

Verify: Inspect your CI run logs after npm ci โ€” you should see no output from Husky.

Integration with Adjacent Tools Jump to heading

Boundary with lint-staged Jump to heading

Husky owns the hook lifecycle โ€” it decides when scripts run by routing core.hooksPath to .husky/. Lint-staged owns the what โ€” it filters staged files and dispatches linter/formatter commands against that precise set. Keep this boundary clean: the pre-commit hook calls npx lint-staged and nothing else. Linter configuration, glob patterns, and auto-fix commands all live in the lint-staged block of package.json.

Boundary with CI/CD pipeline triggers Jump to heading

Local Husky hooks are a developer UX gate, not a security perimeter. They run only on machines where Husky is installed and can be bypassed with --no-verify. Your CI/CD Pipeline Trigger Mapping must independently re-execute all validations (linting, tests, secret scanning) in the pipeline โ€” local hooks and remote pipelines are complementary layers, not redundant ones. This separation is what prevents the โ€œit passed locallyโ€ failure class in production deployments.

Boundary with pre-push validation rules Jump to heading

Huskyโ€™s pre-push hook is the local counterpart to Pre-Push Validation Rules defined in your CI system. Use the local hook for fast feedback (unit tests, quick secret scans under 15 seconds). Reserve integration tests, end-to-end suites, and protected-branch enforcement for the remote pipeline, where they run with guaranteed, consistent infrastructure.

What fires, in what order, around one commitStaging then committing runs pre-commit with no arguments, then commit-msg with the path to the message file. Pushing runs pre-push with the ref updates on standard input. Each hook sees a different slice of the operation, which determines what it can usefully check.addgit addno hookprepre-commitno arguments โ€”read the index yourselfmsgcommit-msg$1 = path to themessage filepushpre-pushref updates on stdina hook that reads the wrong input is the most common reason one silently checks nothing

Keeping Hooks Working on Every Machine Jump to heading

A hook that runs on the machine of the person who wrote it is a demo. Making it run everywhere is a distribution problem, and it has three recurring failure modes worth designing against.

The fresh clone. Git deliberately does not transfer hooks, so a new clone has none until something installs them. If installation depends on a package manager lifecycle script, it happens automatically for anyone who runs an install โ€” and never for someone who clones the repository to read it, or who uses a different package manager, or whose install was run with lifecycle scripts disabled for security. The mitigation is a setup command in the contributing guide plus a check that fails loudly when hooks are absent, rather than assuming the lifecycle script covers everyone.

The shell that is not yours. Hook scripts run under the shell Git invokes, not the readerโ€™s interactive shell. Aliases, functions, tool version managers and anything else configured in an interactive profile are all absent. A hook that works when pasted into a terminal and fails when Git runs it is nearly always this: a binary that exists on the interactive PATH and not on the hookโ€™s. Resolve tools by absolute path or through the projectโ€™s own dependency directory rather than relying on inherited environment.

The other tool that also wants the hooks path. Editors, container tooling and language-specific frameworks all offer to manage Git hooks, and only one of them can win. Two systems installing into the same location produce a hook that silently stops running when the loser is reinstalled. Decide once which system owns the hooks path, write that decision down, and add a check that reports what the path currently points at.

Each of these fails silently โ€” the hook simply does not run, and nothing announces it. That is why a periodic health check that asserts hooks are installed and executable is worth more than any individual hook it protects.

Troubleshooting Jump to heading

SymptomLikely causeFix
hooks not running after cloneprepare script missing from package.jsonAdd "prepare": "husky" to scripts; run npm install to trigger it
core.hooksPath not sethusky init was not run, or ran outside the repo rootRun npx husky init from the directory containing .git/
Permission denied when hook firesHook file missing execute bitRun chmod +x .husky/pre-commit (and other hook files); commit the change
env: sh: No such file or directory on WindowsHook shebang references a path absent in Git BashReplace any hardcoded path shebang with #!/usr/bin/env sh
Husky running in CI and failingCI env variable not set; HUSKY not explicitly disabledAdd HUSKY=0 before npm ci in your CI runner configuration
lint-staged not foundnpx lint-staged fails because lint-staged is not installedAdd lint-staged to devDependencies and run npm install

Pinning the hook manager itself Jump to heading

The hook manager is a dependency like any other, and an unpinned one is a source of surprise: a major release changes the directory layout or the installation command, and the next person to run a fresh install gets hooks that silently do not run. Pin it to an exact version rather than a range, upgrade it deliberately in its own commit, and read the release notes for changes to the install path โ€” those are the changes that break quietly rather than loudly.

The same argument applies to the tools the hooks invoke. A formatter resolved from a range will eventually produce different output on two machines, which shows up as files that flip back and forth between commits. Pinning every tool the hook path depends on is what makes the hookโ€™s behaviour a property of the repository rather than of whoever last ran an install.

Frequently Asked Questions Jump to heading

Does Husky v9 work on Windows? Jump to heading

Yes. Hook scripts that use #!/usr/bin/env sh run correctly under Git Bash and WSL2. Avoid PowerShell syntax inside .husky/ files โ€” Git invokes hooks through its bundled shell, not through PowerShell. If contributors use WSL2, ensure Node is installed inside the WSL2 distribution, not only on the Windows host, because hook scripts execute in the WSL2 shell environment.

How do I stop Husky from running in CI pipelines? Jump to heading

Husky v9 skips installation automatically when the CI environment variable is set to any truthy value. For runners that do not set it, prefix your install command with HUSKY=0 npm ci. This is the recommended approach for Docker builds, Nix environments, and any runner with a read-only Git worktree.

What is the difference between pre-commit and pre-push hooks? Jump to heading

pre-commit fires before the commit object is written to the object store โ€” ideal for sub-two-second checks like formatting and fast linting that give instant feedback. pre-push fires before the push handshake with the remote server โ€” suitable for test suites and secret scanning where a few extra seconds is acceptable in exchange for catching broader regressions. Never move slow checks into pre-commit; slow commit hooks erode adoption faster than almost any other factor.

Can I bypass a Husky hook for an emergency fix? Jump to heading

Yes: git commit --no-verify skips all local hook execution. This is intentional โ€” local hooks are a quality UX aid, not an enforcement mechanism. Document every bypass in your contributing guide, require a follow-up ticket to address any skipped validations, and ensure your CI/CD pipeline catches what the local hook would have caught.

How do I migrate from manually managed .git/hooks/ scripts or Husky v4? Jump to heading

Start by auditing what exists with find .git/hooks -type f -executable -print, document each scriptโ€™s exit code semantics and dependencies, then run npx husky init and recreate the logic in .husky/. The step-by-step process, including v4 configuration mapping, is covered in Migrating from Legacy Git Hooks to Husky v9.