Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

gerenuk

Impact-based pytest selection for Python, powered by ty-find.

A diff comes in; gerenuk run runs exactly the tests that diff impacts, and nothing else:

$ gerenuk run -- -q
gerenuk: 5 node id(s) from 1 origin(s) in 152 ms — details: gerenuk impacted-tests
.........                                                                [100%]
9 passed in 0.02s

Why

A test selector is only as good as its notion of “reaches”. Matching names is not one: it cannot tell two same-named symbols apart, and it cannot follow a call through a re-export. gerenuk asks ty’s type checker instead, through tyf, so the reference graph it walks follows Python’s actual name resolution.

Three commands, one pipeline:

  • changed-symbols — what the working tree changed, from git alone.
  • impacted-tests — which tests reach those symbols, with the chain that says why.
  • run — the same walk in-process, mapped to pytest node ids, and then it becomes pytest.

The one failure mode that would make a selector worse than no selector is a short list that quietly misses a test. So every degrade widens: anything gerenuk cannot see through becomes verdict: run_all — run the whole suite — rather than a confident answer.

The companion: audit

Turned around, the same reference graph answers the opposite question — what does nothing reach? audit asks it for every symbol in a file and reports two findings: symbols nothing references, and symbols only tests reach.

$ gerenuk audit sample_pkg/service.py
warn  sample_pkg/service.py:43  func `legacy_export` has no references
note  sample_pkg/service.py:34  method `ShelterService.seniors` is referenced only from tests (1)

1 file(s), 7 symbol(s) checked — 1 warn, 1 note

It is a verifier rather than a repo-wide scanner: precise, per-file, and it names the referencing sites. Run it on what a sweep like vulture already flagged — see audit for the pipeline.

What it is not

gerenuk reports static references. Dynamic dispatch, plugin registries, getattr lookups and __all__ re-exports are invisible to it. Selections handle that by widening; audit findings are leads to confirm, not a delete list.

Next

  • Setup — install gerenuk and its tyf prerequisite
  • Commands — the full CLI surface
  • How it works — the pipeline, end to end

Setup

Install

uv add --dev "gerenuk[ty]"        # from PyPI (a maturin-built wheel)
uvx --from "gerenuk[ty]" gerenuk impacted-tests   # one-off, no install

From a pip world, pip install "gerenuk[ty]".

What gets installed, and why

gerenuk shells out to tyf (from ty-find), which in turn drives ty’s language server.

PackageHow it arrives
ty-finda dependencytyf is what gerenuk drives, so it is never optional
tythe [ty] extra — recommended, but see below

ty is an extra rather than a dependency deliberately. It is a pre-1.0 type checker that projects tend to pin for their own use, and a hard requirement in gerenuk would compete with that pin for no benefit: when tyf finds no ty on PATH it falls back to uvx ty. So uv add --dev gerenuk works on its own if uv is around — the first call just pays a download.

Install the extra when you want the version pinned in your lockfile, or when the machine running gerenuk has no network.

changed-symbols needs none of this: it uses git and nothing else.

From source:

git clone https://github.com/mojzis/gerenuk
cd gerenuk
cargo install --path .

Verify

$ gerenuk doctor
workspace: /home/you/proj
tyf:       /home/you/proj/.venv/bin/tyf

If tyf lives somewhere unusual, point GERENUK_TYF at it:

GERENUK_TYF=/opt/bin/tyf gerenuk doctor

Workspace detection

By default gerenuk walks up from the current directory until it finds a pyproject.toml, setup.py, setup.cfg, or .git. Override it with --workspace PATH when you want to run against a project you are not standing in.

Telling coding agents about it

Paste this into your project’s CLAUDE.md. It is deliberately two lines — the exit codes, the run_all verdict and the static-reference caveat are all in gerenuk --help and on this site, and an agent that needs them can read them there:

### `gerenuk` — test selection and dead code

- `gerenuk run -- -q` runs only the tests the working tree's diff impacts
  (`--dry-run` to inspect; `gerenuk impacted-tests` explains why).
- `gerenuk audit <file>` confirms a symbol vulture flagged is really unused;
  its `only tests reach it` findings are ones vulture cannot produce.

Commands

The first three are one pipeline — impact-based pytest selection — and run computes the whole of it in-process. The other two stand alone.

CommandWhat it does
changed-symbolsMap the working tree’s diff to the Python symbols it changed
impacted-testsWalk from those changed symbols to the tests that reach them
runRun pytest on exactly those tests
auditReport unreferenced and test-only symbols in the files you name
doctorShow the resolved workspace and tyf binary, then exit
guidePrint agent-facing instructions: setup, triage or tune

Global flags

FlagDefaultMeaning
--workspace PATHauto-detectProject root to resolve symbols against
--format human|jsonhumanOutput shape
-v, --verboseoffDebug logging to stderr (RUST_LOG wins if set)

Exit codes

CodeMeaning
0The run completed and nothing was flagged
1The run completed and findings were reported
2The run could not complete — tyf missing, bad workspace, malformed output

That table is audit’s. The split between 1 and 2 is what makes it usable in CI: a failing check and a broken setup are different problems.

changed-symbols and impacted-tests never return 1: their output is an inventory, not a verdict on one. When impacted-tests cannot trust its own answer it says so in the report (verdict: run_all) and still exits 0 — “run everything” is a usable answer for a pre-commit hook, and failing the hook because the analysis was inconclusive only teaches people to bypass it.

run is the exception to the table. Its own failures exit 2 as usual, but once pytest starts the exit code is pytest’s, verbatim — that is the hook contract. A selection that turns out to impact no tests exits 0 without spawning anything.

Prerequisites per command

changed-symbols needs only git — it parses Python with tree-sitter, so it works in a checkout that has never had ty installed. impacted-tests needs both git and tyf, but looks for tyf only once it knows the walk will actually run. run needs those two plus pytest, which it resolves from GERENUK_PYTEST, then pytest-command in pyproject.toml, then PATH. audit and doctor need tyf on PATH. guide needs nothing at all, not even a repository.

gerenuk changed-symbols

Map the working tree’s diff to the Python symbols it changed.

gerenuk changed-symbols [--base <REF>]

Unlike audit, this command needs only git — no tyf, no ty, no Python environment. It is the first stage of impact-based test selection: impacted-tests walks the reference graph from these symbols out to the tests that reach them.

git is taken from PATH unless GERENUK_GIT points at a specific binary.

Example

$ gerenuk changed-symbols
base main (merge-base 5ddda1f)

changed symbols (2)
  modified  method    mypkg.pipelines.enrich:Enricher.run  src/mypkg/pipelines/enrich.py
  added     function  mypkg.utils:parse_date               src/mypkg/utils.py

ignored symbols (1)
  modified  function  mypkg.daily:normalize_prices         src/mypkg/daily.py  (@transformation)

module-level changes (1)
  mypkg.pipelines.enrich  src/mypkg/pipelines/enrich.py

non-Python changes (1)
  schema.sql

--format json emits the same information as a single object:

