Topic · Branching & Integration

Rebasing

Rebase is the other way to integrate work. Instead of tying two branches together with a merge commit, it lifts your commits off the branch, moves the branch to a new base, and replays the commits on top — same changes, different history.

What rebase does, precisely

Rebase takes the commits that are unique to your branch, sets them aside as a list of patches, moves your branch's base to a new commit, and re-applies the patches one by one. After it finishes, your branch contains the same logical changes, but the commits themselves are new objects with new SHA-1 hashes. The originals become unreachable; they survive in the reflog for a while, then are garbage-collected.

That last detail is the whole story in one sentence: the same change, a different history. Two commits with identical patches are different commits if their parents differ, because a commit's hash is computed from its content, its parent, its author, and its timestamp. Change the parent and the hash changes with it. So replaying a commit onto a new base, even with no conflicts, always produces a new commit.

Before: feature was branched from B; main has since moved to C A B C main D E feature After: git rebase main (while on feature) replays D and E onto C as D' and E' A B C main D' E' feature D' and E' are new commits with new SHAs

D and E are gone from the visible history. D' and E' carry the same patches but with C as the new parent of D'. The branch label feature moved to E'. As far as git log is concerned, your work now grows straight out of C, not B.

git rebase <upstream>

The everyday form has one argument:

rebase the current branch onto an updated upstreamshell
git checkout feature
git fetch origin
git rebase origin/main

That tells Git: "Find the commits on feature that aren't on origin/main. Move my branch tip to origin/main. Replay those commits on top." If there are no conflicts, the rebase finishes silently. If there are, Git stops and asks you to resolve, then continue.

If you omit the second argument, the rebase operates on the current branch. git rebase <upstream> <branch> first checks out <branch>, then rebases it. The two-argument form saves a git checkout; otherwise it's identical.

Why people rebase — and why they don't

The pull toward rebase is straightforward: linear history is easier to read. git log --oneline shows a single sequence of commits. Each one applies cleanly on top of the previous. Bisect works without merge commits muddying the picture. A reviewer reading a feature branch sees a story, not a tangle.

The pull away from rebase is equally important: it rewrites history. The commits you had before the rebase no longer exist (visibly) in your branch. If you'd already pushed those commits, anyone who pulled them now has a divergent view of the world. When they re-fetch, Git sees their copy and your rewritten copy as two separate histories, and trying to merge them produces duplicates and confusion.

Rebase shines when Rebase hurts when
Cleaning up your local commits before sharing them. The commits you're rewriting have already been pushed and pulled by others.
Bringing a feature branch up to date with an advanced trunk. You're working on a long-lived shared branch where history matters as a record.
Squashing fixup commits into a focused, reviewable series. You'd lose meaningful context by collapsing distinct logical steps.

The golden rule

Never rebase shared history

Do not rebase commits that exist outside your repository and that other people may have based work on. The trunk branch (main, master, develop) is the canonical example. Rewriting its history breaks every clone that has the original commits. The Pro Git book states this plainly as "the perils of rebasing."

The rule in three lines:

  • Local-only commits: rebase freely. They exist only in your repository; rewriting them costs no one anything.
  • Pushed commits on a topic branch you alone use: rebase if you must, push with --force-with-lease (see below).
  • Shared commits on a shared branch: don't rebase. Merge, or coordinate explicitly with everyone who has the commits.

Conflicts during a rebase

A rebase replays your commits one by one. Each replay is essentially a git cherry-pick, and any of them can hit a conflict. Resolution looks like the resolution you already know from merge conflicts: open the file, fix the conflict markers, stage the result. The difference is the verb you use to continue.

typical rebase-conflict resolutionshell
# rebase pauses on the offending commit
git status                # shows files with conflicts
$EDITOR src/parser.js     # fix the markers
git add src/parser.js     # stage the resolution
git rebase --continue     # replay the rest

# escape hatches:
git rebase --abort        # roll back to pre-rebase state
git rebase --skip         # drop this commit and continue

Three things to remember. First, --abort is always safe: Git records the pre-rebase tip in ORIG_HEAD, so aborting puts you exactly where you started. Second, you may need to resolve conflicts on more than one commit; the rebase will stop, you'll fix, continue, stop, fix, continue. Third, --skip drops the conflicting commit entirely; use it only when the commit is genuinely obsolete (e.g., already applied upstream).

Interactive rebase

