Custom pre-commit hooks for projects using Rhiza templates.
This repository extracts rhiza's local hooks into a standalone package, allowing rhiza and downstream projects to use them as an external hook repository.
Add to your .pre-commit-config.yaml:
repos:
- repo: https://github.com/Jebel-Quant/rhiza-hooks
rev: v1.1.0 # Use the latest release
hooks:
# Migrated from rhiza
- id: check-rhiza-workflow-names
- id: update-readme-help
# Additional utility hooks
- id: check-rhiza-config
- id: check-makefile-targets
- id: check-python-version-consistency
- id: check-rust-version-consistency
- id: check-go-version-consistency
- id: check-bumpversion-config
- id: check-template-bundles
# Template-ownership and CI-consistency hooks
- id: check-managed-files
- id: check-workflow-make-targets
- id: check-license-metadataThen install the hooks:
pre-commit install| Hook | Triggers on | Autofixes? | Exit code |
|---|---|---|---|
check-rhiza-workflow-names |
.github/workflows/rhiza_*.yml |
✅ rewrites a wrong name: |
1 if any file was changed or has an error, else 0 |
update-readme-help |
Makefile |
✅ rewrites README.md between markers |
1 if README.md was changed, else 0 (never fails when make help is unavailable) |
check-rhiza-config |
.rhiza/template.yml |
❌ validates only | 1 if invalid, else 0 |
check-makefile-targets |
Makefile, .rhiza/*.mk |
❌ warns only | 0 by default (warn-only); 1 on missing targets only with --strict |
check-python-version-consistency |
.python-version, pyproject.toml |
❌ validates only | 1 on mismatch, else 0 |
check-rust-version-consistency |
rust-toolchain, rust-toolchain.toml, Cargo.toml |
❌ validates only | 1 on mismatch, else 0 |
check-go-version-consistency |
.go-version, go.mod |
❌ validates only | 1 on mismatch, else 0 |
check-bumpversion-config |
pyproject.toml, .bumpversion.toml, .bumpversion.cfg, setup.cfg, .rhiza/.cfg.toml |
❌ validates only | 1 if no discoverable config or a drifted current_version, else 0 |
check-template-bundles |
.rhiza/template.yml |
❌ validates only (network) | 1 on validation failure, else 0; 0 when --offline |
check-managed-files |
every staged file | ❌ validates only | 1 if a template-owned file is being modified, else 0 |
check-workflow-make-targets |
.github/workflows/*.yml, .gitlab-ci.yml, Makefile, .rhiza/*.mk |
❌ validates only | 1 if CI invokes an undefined target, else 0 |
check-license-metadata |
pyproject.toml |
❌ validates only | 1 if both licence forms are declared, else 0 |
Details for each hook follow.
Ensures GitHub Actions workflow names have the (RHIZA) prefix in uppercase. Automatically fixes files that don't conform.
Files: .github/workflows/rhiza_*.yml
Usage:
- id: check-rhiza-workflow-namesTroubleshooting:
- The hook only scans
.github/workflows/rhiza_*.yml; if nothing happens, confirm your workflow filename matches that pattern. - A hook failure after edits is expected when it auto-fixes
name:values—re-stage the workflow file and re-run.
Embeds the output of make help into README.md between marker comments.
Triggers on: Changes to Makefile
Usage:
- id: update-readme-helpTroubleshooting:
- If
make(ormake help) is unavailable, this hook exits successfully and skips updates by design.
Validates the .rhiza/template.yml configuration file to ensure:
- All required keys are present (
template-repository,template-branch) - At least one of
includeortemplates(or aliasprofiles) is present - The
template-repositoryis in the correctowner/repoformat - No unknown keys are present
- The
includelist (if present) is not empty - The
templateslist (or aliasprofiles, if present) is not empty - The
languagekey (if present) is a non-empty string
language names the project's rhiza language layer — python, rust or go — and an
absent key means Python, which is why only non-Python pointers carry it. The value is
type-checked but deliberately not validated against a fixed list of languages: this
package is version-pinned by the projects that use it, so enumerating the known languages
here would break every repo on an older pin as soon as rhiza gained another layer.
Usage:
- id: check-rhiza-configTroubleshooting:
- Validate that
.rhiza/template.ymlcontainstemplate-repositoryandtemplate-branch, plus at least one ofinclude,templates, orprofiles. - If you see unknown-key errors, compare your keys to the documented schema and remove unsupported entries.
Checks that your Makefile contains recommended targets for rhiza-based projects:
install- Install dependenciestest- Run testsfmt- Format codehelp- Show available targets
By default, this hook only warns about missing targets. Use --strict to fail on missing targets.
The expected set can be customised:
--target NAME(repeatable) replaces the default set with exactly the targets you list.--extend-target NAME(repeatable) adds to the active set (defaults, or whatever--targetselected).
Usage:
- id: check-makefile-targets
args: [--strict] # Optional: fail if targets are missing
# Require a custom set instead of the defaults:
- id: check-makefile-targets
args: [--target, build, --target, lint]
# Keep the defaults and also require `deploy`:
- id: check-makefile-targets
args: [--extend-target, deploy]Troubleshooting:
- Default mode is warn-only, so missing targets do not fail commits unless you pass
--strict. - If a required target is intentionally different, use
--target/--extend-targetto align checks with your Makefile.
Ensures Python version is consistent between .python-version and pyproject.toml's requires-python.
Usage:
- id: check-python-version-consistencyTroubleshooting:
- Keep
.python-versionaligned withproject.requires-pythoninpyproject.toml. - If ranges are used (for example
>=3.11), ensure the.python-versionvalue satisfies that range exactly.
Ensures the Rust version a project pins agrees with the version it declares it supports. A Rust project states this in up to three places:
rust-toolchain.toml—[toolchain] channel, the toolchain rustup installs for the checkoutrust-toolchain— the legacy form of the same file, either TOML or a bare channel name on one lineCargo.toml—rust-versionunder[package]and/or[workspace.package], the crate's minimum supported Rust version (MSRV)
The hook enforces three relationships:
- The two toolchain files pin the same channel (when both are present).
[package] rust-versionand[workspace.package] rust-versiondeclare the same MSRV (when both are present).- The pinned toolchain is not older than the declared MSRV — a pin below the MSRV cannot build the crate.
Named channels (stable, beta, nightly, nightly-2024-01-01) carry no version number and are accepted without comparison. Trailing zeros are insignificant, so 1.75 and 1.75.0 are the same version. A repository with none of these files passes, so the hook is harmless in a polyglot monorepo.
Triggers on: Changes to rust-toolchain, rust-toolchain.toml, or Cargo.toml
Usage:
- id: check-rust-version-consistencyTroubleshooting:
- "the pinned toolchain must be at least the MSRV" means your
rust-toolchain*channel is older thanrust-versioninCargo.toml; raise the pin or lower the MSRV. - If you keep both
rust-toolchainandrust-toolchain.toml, delete one — rustup only reads the.tomlform, so the other silently drifts. - The hook only reads the repository-root
Cargo.toml; MSRVs declared by individual workspace members are not compared.
Ensures the Go version a project pins agrees with the version its module requires. A Go project states this in up to three places:
go.mod— thegodirective, the minimum language version the module requiresgo.mod— the optionaltoolchaindirective, the toolchain thegocommand switches to.go-version— the toolchain pin honoured by goenv andactions/setup-go
The hook enforces three relationships:
- The
toolchaindirective is not below thegodirective (thegocommand itself rejects that). .go-versionis not below thegodirective — the pinned toolchain could not build the module..go-versionnames the same version as thetoolchaindirective (when both are present).
A leading go prefix is stripped before comparison, so go1.22.5 and 1.22.5 are the same pin, as are 1.22 and 1.22.0. Non-numeric values (toolchain default, toolchain local) carry no version and are accepted without comparison. Contents of parenthesised require (…) blocks are skipped, so a dependency such as go.uber.org/zap is never mistaken for the go directive. A repository with none of these files passes.
Triggers on: Changes to .go-version or go.mod
Usage:
- id: check-go-version-consistencyTroubleshooting:
- "which is below the go.mod go directive" means the pinned toolchain is older than the module's minimum; raise
.go-version/toolchain, or lower thegodirective. - If
.go-versionandtoolchaindisagree, decide which one is authoritative — CI (actions/setup-go) reads the former while localgo buildobeys the latter, so a skew builds different code in the two places. - The hook reads only
go.mod;go.workdirectives in a multi-module workspace are not compared.
Ensures bump-my-version can actually find this project's version configuration.
bump-my-version reads its config from a fixed set of filenames — .bumpversion.toml, pyproject.toml, .bumpversion.cfg, setup.cfg — and nothing else. When it finds none it does not fail: it falls back to git describe and reports the last reachable tag as the current version. Release tooling then computes bump candidates from that number rather than the project's own, which can offer a version that has already been published.
The hook enforces two relationships for any project with a static [project].version:
- A bumpversion section exists in one of the searched files.
- If that section declares
current_version, it equals[project].version— a stale value bumps from the wrong number and then fails to match the file it is meant to rewrite.
The motivating case is rhiza-specific: rhiza syncs a fully-formed [tool.bumpversion] block into .rhiza/.cfg.toml, which is not a searched filename and so never takes effect. When the hook finds that file and no discoverable config, it names it directly rather than just reporting an absence. See jebel-quant/rhiza#1453.
Projects with no pyproject.toml, or with dynamic = ["version"], are out of scope and pass — their version does not live in a file bump-my-version would rewrite. Declaring current_version is optional: with a [tool.bumpversion] table present in pyproject.toml, bump-my-version reads and rewrites PEP 621 [project].version natively, so omitting it keeps a single source of truth.
Triggers on: Changes to pyproject.toml, .bumpversion.toml, .bumpversion.cfg, setup.cfg or .rhiza/.cfg.toml
Usage:
- id: check-bumpversion-configTroubleshooting:
- "no bumpversion config was found" means releases are computing versions from git tags. Add a
[tool.bumpversion]table topyproject.toml; it needs no other keys. - If the error names
.rhiza/.cfg.toml, that block is inert — it is synced from the template but never read. Do not edit it; add the table topyproject.tomlinstead. - A
current_versionmismatch usually means a bump was reverted or hand-edited. Reconcile the two values before releasing.
Validates templates specified in .rhiza/template.yml against the template-bundles.yml file from the template repository. This hook:
- Fetches
template-bundles.ymlfrom the remote template repository specified in your config - Ensures all templates listed in your
.rhiza/template.ymlexist in the remote bundles - Validates bundle structure (each bundle has
descriptionandfiles) - Checks that bundle dependencies are valid
Triggers on: Changes to .rhiza/template.yml
This hook reaches the network on every run. Transient failures are retried with a short linear backoff, and each failed attempt is logged so CI failures are diagnosable. The retry count and per-request timeout are configurable, and --offline skips the remote fetch entirely (the hook then passes without validating), which is useful for offline commits.
Options:
| Flag | Default | Effect |
|---|---|---|
--offline |
off | Skip the remote fetch and pass without validating |
--retries N |
1 |
Retries after the first attempt on transient network errors (0 disables retrying) |
--timeout S |
10.0 |
Per-request network timeout, in seconds |
Usage:
- id: check-template-bundles
# args: [--offline] # Optional: skip the network fetch and pass
# args: [--retries, "3", --timeout, "20"] # Optional: tune flaky-network behaviourTroubleshooting:
- This hook normally fetches
template-bundles.ymlfrom the configured template repository and retries on transient network errors; raise--retries/--timeoutif your network is slow or flaky, and read the per-attempt log lines to see what failed. - Use
--offlinewhen committing without network access; it skips the fetch and exits successfully without remote validation.
Refuses a commit that edits a file the rhiza template owns. Ownership is read from the files: block of .rhiza/template.lock, minus anything listed under exclude: in .rhiza/template.yml — an excluded path is never synced, so it belongs to the project again.
Every rhiza-managed repo's CLAUDE.md states the rule this enforces: managed files are overwritten on the next sync. Until this hook nothing checked it, so the failure was silent and total — the edit worked, got reviewed, got merged, and vanished at the next sync.
Triggers on: every staged file (the hook declares no files: pattern)
The check is path-based: template.lock records paths, not content hashes. A repo that is managed but never synced (no lock file) owns everything, so the hook passes.
Options:
| Flag | Default | Effect |
|---|---|---|
--allow PATH |
none | Waive one managed path; repeatable |
Usage:
- id: check-managed-files
# args: [--allow, Makefile] # Optional: waive a knowingly-temporary overrideTroubleshooting:
- To change a managed file, change it upstream in the template repository, cut a release, bump
ref:in.rhiza/template.ymland re-sync. - To take permanent local ownership of one, add it to
exclude:in.rhiza/template.yml. That is the durable fix;--allowis not. - A
rhiza synccommit legitimately rewrites managed files wholesale. Bypass the hook for it withSKIP=check-managed-files git commit ....
Checks that every make target your CI invokes is actually defined. Targets are collected from the root Makefile plus everything it includes, transitively, with globs expanded (rhiza's own layout is Makefile → .rhiza/rhiza.mk → .rhiza/make.d/*.mk). Invocations are read from the shell snippets of every CI definition: run: in GitHub workflows, script:/before_script:/after_script: in .gitlab-ci.yml.
check-makefile-targets asserts that a few recommended targets exist; this hook checks the opposite direction — that the targets actually invoked are defined — which is what catches a removal or a rename. The template has produced exactly that failure: make validate existed up to rhiza v1.1.3 and was removed by v1.2.1.
Triggers on: .github/workflows/*.yml, .gitlab-ci.yml, Makefile, .rhiza/*.mk — a target removal must re-run the check, not just a workflow edit
Invocations are parsed out of the YAML rather than the raw text, so name: make sure the cache is warm is not mistaken for an invocation. An invocation whose target comes from a variable or matrix expression (make ${{ matrix.task }}) cannot be resolved and is skipped rather than reported — a false positive here would block every commit. A repo with no Makefile reports nothing.
Troubleshooting:
- If a target genuinely exists but is reported missing, check that the file defining it is reachable through an
includefrom the rootMakefile, and that the include path is not itself variable-driven. - Prefer
make -j4 testtomake -j test: with a bare-jthe following word is treated as the flag's value, so the target is not checked.
Rejects a pyproject.toml that declares both a PEP 639 license expression and a legacy License :: OSI Approved :: … trove classifier. That combination is not merely redundant — setuptools>=77 refuses to build the project at all ("License classifiers have been superseded by license expressions"), and uv_build warns.
Either form alone is fine, and so is the pre-PEP-639 table form (license = {file = "LICENSE"}) next to a classifier: that is valid legacy metadata.
Triggers on: pyproject.toml
Validating SPDX expression syntax is out of scope; the value here is the rule that breaks builds.
Options:
| Flag | Default | Effect |
|---|---|---|
--require-license |
off | Also fail when no licence is declared at all |
Usage:
- id: check-license-metadata
# args: [--require-license] # Optional: also require that a licence is declaredTroubleshooting:
- Delete the
License :: …classifier and keep the SPDX expression; that is the direction packaging has moved. - rhiza's synced
test_license_classifier_presentstill asserts the classifier through template v1.2.1, which is unsatisfiable for a PEP 639 project (filed upstream as Jebel-Quant/rhiza#1440). Do not "fix" that test by adding the classifier back — it trades a failing test for an unbuildable package.
- Python 3.11+
- uv (recommended) or pip
# Clone the repository
git clone https://github.com/Jebel-Quant/rhiza-hooks.git
cd rhiza-hooks
# Install dependencies
make install
# Install pre-commit hooks
pre-commit installmake install # Install dependencies
make test # Run tests with coverage
make fmt # Format and lint code
make deptry # Check for unused/missing dependencies
make help # Show all available targetsUse pre-commit try-repo to test hooks without committing:
# Test all hooks against your current project
pre-commit try-repo . --all-files
# Test a specific hook
pre-commit try-repo . check-rhiza-config --files .rhiza/template.ymlThis project enforces 100% line/branch coverage. The gate runs in CI, but you can reproduce it locally before opening a PR:
make test # Run the suite with coverage (fails under 100%)The project test suite mirrors src/rhiza_hooks/ 1:1 under tests/rhiza_hooks/: each module src/rhiza_hooks/<module>.py has a matching tests/rhiza_hooks/test_<module>.py (including unit, integration and property-based tests for that module). Repository meta-tests that are not tied to a single package module — such as tests/test_check_test_layout.py — stay at the top level of tests/. This layout is enforced by scripts/check_test_layout.py, which verifies that every module in src/rhiza_hooks/ is covered by at least one test file that imports it and that every tests/test_*.py file maps to a package module (or is an allowed meta-test); it runs as part of the suite via tests/test_check_test_layout.py.
This project is licensed under the MIT License - see the LICENSE file for details.
- Ask questions and get support in GitHub Discussions using the Q&A template.
- Report bugs or request features using the issue templates.
- Rhiza - The template system these hooks are designed for
- pre-commit - The framework that makes this possible