A feature or topic branch based workflow is an essential part of modern software development. A topic branch gives a developer an isolated place to do work. And once that work is done, it provides a basis for creating a pull request.

Once a feature is completed and pushed, sometimes it makes sense to create a second topic branch, based on the first. These are often called sub branches. This might happen for several reasons, including:

  • You’re not ready to merge the first branch
  • You’re holding off merging, to keep the new feature out of master, for reasons
  • PRs might take a while to get approved

You probably know that creating one topic branch off of another is easy. Merging this work back to master can also be pretty straightforward. Rebasing, however, takes a little more care.

TL;DR: Use git rebase --onto to transplant your topic branches in the order they were created, but take care if your branch is used in a pull request.

The following examples are based on this hypothetical set of topic branches. Each consecutive topic branch a sub-branch of the earlier branch.

    A---B---C           master
      \
       D                branch-one
        \
         E---F---G      branch-two
              \
               H---I    branch-three

I order, branch-one has a single commit, branch-two has three commits, and branch-three has two commits—but it starts halfway along the branch-two work. Meanwhile, two other commits have occurred on master.

Merging strategies

If you’re not rebasing, these branches can be easily merged to master, in order, with one merge commit each. The merge-commit approach would look like this.

    A---B---C---D'---G'--I'   master
      \        /    /   /
       D------'    /   /
        \         /   /
         E---F---G   /
              \     /
               H---I

One way to merge these branches with rebasing is to build them up gradually, starting with rebasing branch-three onto branch-two, and so on. This requires rewriting each sub-branch multiple times. As a consequence, getting PRs to track is difficult.

In this post, we take the approach of rebasing onto master, in the order the branches were created.

Merging branch one

The first case is easy, since it’s branched directly off of master. You can use standard rebase.

git checkout branch-one
git rebase master

We get the expected result, with a new commit, D', containing the changes from commit D replayed on master. The branch reference was also moved as expected, leaving the original commit, D without one.

            +-------------- master
            |   
            v 
    A---B---C---D' <------- branch-one      
         \
          D
           \
            E---F---G       branch-two
                 \
                  H---I     branch-three

If you use GitHub, and you had PRs outstanding for these branches, at this point, GitHub might become confused. It would see that the merge target of branch-two had changed and it would close the PR associated with branch-two.

To avoid this, you can edit the PR and set the target to be master instead of branch-one before rebasing branch-one. This might cause your PR changes to look a little crazy in the interim, but they return to normal afterwards.

After rebasing branch-one, be sure to update the PR to reflect the final state of the branch.

git checkout branch-one
git push -f

Next, merge master and push so the remote repo is up to date.

git checkout master
git merge branch-one
git push

This will be a fast-forward merge if your local master branch was up to date prior to rebasing.

Finally, you’ll want to perform the normal PR cleanup:

  • git push origin -d branch-one # to delete the remote branch
  • git branch -d branch-one # to delete your local branch

Merging branch two

Before we get started, we want to make sure branch-three’s PR isn’t clobbered when we rebase. Again, if we’re using GitHub, we will edit the PR for branch-three and change the merge target from branch-two to master.

Here’s where things change from the normal rebase flow. We want to rebase branch-two onto master, per normal. If we do a normal rebase, git will look all the way back to master, which includes the moribund commit, D. In simple cases, this won’t be a problem, as the replay will be a no-op. It can cause conflicts, though, if changes have been made in the interim on master. Fortunately, rebase provides the --onto flag for exactly this situation.

We need to tell git to rebase only the commits from the topic branch that we care about—that is, commits E, F, and G. If we wanted to move branch-two earlier, while branch-one was still in its original place, we could have run.

git rebase --onto master branch-one branch-two

Indeed, that’s the canonical case. We want to rebase branch-two, starting from the upstream branch, branch-one, onto master. Unfortunately, branch-one is no longer around. So we need to get the hash for commit D as a substitute for branch-one for the upstream argument.

You can get it by checking out branch-two and running git log to find the commit just before E. If you plan ahead, you can run git log --oneline from each branch to get a list of all of the relevant branch refs and their commit hashes prior to starting.

Log from branch-three