{
  "base": "main",
  "merge_base": "5ddda1f2cd66afdf789771b20a3bf667df78f050",
  "changed_symbols": [
    {
      "symbol": "mypkg.pipelines.enrich:Enricher.run",
      "file": "src/mypkg/pipelines/enrich.py",
      "kind": "method",
      "line": 42,
      "column": 9,
      "change": "modified"
    }
  ],
  "ignored_symbols": [
    {
      "symbol": "mypkg.daily:normalize_prices",
      "file": "src/mypkg/daily.py",
      "kind": "function",
      "line": 17,
      "column": 5,
      "change": "modified",
      "ignored_by": "transformation"
    }
  ],
  "module_level_changes": [
    {
      "module": "mypkg.pipelines.enrich",
      "file": "src/mypkg/pipelines/enrich.py"
    }
  ],
  "non_python_changes": ["schema.sql"],
  "test_files_changed": [],
  "errors": []
}

kind is function, method or class; change is added, modified or deleted; line and column are where the definition’s name starts, not its first decorator, and both are 1-based. Every array is sorted, so two runs on the same tree produce byte-identical output.

The schema is the interface between the phases: impacted-tests --changed report.json replays a saved report instead of diffing the working tree, which is why each entry carries enough to be walked from — a definition’s exact position, and a file for every module-level change. Every field above is required: a saved report is parsed strictly, so one that omits column is rejected rather than half-read.

What gets diffed

The range is merge-base(HEAD, <base>)working tree: staged and unstaged edits together, plus untracked files that .gitignore does not cover. Taking the merge base rather than the branch tip means work landing on main after you branched is not attributed to you.

--base defaults to the first of origin/main, main, master that exists. A --base you pass explicitly is used as given; if it does not resolve, the run fails with exit code 2 rather than falling back.

How lines become symbols

Changed line ranges come from git diff -U0, so there is no context to misattribute. Each line is mapped to the innermost definition that encloses it, parsed with tree-sitter-python.

Changed lineAttributed to
Anywhere in a function or method body, its signature, or its default valuesthat function or method
A decoratorthe definition it decorates
A class body, outside any method (attributes, dataclass fields)the class
Inside a nested function or closurethe enclosing named function
Inside a nested classOuter.Inner, and its methods Outer.Inner.method
Module level — imports, constants, module-level statementsmodule_level_changes

Blank lines are the one exception: they carry no meaning, and appending a function inserts two of them, so they are not treated as module-level changes. Docstring and comment edits are attributed normally — phase 1 does not try to detect semantic no-ops.

Symbols are named module.path:QualName. The module path is the unbroken chain of directories above the file that contain __init__.py, which resolves src-layout and flat layout identically. PEP 420 namespace packages fall back to the file path with a leading src/ removed.

Added, modified, deleted

A symbol’s verdict comes from whether it exists on each side of the diff, not from which side the hunk touched:

Old sideNew sideVerdict
presentpresentmodified
presentabsentdeleted
absentpresentadded

So deleting lines from a function that still exists is a modification — its callers were never orphaned. Deletions are found by parsing the old blob out of git, which is why a removed function can still be named.

Renames are not paired. git detects them, but a moved module is a new module: every symbol in the old path is reported deleted and every symbol in the new path added, so phase 2 still revisits the callers of the old name.

Files that are not analysed

Checked in this order, so the first row that matches wins:

KindWhere it goes
Anything that is not .py, including .pyi stubs and binariesnon_python_changes
Test files — a tests/test directory, or test_*.py / *_test.pytest_files_changed
Files that do not parseerrors, and the module is reported as module-level

The order matters for the phases downstream: a non-Python file under tests/ — a fixture .json, say — is a non-Python change, which makes impacted-tests answer run_all. A changed test file needs no symbol analysis: it will select itself later.

Ignoring registry decorators

Registry-style functions — registered by a decorator and invoked by a runner — are neither called directly nor covered by targeted tests, so chasing their callers is wasted work. List their decorators in the repository root’s pyproject.toml:

[tool.gerenuk]
ignore-decorators = ["transformation", "registry.task"]

Matching is syntactic dotted-suffix matching on the decorator expression, with or without call parentheses:

Config entryMatchesDoes not match
transformation@transformation, @transformation(...), @registry.transformation@transform
registry.transformation@registry.transformation, @a.registry.transformation@transformation

A matching decorator moves the symbol to ignored_symbols with the entry that matched, rather than dropping it. One match is enough: a symbol carrying both a matching and a non-matching decorator is still ignored. Ignoring applies to the decorated definition only, never to its neighbours.

Limitation: import aliases are not resolved. from registry import transformation as t followed by @t will not match, because the check never leaves the file’s syntax.

Exit codes

0 on any successful run, including one that reports hundreds of symbols — the output is an inventory, not a verdict. 2 when the run could not complete: not a git repository, or a --base that does not resolve.

gerenuk impacted-tests

Walk the reference graph from the symbols the working tree changed out to the tests that reach them.

gerenuk impacted-tests [--base <REF>] [--changed <FILE>]
                       [--max-depth <N>] [--max-symbols <N>] [--budget-ms <MS>]

This is the second stage of impact-based test selection. changed-symbols answers what changed; this answers what could break. It does not run pytest — the output is deliberately node-ID-shaped so something else can.

It needs both git and tyf.

The verdict

Every run emits a verdict, and both kinds exit 0:

VerdictMeaning
selectedThe walk completed. impacted_tests is the answer.
run_allRun the whole suite. reason says why the selection is not trustworthy.

That is the whole safety argument. A pre-commit hook that quietly under-selects is worse than no hook, so anything gerenuk cannot see through becomes “run everything” rather than a short list or a crash.

reasonWhy
non_python_changesThe diff touched files symbol analysis cannot read
parse_errorsA changed file did not parse, so its symbols are unknown
tyf_unavailabletyf is not installed, so no reference can be resolved
refs_failedtyf failed or answered unparseably part-way through
index_failedThe working tree could not be read part-way through
max_depth / max_symbols / budgetA limit tripped before the frontier emptied
decorator_dispatchA changed symbol is registered by a decorator whose registrar could not be resolved, so the framework’s route to its tests is invisible. errors names the symbol and the decorator.

non_python_changes and parse_errors are settled before tyf is looked for, so a diff of pyproject.toml alone answers in a checkout with no ty installed.

Example

$ gerenuk impacted-tests
base main (merge-base 57510ca)
verdict selected

impacted tests (4)
  tests/test_api.py  (whole file)
    ← sample_pkg.cli ← sample_pkg.cli:main ← sample_pkg.service:describe
  tests/test_pipelines.py::test_run_describes_animals_in_order
    ← sample_pkg.pipelines:Enricher.run ← sample_pkg.service:describe
  tests/test_pipelines.py::test_run_on_an_empty_shelter_returns_nothing
    ← sample_pkg.pipelines:Enricher.run ← sample_pkg.service:describe
  tests/test_service.py  (whole file)
    ← sample_pkg.cli ← sample_pkg.cli:main ← sample_pkg.service:describe

5 symbol(s) visited, 3 tyf call(s), 14 ms

That is a real run of make test-impact, which edits describe in the test fixture. Read the line right to left as “the change, which reaches this, which the test calls” — it is the why-chain. When a selection looks wrong, the chain names the edge to blame, and tyf refs <symbol> confirms it by hand.

