Configuring pre-commit in a Multi-Language Repo

A repo with Python, JavaScript, and Go needs formatters and linters for each, and wiring them by hand is brittle. This page uses the pre-commit framework to declare per-language hooks in one file, pin every hook by revision, and cache their environments in the devcontainer so a polyglot commit stays fast and reproducible.

This matters because a polyglot repo multiplies the surface area where local tooling and CI can drift apart. Without a single source of truth, each language grows its own ad-hoc invocation — a shell alias that runs ruff, a package.json script that calls prettier, a Makefile target that shells out to gofmt — and none of them agree on which version to run. The pre-commit framework collapses those scattered invocations into one .pre-commit-config.yaml that every contributor and the CI pipeline read identically.

Reach for this pattern the moment a repo crosses a language boundary and you want the commit gate to stay honest across every rebuild of the container. The mental model is a manifest: you are not installing three linters into the image and hoping everyone runs them, you are declaring three hooks — each with its own upstream repo and its own pinned rev — and letting the framework provision an isolated environment per hook. Because that provisioning is the slow part, the whole approach only pays off when the environments live on a cache volume that survives container rebuilds, which is why pinning and caching are treated here as a single inseparable discipline rather than two optional extras.

Prerequisites

You need the pre-commit framework and the per-language tools it will manage.

  • The pre-commit framework installed (Python).
  • The workspace mounted so .git exists at postCreate.
  • A cache volume for pre-commit's environments.

Polyglot hook prerequisitesThe framework, per-language hooks pinned by revision, and a cache make polyglot hooks fast.pre-commitframework installedPer-lang hooksruff, prettier, gofmtPin revsreproducibleCache envsfast commits

The three bullets above are load-bearing in that order. The framework itself is a Python package, so it rides in on pip even in a repo whose primary language is Go or JavaScript; that surprises people who assume a Go repo has no Python dependency. The per-language tools it will drive — ruff, prettier, gofmt — do not need to be pre-installed in the image, because each hook's upstream repo carries the tool and the framework builds it into an isolated environment. What the framework cannot conjure is a missing language runtime: a Go hook still needs a Go toolchain present to compile and run, and a Node-based hook needs a Node runtime, so those belong in the base image or in a devcontainer Feature.

The detail people most often get wrong is the timing of the mount. The .git directory must already exist inside the container when pre-commit install runs, because that command writes the hook shims into .git/hooks. If the postCreate step fires before the workspace bind mount is in place, pre-commit install has nothing to write into and the hooks silently never fire on commit. Confirm the workspace is mounted and .git is visible at postCreate time, and give the cache volume a stable named target so the environments it holds outlive any single container.

{
  "postCreateCommand": "pip install pre-commit && pre-commit install --install-hooks",
  "remoteUser": "vscode"
}

The two commands are chained with && so the install of the framework must succeed before the hooks are wired; if pip install pre-commit fails, you never reach a half-configured state where the shims exist but the framework does not. The --install-hooks flag is the part that earns its keep in a polyglot repo: without it, pre-commit install writes only the git shim and defers building each hook's environment until the first commit, which means the first person to commit pays the full cost of provisioning the Python, JavaScript, and Go environments in one blocking step. With --install-hooks, that provisioning happens here in postCreate, so the first real commit is already warm. Running as remoteUser: vscode keeps the shims and the cache owned by the same non-root user that will later run git commit, which lets the cache volume below be read and written without a permission fight.

  1. Declare per-language hooks, each pinned by rev.
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.5.0
    hooks: [{ id: ruff }]
  - repo: https://github.com/pre-commit/mirrors-prettier
    rev: v3.1.0
    hooks: [{ id: prettier }]
  - repo: https://github.com/dnephin/pre-commit-golang
    rev: v0.5.1
    hooks: [{ id: go-fmt }]

