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.
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.jsonalready 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 ciornpm installand you need hooks to skip cleanly without wrapping every command in a--ignore-scriptsflag.
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 behaviour | Husky v9 equivalent |
|---|---|
husky install command | Removed; prepare: "husky" replaces it |
.huskyrc config file | Not supported; hook files in .husky/ are the configuration |
HUSKY_SKIP_INSTALL | Replaced by HUSKY=0 |
HUSKY_SKIP_HOOKS | Replaced 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 +xis 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.
Related Jump to heading
- Local Hook Configuration with Husky — the parent reference covering Husky v9 directory structure,
core.hooksPath, and team-wide configuration patterns this migration targets. - Preventing Broken Builds with Pre-Push Hooks — how to wire the migrated
pre-pushhook to fast unit test and build checks that run before code reaches the remote. - Lint-Staged & Formatting Automation — configure the
lint-stagedtool that your migratedpre-commithook will invoke for staged-file-only analysis.