Both whole-file entries here are the module-level rule at work: cli.py ends in if __name__ == "__main__": main(), so main is referenced at module scope, which makes sample_pkg.cli itself a node — and every test file that imports it is selected wholesale. That is the intended conservatism, and the chain makes it legible.

--format json emits the same run as one object:

{
  "verdict": "selected",
  "reason": null,
  "base": "main",
  "merge_base": "57510ca2b98d1f9e469ed80748a73adca01f3dcc",
  "impacted_tests": [
    {
      "file": "tests/test_api.py",
      "symbol": null,
      "via": ["sample_pkg.cli", "sample_pkg.cli:main"],
      "origin": "sample_pkg.service:describe"
    },
    {
      "file": "tests/test_pipelines.py",
      "symbol": "tests.test_pipelines:test_run_describes_animals_in_order",
      "via": ["sample_pkg.pipelines:Enricher.run"],
      "origin": "sample_pkg.service:describe"
    },
    {
      "file": "tests/test_pipelines.py",
      "symbol": "tests.test_pipelines:test_run_on_an_empty_shelter_returns_nothing",
      "via": ["sample_pkg.pipelines:Enricher.run"],
      "origin": "sample_pkg.service:describe"
    },
    {
      "file": "tests/test_service.py",
      "symbol": null,
      "via": ["sample_pkg.cli", "sample_pkg.cli:main"],
      "origin": "sample_pkg.service:describe"
    }
  ],
  "test_files_changed": [],
  "ignored_symbols": [],
  "stats": {
    "seeds": 1,
    "visited": 5,
    "max_depth_reached": 2,
    "tyf_calls": 3,
    "duration_ms": 14
  },
  "errors": []
}

Reading the identifiers

A symbol id is module.path:QualName. An id with no colon is a module — it is what a reference at module scope reaches.

symbol is null when the whole file is selected rather than one test function, which is what a module-level edge produces. In pytest terms, a non-null symbol is a file::function node id and a null one is just the file.

via holds the symbols strictly between the test and origin, nearest the test first. An empty via means the test references the changed symbol directly. origin is never repeated in via; the human renderer joins them back up.

When a whole file is selected, its individually-reached tests are dropped from the list: the file entry already covers them, and emitting both would hand the same file to pytest twice.

test_files_changed passes straight through from changed-symbols: a changed test selects itself, and needs no walking.

How the walk works

Breadth-first over the reverse reference graph, one tyf refs frontier at a time, on the working tree only.

Seeds are every entry in changed_symbols. ignored_symbols are never seeded — that is what phase 1’s filter is for. Each module_level_changes entry seeds both the module’s own top-level definitions and the module itself.

Each reference found is classified by what owns its line:

The reference isWhat happens
In a test file, inside a test functionRecorded as an impacted test. The walk stops there.
In a test file, at module scopeThe whole file is recorded (symbol: null).
A plain import / from … import lineDropped.
Inside a definitionMapped to that definition and expanded next round.
Inside a symbol carrying an ignored decoratorRecorded under ignored_symbols. Not expanded.
At module scope, not an importThe module is the node; test files importing it are selected.

Dropping import lines is the single biggest precision win in the design. The usages inside an importing module show up as their own references, so the import line adds nothing — and keeping it would select every test that so much as imports the module.

Deleted symbols are the one case a type checker cannot help with: there is no definition left to resolve references to. Those fall back to a word-boundary textual scan of the workspace’s Python files for the bare name, and the walk continues normally from whatever encloses each hit. This over-matches — comments, docstrings, same-named locals — deliberately: deletions are rare per commit, over-selection is safe, and the via chain shows what happened.

Because renames are not paired, a moved module walks precisely on the added half and coarsely on the deleted half.

Budgets

A pre-commit hook must never hang and never silently under-select. Three limits back that up, each settable on the command line or in pyproject.toml:

[tool.gerenuk]
max-depth = 10      # BFS levels past the seeds
max-symbols = 500   # nodes visited
budget-ms = 30000   # wall clock; 0 disables it

A flag beats the config file, which beats the built-in default. Tripping any of them produces run_all, with whatever was found so far still listed.

max-depth counts levels beyond the seed frontier, which is always expanded: --max-depth 0 still resolves the changed symbols’ own references, and the default of 10 walks ten hops out from them.

Replaying a saved report

gerenuk changed-symbols --format json > changed.json
gerenuk impacted-tests --changed changed.json

--changed walks a saved phase-1 report instead of diffing the working tree — useful for debugging a selection, and for replaying a diff the tree no longer has. It conflicts with --base, since the saved report already records the base it was taken against.

Performance

One tyf refs call per BFS level — the whole frontier goes in one invocation — against a warm ty daemon. stats.tyf_calls counts those invocations, so it is the depth of the walk rather than its width.

The first call in a session pays ty’s cold start (roughly one to two seconds); that cost is tyf’s, not gerenuk’s. The fixture walk above takes about 14 ms warm.

Symbols are addressed by file:line:col position rather than by name, which is both faster to resolve and unambiguous between same-named symbols in different modules.

Known gaps

These are deliberate for this phase, not bugs to rediscover:

  • Re-exports. A plain import line is dropped: the importing module’s own uses of the symbol answer for themselves, and following the import too would select every module that so much as mentions it. A renaming import (from x import y as z) is followed, because there the uses below say z and a query about y never returns them — see ADR 0013. __all__-driven star imports and module-object attribute access are still invisible.
  • Module-level execution. A changed symbol called at import time of module M selects only the tests that import M directly. A non-test module that imports M and is tested elsewhere is missed.
  • Fixtures. conftest.py is not treated specially, and pytest injects fixtures by name rather than by reference, so a fixture is a dead end for the walk. Fixture-aware expansion is phase 3.
  • Runtime registries. Decorator registration is followed (ADR 0012), but a registry populated by a plain call at runtime, getattr dispatch and globals() lookups are not.
  • Non-ASCII before a definition’s name. Positions are byte columns. Every prefix Python allows before a def/class name is ASCII, so this is theoretical — but a wrong column resolves to no references rather than to an error.

Exit codes

0 for either verdict — run_all is an answer, not a failure. 2 only when no verdict could be produced at all: not a git repository, or a --changed file that cannot be read or parsed. Never 1.

gerenuk run

Run pytest on exactly the tests the working tree’s changes impact.

gerenuk run [--base <REF> | --impact <FILE>]
            [--max-depth <N>] [--max-symbols <N>] [--budget-ms <MS>]
            [--fallback-command <JSON_ARRAY>]
            [--dry-run] [-- <pytest args>…]

This is the third and last stage. changed-symbols answers what changed, impacted-tests answers what could break, and this runs it. The impact report is computed in-process — one command, no intermediate files, never a subprocess of itself.

It needs git, tyf and pytest.

Why this is a command and not a list of node ids

A selection has three possible answers, and an argument list can only express two of them:

Outcomepytest argvTrap
run thesepytest a.py::t1 b.py
run everythingpytest
run nothing(there is none)an empty argv is “run everything”

So pytest $(gerenuk print-node-ids) would invert the best case: the one diff that impacts no tests at all would run the entire suite. The empty selection has to short-circuit before a shell ever interpolates it.