The big-deal feature. Passing -i opens an editor with a list of the commits about to be replayed and a verb in front of each one. Change the verbs (or reorder the lines), save, exit, and Git carries out the script.

git rebase -i HEAD~4todo file
pick   2c0f7a1 Add parser scaffolding
reword 8e3b9d4 Add empty-input tests
squash 1a4d22e Fix typo in parser
fixup  9b772ce Another typo fix
drop   3d11ef0 Experimental: try regex engine

# Rebase d4e5f6a..3d11ef0 onto d4e5f6a (5 commands)
#
# Commands:
# p, pick   = use commit
# r, reword = use commit, but edit the commit message
# e, edit   = use commit, but stop for amending
# s, squash = use commit, but meld into previous commit
# f, fixup  = like "squash", but discard this commit's log message
# d, drop   = remove commit

The verbs do what they say. pick keeps the commit as-is. reword stops to let you rewrite the message. edit stops with the commit applied but unstaged, so you can amend the contents. squash merges the commit into the previous one and lets you compose a combined message. fixup is squash without the combined message — the previous commit's message wins. drop removes the commit. You can also reorder lines to rearrange commits, as long as the new order produces a buildable history at each step.

Interactive rebase is how you turn a stream of "wip", "fix again", "oops" commits into a clean, reviewable series before opening a pull request. It's the one tool that justifies committing imperfect work-in-progress: you can always reshape it later.

Autosquash workflow

When you spot a small fix that belongs in an earlier commit, you have two options. You can find the commit, run an interactive rebase, mark the right line as edit, amend, and continue. Or you can stage the fix and tell Git which commit it belongs to:

commit a fix earmarked for an earlier commitshell
git add src/parser.js
git commit --fixup=8e3b9d4   # creates "fixup! Add empty-input tests"

That commit's message starts with fixup! followed by the subject of the target commit. Later, when you run an interactive rebase with --autosquash, Git automatically rearranges the todo list so each fixup sits right after its target with the fixup verb:

autosquash flatten the fixupsshell
git rebase -i --autosquash <base>

You eyeball the todo list, save the editor, and the fixups disappear into their targets. The pattern lets you commit work-in-progress and amendments freely throughout a session, then collapse everything into clean commits in one move.

Two configs worth setting

Set rebase.autoSquash = true so git rebase -i always behaves as if you passed --autosquash. Set pull.rebase = true so git pull rebases your local commits on top of upstream instead of creating a merge commit on every pull. Together they make linear history the default without extra typing:

~/.gitconfigshell
git config --global rebase.autoSquash true
git config --global pull.rebase true

--onto: surgical rebases

The general form of rebase takes three arguments:

rebase a range of commits onto a new baseshell
git rebase --onto <newbase> <oldbase> <branch>

This says: "Take the commits that are on <branch> but not on <oldbase>, and replay them on <newbase>." The everyday two-argument form git rebase <upstream> is the special case where <newbase> and <oldbase> are the same.

