Skip to content

Repository files navigation

🪝 Rhiza Hooks

Python pre-commit License: MIT CodeFactor Rhiza Coverage OpenSSF Scorecard

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.

🚀 Quick Start

Add to your .pre-commit-config.yaml:

repos:
  - repo: https://github.com/Jebel-Quant/rhiza-hooks
    rev: v1.3.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-metadata
      - id: check-test-layout

Then install the hooks:

pre-commit install

📋 Available Hooks

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, or nothing was inspected with --require-invocations; else 0
check-license-metadata pyproject.toml ❌ validates only 1 if both licence forms are declared, else 0
check-test-layout any *.py ❌ validates only 1 if a source module or class has no mirrored test (or vice versa), else 0

Details for each hook follow.

Migrated from Rhiza

check-rhiza-workflow-names

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-names

Troubleshooting:

  • 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.

update-readme-help

Embeds the output of make help into README.md between marker comments.

Triggers on: Changes to Makefile

Usage:

- id: update-readme-help

Troubleshooting:

  • If make (or make help) is unavailable, this hook exits successfully and skips updates by design.

Additional Utility Hooks

check-rhiza-config

Validates the .rhiza/template.yml configuration file to ensure:

  • All required keys are present (template-repository, template-branch)
  • At least one of include or templates (or alias profiles) is present
  • The template-repository is in the correct owner/repo format
  • No unknown keys are present
  • The include list (if present) is not empty
  • The templates list (or alias profiles, if present) is not empty
  • The language key (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-config

Troubleshooting:

  • Validate that .rhiza/template.yml contains template-repository and template-branch, plus at least one of include, templates, or profiles.
  • If you see unknown-key errors, compare your keys to the documented schema and remove unsupported entries.

check-makefile-targets

Checks that your Makefile contains recommended targets for rhiza-based projects:

  • install - Install dependencies
  • test - Run tests
  • fmt - Format code
  • help - Show available targets

By default, this hook only warns about missing targets. Use --strict to fail on missing targets.

A catch-all rule satisfies all of them. A Makefile containing

%: FORCE
	@uvx rhiza-task $@

can build any name, so nothing is reported missing even though only help has a rule of its own. This is the shape rhiza v1.4.0 introduced — the root Makefile became a rhiza-task shim — and without the rule the hook reported fmt, install and test missing on a Makefile where all three work. A suffix rule (%.o: %.c) matches only names ending in .o and does not count.

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 --target selected).

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-target to align checks with your Makefile.

check-python-version-consistency

Ensures Python version is consistent between .python-version and pyproject.toml's requires-python.

Usage:

- id: check-python-version-consistency

Troubleshooting:

  • Keep .python-version aligned with project.requires-python in pyproject.toml.
  • If ranges are used (for example >=3.11), ensure the .python-version value satisfies that range exactly.

check-rust-version-consistency

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 checkout
  • rust-toolchain — the legacy form of the same file, either TOML or a bare channel name on one line
  • Cargo.tomlrust-version under [package] and/or [workspace.package], the crate's minimum supported Rust version (MSRV)

The hook enforces three relationships:

  1. The two toolchain files pin the same channel (when both are present).
  2. [package] rust-version and [workspace.package] rust-version declare the same MSRV (when both are present).
  3. 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-consistency

Troubleshooting:

  • "the pinned toolchain must be at least the MSRV" means your rust-toolchain* channel is older than rust-version in Cargo.toml; raise the pin or lower the MSRV.
  • If you keep both rust-toolchain and rust-toolchain.toml, delete one — rustup only reads the .toml form, so the other silently drifts.
  • The hook only reads the repository-root Cargo.toml; MSRVs declared by individual workspace members are not compared.

check-go-version-consistency

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 — the go directive, the minimum language version the module requires
  • go.mod — the optional toolchain directive, the toolchain the go command switches to
  • .go-version — the toolchain pin honoured by goenv and actions/setup-go

