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 | execute → capture_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_inversereads the old state ahead ofrun_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 andHEADstay 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.
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.