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.

How each lint-staged entry maps onto a pinned hookThree rows. A JavaScript glob running eslint --fix becomes the eslint hook at a pinned revision with a files pattern. A glob running prettier --write becomes the prettier mirror hook. A project script called directly becomes a local hook using language system.lint-staged.pre-commit-config.yaml"src/**/*.{js,ts}":"eslint --fix"repo: mirrors-eslint rev: v9.17.0files: ^src/.*\.(js|ts)$"**/*.{css,md,json}":"prettier --write"repo: mirrors-prettier rev: v3.4.2types_or: [css, markdown, json]"*.sh":"scripts/check-shell.sh"repo: local language: systementry: scripts/check-shell.sh
# .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 install while 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/hooks at any moment; the coexistence above works precisely because the framework is called, not installed.

Step 4 — Cut the hook path over Jump to heading

The three states of the cutoverStage one: Husky owns the Git hook path and runs lint-staged. Stage two: Husky still owns the path but also calls pre-commit in advisory mode, so both run and disagreements are visible. Stage three: pre-commit owns the path, Husky is removed, and rollback is a single git revert.1 · beforecore.hooksPath=.huskynpx lint-stagedone system, authoritative2 · side by sidecore.hooksPath=.huskylint-stagedpre-commitboth run; only the left one blocks3 · after.git/hooks/pre-commitpre-commit runone system, authoritativerollback at any stage: git revert the config commit, then re-run the installer for the system you want
# 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.

The checks that go missing, and where they landThree checks commonly lost during the migration: commit-message validation, pre-push secret scanning, and a project script invoked directly by a Husky hook. Each maps to a specific declaration in the new configuration — a commit-msg hook type, a stages pre-push entry, and a local hook.easily lostre-declare it as.husky/commit-msgmessage convention checkpre-commit install --hook-type commit-msg+ commitlint hook, stages: [commit-msg].husky/pre-pushsecret scan before the pushpre-commit install --hook-type pre-push+ hook with stages: [pre-push]scripts/*.sh called inlineproject-specific rulerepo: local · language: systementry: scripts/check-shell.sh

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.