a git subcommand · one Python file

git merge-stack

Rebase stacks of branches that contain merges. You describe the whole stack in one line, and restack makes the graph match it, across every branch and every worktree, in one go.

origin/main > feat/search-index > feat/search-ranking ; origin/main > feat/plugin-api + feat/search-index > feat/search-plugin + feat/search-ranking > fix/search-plugin-ranking
cp git-merge-stack ~/bin/ && git merge-stack show
Python 3.11+, stdlib only. git finds it on your PATH.
the problem

Real stacks aren't a straight line

Stacking tools assume each branch sits on exactly one parent. Real work gets messier than that. Say a notes app is getting full-text search and a plugin API. The search plugin needs both, so it's built on the plugin API and merges in the search index. A fix to it needs the ranking work too, so it merges that in. Then the trunk moves, or a base branch gains a commit, and every branch above has to move with it, merges included.

origin/main feat/search-index 5 commits feat/plugin-api 14 commits feat/search-ranking 5 commits feat/search-plugin 11 commits fix/search-plugin-ranking 1 commit
stacked on (>)merged in (+)
Five branches, two of them built on merges. The line at the top describes exactly this.

With plain git

  • rebase --rebase-merges --update-refs works from one tip, so side branches get left behind.
  • Merges get re-resolved, and an evil merge's extra changes are easy to lose.
  • Each worktree's branch has to be moved by hand.
  • Nothing tells you a branch silently lost a commit.

With merge-stack

  • Write the stack you want as one line. Every branch in it moves.
  • Merges are replayed with their parents; extra changes in a merge are carried forward.
  • Every branch moves in its own worktree, files included.
  • A line that would drop a commit is refused before anything happens.
one restack

Five branches, four worktrees, one command

A week into that work, the stack has drifted in three ways at once:

  1. main gained autosave, so feat/search-index and feat/plugin-api sit on an old trunk commit. Autosave also edits the same save() the search index hooks into.
  2. feat/plugin-api gained a commit, "load plugins from the user's config dir", after feat/search-plugin was built on it. So feat/search-plugin is now on feat/plugin-api~1.
  3. The fix branch couldn't wait for it, so it cherry-picked its own copy of that commit under its merge. No ref points at the copy, so the only name for its base is a hash.

show reads all of that off the graph. 2e1f9f590a3e is the old trunk commit, and 4e9b8b4d3452 is the copy:

$ git merge-stack show --all 2e1f9f590a3e ├─ feat/search-index 5 commits │ └─ feat/search-ranking 5 commits └─ feat/plugin-api 14 commits └─ feat/search-plugin 11 commits on feat/plugin-api~1 + feat/search-index 4e9b8b4d3452 └─ fix/search-plugin-ranking 1 commit + feat/search-ranking 2e1f9f590a3e > feat/search-index > feat/search-ranking ; 2e1f9f590a3e > feat/plugin-api ; feat/plugin-api~1 + feat/search-index > feat/search-plugin ; 4e9b8b4d3452 + feat/search-ranking > fix/search-plugin-ranking

The fix is to say where each branch should be. Keep the line in a file, one chain per line:

# stack
origin/main > feat/search-index > feat/search-ranking
origin/main > feat/plugin-api + feat/search-index > feat/search-plugin
feat/search-plugin + feat/search-ranking > fix/search-plugin-ranking

Then run it. The search index's save hook conflicts with autosave, so it stops right there, in the worktree it was run from, the way git rebase does:

$ git merge-stack restack -f stack conflict: feat/search-index: 398a015dfe92 search: update the index when a note is saved: src/app.ts git-merge-stack: stopped at that commit in ~/src/notes-app; no ref was written yet. Fix the files there and `git add` them, then: git merge-stack restack --continue # or --abort to put everything back $ $EDITOR src/app.ts && git add src/app.ts $ git merge-stack restack --continue feat/search-index: 231847e474e7 -> 7f0aba147149 83856947aca1 -> e7c7ab548786 search: tokenize note text 398a015dfe92 -> ce55391f9ff7 search: update the index when a note is saved 64f9d3f5d0e5 -> 2806e9b240e8 search: persist the index next to the notes b0c5640c1302 -> 9d8e8289cdd9 search: match terms and phrases 231847e474e7 -> 7f0aba147149 search: register the index with the plugin registry feat/plugin-api: 788b83cc25c9 -> d71f17053cdb … 14 commits feat/search-plugin: ef9387f87194 -> 3ab871face44 9d35eef4f1ab -> 2b6a484d5be6 Merge feat/search-index into feat/search-plugin … 10 more feat/search-ranking: a95666bd7a39 -> 55534357f0f7 … 5 commits fix/search-plugin-ranking: 8f1d863bef82 -> 9ecf76054440 8f1d863bef82 -> 9ecf76054440 Merge feat/search-ranking into fix/search-plugin-ranking dropped, their patch is already in the new history: fix/search-plugin-ranking: 4e9b8b4d3452 plugin-api: load plugins from the user's config dir backups: refs/stack-backup/2026-03-14-161205/

