A monorepo with a Python API, a TypeScript frontend, Go workers, shell scripts and Terraform has five different lint setups — a Husky hook in web/, a Makefile target for Go, nothing for Terraform — and each developer knows about the ones for the code they touch. Running pre-commit install with a naive configuration then fails with [ERROR] git failed. Is it installed, and are you in a Git repository directory? in a worktree, or runs eslint on Python files and prints hundreds of parse errors. This page builds one pre-commit configuration that covers every language in the repository with correct file scoping, as part of pre-commit hooks and local quality gates.

The pre-commit framework is language-agnostic: each hook declares the language it needs, and the framework builds an isolated environment for it. That is what makes it a good single entry point for a polyglot repository.

Diagnostic

Inventory existing hook mechanisms and what each language currently checks:

#!/usr/bin/env bash
set -euo pipefail
git config --get core.hooksPath && echo "hooksPath set: another manager owns hooks" || true
ls .husky 2>/dev/null && echo "husky present" || true
ls .git/hooks | grep -v '\.sample$' || echo "no active hooks"
git ls-files | sed -nE 's/.*\.([a-z]+)$/\1/p' | sort | uniq -c | sort -rn | head -8

Expected bad output:

.husky
husky present
hooksPath set: another manager owns hooks
    412 ts
    288 py
    131 go
     64 tf
     37 sh

Husky owns core.hooksPath, so pre-commit install refuses to install (Cowardly refusing to install hooks with core.hooksPath set), and only the TypeScript code is currently checked at commit time.

One Configuration, Five Languages The pre-commit configuration in the centre dispatches hooks to five language areas of the repository. One Configuration, Five Languages .pre-commit-config Python api/ ruff, ruff-format TypeScript web/ prettier, eslint Go workers/ gofmt, go vet Terraform infra/ fmt, tflint Shell scripts/ shellcheck
File filters keep each hook to its own language, so a change pays only for what it touched.

Root cause

Polyglot repositories accumulate per-language tooling because each team adds the setup it knows, in the ecosystem it knows: Husky for Node, a Makefile for Go, nothing for infrastructure code. Hook managers compete for the same .git/hooks directory, and Husky claims it by setting core.hooksPath, which blocks any other manager. When a single manager is introduced, the common mistake is to add hooks without file scoping: every hook then receives every staged file, and tools either fail on files they cannot parse or silently skip them, both of which erode trust in the setup. The fix is one manager, explicit files or types filters on every hook, and local hooks for tools that are not published as hook repositories.

Resolution

  1. Remove the competing manager and let pre-commit own the hooks:
#!/usr/bin/env bash
set -euo pipefail
git config --unset core.hooksPath || true
npm pkg delete scripts.prepare 2>/dev/null || true
rm -rf .husky
pre-commit install --install-hooks
  1. Write the configuration with a filter on every hook:
default_install_hook_types: [pre-commit, commit-msg]
exclude: '^(vendor/|web/dist/|.*\.lock$)'
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.6.0
    hooks:
      - id: end-of-file-fixer
      - id: trailing-whitespace
      - id: check-yaml
        args: [--allow-multiple-documents]
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.6.4
    hooks:
      - id: ruff
        files: ^api/
        args: [--fix]
      - id: ruff-format
        files: ^api/
  - repo: https://github.com/pre-commit/mirrors-prettier
    rev: v3.1.0
    hooks:
      - id: prettier
        files: ^web/.*\.(ts|tsx|json|css|md)$
  - repo: https://github.com/antonbabenko/pre-commit-terraform
    rev: v1.94.1
    hooks:
      - id: terraform_fmt
        files: ^infra/
  - repo: https://github.com/shellcheck-py/shellcheck-py
    rev: v0.10.0.1
    hooks:
      - id: shellcheck
  - repo: local
    hooks:
      - id: eslint
        name: eslint (web)
        language: node
        entry: npx --prefix web eslint --max-warnings=0
        files: ^web/.*\.(ts|tsx)$
        additional_dependencies: []
      - id: gofmt
        name: gofmt (workers)
        language: golang
        entry: gofmt -l -w
        files: ^workers/.*\.go$

ESLint runs as a local hook through the web package's own dependencies, so plugins and configuration stay managed by web/package.json rather than duplicated in the hook configuration. gofmt runs through the framework's Go environment.

  1. Run over all files once and commit the result separately, so formatting churn does not hide in feature changes:
