G9 · Part 2 · Working together
Rebase — the one people fear
Rebase does not move commits. It makes new copies of them on a new base and moves the branch label to the copies.
08On this page
Merge joins two histories and records that the join happened. Rebase rewrites your commits so they appear to have been written on top of the latest main, and the fact that you branched off earlier disappears.
You started a branch on Monday. By Wednesday main has moved on.
git fetch then git rebase origin/main
re-applies your commits one by one on top of today's main, as if
you had started this morning. Your commits get new IDs, so if
you had already pushed the branch you must push again with
git push --force-with-lease. If a commit conflicts,
fix the file, git add it and run
git rebase --continue; git rebase --abort
puts everything back. The one rule: only rebase commits nobody
else has built on.
What rebase actually does
git rebase main, run on feature, works
in four steps:
- Find the commits on
featurethat are not onmain: everything after the merge base. - Check out the tip of
main, with HEAD detached. - Re-apply each of those commits, oldest first, as if by
git cherry-pick. Each one makes a new commit. - Point
featureat the last new commit and re-attach HEAD.
A commit's ID is a hash of its contents, and the contents include its parent's ID. A replayed commit has a different parent, so every replayed commit gets a brand-new ID, even if the code change is byte for byte the same. They are copies. The originals still exist, unreferenced, until garbage collection removes them weeks later.
Two details follow from the cherry-pick model. First, rebase
skips any commit whose change is already on the new base, by
comparing patch IDs (a hash of the diff), so a fix that
was cherry-picked to main does not get applied twice. Second,
git rebase main is roughly shorthand for
git rebase --onto main $(git merge-base main HEAD) HEAD:
"take the commits between the merge base and HEAD, and put them
on main". Keep that longer form in mind; it explains
--onto below.
Interactive rebase: editing history before review
git rebase -i <base> opens a to-do list of
every commit after the base, oldest first (the
reverse of git log). Each line is an instruction.
Change the verb, reorder lines, or delete lines, then save and
close; Git executes the list top to bottom.
git rebase -i origin/main
pick 5a90c2e # Add coupon field to cart form
fixup 0c7e1d4 # fixup! Add coupon field to cart form
pick b41f7a3 # Add coupon model
squash 7f2a9b0 # coupon model tests
reword e2c81d0 # Aply coupon before tax
edit 6d3b8c1 # Extract tax helper
drop a4e0f93 # debug logging, remove later
exec npm test
| Command | Short | What it does |
|---|---|---|
pick | p | Use the commit as it is |
reword | r | Use the change, stop to edit the message |
edit | e | Apply the commit, then stop so you can amend it or split it into several commits; git rebase --continue resumes |
squash | s | Meld into the commit above and open an editor to combine both messages |
fixup | f | Meld into the commit above and keep only the message above. fixup -C keeps this commit's message instead |
drop | d | Remove the commit. Deleting the line does the same |
exec | x | Run a shell command at this point; stop if it fails |
break | b | Stop here, as if you had used edit on nothing |
label, reset, merge | Used by --rebase-merges to rebuild merge commits instead of flattening them | |
update-ref | Move another branch to this point; written for you by --update-refs |
splitting a commit with edit
Stopped at 6d3b8c1... Extract tax helper
You can amend the commit now, with
git commit --amend
Once you are satisfied with your changes, run
git rebase --continue
$ git reset HEAD~
$ git add src/tax.js && git commit -m "Extract tax helper"
$ git add src/cart.js && git commit -m "Use tax helper in cart"
$ git rebase --continue
Successfully rebased and updated refs/heads/feature.
git reset HEAD~ undoes the commit but keeps its
changes in your working tree, so you can commit them again in
smaller pieces. The finished branch reads as if you knew exactly
what you were doing from the start: a few clear commits instead
of eleven, each one reviewable and revertable on its own.
Recent versions of Git write the subject after a #
as a reminder that only the verb and the ID matter; editing the
subject there does not change the commit message. That is what
reword is for.
git rebase -x "npm test" origin/main adds an
exec line after every commit, so the rebase stops
at the first commit that breaks the tests. That is a cheap way
to make sure every commit on the branch builds, which keeps
git bisect useful later.
--autosquash: fix now, tidy automatically
During review you find a bug in a commit three back. Instead of remembering to fix it up later, make a commit that says which commit it belongs to:
fixup commits
$ git commit --fixup 5a90c2e
[feature 0c7e1d4] fixup! Add coupon field to cart form
$ git commit --squash b41f7a3
$ git commit --fixup=reword:e2c81d0
$ git rebase -i --autosquash origin/main
$ git config --global rebase.autoSquash true
--fixup makes a commit whose subject is
fixup! <original subject>. With
--autosquash, the to-do list comes up with that
commit already moved under its target and marked
fixup; you just save. --squash makes a
squash! commit that keeps its message for editing,
and --fixup=reword: makes an amend!
commit that replaces the target's message. With the config set,
every interactive rebase does this automatically.
--onto: moving a branch to a different base
The full form is
git rebase --onto <newbase> <upstream> <branch>:
take the commits in branch that are not in
upstream, and replay them on
newbase. The classic use is a branch built on top
of another branch that has since been squash-merged.
git rebase main here would try to replay
A1 and A2 as well, because main only has S, a different commit
with the same change, and you would conflict against your own
work. --onto says exactly where the replay starts.
other --onto moves
$ git rebase --onto main feature-a feature-b
$ git rebase --onto release/2.1 main hotfix/login
$ git rebase --onto HEAD~3 HEAD~2
$ git rebase --keep-base main
The second line moves a hotfix you accidentally started from
main onto the release branch, without dragging main's new
commits along. The third deletes one commit, HEAD~2,
from the middle of the branch: replay everything after it onto
its parent. --keep-base keeps the branch on its
current merge base, which is handy for an interactive clean-up
without also pulling in main's new commits.
--update-refs: rebasing a stack of branches
Many teams split large work into stacked pull requests: branch
billing-schema, then billing-api on top
of it, then billing-ui on top of that. Rebasing the
top branch used to leave the lower branches pointing at the
old, pre-rebase commits. Since Git 2.38,
--update-refs moves every branch that points into
the rebased range, and says so when it finishes
(Updated the following refs with --update-refs:):
git rebase -i --update-refs main, run on billing-ui
pick 1a2b3c4 # Add billing tables
pick 5d6e7f8 # Add billing migration
update-ref refs/heads/billing-schema
pick 9a0b1c2 # Add invoice endpoint
update-ref refs/heads/billing-api
pick 3d4e5f6 # Add invoice page
--update-refs, only billing-ui
would move. billing-schema and
billing-api would still point at the old commits,
and their pull requests would show stale code.
Git writes the update-ref lines for you. Edit a
commit low in the stack, and after one rebase all three branches
point at the right new commits. Set
rebase.updateRefs true to make it the default, then
push each branch with --force-with-lease.
git replay (added in Git 2.44 and still marked
experimental) does rebase's core job with no working tree and
no index. It replays commits in memory using the same
ort merge machinery, so it runs in a bare
repository and never touches files, which makes it fast on huge
repositories and safe on servers. git replay --onto main
main..billing-ui rebases that branch and, in current
versions, updates the ref itself in one atomic transaction;
--contained also moves every branch inside the
range, as --update-refs does, and
--ref-action=print prints
update-ref commands instead. It has no interactive
mode and no way to stop and let you resolve: on a conflict it
exits with status 1 and changes nothing. That is why its users
are forges and stacked-PR tools rather than people at a
terminal. Its options have already changed between releases, so
scripts should check git replay -h on the version
they run.
Conflicts in the middle of a rebase
Because rebase applies commits one at a time, you can hit a conflict on any of them, and you may resolve several in a row.
a rebase that stops
$ git rebase main
Auto-merging src/cart.js
CONFLICT (content): Merge conflict in src/cart.js
error: could not apply e2c81d0... Apply coupon before tax
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".
hint: Disable this message with "git config set advice.mergeConflict false"
Could not apply e2c81d0... # Apply coupon before tax
$ git status
interactive rebase in progress; onto d41c8e2
Last commands done (3 commands done):
pick b41f7a3 # Add coupon model
pick e2c81d0 # Apply coupon before tax
(see more in file .git/rebase-merge/done)
No commands remaining.
You are currently rebasing branch 'feature' on 'd41c8e2'.
(fix conflicts and then run "git rebase --continue")
(use "git rebase --skip" to skip this patch)
(use "git rebase --abort" to check out the original branch)
Unmerged paths:
(use "git restore --staged <file>..." to unstage)
(use "git add <file>..." to mark resolution)
both modified: src/cart.js
$ git add src/cart.js
$ git rebase --continue
Status says "interactive rebase" even though you typed a plain
git rebase. Both run on the same machinery and the
same to-do list, and the "Last commands done" lines show which
commit you are stopped on.
| Command | When to use it |
|---|---|
git rebase --continue | You resolved and staged the conflict; carry on with the next commit |
git rebase --skip | This commit's change is no longer needed, for example main already contains it |
git rebase --abort | Stop and put the branch back exactly where it was before the rebase began |
git rebase --edit-todo | Change the remaining plan while paused |
In a merge, ours is your branch. In a rebase, Git is
standing on main and applying your commits to it, so
ours (HEAD, stage 2) is main plus the
commits replayed so far, and theirs (stage 3) is your
commit being applied. git checkout --theirs file
during a rebase keeps your version. The
conflict markers give it away: the top section is labelled
HEAD, which here means the upstream side, and the
bottom is labelled with your commit's ID and subject, such as
>>>>>>> e2c81d0 (Apply coupon before tax).
Two settings make this less painful. rerere.enabled
remembers resolutions, so rebasing the same branch again does
not ask the same question twice. rebase.autoStash true
stashes uncommitted edits before the rebase and restores them
after, instead of refusing to start.
ORIG_HEAD and the reflog: undoing a rebase
A finished rebase feels permanent. It is not. Before it starts,
rebase records the old tip in ORIG_HEAD, and every
step is written to the reflog.
recovering the branch as it was
$ git reflog -6
58b3e6f (HEAD -> feature) HEAD@{0}: rebase (finish): returning to refs/heads/feature
58b3e6f (HEAD -> feature) HEAD@{1}: rebase (pick): Apply coupon before tax
c7d2a51 HEAD@{2}: rebase (pick): Add coupon model
9e4f10b HEAD@{3}: rebase (pick): Add coupon field to cart form
d41c8e2 (main) HEAD@{4}: rebase (start): checkout main
e2c81d0 HEAD@{5}: commit: Apply coupon before tax
$ git reset --hard ORIG_HEAD
$ git reset --hard feature@{1}
$ git reset --hard e2c81d0
$ git range-diff ORIG_HEAD...HEAD
Any of the three resets puts the branch back on the old commits.
ORIG_HEAD is quickest but is overwritten by the next
reset, merge or rebase. feature@{1} uses the
branch's own reflog: "where feature was one move ago", and a
whole rebase is a single move for the branch. The raw hash from
HEAD@{5} always works. git range-diff
compares the old series with the new one commit by commit,
which is the best way to check a rebase did what you meant
before you force-push it.
git push --force-with-lease, never plain
--force. The lease checks that the remote is still
where you last saw it, and refuses if a teammate pushed in the
meantime, which is exactly the accident plain force would
silently cause.
Rebase or merge?
| Rebase | Merge | |
|---|---|---|
| History shape | A straight line; easy to read and bisect | The real shape of the work, with a merge commit per integration |
| Commit IDs | New IDs for every replayed commit | Existing commits untouched |
| Safe on shared branches | No; requires a force push | Yes |
| Conflicts | Resolved commit by commit, possibly the same area several times | Resolved once, in the merge commit |
| Each commit tested? | Replayed commits were never run as they now are; use -x to check | Original commits stay as they were tested |
| Undo | Reflog or ORIG_HEAD, local only | Revert the merge commit with -m 1 |
| Best for | Updating your own branch; cleaning up before review | Landing reviewed work on a shared branch; long-lived branches |
Most teams use both: rebase your own branch onto main while you
work, clean it up with rebase -i before review, and
let the merge button (a merge commit, a squash, or a rebase
merge, as the team prefers) land it on main.
Finished reading?
0 of 18 chapters read in this series