Sharing Husky hooks across a monorepo Jump to heading

A monorepo has one .git directory but many packages, each with its own lint rules, test runner, and build. The instinct is to give every package its own Husky install — and it fails silently, because Git resolves exactly one core.hooksPath for the whole repository. The second install overwrites the first, or worse, none of them fire from the directory a contributor happens to cd into. The correct model is a single Husky install at the repository root that every package shares, with the root hook dispatching package-scoped work only to the packages a commit touches. This page is a focused recipe within Local Hook Configuration with Husky, which covers the full Husky v9 model, the prepare lifecycle, and how core.hooksPath is resolved.

When to use this approach Jump to heading

Apply this recipe when:

  • Your repository holds multiple packages or apps under one .git, managed by npm, pnpm, or Yarn workspaces — the branch-level layout is covered in Monorepo Branch Topology.
  • Contributors commit from different package subdirectories and you need identical hook behavior regardless of cwd.
  • Running every package’s lint and test suite on every commit has pushed hook time past 30 seconds, and you want to validate only the packages a commit actually changed.
  • You already scope commit-time formatting with Lint-Staged & Formatting Automation and now need those globs to route work to the right workspace.
  • The tree mixes Node packages with non-Node subrepos (a Go service, a Rust crate, generated protobufs) that must be validated by the same commit gate.

This recipe is not the right fit if: your repository is a single package (a flat Husky install is simpler), or if the “monorepo” is actually several independent repositories stitched together with submodules — in that case each submodule keeps its own hooks and you coordinate them at the superproject level instead.

The diagram below shows the one-install-many-packages topology and how a single root hook fans out only to the packages a commit touched.

Single root Husky install dispatching to touched packagesDiagram showing one repository root with a single .git and core.hooksPath pointing at .husky. The root pre-commit runs lint-staged, which routes staged files by directory glob to only the packages that changed — api and web run, while ui and a Go service are skipped because no files under them were staged.repo rootone .gitcore.hooksPath=.husky.husky/pre-commitlint-stagedpackages/api (changed)eslint + vitest runpackages/web (changed)eslint + tsc runpackages/ui (untouched)skippedservices/gateway (Go)skippedrunno staged files

Step-by-step recipe Jump to heading

Step 1 — Confirm one Git root and one workspace Jump to heading

Every technique here depends on the whole tree being a single Git repository with a single package-manager workspace. Verify before touching hooks:

# Must print the monorepo root — run it from several package subdirectories
# and confirm they all resolve to the SAME path
git rev-parse --show-toplevel

# There must be exactly one .git at the root and no nested ones inside packages
find . -name .git -not -path './node_modules/*'

The find command should return a single ./.git. Any second result is a nested repository that the root hooks cannot reach — see the safety warning below and Step 6. Confirm the workspace layout is declared once at the root:

# npm/Yarn: "workspaces" array in the root package.json
# pnpm: a pnpm-workspace.yaml at the root
cat pnpm-workspace.yaml 2>/dev/null || node -p "require('./package.json').workspaces"

Step 2 — Install Husky at the root with a root prepare script Jump to heading

Install Husky only in the root package.json, never in a package’s. The prepare lifecycle script runs after every npm install / pnpm install at the root, so every contributor gets the hooks wired automatically after cloning:

# Run from the repository root
pnpm add -Dw husky        # -w installs into the workspace root
pnpm exec husky init      # creates .husky/ and sets the prepare script

husky init writes a prepare script and a starter .husky/pre-commit. Pin the root package.json so the intent is explicit and package installs cannot re-trigger it:

{
  "name": "monorepo-root",
  "private": true,
  "scripts": {
    "prepare": "husky"
  },
  "devDependencies": {
    "husky": "^9.1.0",
    "lint-staged": "^15.2.0"
  }
}

Modern Husky (v9+) sets core.hooksPath to .husky/_ on init, so you do not set it by hand. Verify exactly one hooks path is registered for the whole repo:

# Expect a single value like ".husky/_" — never a per-package path
git config --get core.hooksPath

Step 3 — Write a root pre-commit that always runs from the root Jump to heading

The hook must behave identically no matter which package directory the contributor committed from. Husky invokes the hook with cwd at the repository root already, but make it explicit and resilient so a contributor running git commit from packages/api gets the same result:

# .husky/pre-commit
# Husky v9: no shebang and no ". husky.sh" sourcing line needed.
# Resolve the repo root so every command below is root-relative.
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT" || exit 1

# Single entry point — lint-staged reads the root config and routes
# each staged file to the right package (Step 4).
pnpm exec lint-staged

Verify the hook fires from a nested directory:

# Commit from inside a package and confirm lint-staged still runs
cd packages/api && git commit --allow-empty -m "probe: hook from subdir"
# Expect lint-staged output; then discard the probe commit:
git reset --soft HEAD~1

Step 4 — Configure workspace-aware lint-staged Jump to heading

A single lint-staged config at the root maps staged paths to package-scoped commands using per-directory globs. Files outside every glob are skipped, so untouched packages never run. Put this in the root package.json or a root lint-staged.config.js:

// lint-staged.config.js  (repository root)
// Each key is a glob scoped to one package; the value receives the
// absolute paths of the staged files that matched THAT glob only.
export default {
  // TS/JS in the api package -> lint + typecheck that package
  "packages/api/**/*.{ts,tsx}": (files) => [
    `eslint --fix ${files.join(" ")}`,
    // typecheck the whole package once, not file-by-file
    "pnpm --filter @acme/api exec tsc --noEmit",
  ],
  // Web package has its own ruleset
  "packages/web/**/*.{ts,tsx,vue}": (files) => [
    `eslint --fix ${files.join(" ")}`,
  ],
  // Repo-wide formatting for everything else (docs, configs)
  "*.{json,md,yml,yaml}": "prettier --write",
};

