Migrating from Legacy Git Hooks to Husky v9 Jump to heading

Legacy .git/hooks/ scripts are invisible to version control, break silently after a fresh clone when executable permissions are lost, and diverge across developer machines — meaning each teammate may run a different version of your pre-commit gate without knowing it. This migration recipe moves that logic into Husky v9’s managed hook directory, which is version-controlled, installs automatically on npm install, and integrates cleanly with the Git Automation & CI/CD Hook Engineering stack this site covers.


Legacy hooks to Husky v9 migration flowA left-to-right diagram showing three stages: legacy .git/hooks (unversioned, manual chmod, per-machine), then migration steps (audit, backup, npx husky init, script copy, chmod), then Husky v9 .husky/ (version-controlled, auto-install on npm install, CI-aware).BEFORE.git/hooks/Not version-controlledchmod lost on cloneDiverges per machineNo CI skip logicShell-only, ad-hocMIGRATION STEPSAudit & backupfind .git/hooks -executablenpx husky initCreates .husky/ + prepareCopy scripts + chmodPOSIX shebang, npm run refsSet HUSKY=0 in CIAFTER.husky/Version-controlledAuto-installs via prepareConsistent across teamCI-aware (HUSKY=0)Delegates to npm scripts

When to use this approach Jump to heading

Apply this recipe when all of the following are true:

  • Your repository has one or more executable files in .git/hooks/ that enforce commit-time rules (linting, formatting, test gates, message format checks).
  • New clones regularly miss those hooks because no installation step is documented or automated.
  • You use Node.js ≥ 18 and package.json already exists in the repository root — Husky v9 is an npm dev dependency and assumes a Node project.
  • You want hook logic version-controlled alongside source code so that hook updates ship in the same PR as the code they guard.
  • Your CI pipeline runs npm ci or npm install and you need hooks to skip cleanly without wrapping every command in a --ignore-scripts flag.

If your project is not Node-based and npm tooling is not otherwise present, consider a lighter alternative: a plain core.hooksPath configuration pointing at a version-controlled directory, documented in the Git Automation & CI/CD Hook Engineering reference. For repositories that already use Husky v4 or v7, the breaking changes between major versions make this same recipe applicable with minor adjustments to environment variable names.

Step-by-step recipe Jump to heading

Step 1 — Audit existing hooks Jump to heading

Identify every active hook before touching anything:

# List all executable hook files currently installed
find .git/hooks -type f -executable -print

Verification: The output lists the hook names (pre-commit, commit-msg, pre-push, etc.). For each file, record the shebang line, what command it runs, and any environment variables it reads. This inventory drives the migration in Step 3.

For each hook, note whether it calls a tool that must also be available in CI — tools like lint-staged or test runners that belong in devDependencies and run identically in both local and pipeline contexts.


Step 2 — Back up legacy hooks and initialize Husky v9 Jump to heading

SAFETY WARNING: The next command removes the hooks directory as the source of truth for Git. Back up first. If Husky initialization fails and you have already deleted .git/hooks/ contents, you lose your enforcement gate until you restore from the backup below.

