Skip to content

Git Rebase Explained: How Rewriting History Works

Lesson 1 of 7Intermediate12 min readModern Git Workflows · RebasingVerified: Git 2.43.0 on Ubuntu 24.04; every command and output in this lesson was run

git rebase takes the commits on your branch, sets them aside, moves your branch to a new base, and then re-applies each commit in order as a new commit.

The word “rebase” suggests moving something. Nothing moves. Each original commit is read, its changes are applied on top of the new base, and a new commit is written. The originals remain in the object database until garbage collection removes them.

Understanding that distinction is the difference between using rebase confidently and being afraid of it.

A commit object records its parent:

tree 06f3e565236176c7633ec5bb2471844d53aafa24
parent c86ff2b9962c64f6e2d5361b57f4f6d7d875d90b
author Dev <dev@example.com> 1787405411 +0000
committer Dev <dev@example.com> 1787405411 +0000
Add validation

The commit’s ID is the SHA-1 of that entire text. Rebasing gives the commit a different parent, so the text differs, so the hash differs. There is no mechanism by which a commit could keep its ID while changing its parent — that is what content-addressed storage means.

Before: feature branched from A, main has moved to C

A trunk lane labelled main with commits A, B and C. A branch lane labelled feature leaves main after commit A with commits D and E.

ABCDEmainfeatureMerge base is A. feature's commits were written against A, not C.
Terminal window
git switch feature
git rebase main
Successfully rebased and updated refs/heads/feature.
After: the same changes, replayed on top of C

A single lane containing commits A, B, C, then D-prime and E-prime with the feature label at the end. The primed commits are new objects containing the same changes as D and E.

ABCD'E'featurefeature now descends directly from main's tip. Merging it would be a fast-forward.

You can watch the IDs change:

Terminal window
git log --format='%h %s' feature -2

Before:

a1d07c2 E: second feature commit
232a7d8 D: first feature commit

After:

eee8f01 E: second feature commit
84b5d12 D: first feature commit

Same messages, same changes, entirely different objects.

  1. Find the commits to replay. Everything reachable from your branch but not from the new base — the same set git log main..feature shows.

  2. Check out the new base. HEAD moves to main’s tip, detached. This is why “ours” and “theirs” invert during conflicts: you are now sitting on main.

  3. Apply each commit in order. For each, Git computes the change that commit introduced and applies it to the current state.

  4. Write a new commit for each application, preserving the original author and message, updating the committer, and using the new parent.

  5. Move the branch ref to the last new commit and reattach HEAD.

Step 3 is where conflicts arise, and it is per commit — not once for the whole branch. That is the most important operational difference from merging.

Because commits replay one at a time, a rebase can stop several times.

Terminal window
git rebase main
error: could not apply 84705a1... Set feature value
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".

git status tells you exactly where you are:

interactive rebase in progress; onto 45cc80f
Last command done (1 command done):
pick 84705a1 Set feature value
Next command to do (1 remaining command):
pick ebb268d Add extra file

That “1 remaining command” is the crucial detail: you are partway through, and more conflicts may follow.

<<<<<<< HEAD
setting: main-value
=======
setting: feature-value
>>>>>>> 84705a1 (Set feature value)

HEAD here is main, because Git checked out the new base. The section below ======= is your own commit.

CommandEffect
git rebase --continueResolve, git add, then continue with the next commit
git rebase --skipDrop the current commit entirely and move on
git rebase --abortCancel everything; restore the branch exactly as it was
Terminal window
git add cfg.txt
git rebase --continue

What it doesResumes the rebase after you have staged your conflict resolution, writing the current commit and moving to the next one.

Why we run itStaging is how you signal the conflict is resolved; --continue is how you tell Git to proceed.

Expected resultEither the rebase completes, or it stops again on the next conflicting commit. Git opens an editor for the commit message unless nothing needs changing.

git rebase --abort is always safe. It restores the branch, working tree and index to exactly their pre-rebase state.

Your branch and its remote copy no longer share history, so an ordinary push is rejected:

! [rejected] feature -> feature (non-fast-forward)
error: failed to push some refs to 'origin'
hint: Updates were rejected because the tip of your current branch is behind

The rejection is Git protecting the remote from losing commits. Overriding it is the point of a force push — but there is a right way and a wrong way.