VerdictWhat happensExit
selected, non-emptypytest <node ids> <passthrough>pytest’s
selected, emptynothing is spawned, one line to stderr0
run_all (any reason)pytest <passthrough> — the full suite, or the fallback command when one is configuredpytest’s, or the fallback’s

The run_all row is the safety argument carried to its conclusion: gerenuk degrading mid-walk still produces a correct hook, just not a fast one. In a large repository “the full suite” can be a painful default, which is what the fallback command is for.

Example

$ gerenuk run -- -q
gerenuk: 5 node id(s) from 1 origin(s) in 152 ms — details: gerenuk impacted-tests
.........                                                                [100%]
9 passed in 0.02s

gerenuk says one line for itself and then becomes pytest — Ctrl-C, colours and the exit code are pytest’s own, because the process is pytest. The other two outcomes announce themselves the same way:

gerenuk: full suite — non-Python files changed
gerenuk: no tests impacted — nothing to run

--dry-run

Prints the decision and the exact argv, and spawns nothing:

$ gerenuk run --dry-run
decision: selected
gerenuk: 5 node id(s) from 1 origin(s) in 152 ms — details: gerenuk impacted-tests

tests/test_api.py
  ← sample_pkg.cli ← sample_pkg.cli:main ← sample_pkg.service:describe
tests/test_fixtures.py::test_described_marks_the_senior
  ← tests.conftest:described ← sample_pkg.service:describe
tests/test_pipelines.py::test_run_describes_animals_in_order
  ← sample_pkg.pipelines:Enricher.run ← sample_pkg.service:describe

expanded tests.conftest:described (fixture) → 1 node id(s)

argv
  uv
  run
  pytest
  tests/test_api.py
  tests/test_fixtures.py::test_described_marks_the_senior
  tests/test_pipelines.py::test_run_describes_animals_in_order

One argv element per line, deliberately: it is for reading, not for $(…).

When the outcome is run_all and a fallback command is configured, the dry run says so instead, and the argv is the fallback’s:

decision: run_all
gerenuk: full suite — non-Python files changed

would exec fallback: ["/home/you/proj/scripts/pick-subprojects.sh","--from-gerenuk"] (reason: non_python_changes)
  from: fallback-command in pyproject.toml

argv
  /home/you/proj/scripts/pick-subprojects.sh
  --from-gerenuk

--format json emits the selection as one object — the verdict and reason carried through from the impact report, plus node_ids, expanded, dropped, the assembled argv, and fallback:

{
  "verdict": "selected",
  "reason": null,
  "decision": "selected",
  "node_ids": [
    {
      "node_id": "tests/test_fixtures.py::test_described_marks_the_senior",
      "via": ["tests.conftest:described"],
      "origin": "sample_pkg.service:describe"
    }
  ],
  "expanded": [
    {
      "from": "tests.conftest:described",
      "kind": "fixture",
      "into": ["tests/test_fixtures.py::test_described_marks_the_senior"]
    }
  ],
  "dropped": [],
  "argv": ["uv", "run", "pytest", "tests/test_fixtures.py::test_described_marks_the_senior"],
  "fallback": null
}

decision is the three-way outcome (selected / run_all / nothing); verdict is the impact report’s, unchanged. fallback is always present and is null unless the outcome is run_all and a fallback command is configured; then it carries what would have been exec’d — the resolved argv, its source (flag, env or config), the reason, and the complete payload that would have been written to its stdin:

{
  "verdict": "run_all",
  "reason": "non_python_changes",
  "decision": "run_all",
  "node_ids": [],
  "expanded": [],
  "dropped": [],
  "argv": ["/home/you/proj/scripts/pick-subprojects.sh", "--from-gerenuk"],
  "fallback": {
    "argv": ["/home/you/proj/scripts/pick-subprojects.sh", "--from-gerenuk"],
    "source": "config",
    "reason": "non_python_changes",
    "payload": {
      "gerenuk_fallback_payload_version": 1,
      "reason": "non_python_changes",
      "report": { "base": "origin/main", "merge_base": "…", "non_python_changes": ["requirements.txt"], "…": "…" }
    }
  }
}

A dry run never executes the fallback, whatever the outcome.

From symbol to node id

impacted_tests[].symbol was designed to be node-id-shaped. Turning it into one is mostly mechanical and entirely about pytest’s collection rules.

  • symbol: null → the file path is the node id.
  • tests.test_x:test_fntests/test_x.py::test_fn. The file is taken as-is and the qualified name’s dots become ::, so tests.test_x:TestFoo.test_bartests/test_x.py::TestFoo::test_bar.
  • Parametrised tests need nothing: file::test_fn selects every parametrisation, which is the right grain for impact selection.

The collectibility gate

The walk records the enclosing symbol of a reference, which is frequently a helper or a fixture rather than a test. Handing pytest a non-collectible node id is a usage error (exit 4) that fails the entire run, so each qualified name is checked segment by segment against pytest’s defaults — test* functions, Test* classes with no __init__:

The symbol isNode id
Collectible throughoutfile::seg::seg
Collectible up to a point (TestFoo.helper)trimmed to file::TestFoo
Not collectible at all, and a fixturefixture expansion
Not collectible at all, anything elsethe whole file

Every degrade is towards more tests. A file pytest collects nothing from at all — __init__.py, a helper module, a conftest.py on its own — is dropped instead, because selecting it would mean exit code 5.

pytest’s ini-level overrides (python_functions = check_*) are not read; see Known gaps.

Fixture awareness

pytest injects fixtures by name, not by reference, so a fixture is invisible to tyf from the consuming side — the walk dead-ends at the fixture’s definition. Two failure modes follow directly, and both under-select silently, which is the one thing the design promises never to do:

  1. A changed symbol referenced from a fixture body is recorded as tests.conftest:shelter. That is not collectible, and conftest.py collects zero tests, so the whole-file fallback would select nothing.
  2. A directly edited conftest.py lands in test_files_changed, and “the file selects itself” again selects nothing.

So gerenuk resolves the name edge itself, with tree-sitter and no tyf:

Recognising a fixture. A def whose decorators suffix-match pytest.fixture or fixture — the same syntactic matcher ignore-decorators uses, with import aliases deliberately unresolved. The fixture’s name is the def’s name unless a string-literal name="…" overrides it. autouse= is recorded, and an autouse= whose value is not a literal is read as on: the widening direction, since an autouse fixture nothing names would otherwise reach no test at all.

Scope. A fixture in a test module is visible in that module. A fixture in a conftest.py is visible in every test file in that directory’s subtree. A module-level fixture shadows a conftest.py one of the same name — pytest’s own rule — and a nearer conftest.py shadows a further one. pytest’s override idiom, def shelter(shelter) in a nearer conftest.py, still runs the fixture it shadows, so the shadowed one still reaches that subtree.

Consumers, within that scope:

  • test functions and methods whose parameter list names the fixture;
  • anything carrying @pytest.mark.usefixtures("name") with string-literal arguments, and the methods of a Test* class so decorated;
  • any test whose usefixtures arguments could not all be read — usefixtures(*NAMES) — since it may be asking for the fixture under a name gerenuk cannot see;
  • other fixtures whose parameters name it — followed transitively, with cycles terminating on the visited set;
  • every test in scope when the fixture is autouse=True.

