Skip to content

The execution engine

Every mutating git-city command — new, sync, land, delete, insert, squash, and the rest — is built from a single, small model. A command never shells out to Git ad hoc. Instead it reads the repository once, plans a list of tiny reversible operations, and hands that list to one interpreter. From that one design, four user-facing behaviours fall out for free: live execution, --dry-run, single-level undo, and pausing on a conflict with continue/abort.

This page traces that model end to end: the immutable RepoState snapshot, the Step opcode set, the pure planners, and the interpreter functions (render_step, run_step, capture_inverse, execute). It assumes you have read the architecture overview and want the deep version.

Architecture: functional core / imperative shell


The shape of the pipeline

read_repo_state(git)      ──►  RepoState     (immutable snapshot; the only Git reads)
plan_<command>(state, …)  ──►  Plan          (a list of Steps; pure, no Git)
execute(plan, git)        ──►  side effects  (+ a captured inverse Plan)

The middle stage — the planner — is a pure function. Given the same RepoState and the same intent, it produces the same Plan every time, and it touches nothing. All the I/O lives at the two ends: read_repo_state reads, the interpreter writes.


RepoState: read the world once

read_repo_state(git) is the only place in git-city that reads from Git. It shells out a handful of times and assembles an immutable RepoState snapshot in which everything a planner could need is already computed:

Per branch, precomputed Meaning
sha the branch's current tip
parent the recorded parent (from git-city.branch.<name>.parent)
fork-point / merge-base where the branch diverged from its parent
tracking branch the remote branch it pushes to, if any
ahead / behind commit counts vs the parent and vs the tracking branch
modifier flags parked, private

Because all of this is precomputed and frozen, the planners never have to ask Git a follow-up question mid-plan. They walk the snapshot, not the repository. That is what makes them pure — and therefore what makes them trivial to test exhaustively with no Git at all.

Reversible operations: why we snapshot first


The Step opcode set

A Plan is just a list[Step]. Each Step is a small frozen dataclass that describes a unit of work and carries no behaviour — pure data, which is why it can be both executed and serialized.

Step Does
Fetch git fetch <remote> --prune
Checkout check out a branch
FastForward fast-forward a branch ref to a target sha
ResetRef move a branch ref (uses --keep/--hard for the current branch)
RebaseOnto git rebase --onto <onto> <upstream> <branch>
CreateBranch create a branch at a sha
DeleteLocalBranch delete a local branch (-d safe, -D forced)
SetParent write/clear git-city.branch.<name>.parent
Push push (optionally --force-with-lease, with the expected sha)
ForcePushSha force a remote branch to a specific sha (used by undo)
DeleteRemoteBranch delete a branch on the remote
Squash reset-soft over a base and re-commit as one
NoOp nothing (keeps plans total and easy to reason about)

That is the entire vocabulary. Every command is some arrangement of these thirteen opcodes.

Steps are serializable on purpose

Because a Step is plain data, a Plan round-trips through JSON. That is what lets git-city persist the remaining steps of a paused operation to <git-dir>/git-city/runstate.json and pick them up in a later process for continue or abort.


Pure planners

A planner is a function from (RepoState, intent) to a Plan. It encodes all of git-city's workflow knowledge — and none of its I/O. For example, the sync planner for a feature decides, purely from the snapshot, that it must fetch, bring the parent up to date, rebase the feature onto the parent, and force-push with a lease. It emits the corresponding steps in order. It never runs them.

This separation is the whole point: the rules live in one pure place that is table-tested without Git, and the messy business of actually invoking Git lives in one impure interpreter.


The interpreter: four functions

The interpreter in git_city.engine is a flat, match-style dispatch over the opcode set. There are four functions, each with one job.

render_step(step) — describe, don't do

Returns the exact git command a step would run, as text. This is what --dry-run prints. It runs nothing.

run_step(step, git) — do it

Matches on the step's kind and issues the corresponding Git command. This is the only function that mutates the repository.