Each entry names an upstream repo, a fixed rev, and the hooks to enable from it — here ruff for Python, prettier for JavaScript, and go-fmt for Go. The rev values (v0.5.0, v3.1.0, v0.5.1) are the mechanism that makes the config reproducible: the framework checks out exactly that tag of each hook repo and builds the tool at that version, so the formatter that reflows your code in the container is byte-for-byte the one that runs in CI. Leaving a rev off, or pointing it at a moving branch, reintroduces the drift the whole exercise is meant to eliminate — a hook that silently upgrades will reformat files a teammate on the old version considered clean, and the resulting diff churn is exactly the failure mode pinning prevents. Keep these tags current with pre-commit autoupdate on a deliberate cadence rather than letting them float, so version bumps land as reviewable commits instead of surprises.

  1. Cache hook environments on a named volume so they aren't rebuilt.
{
  "mounts": ["source=devcontainer-precommit,target=/home/vscode/.cache/pre-commit,type=volume"]
}

The target here — /home/vscode/.cache/pre-commit — is the directory where the framework stores every hook's built environment, and it is deliberately under the vscode home so it matches the remoteUser that provisions and runs the hooks. By backing it with a named type=volume (devcontainer-precommit) rather than an anonymous or bind mount, the Python, JavaScript, and Go environments built during --install-hooks survive a container rebuild and are reused instead of recompiled. In a polyglot repo that reuse is the difference between a sub-second commit and a multi-minute one, because rebuilding three separate language environments from scratch dominates the cost of any single commit.

  1. Verify all hooks run across the languages.
pre-commit run --all-files

Everyday commits only check staged files, which is what keeps the commit path fast, but --all-files deliberately overrides that to sweep the entire tree once. This is the check that proves every language hook actually resolves: it forces the framework to build and run ruff, prettier, and go-fmt against real files, surfacing a missing runtime or a bad rev immediately rather than on some future commit that happens to touch that language. Run it once right after the container is provisioned and again in CI against the identical .pre-commit-config.yaml; if the whole-repo sweep is clean in both places, you have confirmed the local-equals-CI guarantee across all three languages at once. A non-zero exit here is a feature — it fails the build before a drifting or broken hook can reach main.

Polyglot hook anatomyOne config declares pinned per-language hooks whose environments are cached for speed.pre-commit frameworkone config, many hooksper-language hooksruff, prettier, gofmtpinned revsreproducible checkscached envsfast polyglot commits

Common Pitfalls

Polyglot hook issues are unpinned revs, a missing language runtime, or uncached environments.

The most common self-inflicted wound is a permission mismatch on the cache. If the container provisions the hook environments as root but later runs git commit as vscode — or the named devcontainer-precommit volume was first written by a different user on an earlier build — the framework cannot write into /home/vscode/.cache/pre-commit and either rebuilds environments every time or fails outright. Keep a single remoteUser (vscode) across postCreate and interactive sessions so the same identity owns both the shims in .git/hooks and the cache volume; if you have already poisoned the volume with root-owned files, remove and recreate it rather than trying to chown inside a running container.

The subtler polyglot trap is assuming that a green pre-commit run --all-files means every language is truly covered. A hook only runs against files whose type it recognizes, so if the Go sources live in a path the go-fmt hook's default file filter misses, the hook reports success by simply having nothing to do — and the gap goes unnoticed until badly formatted Go lands in main. When you add a language, verify the hook actually processed files of that type (raise verbose: true on the hook, or check that the run touched the expected paths) rather than trusting an all-green summary that may be green because a filter matched nothing.

Polyglot triageA triage path from slow or drifting polyglot hooks to fast, pinned ones.Is every hook pinned by rev?NOAdd rev to each repoAre hook envs cached on a volume?YESAll language runtimes presentFast, reproducible polyglot hooks

SymptomRoot CauseRemediation
Local passes, CI fails a hookHook revs unpinnedPin every hook by rev; run same config in CI
A language hook errorsRuntime missing in the containerInstall the language via a Feature
Commits are slowHook envs rebuilt each timeCache ~/.cache/pre-commit on a volume
Hooks don't runFramework not installed post-mountRun pre-commit install in postCreate

Conclusion

One framework, one config, every language. The pre-commit tool declares per-language hooks in a single pinned file, installs after the mount, and caches each hook's environment so a polyglot commit stays fast. Pin every rev and mirror the config in CI, and a mixed-language repo enforces consistent quality with no bespoke wiring.