Coarse fallbacks, where precision is not available:

SituationSelection
Fixture with a dynamic name=every test file in its scope
Fixture with a dynamic autouse=every test in its scope
Test with an unreadable usefixtures argumentconsumes every fixture visible to it
A changed conftest.pyevery test file in its subtree
A conftest.py that cannot be parsedevery test file in its subtree

An expanded entry keeps its audit trail: the fixture’s symbol id is prepended to the why-chain, so the output reads

tests/test_service.py::test_summary
  ← tests.conftest:shelter ← sample_pkg.service:describe

and tests.conftest:shelter is a real symbol id you can hand to tyf refs.

Changed test files, filtered

test_files_changed arrives from phase 1 unfiltered; this is where it is resolved:

  • entries the working tree no longer has → dropped;
  • conftest.py entries → subtree expansion, never a node id;
  • anything pytest collects no tests from → dropped;
  • everything else → the file is its own node id.

After mapping and expansion, whole-file entries supersede their own per-test node ids — the same collapse the closure applies, re-run because expansion can introduce new whole-file entries. Superseded ids appear under dropped.

Finding pytest

First of: GERENUK_PYTEST, then pytest-command in pyproject.toml, then pytest on PATH. The config key is an argv rather than a string, because the common real-world value is a multi-word runner:

[tool.gerenuk]
pytest-command = ["uv", "run", "pytest"]

GERENUK_PYTEST names a single executable, matching GERENUK_TYF and GERENUK_GIT; use pytest-command for anything with arguments.

pytest is invoked from the repository root, because the node ids gerenuk hands it are repository-relative. Passthrough paths are interpreted from there too, and so is pytest’s own rootdir and ini discovery — which is a difference from running pytest yourself in a subdirectory.

Everything after -- is appended to the argv verbatim — -x, -k, -n auto, whatever. gerenuk has no opinion about ordering, parallelism or --failed-first.

The fallback command

A run_all outcome means gerenuk could not bound the impact of the change. By default that runs the whole suite under pytest. A repository that already has its own way of narrowing work — a script that maps changed files to sub-projects, say — can name it, and run execs that instead:

[tool.gerenuk]
fallback-command = ["scripts/pick-subprojects.sh", "--from-gerenuk"]

run_all then means “delegate to the fallback”; selected and nothing are untouched, and the default pytest resolution is not consulted at all on that path.

Configuring it

The value is an argv, never a shell string: no shell is involved, nothing is word-split, and nothing inside an element is substituted. The first element is resolved the way any exec’d program is — an absolute path as itself, a bare name on PATH, and anything with a path separator in it relative to the repository root (the directory pyproject.toml is in), never to the directory gerenuk was run from.

Three sources, highest wins, mirroring how pytest is found:

SourceForm
--fallback-command '["scripts/pick.sh", "--from-gerenuk"]'a JSON array of strings
GERENUK_FALLBACK='["scripts/pick.sh", "--from-gerenuk"]'a JSON array of strings
fallback-command under [tool.gerenuk]a TOML array of strings

Absent everywhere is fine and means the default. An empty array anywhere in the chain is a configuration error — even in a layer a higher one overrides, and even when the outcome would have been selected. It fails at startup with exit 2, not on the day the bail-out first happens.

The fallback runs from the repository root, like pytest. Everything after -- belongs to pytest and is not appended to the fallback’s argv: the fallback is not pytest, and gerenuk does not know what its arguments mean.

What it receives

The fallback inherits gerenuk’s environment, plus one variable, GERENUK_FALLBACK_REASON=<reason>, so a shell script can branch without parsing anything. On its stdin it finds a JSON payload:

{
  "gerenuk_fallback_payload_version": 1,
  "reason": "non_python_changes",
  "report": {
    "base": "origin/main",
    "merge_base": "3f2c…",
    "changed_symbols": [],
    "ignored_symbols": [],
    "module_level_changes": [],
    "non_python_changes": ["requirements.txt"],
    "test_files_changed": [],
    "errors": []
  }
}
  • gerenuk_fallback_payload_version is 1. Adding a field or a reason variant keeps it; renaming or removing one bumps it.
  • report is the changed-symbols report the run was computed from, in exactly the shape that command prints — the changed symbols with their files, the module-level changes, the non-Python changes, the changed test files and the parse errors. It is null when --impact replayed a saved impact report, since no diff was taken; a fabricated empty report would read as “nothing changed”.
  • reason is why the outcome is run_all — the same value impacted-tests reports, as a stable snake_case name:
reasonMeaning
non_python_changesthe diff touched files gerenuk cannot reason about
parse_errorsa changed Python file did not parse
tyf_unavailabletyf is not installed, so no reference can be resolved
refs_failedtyf failed part-way through the walk
index_failedthe working tree could not be read part-way through the walk
max_depththe frontier was still growing at max-depth levels
max_symbolsmore than max-symbols symbols were visited
budgetthe wall-clock budget ran out
decorator_dispatcha changed symbol is dispatched by a decorator whose registrar could not be resolved
unspecifieda replayed report said run_all with no reason

New variants may be added; existing names are never renamed within a payload version.

The payload is delivered from a file that is already unlinked, not a pipe, so a script that never reads its stdin neither blocks nor fails, and nothing is left behind either way.

Exec semantics

gerenuk execs the fallback exactly as it execs pytest: its process becomes the fallback, and from then on the terminal, the signals and the exit code are the fallback’s own. gerenuk does not interpret, wrap or annotate any of it. Before the exec it says one line for itself:

gerenuk: full suite — non-Python files changed
gerenuk: delegating to fallback ["/home/you/proj/scripts/pick-subprojects.sh","--from-gerenuk"] (from fallback-command in pyproject.toml)

If the fallback cannot be started at all — the program is missing, or not executable — the error names the resolved path and where it was configured, and gerenuk exits 2 having run nothing else.

Delegation only

This is a one-way handover, deliberately. The fallback receives context and ownership of the invocation; it has no way to hand a test selection back to gerenuk. A script that computes a narrower set of tests runs them itself, and its exit code is the answer.

That keeps the contract small enough to pin: one payload, one direction, one exit code. A two-way protocol — the fallback returning node ids for gerenuk to run — would be a new, separately versioned mechanism with its own record, not a widening of this one. There is likewise no per-reason routing: one command for every run_all reason, and the script branches on reason itself if it wants to.

Replaying a saved report

gerenuk impacted-tests --format json > impact.json
gerenuk run --impact impact.json

--impact maps a saved phase-2 report instead of walking the tree. It is parsed strictly — every field required, unknown keys rejected — because a report that half-parses becomes a confident selection of the wrong tests. It conflicts with --base and the budget flags, which belong to the walk that already happened.

Exit codes

Once pytest starts, the exit code is pytest’s, verbatim: 0 green, 1 failures, and 25 mean what pytest says they mean. That is the hook contract, and a fallback command inherits it: once it starts, the exit code is the fallback’s.

Before any spawn, gerenuk’s own operational failures exit 2 as everywhere else, and there is no ambiguity because pytest never ran. The empty selection exits 0 — deliberately indistinguishable from a green suite, because for a hook that is exactly what it is.