Recovery: cp -r .git/hooks-backup/. .git/hooks/ && chmod +x .git/hooks/*

# Preserve a full copy of existing hooks
cp -r .git/hooks/ .git/hooks-backup/

# Initialize Husky v9 — creates .husky/_/husky.sh and writes prepare to package.json
npx husky init

Verification: Confirm two outcomes — .husky/ directory exists, and package.json now contains a prepare script:

{
  "scripts": {
    "prepare": "husky"
  }
}

The prepare lifecycle hook runs automatically on every npm install and npm ci, which means every developer who clones the repository and installs dependencies gets hooks installed without manual steps. This is the core behaviour that legacy .git/hooks/ scripts cannot replicate.

Key v9 changes to know before proceeding:

Legacy behaviourHusky v9 equivalent
husky install commandRemoved; prepare: "husky" replaces it
.huskyrc config fileNot supported; hook files in .husky/ are the configuration
HUSKY_SKIP_INSTALLReplaced by HUSKY=0
HUSKY_SKIP_HOOKSReplaced by HUSKY=0

Step 3 — Migrate hook scripts to .husky/ Jump to heading

Translate each file from the audit in Step 1 into a corresponding file inside .husky/. Husky v9 hook files are plain POSIX shell scripts — no Husky-specific syntax required:

#!/usr/bin/env sh
# .husky/pre-commit
# Migrated from .git/hooks/pre-commit
# Delegates file-level analysis to lint-staged (staged-files only)
npx lint-staged

Where the legacy hook called a tool directly, prefer referencing a package.json script instead. This keeps hooks thin and lets CI run the same command without invoking the hook mechanism:

#!/usr/bin/env sh
# .husky/pre-push
# Delegates unit test execution to the package.json test script
npm run test:unit

Apply executable permissions immediately — without this step the hook files exist but Git will not execute them:

chmod +x .husky/pre-commit .husky/commit-msg .husky/pre-push

Verification: Stage an empty change and commit:

git add .husky/
git commit -m "chore: migrate to husky v9"

The pre-commit hook should execute and produce the same linting or formatting output as the legacy .git/hooks/pre-commit did.

SAFETY WARNING: If chmod +x is omitted, the hooks silently do nothing on POSIX systems. On Windows with Git for Windows, permissions are managed differently — test on all target platforms before removing the backup.

Recovery: chmod +x .husky/*


Step 4 — Align CI/CD environments Jump to heading

CI pipelines must not attempt to install local Git hooks. Set HUSKY=0 in your pipeline environment, or rely on the CI=true variable that most hosted CI providers set automatically:

# In your CI runner — prevents the prepare script from running husky
HUSKY=0 npm ci

For GitHub Actions, add the variable to your workflow environment block:

env:
  HUSKY: "0"

Verification: Run a pipeline build and confirm that the prepare step produces no Husky output and exits 0. The hooks themselves should never execute in CI — instead, run linting and tests explicitly via npm run lint and npm run test as discrete pipeline steps. This prevents redundant validation overlap with CI/CD pipeline trigger mapping and keeps pipeline logs clean.


Step 5 — Team rollout and documentation Jump to heading

Commit .husky/ and the updated package.json (and any lint-staged configuration) in a single PR so reviewers see the complete changeset:

git add .husky/ package.json
# Add lint-staged config if you moved it from .lintstagedrc to package.json
git add package.json
git commit -m "chore: migrate git hooks to husky v9"

Each teammate gets hooks automatically after pulling and running:

npm install
# The prepare script fires here and installs .husky/ hooks via core.hooksPath

Verification: Ask a teammate to clone a fresh copy and run npm install. Then have them confirm hooks are present:

ls -la .husky/
# Expect: pre-commit, commit-msg, pre-push — all executable

Update CONTRIBUTING.md to note that hooks are now version-controlled and install automatically — no manual setup required for new contributors. Remove the backup directory after one sprint of confirmed production use:

rm -rf .git/hooks-backup/

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Can I keep some hooks in .git/hooks/ while others are in .husky/? Jump to heading

No. Git resolves hooks from exactly one directory at a time, controlled by core.hooksPath. Once Husky sets this to .husky/, Git ignores .git/hooks/ entirely. Migrate all hooks before removing the legacy directory.

Why does Husky skip installation in CI even without setting HUSKY=0? Jump to heading

Husky v9’s bootstrap script checks for the CI environment variable. Most hosted CI providers — GitHub Actions, GitLab CI, CircleCI, and others — set CI=true automatically, so the prepare script exits early without installing hooks. Setting HUSKY=0 is an explicit opt-out that is more portable and does not rely on CI providers following that convention.

What happened to husky install and .huskyrc? Jump to heading

Both are removed in v9. The husky install command is replaced by the prepare lifecycle script ("prepare": "husky"). The .huskyrc configuration file is gone entirely — all configuration now lives as executable shell files inside .husky/. There is no v9 equivalent of .huskyrc; hook files are the configuration.