AmitMaster Git's precision scalpel: learn how git rebase --onto untangles stacked PRs, squash merges, bad commits, and branch splits.
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:
feature-B off feature-A while waiting for code review.feature-A gets approved and merged into main using GitHub's or GitLab's Squash & Merge.feature-B pull request to main.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.
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.
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].
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.
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.
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.
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)
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
* ...
Point a branch at commit 5555555:
git branch feature-part1-backend 5555555
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-backend ready for review with commits 1–5.feature-part2-frontend ready to stack on top of part 1 or target main independently.^ Trick)
If you want to move a specific single commit X onto main:
git rebase --onto main X branch will exclude commit X.git rebase --onto main X^ branch (using X^ targets the parent of X, ensuring X is included).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.
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.
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.
# 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!