Known gaps

  • Collection-convention drift. A repo overriding python_functions or python_classes makes the collectibility gate wrong in both directions. The gate mirrors pytest’s defaults; a mismatch degrades to whole-file.
  • Plugin-provided fixtures. A fixture living in an installed plugin, or reached through pytest_plugins, is invisible, and its consumers dead-end unexpanded. The walk still reaches the definition module when it is in the repo, so the miss is narrower than it sounds.
  • usefixtures beyond string literals — variables, pytestmark lists — is not read, so the names cannot be matched. A test whose usefixtures call carries an unreadable argument is treated as a consumer of every fixture visible to it rather than of none, so the gap over-selects rather than missing a test. A pytestmark list assigned at module level is a different shape and is not read at all. A usefixtures mark on any enclosing class is read, nested classes included.
  • Deep fixture-override chains. def shelter(shelter) in a nearer conftest.py is followed, so a change to the shadowed fixture still selects the overriding subtree. The chain is followed by name through successive conftest.py files; an override that shadows without requesting the name it shadows correctly ends it.
  • indirect= parametrisation and dynamically generated fixtures are not modelled.
  • Exit-code aliasing. After the exec, gerenuk cannot distinguish its own never-happened failures from pytest’s 2. Every pre-exec failure has already exited before pytest existed, so this only matters when reading a log after the fact.

gerenuk audit

Report symbols that nothing references, and symbols only tests reach.

gerenuk audit [OPTIONS] <FILE>...

audit is the companion to the selection pipeline, not the product: the walk that impacted-tests does already holds a resolved reference graph, and asking it what does nothing reach? is one more query against the same graph.

A verifier, not a repo-wide sweep

vulture is the cheap sweep for dead code: one pass over a whole project, no type checker involved, nothing to install beyond vulture itself. It works on names, though, which sets its limits — it cannot tell two same-named symbols apart, it flags dynamic and framework code that is very much alive, and it cannot tell you who references a symbol.

gerenuk audit asks ty’s type checker instead, through tyf. References resolve the way Python resolves them, and each one comes back as a file and a line. That costs one tyf refs call per symbol and needs tyf installed, which is why audit takes the files you name rather than a repository — it is shaped for confirming a specific suspicion, not for finding one.

So run them in that order:

vulture src/                    # sweep: what might be dead
gerenuk audit src/suspect.py    # confirm the file against resolved references
tyf refs one_symbol             # or confirm a single name
# then delete

Referenced only from tests

The note severity is the finding a name-based sweep cannot produce at all: every reference to the symbol exists, and every one of them is in a test file. Production stopped calling it and its own tests are what keep it alive — usually the residue of an unfinished refactor, and exactly the code that survives a dead-code scan forever. That finding is why audit earns a place next to a repo-wide scanner rather than deferring to one entirely.

What it cannot see

gerenuk reports static references, plus the three edges a type checker cannot draw and gerenuk models explicitly: conftest.py fixtures, registering decorators, and renaming imports (from x import y as z, which tyf answers for under y while the code says z). Everything else dynamic — registries populated at runtime, getattr dispatch, __all__ star re-exports — stays invisible.

So findings are leads, not a delete list. Confirm with tyf refs <symbol> before deleting anything, and see Troubleshooting for the shapes that are flagged but alive.

Example

$ gerenuk audit sample_pkg/service.py
warn  sample_pkg/service.py:43  func `legacy_export` has no references
note  sample_pkg/service.py:34  method `ShelterService.seniors` is referenced only from tests (1)

1 file(s), 7 symbol(s) checked — 1 warn, 1 note

Locations are relative to the workspace root and one-based, so they paste straight into an editor.

Severities

SeverityRule
warnThe symbol has no references anywhere — production or test
noteEvery reference lives in a test file

A file counts as a test when its path contains a tests/ or test/ directory, or its name matches test_*.py / *_test.py.

What gets audited

Only callable symbols — functions and methods. Within those, three groups are skipped deliberately:

  • names starting with _, including dunder methods: private by convention, or invoked implicitly by the interpreter;
  • classes, variables and constants: their reference patterns are noisy enough that flagging them produces more false positives than signal;
  • functions carrying a registering decorator@app.command(), @router.get(...), @mcp.tool(). A framework holds the reference and calls them, so “no references” says nothing about whether they are dead. Flagging them means flagging every CLI command and every route in the project, which buries the findings that are real: on one project this rule alone cut 48 warnings to 5 while keeping both genuine ones. Decorators that merely wrap (@property, @staticmethod, @functools.wraps) do not count, and those functions are still audited. See ADR 0012.

The N symbol(s) checked count in the summary reports every symbol in the outline, including the skipped ones, so you can see how much of the file the rules actually looked at.

JSON output

$ gerenuk audit --format json sample_pkg/service.py
{
  "files": ["sample_pkg/service.py"],
  "symbols_checked": 7,
  "findings": [
    {
      "symbol": "legacy_export",
      "kind": "Function",
      "file": "/home/you/proj/sample_pkg/service.py",
      "line": 43,
      "severity": "warn",
      "message": "`legacy_export` has no references"
    }
  ]
}

JSON keeps absolute paths, so downstream tools do not have to know the workspace root. Human output uses relative ones.

What counts as a reference

The symbol’s own definition does not. Neither does a mention in a docstring, comment or string literal — ty resolves names, so those never appear.

References are classified as production or test by path, relative to the workspace root. That relative part matters if your project itself lives under a directory named tests/: absolute-path heuristics (including tyf’s own) would call every file a test.

Cost

audit runs one tyf list per file plus one tyf refs per auditable symbol. On a large module that is a lot of LSP round-trips — pass the files you care about rather than the whole package.

gerenuk doctor

Check that the workspace and the tyf binary resolve, without running an analysis.

$ gerenuk doctor
workspace: /home/you/proj
tyf:       /home/you/proj/.venv/bin/tyf

Exits 0 when both resolve and 2 when either does not — so it works as a cheap preflight step in CI before the real audit runs.

What it checks

  1. The workspace root. Either the --workspace path (which must exist), or the nearest ancestor of the current directory holding a pyproject.toml, setup.py, setup.cfg, or .git.
  2. The tyf binary. GERENUK_TYF if set, otherwise the first tyf on PATH.

It does not start ty or make an LSP request. A green doctor means gerenuk knows where to look; it does not prove the language server will come up. If doctor passes but audit fails, the problem is downstream — see Troubleshooting.

gerenuk guide

Print the instructions for one of the three moments someone — usually an agent — meets gerenuk.

gerenuk guide          # let gerenuk choose
gerenuk guide setup    # not wired into this repo yet
gerenuk guide triage   # a run said run_all, or a selection surprised you
gerenuk guide tune     # the reference: base, budgets, keys, binaries
TopicFor
setupA repository gerenuk is not wired into yet.
triageReading a report: the three outcomes and the run_all ladder.
tuneThe base ref, the budgets, [tool.gerenuk] and the binaries.

Choosing a topic

With no topic, gerenuk reads one file, ./pyproject.toml, and prints triage when a [tool.madoqua] table in it names gerenuk as a step — parsed rather than grepped, so a mention in a comment or in dependencies does not count — and setup otherwise. It never walks up, so run it at the repository root. tune is never auto-selected: it is a reference, and nothing about a repository’s state says “you need the reference right now”.

