# Rebasing Stacked Branches Without the Squash-Merge Pain

Published 2025-12-08. Updated 2026-09-17.
Canonical URL https://log.delaa.dev/blog/2025-12-08-git-rebase-onto-stacked-branches

> A dependent branch, a squashed parent, and the Git flag most developers never reach for until it saves them.

---

<Image
  src="/images/git-rebase-onto-stacked-branches/hero.png"
  alt="A git commit graph showing a dashed old commit bypassed while a child branch is rebased onto a new blue commit line"
  width={2000}
  height={1125}
  className="rounded-lg"
/>

You're on a branch that depends on another branch. Not a library dependency, a
code dependency. The function you need, the schema change you're building on top
of, the shared fix your feature assumes exists. That branch is still in review.

So you branch off it anyway, because waiting for a review to land before you can
write a single line is not how the work gets done. You keep working. Review takes
longer than expected. It always does. Eventually the base branch gets approved
and merged, as a **squash merge**, because that's the team's convention. One clean
commit per feature on `main`, no messy history.

Now you go back to your branch and run the routine command:

```bash
git rebase main
```

And Git tries to replay every commit from the base branch on top of `main`, even
though those commits are already there, just squashed into a single new commit
with a different hash. Git doesn't know that. It sees commits it doesn't
recognize, and it does what rebase always does with unrecognized commits. It
replays them, one by one, and asks you to resolve every conflict along the way.

The frustrating part isn't that there are conflicts. It's that the conflicts are
fake. The code is already there. You're being asked to manually reconcile changes
against a version of themselves.

## Why the plain rebase breaks

A squash merge collapses N commits into 1. The new commit's hash has no
relationship to any of the original commits. Git can't trace lineage through it.

```mermaid
gitGraph
  commit id: "main before"
  branch base-branch
  checkout base-branch
  commit id: "C1 original commit"
  branch your-branch
  checkout your-branch
  commit id: "C2 your work"
  checkout main
  commit id: "S1 (squashed base branch)"
```

When you rebase `your-branch` onto `main`, Git's default behavior is to find every
commit in `your-branch` that isn't an ancestor of `main`, and replay them in
order. Since `C1` predates the squash and isn't recognized as "already merged,"
it gets replayed too, on top of a `main` that already contains its changes
(inside `S1`). Conflict.

Multiply this by every file `C1` touched, and a five-minute rebase becomes a
thirty-minute conflict-resolution exercise on commits you never needed to touch.

## The fix, `git rebase --onto`

`--onto` lets you tell Git exactly which commits to move, and where to graft
them, instead of letting it guess based on ancestry it can no longer see.

```bash
git rebase --onto <new-base> <old-base> <your-branch>
```

In this scenario:

```bash
git rebase --onto main base-branch your-branch
```

Read it as follows. *Take everything in `your-branch` that isn't already in
`base-branch`, and replay only that, on top of `main`.*

Before:

```mermaid
gitGraph
  commit id: "main"
  commit id: "S1"
  branch base-branch
  checkout base-branch
  commit id: "C1"
  branch your-branch
  checkout your-branch
  commit id: "C2"
```

After `git rebase --onto main base-branch your-branch`:

```mermaid
gitGraph
  commit id: "main"
  commit id: "S1"
  commit id: "C2'"
```

`C1` is never replayed. It's already accounted for, inside `S1`. Only `C2`, your
actual new work, moves. Zero conflicts related to the squashed parent.

## The workflow that avoids this entirely

The best fix is not reaching for `--onto` after the fact. It's structuring
dependent work so you rarely need to.

1. Create a shared technical base branch off the trunk for the piece everything
   else depends on.
2. Branch every dependent feature off that base branch, not off the trunk
   directly.
3. Once the base branch is reviewed and squash-merged, resync every dependent
   branch with a single `--onto` command instead of a painful rebase.

```bash
git checkout base-branch
git checkout -b your-feature
# ... work ...
# base-branch gets squash-merged into main
git rebase --onto main base-branch your-feature
```

This turns "wait for review before starting" into "start now, resync in one
command later", without losing the clean, squashed history the team wants on
the trunk.

## When this isn't worth it

If your team merges fast and reviews land in hours, not days, you probably
never hit this. Branching off the trunk directly and rebasing normally is simpler, and
you shouldn't add process for a problem you don't have.

This pattern earns its place when dependent work is common and review turnaround
is slow enough that "wait" isn't a real option. If that's not your situation,
skip the ceremony. If it is, `--onto` is worth knowing before the pain teaches it
to you the hard way.
