Testing¶
git-city is heavily tested, and the test suite is the reason the local workflow is considered done. Five adversarial review rounds turned up data-integrity bugs; every confirmed one was fixed and pinned with a regression test. The strategy below is what makes that level of confidence affordable: each layer of the architecture is tested with the cheapest tool that can prove it correct.
The guiding rule is that the more a layer can be tested without Git, the more exhaustively it is tested. The pure core carries the bulk of the cases; the imperative shell is verified against real repositories; and a thin end-to-end layer checks that the whole thing wires together.
The test pyramid¶
Tests live under tests/, in three directories that sort alphabetically into pyramid order — fast and broad at the bottom, slow and few at the top.
| Directory | Layer | What it exercises | Touches Git? |
|---|---|---|---|
tests/a_unit/ |
Unit | Pure planners, steps, rendering, config parsing | No |
tests/b_integration/ |
Integration | The interpreter and read_repo_state against throwaway repos |
Yes (temp repos) |
tests/c_e2e/ |
End-to-end | The git city CLI as a subprocess |
Yes (temp repos) |
Each layer maps to a seam in the functional core / imperative shell design, so a failure points you straight at the responsible piece of code.
Unit: the pure planner, table-tested¶
The pure planners take a (RepoState, intent) pair and return a Plan — a list of Step dataclasses. Because they touch no Git and have no side effects, they can be tested as plain data in, plain data out.
The unit tests build a RepoState snapshot by hand, call a planner, and assert on the exact list of steps it produces. No temp directories, no git subprocesses, no clock or filesystem — just constructing inputs and comparing outputs. That makes them fast enough to enumerate cases exhaustively: feature vs. trunk, ahead/behind permutations, parked and private flags, diverged branches, empty stacks, and the awkward corners that real repositories rarely reach on cue.
Why table tests pay off here
A pure function is the easiest thing in the world to test: there is nothing to mock and nothing to clean up. Pushing all the branch-logic decisions into the planner means the hardest part of git-city — deciding what to do — is covered by the fastest, most numerous tests. The interpreter below only has to get the comparatively mechanical part right: turning each step into the right git command.
This directory also covers the steps themselves, output rendering (the dashboard and tree views), and TOML configuration parsing — including that a malformed config file produces a clean error rather than a crash.
Integration: the interpreter against throwaway repos¶
The interpreter — run_step, capture_inverse, and execute — is the only code that mutates Git, so it has to be tested against actual repositories. The integration tests create disposable Git repos in temp directories, run real operations through them, and assert on the resulting refs and working tree.
read_repo_state(git), the single read path that builds the immutable snapshot, is tested the same way: set up a repo with known branches, parents, and ahead/behind counts, then check that the snapshot matches.
These tests cover the genuinely stateful behavior that the pure layer cannot — fetching, fast-forwarding, rebasing onto a new base, force-pushing with --force-with-lease, land's re-homing of children, delete's re-homing, and the stack-editing operations. Conflict handling is exercised here too: an operation that pauses mid-rebase persists its remaining steps and inverses to run-state, and the tests verify that continue and abort resume or roll back correctly.
Undo: round-trip property tests¶
Undo is the highest-stakes feature in git-city — it is supposed to put the repository back exactly as it was — so it gets its own style of test: a round-trip.
Each round-trip test follows the same shape:
1. snapshot the repo (record every ref + the working-tree state)
2. run an operation (sync, land, delete, squash, reparent, insert, ...)
3. run `git city undo`
4. assert: refs are identical to the snapshot, and the working tree is clean
Because undo replays the inverses captured before each mutation, the assertion is exact: not "close enough," but byte-identical refs. Running this across the whole catalog of mutating operations — including force-pushed and deleted remote branches — is what lets git-city promise that undo restores moved refs, recreated branches, and clobbered remote work.
Undo's refusals are tested as refusals
Undo is designed to refuse rather than destroy. The suite asserts on those guardrails directly: undo declines on a dirty working tree, declines to delete a branch carrying commits that exist on no other branch, and declines while an operation is paused. A passing test here means git-city did not run — which is the correct, safe outcome.
End-to-end: the CLI as a subprocess¶
The top of the pyramid runs the installed git city command as a real subprocess against a temp repo and checks what a user would actually see: stdout, stderr, and the exit code.
This layer is deliberately thin — the heavy logic is already covered below it — but it catches the things only a full invocation can: argument parsing, that errors surface as a clean git-city: <message> on stderr with exit code 1 (never a raw traceback), and that --dry-run prints the ordered git commands and executes nothing.
Running the tests¶
The whole suite runs with one command:
To run a single layer, point pytest at its directory:
uv run pytest tests/a_unit # fast, no Git
uv run pytest tests/b_integration # interpreter against temp repos
uv run pytest tests/c_e2e # CLI subprocess tests
Coverage is available via pytest-cov:
Note
The integration and e2e layers shell out to git and create temporary repositories, so a working Git install is required — the same git git-city uses at runtime.
When you add a feature or fix a bug, add the test at the lowest layer that can prove it: a new branch-logic decision belongs in a pure planner table test, a new Git mutation belongs in an integration test, and any change that touches a mutating command earns an undo round-trip. The contributing guide walks through the full workflow.