Because each command receives only the files that matched its glob, a commit that touches just packages/api never invokes packages/web tooling. Verify the routing with a dry run:

# Stage a change in one package, then preview what lint-staged would run
git add packages/api/src/index.ts
pnpm exec lint-staged --debug   # prints the matched globs and commands

The --debug output should list only the packages/api task list, confirming other packages are excluded. For the deeper mechanics of matching and staging behavior, see Lint-Staged & Formatting Automation.

Step 5 — Dispatch package-scoped checks only for touched packages Jump to heading

Some checks (a full package test run, a build graph) are too coarse for lint-staged’s per-file model. Compute the set of changed packages once, then run each package’s own script through the workspace filter. Extract the touched packages from the staged file list:

# .husky/pre-commit  (append below the lint-staged call)
# Derive the unique top-level package dirs from staged files.
CHANGED_PKGS=$(git diff --cached --name-only --diff-filter=ACMR \
  | grep -E '^packages/[^/]+/' \
  | cut -d/ -f1-2 \
  | sort -u)

if [ -z "$CHANGED_PKGS" ]; then
  echo "→ No package sources staged — skipping package tests."
  exit 0
fi

FAIL=0
for pkg in $CHANGED_PKGS; do
  # Read the package name from its own package.json and filter to it.
  name=$(node -p "require('./$pkg/package.json').name")
  echo "→ Running scoped tests for $name"
  # --if-present: packages without a "test" script are skipped cleanly.
  pnpm --filter "$name" run --if-present test || FAIL=1
done

[ "$FAIL" -ne 0 ] && { echo "ERROR: package tests failed"; exit 1; }

With Turborepo or Nx you can delegate the same dispatch to the task graph, which adds caching so unchanged inputs are never recomputed:

# Turborepo: --filter=...[HEAD] restricts to packages affected since HEAD
pnpm exec turbo run test lint --filter="...[HEAD^1]" --continue

Verify only touched packages run:

# Stage a change in exactly one package, then run the hook body directly
git add packages/api/src/index.ts
bash .husky/pre-commit
# Expect scoped test output for @acme/api ONLY — no other package name

Step 6 — Handle non-Node subrepos and verify the shared install Jump to heading

A monorepo often contains directories that are not npm packages — a Go service, a Rust crate, a Terraform module. The root hook still governs them because there is one .git; route them by path just like packages, invoking their native toolchain:

# .husky/pre-commit  (non-Node dispatch)
GO_FILES=$(git diff --cached --name-only --diff-filter=ACMR \
  | grep -E '^services/gateway/.*\.go$' || true)

if [ -n "$GO_FILES" ]; then
  echo "→ Go files staged — running gofmt check"
  # gofmt -l lists files that are NOT formatted; non-empty output = fail
  unformatted=$(cd services/gateway && gofmt -l .)
  if [ -n "$unformatted" ]; then
    echo "ERROR: gofmt needed in: $unformatted"
    exit 1
  fi
fi

Now confirm a fresh clone wires everything with no manual steps — the whole point of the root prepare script:

# Simulate onboarding in a scratch clone
git clone <this-repo-url> /tmp/mono-check && cd /tmp/mono-check
pnpm install                       # runs the root "prepare": husky
git config --get core.hooksPath    # expect the single ".husky/_" value
ls -la .husky/pre-commit           # hook present after install

SAFETY WARNING: Only one core.hooksPath exists per repository, so a second Husky install (in a package, or a nested .git) silently overrides or bypasses the root hooks and commits go unvalidated. Never run husky init inside a package, and never let a subdirectory keep its own .git. Audit with find . -name .git -not -path '*/node_modules/*' — it must return exactly one path. If a nested repo slipped in, convert it to a submodule or remove its .git and re-add the files: rm -rf packages/legacy/.git && git add packages/legacy.

Validation checklist Jump to heading

Before considering the shared setup production-ready, confirm each item:

Frequently asked questions Jump to heading

Should each package have its own Husky install in a monorepo? Jump to heading

No. Git resolves exactly one core.hooksPath per repository, so only one Husky install can ever fire. Install Husky once at the repository root through a single prepare script and let the root hooks dispatch to individual packages via lint-staged globs and workspace filters. Per-package installs either fight over core.hooksPath — last write wins — or silently never run because the active hooks path points elsewhere.

How do I run checks only for the packages a commit actually touches? Jump to heading

Give lint-staged per-directory globs like packages/api/**/*.ts, and have each glob’s command scope to that package with a workspace filter (pnpm --filter, npm --workspace, turbo run --filter). Files that match no package glob are skipped entirely, so untouched packages never build or test. For coarse checks like a full package test suite, derive the changed package set from git diff --cached --name-only and loop over just those directories.

What happens if a subdirectory has its own nested .git directory? Jump to heading

A nested .git turns that subdirectory into a separate repository with its own core.hooksPath, so the root Husky hooks will not fire inside it and its commits go unvalidated. Either convert the nested repo into a proper submodule or subtree, or remove its .git so the whole tree is a single repository the root hooks govern. Audit periodically with find . -name .git -not -path '*/node_modules/*', which must always return exactly one path.