Back to all posts

Untangling Git Branches with git rebase --onto

Tue, October 6, 2026

Today I want to write a bit about one of my favourite git commands: git rebase --onto.

Unless we’re working alone, git conflicts are a common part of our daily work. And depending on how your team structures branches (feature branches, trunk based development, stacked pull requests, etc.), you might run into merge conflicts quite often.

A little bit of a funny story: a coworker of mine would often rush to merge their PR in right after standup if they found out someone else’s PR touched the same files. They wanted to get their code in first so they wouldn’t have to deal with the conflicts themselves. Haha. I get it, I really do. Nobody enjoys wrestling with messy code collisions on top of their existing tickets.

Call me weird, but I actually kind of loved solving merge conflicts.

I really loved looking at the diffs, comparing the two sides line by line, and figuring out how to combine both changes in a way that makes sense. In my experience, working through conflicts was also wonderful practice for reading code written by other people on the team. It helped me learn the codebase much faster. (Though with agentic coding these days, merge conflicts has been elevated into another dimention.. which is a story we can save for another day :c )

Still, there are times when regular merging or standard rebasing falls short. You end up with duplicate commits, weird history loops, or conflicts that make you want to throw your laptop out the window.

That is where git rebase --onto comes in.

Learning new git commands is always a bit intimidating, but this is easily one of the most useful tools in my day to day workflow. Let’s walk through how it works, why I reached for it so often, and how to use it safely.

What is Git Rebase, Really?

Before we look at the --onto flag, we should quickly remind ourselves of what standard git rebase actually does.

Git commits are like links in a chain. Each commit holds a snapshot of your files and points backward to the commit that came right before it (its parent).

main:      A --- B --- C
                        \
feature:                 D --- E

Here, we branched feature off commit C. While we were working on commits D and E, a teammate added commit F to main.

main:      A --- B --- C --- F
                        \
feature:                 D --- E

Now our feature branch is behind main. If we want to bring our branch up to date, we have two main choices: merge or rebase.

If we run git merge main, Git creates a new “merge commit” that ties both branches together. It leaves history as it happened, but it can make your commit tree look like a bowl of spaghetti.

If we run git rebase main instead, Git does something clever. It takes our commits (D and E), temporarily sets them aside, moves our branch starting point to F, and replays our commits on top of F one by one.

main:      A --- B --- C --- F
                              \
feature:                       D' --- E'

Notice that D and E became D' and E'. They look the same, but they have brand new commit hashes because their parent commit changed from C to F.

Rebasing gives us a clean, linear history that’s tidy and easy to follow.

So why do we need --onto?

Why and When Should You Use git rebase –onto?

A standard git rebase assumes that you want to move all commits on your current branch that are not on the target branch.

Most of the time, that works fine. But what happens if you only want to move a small slice of those commits?

What if your work is stuck on top of commits you want to skip entirely? Or if your branch grew out of someone else’s unfinished work?

If you run a normal rebase in those situations, Git will drag every single commit along for the ride. That’s when you get hit with annoying duplicate commits and get stuck resolving conflicts on commits that were already approved weeks ago!

git rebase --onto solves this by giving you surgical control.

Instead of moving everything, you tell Git three specific things:

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

Let’s break down those three pieces:

  1. <new-base>: Where do you want to place the commits? (The new landing spot).
  2. <old-base>: Where should Git start cutting from? (Git will take commits that come after this point).
  3. <branch>: Which branch are you moving? (If you are already on that branch, you can leave this out).

In plain English, this command tells Git: “Take the commits on <branch> that come after <old-base>, and replay them directly onto <new-base>.”

It is a cut and paste operation for your commit history. Once you see it that way, the syntax stops feeling like arcane magic. :)

IMPORTANT NOTE!

Don’t leave your first commit behind!

<old-base> is exclusive. Git takes the commits that come after it. A common mistake is passing in the hash of the first commit you want to keep, which causes Git to skip it entirely. Always specify that commit’s parent (or use <first-commit>~1).

Branch Names vs Commit Hashes

People often ask whether they should pass branch names or raw commit hashes into this command. The short answer is use branch names when you can, and use commit hashes only when you need to make a surgical cut.

In practice, you’ll often end up mixing both in the same command.

