Skip to content

Git Rebase Explained: Rewrite History Without Breaking It

Git rebase explained: how it replays commits, interactive rebase, autosquash, ours vs theirs in conflicts, and safe pushing with --force-with-lease.

Dasun Kalupahana 7 min read 103 views 0 comments
On this page
  1. What rebase does: it replays commits
  2. The one rule: don't rebase commits other people have
  3. Undoing a rebase
  4. Interactive rebase: clean up before you share
  5. Conflicts during a rebase
  6. Moving a branch with --onto
  7. Pulling with rebase
  8. Pushing after a rebase
  9. Common problems
  10. Before you move on

Git rebase is the command people are told both to love and to fear, and both reactions come from the same fact: it rewrites history. Used on your own unpushed branch, it gives you a clean, linear series of commits that reviewers can read. Used on commits other people already have, it creates the kind of mess that ends with someone running git reset --hard and losing an afternoon. This part explains what rebase actually does to your commits, how to squash and reorder them with interactive rebase, what changes when a rebase hits a conflict, and how to push the result without overwriting a teammate's work.

It builds on Part 3, where merging combined two histories. Rebase reaches a similar end state a different way. Everything below was run on Git 2.43.0 in a scratch repository with a local bare repository as the remote.

What rebase does: it replays commits

Say you branched feature/coupon from main, made two commits, and meanwhile main gained a commit:

* f8be553 Apply coupon           (feature/coupon)
* 488d7d4 Add coupon field
| * f8ad49d Add Other            (main)
|/
* 65992c6 Add Cart

Now run, from the feature branch:

git switch feature/coupon
git rebase main
* d83f9ba Apply coupon
* 7fda115 Add coupon field
* f8ad49d Add Other
* 65992c6 Add Cart

Git took each of your commits, re-applied its changes on top of the new tip of main, and moved the branch pointer to the result. Look at the hashes: 488d7d4 became 7fda115 and f8be553 became d83f9ba. Same changes and same messages, but different commits, because a commit's hash covers its parent (from Part 1). The old commits still exist for now, but nothing on a branch points at them.

Merge or rebase?

 mergerebase
History shapeKeeps both lines and adds a merge commitStraight line, as if you started from the latest main
Existing commitsUnchangedReplaced with new commits (new hashes)
ConflictsResolved once, in the merge commitPossibly resolved once per replayed commit
Safe on shared commitsYesNo

Neither is "correct". Teams choose based on whether they value an exact record of what happened (merge) or a readable history (rebase).

The one rule: don't rebase commits other people have

Rebase is safe on commits that exist only in your clone. Once you have pushed a branch and someone has based work on it, rewriting those commits means their copy and yours no longer share history, and they will have to untangle it. A workable rule: rebase your own feature branch freely until others depend on it; never rebase main or any branch several people push to.

Undoing a rebase

If a rebase goes somewhere you don't like, Git has kept a pointer to where the branch was. Right after the rebase above, ORIG_HEAD pointed at f8be553, the old tip:

git reset --hard ORIG_HEAD

The branch returns to its pre-rebase state. Be aware that ORIG_HEAD is overwritten by other commands (merge, reset, another rebase), so if you've done anything since, use the reflog instead. git reflog lists every step the rebase took and the position before it, and you can reset to any of those entries. Part 5 goes into reflog recovery in detail.

Interactive rebase: clean up before you share

git rebase -i lets you edit the list of commits before they are replayed. To rework everything since main:

git rebase -i main

Git opens your editor with a todo list, oldest commit first, followed by help text. This is the real list of commands from Git 2.43.0:

CommandWhat it does
pickUse the commit as is
rewordUse the commit but edit its message
editStop at this commit so you can amend it
squashMeld into the previous commit and combine the messages
fixupLike squash, but keep only the previous commit's message
dropRemove the commit
execRun a shell command after this commit (for example your test suite)

Lines are executed top to bottom, so reordering lines reorders commits. The help text also warns: if you remove a line, that commit will be lost. Use drop when you mean it so the intent is visible.

Example. After a messy afternoon the branch has four commits: commit 1, commit 2, commit 3 and commit 4. In the todo list I changed the first to reword, the second to drop and the fourth to squash. The result was two commits, a reworded first one and one combining 3 and 4, and the file added by the dropped commit was gone from the working tree.

Fixup commits and autosquash

A faster habit for small corrections: when you notice a problem in an earlier commit, commit the fix with a marker instead of editing history immediately.

git commit --fixup a1b2c3d     # a1b2c3d = the commit being corrected
# ...later, before opening the pull request:
git rebase -i --autosquash main

The fixup commit is named fixup! Add helper. With --autosquash Git pre-arranges the todo list, moving it directly under the commit it fixes and marking it fixup. I tested this with a file fixed after two later commits: the resulting history had the original Add helper commit containing the corrected file, and the later commit remained after it. To make it the default for interactive rebases: git config --global rebase.autoSquash true.

If your fixup touches the same lines as a later commit, the replay can conflict. That is normal; resolve it as described next.

Conflicts during a rebase

