Rebasing After a Squash-Merge: Using git rebase --onto to Skip Already-Merged Commits

·11 min read

You're on a fix branch. You ran git rebase main. It paused immediately with a conflict in the first commit.

You look at the conflict. It's in code you didn't write. It's code that was already merged to main yesterday. You don't recognize any of the lines.

You skip the commit. The next one conflicts on the same files. You skip again. After three skips you stop and check the rebase TODO: 18 commits remaining, all conflicting.

You haven't touched 17 of these commits. Why is git replaying them?

Table of Contents

  1. The Problem: Squash-Merge Hides the Work from git rebase
  2. The Setup
  3. Why git rebase main Fails Here
  4. The Fix: git rebase --onto
  5. Step by Step
  6. Verifying the Result
  7. The Full Command Sequence
  8. Why Squash-Merges Cause This in the First Place
  9. When --onto Is the Wrong Tool
  10. Takeaways

The Problem: Squash-Merge Hides the Work from git rebase

git rebase is more capable than it sometimes looks. When replaying commits onto a new base, it tries to skip ones that are already applied by comparing patch IDs: a hash of each commit's textual diff. If your commit's patch ID matches a commit already in the new base, git omits it from the replay.

Regular merge commits keep this check working. Squash-merges break it.

A squash-merge collapses many commits into one new commit whose diff is the combined changes of all the originals. The squash's patch ID hashes that combined diff. None of the original commits have a patch ID matching the combined diff (each hashes its own individual change). So git's "already applied" check doesn't recognize the equivalence.

The result: git tries to replay every original commit on top of the new base. The work is already there, so the 3-way merge fails on every commit and you get conflicts on code you didn't write. These conflicts aren't pointing at a real disagreement, they're just artifacts of the squash. But git can't tell, and the rebase walks through every one of them.

The Setup

Here's the situation I hit. A teammate opened a refactor PR with 18 commits on a branch called feature/big-refactor. I reviewed it, found a bug, and opened my own fix branch on top of their work:

$ git log --oneline -5 fix/cache-recovery
c8a91b3 fix: re-emit state on offline transition       # my fix
f7d4e2a test: cover selector pick logic                 # last upstream commit
e8b1d4a test: more selector cases
5c7a8d2 test: add deregister and duplicate cases
9b2f1a8 fix: add ErrNoCapacity

The branch graph looked like this:

old main ───●
              \
               ●──●──...──● (f7d4e2a)   ← 18 commits from feature/big-refactor
                          \
                           ● (c8a91b3)  ← my fix
                             fix/cache-recovery

Then the refactor PR got squash-merged into main:

