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)

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.

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

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.