e61e988 (HEAD -> branch-three) I
37dcec4 H
61c2db4 F
32c9e2c E
2faee42 (branch-one) D
1e10333 B
0e58a1e A

Log from branch-two

5f45eae (HEAD -> branch-two) G
61c2db4 F
32c9e2c E
2faee42 (branch-one) D
1e10333 B
0e58a1e A

Once we have the hash for D, we can rebase branch-two onto master.

git rebase --onto master 2faee42 branch-two
                +------------------ master
                |   
                v 
    A---B---C---D'--E'--F'--G' <--- branch-two      
         \
          D
           \
            E---F---G
                 \
                  H---I             branch-three

Now, perform the remaining steps to wrap up branch-two.

# Update the PR
git checkout branch-two
git push -f

# Fast-forward merge
git checkout master
git merge branch-two
git push

# Delete the branch
git push origin -d branch-two
git branch -d branch-two

Interlude: merging branch two after squashing

Some teams routinely squash commits prior to merging or rebasing. This can make it easier to see where some work ends and other work begins, especially if rebasing onto master.

To squash, just use interactive rebase as you normally would. In this case, we would like to squash commits E, F, and G into one, say E'.

git checkout branch-two
git rebase -i HEAD~3

In the invoked editor, we would squash (or fixup) two commits.

pick 5cc6ba4cd E
squash eda9daade F
squash c4f181c1b G

This would leave us with a tree that looks like this:

    A---B---C---D' <------- branch-one      
      \
       D
       |\
       | E'                 branch-two
       \
        E---F---G
             \
              H---I         branch-three

The rebase would then continue as before, with the --onto flag.

Merging branch three

The process continues for branch-three as it was performed for branch-two. Note that in this case, branch-three was created partway through the work on branch-two. This doesn’t create any special considerations. It just means that you will need to select a hash for commit F or G rather than E for the rebase. When running rebase with branch names, git looks at the common ancestor of each branch to determine where they diverge. The same goes for commit hashes, so the choice of F over G isn’t important.

The final state will look like this.

    A---B---C---D'--E'--G'--H'--I'  master
      \
       D
        \
         E---F---G
              \
               H---I

Now that there are no references to the original commits, they will eventually be garbage collected.

Summary

Prior to starting

  • Check out each branch
  • Run git log --oneline to get hashes associated with original branch names

For each branch

  • Prepare relevant PRs (if using GitHub)
    • Update the merge target of the descendent PR to refer to master
  • Squash (optional)
    • git rebase -i <upstream> <branch>
  • Rebase onto master
    • git rebase --onto <newbase> <upstream> <branch>
  • Update the PR
    • git checkout <branch>
    • git push -f
  • Merge the branch to master
    • git checkout master
    • git merge <branch>
    • git push
  • Delete the branch
    • git push origin -d <branch>
    • git branch -d <branch>

An aside

When newcomers first learn about rebasing, it can seem confusing. Part of this confusion arises, in my opinion, because rebasing is used to do different things—much like merge. (This relates to complaints sometimes heard about git’s porcelain.) In this post, I referenced two different modes for using rebase. The first (and the point of the article), is rebasing to move commits rather than using merge. The second mode is rebasing to combine commits, often called squashing. This is usually done using interactive rebase, with the -i or --interactive flag. The fact that they’re often done in the same workflow can add to the confusion.

Final note

Whether to merge or rebase is a question each organization needs to answer for themselves. The debate over which approach to take can sometimes result in levels of vehemence only seen in arguments over tabs vs. spaces, and similar important questions.

In our organization, we take a compromised approach. If a PR has one commit, then the answer is easy: rebase and avoid a merge commit. The PR and comment are self-contained, so avoid the clutter.

If a PR has several significant commits, then just do a merge commit. This preserves the branch structure. The progression of work can be seen in isolation. The merge commit can also contain the original branch name in the comment, which is lost after a merge when using git, due to the ephemeral nature of branches. This can be a plus for some people.

Finally, if the PR has more than one commit, but most are tiny or inconsequential, then the history of those commits isn’t that important. In this case, it makes sense to squash down to one commit on the branch and then rebase onto master.

This approach has worked well for us. It combines the best of both philosophies—preserving history and branch structure when it’s important, and cleaning up when it’s not.