The first line of the output names the topic and why it was chosen:

# gerenuk guide: configured via pyproject.toml [tool.madoqua] -> triage

An explicit topic reads nothing from disk and prints # gerenuk guide: tune instead.

What it needs

Nothing. guide is dispatched before the workspace is looked for, so it runs in a directory that is not a repository, with no tyf, no git and no Python environment — uvx gerenuk guide in an empty directory works. Like changed-symbols it is an inventory, not a verdict: it exits 0, or 2 if it could not write its output, and never 1.

Where the text comes from

The three pages under Agent guide are include_str!d into the binary, so the site and the CLI serve the same bytes. Tests hold them to it: every gerenuk command a guide shows is fed through the real argument parser, every --flag it names exists on some command, every [tool.gerenuk] key it shows is one the config deserializer accepts (and every accepted key is named by a guide), the triage ladder names every run_all reason by the exact label the CLI prints, and no page may exceed 60 lines — one that grows past a screenful stops being read.

How it works

gerenuk is a test-selection pipeline, plus an auditor that reads the same reference graph backwards.

The selection pipeline

changed-symbols parses Python itself, with tree-sitter-python, and needs nothing but git — so the first stage works in a checkout that has never had ty installed.

impacted-tests adds the second source: tyf, which asks ty’s language server for the references, and does the real name resolution. It walks that graph outwards from the changed symbols until it reaches test code.

run adds a third source that is neither: the test files themselves, parsed for pytest’s collection conventions and for the fixture edges pytest resolves by name — which no type checker can follow. It then execs pytest.

flowchart LR
    H[gerenuk impacted-tests] --> I[changed-symbols<br/>git + tree-sitter]
    I --> J{non-Python<br/>or unparseable?}
    J -- yes --> K[verdict run_all<br/>no tyf needed]
    J -- no --> L[BFS frontier]
    L --> M["tyf refs file:line:col"]
    M --> N{what owns the line?}
    N -- import --> P[dropped]
    N -- definition --> L
    N -- test --> O[impacted test<br/>+ why-chain]
    O --> Q[select<br/>node ids + fixtures]
    K --> Q
    Q --> R{anything to run?}
    R -- yes --> S[exec pytest]
    R -- no --> T[exit 0<br/>nothing spawned]

The audit pipeline

For audit, gerenuk does no parsing of its own at all — it asks tyf for the file’s outline and then for each symbol’s references.

flowchart LR
    A[gerenuk audit file.py] --> B[tyf list file.py]
    B --> C[outline: symbols + ranges]
    C --> D[tyf refs SYMBOL<br/>once per callable]
    D --> E[reference lists]
    E --> F[rules: unused / test-only]
    F --> G[human or JSON report]

Step by step:

  1. Outline. tyf --format json list <file> returns an LSP document outline: every symbol in the file, its kind, and its ranges.
  2. Select. analyze::auditable_symbols flattens that outline and keeps the callable, non-underscore symbols. Methods become dotted names (ShelterService.seniors) so tyf can resolve them unambiguously.
  3. Resolve. One tyf --format json refs <symbol> per selected symbol, yielding production and test reference lists.
  4. Classify. analyze::audit applies the rules — see Two corrections gerenuk applies below.
  5. Render. report::Report writes human lines or one JSON object.

Two corrections gerenuk applies

tyf’s answers need two adjustments before the rules can use them.

The buckets are re-derived from paths. tyf splits references into production and test lists using its own heuristic, which inspects the whole absolute path. A project living under a directory named tests/ — gerenuk’s own fixture package does — has every reference filed as a test. gerenuk therefore re-classifies each reference itself, against a path made relative to the workspace root first.

That only works when the lists are complete, so gerenuk calls tyf refs <symbol> --tests --references-limit 0: --tests populates test_references (withheld by default) and --references-limit 0 disables truncation. When the lists still come back shorter than the reported counts, gerenuk falls back to tyf’s own counts — imperfect buckets and all — rather than reading a withheld list as zero references.

The definition is not a usage. tyf includes the symbol’s own definition among the references. Counting it would mean nothing is ever unreferenced, so gerenuk drops the reference that lands on the definition’s line.

Module layout

ModuleResponsibility
cliArgument parsing and command bodies
tyfSpawning tyf, decoding its JSON — one of three impure modules
gitSpawning git — the second
pytestExec’ing pytest — the third, and it only execs (ADR 0011)
modelWire types for what tyf emits
workspaceProject-root detection, test-path heuristic
analyzeThe audit rules — pure, given parsed data
diffUnified-diff text → per-file line ranges
pysourceTree-sitter symbol extraction: which symbol owns a line
modpathFile path → dotted module path
config[tool.gerenuk] in pyproject.toml
changedDiff plus sources → the changed-symbols report
closureBFS over the reverse reference graph — pure, behind two traits
impacttyf and the working tree behind those traits; the impact report
fixturespytest’s fixture map and collection conventions — pure
selectImpact report plus the working tree → pytest node ids — pure
reportHuman and JSON rendering

tyf::Runner::run, git::Git::run and pytest::Runner::exec are the only functions that spawn a process. Everything downstream takes already-parsed values, which is why the analysis and rendering tests need no tyf, no ty, no git, no pytest and no Python: the integration tests stub tyf and pytest with shell scripts and set GERENUK_TYF and GERENUK_PYTEST, and changed’s unit tests substitute a HashMap for the changed::Sources trait.

The third seam is a different shape from the other two, which is why it was allowed. It is exec-and-replace rather than run-and-parse: gerenuk’s process becomes pytest, so there is nothing to capture and no caller left to return to.

Why dotted names

tyf refs summary would match every summary in the project. tyf refs ShelterService.summary narrows to the method. gerenuk builds those dotted names from the outline’s nesting, so class members are resolved correctly without gerenuk knowing anything about Python scoping.

Troubleshooting

tyf not found on PATH

gerenuk looks for tyf on PATH unless GERENUK_TYF is set.

uv add --dev ty-find
# or
GERENUK_TYF=/path/to/tyf gerenuk doctor

`git` not found on PATH

changed-symbols looks for git on PATH unless GERENUK_GIT is set.

GERENUK_GIT=/path/to/git gerenuk changed-symbols

no Python project root above ...

Nothing above the current directory holds a pyproject.toml, setup.py, setup.cfg, or .git. Either run from inside the project, or pass --workspace /path/to/project.

no JSON found in tyf output

tyf answered with human text instead of a JSON payload — usually because the symbol does not exist, or because ty failed to start and tyf reported that in prose. Run the same query by hand to see it:

tyf --format json refs the_symbol

ty server did not start

tyf needs ty. Install it, or make sure uvx is available for tyf’s fallback:

uv add --dev ty

The audit is slow

audit makes one tyf refs call per auditable symbol. A module with fifty public functions means fifty LSP round-trips. Narrow the input:

gerenuk audit pkg/the_one_file.py     # not pkg/*.py

tyf keeps a daemon warm between calls, so the second run on the same project is much faster than the first.

A symbol is flagged but is definitely used

