git rebase --update-refs: rebase stacked branches in one shot
Rebase a stack of branches at once with git rebase --update-refs. Keep every stacked PR branch pointer in sync, the config to make it default, and how to push safely after.
You split a big feature into three pull requests so reviewers don't drown. Branch part-1 sits on main. Branch part-2 sits on part-1. Branch part-3 sits on part-2. This is a stack. Each PR is small, each builds on the one below it, and reviewers can approve them one at a time.
Then a reviewer asks for a change in part-1. You check out part-1, fix it, and rebase to clean up the commit. The fix changes the SHAs on part-1.
Now part-2 is broken. It still points at the old commits of part-1 — the ones that no longer exist on the branch. So you check out part-2 and rebase it onto the new part-1. That fixes part-2, but changes its SHAs too. So part-3 is now broken. You check out part-3 and rebase it onto the new part-2.
Three branches. Three manual rebases. In the exact right order. Miss one and a branch silently keeps a stale base, and your next PR diff shows commits that were already reviewed.
There is a one-command version of this. Since Git 2.38 (released 2022-10-15), git rebase --update-refs does the whole chain for you. This post shows how it works, how to make it the default, and how to push the result without clobbering anyone.
This is not reordering commits
First, a distinction, because the two get confused.
Reordering commits rearranges commits inside one branch. You have five commits on one branch in an awkward order, and you shuffle the lines in a rebase TODO so they read better for review. One branch, new commit order.
--update-refs is about multiple branches. The commits keep their order. What changes is that several branch pointers — not just the one you are rebasing — get moved to follow the rewritten commits. Many branches, pointers kept in sync.
You can use both at once: reorder commits and update refs in the same rebase. But they solve different problems. If your question is "my commits are in the wrong order," read the reorder post. If your question is "I rebased the bottom of my stack and the branches above it are now stale," keep reading.
What stacked branches are, and why people use them
A stacked branch is a branch whose base is another feature branch instead of main.
The normal flow is: branch off main, do work, open a PR. One branch, one PR. That works until the feature is large. A 900-line PR is hard to review well. Reviewers skim it, miss things, and approve out of fatigue.
Stacking breaks the feature into a chain of small PRs:
main
└── feat/api-types (PR #1: add the types)
└── feat/api-impl (PR #2: implement against the types)
└── feat/api-tests (PR #3: test the implementation)
Each PR is small and reviewable on its own. PR #2 only shows the implementation diff, because PR #1 already established the types as its base. A reviewer reads them bottom to top, and each one makes sense.
The cost shows up when the bottom of the stack changes. Any rewrite of feat/api-types — a squash, a reword, an amended commit, a rebase onto a moved main — gives it new SHAs. Every branch above it still points at the old SHAs. Git does not automatically know that feat/api-impl was meant to follow feat/api-types; it only knows which commits each branch points at. So you fix the chain by hand, one branch at a time.
This is the exact problem --update-refs removes.
What git rebase --update-refs does
The git rebase docs describe the flag in one sentence:
Automatically force-update any branches that point to commits that are being rebased. Any branches that are checked out in a worktree are not updated in this way.
Read that carefully, because the precise behavior matters.
When you rebase the tip of your stack (the topmost branch, feat/api-tests), Git replays every commit from the bottom of the stack upward. As it replays, it watches for commits that have a branch pointing at them. When it finishes replaying such a commit, it moves that branch to the new commit. So the intermediate pointers — feat/api-types and feat/api-impl — slide forward onto the rewritten commits automatically.
The key points:
- You rebase one branch (the tip). Git updates all the intermediate branch pointers that sat on rebased commits.
- A branch only gets updated if it points at a commit that is part of the rebase. Branches outside the range are untouched.
- A branch that is checked out in another worktree is not moved. Git refuses to silently move a ref that another working tree is sitting on.
That last point is a safety rule, not a limitation. It stops the rebase from yanking the floor out from under a second working tree.
Here is the everyday version. You rebase the top of your stack onto an updated main:
git checkout feat/api-tests
git rebase --update-refs main
Git replays the whole stack onto the new main. When it is done, feat/api-types, feat/api-impl, and feat/api-tests all point at the correct rewritten commits. One command instead of three.
The update-ref lines in an interactive rebase
The mechanism is visible if you run an interactive rebase with the flag:
git rebase -i --update-refs main
Git inserts update-ref commands into the TODO list — one for each intermediate branch, placed right after the last commit on that branch. A realistic TODO for the stack above looks like this:
pick a1b2c3d feat: add request and response types
pick d4e5f6a feat: add error type enum
update-ref refs/heads/feat/api-types
pick 9f8e7d6 feat: implement the endpoint
pick 3c2b1a0 feat: wire up validation
update-ref refs/heads/feat/api-impl
pick 7e6d5c4 test: endpoint happy path
pick 2f1e0d9 test: validation failures
update-ref refs/heads/feat/api-tests
Read it top to bottom, oldest commit first, the same as any rebase TODO. Each update-ref refs/heads/<branch> line tells Git: "after replaying the commit above me, move this branch pointer here." The short form is u, the same way pick shortens to p.
You can edit these lines like any other TODO entry. Delete an update-ref line and that branch will not be moved. Add one (using the full ref path, refs/heads/<branch>) and Git will move that branch to that point in the replay. Most of the time you leave them exactly as Git generated them.
If you want the update-ref lines without typing --update-refs every time, set the config below.
Make it the default
The config key is rebase.updateRefs. The git-rebase docs state plainly:
If set to true enable
--update-refsoption by default.
Turn it on globally:
git config --global rebase.updateRefs true
Now every git rebase and git rebase -i behaves as if --update-refs were passed. The update-ref lines appear in your TODO automatically, and plain git rebase main keeps your whole stack in sync.
If you ever want to opt out for a single rebase after enabling the default, pass --no-update-refs.
There is a small thing to know with the default on: the update-ref lines appear for any branch in the rebase range, not only the ones you think of as "the stack." If you have a stray local branch pointing at a commit in the range, you will see an update-ref line for it. That is correct behavior — Git is telling you it would move that branch. If you do not want it moved, delete its line from the TODO.
Check your Git version
--update-refs needs Git 2.38 or newer. Check:
git --version
If you see git version 2.38.0 or higher, you have it. rebase.updateRefs as a config key arrived in the same release. On an older Git the flag is simply unknown and the rebase errors out, so there is no risk of it silently doing nothing.
To upgrade: brew upgrade git on macOS, your package manager on Linux, or the installer from git-scm.com. Git 2.38 shipped in October 2022, so most current installs already have it — but corporate machines and old CI images sometimes lag, which is worth a check before you build a workflow on this.
Pushing the stack after a rebase
The rebase rewrote SHAs on every branch in the stack. Your local branches are correct; the remote ones still hold the old commits. You need to update each remote branch, and because the history was rewritten, a plain git push will be rejected as non-fast-forward.
The unsafe answer is git push --force, which overwrites the remote no matter what is there — including a teammate's commit you never pulled. Do not use it on shared branches.
The safe answer is --force-with-lease. The git-push docs describe it:
Usually,
git pushrefuses to update a remote ref that is not an ancestor of the local ref used to overwrite it. This option overrides this restriction if the current value of the remote ref is the expected value.
In plain terms: --force-with-lease only overwrites the remote branch if the remote is still at the commit your local copy last saw for it (your remote-tracking ref). If someone pushed to that branch in the meantime, the value differs, and your push is refused. You force, but only over a remote you actually have an up-to-date view of.
Push each branch in the stack:
git push --force-with-lease origin feat/api-types feat/api-impl feat/api-tests
There is a sharper edge to --force-with-lease worth closing. The check compares against your remote-tracking ref, and certain background operations (some git fetch runs, IDE auto-fetch) can update that ref without you ever seeing the new commits. Then --force-with-lease thinks you are current and lets the overwrite through. --force-if-includes plugs that gap. The docs:
Force an update only if the tip of the remote-tracking ref has been integrated locally. This option enables a check that verifies if the tip of the remote-tracking ref is reachable from one of the "reflog" entries of the local branch being rewritten.
So --force-if-includes checks your reflog to confirm you actually had the remote's latest commit in your branch's history before rewriting — not just that your tracking ref happened to point at it. Use the two together:
git push --force-with-lease --force-if-includes origin feat/api-types feat/api-impl feat/api-tests
Better, make --force-if-includes automatic. The config key is push.useForceIfIncludes. The git-config docs state it is "equivalent to specifying --force-if-includes as an option to git-push in the command line":
git config --global push.useForceIfIncludes true
With that set, git push --force-with-lease quietly gets the --force-if-includes check too. Note one detail from the docs: --force-if-includes is a no-op unless --force-with-lease is also in play, so you still pass --force-with-lease on the command line.
After pushing, the PR for each branch updates to the rewritten commits, and the bases line up again. If your host shows a PR's base branch (GitHub does, under the branch dropdown), confirm each stacked PR still targets the branch below it and not main.
When not to use it
--update-refs is for branches you own and have not finished sharing. A few cases where it is the wrong move:
Shared branches. If a teammate has checked out feat/api-impl and committed on top of it, rebasing the stack rewrites the commits under their work. When they next pull, their branch and yours have diverged, and they have to untangle it. The rule from the Pro Git book holds: do not rewrite history that other people have pulled. Stacks are safest when one person owns the whole stack.
Already-merged PRs. If the bottom of your stack has already merged into main, do not keep treating it as part of the stack. Its commits are now in main's history. Rebase the remaining branches onto main directly. Including a merged branch in an --update-refs rebase can move a pointer you no longer need to touch.
Branches checked out in another worktree. As covered above, Git will not move these. If part of your stack is checked out in a second worktree, switch it away first or that pointer stays put while the others move — leaving the stack inconsistent.
Long-lived branches. Never rebase main, develop, or a release branch with --update-refs (or without it). Those are integration branches, not part of a personal stack.
For a personal stack of small PRs that only you are working on, none of these apply, and --update-refs is the cleanest tool there is.
Common myths
Myth 1: "--update-refs rewrites other people's branches." Wrong. It only moves branch pointers in your local repository, and only branches that point at commits inside the rebase you are running. It touches nothing on the remote until you push, and the push step is a separate, explicit git push --force-with-lease per branch. A teammate's branch on the server is not affected by your rebase; it changes only if you choose to force-push over it. The flag is local pointer bookkeeping, not a remote operation.
Myth 2: "You need a tool like Graphite to work with stacked branches." Wrong, though tools help. Stacked-branch tools (Graphite, git-branchless, git-stack, gt) add convenience — auto-restacking, PR creation, navigation between branches. But the core operation, keeping the stack's pointers in sync during a rebase, is built into Git itself since 2.38. You can run a clean stacked workflow with git rebase --update-refs and git push --force-with-lease and nothing else. Reach for a tool when the manual flow gets repetitive, not because plain Git can't do it.
Myth 3: "Rebasing a whole stack is too dangerous." Wrong on local branches. A rebase that hits a conflict pauses — it does not destroy. git rebase --abort returns every branch to exactly where it was before you started, pointers and all. And even after a finished rebase, the old commits live in git reflog for at least 30 days, so a stack you rebased badly is recoverable. The danger lives in force-pushing over shared branches, which is why --force-with-lease exists. The rebase itself, on branches only you have, is reversible.
What to read next
- Git rebase to reorder commits — the other rebase job: rearranging commits inside one branch, not syncing pointers across many. Read this if your commits are in the wrong order.
- git reflog: the undo button you didn't know you had — how to recover if a stack rebase goes sideways.
- Trunk-Based Development: when it wins and when it doesn't — stacking is one answer to "my PRs are too big"; short-lived branches on trunk is another.
- Choosing a Git workflow: a decision guide for real teams — where stacked PRs fit among the common team workflows.
- How to resolve merge conflicts in Git — rebasing a stack often pauses on a conflict; this covers how to resolve them, including the
ours/theirsswap that catches rebasers out. - The Interactive Rebase lesson opens a live terminal where you can run
git rebase -iand see the TODO list for yourself.
If you remember one thing: a stack of branches is just pointers, and --update-refs moves all of them at once when you rebase the tip. Turn on rebase.updateRefs, push each branch with --force-with-lease, and the three-rebase chore becomes one command.