Keeping a Stack of Pull Requests in Sync: One Rebase, One Fast-Forward
Split a big change into a stack of pull requests and moving master leaves every branch above it on a stale base. Here's the two-role workflow we use: git rebase --update-refs for whoever owns the stack, fast-forward-only merges for whoever merges it.
You have one big change, so you don’t open it as one big pull request. You split it into a stack: branch A off master, branch B off A, branch C off B, each one small enough that a reviewer can actually hold it in their head. It works right up until master moves, or you fix something low in the stack, and suddenly every branch above the change is sitting on a base that no longer exists at the tip. On Guardese, our newest project, that happened in the first week, and refreshing the branches one at a time got old fast.
Why we stack, and how it goes stale
Stacking is a review decision before it’s a Git one. Someone can give a fair reading to three small, focused pull requests in a way nobody can give to one two-thousand-line diff, so we trade for that clarity with a maintenance cost. The stack has to be kept current. The moment master gains a commit, the base A was cut from is no longer master’s tip, and the whole stack (some teams call it a chain of branches) has quietly drifted off the trunk.
That picture is the entire problem. Your work is stranded one commit back, and getting it current means replaying all three branches onto M2 without the stack coming apart along the way.
Two roles, kept apart
Doing that cleanly comes down to one rule: the two jobs never mix. One person owns the stack and is the only one allowed to rewrite it; one person merges and rewrites nothing. The owner refreshes the branches, the merger slides them onto master. Everything below is just those two jobs, done in that order.
If the stack is yours: one rebase moves everything
Don’t rebase the branches one by one. Go to the branch at the very tip and rebase the whole stack in a single command:
git checkout C
git rebase --update-refs master
That replays A, B and C onto master’s current tip. The flag is the part that matters. A plain git rebase master would move your commits but leave the A and B branch labels pointing at the old commits it just replaced, so your middle branches would silently drop off the stack. --update-refs carries those branch pointers forward to the replayed commits, which is the one thing that lets the stack travel as a unit.
From there it’s cleanup. Conflicts are yours to resolve, because you understand your own commits better than a merger ever could; work through them as the rebase stops on each one. Then, since a rebase gives every commit a new hash, the copies on the remote no longer match and you have to force-push. Use the lease so you can’t overwrite something a teammate pushed while you were rebasing:
git push --force-with-lease origin A B C
That updates all three branches in one push. If this is a regular part of your week, git config --global rebase.updateRefs true makes the flag the default so you can stop typing it.
If you’re merging: only ever fast-forward
The merge side is deliberately boring, and it has exactly one rule: fast-forward only, never a merge commit. When a branch already contains master’s tip in its history, merging is nothing more than sliding master’s pointer up to the branch. Nothing gets created, nothing gets rewritten, and the trunk stays a straight line you can read top to bottom.
Merge commits aren’t a mistake, and they earn their place elsewhere: they record that a group of commits arrived together, and they let you revert the whole group as one. On a trunk we want to read as one linear history, that record is just noise, and the stack already gives us the grouping in its own branches.
In the pull request, pick the Fast forward strategy instead of merge or squash. On the command line it’s the same as:
git checkout master
git merge --ff-only C
Merge the pull request at the bottom of the stack first, the one targeting master, then work your way up as each one lands.
A refused fast-forward isn’t yours to force. It means the branch has fallen behind master and the two have diverged, the right-hand picture above. Hand it back to the owner, who runs the same git rebase --update-refs master to bring the stack onto the current tip, and the fast-forward goes through on the next try.
What the rebase is really moving
If the rebase still feels like sleight of hand, here’s all it does: it replays a run of commits onto a new base, one at a time, minting a fresh hash for each. Everything before that base is left untouched. --update-refs adds one rule on top, which is that any branch label sitting on a commit being replayed rides along to the replayed copy. That single addition is the whole reason a stack can move in one command instead of coming apart.
Once that clicks, git rebase --onto is the same idea with the guesswork taken out. A plain rebase decides for itself where your branch forked from its base. --onto lets you name it outright. The form is git rebase --onto <new-base> <old-base> [<until>]: replay the commits after <old-base>, up to <until>, onto <new-base>. Say B was built on A, but A turned out to be a dead end and you want B sitting straight on master. git rebase --onto master A lifts exactly the commits that came after A and drops them onto master, leaving A out of the result entirely, with no interactive rebase and no cherry-picking by hand.
The point of all this
What the discipline buys you is a master branch that only ever moves forward in a straight line: no merge bubbles, no rewritten history on the trunk, and a stack you bring current with one command instead of rebasing each branch by hand. The part worth taking to your own team isn’t the exact commands; it’s the split of responsibilities where one person rewrites and one person only fast-forwards. We keep notes like this as we hit them building our Atlassian apps, mostly so the next person doesn’t have to rediscover the flag. Get the two roles right, and your history stops being something you fight and becomes something you can read.