#!/usr/bin/env bash
set -euo pipefail
pre-commit run --all-files || true
git add -A
git commit -m "style: apply pre-commit formatting across the repository" --no-verify
pre-commit run --all-files

The final run must pass cleanly; --no-verify is justified only on this one mechanical commit.

  1. Record the commit in .git-blame-ignore-revs so git blame skips the formatting change:
#!/usr/bin/env bash
set -euo pipefail
git rev-parse HEAD >> .git-blame-ignore-revs
git config blame.ignoreRevsFile .git-blame-ignore-revs
git add .git-blame-ignore-revs && git commit -m "chore: ignore formatting commit in blame"
Introducing pre-commit to an Existing Repo Ordered steps from removing a competing hook manager to a clean all-files run. Introducing pre-commit to an Existing Repo 1 — remove Husky and hooksPath 2 — write config with file filters 3 — pre-commit install --install-hooks 4 — run all files, commit formatting alone 5 — add commit to blame ignore list
The one-off formatting commit is isolated so reviews and blame stay readable.

Expected output

$ pre-commit run --all-files
fix end of files.........................................................Passed
trim trailing whitespace.................................................Passed
check yaml...............................................................Passed
ruff.....................................................................Passed
ruff-format..............................................................Passed
prettier.................................................................Passed
terraform_fmt............................................................Passed
shellcheck...............................................................Passed
eslint (web).............................................................Passed
gofmt (workers)..........................................................Passed

Every language has its checks, and a commit that only touches api/ runs only the Python and generic hooks — the others print (no files to check)Skipped.

That skipping is what keeps a polyglot configuration fast in practice. A developer who only works in the frontend never waits for Terraform or Go checks, yet every language is covered for whoever touches it. When a new directory or language appears, adding one filtered hook extends coverage without slowing anyone else down.

Prevention

  1. Run the same configuration in CI with pre-commit run --all-files, as described in running the same lint checks in pre-commit and CI.

  2. Require a filter on every hook in review. A hook without files, types or exclude either runs on everything or relies on the tool to ignore unknown files, which fails differently per tool.

  3. Install hooks from the bootstrap target (pre-commit install --install-hooks) so new clones get them and the first commit does not pay the environment setup cost.

Hooks Run per Commit by Area Changed Bar chart of how many hooks run for commits touching different parts of the monorepo. Hooks Run per Commit by Area Changed api only 5 hooks web only 5 hooks infra only 4 hooks all areas 10 hooks
File filters keep per-commit work proportional to what changed.

Platform caveats

Windows (native): the framework installs Python, Node and Go environments on Windows, so the hooks above are portable. Avoid language: system hooks that call Bash.

macOS: the first --install-hooks run downloads a Go toolchain for the golang hook if none is installed; this is a one-time cost of about a minute.

Apple Silicon (ARM64): mirrors-prettier and ruff publish arm64 builds; the Terraform hooks call the terraform binary on PATH, which must be an arm64 build from the project's toolchain file.

Git worktrees: pre-commit supports worktrees but must be installed from the main checkout; run pre-commit install there, and hooks apply to all worktrees.

Rollback

Uninstall the hooks; the configuration file can stay for CI or be reverted:

#!/usr/bin/env bash
set -euo pipefail
pre-commit uninstall --hook-type pre-commit --hook-type commit-msg
git revert --no-edit "$(git log -1 --format=%H -- .pre-commit-config.yaml)"

Frequently Asked Questions

Why does pre-commit install refuse to run?

core.hooksPath is set, usually by Husky, and pre-commit will not install hooks into a directory another manager controls. Unset it with git config --unset core.hooksPath after removing the other manager.

Should ESLint run through a mirror hook or a local hook?

A local hook using the web package's own dependencies is usually better, because ESLint plugins and configuration are already declared in package.json. Mirror hooks require listing every plugin again in additional_dependencies.

How do we avoid a huge formatting diff in feature pull requests?

Run pre-commit run --all-files once, commit the result as a dedicated formatting commit, and add it to .git-blame-ignore-revs. After that, hooks only touch changed files.

Can different directories use different versions of the same tool?

Yes, by declaring the hook twice with different rev values or additional_dependencies and non-overlapping files patterns. Use this sparingly; a single version per tool is easier to maintain.