Migrating from Husky to the pre-commit framework Jump to heading
The usual trigger for this migration is a second language. A JavaScript repository grows a Python service, then a Terraform module, and suddenly the Husky and lint-staged arrangement that worked perfectly needs every contributor to have three toolchains installed at compatible versions. The pre-commit framework solves that by provisioning the toolchains itself — but only if the cutover does not break hooks for the team in the middle. This recipe does it incrementally, keeping a working rollback at every step.
When to use this approach Jump to heading
- The repository has grown past one language, and installing every linter locally is now a barrier to contribution.
- You want linter versions pinned in a reviewed file rather than resolved from whatever each developer happens to have installed.
- Formatting differs between developers’ machines because their editors and the hooks run different tool versions.
- You are already fighting the “it passed locally” class of problem and want one authoritative definition of the checks.
- If none of these are true — a single-language JavaScript repository with a stable toolchain — stay on Husky. The migration costs more than it returns.
Step 1 — Inventory what Husky currently runs Jump to heading
You cannot preserve behaviour you have not written down. Start by listing every hook file and every command inside it.
# Every hook Husky manages, and what each one invokes
for f in .husky/*; do
case "$f" in *_|*.md) continue ;; esac
printf '\n===== %s =====\n' "$f"
grep -vE '^\s*(#|$)' "$f"
done
# The lint-staged configuration, wherever it lives
cat .lintstagedrc* 2>/dev/null || \
node -e "console.log(JSON.stringify(require('./package.json')['lint-staged'], null, 2))" What changed: nothing yet. You now have the exact set of behaviours the migration must reproduce — write it into the migration issue so “did we lose a check?” has an answer later.
git config core.hooksPath # expect: .husky (Husky v9's install target)
ls -l .git/hooks/pre-commit # may not exist when core.hooksPath is set Step 2 — Translate each lint-staged entry into a pinned hook Jump to heading
The mapping is mechanical: a lint-staged glob becomes a files pattern, and the command it ran becomes a hook from a pinned repository.
# .pre-commit-config.yaml — the translated configuration
repos:
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v9.17.0
hooks:
- id: eslint
args: ['--fix']
files: ^src/.*\.(js|ts)$
additional_dependencies:
- [email protected]
- '@typescript-eslint/[email protected]'
- repo: https://github.com/rbubley/mirrors-prettier
rev: v3.4.2
hooks:
- id: prettier
types_or: [css, markdown, json]
- repo: local
hooks:
- id: check-shell
name: shell script checks
entry: scripts/check-shell.sh
language: system
files: \.sh$ additional_dependencies is the entry people miss. An ESLint hook needs its plugins and parser inside the isolated environment, and listing them here is what makes the hook reproducible rather than dependent on whatever is in the project’s node_modules.
pre-commit install-hooks # build the environments once
pre-commit run --all-files # compare against a fresh Husky run Step 3 — Run both systems side by side Jump to heading
Do not swap the hook path yet. Instead, have the existing Husky hook call the framework so both run and you can compare output on real commits.
#!/usr/bin/env sh
# .husky/pre-commit — during migration only.
# Existing behaviour, unchanged.
npx lint-staged
# New system, running alongside. Not yet authoritative:
# a non-zero exit here is reported but does not fail the commit.
if command -v pre-commit >/dev/null 2>&1; then
pre-commit run || echo "note: pre-commit reported findings (advisory during migration)" >&2
fi What changed: every commit now exercises both systems, and any disagreement shows up in the terminal while lint-staged remains the thing that can actually block a commit.
git commit --allow-empty -m "chore: migration smoke test"
# Expect: lint-staged output, then pre-commit output, commit succeeds Leave this in place for a week of ordinary work. What you are looking for is not whether the framework runs — it is whether it disagrees with lint-staged about any real file. Every disagreement is either a scoping bug in the new configuration or a version difference worth knowing about.
SAFETY WARNING — do not run
pre-commit installwhile Husky owns the hook path. The framework will either refuse or overwrite Husky’s hook depending on your configuration, and an overwrite silently disables every check Husky was running. Keep exactly one system installed into.git/hooksat any moment; the coexistence above works precisely because the framework is called, not installed.
Step 4 — Cut the hook path over Jump to heading
# 1. Release the hook path from Husky
git config --unset core.hooksPath
# 2. Let the framework install its own hooks
pre-commit install --install-hooks
pre-commit install --hook-type commit-msg # only if you enforce message shape
# 3. Confirm who owns the hook now
head -3 .git/hooks/pre-commit # expect the generated pre-commit shim What changed: Git now invokes the framework directly. Every developer must run pre-commit install once after pulling this change, which is the one manual step in the whole migration — put it in the pull request description and the contributing guide on the same day.
Step 5 — Remove Husky and verify nothing was lost Jump to heading
# Remove the old system only after a full sprint on the new one
git rm -r .husky
npm uninstall husky lint-staged
# Drop the "prepare": "husky" script from package.json
# Prove the checks still fire
pre-commit run --all-files
git commit --allow-empty -m "chore: post-migration verification" Then compare against the inventory from Step 1, item by item. The checks that most often go missing are the ones that were never in lint-staged at all: a commit-msg hook validating Conventional Commits, or a pre-push hook running a secret scan. Both have direct equivalents — pre-commit install --hook-type commit-msg and a stages: [pre-push] entry — but neither appears automatically.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Can Husky and pre-commit run at the same time? Jump to heading
Not through the same hook file — whichever installs last owns .git/hooks/pre-commit and the other silently stops running. During the cutover, keep Husky as the installed hook and have its script call the framework explicitly. That way one file is in charge, both sets of checks execute, and you can see the new system’s output before it becomes authoritative.
Do I lose lint-staged’s staged-file filtering? Jump to heading
No — the framework does the same thing natively. It passes only staged files to each hook and stashes unstaged work first, which is strictly closer to what you want than lint-staged’s default behaviour of leaving unstaged changes visible to the linter. The files and exclude patterns replace lint-staged’s glob keys directly.
What about hooks that are pure project scripts? Jump to heading
Declare them as repo: local hooks with language: system, pointing entry at the same script Husky called. Nothing about the script changes; it simply gains the framework’s file scoping and staged-file handling. Local hooks are also the right home for anything that would never make sense as a published upstream hook.
Related Jump to heading
- The pre-commit Framework for Polyglot Repositories — the parent guide: pinned hook repositories, file scoping, and running the same configuration in CI.
- Caching pre-commit Environments in CI — the first thing to fix once the migration lands, before anyone notices the cold-run cost.
- Writing a Custom Local pre-commit Hook — how to port the project scripts Husky used to call inline.