Rebase: replay your work on a new base
The interactive lesson needs JavaScript. The text below is the complete lesson.
After this lesson you can
- explain why
git rebaseproduces new commits with new IDs, and what happens to the originals understand - use
git rebase mainto put a branch on top ofmain, then fast-forwardmainapply - continue or abort a rebase that stops on a conflict apply
- choose between merge and rebase using the golden rule: rebase only private history, merge shared history evaluate
Understand: Same work, new parent
You work on feature and have made two commits, B and C. Meanwhile someone added D to main. The two lines have forked. You want your work to sit on top of the newest main.
You already know one answer: a merge. Git makes a merge commit M with two parents. Nothing is rewritten, and the graph shows honestly that the work happened in parallel.
Back to the fork: rebase takes another path. Git starts at the newest commit on main and replays your commits one at a time, as if you had written them there. The change from B becomes a new commit on top of D. If a change does not fit, Git stops.
When every commit has been replayed, Git moves the feature label to the last new commit. The history is now one straight line on top of D.
Look at the dashed commits. They are the originals, B and C, and they are unchanged. A commit's ID is computed from its snapshot, its parent, its message and its author. New parent, new ID. So rebase never moves commits. It makes copies and leaves the old ones behind.
To bring the work into main, you run git switch main and then git merge feature. Because feature is now directly ahead, it is a fast-forward. No merge commit, just a straight line.
Now the golden rule: never rebase commits that others already have. Suppose you had pushed the old feature. Then the originals live on at origin, and there are two versions of the same work. Rebase only private history, and merge when it is shared.
Watch: Put a feature branch on top of main
We are on feature. The graph shows the fork: two commits on feature and one new commit on main. Make a note of the IDs of the two feature commits.
$ git log --oneline --graph --all
Now the key command. git rebase main replays the commits from feature on top of main. Keep an eye on the IDs.
$ git rebase main
The graph is now a straight line, with new IDs on the feature commits. Git saved the old tip in ORIG_HEAD, and the log from there shows the originals, completely unchanged. If you regret the rebase, git reset --hard ORIG_HEAD takes you back.
$ git log --oneline -2 ORIG_HEAD
Now the work goes into main. First we switch to main.
$ git switch main
Merging feature is now a fast-forward. Git only moves the main label forward. Look for the word Fast-forward in the output.
$ git merge feature
The result is one straight line: first the snapshot, then the docs, then the two login commits.
$ git log --oneline --graph
Tasks
- Show all branches as a graph, with one line per commit.
- Replay
featureon top of the newestmain. - Bring
mainup to date with the work fromfeature, without a merge commit.
Watch: When a replay conflicts
Both branches changed the same line in README.md. We rebase greeting onto main. Git replays the commit, hits the conflict and stops halfway.
$ git rebase main
Let's look at the file. The markers are the same as in a merge conflict. But which side is called HEAD this time?
$ cat README.md
We resolve it like any conflict. We write the version we want, and mark it resolved with git add.
$ echo 'Welcome to Git class, friend!' > README.md && git add README.md
Now git rebase --continue makes the new commit and replays the rest. Git opens the commit message so you can adjust it. We keep it and save.
$ git rebase --continue
Tasks
- Start replaying
greetingon top ofmain. - Resolve the conflict by combining both lines, and mark the file as resolved.
- Finish the rebase.
Practise: Transfer: a shop with three branches
You are on main. The branches docs, faq and pricing started before main moved on. faq is pushed, and Ada is building on it. The other two exist only on your machine. In the middle of a rebase, git rebase --abort puts everything back. No demo this time.
Tasks
- Bring the work from
docsintomainas one straight line, with no merge commit. - Bring the FAQ from
faqintomain. - Rebase
pricingontomain. It conflicts. You decide not to resolve it now. Back out, so thatpricingis exactly as before.
Check yourself: Recall it
You pushed feature yesterday, and a classmate has built on it. Today main has moved on. What is the safe choice?
- Rebase
featureontomainand force-push - Merge, so the shared commits stay as they are
- Rebase, it is fine as long as you use
git push --force-with-lease
During a rebase conflict you run git checkout --ours README.md. Which version do you get in the file?
- The version from your own commit, the one being replayed
- The version from the new base, with the commits already replayed
- The version from the common ancestor
Halfway through a rebase with conflicts you realise you started from the wrong branch. What puts everything back as it was before?
git rebase --abortgit rebase --skipgit reset --hard
Review from “Branches and HEAD”: at the very end of a rebase, Git moves the branch feature. What does Git actually move?
- A copy of all the files on the branch
- A pointer to one commit, the tip of the branch
- A stored list of all the commits on the branch
Common misconceptions
- “Rebase moves my commits.” It makes new commits with new IDs. The originals stay unchanged until they are eventually cleaned up.
- “Rebase is always better because history looks cleaner.” Rewriting commits others already have leaves the team with duplicate versions and a lot of extra work.
- “In a rebase conflict, HEAD is my own change.” During a rebase HEAD stands on the new base, plus any commits already replayed. Your commit is the incoming side.
Read more
What git rebase main actually does
Git finds the commits that are on your branch but not on main (the range main..HEAD). It saves the old tip in ORIG_HEAD, checks out the newest main on a detached HEAD, and applies those commits one by one, much like a series of cherry-picks. Only when the last one is done does it move your branch label to the new tip. Each new commit gets a new parent, so it gets a new ID; the originals stay untouched in the repository.
$ git rebase main
Successfully rebased and updated refs/heads/feature.
$ git log --oneline --graph --all
* d9c6470 Add login test
* d355c49 Add login form
* 6007fdb Add docs
* a5ea1be Initial snapshot
$ git reflog -5
d9c6470 HEAD@{0}: rebase (finish): returning to refs/heads/feature
d9c6470 HEAD@{1}: rebase (pick): Add login test
d355c49 HEAD@{2}: rebase (pick): Add login form
6007fdb HEAD@{3}: rebase (start): checkout main
0c10b5b HEAD@{4}: checkout: moving from main to feature
$ git rev-parse --short ORIG_HEAD
0c10b5b
Three details are worth knowing:
- A commit whose change is already on
main(for example because someone cherry-picked it) is skipped. Git printswarning: skipped previously applied commit. - Merge commits on your branch are dropped by default and the history is flattened into one line.
- The branch moves only at the very end. That is why
git rebase --abortcan always go back to the exact starting point.
When a commit does not fit
A rebase stops at the first commit that conflicts. Resolve it as you would a merge conflict, then choose how to go on:
| Command | Effect |
|---|---|
git rebase --continue |
Commit the resolved change and replay the rest |
git rebase --skip |
Leave this commit out of the new history (its change is lost from the branch) |
git rebase --abort |
Stop and put the branch back exactly where it was |
During a rebase the sides are swapped compared with a merge into your own branch: HEAD and "ours" are the new base plus the commits replayed so far, "theirs" is your commit being replayed. So git checkout --ours file does not give you your own version.
Compare old and new with git range-diff
After a rebase you have two versions of the same series. git range-diff pairs them up and compares the patches, not the IDs:
$ git range-diff main ORIG_HEAD feature
1: 86eeba1 = 1: d355c49 Add login form
2: 0c10b5b = 2: d9c6470 Add login test
= means the change is identical, ! means it differs (an inner diff follows), < means a commit exists only in the old series, and > only in the new. If you want a lasting bookmark instead of ORIG_HEAD, create a branch first: git branch feature-before before you rebase, then git range-diff main feature-before feature.
Transplant part of a branch: --onto
git rebase --onto <newbase> <upstream> <branch> takes the commits in upstream..branch and replays them on newbase. The classic case: you started discount from pricing by mistake, and only the discount commit should go to main.
$ git log --oneline --graph --all
* 95dbcf3 Add student discount
* d3a55ee Show prices
| * 82af405 Add license
|/
* a5ea1be Initial snapshot
$ git rebase --onto main pricing discount
Successfully rebased and updated refs/heads/discount.
$ git log --oneline --graph --all
* 535e91d Add student discount
* 82af405 Add license
| * d3a55ee Show prices
|/
* a5ea1be Initial snapshot
Show prices stays on pricing; only the commit after it moved.
Rebasing something you already pushed
A rebased branch no longer contains the old tip, so a plain git push is rejected as non-fast-forward. Overwriting the remote branch is acceptable only when the branch is yours alone, typically your own pull-request branch that nobody else has fetched or built on, or when the team has explicitly agreed. Never on main or another shared branch.
Even then, prefer --force-with-lease to --force. It overwrites only if the remote branch is still where your origin/feature says it is. Here Ada pushed a commit to the branch while you were rebasing:
$ git push --force-with-lease
! [rejected] feature -> feature (stale info)
The lease has a weak spot: if git fetch (or an editor in the background) updates origin/feature first, the lease passes and Ada's commit is overwritten anyway. --force-if-includes adds a check that you have actually integrated what you fetched:
$ git fetch
$ git push --force-with-lease --force-if-includes
! [rejected] feature -> feature (remote ref updated since checkout)
Neither flag makes it safe to rewrite history that others use; they only stop some accidents.
git pull --rebase
git pull --rebase fetches and then rebases your local commits that are not on the upstream branch onto its updated version. That is safe as long as those commits are private, which is why it is the rebase most people use daily. git config pull.rebase true makes it the default.
Merge or rebase?
| Situation | Choice |
|---|---|
| Private branch, you want a straight line before sharing | rebase |
| Branch others have fetched or built on | merge |
| Bring your own unpushed commits up to date | git pull --rebase |
| You need the history to show that work happened in parallel | merge |
Per-Arne's Git Good!