Architecture¶
git-city is built as a strict functional core / imperative shell. The core is pure: it takes an immutable snapshot of your repository plus an intent, and returns a list of small data objects describing exactly what to do. The shell is the only part that touches Git, the filesystem, or the network. This split is what makes every mutating command predictable, dry-runnable, resumable, and reversible from a single source of truth.
If you only remember one thing: nothing decides and acts in the same place. Reads happen once, decisions are pure, and effects run last.
The two halves¶
FUNCTIONAL CORE IMPERATIVE SHELL
(pure, no I/O, total) (the only code that does I/O)
RepoState ──▶ planner ──▶ Plan git ◀── read_repo_state
(snapshot) (pure fn) [Step, …] (shell-out) builds RepoState
│
▼
engine ──▶ render / run / inverse / execute
│
▼
runstate.json (on conflict)
The core (state, planner, steps) never imports anything that performs I/O. The shell (git, engine, runstate, config, cli) wires it to the real world.
Note
Because the planner is pure and total, it can be exhaustively table-tested with no Git at all. The interpreter is tested separately against throwaway repositories. See the testing strategy.
Module map¶
| Module | Layer | Responsibility |
|---|---|---|
git |
shell | The only code that shells out to Git. Thin wrappers over git subprocess calls. |
state |
core (built by shell) | read_repo_state(git) builds the immutable RepoState snapshot. |
planner |
core | Pure functions mapping (RepoState, intent) to a Plan — a list of Steps. |
steps |
core | The frozen Step dataclasses — the vocabulary a plan is written in. |
engine |
shell | The interpreter: render_step, run_step, capture_inverse, execute. |
runstate |
shell | Persists the remaining steps and captured inverses to disk when a sync pauses. |
config |
shell | Loads and merges the global + local TOML configuration. |
cli |
shell | The Cyclopts command surface; parses args, calls a planner, then the engine. |
git — the only door to Git¶
Every read from and write to Git goes through git. Nothing else in the codebase runs a subprocess or parses Git output. This single chokepoint is what lets the rest of the code stay pure and what makes the dry-run and undo guarantees believable: if a step did not go through git, it did not happen.
state — the snapshot¶
read_repo_state(git) is the only place that reads from Git. It runs once at the start of a command and returns an immutable RepoState: every branch with its sha, parent, fork-point / merge-base, tracking branch, and precomputed ahead/behind counts. Everything the planner needs to make a decision is already in this snapshot — the planner never asks Git a follow-up question.
RepoState
trunk: main
branches:
main sha=… parent=∅ tracking=origin/main
add-login sha=… parent=main merge_base=… ahead=2 behind=0
tracking=origin/add-login
Computing all of this up front means decisions are made against one consistent view of the world, not a sequence of live queries that could disagree with each other mid-command.
planner — pure plans¶
A planner is a pure function: (RepoState, intent) → Plan. It performs no I/O and returns no surprises — given the same snapshot and the same request, it always returns the same plan. A Plan is just a list of Steps, and a Step is a small frozen dataclass:
Fetch Checkout FastForward ResetRef RebaseOnto
CreateBranch DeleteLocalBranch SetParent
Push ForcePushSha DeleteRemoteBranch Squash NoOp
Each command (new, sync, land, delete, insert, squash, …) has a planner that emits the right sequence of these steps. Validation lives here too: rejecting cycles, unknown parents, self-parenting, a feature that is behind its parent, and so on — all decided against the snapshot, before any effect runs.
engine — the interpreter¶
The engine takes a Plan and interprets it. The same plan drives four behaviors, which is why one model gives you dry-run, execution, conflict-pausing, and undo without four separate code paths:
| Function | What it does |
|---|---|
render_step |
Renders a step as the exact Git command it represents — this is what --dry-run prints. |
run_step |
Match-dispatches a step to real Git commands (via git). |
capture_inverse |
Reads the pre-mutation state to record how to reverse a step. Captured before the step runs. |
execute |
Runs the steps in order, accumulating the inverse program as it goes. |
--dry-run renders every step and runs nothing:
git fetch origin --prune
git branch -f main origin/main
git checkout add-login
git rebase --onto main <base> add-login
git push --force-with-lease=add-login:<sha> origin add-login
Nothing executed (--dry-run).
undo replays the accumulated inverses in reverse order, restoring moved refs, recreated branches, and even force-pushed or deleted remote branches.
→ The execution engine in depth
runstate — persistence across a pause¶
A rebase can conflict. When that happens the engine leaves the rebase in progress and persists everything it still needs — the remaining steps and the inverses captured so far — to <git-dir>/git-city/runstate.json.
git-city: sync add-login is paused.
stopped on: git rebase --onto main <base> add-login
resolve the conflict and `git add`, then:
git city continue resume the operation
git city abort undo everything and return to the start
git city info show this and the repo status
continue reloads the run-state and resumes from the next step; abort replays the inverses and returns to the start. Undo is single-level: each command overwrites the run-state, so you can always reverse the last git-city command.
config — settings¶
config loads the TOML configuration: a global file merged with a committable local git-city.toml, where local overrides global key by key. It resolves the effective trunk and perennials and surfaces a clean error on malformed TOML rather than crashing.
trunk: main (auto-detected)
perennials: (none)
global: /home/you/.config/git-city/config.toml (not present)
local: /path/to/repo/git-city.toml
cli — the command surface¶
cli is the Cyclopts layer. Each subcommand parses its arguments, builds a snapshot via read_repo_state, calls the relevant pure planner, then hands the resulting plan to the engine to render (for --dry-run) or execute. Errors are printed as a clean git-city: <message> on stderr with exit code 1 — never a raw traceback.
Data flow, end to end¶
A single mutating command walks the same path every time:
- Snapshot.
clicallsread_repo_state(git)once. This is the only read of Git for the whole command. - Plan. The command's pure planner maps
(RepoState, intent)to aPlanofSteps, validating as it goes. No effects yet. - Render or execute. With
--dry-run, the engine renders each step and stops. Otherwise it executes: for each step it captures the inverse first, then runs the step, accumulating the inverse program. - Pause (if needed). A conflict freezes the run, writes the remaining steps and inverses to
runstate.json, and prints how tocontinue/abort(and to re-read the state withinfo). - Reverse (on demand).
undo(orabort) replays the captured inverses in reverse to return the repo to where it started.
Tip
This is the whole design in one sentence: read once, decide purely, act last, and remember how to take it all back.