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

ScopeBaseCovers
last-commit (default)HEAD~1the last commit plus anything not committed yet — in a pre-commit hook, the commit being made
branchmerge-base(HEAD, base), base the first of origin/main, main, master that existsevery 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 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 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 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, that an ignore-paths pattern matchesignored_paths
Anything else 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.

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.