Git Rebase Explained: How Rewriting History Works
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.
Why new commits are unavoidable
Section titled “Why new commits are unavoidable”A commit object records its parent:
tree 06f3e565236176c7633ec5bb2471844d53aafa24parent c86ff2b9962c64f6e2d5361b57f4f6d7d875d90bauthor Dev <dev@example.com> 1787405411 +0000committer Dev <dev@example.com> 1787405411 +0000
Add validationThe 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.
The basic operation
Section titled “The basic operation”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.
git switch featuregit rebase mainSuccessfully rebased and updated refs/heads/feature.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.
You can watch the IDs change:
git log --format='%h %s' feature -2Before:
a1d07c2 E: second feature commit232a7d8 D: first feature commitAfter:
eee8f01 E: second feature commit84b5d12 D: first feature commitSame messages, same changes, entirely different objects.
What Git does step by step
Section titled “What Git does step by step”-
Find the commits to replay. Everything reachable from your branch but not from the new base — the same set
git log main..featureshows. -
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 onmain. -
Apply each commit in order. For each, Git computes the change that commit introduced and applies it to the current state.
-
Write a new commit for each application, preserving the original author and message, updating the committer, and using the new parent.
-
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.
Conflicts during a rebase
Section titled “Conflicts during a rebase”Because commits replay one at a time, a rebase can stop several times.
git rebase mainerror: could not apply 84705a1... Set feature valuehint: Resolve all conflicts manually, mark them as resolved withhint: "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 45cc80fLast command done (1 command done): pick 84705a1 Set feature valueNext command to do (1 remaining command): pick ebb268d Add extra fileThat “1 remaining command” is the crucial detail: you are partway through, and more conflicts may follow.
The labels are inverted
Section titled “The labels are inverted”<<<<<<< HEADsetting: 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.
The three ways forward
Section titled “The three ways forward”| Command | Effect |
|---|---|
git rebase --continue | Resolve, git add, then continue with the next commit |
git rebase --skip | Drop the current commit entirely and move on |
git rebase --abort | Cancel everything; restore the branch exactly as it was |
git add cfg.txtgit rebase --continueWhat 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.
Pushing after a rebase
Section titled “Pushing after a rebase”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 behindThe 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.
git push --force-with-leaseWhat 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)Rebasing onto a different base
Section titled “Rebasing onto a different base”The three-argument form re-parents a branch, which is how you fix a stacked branch after its parent has been squashed or rebased.
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:
git rebase --onto main feature-a feature-bSuccessfully 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.
Useful options
Section titled “Useful options”| Option | Effect |
|---|---|
-i, --interactive | Edit the list of commits before replaying. Lesson 2 |
--onto <base> | Replay onto a specified commit rather than the upstream |
--autostash | Stash uncommitted changes, rebase, then restore them |
--autosquash | Reorder fixup!/squash! commits next to their targets |
--rebase-merges | Preserve merge commits rather than flattening them |
--root | Include the very first commit, so you can rewrite from the beginning |
--update-refs | Update 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:
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.
Keeping stacked branches coherent
Section titled “Keeping stacked branches coherent”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:
git switch b2git rebase --update-refs mainSuccessfully rebased and updated refs/heads/b2. refs/heads/b1The extra line lists the other refs Git moved. Both branches now sit on the rewritten history:
git log --oneline b1e07bfcb b1 work5999a0e main work372f163 basegit log --oneline b26e06a03 b2 worke07bfcb b1 work5999a0e main workMake it automatic:
git config --global rebase.updateRefs trueBefore 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.
Preserving merge commits
Section titled “Preserving merge commits”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:
git rebase --rebase-merges mainGit generates a todo list containing label, reset and merge instructions describing the topology, and
rebuilds it on the new base.
Rebase and pull
Section titled “Rebase and pull”git pull can rebase instead of merging:
git pull --rebaseThis 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:
git config --global pull.rebase trueThere is also pull.rebase merges, which preserves local merge commits rather than flattening them.
When rebasing is the right tool
Section titled “When rebasing is the right tool”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.
When it is not
Section titled “When it is not”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-emptygit 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.
Recovering from a rebase
Section titled “Recovering from a rebase”Nothing is lost immediately. The reflog records where the branch pointed before the rebase started:
git reflogeee8f01 HEAD@{0}: rebase (finish): returning to refs/heads/feature84b5d12 HEAD@{1}: rebase (pick): D: first feature commit68cbf80 HEAD@{2}: rebase (start): checkout maina1d07c2 HEAD@{3}: commit: E: second feature commitHEAD@{3} is the pre-rebase tip. Restore it:
git reset --hard HEAD@{3}Common mistakes
Section titled “Common mistakes”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.
Mental Model
Section titled “Mental Model”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.
What You Learned
Section titled “What You Learned”- 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,--skipand--abortare the three exits;--skipdeletes that commit’s work.- Author is preserved; committer is updated.
- Pushing after a rebase requires
--force-with-lease, and fetching immediately beforehand weakens it. --ontore-parents a branch;--update-refskeeps stacked branches coherent.- The reflog makes every rebase recoverable, within its expiry window.
Try It Yourself
Section titled “Try It Yourself”- Create a repository with a file
cfg.txtcontainingsetting: default, and commit. - Create
feature; change the value tofeature-valueand commit; then add an unrelated file and commit. - On
main, change the same line tomain-valueand commit. - Note the branch’s commit IDs:
git log --format='%h %s' feature -2. - Take a backup ref:
git branch backup. - Rebase:
git switch feature && git rebase main. It will conflict. - Run
git statusand read “Next command to do”. How many commits remain? - Look at the markers. Which side is
HEAD? Confirm it ismain’s value, not yours. - Resolve to
feature-value, thengit add cfg.txt && git rebase --continue. - Compare the new IDs with step 4. Every one should differ.
- 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.
Next Lesson
Section titled “Next Lesson”Interactive rebase is the same replay mechanism with an editable list of instructions, which is what makes reordering, squashing and rewording possible.