The classic use is moving a stack of commits to a different parent. Suppose you branched client off server, but you actually want those client commits to live on main instead. The commits unique to client (everything after server's tip) should land on main:

transplant client commits from server onto mainshell
git rebase --onto main server client

Three arguments, three roles: the new home, the old base to subtract, and the branch to move. Read in order, the command translates to "onto main, take whatever's past server, in the branch client."

Pushing after a rebase

If a branch is rebased after it has been pushed, the next git push will be rejected. The remote's history and your local history have diverged — not in the additive way Git is used to, but because your local copy replaced commits the remote still holds. A normal push would have to overwrite the remote, and Git refuses without permission.

The first instinct is git push --force. Don't. --force overwrites the remote ref blindly, with no check that you have an up-to-date view of what's there. If a teammate pushed to the branch since you last fetched, their commits vanish. Use --force-with-lease instead:

--force-with-lease

A safer alternative to --force. Refuses the push if the remote ref has moved since you last fetched it. Concretely, Git compares the remote's current value to your local remote-tracking branch (e.g., origin/feature): if they match, the push proceeds; if they differ, someone else has pushed and the operation is aborted so you can investigate.

push a rebased branch safelyshell
git fetch origin                       # update remote-tracking refs
git rebase origin/main                 # rebase locally
git push --force-with-lease origin feature

If the push is rejected, do not retry with --force. Fetch, inspect what's new on the remote, decide whether to incorporate it, then push with --force-with-lease again. The check exists precisely so you can't lose someone else's work by accident.

Rebase vs merge

Both produce an integrated branch. They differ in what history records and how it reads later.

Question Rebase Merge
What does history look like? Linear: each commit a single parent. Graph: a merge commit with two parents at every integration.
Do commit SHAs change? Yes — replayed commits have new hashes. No — original commits stay; a new merge commit is added.
Is the integration event preserved? No. The branch looks like it was always on the new base. Yes. The merge commit records that two branches came together.
Safe on shared branches? No. Yes.

A workable team policy: rebase local feature commits onto an updated trunk before opening a PR (clean, focused, easy to review); merge when integrating long-lived branches or when the historical fact "this was a feature" is worth preserving. Many teams compromise with squash-merge on PR merge — the feature branch is developed however the author likes, then collapses into a single commit on the trunk.

Pull --rebase

By default, git pull fetches and then merges. If you've made local commits since your last fetch and the remote has moved, you get a merge commit on every pull. The history fills up with "Merge branch 'main' of origin/main" entries that record nothing meaningful.

Rebase fixes this. git pull --rebase fetches the remote, then rebases your local commits on top — as if you'd pulled before making your changes. The result is linear: upstream commits, then your work, no merge bubble. For the case where you're working alone on a branch (or you only have local commits ahead of the remote), this is almost always what you want. Set it as the default:

make pull rebase by defaultshell
git config --global pull.rebase true

The same golden rule still applies: only your local commits get rebased, so the operation is safe by construction. The fetched upstream commits are untouched.

Common pitfalls

Rebasing the shared trunk

Never run git rebase on main or any branch other people push to. Even one such rewrite can break every clone in the team. Rebase only on topic branches you own.

Force-pushing without --force-with-lease

git push --force blindly overwrites the remote and silently destroys work pushed by others since you last fetched. Always use git push --force-with-lease. If it rejects, fetch and investigate — never escalate to plain --force.

Aborting halfway and forgetting where you were

A rebase that's paused on a conflict leaves your working tree in a strange in-between state. If you wander off, git status will remind you ("interactive rebase in progress"). Either --continue or --abort; don't leave it dangling, and don't try to commit your way out manually.

Squashing distinct logical changes

Interactive rebase makes it tempting to flatten an entire feature into one commit. Resist when the steps are genuinely distinct: a refactor and the feature it enables are easier to review (and bisect) as two commits than one.

Forgetting to fetch first

git rebase origin/main uses your local view of origin/main. If you haven't fetched, you're rebasing onto a stale tip and will likely have to rebase again. Fetch, then rebase.

Worked examples

Update a feature branch onto a moved main
scenario: feature was branched from old main; main has since movedshell
git checkout feature
git fetch origin
git rebase origin/main

# resolve any conflicts as they arise:
#   edit file, git add, git rebase --continue

# share the rebased branch (already pushed before):
git push --force-with-lease origin feature

Result: the feature branch now grows out of the current main. Reviewers see a clean, linear diff against trunk; CI runs against the actual base it will be merged into.

Clean up four wip commits into two focused ones
before: messy local historyshell
a1b2c3d Add parser
e4f5g6h wip
i7j8k9l fix typo in parser
m0n1o2p Add parser tests
git rebase -i HEAD~4todo file
pick   a1b2c3d Add parser
fixup  e4f5g6h wip
fixup  i7j8k9l fix typo in parser
pick   m0n1o2p Add parser tests

Save the editor. The two fixup commits collapse into "Add parser"; the test commit stays as-is. Final history: two clean commits, ready for review.

Use --fixup and --autosquash for a forgotten edit

You realize the parser commit (a1b2c3d) needs one more line. Don't stop what you're doing — stage the change as a targeted fixup:

commit the fix earmarked for the targetshell
git add src/parser.js
git commit --fixup=a1b2c3d

Keep working. When you're ready to tidy up:

autosquash flattenshell
git rebase -i --autosquash origin/main

Git pre-arranges the todo list so the fixup sits right under a1b2c3d with the fixup verb. You save the editor, and the fix folds in automatically. The original commit message survives unchanged.

Transplant a stack of commits with --onto

You branched client off server two commits ago, but it should really sit on main instead. Move it:

git rebase --onto main server clientshell
git rebase --onto main server client

Git extracts the commits on client that aren't on server, then replays them on top of main. The server branch is untouched; only client moves. Push with --force-with-lease if client had already been shared.

Sources & further reading

The two Git references — Pro Git and the man pages — are the source of truth for behavior. Atlassian and GitHub's docs are clearer for picking a workflow.