The strategic payoff is that quality enforcement stops scaling with the number of languages. Adding a fourth language later is a three-line addition to .pre-commit-config.yaml — a repo, a rev, and a hooks entry — not a fresh round of Makefile targets, shell aliases, and CI steps that each drift independently. Because the config is the single artifact both the container and CI consume, onboarding a new contributor is reduced to rebuilding the devcontainer: the pinned hooks and cached environments arrive with it, and their very first commit is gated by the same ruff, prettier, and go-fmt versions everyone else runs.

This is the same pin-and-cache discipline that governs base images and dependency lockfiles, applied to the commit gate. The pinned rev values are a lockfile for your tooling, and the devcontainer-precommit volume is a cache that makes that lock cheap to honor on every rebuild. Reproducibility and speed are usually presented as a trade-off; here they reinforce each other, because the pinned environment is exactly the thing worth caching. Treat the config and the cache volume as a pair, and a polyglot repo stays both fast and honest for the life of the project.

One config, kept fastA single config covers every language; pinning and caching keep it fast.One config forPython (ruff)JS (prettier)Go (gofmt)Keep fast viaPinned revsCached envsStaged-file runs

FAQ

How does pre-commit handle multiple languages in one repo? It manages an isolated environment per hook, so a Python ruff hook, a JavaScript prettier hook, and a Go gofmt hook coexist in one .pre-commit-config.yaml. Each hook declares its own repo and rev, and the framework provisions the right runtime for each — you just list them. The isolation is the important part: the ruff hook's Python environment never collides with your project's own virtualenv, and the prettier hook's Node modules stay out of your node_modules. That separation is why a Go-first repo can carry a Python-based framework and a Node-based formatter without any of them contaminating one another or your application dependencies.

Why pin every hook's rev? So the checks are reproducible. An unpinned hook can update and start flagging different issues than CI, breaking the local-equals-CI guarantee. Pin each hook by rev, run the identical config in CI, and every environment enforces the same versions. In a polyglot repo the risk compounds, because three hooks floating independently means three separate chances for a silent upgrade to churn a diff. The pinned rev values (v0.5.0, v3.1.0, v0.5.1 here) act as a lockfile for your tooling, and moving them should be a reviewed commit produced by pre-commit autoupdate, never an accident of whatever the upstream branch happened to be that day.

How do I keep polyglot commits fast? Cache the framework's environment directory (~/.cache/pre-commit) on a named volume so the per-language hook environments aren't rebuilt on each container, and rely on the default staged-file runs rather than whole-repo sweeps. Heavy checks belong in CI, not the commit path. The first provisioning of three language environments is the expensive step, so front-load it with --install-hooks in postCreate and let the devcontainer-precommit volume keep the result warm across rebuilds. After that, each commit only builds and lints the handful of files you actually staged, which is why a mixed Python, JavaScript, and Go commit still returns in well under a second.

Do I need the language runtimes installed in the image? For the framework and most tool downloads, no — each hook repo carries its own tool and the framework builds it into an isolated environment. But a hook that compiles or executes in its target language still needs that language's runtime present: the go-fmt hook needs a Go toolchain, and a Node-based hook needs Node. Install those through the base image or a devcontainer Feature; a missing runtime is the usual cause of a hook that errors only for one language while the others pass.

Why did pre-commit install succeed but no hooks run on commit? Almost always because .git was not present when pre-commit install executed. That command writes shims into .git/hooks, so if postCreate ran before the workspace mount was in place, the shims went nowhere and commits proceed ungated. Confirm the workspace is mounted and .git is visible at postCreate time, then re-run pre-commit install --install-hooks inside the container to write the shims into the real .git/hooks.

Can I run one language's hooks without the others? Yes — pass the hook id to the run command, for example pre-commit run ruff --all-files to exercise only the Python formatter. This is useful when you are debugging a single language's configuration or a flaky rev bump. The full pre-commit run --all-files sweep remains the check you run in CI, so that every language is verified together against the same pinned config.