GitGroundworkGit
9 / 18 written chapters

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.

11 min read3 diagrams8 sections
08On this page
  1. What rebase actually does
  2. Interactive rebase: editing history before review
  3. --autosquash: fix now, tidy automatically
  4. --onto: moving a branch to a different base
  5. --update-refs: rebasing a stack of branches
  6. Conflicts in the middle of a rebase
  7. ORIG_HEAD and the reflog: undoing a rebase
  8. Rebase or merge?
Fresher Mid Senior

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.

In one minute

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:

  1. Find the commits on feature that are not on main: everything after the merge base.
  2. Check out the tip of main, with HEAD detached.
  3. Re-apply each of those commits, oldest first, as if by git cherry-pick. Each one makes a new commit.
  4. Point feature at 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.

BEFORE 5a90c2e b41f7a3 e2c81d0 feature ← HEAD A B C D main AFTER git rebase main 9e4f10b c7d2a51 58b3e6f feature ← HEAD A B C D · main old copies: ORIG_HEAD and the reflog still find them
Same changes, new parents, new IDs. main does not move at all. Anyone who had the old three commits now has a history that no longer matches yours.

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.

The golden rule of rebasing Never rebase commits that other people have already pulled. Rewriting shared history replaces commits your teammates have built on, and every one of them will hit duplicate commits and repeat conflicts when they next pull. Rebase freely on your own unpushed or unshared branch; use merge or revert on anything public. A branch pushed only so CI runs, which nobody else commits to, is still yours.

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
CommandShortWhat it does
pickpUse the commit as it is
rewordrUse the change, stop to edit the message
editeApply the commit, then stop so you can amend it or split it into several commits; git rebase --continue resumes
squashsMeld into the commit above and open an editor to combine both messages
fixupfMeld into the commit above and keep only the message above. fixup -C keeps this commit's message instead
dropdRemove the commit. Deleting the line does the same
execxRun a shell command at this point; stop if it fails
breakbStop here, as if you had used edit on nothing
label, reset, mergeUsed by --rebase-merges to rebuild merge commits instead of flattening them
update-refMove 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.

BEFORE A1 A2 B1 B2 feature-b feature-a S = A1+A2 squashed main AFTER git rebase --onto main feature-a feature-b A1 A2 feature-a (can be deleted) S main B1′ B2′ feature-b ← HEAD
A plain 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
BEFORE billing-schema billing-api billing-ui ← HEAD main AFTER git rebase --update-refs main billing-schema billing-api billing-ui ← HEAD main one rebase, three labels moved
Without --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.

For seniors: git replay

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.

CommandWhen to use it
git rebase --continueYou resolved and staged the conflict; carry on with the next commit
git rebase --skipThis commit's change is no longer needed, for example main already contains it
git rebase --abortStop and put the branch back exactly where it was before the rebase began
git rebase --edit-todoChange the remaining plan while paused
ours and theirs are swapped during a rebase

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.

After a rebase you must force push Your local commits now have different IDs than the ones on the remote, so a normal push is rejected as a non-fast-forward. Use 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?

RebaseMerge
History shapeA straight line; easy to read and bisectThe real shape of the work, with a merge commit per integration
Commit IDsNew IDs for every replayed commitExisting commits untouched
Safe on shared branchesNo; requires a force pushYes
ConflictsResolved commit by commit, possibly the same area several timesResolved once, in the merge commit
Each commit tested?Replayed commits were never run as they now are; use -x to checkOriginal commits stay as they were tested
UndoReflog or ORIG_HEAD, local onlyRevert the merge commit with -m 1
Best forUpdating your own branch; cleaning up before reviewLanding 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.