Terminal window
git push --force-with-lease

What it doesOverwrites the remote branch with your rewritten one, but only if the remote still points where your last fetch said it did.

Why we run itIf a colleague pushed since you last fetched, the lease check fails and the push is refused — so you cannot silently destroy their work.

Expected resultA push summary showing a forced update, or a rejection saying the remote reference has changed.

+ eee8f01...84b5d12 feature -> feature (forced update)

The three-argument form re-parents a branch, which is how you fix a stacked branch after its parent has been squashed or rebased.

Terminal window
git rebase --onto <new-base> <old-base> <branch>

Suppose feature-b was branched from feature-a, and feature-a has since been squash-merged into main. feature-b still carries copies of feature-a’s commits, which now duplicate what is on main:

Terminal window
git rebase --onto main feature-a feature-b
Successfully rebased and updated refs/heads/feature-b.

Read the arguments as: replay the commits after feature-a onto main, for branch feature-b. The inherited copies are excluded, so feature-b ends up containing only its own work sitting on current main.

OptionEffect
-i, --interactiveEdit the list of commits before replaying. Lesson 2
--onto <base>Replay onto a specified commit rather than the upstream
--autostashStash uncommitted changes, rebase, then restore them
--autosquashReorder fixup!/squash! commits next to their targets
--rebase-mergesPreserve merge commits rather than flattening them
--rootInclude the very first commit, so you can rewrite from the beginning
--update-refsUpdate any other branches pointing at rewritten commits

--autostash is the small quality-of-life flag worth adopting immediately — it removes the “cannot rebase: you have unstaged changes” interruption:

Terminal window
git config --global rebase.autoStash true

--update-refs is the modern answer to stacked branches: it updates every local branch pointing at a commit being rewritten, keeping a stack coherent through one rebase.

If b2 was branched from b1, rebasing b2 onto main normally leaves b1 pointing at the old, now-orphaned commits. --update-refs fixes that in one pass:

Terminal window
git switch b2
git rebase --update-refs main
Successfully rebased and updated refs/heads/b2.
refs/heads/b1

The extra line lists the other refs Git moved. Both branches now sit on the rewritten history:

Terminal window
git log --oneline b1
e07bfcb b1 work
5999a0e main work
372f163 base
Terminal window
git log --oneline b2
6e06a03 b2 work
e07bfcb b1 work
5999a0e main work

Make it automatic:

Terminal window
git config --global rebase.updateRefs true

Before this existed, keeping a stack coherent meant rebasing each branch in turn with --onto, and it was easy to get wrong. If you work with stacked branches at all, this is the single most useful rebase option.

By default a rebase flattens history: merge commits in the range being replayed are dropped and their contents replayed as ordinary commits. That is usually what you want on a feature branch.

When it is not — because the branch’s internal merge structure is meaningful — --rebase-merges recreates the merges:

Terminal window
git rebase --rebase-merges main

Git generates a todo list containing label, reset and merge instructions describing the topology, and rebuilds it on the new base.

git pull can rebase instead of merging:

Terminal window
git pull --rebase

This fetches, then replays your local commits on top of the updated remote branch instead of creating a merge commit. On a branch where you have a couple of local commits and the remote has moved, it produces a clean linear result rather than a merge commit that says nothing useful.

Make it the default:

Terminal window
git config --global pull.rebase true

There is also pull.rebase merges, which preserves local merge commits rather than flattening them.

Bringing a private branch up to date. The most common good use. Your branch replays onto current main, conflicts surface while you have context, and integration becomes a fast-forward.

Cleaning up before review. Squash the fixups, reword unclear messages, order the commits so they tell a story. See Interactive Rebase.

Maintaining a linear history. Teams that prefer git log main to read as a sequence rebase branches before integration.

Re-parenting a stacked branch. --onto, as above.

Fixing a commit that is not the most recent. Interactive rebase reaches back further than --amend.

The branch is shared. Anyone who has pulled it now has orphaned commits.

Review is in progress. Comments are anchored to commits that will cease to exist.

Conflicts are extensive. A rebase may present a related conflict on every replayed commit. A merge resolves it once.

You need a record of integration. Rebasing leaves no evidence that a branch existed.

The history is genuinely valuable as-is. Rewriting a well-structured branch to make it linear can destroy information.

