Skip to content

Restack, pull, prune

Three commands keep a stack current. restack replays commits so each branch’s contents match the structure g2g records. pull fetches, advances the base and replays, which is what you want after the trunk moves. prune forgets branches whose work has already landed. Each previews first and acts only with --apply.

They work on the stack as g2g’s own graph records it, and need no Graphite. Their defaults for --scope differ because the commands differ — restack defaults to subtree, pull offers only stack and trunk, and prune defaults to stack and offers all; see Scope for why.

g2g restack replays a stack’s commits so its contents match its recorded structure. This is what a squash merge upstream breaks: the child keeps its parent’s pre-squash commits, so its pull request shows the parent’s changes a second time and merging it reapplies work the trunk already has.

Terminal window
# Preview. Says exactly what will be replayed and whether it will conflict.
g2g restack --branch main --scope stack
# Replay.
g2g restack --branch main --scope stack --apply
# Move a fragment onto a different base instead of its recorded parent.
g2g restack --branch feature/login --scope subtree --onto main --apply
# Resume verbs, with the same meanings they have in git rebase.
g2g restack --continue
g2g restack --abort
g2g restack --skip

By default it replays the branch you are on and what depends on it. It does not replay what sits below you uninvited, because rewriting is not free: a conflict below you may be one you are deliberately deferring, and replaying it anyway is how restacking from the middle walks into it every time.

A clean replay never touches your working tree

Section titled “A clean replay never touches your working tree”

The preview knows in advance whether the rewrite applies, and says so. Here login needs a restack because auth moved underneath it:

Target auth · current Git branch
● auth ← target
● login needs restack
● session
Replays login and session onto auth.
Applies without touching your working tree or checked-out branch.
No changes were made. Rerun with --apply to replay these commits.

With --apply it revalidates, prints the plan again, and replays:

Ready to apply
Target auth · current Git branch
● auth ← target
● login needs restack
● session
Replays login and session onto auth.
Applies without touching your working tree or checked-out branch.
Replayed.
Branch contents now match the recorded structure.
Suggested next step: g2g push

When the replay cannot apply cleanly, the preview says This will not apply cleanly before you apply. Rebasing then happens in your own working tree, because resolving a conflict needs a tree you can edit with your own tools, and it stops on the conflict for you. Resolve the conflict, git add the files, and run g2g restack --continue.

Using git rebase --continue or git rebase --abort yourself is fine too: --continue re-derives what is left from the refs rather than replaying a stored queue, so your own git commands simply change what remains to do. g2g restack --abort restores every branch to where it started, including ones an earlier step already moved. --skip abandons the commit the restack stopped on.

This is g2g’s only resumable operation, so every other command that changes anything refuses while a restack is unfinished: mid-restack a branch may already have moved while the graph still records where it used to be.

  • A branch it empties. If everything a branch carried is already upstream, it collapses onto its base and its pull request would show no changes.
  • Commits the parent dropped. They are dropped from the child too, by default. Where every one of them was genuinely removed rather than rewritten, --absorb keeps them as the child’s own instead — which rewrites nothing and only re-records where the branch forks.
  • A branch you rebased by hand. Its recorded fork point is no longer in its history, so the replay range would silently widen to include the base’s own commits. Re-record it with g2g track first.
  • A branch another worktree has checked out, because that worktree would be left describing a commit its branch no longer points at. Switch that worktree away or close it, or select less with --branch or --scope.
  • A branch Graphite describes and g2g does not. A restack needs a fork point, which only g2g’s own store records, so it says to g2g track the branch first. See Where structure comes from.

See design-docs/restack.md for how the replay range is chosen, the two engines, and the resumable state.

Terminal window
# Fetch, fast-forward the base, replay the stack.
g2g pull
g2g pull --apply
# The same, then forget the branches whose work has landed.
g2g pull --prune --apply

This is git switch main && git pull && git switch back && restack in one command.

The fetch writes only into refs/g2g/remotes/, so your own remote-tracking refs, FETCH_HEAD, and ahead/behind counts are untouched. The base is fast-forwarded or not at all: a base that has diverged is reported, never merged or reset, because “you are behind” and “you have diverged” want different responses and only you can give the second.

If the replay stops on a conflict part-way, what replayed stays replayed and pull exits 3; see Exit status.

On its own pull does not forget anything. Pruning is g2g prune, a separate command, because it answers a different question on the same boundary and edits the recorded graph rather than moving branches. --prune runs the two in order over the same selection, since after a squash merge upstream the usual thing to want is both.

The preview says it will prune without saying what: what has landed is only known once the base has moved, and the base does not move in a preview. If the prune then refuses, the pull has already happened and stays happened, so the command stops part-way and exits 3. --json and --porcelain refuse --prune, because it produces two reports and those formats are one document.

When a branch and its published version have each moved, pull refuses rather than choosing. --take published is the way through, and it is the one path where pull loses work that exists nowhere else — so the preview names every commit it would discard.

Terminal window
g2g pull --take published # the whole stack
g2g pull --take published --through synthetic-fix # and no further

It only ever changes the outcome for a branch that has genuinely diverged. A branch that is merely ahead of its published version is push’s business and is left alone; one that is behind, or whose published version supersedes it, is brought down either way.

That makes --take published all or nothing, and --through narrows it. With two diverged branches the unbounded form takes both — discarding local work on the upper one alongside the lower one you meant. --through stops at the branch you name and refuses the rest, because a boundary says where you have decided, not that you have decided everywhere. Above the boundary your commits are kept, and replayed onto what was taken below — which is what pull does anyway.

The boundary is the branch you name and what it is stacked on, because a branch’s published version is built on its parent’s, so taking one and not the other describes a stack that never existed. A sibling on another fork is outside it; the trunk is inside any boundary, and naming the trunk takes it and nothing else.

published is a side, not a place. It means the branch as the remote holds it: the version pull just fetched from the remote --remote names — one of the names git remote lists, origin unless you say otherwise. It never means GitHub, and it does not ask gh anything; a pull request is not the published version of a branch, the remote’s ref is. When the remote is not origin, say so on both runs, because the command pull suggests carries it:

Terminal window
g2g pull --remote upstream # refuses: both sides moved
g2g pull --remote upstream --take published --apply
Terminal window
g2g prune
g2g prune --apply

Pruning forgets a landed branch in the recorded graph, asking Git by content — a squash merge included — whether its work is already in the trunk. It never deletes a branch: that is a separate, deliberate act, not the tail of another command.

Forgetting a branch can leave a child recorded under something that is no longer there. Where Git shows the branch below is an ancestor of that child — which is what a pull leaves, having replayed the child onto it — prune records the child there, with the same check and the same fork point track would use, because that is where the child already sits.

Where Git does not show it, prune refuses rather than reparenting around it: a trunk advanced by hand with the stack not replayed, say, or a child outside the selection. The refusal offers g2g pull --prune first, which replays the child and then prunes, and then a g2g track --branch <child> --parent <branch> for each child, to record it by hand.