Expected, for a few known shapes:

  • the caller reaches it through getattr, a registry, or a plugin entry point;
  • it is re-exported via __all__ and consumed outside the project;
  • it is a framework hook (a pytest fixture, a Django signal receiver) invoked by name rather than by reference.

gerenuk reports static references only. Confirm with tyf refs <symbol> before deleting.

A symbol is used only by tests and that is fine

Some helpers legitimately exist for the test suite. The rule emits a note rather than a warn for exactly that reason — it is a prompt to look, not a failure.

Setup gerenuk in this repository

gerenuk is not wired into this repository yet. Run everything below at the repository root, in this order.

1. Install. References resolve through tyf (from ty-find), which drives ty’s language server; the first real run starts ty-find’s background daemon, and later runs reuse it.

uv add --dev gerenuk ty-find pytest

2. Check the wiring. gerenuk doctor prints the workspace and the tyf it found, and exits 2 if either is missing. Nothing is analysed.

3. Wire it into the commit hook. As a madoqua step, in pyproject.toml:

[tool.madoqua]
extend_check = [
  { name = "gerenuk", cmd = "gerenuk run -- -q", pass_files = false, timeout_s = 120 },
]

pass_files = false because gerenuk takes no file list: it diffs the working tree against origin/main (then main, then master), not the index, so unstaged edits and untracked files count in a hook. --base <REF> overrides the ref. Everything after -- goes to pytest verbatim.

4. Name the pytest, only if plain pytest is not the one on PATH:

[tool.gerenuk]
pytest-command = ["uv", "run", "pytest"]

5. Verify with a real diff. With no diff at all, gerenuk run selects nothing, spawns nothing and exits 0, which proves only that it ran. Push, so that origin/main matches; change one symbol a test reaches; then:

gerenuk impacted-tests

Expect verdict selected and that test with its arrow chain back to the symbol. gerenuk run --dry-run then shows the pytest argv that would follow. A run_all on this first try is what gerenuk guide triage is for.

6. Exit codes. run returns pytest’s own code once pytest starts, 0 for an empty selection, 2 when gerenuk could not run. impacted-tests and changed-symbols never return 1: they are inventories, not verdicts. audit is the one command that returns 1 for findings.

next: run gerenuk impacted-tests

Triage a gerenuk report

gerenuk run said one line on stderr before handing over to pytest, or gerenuk impacted-tests printed a report. Read the verdict before the list.

Three outcomes.

  • selected: the walk completed and the listed tests are the whole answer. Each entry carries an arrow chain from the test back to the changed symbol; read it right to left. A surprise in the list is a real edge, not a guess.
  • run_all: gerenuk could not bound the impact, so the full suite runs (or the fallback-command does). The reason line says why; work the ladder below. impacted-tests still exits 0: “run everything” is an answer.
  • nothing: selected with an empty list. run spawns nothing and exits 0, indistinguishable from a green suite, which for a hook it is.

The run_all ladder. Take the first reason that matches.

  1. non-Python files changed: the diff touched a file gerenuk cannot reason about - config, a template, data - and any test may depend on it, so no selection is trustworthy. Commit those files separately, or accept the full run; fallback-command is the knob for repositories where the full suite is too slow.
  2. a changed file did not parse: fix the syntax error, then rerun.
  3. tyf is not available: uv add --dev ty-find, then gerenuk doctor.
  4. tyf failed during the walk or the working tree could not be read: gerenuk -v impacted-tests prints the underlying error. Usually the daemon (tyf daemon status, tyf daemon stop) or a permissions problem.
  5. the depth limit was reached, the symbol limit was reached or the time budget ran out: a hub symbol. Raise the budget for this one run, gerenuk impacted-tests --max-depth 20, and read what it finds; make it policy only via gerenuk guide tune.
  6. a changed symbol is dispatched by an unresolvable decorator: a registrar gerenuk cannot see. Add the decorator to ignore-decorators only if the tests reach the symbol some other way; otherwise the full run is right.

JSON. --format json prints one object: verdict, reason (null when selected), base, merge_base, impacted_tests as {file, symbol, via, origin} where symbol is null for a whole file and via is the chain, test_files_changed, ignored_symbols, stats and errors.

Replay. Save one walk and map it more than once:

gerenuk impacted-tests --format json > impact.json
gerenuk run --impact impact.json -- -q

The saved report is parsed strictly, and a stale one selects the wrong tests, so regenerate it after every diff. gerenuk run --dry-run prints the decision and the exact argv without spawning anything, in either form.

Do not:

  • Do not bypass the hook or drop the step to get past run_all. The full suite is the safe answer; a skipped one is no answer.
  • Do not raise a budget in pyproject.toml to make one commit go through.

next: run gerenuk run -- -q

Tune gerenuk

Reference for the knobs. The defaults are conservative on purpose: every degrade widens the selection and never narrows it, so tune for speed only once a run_all reason keeps repeating.

Where the diff comes from. --base <REF> names the ref to diff against; by default origin/main, then main, then master, whichever exists first. The diff is taken from the merge-base, so commits already on the base do not count, and it is the working tree that is diffed - staged, unstaged and untracked alike. --workspace <PATH> names the project root instead of walking up from the current directory to the nearest pyproject.toml, setup.py, setup.cfg or .git.

Budgets. The walk stops and says run_all at whichever limit it hits first. A flag beats [tool.gerenuk] in pyproject.toml, which beats the built-in default.

FlagKeyDefaultMeaning
--max-depth <N>max-depth10levels from a changed symbol out to a test
--max-symbols <N>max-symbols500symbols visited before giving up
--budget-ms <MS>budget-ms30000wall clock for the walk; 0 disables it
[tool.gerenuk]
max-depth = 20
ignore-decorators = ["transformation", "celery.task"]
pytest-command = ["uv", "run", "pytest"]
fallback-command = ["scripts/pick-subprojects.sh", "--from-gerenuk"]
  • ignore-decorators: dotted names, suffix-matched syntactically, of decorators that register a function with a runner. A changed symbol carrying one is reported as ignored instead of walked; import aliases are not resolved.
  • pytest-command: an argv, never a string, because the common value has arguments. Empty means pytest on PATH; GERENUK_PYTEST beats both.
  • fallback-command: what run execs instead of the whole suite on run_all. It receives the reason in GERENUK_FALLBACK_REASON and the changed-symbols report as JSON on stdin, and its exit code becomes the hook’s. --fallback-command <JSON_ARRAY> and GERENUK_FALLBACK override it, in that order. An empty array anywhere is an error at startup.

Binaries. GERENUK_TYF, GERENUK_GIT and GERENUK_PYTEST each name one executable and skip the PATH lookup. tyf is looked for only once a walk is actually needed, so changed-symbols and a run_all settled by the diff alone work in a checkout with no ty at all.

Reading a walk. gerenuk changed-symbols is the first stage on its own: the symbols the diff changed, from git alone. gerenuk impacted-tests adds the walk, with --changed <FILE> to replay a saved first stage. --format json on either is the schema the next stage reads.

Audit. gerenuk audit src/app.py reads the same reference graph backwards for the files you name: symbols nothing references, and symbols only tests reach. Exit 1 on findings. It is a verifier for a candidate something else flagged, not a sweep.

next: run gerenuk run --dry-run