Syed Hasibur Rahman16 min read
Stacked Branches Without the Cascade: --update-refs, --onto, rerere, and the Force-Push That Isn't Safe
Squash-merging the bottom of a stack detaches everything above it: the merged commits are in main under different SHAs, so git rebase main dutifully replays them and hands you conflicts on code that already shipped. This post works the whole problem through one concrete two-developer scenario, with every transcript taken from a real repository rather than written from memory — the --onto range surgery that skips already-merged work, what --update-refs moves (and the ref it pointedly does not), the two conditions rerere silently requires before it replays anything, and a reproduction in which --force-with-lease destroys a colleague's commit after nothing more sinister than a git fetch. Then GitHub's server-side stacks, and the repository policy that makes the whole class of problem smaller.
The message came in on a Thursday afternoon, and it is the same message every time: “I can’t rebase B3. Git says there are conflicts in files I never touched.”
The situation behind it was ordinary. Two developers had branched from the same commit on main. One of them had five stacked pull requests open; so did the other. The first two of Dev 2’s PRs had been reviewed, approved, and squash-merged — the normal, encouraged, branch-protected path. Then Dev 1’s five PRs landed on top. Now Dev 2 wanted to merge the third layer of his own stack, and git was reporting conflicts in a file whose changes were, demonstrably, already in main.
Nothing was broken. Git was doing exactly what it was asked. The problem is that “rebase my branch onto main” is not what Dev 2 meant, and the gap between the command and the intent is where an afternoon goes.
This post works that scenario through end to end. The organizing claim is that cascade pain has two distinct halves that get conflated: a mechanical half, which modern git solves almost completely with --onto and --update-refs, and a safety half, which it does not solve, and where the tool most people reach for — --force-with-lease — protects less than its name suggests. I will show the second half destroying a commit, because I could not find a write-up that demonstrates it rather than asserting it.
Every transcript below is real output from git 2.47.3, captured from a scratch repository built to match the scenario. Where a claim comes from documentation rather than from a run, I say so.
The scenario
Two developers, one base commit A, five stacked PRs each:
main: ... ── A
Dev 1: A ── A1 ── A2 ── A3 ── A4 ── A5
Dev 2: A ── B1 ── B2 ── B3 ── B4 ── B5
Standard branch protection: no direct commits to main, every change lands via pull request, every PR needs review and green CI. Then, in order: Dev 2’s B1 and B2 are squash-merged; Dev 1 rebases his stack and lands all five; Dev 2 comes back to merge B3.
Here is the state Dev 2 walks into, printed from the real repository:
* 20cdb14 (origin/main, main) A5: dev1 change (#205)
* f57679f A4: dev1 change (#204)
* 2ee6275 A3: dev1 change (#203)
* 47cda86 A2: dev1 change (#202)
* 5ea5ab8 A1: dev1 change (#201)
* 1309742 B1+B2: zakat schema (#101)
| * 297362c (origin/B5, B5) B5
| * e9414fe (origin/B4, B4) B4
| * 8413cbe (origin/B3, B3) B3
| * cc459a2 (origin/B2, B2) B2
| * b58fdfa (origin/B1, B1) B1
|/
* fbb20d2 A
Read the two branches of that graph carefully, because the whole problem is visible in it. 1309742 is B1 and B2, merged, in main. b58fdfa and cc459a2 are also B1 and B2 — the originals, still sitting under Dev 2’s stack, with different SHAs and no ancestry relationship to the commit that actually shipped.
That is what squash-merging does. It takes the content of a branch and commits it as one new object. The original commits are not ancestors of main; they are not in main at all. Nothing points at them except Dev 2’s stale branches.
Why the obvious command fails
$ git switch B5
$ git rebase main
Rebasing (1/5)Auto-merging model.py
CONFLICT (content): Merge conflict in model.py
error: could not apply b58fdfa... B1
Note the commit it stopped on: b58fdfa, which is B1 — the first commit of the stack, whose content is already in main. Git was told to replay everything from the merge-base up to B5, and B1 is in that range, so git replayed it. The conflict looks like this:
line1
line2
line3
B1
<<<<<<< HEAD
B2
A1
A2
A3
A4
A5
=======
>>>>>>> b58fdfa (B1)
Git is asking Dev 2 to reconcile B1 against a main that already contains B1. There is no correct answer to that question, which is why the experience is so disorienting: every resolution feels wrong because the question is malformed. And this is only conflict one of five. The same thing happens for B2, and then the real conflicts against Dev 1’s work start.
This is the entire “cascade rebase” problem. It is not that rebasing is hard. It is that the default range is wrong.
--onto: replay the range you meant
git rebase takes three logical arguments, and the two-argument form most people use hides one of them:
git rebase --onto <new-base> <old-base-exclusive> <branch>
Read it as: take the commits after <old-base-exclusive> up to <branch>, and replay them on <new-base>. The plain git rebase main form computes <old-base-exclusive> as the merge-base — which, after a squash merge, is the original commit A, sweeping B1 and B2 into the range.
Dev 2 does not want the range starting at A. He wants it starting at B2:
git fetch origin
git switch B5
git rebase --onto origin/main B2 B5 --update-refs
B2 here is the local branch still pointing at the pre-merge commit. If it has already been deleted, any reference to that commit works — a SHA from git reflog, or git log --oneline B3~1.
The difference is total:
| Command | Replays | Outcome |
|---|---|---|
git rebase origin/main | B1, B2, B3, B4, B5 | Two malformed conflicts against already-merged code, then the real ones |
git rebase --onto origin/main B2 B5 | B3, B4, B5 | Only genuine conflicts surface |
Here is the run:
$ git rebase --onto origin/main B2 B5 --update-refs
Rebasing (1/5)Auto-merging model.py
CONFLICT (content): Merge conflict in model.py
error: could not apply 8413cbe... B3
The stop is on 8413cbe — B3, real work, genuinely conflicting with Dev 1’s changes. B1 and B2 were never replayed. And the conflict is now a question with an answer:
line1
line2
line3
B1
B2
<<<<<<< HEAD
A1
A2
A3
A4
A5
=======
B3
>>>>>>> 8413cbe (B3)
Both sides are real. Dev 2 keeps both, continues, and the rebase finishes:
[detached HEAD 999195f] B3
Rebasing (2/5)Rebasing (3/5)Rebasing (4/5)Rebasing (5/5)
Successfully rebased and updated refs/heads/B5.
Updated the following refs with --update-refs:
refs/heads/B3
refs/heads/B4
One conflict, resolved once, for a stack that the naive approach would have dragged through five.
A detail that confuses people: the counter says
(1/5)through(5/5)even though only three commits are being replayed. It counts todo-list commands, not commits, and the list contains threepicklines plus twoupdate-reflines. Five commands, three commits. Nobody is secretly replaying B1 and B2.
--update-refs: what actually moves
A normal rebase moves exactly one ref: the branch you are on. Every other branch pointing at a commit inside the replayed range is left behind, pointing at commits that are now orphaned. That is the mechanical source of the cascade — you rebase B5 and discover B3 and B4 still point into the old timeline, so you rebase each of them too.
--update-refs, added in git 2.38, moves them along with the commits.
git config --global rebase.updateRefs true # make it the default
The clearest way to see what will move is the interactive form, which writes the refs directly into the todo list. This is real output:
pick 8413cbe B3
update-ref refs/heads/B3
pick e9414fe B4
update-ref refs/heads/B4
pick 297362c B5
# Rebase cc459a2..297362c onto 20cdb14 (5 commands)
There is no update-ref line for B5, and there should not be. B5 is the branch being rebased; it moves as HEAD by the ordinary mechanism. --update-refs handles the intermediate refs — the ones a plain rebase abandons. This is worth stating explicitly because it is the single most common misreading of the feature, and because a todo list showing update-ref refs/heads/B5 after the final pick is not something git produces.
Because the refs are lines in an editable file, you can do things that would otherwise take several rebases: reorder commits, squash a fixup into a lower layer, or move an update-ref line to shift where a branch boundary sits. One edit, one rebase.
A footgun in the global setting.
rebase.updateRefs truemoves every ref in the range, including ones you forgot were there — an oldwip/branch, a bisect leftover, a colleague’s branch you checked out locally last week. It is usually what you want, and it is occasionally a surprise.git rebase --no-update-refsopts out for one invocation, and the interactive todo list will always tell you the truth before you commit to it.
rerere: helpful, and narrower than advertised
rerere — reuse recorded resolution — records the conflict hunk and your resolution, then replays it if the identical conflict appears again.
git config --global rerere.enabled true
git config --global rerere.autoupdate true
During the rebase above, resolving the B3 conflict produced:
Recorded resolution for 'model.py'.
Discarding that rebase and running the identical one again, the conflict was resolved automatically: model.py came back with no conflict markers, already staged, and git rerere status reported nothing pending.
That is the good case. Two limits matter more than the feature description suggests, and I found both by running it rather than reading about it.
It matches conflict hunks byte-for-byte. After landing one more commit on main — changing the surrounding context but not the substance of the conflict — the recorded resolution did not apply. Git recorded a fresh preimage and asked again. This is correct behaviour, not a bug: a resolution recorded against different surrounding text is not obviously valid. But it means the common pitch for rerere — rebase repeatedly during a long review cycle and never re-resolve — holds only while upstream sits still. In an active repository, upstream does not sit still, which is the whole reason you are rebasing.
It does not make the rebase unattended. Even with rerere.autoupdate staging the resolved file, the rebase still halts and waits for git rebase --continue. rerere removes the thinking, not the stop.
Useful when a recording goes bad:
git rerere status # conflicts currently pending
git rerere diff # what the recorded resolution changes
git rerere forget path/to/file # discard a bad recording
rerere forget is the one to remember. A resolution recorded wrongly is worse than no resolution, because it will be applied silently.
--force-with-lease is not the safety net you think
Rebasing rewrites history, so the remote branch must be force-updated. Everyone knows plain --force is dangerous and --force-with-lease is the safe version. The usual explanation is that it “refuses if the remote branch moved since your last fetch.”
That explanation is wrong in a way that costs people commits, and it is easy to demonstrate.
A teammate pushes a hotfix to B4. Dev 2, who has rewritten B4 locally, tries to push:
$ git push --force-with-lease origin B4
! [rejected] B4 -> B4 (stale info)
The lease works. Now Dev 2 does the most natural thing in the world — checks whether anything changed upstream — and tries again:
$ git fetch origin
$ git push --force-with-lease origin B4
+ 23c9190...6323aef B4 -> B4 (forced update)
The push succeeded. The teammate’s commit is gone.
Nothing exotic happened. --force-with-lease compares the remote against your remote-tracking ref, refs/remotes/origin/B4 — not against what you have actually seen or integrated. A bare git fetch updates that ref. The lease is therefore re-armed against the new value, and the safety check passes while asserting something Dev 2 never verified: that he knows what is on B4. He does not. He fetched it and never looked.
Any background git fetch does this. So does an IDE that fetches on a timer, and so does a shell prompt that refreshes upstream status.
The fix is --force-if-includes, added in git 2.30, which additionally requires that the remote tip is actually reachable from your local branch — that you have integrated it, not merely downloaded it. Same setup, same bare fetch, both flags:
$ git push --force-with-lease --force-if-includes origin B4
hint: Updates were rejected because the tip of the remote-tracking branch has
hint: been updated since the last checkout. If you want to integrate the
hint: remote changes, use 'git pull' before pushing again.
Rejected, correctly. So the alias worth having is not the usual one:
git config --global alias.pushf 'push --force-with-lease --force-if-includes'
Use git pushf origin B3 B4 B5 and the whole rewritten stack goes up under a check that means what people already believe --force-with-lease means.
One caveat in the other direction: --force-if-includes needs a reflog to reason about, so it is inert on a fresh shallow clone with no local history — CI runners, mostly. That is a reason to be careful about force-pushing from CI, not a reason to drop the flag.
Recovery: nothing is lost until gc
Every pre-rebase branch tip stays in the reflog, by default for at least 30 days.
git reflog show B4
# 1a2b3c4 B4@{0}: rebase (finish): refs/heads/B4 onto ...
# 9d8e7f6 B4@{1}: commit: add calculation model
git branch B4-backup 9d8e7f6 # park the old tip somewhere safe
git reset --hard B4@{1} # or move the branch straight back
Mid-rebase, git rebase --abort returns everything — including refs that --update-refs had already moved — to the state before the rebase started. I verified this against the scenario above: abort put B3, B4 and B5 back at their original commits.
The one thing the reflog cannot recover is a commit that only ever existed on the remote — the teammate’s hotfix in the previous section. It was never in Dev 2’s reflog, because Dev 2 never had it. It is recoverable from the teammate’s machine, or from GitHub’s own reflog via support, and from nowhere else. This asymmetry is the reason the force-push section matters more than the rebase sections.
GitHub’s native stacks
GitHub moved stacked pull requests into public preview on 30 July 2026, for all repositories.1 The server maintains the stack: when a lower PR merges, GitHub rebases and retargets the layers above it, and existing branch protection, CODEOWNERS, required checks and merge queues apply to every layer.2
gh extension install github/gh-stack
The extension requires gh 2.0 or later.3 Building Dev 2’s stack:
git switch main && git pull
git switch -c B1 # edit, commit
gh stack init # marks B1 as the bottom of a new stack
git switch -c B2 # edit, commit
gh stack add # B2 becomes the next layer
# ... B3, B4, B5 the same way
gh stack submit # push all branches, open/update all PRs
Beyond those three, the documented command set includes gh stack view, checkout, modify, sync, rebase, push, merge, link, unstack, and navigation helpers (up, down, top, bottom, trunk).3 Two are directly relevant here: gh stack rebase pulls from the remote and runs a cascading rebase across the stack, and gh stack sync fetches, rebases, pushes and syncs PR state in one command. If you adopt stacks natively, reach for those before hand-rolling the --onto incantation.
Behaviour worth knowing before you plan around it, all from the documentation rather than from my own testing:
- Same-repository only. Cross-fork stacks are not supported.4
- Checks run per layer, evaluated against the stack’s base. Workflows trigger as if each PR targets the base of the stack rather than the layer below it — so a five-layer stack runs your
main-targeted suite five times.4 - Merge queues are supported, and integrate properly: the whole stack is queued in order, and ejecting one PR removes every layer above it. A stack too large for the merge group’s size buffer is split across consecutive groups.4
- Merging the top layer lands everything below it as one atomic operation, by merge commit, squash, or rebase — and only if every layer below satisfies its own protection requirements.4
The part native stacks do not solve
Our scenario is not an intra-stack problem. Dev 1’s five PRs landing between B2 and B3 is a cross-stack interaction, and no amount of server-side bookkeeping resolves a semantic conflict between two people’s code. GitHub rebases the layers; if an A-commit and B3 edit the same lines, a human still decides what the result should say.
So the local skills above do not become obsolete. They become the thing you do when gh stack sync reports a conflict:
git fetch origin
git switch B5
git rebase --onto origin/main B2 B5 --update-refs
git push --force-with-lease --force-if-includes origin B3 B4 B5
The stack metadata survives the force-push.
Because this is a preview, treat gh stack --help as more authoritative than any blog post, including this one, and expect subcommands to move.
Policy that shrinks the problem
Every technique above is remediation. The cascade is cheaper to prevent.
| Setting | Why |
|---|---|
| Merge queue enabled | The queue builds each PR on top of what is already queued, so CI is green against the real future main without authors rebasing to catch up. This removes most of the reason to rebase mid-review. |
| Require linear history, squash or rebase merges only | Guarantees the straight line on main regardless of how messy feature branches get. |
| Require status checks via the queue, not “branch must be up to date” | The up-to-date requirement is what forces the serialized rebase treadmill on every author whenever anything lands. |
| Short stacks, merged bottom-up, eagerly | Merge B1 the moment it is green. Deep long-lived stacks are the root cause; every day a layer stays open is another chance for someone else’s work to land underneath it. |
| Cut layers at interface boundaries | Schema → model → endpoint. Two developers’ stacks rarely collide at the same depth when the layers are real seams rather than arbitrary commit-size chunks. |
The last two are the ones that actually pay. A five-deep stack open for a week will generate this problem no matter how good your git is.
Cheat sheet
# one-time setup
git config --global rebase.updateRefs true
git config --global rerere.enabled true
git config --global rerere.autoupdate true
git config --global alias.pushf 'push --force-with-lease --force-if-includes'
# lower layers were REBASE-merged (their commits are on main)
git switch <top>
git rebase --update-refs origin/main
# lower layers were SQUASH-merged (their commits are NOT on main)
git switch <top>
git rebase --onto origin/main <old-base> <top> --update-refs
# see exactly which refs will move, before committing to it
git rebase -i --onto origin/main <old-base> <top> --update-refs
# push the whole rewritten stack, safely
git pushf origin <b3> <b4> <b5>
# undo
git rebase --abort # mid-rebase; restores moved refs too
git reflog show <branch> # find the old tip
git reset --hard <branch>@{1}
# GitHub native stacks
gh extension install github/gh-stack
gh stack init | add | submit
gh stack sync # fetch, rebase, push, sync PR state
What to take away
Three things, in ascending order of how much they cost when ignored.
The default rebase range is wrong after a squash merge, and --onto fixes it by stating the range explicitly. This costs an afternoon.
--update-refs moves the intermediate branches so a stack rebases once instead of once per layer. This costs an afternoon per rebase, repeatedly, which is worse.
--force-with-lease does not mean what its name implies. It checks your remote-tracking ref, which a background fetch silently refreshes, and it will let you overwrite a commit you have never looked at. Add --force-if-includes. This one costs somebody else’s work, and unlike everything else in this post, it is not in your reflog to recover.
Further reading: git help rebase (the --onto and --update-refs sections), git help rerere, and git help push for --force-with-lease and --force-if-includes.
GitHub Changelog, “Stacked pull requests are now in public preview,” 30 July 2026. https://github.blog/changelog/2026-07-30-stacked-pull-requests-are-now-in-public-preview/ ↩︎
GitHub Docs, “About stacked pull requests.” https://docs.github.com/en/pull-requests/get-started/about-stacked-prs ↩︎
GitHub Docs, “Stacked pull requests CLI commands.” https://docs.github.com/en/pull-requests/reference/stacked-prs-cli-commands ↩︎ ↩︎
GitHub Docs, “Stacked pull requests” (reference). https://docs.github.com/en/pull-requests/reference/stacked-pull-requests ↩︎ ↩︎ ↩︎ ↩︎