Setting Up pre-commit for a Polyglot Repository
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.
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
- Remove the competing manager and let
pre-commitown 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
- 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.
- 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.
- Record the commit in
.git-blame-ignore-revssogit blameskips 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"
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
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.Require a filter on every hook in review. A hook without
files,typesorexcludeeither runs on everything or relies on the tool to ignore unknown files, which fails differently per tool.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.
Platform caveats
Windows (native): the framework installs Python, Node and Go environments on Windows, so the hooks above are portable. Avoid
language: systemhooks that call Bash.
macOS: the first
--install-hooksrun downloads a Go toolchain for thegolanghook if none is installed; this is a one-time cost of about a minute.
Apple Silicon (ARM64):
mirrors-prettierandruffpublish arm64 builds; the Terraform hooks call theterraformbinary onPATH, which must be an arm64 build from the project's toolchain file.
Git worktrees:
pre-commitsupports worktrees but must be installed from the main checkout; runpre-commit installthere, 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.