9. Use Pre-commit Hooks for Automated Code Quality Enforcement¶
Date: 2024-04-01
Status¶
Accepted. Amended 2026-08-03: the runner is now
prek rather than pre-commit itself — see
Amendment: prek at the end of this record. Everything the decision rests on is
unchanged, including .pre-commit-config.yaml as the config format.
Context¶
Code quality tools (linters, formatters, security scanners) are most valuable when they
run consistently and automatically. Relying on developers to remember to run make fmt
before every commit leads to:
- Style violations slipping into the repository and requiring separate clean-up commits.
- CI failures on trivial issues (missing newlines, import order) that block PRs unnecessarily.
- Inconsistent enforcement—some contributors run the tools, others do not.
- Review overhead as reviewers catch style issues that tooling should have caught.
The alternative—running all quality checks only in CI—creates a slow feedback loop. Developers learn about formatting issues only after pushing a commit and waiting for CI to complete.
Decision¶
We will use pre-commit to run code quality hooks automatically on every commit, enforcing standards at the point of authorship.
Key aspects:
- Configuration file: All hooks are defined in
.pre-commit-config.yamlin the project root. - Core hooks: The standard configuration includes:
check-toml,check-yaml— syntax validation for TOML and YAML filesruff— Python linting with auto-fixruff-format— Python formattingmarkdownlint— Markdown style enforcementcheck-jsonschema— schema validation for configuration filesactionlint— GitHub Actions workflow syntax validationvalidate-pyproject—pyproject.tomlschema validationbandit— Python security scanninguv-lock— ensuresuv.lockis up-to-daterhiza-hooks— custom Rhiza-specific checks (workflow naming, Makefile targets, Python version consistency)- Installation:
make installinstalls the hooks viauvx prek install. - CI enforcement: The
rhiza_pre-commit.ymlGitHub Actions workflow runs all hooks in CI to catch any commits that bypassed local hooks. - Auto-fix in CI: The CI workflow applies auto-fixes and commits them back, reducing friction for contributors who forget to run hooks locally.
Consequences¶
Positive¶
- Shift-left quality: Issues are caught at commit time, before they enter the repository and slow down CI.
- Consistent enforcement: Every contributor gets the same checks regardless of their local tooling setup.
- Reduced review noise: Reviewers spend time on logic, not style. Automated tools handle formatting and trivial issues.
- Self-updating:
prek update(pre-commit'sautoupdate) keeps hook versions current. Renovate automates it by reading.pre-commit-config.yamldirectly. - Extensible: New tools are added by appending to
.pre-commit-config.yamlwith no change to the Makefile.
Neutral¶
- Slower commits: Running all hooks on every commit adds latency. For large codebases this can be several seconds. Pre-commit only runs hooks on changed files, keeping this manageable.
- Occasional bypasses needed: Urgent fixes sometimes require
git commit --no-verifyto skip hooks. This is an escape hatch, not a workflow pattern.
Negative¶
- Bootstrap dependency: Pre-commit must be installed for hooks to run. If a
contributor skips
make install, they will not have hooks. The CI enforcement provides a backstop in this case. - Hook version drift: Hook repositories release new versions independently. Outdated
hooks may fail with newer tool versions. Regular
prek updateruns (automated via Renovate) mitigate this.
Amendment: prek (2026-08-03)¶
The hook runner moved from pre-commit to prek, a Rust
reimplementation that reads the same .pre-commit-config.yaml. None of the four
.pre-commit-config.yaml files changed, no hook was added or removed, and Renovate keeps
managing hook versions from the same file — so this amends how the decision is executed,
not the decision.
What actually changed:
make fmtrunsuvx prek run --all-files --config .pre-commit-config.yaml, andmake installrunsuvx prek install.- The
-p $(PYTHON_VERSION)coupling is gone.uvx pre-commithad to pick an interpreter to run pre-commit itself on, and a Rust or Go project ships no.python-version— so the language-neutral half of the template depended onrhiza.mk's fallback resolving to a real version. prek is a binary and provisions each hook's toolchain itself, so the dependency is removed rather than satisfied. - CI caches
~/.cache/prekinstead of~/.cache/pre-commit; the cache key still hashes.pre-commit-config.yaml. --configis passed explicitly. prek otherwise treats every nested.pre-commit-config.yamlas a separate project — good in a monorepo, wrong in this repo, wherebundles/{python,rust,go}-coreship one each as template content. Without the flag, go-core's hooks rungo vet ./...in a directory with nogo.modand fail.
The CI job keeps its id (pre-commit) and display name ("Pre-commit hooks"), because
that name is a required status check in .github/rulesets/main-branch-protection.json.
Renaming it would leave every PR waiting on a context that never reports.