update-refs and keep-base
update-refs and keep-base
Thesis
A plain rebase moves one branch name: the one you are on. Real work often comes as a stack: feat/api ← feat/ui ← feat/docs, each reviewed as its own pull request. If you rebase the top of the stack, every branch name under it is left pointing at the old commits. Two flags fix this:
--update-refsforce-moves every local branch that points into the rebased range, so the whole stack moves together.--keep-baserewrites commits in place, on the same merge base. You can tidy a stack (fixups, rewording) without also pulling in whatever landed onmainovernight.
Use them together and you can edit any commit in the stack, from the top branch, in one command.
--update-refs arrived in Git 2.38 and --keep-base in Git 2.24. The transcripts on this page come from Git 2.47. The behaviour is the same in current releases (2.56 as of October 2026).
Mental model
before: a stack of three branches (api, ui, docs = feat/*); main has moved on to B ── M
B ── A1 ── A2 ── U1 ── U2 ── D1
▲ ▲ ▲ ▲
base api ui docs
plain `git rebase main`: only HEAD's branch moves
B ── M ── A1' ── A2' ── U1' ── U2' ── D1' ← feat/docs
└── A1 ── A2 ── U1 ── U2 ← feat/api, feat/ui left behind
`git rebase --update-refs main`: every branch inside the range moves
B ── M ── A1' ── A2' ── U1' ── U2' ── D1'
▲ ▲ ▲
api ui docs
`git rebase -i --keep-base --update-refs main`: rewrite in place on B
B ── A1' ── A2' ── U1' ── U2' ── D1' (M stays off to the side)
| Piece | What it does |
|---|---|
--update-refs |
Adds an update-ref refs/heads/<name> line to the todo list after the last commit of each branch inside the range. The refs move only when the rebase finishes, so --abort leaves them alone |
rebase.updateRefs=true |
Makes --update-refs the default. Turn it off for one run with --no-update-refs |
--keep-base <upstream> |
New base = merge-base(<upstream>, HEAD). Same as --onto <upstream>...HEAD <upstream>, and also implies --reapply-cherry-picks and --no-fork-point |
| Branches checked out in another worktree | Never updated. The todo shows a # Ref … checked out at … comment instead |
| Refs outside the rebased range | Untouched. Rebase from the top of the stack, or descendants get left behind |
--keep-base cannot be combined with --onto or --root. It is a way of choosing the onto point, so those flags would conflict with it.
Worked examples (Cases)
Every case starts from the same throwaway repository. Source the environment file so your commit hashes match the transcripts.
# stack-env.sh — source this so your hashes match the transcripts on this page
export GIT_AUTHOR_NAME="Dev" GIT_AUTHOR_EMAIL="dev@example.com"
export GIT_COMMITTER_NAME="Dev" GIT_COMMITTER_EMAIL="dev@example.com"
export GIT_AUTHOR_DATE="2026-10-02T10:00:00+05:30"
export GIT_COMMITTER_DATE="2026-10-02T10:00:00+05:30"#!/usr/bin/env bash
# setup-stack.sh — build a three-branch stack on top of main, then move main
set -euo pipefail
export GIT_AUTHOR_NAME="Dev" GIT_AUTHOR_EMAIL="dev@example.com"
export GIT_COMMITTER_NAME="Dev" GIT_COMMITTER_EMAIL="dev@example.com"
export GIT_AUTHOR_DATE="2026-10-01T09:00:00+05:30"
export GIT_COMMITTER_DATE="$GIT_AUTHOR_DATE"
git init -q -b main "${1:-stack}" && cd "${1:-stack}"
c() { echo "$2" >> "$1"; git add "$1"; git commit -q -m "$3"; }
c README.md "# shop" "init: README"
c lib.sh "price() { echo 100; }" "main: add lib.sh"
git switch -q -c feat/api # bottom of the stack
echo "timeout=3" > api.conf && git add api.conf
c api.sh "get_price() { price; }" "api: add get_price"
c api.sh "list_items() { :; }" "api: add list_items"
git switch -q -c feat/ui # middle: builds on feat/api
c ui.sh "render() { get_price; }" "ui: render price"
c ui.sh "render_list() { list_items; }" "ui: render list"
git switch -q -c feat/docs # top: builds on feat/ui
c docs.md "Prices come from get_price." "docs: describe pricing"
git switch -q main # meanwhile, main moves on
c lib.sh "tax() { echo 18; }" "main: add tax()"
git switch -q feat/docsmkdir -p /tmp/vcs && cd /tmp/vcs # save both files here first
chmod +x setup-stack.sh
source ./stack-env.sh
./setup-stack.sh && cd stack
git log --graph --oneline --decorate --all* 87455ba (HEAD -> feat/docs) docs: describe pricing
* 576a711 (feat/ui) ui: render list
* 42dfa4c ui: render price
* 87584b9 (feat/api) api: add list_items
* f2f0cff api: add get_price
| * c6a0e25 (main) main: add tax()
|/
* 6b188b4 main: add lib.sh
* 2a401c6 init: README
To reset between cases, run cd /tmp/vcs && rm -rf stack && ./setup-stack.sh && cd stack.
Case 1: The stale stack (what goes wrong without the flag)
git rebase main
git log --graph --oneline --decorate --all
git log --oneline main..feat/uiSuccessfully rebased and updated refs/heads/feat/docs.
* 1861481 (HEAD -> feat/docs) docs: describe pricing
* f0527d0 ui: render list
* 55affa4 ui: render price
* 49a9b11 api: add list_items
* 58ab280 api: add get_price
* c6a0e25 (main) main: add tax()
| * 576a711 (feat/ui) ui: render list
| * 42dfa4c ui: render price
| * 87584b9 (feat/api) api: add list_items
| * f2f0cff api: add get_price
|/
* 6b188b4 main: add lib.sh
* 2a401c6 init: README
576a711 ui: render list
42dfa4c ui: render price
87584b9 api: add list_items
f2f0cff api: add get_price
feat/docs moved and the other two did not. The repository now holds two copies of the api and ui commits. If you push feat/ui now, its pull request still targets the old base. If you push feat/docs, its pull request shows four extra commits that “belong” to other PRs. The old way to repair this was one git branch -f per branch, with hashes copied by hand.
Case 2: --update-refs moves the whole stack
git rebase --update-refs main
git log --graph --oneline --decorate --allSuccessfully rebased and updated refs/heads/feat/docs.
Updated the following refs with --update-refs:
refs/heads/feat/api
refs/heads/feat/ui
* 1861481 (HEAD -> feat/docs) docs: describe pricing
* f0527d0 (feat/ui) ui: render list
* 55affa4 ui: render price
* 49a9b11 (feat/api) api: add list_items
* 58ab280 api: add get_price
* c6a0e25 (main) main: add tax()
* 6b188b4 main: add lib.sh
* 2a401c6 init: README
Prove that the rewrite only replayed the commits and changed none of them. range-diff pairs each old commit with its new version, and = means “identical patch”:
git range-diff main feat/docs@{1} feat/docs
git reflog -2 feat/ui1: f2f0cff = 1: 58ab280 api: add get_price
2: 87584b9 = 2: 49a9b11 api: add list_items
3: 42dfa4c = 3: 55affa4 ui: render price
4: 576a711 = 4: f0527d0 ui: render list
5: 87455ba = 5: 1861481 docs: describe pricing
f0527d0 feat/ui@{0}: rewritten during rebase
576a711 feat/ui@{1}: commit: ui: render list
Each moved branch gets a rewritten during rebase reflog entry, so git branch -f feat/ui feat/ui@{1} undoes one branch if you need to.
Case 3: Fix the bottom commit from the top, without moving the base
Review on feat/api asks for timeout=30. You are on feat/docs, and main has moved, but you do not want to drag main: add tax() into three open pull requests just to fix one line.
sed -i 's/timeout=3/timeout=30/' api.conf
git commit -q -am "fixup! api: add get_price"
git log --oneline -1
GIT_SEQUENCE_EDITOR=cat git rebase -i --autosquash --keep-base --update-refs main
git log --graph --oneline --decorate --allGIT_SEQUENCE_EDITOR=cat prints the todo list and accepts it unchanged. In real work you would let your editor open it. Comment lines are omitted below:
ddf1e1f fixup! api: add get_price
pick f2f0cff api: add get_price
fixup ddf1e1f fixup! api: add get_price
pick 87584b9 api: add list_items
update-ref refs/heads/feat/api
pick 42dfa4c ui: render price
pick 576a711 ui: render list
update-ref refs/heads/feat/ui
pick 87455ba docs: describe pricing
Successfully rebased and updated refs/heads/feat/docs.
Updated the following refs with --update-refs:
refs/heads/feat/api
refs/heads/feat/ui
* 6824a87 (HEAD -> feat/docs) docs: describe pricing
* 76f2b7a (feat/ui) ui: render list
* c08e7a1 ui: render price
* b46d23a (feat/api) api: add list_items
* 7e3a228 api: add get_price
| * c6a0e25 (main) main: add tax()
|/
* 6b188b4 main: add lib.sh
* 2a401c6 init: README
Three things to notice:
--autosquashmoved the fixup next to its target.--update-refsput eachupdate-refline after the branch’s last commit, sofeat/apinow includes the fix.--keep-basekept the fork point at6b188b4.mainis still off to the side, and each pull request’s diff against its base changed only where you meant it to.- The
update-reflines are ordinary todo commands. You can move them, delete one (that branch then stays where it was), or add one (update-ref refs/heads/feat/ui-split) to cut a new branch boundary in the middle of the stack.
Verify what changed. --creation-factor raises the threshold for pairing commits, which helps when a commit is tiny and the fix is a large share of it:
git range-diff --creation-factor=95 "$(git merge-base main feat/docs)" feat/docs@{1} feat/docs1: f2f0cff ! 1: 7e3a228 api: add get_price
@@ Commit message
## api.conf (new) ##
@@
-+timeout=3
++timeout=30
## api.sh (new) ##
@@
2: 87584b9 = 2: b46d23a api: add list_items
3: 42dfa4c = 3: c08e7a1 ui: render price
4: 576a711 = 4: 76f2b7a ui: render list
5: 87455ba = 5: 6824a87 docs: describe pricing
6: ddf1e1f < -: ------- fixup! api: add get_price
One ! (changed) commit, four =, and the fixup gone: exactly the intended edit.
Case 4: Make it the default
git config rebase.updateRefs true # this repo; use --global for all repos
git rebase main
git config --show-origin rebase.updateRefsSuccessfully rebased and updated refs/heads/feat/docs.
Updated the following refs with --update-refs:
refs/heads/feat/api
refs/heads/feat/ui
file:.git/config true
git rebase --no-update-refs main opts out for one run. That is useful when you deliberately want to fork a copy of a stack and leave the original branches alone.
Case 5: Publish the moved stack in one atomic push
A rewritten stack needs a force push. Push all the branches together, so a reviewer never sees feat/ui rebased while feat/api is still old:
git init -q --bare ../origin.git && git remote add origin ../origin.git
git push -q origin main feat/api feat/ui feat/docs # the "already published" state
git rebase -q --update-refs main
git push --force-with-lease --atomic origin feat/api feat/ui feat/docsTo ../origin.git
+ 87584b9...49a9b11 feat/api -> feat/api (forced update)
+ 87455ba...1861481 feat/docs -> feat/docs (forced update)
+ 576a711...f0527d0 feat/ui -> feat/ui (forced update)
--force-with-lease refuses to overwrite a branch someone else pushed to since your last fetch. --atomic makes the server accept all three ref updates or none of them.
Case 6: The bottom branch was squash-merged
feat/api lands on main as one squash commit. Its original commits will never appear on main, so git rebase main would try to replay them and conflict, or produce empty commits. Cut them off explicitly with --onto:
git switch -q main && git merge -q --squash feat/api && git commit -q -m "api: get_price and list_items (#41)"
git switch -q feat/docs
git rebase --update-refs --onto main feat/api feat/docs
git log --graph --oneline --decorate --allSuccessfully rebased and updated refs/heads/feat/docs.
Updated the following refs with --update-refs:
refs/heads/feat/ui
* 49a9441 (HEAD -> feat/docs) docs: describe pricing
* 9faa213 (feat/ui) ui: render list
* cb443ba ui: render price
* a49d524 (main) api: get_price and list_items (#41)
* c6a0e25 main: add tax()
| * 87584b9 (feat/api) api: add list_items
| * f2f0cff api: add get_price
|/
* 6b188b4 main: add lib.sh
* 2a401c6 init: README
The range feat/api..feat/docs excludes the squashed commits. feat/ui moves because it points inside the range. feat/api does not, because it is the boundary. Delete it (git branch -D feat/api) and retarget the feat/ui pull request to main.
The trap
Rebasing from the middle of the stack, or from a worktree, silently strands branches.
--update-refs only touches refs that point into the commits being rewritten. Rebase feat/ui and its descendant feat/docs is not in that range:
git switch -q feat/ui
git rebase --update-refs main
git log --graph --oneline --decorate --allSuccessfully rebased and updated refs/heads/feat/ui.
Updated the following refs with --update-refs:
refs/heads/feat/api
* f0527d0 (HEAD -> feat/ui) ui: render list
* 55affa4 ui: render price
* 49a9b11 (feat/api) api: add list_items
* 58ab280 api: add get_price
* c6a0e25 (main) main: add tax()
| * 87455ba (feat/docs) docs: describe pricing
| * 576a711 ui: render list
| * 42dfa4c ui: render price
| * 87584b9 api: add list_items
| * f2f0cff api: add get_price
|/
* 6b188b4 main: add lib.sh
* 2a401c6 init: README
The second failure is quieter. A branch that is checked out in another worktree is skipped, and a non-interactive rebase gives no warning at all:
git worktree add -q ../wt-ui feat/ui
GIT_SEQUENCE_EDITOR=cat git rebase -i --update-refs main | grep -E 'update-ref|checked out'update-ref refs/heads/feat/api
# Ref refs/heads/feat/ui checked out at '/tmp/vcs/wt-ui'
The run then reports Updated the following refs with --update-refs: refs/heads/feat/api only. feat/ui stays on the old commits, and the next git push from that worktree re-publishes the stale stack.
Recovery is cheap, because nothing is lost. Run git switch feat/docs && git rebase --update-refs main from the true top. For a worktree, remove it or detach its HEAD first (git -C ../wt-ui switch --detach), then rebase again.
The boring rule
Turn on
rebase.updateRefs. Rebase stacks only from the top branch, with no stack branch checked out in another worktree. Use--keep-basewhen you are tidying and plain<upstream>when you are catching up. Prove every rewrite withgit range-diff, and publish withgit push --force-with-lease --atomicnaming every branch in the stack.
Try this
- Build the stack, then run
git rebase -i --keep-base --update-refs mainand rewordui: render price. Usegit range-diffto confirm that only that commit shows!, and only in its message. - In the todo list, add
update-ref refs/heads/feat/ui-priceright afterpick … ui: render price. Check that the new branch exists after the rebase and points at the rewritten commit. - Start a rebase that conflicts (edit
ui.shonmainfirst), thengit rebase --abort. Confirm withgit log --oneline -1 feat/apithat no stack branch moved: the refs only update when the rebase completes. - Reproduce the worktree trap, then fix it with
git -C ../wt-ui switch --detachand a fresh rebase fromfeat/docs. - Set
git config --global rebase.updateRefs trueandrebase.autoSquash true. For a week, write review fixes asgit commit --fixup=<sha>and land them with onegit rebase -i --keep-base <base>.
Sources
- git-rebase documentation:
--update-refs,--keep-base,update-reftodo command, incompatible options (Git 2.56 docs) - git-config:
rebase.updateRefs - git-range-diff documentation:
--creation-factor, output markers=,!,<,> - git-push documentation:
--force-with-lease,--atomic - Git 2.38 release notes (introduced
--update-refs); Git 2.24 release notes (introduced--keep-base)