git-city
High-level branch workflows for Git — like git-town, but simpler, forge-agnostic, written in Python, and obsessed with safety and UX.
git-city automates the recipes you retype every day — start a feature off an up-to-date trunk, bring my branch (or my whole stack) up to date, merge this branch down and clean up — as a handful of boring, guessable commands. Every command previews with --dry-run and reverses with undo.
0.5.0 — first public release. The local workflow (branches, stacks, sync, land, undo) is complete and heavily hardened. Forge integration (pull/merge requests, patch series) is intentionally not built yet — git-city works fully with only a plain Git remote, or none at all. See CHANGES.md.
Why
Git's plumbing is great; its porcelain for branch workflows is not. git-town solved this years ago, but it's tailored toward forge-centric, pull-request workflows and is opinionated in directions you may not share (seven branch types, ~30 commands). git-city keeps git-town's genuinely good ideas and sheds the rest:
- Forge-agnostic core. A forge is never required. Every command works on a plain Git remote — or no remote.
- Two branch types, not seven.
trunkandfeature. That's the whole mental model. - Rebase-first. Branches and stacks stay linear; syncing rebases onto the parent with
--force-with-lease. - Bulletproof undo. Every command is planned as a list of reversible steps, so
git city undoreverses the last one — and never silently destroys uncommitted work or a remote branch it didn't create. - Boring, guessable commands.
new,sync,land,switch— nothack,append,prepend.
Install
Requires Python ≥ 3.12 and Git. Built with uv.
From source:
git clone <this-repo> && cd git-city
uv tool install . # installs the `git-city` executable on your PATH
Because the executable is named git-city, Git automatically exposes it as a subcommand — so git city <command> and git-city <command> are equivalent. The docs use git city.
Optionally, set up tab-completion for your shell (bash, zsh, or fish):
git city completions --install # or: git city completions zsh > /path/to/completions
For development, skip the install and run it in place:
uv sync
uv run git-city # or: uv run python -m git_city
Quick start
git city init # record your trunk branch (interactive, or --trunk main)
git city new add-login # branch off an up-to-date trunk
# ...hack, commit...
git city sync # main moved on? rebase add-login onto it (and push)
git city land # fast-forward main to add-login and push (keeps the branch)
git city prune # later: sweep away branches you've landed
See where you are at any time:
$ git city info
git-city · my-project
trunk: main
remote: origin
on ● add-login (feature, parent: main)
working tree: clean
vs main: ↑2 ↓0
vs origin/add-login: ↑0 ↓0
stack:
main (trunk)
└─ ● add-login ↑2 ↓0
Stacked changes
Build a branch on top of another, then sync the whole stack at once:
git city new api # off main
git city new ui --onto api # ui stacks on api
git city sync --all # rebase the whole stack, parents before children
$ git city tree
main (trunk)
└─ api ↑3 ↓0
└─ ● ui ↑2 ↓0
Preview and undo
Every mutating command takes --dry-run and shows the exact Git commands it would run:
$ 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).
And undo reverses the last command — restoring moved refs, recreated branches, and even force-pushed remote branches:
$ git city undo
✓ undid: sync add-login
Conflicts
When a sync hits a conflict it stops cleanly and tells you exactly how to proceed:
$ git city sync
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
Commands
| Command | What it does |
|---|---|
git city |
With no command, print the grouped list of commands (the help menu) |
git city info [--verbose / --terse] |
Dashboard: current branch, its place in the stack, ahead/behind, dirty state — and any paused operation. -t collapses it to one line; -v adds shas and unparented branches |
git city tree |
Show the branch hierarchy |
git city new <name> [--onto <branch>] |
Create a feature branch off the trunk (or stack it onto <branch>) |
git city switch [name] |
Switch branches; with no name, pick one with a fuzzy picker |
git city sync [branch] [--all] |
Rebase a branch (or every feature, parents first) onto its parent and remote |
git city land [branch] (alias merge) |
Fast-forward the parent to the branch, push, re-home children — and keep the branch (flagged landed); --delete/-d removes it then and there |
git city prune |
Delete the branches you've landed once their work is in the parent (skips any you've since added commits to) |
git city delete [branch] |
Delete a branch without merging; re-home its children |
git city reparent <new-parent> [--branch <b>] |
Change a branch's parent (lineage only; the next sync moves the commits) |
git city insert <name> [--branch <b>] |
Insert a new branch between a branch and its parent |
git city squash [branch] [--message <m>] |
Compress a branch's commits into one |
git city undo |
Reverse the last git-city command |
git city continue / abort |
Resume, or roll back, a sync paused on a conflict (inspect it with git city info) |
git city init [--trunk <name>] [--remote <name>] |
Set up git-city for this repo (records the trunk, and a preferred remote) |
git city config |
Show the effective configuration and where it comes from |
git city completions [shell] |
Print a shell completion script (bash, zsh, fish); --install to set it up |
Every mutating command accepts --dry-run. Commands that move commits refuse to run on a dirty working tree (commit or stash first), so they never silently carry or discard your changes; switch and reparent, which don't touch your files, don't. The commands that delete a branch or rewrite published history — delete, prune, squash, and land --delete (plain land keeps the branch, so it doesn't prompt) — also confirm before running; pass --yes (or -y) to skip the prompt, which is required to run them non-interactively.
How it works
Branch types. Just two: a trunk (main, plus any configured perennials) is long-lived and never rebased; a feature is everything you work on, and it knows its parent. Follow the parent chain and you get a tree rooted at the trunk — a "stack" is a path down it.
Operations are reversible programs. Every command first compiles, from an immutable snapshot of the repo, into an explicit list of small, reversible steps (checkout, rebase --onto, push --force-with-lease, …). An interpreter then runs them. That single design is what gives --dry-run, undo, and conflict continue/abort for free, and what makes the planner exhaustively testable without touching Git.
Sync rebases. sync brings a feature up to date by rebasing it onto its (also-updated) parent, absorbing any commits the remote has that you don't, then force-pushing with a lease so it can never clobber unseen remote work. Trunks fast-forward from their remote; they're never rebased onto a child.
Undo is a guarantee, not best-effort. Inverses are captured before each step mutates, so undo restores prior ref positions exactly. It refuses rather than discard: it won't run on a dirty tree, and it won't delete a branch carrying commits that live on no other branch.
Configuration
Settings live in TOML, merged from a global user file ($XDG_CONFIG_HOME/git-city/config.toml, default ~/.config/...) and a local, committable git-city.toml at the repo root — local wins:
trunk = "main"
remote = "origin"
perennials = ["develop", "staging"]
git city init writes the local file for you (--remote records the remote too). Set remote when a repo has more than one — say a deploy target like piku alongside your upstream — so git-city pushes to the right place; it otherwise defaults to origin, or the first remote. Per-branch state (each branch's parent, and parked/private flags) lives in Git config under git-city.branch.<name>.* — local to your clone, never churning a tracked file. A parked branch is skipped by sync; a private branch syncs with its parent but is never pushed.
Compared to git-town
| x | git-town | git-city |
|---|---|---|
| Forge | central to the workflow | optional plugin (not built yet); core needs none |
| Branch types | 7 | 2 (+ parked/private flags) |
| Default sync | merge (features) | rebase, with --force-with-lease |
| Config | many Git-config keys | small global + local TOML |
| Language | Go | Python |
git-city borrows git-town's best ideas (high-level verbs, parent hierarchy, reversible-operation engine) — it is not a fork and makes no compatibility promise.
Development
uv sync
make test # the test suite (unit / integration / e2e)
make lint # ruff + ruff format + ty + pyrefly + zuban (incl. --strict)
The architecture is a strict functional core / imperative shell: pure planners turn an immutable repo snapshot into step lists; one thin shell (git.py) shells out to Git porcelain.
Full documentation is in docs/, a Zensical site — preview it with uv run zensical serve.
License
Licensed under the Apache License, Version 2.0. Copyright 2026 Stefane Fermigier.
Metadata
Release files for git-city 0.5.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| git_city-0.5.1.tar.gz | 33.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| git_city-0.5.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 73.4 kB
Release files / git_city-0.5.1.tar.gz
| Download URL | git_city-0.5.1.tar.gz |
|---|---|
| Size | 33.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
74af2ad7d75165d3f492db22835d27077de2ee08b33b770322e48a354c2b7d5a
|
|
BLAKE2b-256 checksum How to use checksums |
0c48adc65eca9e23e6d9ff9987427544284facf5c381a4a24f473bb9981271aa
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / git_city-0.5.1-py3-none-any.whl
| Download URL | git_city-0.5.1-py3-none-any.whl |
|---|---|
| Size | 39.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
753e1ecac9e493b84eab13e5596deccdfa9cd49fec87481fe441281b6281763f
|
|
BLAKE2b-256 checksum How to use checksums |
ff3c2dee845b19030b00bdf8176fefc78b81762bd68515d3a62837b81972a778
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|