If you work in an engineering team that uses stacked branches or a Squash and Merge PR workflow, you have almost certainly encountered this stomach-dropping nightmare:
- You create branch
feature-Bofffeature-Awhile waiting for code review. -
feature-Agets approved and merged intomainusing GitHub's or GitLab's Squash & Merge. - You change the target of your
feature-Bpull request tomain. - Disaster strikes: Your PR suddenly shows 25 commits instead of 3, hundreds of lines of duplicate diff you didn't write, and conflicting files you never touched.
Naturally, your first instinct is to run:
git checkout feature-B
git rebase main
...only to be greeted by a terrifying stream of merge conflicts.
Why Does Standard Rebase Fail Here?
When GitHub or GitLab squashes feature-A, it bundles all its changes into a single brand-new commit on main with a brand-new SHA hash.
A standard git rebase main looks backward from feature-B to find the common ancestor with main. Because the individual commit hashes from feature-A don't exist on main, Git assumes those commits are new changes you authored, and tries to replay them one-by-one—colliding directly with the squash commit that already contains those exact changes.
This is where Git's most powerful, under-utilized precision tool comes in: git rebase --onto.
The Mental Model: Scissors & Glue
A standard rebase says:
"Take everything on my branch since it diverged from base, and replay it on top of new base."
The --onto flag gives you precision scissors:
"Take only the commits between point X and point Y, cut them out, and glue them onto point Z."
git rebase --onto <new-base> <old-base> [<branch>]
| Parameter | What it represents | Analogy | Boundary Rule |
|---|---|---|---|
<new-base> |
Where commits will land | The table where you paste the slice | Target base |
<old-base> |
The cutoff point where slice begins | Where you put the first scissor cut | Exclusive (not copied) |
[<branch>] |
The tip of the commit slice | Where you put the second scissor cut |
Inclusive (defaults to HEAD) |
In set notation: Git replays (old-base .. branch].
Visualizing the Problem & The Fix
The Squash-Merge Trap
Squashed & merged via GitHub UI
┌─────────────────────────────────┐
│ ▼
main: o───o─────────────────────────────────S (Squash Commit: SHA '9f2a')
\
feature-A: A1───A2───A3 (SHA '1111' .. '3333')
\
feature-B: B1───B2 (HEAD)
Because commit S has hash 9f2a, Git doesn't know that A1, A2, and A3 are already inside S.
The Surgical Suture
You want Git to discard A1..A3 and only grab B1 and B2, pasting them onto S:
git checkout feature-B
git fetch origin
# Syntax: git rebase --onto <where-to-land> <where-to-cut-from> <tip>
git rebase --onto origin/main origin/feature-A feature-B
The Clean Result:
main: o───o───S───B1'───B2' (feature-B)
No duplicate commits. Zero false diffs in your PR.
3 Real-World Scenarios with Concrete Examples
Scenario 1: The Stacked PR After Squash Merge (The Classic)
Let's look at what your terminal actually looks like in this scenario.
Suppose your git log --oneline on feature-B looks like this:
f6a1b2c (HEAD -> feature-B) Add checkout UI tests
e5d4c3b Add payment checkout UI
3a2b1c0 (origin/feature-A) Finalize Stripe client
2f1e0d9 Add Stripe webhooks
1c2b3a4 Setup Stripe API keys
Commits 1c2b3a4, 2f1e0d9, and 3a2b1c0 are from feature-A, which was just squash-merged into main as commit 888aaaa.
The Command:
git fetch origin
git rebase --onto origin/main origin/feature-A feature-B
Verify the result:
git log --oneline -n 3
Output:
a1b2c3d (HEAD -> feature-B) Add checkout UI tests
9e8d7c6 Add payment checkout UI
888aaaa (origin/main) Merge pull request #101 from feature-A
Clean, surgical, and conflict-free.
Scenario 2: Slicing Out Broken or Experimental Commits
Imagine you were hacking away on a feature, added experimental debugging/mocking code, and later wrote clean implementation commits on top. You want to ditch commits C and D without entering an interactive rebase editor:
A ─── B ─── C (bad mock) ─── D (dirty debug) ─── E (clean impl) ─── F (clean tests - HEAD)
You want to keep A, B, E, and F.
The Command:
git rebase --onto B D HEAD
Remember the exclusive rule: <old-base> is D. Git takes everything after D up to HEAD (E and F) and grafts them directly on top of B.
A ─── B ─── E' ─── F' (HEAD)
Scenario 3: Splitting a Monolithic 10-Commit Branch into Two Clean PRs
You got carried away and implemented database migrations, backend services, and frontend UI all in one branch: mega-feature (10 commits).
Your team lead asks: "Can you split this into two PRs? One for Backend, one for Frontend?"
Inspect your log:
* 9999999 (HEAD -> mega-feature) [FE] Connect form to API
* 8888888 [FE] Build dashboard UI
* 7777777 [FE] Add UI state management
* 6666666 [BE] Expose REST endpoints
* 5555555 [BE] Add service layer logic <-- Split point!
* 4444444 [BE] Add database migrations
* ...
Step 1: Branch off Part 1 (Backend)
Point a branch at commit 5555555:
git branch feature-part1-backend 5555555
Step 2: Extract Part 2 (Frontend) onto main
Now slice out commits 6666666 through HEAD and rebase them onto main:
git checkout mega-feature
git rebase --onto main feature-part1-backend mega-feature
git branch -m mega-feature feature-part2-frontend
Now you have:
-
feature-part1-backendready for review with commits 1–5. -
feature-part2-frontendready to stack on top of part 1 or targetmainindependently.
Pro-Tips & Safety Nets
1. The Exclusive Boundary Trap (The Parent ^ Trick)
If you want to move a specific single commit X onto main:
- Running
git rebase --onto main X branchwill exclude commitX. - Instead, run:
git rebase --onto main X^ branch(usingX^targets the parent ofX, ensuringXis included).
2. Always Verify Before Pushing
Before force-pushing, inspect the exact commits that will go up to the remote:
git log origin/main..HEAD --oneline
If you see anything you didn't write or expect, stop and inspect.
3. Safety Net: The Undo Button (git reflog)
Mess up your cut? Git does not delete commits immediately.
git reflog
Find the entry right before you ran the rebase (e.g., HEAD@{1}: checkout: moving from...), then reset:
git reset --hard HEAD@{1}
You are immediately back to where you started.
4. Always Push with Lease
Rewriting commit history requires a force push. Avoid bare git push -f. Always use:
git push --force-with-lease origin <branch-name>
--force-with-lease guarantees that if a teammate pushed changes to your branch while you were rebasing, Git aborts rather than overwriting their work.
Summary Cheat Sheet
# When feature-A was squashed into main:
git rebase --onto origin/main origin/feature-A feature-B
# Drop commits between commit-X and commit-Y:
git rebase --onto <commit-X> <commit-Y> HEAD
# Verify your slice:
git log origin/main..HEAD --oneline
# Push safely:
git push --force-with-lease origin feature-B
Have you had to rescue a stacked PR recently? Drop your favorite Git surgical tricks in the comments below!
Top comments (0)