$ git log --oneline -3 main
a4f1c2e refactor: improve cache and selector subsystem (#42)
7a3b9e0 docs: add codecov badge (#41)
1f6c8d4 build(deps): bump docker/login-action from 3 to 4 (#40)

a4f1c2e is one commit. It contains every line of code from the original 18, but it's a new commit with a new SHA. The 18 originals are gone from main's history.

I want to rebase my fix onto the new main so it's just one commit ahead. Should be a one-liner:

$ git checkout fix/cache-recovery
$ git rebase main
Auto-merging internal/provider.go
CONFLICT (content): Merge conflict in internal/provider.go
Auto-merging internal/provider_test.go
CONFLICT (content): Merge conflict in internal/provider_test.go
Auto-merging internal/registry.go
CONFLICT (content): Merge conflict in internal/registry.go
Could not apply e2f8b1c... refactor: rework Provider interface

That's the first commit being replayed: e2f8b1c, "rework Provider interface." It's the first of the 18, written days ago, already in main. The rebase TODO shows 17 more commits like it.

Why git rebase main Fails Here

When you run git rebase main, git computes which commits exist on your branch that aren't in main, then replays them on top of main one by one. The range it picks is main..HEAD: commits reachable from HEAD but not from main.

For each commit in that range, git does a 3-way merge: it combines the commit's parent state, the commit's own state, and the new base (main). For a regular merge commit on the base, this 3-way merge resolves cleanly because git can identify shared history.

After a squash-merge, there's no shared history at the commit level:

  • The original 18 commits on feature/big-refactor have SHAs like e2f8b1c, c4a8d3f, 9b1e7a2. None of those SHAs are in main.
  • Main has one different SHA, a4f1c2e, that contains all of their content.
  • The patch-ID check (textual-diff equivalence) doesn't match either, because the squash's combined diff has a different patch ID than any individual commit's diff.

So git sees 19 commits on your branch that aren't in main and tries to replay all 19.

Each of those 18 originals is a patch that says "change line X from A to B," where A and B are the line's states before and after that one commit. Main now has the final state Z of line X, after all 18 commits squashed together. When git replays commit 1's patch, the 3-way merge sees both "theirs" (the original) and "ours" (the squash) editing the same lines from the same parent. It can't decide which edit wins, so it reports a content conflict. The remaining 17 commits do the same thing for the same files.

The Fix: git rebase --onto

Instead of letting git decide what to replay, you tell it explicitly: replay only the commits after this boundary, and land them on top of that base.

git rebase --onto main f7d4e2a fix/cache-recovery

One command and the conflicts go away.

How --onto Works

The full form is:

git rebase --onto <newbase> <upstream> [<branch>]
  • <newbase>: where the replayed commits will land. In my case, main.
  • <upstream>: the boundary. Everything reachable from the branch but not reachable from <upstream> gets replayed. Everything reachable from <upstream> is dropped.
  • <branch>: which branch to operate on. Defaults to current HEAD.

So git rebase --onto main f7d4e2a fix/cache-recovery reads as: "Take commits in fix/cache-recovery that are not reachable from f7d4e2a, replay them onto main."

Picking the Upstream Boundary

<upstream> is the part that needs care. You want it to be the last commit on your branch whose content is already in <newbase>, even if its SHA isn't.

In my case:

  • The last commit on my branch that was part of the squash-merged work is f7d4e2a.
  • Everything f7d4e2a and earlier is content-equivalent to what's in a4f1c2e (the squash on main).
  • So f7d4e2a is the right boundary.

The set of commits reachable from fix/cache-recovery but not from f7d4e2a is just one commit: c8a91b3, my fix.

Visualized:

new main ───● (a4f1c2e)         ← replay lands here
              \
               ● (b3d5e9f)      ← my fix, new SHA after rebase
                  fix/cache-recovery

The 18 already-merged commits are dropped from the replay because they fall outside the range.

Step by Step

When the rebase paused with the first conflict, the first move is to abort. Resolving these manually is wasted effort, because every resolution will be "accept whatever main has."

Step 1: Abort the in-progress rebase.

$ git rebase --abort
$ git status
On branch fix/cache-recovery
nothing to commit, working tree clean

Abort restores the branch to its pre-rebase state. The reflog still has the original HEAD if you need to recover.

Step 2: Identify the upstream boundary.

You need the SHA of the last commit on your branch that's content-equivalent to <newbase>. The simplest way is git log against your branch:

$ git log --oneline fix/cache-recovery -5
c8a91b3 fix: re-emit state on offline transition       # mine, want to keep
f7d4e2a test: cover selector pick logic                 # last upstream commit
e8b1d4a test: more selector cases
5c7a8d2 test: add deregister and duplicate cases
9b2f1a8 fix: add ErrNoCapacity

f7d4e2a is the last commit that's part of the squash-merged work. Everything below it is too.

Step 3: Verify the range before running the rebase.

$ git log --oneline f7d4e2a..fix/cache-recovery
c8a91b3 fix: re-emit state on offline transition

This is what --onto will replay. Just the fix.

Step 4: Run the rebase.

$ git rebase --onto main f7d4e2a fix/cache-recovery
Rebasing (1/1)
Successfully rebased and updated refs/heads/fix/cache-recovery.

No conflicts to resolve.

Verifying the Result

Check the new history:

$ git log --oneline -3
b3d5e9f fix: re-emit state on offline transition       # rebased, new SHA
a4f1c2e refactor: improve cache and selector subsystem (#42)
7a3b9e0 docs: add codecov badge (#41)

A few things to notice:

  • The fix's SHA changed from c8a91b3 to b3d5e9f. Rebasing always rewrites SHAs because timestamps and parents change.
  • The fix now sits directly on top of main's a4f1c2e.
  • The 18 already-merged commits are gone from the branch's history.

Confirm the branch is exactly one commit ahead of main:

$ git log --oneline main..HEAD
b3d5e9f fix: re-emit state on offline transition

Confirm the code is intact:

$ git diff main -- internal/registry.go

The diff should show only the fix, not 18 other changes.

The Full Command Sequence

Here's everything in order:

# 1. Abort the failing rebase
git rebase --abort

# 2. Find the upstream boundary (last commit before yours that's already in main's squash)
git log --oneline fix/cache-recovery

# 3. Verify the replay range is what you expect
git log --oneline <upstream>..fix/cache-recovery

# 4. Run the rebase with --onto
git rebase --onto main <upstream> fix/cache-recovery

# 5. Verify the result
git log --oneline main..HEAD

# 6. Force-push if the branch was already published
git push --force-with-lease origin fix/cache-recovery

The fourth command does the actual work. The rest are setup and verification.

Why Squash-Merges Cause This in the First Place

Squash-merging is GitHub's default for many teams. It produces a clean main history where every PR is one commit. The trade-off is the situation I described above:

  • The merged commits keep their content but lose their SHAs.
  • The combined diff has a different patch ID than any individual commit's diff, so git's "already applied" detection doesn't recognize it.
  • Any branch built on top of the original SHAs gets stranded.

A regular merge commit preserves the original SHAs and their individual patch IDs in main's history. Standard git rebase main would see those and skip them, and you wouldn't hit any of this.

If your team uses squash-merges (most do), every long-lived branch that touched the same files as a recently-merged PR will hit this. --onto is the cheapest way out.

When --onto Is the Wrong Tool

--onto skips commits unconditionally. If you have real work in those commits that wasn't part of the squash-merge, dropping them is wrong.

If your branch has:

A → B → C → D → E (my own work)

...and the squash-merged PR contained only A → B → C, then D and E are still your own work and you need to replay them. Use git rebase --onto main C so the replay range is D, E. That works the same way.

If your branch has commits interleaved between PR commits and your own (you cherry-picked some, wrote some new, then rebased), --onto gets fiddly. In that case, git rebase -i main and dropping the duplicates manually is usually cleaner.

--onto works best when the commits you want to skip form a contiguous prefix of your branch.

Takeaways

Squash-merges break git's "already applied" detection. Git tries to skip commits whose patch ID matches one already in the base. The squash's patch ID hashes the combined diff, not any individual commit's diff, so the check doesn't find a match. Git replays every original commit and the 3-way merge fails on each one.

If you see conflicts on commits you didn't write, suspect a squash-merge. Especially if the conflicting files are ones touched heavily by a recently-merged PR. That's the usual cause.

git rebase --onto <newbase> <upstream> lets you pick the replay range explicitly. The replay is "everything reachable from your branch, but not from <upstream>." Anything reachable from <upstream> is dropped from the replay.

Pick <upstream> carefully. It should be the last commit on your branch whose content is already in <newbase>. Run git log <upstream>..HEAD to confirm the replay range before you commit to the rebase.

Abort before you start manually resolving. If you've already started clicking through conflict markers, git rebase --abort will roll back cleanly without losing history.

Use --force-with-lease to push. Rebasing rewrites SHAs. If the branch is published, you need to force-push, and --force-with-lease won't overwrite a teammate's commits if they pushed since your last fetch.

The reflog keeps a record. Even if --onto lands somewhere you didn't want, git reflog show <branch> lists every previous HEAD. You can reset to any earlier state. Very little in git is truly unrecoverable.