Step 07 of 10

Rebasing

Rebase branches onto main, clean up commits with interactive rebase, and follow the golden rule so rewriting history never hurts your team.

19 min 8 sections

Merging preserves history exactly as it happened, forks and all. Rebasing offers a different deal: it rewrites your branch so it looks as if you had started from the latest main, producing a straight, readable line. It is also the tool for polishing a messy series of commits before others review them. Used carelessly it causes real damage, so this lesson covers both how it works and the one rule that keeps it safe.

What rebase actually does

Start with a feature branch that has fallen behind main:

text
A ── B ── E          ← main
      \
       C ── D        ← feature/rate-limit ← HEAD

git rebase main performs three steps:

  1. Find the commits on your branch that are not on main (C and D).
  2. Move to the tip of main (E).
  3. Replay each commit on top, one at a time, by reapplying its changes.
text
A ── B ── E                ← main
           \
            C' ── D'       ← feature/rate-limit ← HEAD

(C and D still exist, but no branch points at them)

The replayed commits contain the same changes and messages, but they are new commits with new IDs. That has to be true: a commit's ID covers its parent, and C' has a different parent (E) from C (B). Remember this; everything about rebasing safely follows from it.

Compare with merging the same branches:

text
Merge:   A ── B ── E ──────── M   ← main
               \             /
                C ───────── D

Rebase + fast-forward:
         A ── B ── E ── C' ── D'  ← main

Both end up with the same files. Merging records the true shape of the work; rebasing gives a linear story that is easier to read and search.

Rebasing a feature branch onto main

Here is the real session. The branch has two commits and main has gained one:

bash
git log --oneline --graph --all
text
* 511b359 (HEAD -> feature/rate-limit) Wire limiter into router
* 4badb62 Add rate limiter
| * 78038d5 (main) Upgrade logger
|/  
* 734879a Initial commit

Rebase while on the feature branch, naming the branch you want to build on:

bash
git rebase main
text
Successfully rebased and updated refs/heads/feature/rate-limit.
bash
git log --oneline --graph --all
text
* fcef364 (HEAD -> feature/rate-limit) Wire limiter into router
* 9444c3e Add rate limiter
* 78038d5 (main) Upgrade logger
* 734879a Initial commit

Note the new IDs. main is now a direct ancestor of the feature branch, so integrating it is a fast-forward:

bash
git switch main
git merge --ff-only feature/rate-limit

When main lives on a remote, rebase onto the remote-tracking branch after fetching: git fetch then git rebase origin/main. And git pull --rebase, which you met in the remotes lesson, is exactly "fetch, then rebase my local commits onto the upstream".

Replay that session below. Watch the ids under the commits: the rebased copies get new ones, and the originals fade out because no branch points at them any more. Then press Reset, run git switch main and git merge feature/rate-limit instead, and compare the two shapes.

InteractiveRebase, then fast-forwardOpen in Labs
Loading interactive…

Rebase finds the commits on feature/rate-limit that main lacks, replays them on top of main's tip as new commits, and moves the branch. main is then an ancestor of the branch, so --ff-only succeeds. Running git reset --hard ORIG_HEAD straight after the rebase brings the old commits back.

Conflicts during a rebase

Because rebase replays commits one at a time, a conflict stops it on the commit that could not be applied:

text
Auto-merging limiter.js
CONFLICT (add/add): Merge conflict in limiter.js
error: could not apply 9444c3e... Add rate limiter
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".

You resolve it exactly as in a merge — edit, git add — but you finish with git rebase --continue instead of git commit. Git then applies the next commit, which may conflict again. Your options at each stop:

CommandEffect
git rebase --continueCommit the resolved changes and carry on with the next commit
git rebase --skipDrop this commit entirely (useful if main already contains the same change)
git rebase --abortStop and return the branch to exactly where it was before the rebase

If a long branch conflicts at many commits, rebase more often, or squash the branch first so there is only one commit to replay.

Interactive rebase: editing history

git rebase -i lets you rewrite a series of commits before sharing them: reorder, combine, reword or drop. Suppose your branch looks like this after a day of work:

text
81fa1d0 WIP
905015a fix typo
fcef364 Wire limiter into router
9444c3e Add rate limiter

Reviewers do not need "fix typo" and "WIP". Start an interactive rebase onto main (or HEAD~4, "the last four commits"):

bash
git rebase -i main

Git opens your editor with a todo list, oldest commit first (the opposite of git log):

text
pick 9444c3e Add rate limiter
pick fcef364 Wire limiter into router
pick 905015a fix typo
pick 81fa1d0 WIP

# Rebase 78038d5..81fa1d0 onto 78038d5 (4 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
...

Edit the words at the start of each line, and reorder lines to reorder commits. The typo fix belongs to the first commit, so move it up and fold it in; the WIP belongs with the router change:

text
pick 9444c3e Add rate limiter
fixup 905015a fix typo
pick fcef364 Wire limiter into router
fixup 81fa1d0 WIP

Save and close. Git replays the list top to bottom:

text
Successfully rebased and updated refs/heads/feature/rate-limit.
bash
git log --oneline main..
text
c7a91e2 Wire limiter into router
6d2e0f4 Add rate limiter