All five branches moved, each in its own worktree. The cherry-picked copy was dropped because its patch is already in the new history, through feat/plugin-api. Afterwards show prints exactly the line from the file:

origin/main ├─ feat/search-index 5 commits │ └─ feat/search-ranking 5 commits └─ feat/plugin-api 14 commits └─ feat/search-plugin 11 commits + feat/search-index └─ fix/search-plugin-ranking 1 commit + feat/search-ranking
the line

Three operators

A line is chains separated by ;. Each chain is terms joined by >, and a term is a ref plus any refs merged into it with +. A name in two chains is the same branch. A name never written after > is a fixed point: it's read, never rewritten.

writemeans
a > bput b's own commits on top of a
a + b > cmerge b into a, and c starts from that merge (the merge is c's bottom commit)
x ; ytwo chains, restacked together
a~2 > bkeep b on an older commit of a
a > b:c^..d,e..fmake b exactly those runs of commits, dropping the rest

+ binds tighter than >. In a file, each line is a chain; end a line with an operator to carry the chain onto the next one.

commands

Show, restack, save

runto
git merge-stack showprint the stack you're on as a tree and a line, derived from the graph
git merge-stack restack -n -f stackhow the graph differs from the line, then what would move, every conflict, and any commit it would drop. Writes nothing; exits 0 only when they're in sync.
git merge-stack restack -f stackmake the graph match the line (or pass the line as an argument)
git merge-stack restackwith no line: put every branch in your stack onto the current tip of what it's stacked on
git merge-stack restack -ithe same, but open the line in your editor first
restack --continue · --abortafter a conflict: carry on, or put everything back as it was
restack --exec 'make test'run a check at every moved tip before any ref is written
git merge-stack save notes stackkeep the line in git, as a commit at refs/merge-stacks/notes; list shows what's saved
restack --stack notesrestack the saved line (or a new one), and save it once the graph matches
restack -n --stack notesafter a fetch or a restack: does the graph still match the saved line?
how it's safe

Nothing moves until everything works

In memory first

The whole stack is replayed with git merge-tree and git commit-tree. No worktree, index or ref is touched until the replay is done.

One transaction

Every ref moves in one update-ref transaction, guarded by the old values, with a backup of each under refs/stack-backup/.

Refuses to lose commits

A line that would drop a commit from a branch is refused up front, unless the commit's patch is already in the new history.

Merges keep their changes

A merge that changed more than a plain merge would have (an evil merge, or a conflict resolution) has that difference carried forward.

Conflicts, like rebase

It stops in your worktree with git's usual conflict state. Fix, git add, --continue. rerere is on, so a conflict you've resolved before comes back with your resolution filled in.

Every worktree

A branch checked out anywhere moves with reset --keep in its own worktree. Local changes in the way stop that branch, loudly.

Signed when you sign

With commit.gpgsign set, every commit it writes is signed. Commits it reuses keep their own signatures.

Upstream hints

Commits that look already upstream (same patch, a range-diff pair, or an empty replay) are listed with the line that would drop them. Only listed, never dropped.

install

One file on your PATH

curl -fLo ~/bin/git-merge-stack https://raw.githubusercontent.com/jb55/git-merge-stack/master/git-merge-stack
chmod +x ~/bin/git-merge-stack
git merge-stack show

It needs Python 3.11 or newer and nothing else. It shells out to the git CLI and uses git merge-tree --write-tree, with --merge-base, so it needs git 2.40 or newer. Use git merge-stack -h for help; --help is caught by git, which looks for a man page.