Because rebase replays commits one at a time, a conflict stops it at the commit being applied, and you may meet several. You get these commands:

  • git rebase --continue after resolving and staging the files
  • git rebase --skip to drop the current commit entirely
  • git rebase --abort to return to the state before the rebase started
Could not apply b9df546... Count returns 2 on branch
...
You are currently rebasing branch 'feature/count' on 'b2b5be8'.
  (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)

Resolve the markers exactly as in Part 3, git add the file, then continue. Note that git rebase --continue may open an editor for the commit message; set GIT_EDITOR=true for that single command if you want to keep the message as is.

"Ours" and "theirs" are reversed

This is the part that surprises everyone. In a merge, "ours" is the branch you are on. In a rebase, Git is replaying your commits onto the other branch, so the roles swap. I checked this with a conflict where main returned 1 and my branch returned 2:

<<<<<<< HEAD
    public function count(): int { return 1; }
=======
    public function count(): int { return 2; }
>>>>>>> b9df546 (Count returns 2 on branch)
  • git restore --ours Cart.php produced return 1;, the version from main.
  • git restore --theirs Cart.php produced return 2;, which is your own commit being replayed.

So during a rebase, "ours" means the branch you are rebasing onto, and "theirs" means your own work. Read the marker label (HEAD versus the commit message) rather than trusting the words.

Moving a branch with --onto

Sometimes a branch was started from the wrong place. Say topicB was branched from topicA, and topicA is not going to be merged, but you want topicB's own commit on main:

git rebase --onto main topicA topicB

Read it as "take the commits after topicA up to topicB, and replay them onto main". In my test topicB ended up with just its own commit on top of main, with topicA's commit gone from its history.

Pulling with rebase

When your local branch and the remote branch both have new commits, a plain git pull creates a merge commit. git pull --rebase instead replays your local commits on top of what you fetched, keeping a straight line. You can make it your default with git config --global pull.rebase true. It is safe for the same reason as before: your local, unpushed commits are the ones being rewritten.

Pushing after a rebase

After rebasing a branch you already pushed, your local history and the remote's have diverged, and a normal push is refused:

 ! [rejected]        f3 -> f3 (non-fast-forward)
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart.

The hint suggests git pull, which is the wrong fix here because it would merge your old commits back in. The right tool is a forced push, but use the safer variant:

git push --force-with-lease
 + d0b464f...ff4a732 f3 -> f3 (forced update)

Plain --force overwrites whatever is on the remote. --force-with-lease only overwrites if the remote branch is still where your last fetch saw it. When a teammate pushed in the meantime, it refuses:

 ! [rejected]        f3 -> f3 (stale info)

One limit worth knowing: the lease compares against your remote-tracking reference. If you run git fetch (which updates that reference) and don't look at what arrived, the lease will then pass and you can still overwrite the new commits. Check git log origin/f3 before forcing, or fetch only when you intend to review.

Common problems

SymptomCause and fix
Push rejected after rebase (non-fast-forward)History was rewritten. Use git push --force-with-lease on a branch only you push to.
Same conflict repeated for each commitRebase replays commits one by one. Squash the branch first, or enable rerere (Part 3).
"ours"/"theirs" look backwardsThey are swapped during rebase. "ours" is the branch you're rebasing onto.
Commits seem to have disappearedCheck git reflog and reset to the entry before the rebase.
Teammates' history is now messyYou rebased shared commits. Coordinate, and have them rebase their work onto the new branch.
Not sure how to stopgit rebase --abort restores the branch to its pre-rebase state.

Before you move on

Rebase only commits that nobody else has. Commit with --fixup and tidy with --autosquash before asking for review. Remember that ours and theirs swap during a rebase. Push rewritten branches with --force-with-lease, never plain --force. And if a rebase goes wrong, git rebase --abort or the reflog gets you back.

References: git-rebase, git-push (see --force-with-lease) and git-pull in the official Git documentation.

Related reading: Part 1: Git Staging Area Explained, Part 2: Git Commit Best Practices and Part 3: Git Merge Conflicts.

Free resources for this topic

Get one practical tip every week

New tutorials, fixes and free cheat sheets from DEV Talk, straight to your inbox. No spam, unsubscribe in one click.

Free download: Git cheat sheet (PDF)

Have a question about this post?

Ask the community: other developers and the author can answer.

Ask the community

Get notified about new articles

One click, no email needed. Turn it off anytime with the bell at the top.

Get new articles on your iPhone

Add DEV Talk to your Home Screen, open it, then tap the bell.

Rate this post
0.0
Share

Enjoying the content?

If this article helped you, you can support the author with a coffee. It keeps new posts coming.

Buy me a coffee (opens in a new tab)

Comments 0

Be kind. Comments with links are reviewed before they appear.

No comments yet

Be the first to share your thoughts.

Get new articles in your inbox

Practical tutorials and deep dives, about once a week. No spam, unsubscribe anytime.

Privacy policy

Add DEV Talk to your Home Screen

  1. 1 Tap the Share button in Safari's toolbar.
  2. 2 Scroll down and tap Add to Home Screen.
  3. 3 Tap Add. Open it from the Home Screen to read like an app and to turn on notifications with the bell.

We use cookies to improve your experience and analyze traffic. By continuing, you agree to our Privacy Policy.