The commands you will use most:

CommandWhat it does
pickKeep the commit as is
rewordKeep it, but stop to edit the message
editStop after applying it so you can amend it (or split it into several commits)
squashMerge into the previous commit and combine both messages
fixupMerge into the previous commit and discard this commit's message
dropRemove the commit (deleting the line does the same)

Reordering can cause conflicts when commits depend on each other; resolve them as above, or --abort and try another plan.

Fixup commits and autosquash

You can plan this cleanup while you work. When you notice a mistake in an earlier commit, record the fix as a fixup aimed at it:

bash
git add limiter.js
git commit --fixup=6d2e0f4
git log --oneline -3
text
828fc55 fixup! Add rate limiter
c7a91e2 Wire limiter into router
6d2e0f4 Add rate limiter

Later, git rebase -i --autosquash main pre-arranges the todo list for you, moving each fixup! commit under its target and marking it fixup. Set git config --global rebase.autoSquash true to make that the default for interactive rebases.

The golden rule and force pushing

In practice, rebasing your own feature branch after you pushed it — to tidy it up or to catch up with main during review — is common and fine, as long as nobody else is committing to that branch. But the remote still has the old commits, so a normal push is rejected as a non-fast-forward. You must force it, and you should do so carefully:

bash
git push --force-with-lease
text
To github.com:ada/api.git
 + be57890...0ee49ee feature/rate-limit -> feature/rate-limit (forced update)

Plain --force overwrites the remote branch no matter what. --force-with-lease only overwrites it if it still points where your origin/feature/rate-limit says it does. If a colleague pushed in the meantime, the push is refused:

text
 ! [rejected]        feature/rate-limit -> feature/rate-limit (stale info)

One subtlety: a git fetch updates origin/feature/rate-limit, which renews the "lease" even if you never looked at the new commits. Adding --force-if-includes closes that gap by also checking that the remote tip is reachable from your local branch's reflog, meaning you actually had it locally at some point (your pre-rebase tip counts); it refuses with (remote ref updated since checkout) otherwise. A good habit is git push --force-with-lease --force-if-includes, perhaps behind an alias.

Recovering and choosing

A rebase that went wrong is easy to undo while you are in it (git rebase --abort). After it finishes, the old tip is still available: Git stores it in ORIG_HEAD, and the reflog shows it on the line just below rebase (start).

bash
git reset --hard ORIG_HEAD     # straight after the rebase
git reflog                     # otherwise, find the old tip and reset to it

When to use which:

Prefer rebase whenPrefer merge when
Updating your own branch with the latest mainIntegrating a finished branch others have used
Cleaning up commits before reviewThe branch is shared and others may have pulled it
You want a linear, bisectable historyYou want history to show exactly how work came together

Practice

  1. Create a repository with one commit on main. Branch feature/tags and make three commits: Add tag model, oops, and Add tag API, where oops fixes a mistake in the tag model file.
  2. On main, add a commit that touches a different file.
  3. Rebase feature/tags onto main, then use interactive rebase to fold oops into Add tag model.
  4. Confirm the branch now has exactly two commits after main, then fast-forward main to it.
Show solution
bash
git init rebase-lab && cd rebase-lab
echo "readme" > README.md && git add . && git commit -m "Initial commit"

git switch -c feature/tags
echo "class Tag {}" > tag.js && git add tag.js && git commit -m "Add tag model"
echo "export class Tag {}" > tag.js && git commit -am "oops"
echo "GET /tags" > api.txt && git add api.txt && git commit -m "Add tag API"

git switch main
echo "MIT" > LICENSE && git add LICENSE && git commit -m "Add licence"

git switch feature/tags
git rebase main
git rebase -i main

In the editor, change the second line from pick to fixup:

text
pick 1a2b3c4 Add tag model
fixup 5d6e7f8 oops
pick 9a8b7c6 Add tag API
bash
git log --oneline main..      # two commits: Add tag API, Add tag model
git switch main
git merge --ff-only feature/tags
git log --oneline             # linear: Add tag API, Add tag model, Add licence, Initial commit

oops came directly after its target, so no reordering was needed. Your IDs will differ, and all three branch commits got new IDs during the first rebase.

Key takeaways

  • Rebase replays your commits on a new base, creating new commits with new IDs.
  • Resolve rebase conflicts commit by commit, then --continue, --skip or --abort; ours and theirs are swapped.
  • git rebase -i lets you pick, reword, edit, squash, fixup, reorder and drop commits; --fixup plus --autosquash automates cleanup.
  • Golden rule: never rebase commits others have built on.
  • After rebasing your own pushed branch, use git push --force-with-lease (ideally with --force-if-includes), never plain --force.
  • ORIG_HEAD and the reflog let you undo a finished rebase.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.Why do commits get new IDs when you rebase them onto main?

  2. 2.During git rebase main on your feature branch, a conflict appears. Which side does --ours refer to?

  3. 3.In an interactive rebase todo list, what does changing pick to fixup do?

  4. 4.You rebased your own feature branch, which you had already pushed. What is the right way to update the remote?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.