Sitemap

git rebase --onto: The Trick I Wish I'd Known for Stacked Pull Requests

--

Yesterday I spent ten minutes watching git rebase produce conflict after conflict on a follow-up PR. Fifteen conflicts in a row, all duplicates, all in files I'd barely touched. Every conflict was the same three lines, asking me to choose between "the version I wrote" and "the version that's already on main."

I hadn’t done anything wrong. I’d hit the squash-merge trap, and the fix turned out to be a five-word incantation I’d never used before: git rebase --onto.

Here’s what I learned.

How I got there

I’d been doing stacked work - a feature too big for one PR, so I split it into three. Step 1 was the foundation, step 2 built on it, step 3 built on step 2. While step 1 was in review, I started step 2 by branching off it. Standard stacked-PR pattern.

main:    A ── B ── C
\
step 1: P1 ── P2 ── P3
\
step 2: P4 ── P5

Step 1 got approved and merged. The reviewer hit “Squash and merge” - our team’s default.

That collapsed P1, P2, P3 into one new commit on main:

main:    A ── B ── C ── S
▲
all of P1+P2+P3 squashed into one new SHA

S has the same content as P1+P2+P3 combined, but a completely different SHA. The original P1, P2, P3 SHAs are nowhere in main's history.

So I did what I’d always done - git fetch origin main && git rebase origin/main - and git replayed every commit on my branch onto main, including P1, P2, P3. Each one tried to apply a patch whose content was already there under S. Each one conflicted on lines that already existed.

I spent the next hour clicking through “accept incoming” prompts before someone on my team pointed me at git rebase --onto.

What was actually going on

Git doesn’t know “P1+P2+P3 = S.” Git only tracks commits by SHA, and S’s SHA isn’t in my branch’s history. From git’s perspective, step 2 still had all five commits as unique work that needed to be replayed.

What I actually wanted to say was: “Just take P4 and P5. Forget P1, P2, P3 - those are already there.”

That’s exactly what git rebase --onto does.

The command

git rebase --onto <new-base> <old-base> <branch>

I read it as a sentence: “Take every commit reachable from <branch> but not from <old-base>, and replay it on top of <new-base>."

For my situation:

git rebase --onto origin/main P3 step-2

“Replay every commit after P3, up to the tip of step-2, onto origin/main.”

Result:

main:    A ── B ── C ── S
\
step 2: P4' ── P5'

Two commits, no conflicts. Done.

The two things that tripped me up

<old-base> is exclusive. P3 itself does not get replayed. I'm naming the last commit I want to drop, not the first commit I want to keep. The first time I tried this, I pointed at the wrong end of the range and accidentally dropped my real work. Easy mistake.

<branch> is inclusive. Everything up to and including the branch tip comes along.

My recipe now

Whenever a parent branch I depend on lands via squash merge, here’s what I do:

Get Minh Reigen’s stories in your inbox

Join Medium for free to get updates from this writer.

1. Find the boundary commit.

git log --oneline origin/main..HEAD

This lists every commit on my branch that isn’t on main yet. There’s always a clear visual boundary between “the parent PR’s commits” and “my real work” - the commit messages give it away.

Here’s what mine looked like:

a65dd6c4c5 representer + FE combo box calls internal API EP   ← my real work
6ac7fbe18c internal API endpoint for billing sch search ← my real work
7d80eef20b fb: handle blank string submission ← parent PR
849c68de7a fb: error display bug ← parent PR
... 13 more parent PR commits ...

The last parent commit was 7d80eef20b. That was my <old-base>.

2. Run the rebase.

git fetch origin main
git rebase --onto origin/main 7d80eef20b

I omit <branch> because I'm already on it.

3. Verify.

git log --oneline origin/main..HEAD

Should show only my real work. If I see commits from the parent PR, I pointed <old-base> at the wrong place git rebase --abort and try again.

When I don’t use this

I don’t run git rebase --onto on a branch that's already open as a PR with reviewers actively looking at it. Rebase rewrites SHAs, which forces a force-push, which resets reviewers' "you've reviewed up to commit X" state and orphans inline comments anchored to old line numbers.

For PRs already in review, I merge main into my branch instead. Non-destructive, GitHub shows reviewers “changes since you last reviewed,” everyone stays sane.

I save --onto for:

  • Solo follow-up branches that aren’t yet under review.
  • Cleaning up a local branch before opening the PR.
  • The exact scenario above - my dependency landed and I need to detach from it cleanly.

A bonus use I picked up

The same syntax drops a single commit I no longer want. Say my history is:

A ── B ── C ── D (HEAD)

And I want to drop C:

git rebase --onto B C HEAD

Replay everything after C, up to HEAD, onto B → A ── B ── D'. C is gone.

(git rebase -i and deleting the line is usually friendlier for a single commit. But --onto works too, and once the mental model clicks, it's nice to have a single tool that does both jobs.)

What I took away

git rebase --onto looked intimidating to me for years until I realized it's just "transplant this range of commits over there." Once that clicked, I stopped fearing stacked PRs.

The next time my follow-up PR’s rebase explodes after the parent landed, I’m not going to slog through fifteen conflicts. I’ll find the boundary commit, run one command, and move on.

My future self at 11pm will thank me.

A note on team workflow: if your team does stacked work often, you can sidestep this entirely by using merge commits (not squash merges) for the parent PRs. The original commit SHAs stay reachable, and git rebase origin/main "just works" because git recognizes the duplicates. But most teams I've worked on prefer squash merges for cleaner main histories - in which case, this trick is the one I now keep in my back pocket.

--

--