For <new-base> and <branch>, I almost always stick to branch. Why? Because you want to land on the actual tip of that branch, not an old frozen commit. Plus, if you pass a commit hash for <branch>, Git will drop you into a detached HEAD state (and nobody likes dealing with that by surprise).

<old-base>, on the other hand, is where commit hashes shine. If you are cutting out a middle slice of a branch, there usually isn’t a branch name sitting right at that cut point. In that case, grabbing the short hash from git log is the easiest way to go.

So the rule of thumb that I follow is pretty simple:

  • <new-base>: Use a branch name so that you land on the latest code.
  • <old-base>: Use a branch name if one exists, or a short commit hash if you are cutting mid history.
  • <branch>: Use a branch name so that Git moves your branch pointer for you.

Common Use Cases

Let’s look at a few real world situations where --onto saves the day.

1. Stacked Pull Requests (The Classic Headache)

Splitting giant features into smaller, bite sized pull requests is a good habit we should all get into.

Nobody wants to review a massive 2000 line pull request. When a change is that huge, reviewers get overwhelmed. They either leave it sitting for days, hoping someone else will review it, or worse, skim right past sneaky bugs, sigh, and click “LGTM” just to get it off their plate. A tiny, focused pull request gets real, thoughtful feedback because someone can read it over a quick coffee.

Smaller chunks are also much safer. If something unexpected breaks, finding the bug is easy, and rolling back that tiny slice takes seconds. You also keep your momentum going. Teammates can review your first chunk while you stay in flow and build the next piece.

This happens constantly in any layered architecture.

When I was doing backend application development, I would always build each layer as its own separate PR. For data centric apps, I usually take a bottom up approach. First PR: design the database schema migrations. Second PR: stack the repository layer on top to handle basic CRUD operations. Third PR: add any business logic in the service layer. Fourth PR: add the HTTP endpoints.. Nth PR: add any caching, message queueing, etc.

Or if we were building something heavy on business logic, I would take a domain driven approach instead. In that case, I would start with the core service layer and domain models before touching the database or controllers.

Every layer cleanly builds on the one right below it. You can’t write your repository queries without the schema, and you can’t wire up the controller without the service. But you definitely don’t want to bundle all of those layers into one monster PR!

So you stack them. That is how stacked branches are born.

Say you create feature-a (like your database migrations), open a pull request, and while waiting for code review, you start feature-b (your repository layer) right on top of it:

main:        A --- B
                    \
feature-a:           C --- D
                            \
feature-b:                   E --- F

Your team reviews feature-a. Everything looks great! They merge feature-a into main using GitHub’s “Squash and merge” button.

Now main has a single squashed commit (CD_squashed), while feature-a is deleted:

main:        A --- B --- CD_squashed
                    \
feature-b:           C --- D --- E --- F

Now you need to update feature-b against main. What happens if you run git checkout feature-b && git rebase main?

Git will look at feature-b and say: “Okay, commits C, D, E, and F are not on main, so I will replay all four of them!”

Because C and D contain the exact same code changes that are already inside CD_squashed, Git gets confused. It throws conflict after conflict, forcing you to resolve changes you already resolved. It can be really, really frustrating.

With git rebase --onto, you can skip C and D completely:

git rebase --onto main feature-a feature-b

Git looks at feature-b, ignores everything up to and including feature-a (commits C and D), and only takes commits E and F. It transplants them right onto main:

main:        A --- B --- CD_squashed
                                    \
feature-b:                           E' --- F'

No conflicts. No duplicate commits. Just a clean branch ready for its own review.

2. Rescuing a Feature Stuck on an Unfinished Branch

I ran into this exact situation on a big project I was leading.

Along the way, I put together a neat backend filter for our data. The full project was nowhere near ready, but this new filter was already in great shape. We decided to ship that filter early as a standalone.

There was just one problem: my filter branch was stuck right on top of half finished database schema changes that we couldn’t deploy yet.

main:           A --- B
                       \
schema-work:            C --- D (unfinished schema)
                               \
backend-filter:                 E --- F (ready to ship!)

How do you ship the filter without dragging broken database code into production?

If you try running git checkout backend-filter && git rebase main, Git will scoop up commits C and D along with the filter code. Suddenly, half baked database changes are marching straight toward your production branch!

We could cherry-pick, sure. But I personally hate cherry-picking.. because manually selecting multiple commits by hand is messy and gets old fast.

This is where --onto shines:

git rebase --onto main schema-work backend-filter

We tell Git: “Take only the commits on backend-filter that come after schema-work, and plant them directly on main.”

main:           A --- B --- E' --- F'
                       \
schema-work:            C --- D

Just like that, your filter branch is clean! The unfinished schema work stays safely isolated on its own branch, and your feature is ready to merge today.

3. Dropping a Bad Commit from the Middle of a Branch

Imagine you are working on a feature branch, and in the middle of your work, you tried an experiment that broke everything or added a heavy dependency you no longer want.

main:       A --- B
                   \
feature:            C --- D (broken experiment) --- E --- F

You want to keep C, E, and F, but you want commit D gone completely.

You could open an interactive rebase (git rebase -i) and delete line D. That works! But you can also do it in one direct command with --onto:

git rebase --onto C D feature

Remember our three arguments:

  • <new-base> is C (where we want to graft our work).
  • <old-base> is D (the cut point: take commits after D).
  • <branch> is feature.

Git takes everything after D (which is E and F) and attaches them directly onto C. Commit D is left behind in the dust.

main:       A --- B
                   \
feature:            C --- E' --- F'
                     \
                      D (abandoned)

4. Splitting Out an Unrelated Quick Fix

Another common scenario: you are working on a long feature branch, and you spot a tiny bug or typo in existing code. You fix it, commit it, and keep working.

A few hours later, you realize that small fix really should be merged right away, without waiting for the big feature to finish.

main:       A --- B
                   \
feature:            C --- D (quick bugfix) --- E --- F

How do you pull out just that one fix onto a fresh branch without dragging all of feature with it?

You can create a quick branch right at commit D:

git checkout -b bugfix-branch D

Then, you can rebase it onto main starting after C:

git rebase --onto main C bugfix-branch

Now bugfix-branch has only commit D resting on top of main. You can open a quick PR for it immediately while you continue working on feature:

bugfix-branch:          D'
                       /
main:           A --- B
                       \
feature:                C --- D --- E --- F

Best Practices and Safety Tips

Rewriting commit history can feel scary if you haven’t done it much. That fear is normal! But as long as you follow a few simple habits, you will never lose work.

1. Only Rebase Local, Unshared Branches

This is the golden rule of rebasing: never rebase commits that other people have already pulled and built on.

Because rebasing creates new commit hashes, pushing a rebased public branch will cause headaches for anyone else working on that same branch. Keep rebasing strictly to your personal feature branches before they get merged.

2. Your Safety Net: git reflog

I cannot stress this enough: Git almost never deletes your data immediately. Even if a rebase goes horribly wrong and your branch seems broken, your old commits are still sitting safely in your local repository.

Run this command if you ever get lost:

git reflog

The reflog shows a chronological log of every single move your HEAD pointer has made. Find the commit hash right before you started the rebase, and reset back to it:

git reset --hard HEAD@{1}

Poof! You are right back where you started, like nothing ever happened. It is the best safety net in software engineering.

3. Check Your Log Before and After

Whenever I run a complex rebase, I always check my commit graph first:

git log --graph --oneline --decorate -n 10

Or, to see the whole graph across all branches:

git log --graph --oneline --decorate --all

Take a quick look at the commit hashes. Run your rebase command, and check the log again. Seeing the branch visually jump to its new base builds confidence quickly.

4. Push with Lease, Not Blind Force

Once you rebase a branch that has already been pushed to a remote server (like GitHub or GitLab), Git will reject a standard git push. That is expected, because your local commit history diverged from the remote history.

Instead of running a blunt git push --force, always use:

git push --force-with-lease

--force-with-lease checks that nobody else pushed new commits to the remote branch while you were working. If someone did, it stops and warns you instead of blindly overwriting their changes.

Wrapping Up

Git can feel mysterious at first. Many developers stick to a tiny handful of commands (add, commit, pull, push) because venturing outside that bubble feels risky.

I used to feel that way too. But learning git rebase --onto completely changed how I think about version control.

Instead of treating branches like rigid pathways, you start seeing commits for what they really are: modular building blocks that you can rearrange, transplant, and clean up whenever you need to.

If you haven’t used --onto before, try creating a little sandbox repository on your machine. Make a few dummy commits on two branches, create a conflict on purpose, and run the command. Once you see it work with your own eyes, you’ll wonder how you ever lived without it. :)