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 replay

Rebuild the test selection a commit’s diff makes — to see what it ran and why, and with --run, how long each of those tests took.

gerenuk replay <COMMIT> [--base <REF>]
               [--max-depth <N>] [--max-symbols <N>] [--budget-ms <MS>]
               [--run [--durations <N>]] [-- <pytest args>…]

This is for the day a hook took far longer than it should have. Two questions come up then: why were these tests selected, and which of them is slow. replay answers the first by default and the second with --run.

It needs what run needs: git, tyf, and pytest for --run.

Where it runs

ty and pytest both read the working tree, so a selection rebuilt against any other tree would be about other code. A replay therefore needs a checkout of COMMIT, and gerenuk makes one when yours is not:

$ gerenuk replay 1a2b3c4
gerenuk: the checkout is not 1a2b3c4d5e6f, so it is replayed in /repo/.git/gerenuk/replay — kept for the next replay; `git worktree remove --force /repo/.git/gerenuk/replay` drops it
gerenuk: replaying 1a2b3c4d5e6f against 1a2b3c4^ (0f9e8d7c6b5a)
…

Your own checkout — branch, index, uncommitted edits — is not touched. The worktree is detached and kept: the first replay pays for a full checkout, and each later one only rewrites the files that differ, so checking a handful of commits in a row is cheap. Untracked files left in it are removed between replays; ignored ones (a .venv, caches) stay.

If your checkout already matches COMMIT — HEAD need not be COMMIT, only its tracked files have to be — it is used as it is and no worktree is made. Untracked files are ignored either way; they were never part of the commit. COMMIT and --base are resolved in your checkout, so HEAD~1 means what it means where you typed it.

Under --run, pytest starts in the worktree with whatever pytest gerenuk resolves (see run). A virtualenv with the project installed editable points at your main checkout, so a src/ layout may import that code instead of the commit’s; a pytest-command like uv run pytest builds an environment for the worktree instead, once.

Which diff

InvocationDiffUse it for
replay <COMMIT>COMMIT^ → COMMITthat commit’s own change
replay <COMMIT> --base mainmerge-base(COMMIT, main) → COMMITwhat a hook under scope = "branch", on a branch forked from main, saw when it made COMMIT

By default the hook diffs the working tree from HEAD~1, so each commit is judged on itself (and the one before it). Under scope = "branch" it diffs from the merge base with origin/main (see changed-symbols), so a commit late in a branch was judged on the whole branch so far; --base reproduces that. The default looks at the one commit only. A root commit has no parent and needs --base.

Listing (the default)

Prints exactly what run --dry-run prints — the decision, every selected node id with the chain that reached it, and the argv — and spawns nothing:

$ gerenuk replay 1a2b3c4
gerenuk: replaying 1a2b3c4d5e6f against 1a2b3c4^ (0f9e8d7c6b5a)
decision: selected
gerenuk: 5 node id(s) from 1 origin(s) in 41 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
…

--format json is the dry run’s JSON. A run_all decision names its reason, which is often the whole answer to “why did that take so long”.

--run: with timings

Runs the selection under pytest with --durations=N added, so pytest ends with its table of the slowest test phases — setup, call and teardown separately, which is how a slow fixture shows up:

$ gerenuk replay 1a2b3c4 --run --durations 10
gerenuk: replaying 1a2b3c4d5e6f against 1a2b3c4^ (0f9e8d7c6b5a)
gerenuk: 5 node id(s) from 1 origin(s) in 41 ms — details: gerenuk impacted-tests
...
============================= slowest 10 durations =============================
12.41s call     tests/test_api.py::test_full_export
 3.02s setup    tests/test_service.py::test_lookup
…

--durations defaults to 0, every phase; pytest hides those under 5 ms unless you pass -- -vv. The flag goes in before the passthrough, so a --durations of your own after -- wins.

Everything else is run’s: gerenuk becomes pytest and the exit code is pytest’s, an empty selection spawns nothing, and a run_all decision runs the full suite — or the configured fallback command, which gets no pytest arguments and so no timings. suite-ms, git-env and the other [tool.gerenuk] keys apply as they do to run.

Not to be confused with

run --impact and impacted-tests --changed replay a saved report. replay recomputes everything from a commit, against today’s gerenuk and ty — so a selection that differs from the one the hook made at the time is itself a finding.