gerenuk changed-symbols
Map the working tree’s diff to the Python symbols it changed.
gerenuk changed-symbols [--base <REF> | --scope <SCOPE>]
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 HEAD~1 (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": "HEAD~1",
"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"],
"ignored_paths": [],
"test_files_changed": [],
"inert_changes": ["mypkg.models:Animal.label"],
"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 <base> → working tree: staged and unstaged edits together,
plus untracked files that .gitignore does not cover. By default <base> is
HEAD~1, so the range is the last commit plus anything not committed yet. Under
the branch scope or an explicit --base it is merge-base(HEAD, <ref>):
taking the merge base rather than the branch tip means work landing on main
after you branched is not attributed to you.
With no --base, where the diff starts depends on the scope:
| Scope | Base | Covers |
|---|---|---|
last-commit (default) | HEAD~1 | the last commit plus anything not committed yet — in a pre-commit hook, the commit being made |
branch | merge-base(HEAD, base), base the first of origin/main, main, master that exists | every commit on the branch |
Set it with scope = "branch" under [tool.gerenuk], or per run with
--scope branch; the flag beats the key. A root commit has no HEAD~1, so
last-commit falls back to the branch chain there.
A --base you pass explicitly beats both and 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 line | Attributed to |
|---|---|
| Anywhere in a function or method body, its signature, or its default values | that function or method |
| A decorator | the definition it decorates |
| A class body, outside any method (attributes, dataclass fields) | the class |
| Inside a nested function or closure | the enclosing named function |
| Inside a nested class | Outer.Inner, and its methods Outer.Inner.method |
| Module level — imports, constants, module-level statements | module_level_changes |
Blank lines are the first exception: they carry no meaning, and appending a function inserts two of them, so they are not treated as module-level changes. A blank line inside a multi-line string is not blank in this sense — it is part of the string’s value. Comment and docstring lines are the second exception; see below.
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 side | New side | Verdict |
|---|---|---|
| present | present | modified |
| present | absent | deleted |
| absent | present | added |
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:
| Kind | Where it goes |
|---|---|
Anything that is not .py, including .pyi stubs, that an ignore-paths pattern matches | ignored_paths |
Anything else that is not .py, including .pyi stubs and binaries | non_python_changes |
Test files — a tests/test directory, or test_*.py / *_test.py | test_files_changed |
| Files that do not parse | errors, 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 entry | Matches | Does 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.
Ignoring non-Python paths
Any entry in non_python_changes makes impacted-tests and run answer
run_all, and the diff is branch-wide: one README edit early on a branch
widens every later hook run on it. Files no test can read — documentation,
changelogs, agent instructions — can be set aside instead:
[tool.gerenuk]
ignore-paths = ["**/*.md", "docs/**", "CHANGELOG.md"]
A matching file goes to ignored_paths rather than non_python_changes, so it
never widens a selection, and it is still listed so the decision can be
audited. The list is empty by default.
Patterns match the whole repository-relative path. * and ? stay within one
path segment, and ** as a whole segment spans any number of them, so *.md
is the root’s Markdown and **/*.md all of it. Character classes, brace
alternation and backslashes are refused when the config is loaded — exit 2,
naming the key — rather than read literally and never matching.
A .py file is never ignored, whatever the pattern: a pattern as broad as
** still cannot hide a code change. A .py file git calls binary stays a
non-Python change too.
Caveat: keep out anything a test reads. If a test lints the docs or checks an agent instruction file, those paths are inputs, and ignoring them selects around the one test that would catch the change.
Comment- and docstring-only edits
A line that holds nothing but a comment or a docstring does not change the
symbol it sits in. Editing one moves its symbol to inert_changes instead of
changed_symbols, and a module-level comment or docstring is not a
module-level change, so the edit selects no tests. The symbol is listed by id, a module’s without a colon,
so the decision can be audited. An edit that also touches any other line of
the symbol is a modification as before.
A blank line inside a definition is skipped the same way, silently, as one between definitions always was. Rewording a comment block across a blank line puts the blank line in the diff too.
A docstring is the first statement of a module, class or def body, when that
statement is a plain string literal: an f-string, a t-string or a bytes literal
is code. Only lines the string has to itself count, so in
def f(): """doc""" the line is the def’s. A comment line is one whose
text is a comment from its first character on; x = 1 # why is code, and a
# inside a multi-line string is string content. A shebang or an encoding
cookie on line 1 or 2 is code too: it decides what runs the file and how it
decodes. Either kind is set aside only when the same module exists on both
sides of the diff: deleting or renaming a file breaks its importers whatever
it held.
Caveat: some code reads __doc__ at runtime — docopt parses it, CLI
frameworks print it as help text, web frameworks put it in an API schema. A
test that checks that output is skipped by a docstring edit. Such a project
counts docstrings as code. Comments are set aside either way:
[tool.gerenuk]
docstrings = "code"
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, a --base that does not resolve, or an ignore-paths
pattern that cannot be read.