When Not to Rebase covers these properly, and Rebase vs Merge is the full comparison.

Does a rebase produce the same result as a merge?

Section titled “Does a rebase produce the same result as a merge?”

Usually the final tree is identical. Both operations combine the same two sets of changes, so if there are no conflicts the resulting files match.

Where they differ:

Intermediate states. A merge produces one new tree. A rebase produces one per replayed commit, and those intermediate states never existed before. A commit that built fine in its original position may not build in its new one — for example if it depended on something that only arrives in a later commit on the branch, and main has meanwhile changed the surrounding code.

Conflict resolution granularity. A merge resolves each conflicting region once, against the final state of both sides. A rebase resolves per commit, against whatever the state was at that point in the replay. It is possible to resolve each step reasonably and end up with a final state you would not have chosen.

Empty commits. If a commit’s changes are already present on the new base, replaying it produces nothing. Git stops and tells you:

The previous cherry-pick is now empty, possibly due to conflict resolution.
If you wish to commit it anyway, use:
git commit --allow-empty

git rebase --skip is the normal answer here — this is the legitimate use of --skip.

The practical takeaway: run the tests after a rebase, and if your team relies on git bisect, consider a CI job that builds every commit in a pull request rather than only its tip.

Nothing is lost immediately. The reflog records where the branch pointed before the rebase started:

Terminal window
git reflog
eee8f01 HEAD@{0}: rebase (finish): returning to refs/heads/feature
84b5d12 HEAD@{1}: rebase (pick): D: first feature commit
68cbf80 HEAD@{2}: rebase (start): checkout main
a1d07c2 HEAD@{3}: commit: E: second feature commit

HEAD@{3} is the pre-rebase tip. Restore it:

Terminal window
git reset --hard HEAD@{3}

Rebasing a shared branch. The mistake with the widest blast radius.

Using bare --force. Use --force-with-lease, and fetch before you start rebasing rather than immediately before pushing.

Reaching for --ours during a rebase conflict. It means upstream, not you.

Using --skip to escape a hard conflict. It deletes that commit’s work.

Rebasing during review. Add commits instead; reshape before or after.

Forgetting the branch is now diverged locally after a platform rebase-and-merge. The server rebased its copy, not yours. Delete your local branch with -D.

Assuming a clean rebase is a correct rebase. Each replayed commit is a new object whose intermediate state was never tested. Run the tests at the end, and ideally on each commit if you rely on bisect.

Rebase asks: what if I had started this work from here instead?

Git answers by taking each of your changes and applying it to the new starting point, writing a fresh commit each time. Same changes, same messages, same authors — new commits, because a commit is defined partly by what came before it.

The old commits are not deleted. They are simply no longer named by anything, which is why the reflog can still find them.

  • Rebase replays commits as new objects; IDs change because the parent is part of a commit’s identity.
  • Git checks out the new base first, which is why “ours” and “theirs” invert during conflicts.
  • Conflicts arrive per commit, so a rebase can stop repeatedly.
  • --continue, --skip and --abort are the three exits; --skip deletes that commit’s work.
  • Author is preserved; committer is updated.
  • Pushing after a rebase requires --force-with-lease, and fetching immediately beforehand weakens it.
  • --onto re-parents a branch; --update-refs keeps stacked branches coherent.
  • The reflog makes every rebase recoverable, within its expiry window.
  1. Create a repository with a file cfg.txt containing setting: default, and commit.
  2. Create feature; change the value to feature-value and commit; then add an unrelated file and commit.
  3. On main, change the same line to main-value and commit.
  4. Note the branch’s commit IDs: git log --format='%h %s' feature -2.
  5. Take a backup ref: git branch backup.
  6. Rebase: git switch feature && git rebase main. It will conflict.
  7. Run git status and read “Next command to do”. How many commits remain?
  8. Look at the markers. Which side is HEAD? Confirm it is main’s value, not yours.
  9. Resolve to feature-value, then git add cfg.txt && git rebase --continue.
  10. Compare the new IDs with step 4. Every one should differ.
  11. Confirm recovery works: git reset --hard backup, and check the old IDs are back.

Step 8 is the one that prevents a real mistake later. Step 11 is what makes the whole cluster approachable — you can always get back.

Interactive rebase is the same replay mechanism with an editable list of instructions, which is what makes reordering, squashing and rewording possible.