Whoops!

I like to squash commits whenever I merge a pull request into the main branch to keep the history as linear and simple as possible. I would normally set this policy on the repository and not think about it ever again.

However, today I accidentally merged a pull request with a merge --no-ff policy since I assumed the default was set to squash, but it was not…

So what just happened?

Imagine you are working on a new feature and you made multiple small and meaningful commits. You received some feedback and applied the suggested changes. Your branches could look something like this:

starting-point

You are ready to complete the pull request and you pressed merge via the UI, but you forgot to check the merge strategy. To your surprise the history of the main branch now looks like this:

no-fast-forward

You can display this with the following command git log [branch] --graph --oneline --decorate or check the history in the UI.

Rebase to the rescue! Right…?

Unfamiliar with interactive rebasing (rebase -i)? It is definitely worth checking out.

I figured with interactive rebasing I could just select a commit and fixup all the other commits, resulting in a single commit and a clean history. So I ran git rebase -i HEAD~4 and to my surprise commit D was not among the commits, but it was definitely in the logs.

# Result of `git log --oneline -4:

D (HEAD -> main, origin/main, origin/HEAD) Merged PR feature x
C Apply suggestions from code review
B Add version range example
A Merged PR 155
# Result of git rebase main -i HEAD~4:

pick A Merged PR 155
pick B Add version range example
pick C Apply suggestions from code review

After some digging it turns out that merge commits are not displayed by default when using rebase -i.

Note the following part of the documentation: “An editor will be fired up with all the commits in your current branch (ignoring merge commits), which come after the given commit.

Besides, when you run rebase -i the merge commit will be dropped, and you will have to pull it from remote or it will be lost if it is local.

What is a merge commit?

A merge commit is special commit in that it has more than one parent. Whenever git merge is called and the commits follow a linear history Git uses fast-forward. This means it just moves its internal pointer to the new commit without creating a new merge commit. However since our policy used git merge --no-ff it guarantees a new merge commit is created, D in our case.

merge-commit

Extra resources:

Rebase with merges

So how do I get rid of the merge commit, since it is not shown when rebasing? This is where --rebase-merges comes into play. This flag makes sure the branch topology can be recreated:

# Result of git rebase -i --rebase-merges HEAD~2

label onto

# Branch Merged-PR-166-feature-x
reset onto
pick A Merged PR 155
label branch-point
pick B Add version range example
pick C Apply suggestions from code review
label Merged-PR-166-feature-x

reset branch-point # Merged PR 155
merge -C D Merged-PR-166-feature-x # Merged PR feature x

First, we see a few new commands:

  • label creates a pointer to the current HEAD. It is basically a local reference which stops to exist after the rebasing is done.
  • reset resets the HEAD sort of similarly to git reset --hard with some caveats.
  • merge will merge the commit into the current HEAD.

So what happens here?

An attempt to visualize the situation: complex

The first command label onto labels the change onto which the commits are rebased; The name onto is just a convention. The HEAD gets reset to this commit as starting point.

Next, commit A is picked from main and a label is created to mark when our feat/x branch got created (1).

It then picks both commits from the feat/x branch and creates a new label at this position (2) while resetting back to the original commit of main (3).

Finally it merges HEAD which is at branch-point with label Merged-PR-166 containing commit B and C (4) resulting in parent 1 and parent 2 of the merge commit.

The fix

Luckily for me I did not need to keep the branch topology intact, so we can remove merge commit D and squash B and C using the following approach:

the-fix

  1. First, we drop commit D.
  2. Then we fixup C into B and reword B with the PR message I want.
  3. Finally, Comment or remove the reset branch-point command otherwise the HEAD will be reset back to the label point, otherwise commits D, C and B will be removed all together.

Since the labels are local reference to this session we can safely ignore those commands as they will be forgotten afterwards, or comment them if you like.

# Adjusted to remove the merge commit

label onto

# Branch Merged-PR-166-feature-x
reset onto
pick A Merged PR 155
#label branch-point
reword B Add version range example
fixup C Apply suggestions from code review
#label Merged-PR-166-feature-x

#reset branch-point # Merged PR 155
drop D Merged-PR-166-feature-x # Merged PR feature x

Conclusion

Set a default merge strategy on your repositories to prevent this from happening in the first place. If you still run into a scenario where you need to rebase with merge commits make sure to use git rebase -i --rebase-merges to avoid losing the merge commits.