DEV Community

Amit
Amit

Posted on

The Power of git rebase --onto: Surgical Commit Slicing in Git

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:

  1. You create branch feature-B off feature-A while waiting for code review.
  2. feature-A gets approved and merged into main using GitHub's or GitLab's Squash & Merge.
  3. You change the target of your feature-B pull request to main.
  4. 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
Enter fullscreen mode Exit fullscreen mode

...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>]
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The Clean Result:

main:        o───o───S───B1'───B2' (feature-B)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Verify the result:

git log --oneline -n 3
Enter fullscreen mode Exit fullscreen mode

Output:

a1b2c3d (HEAD -> feature-B) Add checkout UI tests
9e8d7c6 Add payment checkout UI
888aaaa (origin/main) Merge pull request #101 from feature-A
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

You want to keep A, B, E, and F.

The Command:

git rebase --onto B D HEAD
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
* ...
Enter fullscreen mode Exit fullscreen mode

Step 1: Branch off Part 1 (Backend)

Point a branch at commit 5555555:

git branch feature-part1-backend 5555555
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

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 branch will exclude commit X.
  • Instead, run: git rebase --onto main X^ branch (using X^ targets the parent of X, ensuring X is 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Find the entry right before you ran the rebase (e.g., HEAD@{1}: checkout: moving from...), then reset:

git reset --hard HEAD@{1}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

--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
Enter fullscreen mode Exit fullscreen mode

Have you had to rescue a stacked PR recently? Drop your favorite Git surgical tricks in the comments below!

Top comments (0)