capture_inverse(step, git) — record how to reverse it

Called before the step runs, this reads the pre-mutation state and returns a Step that would reverse the one about to execute. Capturing before mutating is the central safety idea: by the time the step has run, the information needed to reverse it may be gone, so we record it first.

Forward step Inverse captured before it runs
ResetRef(b → new) ResetRef(b → old_sha)
CreateBranch(b) DeleteLocalBranch(b)
DeleteLocalBranch(b) CreateBranch(b at old_sha)
SetParent(b → p) SetParent(b → old_parent)
Push/ForcePushSha/force-push ForcePushSha(b → old_remote_sha) — but only if git-city created or moved a ref it had already seen

execute(plan, git) — drive the run

Walks the plan once. For each step it captures the inverse, runs the step, and prepends the inverse to a growing inverse program. If a RebaseOnto hits a conflict, it stops there, leaving the rebase in progress, and persists the remaining steps plus the inverse-so-far to runstate.json.


A worked sync example

Suppose add-login is a feature whose parent is the trunk main, you have two local commits on top, and main has advanced on the remote. Running git city sync plans this:

  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

In opcodes that is Fetch → FastForward(main → origin/main) → Checkout(add-login) → RebaseOnto(add-login onto main) → Push(add-login, force_with_lease).

As execute walks it, it captures an inverse for each mutating step before running it. The inverse program — built in reverse order, ready to replay top-to-bottom — comes out as:

  # undo the force-push: restore origin/add-login to the sha it had
  git push --force-with-lease origin <old_remote_sha>:add-login
  # undo the rebase: move the local branch ref back to its old tip
  git reset add-login → <old_local_sha>
  # undo the fast-forward: move main back to where it was
  git branch -f main <old_main_sha>

(Fetch and Checkout need no inverse — fetching is additive and a checkout is reversed implicitly by the ref restores plus returning to the starting branch.)

If you later run git city undo, the interpreter replays exactly this program. If instead the RebaseOnto had hit a conflict, execute would have stopped there, persisted the unrun tail (Push) and the inverse-so-far, and printed:

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 saved tail; abort replays the saved inverse and returns you to the start. Both read the same persisted Plan the live run was using — there is no second code path.


How four behaviours derive from one model

Behaviour How it falls out
Live run executecapture_inverse then run_step, per step
--dry-run render_step over the plan; nothing runs
undo replay the captured inverse program in reverse
continue / abort resume or roll back the Plan persisted to runstate.json

One planner, one opcode set, one interpreter — and every mode is a different way of reading the same Plan.


Safety invariants

These hold for every command, because they are enforced in the interpreter, not per-command:

Invariants the engine never breaks

  • Inverses are captured before mutation. capture_inverse reads the old state ahead of run_step, so a reversal is always possible even after the forward step destroys information.
  • Force-pushes always use --force-with-lease. git-city refuses to clobber a remote branch that moved out from under it.
  • The worktree never goes stale. Moving the current branch's ref uses git reset --keep/--hard, never a bare ref write, so your working tree and HEAD stay consistent.
  • Undo never deletes a remote branch git-city did not create. The inverse for a push only force-restores a prior sha; it will not delete a branch that existed before git-city touched it.
  • Undo refuses rather than destroy. It will not run on a dirty worktree, will not delete a branch carrying commits found on no other branch, and will not run while an operation is paused.

These are the same guarantees the user-facing safety story rests on — here they are simply visible as properties of the engine.

Reversible operations


Why it is built this way

A single model means a single place to be correct. The pure planner can be tested as a table of (state, intent) → plan cases with no repository in sight; the interpreter can be tested against throwaway repos; and undo can be tested as a round-trip — snapshot, run an op, undo, then assert the refs and worktree are byte-for-byte identical. New commands inherit --dry-run, undo, and conflict handling for free, just by emitting steps.

How it's tested · Want to contribute?