little cubes

How to interactively rebase git merge commits

Zach Olivare --

The easiest way to rearrange merge-commit pull requests

Why

One of my core tenants as a developer is that git history should be a tool for solving problems. To me, that means that it should take the shape of a single continuous line of commits, which are delineated into groups (which usually represent pull requests) by merge commits.

Terminal window
* 22fb86c (HEAD -> main) Merge PR #3
|\
| * 468bc63 ten
| * ef0893d nine
| * 5a8e851 eight
| * 95b1937 seven
| * bee57ff six
|/
* d89d835 Merge PR #2
|\
| * 02561c3 five
| * f8d4ec5 four
| * d738586 three
|/
* 815a983 Merge PR #1
|\
| * 3b5c0d2 two
| * 7c62c07 one
|/
* 87f90a3 Initial commit

The one struggle I’ve found with this approach is that in order to release some code and not release other code for whatever reason, it is sometimes necessary to rearrange the order of the pull requests after they have been merged in. My old solution for this was very time consuming and involved a lot of manual cherry-picking and merging.

But THEN in 2019, the —rebase-merges flag was added to the Git rebase command (version 2.18.0), and it made this process so much easier.

But when I used that flag for the first time I was super confused by the output, and shied away from it for awhile until I took the time to sit down and parse the complicated output. This blog post is what I’ve learned from that process and in the years since.

Audience

This post is intended for people who are already very comfortable with git interactive rebasing; git rebase -i.

Syntax

The syntax is nearly identical to the standard git rebase command, with the addition of the --rebase-merges flag.

Terminal window
git rebase -i --rebase-merges <commit-ish>

Sandbox

My example in this post will be based on this sample git history:

Terminal window
git log --graph --oneline --decorate
* 22fb86c (HEAD -> main) Merge PR #3
|\
| * 468bc63 ten
| * ef0893d nine
| * 5a8e851 eight
| * 95b1937 seven
| * bee57ff six
|/
* d89d835 Merge PR #2
|\
| * 02561c3 five
| * f8d4ec5 four
| * d738586 three
|/
* 815a983 Merge PR #1
|\
| * 3b5c0d2 two
| * 7c62c07 one
|/
* 87f90a3 Initial commit

If you’d like to you can follow along by cloning the repo from GitHub.

Understanding the output

In my opinion, the most complicated part of -i --rebase-merges is the confusing grouping that git outputs. You can find each one of your original pull requests in its output, but but git doesn’t group them that way by default.

More on that in a moment, but first let’s see the output of the command:

Terminal window
git rebase -i --rebase-merges 87f90a3
Terminal window
label onto
# Branch Merge-PR-1
reset onto
pick 9006a2d one
pick 117b0ff two
label Merge-PR-1
# Branch Merge-PR-2
reset onto
merge -C 428d254 Merge-PR-1 # Merge PR #1
label branch-point
pick 0808cce three
pick 67c0c1f four
pick 5d04848 five
label Merge-PR-2
# Branch Merge-PR-3
reset branch-point # Merge PR #1
merge -C 9d3c988 Merge-PR-2 # Merge PR #2
label branch-point-2
pick 81091b0 six
pick 86faedf seven
pick b5c836c eight
pick f5bbb45 nine
pick 0b325e9 ten
label Merge-PR-3
reset branch-point-2 # Merge PR #2
merge -C 621bc14 Merge-PR-3 # Merge PR #3

Just like standard interactive rebasing, each line of the output corresponds to a command, with the first word being the command and the rest of the line being the arguments. Unlike standard interactive rebasing, each line does not correspond to a single commit (the pick lines still do represent a single commit though).

The pick <commit> command you should be familiar with from regular interactive rebasing (it just means “use this commit”), but the rest are new.

  • label <label> is used to give a temporary name to the commit that HEAD currently points to when the command is processed.
  • merge -C <commit> <label> is used to create a merge commit from HEAD to the specified label, with the same message as the specified commit.
  • reset <label> is similar to the git reset command, and resets the HEAD pointer to the commit that was labeled with the specified label.

Step 1: Rearrange Git’s Default Output

You can see from the output above that the blank lines split up the output into 5 sections, three of them being larger than the others. You might assume that the three big ones correspond to your three pull requests, but that’s not entirely true; the blank lines don’t properly correspond to the separation between the pull requests.

Git puts a blank line before every reset command. It also adds a full line comment before the reset command (which I find more distracting than helpful). To make the groups correctly correspond to the pull requests, we want the blank lines to come after the merge commands, like this:

Terminal window
label onto
reset onto # doesn't actually do anything (HEAD is already at this label) -- could delete
pick 9006a2d one
pick 117b0ff two
label Merge-PR-1
reset onto
merge -C 428d254 Merge-PR-1 # Merge PR #1
label branch-point
pick 0808cce three
pick 67c0c1f four
pick 5d04848 five
label Merge-PR-2
reset branch-point # Merge PR #1
merge -C 9d3c988 Merge-PR-2 # Merge PR #2
label branch-point-2
pick 81091b0 six
pick 86faedf seven
pick b5c836c eight
pick f5bbb45 nine
pick 0b325e9 ten
label Merge-PR-3
reset branch-point-2 # Merge PR #2
merge -C 621bc14 Merge-PR-3 # Merge PR #3

All I’ve done is move some blank lines around and delete the full line comments, but now each one of these groups of commands delineates one of the pull requests.

This works because each group of commands takes this form:

Terminal window
label start
pick commit1
pick commit2
pick commit3
label end
reset start
merge -C origCommitToCopyMsgFrom end

Each group adds a label at its start, then (cherry-)picks each of its commits, then adds another label at its end, moves HEAD back to start, and finally creates a merge commit between the start and end.

Step 2: Rearrange the pull requests

Now that we have three groups of commands, each of which actually defines one pull request, all that’s required to change the order of the pull requests in git history is to rearrange those groups of commands in your text editor!

Because in this example repo I’ve ensured that there can’t be any merge conflicts, you can rearrange the groups in any order of your choosing. Then save and close the file, and git will do the rest.

For example, if you reverse the order of the groups, you’ll get this git history:

Terminal window
* 746e3ba (HEAD -> main) Merge PR #1
|\
| * 54c55d9 two
| * 6c49806 one
|/
* 7201f4d Merge PR #2
|\
| * 0a59673 five
| * e797c7f four
| * 27cd869 three
|/
* 874fea3 Merge PR #3
|\
| * 5173212 ten
| * b157e78 nine
| * d7c129e eight
| * 6e766e3 seven
| * eea2949 six
|/
* 87f90a3 Initial commit