The hook enforces three relationships:

  1. The toolchain directive is not below the go directive (the go command itself rejects that).
  2. .go-version is not below the go directive — the pinned toolchain could not build the module.
  3. .go-version names the same version as the toolchain directive (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-consistency

Troubleshooting:

  • "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 the go directive.
  • If .go-version and toolchain disagree, decide which one is authoritative — CI (actions/setup-go) reads the former while local go build obeys the latter, so a skew builds different code in the two places.
  • The hook reads only go.mod; go.work directives in a multi-module workspace are not compared.

check-bumpversion-config

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:

  1. A bumpversion section exists in one of the searched files.
  2. 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-config

Troubleshooting:

  • "no bumpversion config was found" means releases are computing versions from git tags. Add a [tool.bumpversion] table to pyproject.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 to pyproject.toml instead.
  • A current_version mismatch usually means a bump was reverted or hand-edited. Reconcile the two values before releasing.

check-template-bundles

Validates templates specified in .rhiza/template.yml against the template-bundles.yml file from the template repository. This hook:

  • Fetches template-bundles.yml from the remote template repository specified in your config
  • Ensures all templates listed in your .rhiza/template.yml exist in the remote bundles
  • Validates bundle structure (each bundle has description and files)
  • 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 behaviour

Troubleshooting:

  • This hook normally fetches template-bundles.yml from the configured template repository and retries on transient network errors; raise --retries/--timeout if your network is slow or flaky, and read the per-attempt log lines to see what failed.
  • Use --offline when committing without network access; it skips the fetch and exits successfully without remote validation.

Template-ownership and CI-consistency Hooks

check-managed-files

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 override

Troubleshooting:

  • To change a managed file, change it upstream in the template repository, cut a release, bump ref: in .rhiza/template.yml and re-sync.
  • To take permanent local ownership of one, add it to exclude: in .rhiza/template.yml. That is the durable fix; --allow is not.
  • A rhiza sync commit legitimately rewrites managed files wholesale. Bypass the hook for it with SKIP=check-managed-files git commit ....

check-workflow-make-targets

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 Makefilelocal.mk, and was Makefile.rhiza/rhiza.mk.rhiza/make.d/*.mk before v1.4.0). Invocations are read from the shell snippets of every CI definition: run: in GitHub workflows, script:/before_script:/after_script: in .gitlab-ci.yml.

A catch-all rule (%:) silences the comparison, and honestly so: with one in the Makefile every name resolves, so an invocation of a target that does not exist stops being distinguishable from one that does — make itself cannot tell either. The run says so rather than implying it checked:

inspected 2 CI file(s), found 1 resolvable `make` target invocation(s); a catch-all rule (`%:`) defines every name, so none was compared

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.

Only inline shell steps are read. A job that delegates to a reusable workflow —

jobs:
  ci:
    uses: jebel-quant/rhiza/.github/workflows/rhiza_ci.yml@v1.5.1

— keeps every command in another repository, behind a pinned ref, where this hook cannot reach it. Such a repo has nothing for the check to compare, and it will pass while inspecting nothing.

So every run reports what it worked from, on stderr:

inspected 8 CI file(s), found 0 resolvable `make` target invocation(s)

pre-commit hides a passing hook's output unless you set verbose: true on it, which is why --require-invocations exists as well: it turns that zero into a failure, for a repo that believes it has inline invocations to check. It is opt-in because zero is the correct and permanent answer for a repo whose CI is entirely delegated — failing by default would report every such repo as broken.

Options:

Flag Default Effect
--require-invocations off Fail when the repo ships CI files but none of them invokes make

Usage:

- id: check-workflow-make-targets
  # args: [--require-invocations]   # Optional: refuse to pass without inspecting an invocation

Troubleshooting:

  • If a target genuinely exists but is reported missing, check that the file defining it is reachable through an include from the root Makefile, and that the include path is not itself variable-driven.
  • Prefer make -j4 test to make -j test: with a bare -j the following word is treated as the flag's value, so the target is not checked.
  • If --require-invocations fails on a repo you expected it to pass, read the summary line first: it separates "found no CI files" from "found CI files that invoke nothing". Only the second is what the flag reports.

check-license-metadata

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 declared

Troubleshooting:

  • Delete the License :: … classifier and keep the SPDX expression; that is the direction packaging has moved.
  • rhiza's synced test_license_classifier_present still 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.

check-test-layout

Enforces that the test tree mirrors the source tree 1:1, in both directions:

  • every source module <src>/…/xyz.py has a test file <tests>/…/test_xyz.py (nested packages are mirrored);
  • every top-level class A in a source module has a matching TestA class in that test file;
  • every test file traces back to a source module (no orphan test files);
  • every Test* class traces back to a source class (no orphan test classes).

The reverse direction is the one that pays for itself: a renamed or retired module leaves its tests behind, and those orphaned tests keep passing against nothing. Test functions are unconstrained — the rules bind files and classes only. __init__.py and conftest.py are ignored on both sides, and tests/benchmarks/ and tests/stress/ are exempt by default.

Triggers on: any *.py file — parity breaks when either side moves

The check reads the whole tree rather than the staged files (pass_filenames: false), because the orphan direction cannot be decided from one file.

Options:

Flag Default Effect
--src DIR <repo root>/src Source directory
--tests DIR <repo root>/tests Tests directory
--config FILE <repo root>/pyproject.toml File providing [tool.check_test_layout]

Configuration lives in pyproject.toml, so a project that is not rhiza-managed can still configure it:

[tool.check_test_layout]
# Extend the built-in benchmarks/stress exemptions (top-level dirs under tests/)
exempt_dirs = ["meta", "integration"]
# Exempt individual test files, by path relative to the tests root
exempt_files = ["test_packaging.py"]

A repository that organises tests by behaviour rather than by module can opt out entirely — but only on the record:

[tool.check_test_layout]
enforce = false
reason = "Tests are grouped by behaviour; per-module coverage is enforced by a 100% gate."

reason is required: enforce = false without one exits 1. An undocumented opt-out is indistinguishable from neglect.

Usage:

- id: check-test-layout
  # args: [--src, scripts, --tests, tests]   # Optional: for a non-src/ layout

Troubleshooting:

  • A reported orphan is usually right: check whether the source module was renamed or deleted and its test left behind.
  • For a whole subtree that legitimately has no counterpart (repository meta-tests, integration suites), use exempt_dirs. For one loose file, use exempt_filesexempt_dirs cannot express it, since the file's first path component is the file itself.

🛠️ Development

Prerequisites

  • Python 3.11+
  • uv (recommended) or pip

Setup

# 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 install

Common Commands

make install    # Install dependencies
make test       # Run tests with coverage
make fmt        # Format and lint code
make deps       # Check for unused/missing dependencies (deptry)
make help       # Show all available targets

Testing hooks locally

Use 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.yml

Tests & coverage

This 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 — the pre-commit manifest, the docs nav, encoding hygiene — live in tests/meta/, which [tool.check_test_layout] exempt_dirs exempts from the orphan direction. The layout is enforced by this repo's own check-test-layout hook, dogfooded through the repo: local block in .pre-commit-config.yaml and covered by tests/rhiza_hooks/test_check_test_layout.py.

📄 License

This project is licensed under the MIT License - see the LICENSE file for details.

💬 Getting help

  • Open an issue — questions, bug reports and feature requests all go here; Discussions are not enabled on this repo. There is no form to fill in — please name the hook id and the rev: you pinned.

🙏 Acknowledgments

  • Rhiza - The template system these hooks are designed for
  • pre-commit - The framework that makes this possible

About

Hooks supporting the rhiza framework

Resources

Code of conduct

Contributing

Security policy

Stars

2 stars

Watchers

1 watching

Forks

Releases

Contributors

Languages