Skip to content

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.

Commands overview


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 city sync --dry-run
  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.

Conflicts and recovery


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.

git city config
trunk:      main  (auto-detected)
perennials: (none)
global:     /home/you/.config/git-city/config.toml  (not present)
local:      /path/to/repo/git-city.toml

Configuration reference


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:

  1. Snapshot. cli calls read_repo_state(git) once. This is the only read of Git for the whole command.
  2. Plan. The command's pure planner maps (RepoState, intent) to a Plan of Steps, validating as it goes. No effects yet.
  3. 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.
  4. Pause (if needed). A conflict freezes the run, writes the remaining steps and inverses to runstate.json, and prints how to continue / abort (and to re-read the state with info).
  5. Reverse (on demand). undo (or abort) 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.


The execution engine in depthHow it is all tested