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:
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:
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.
Extra resources:
- More information on basic branching and merging can be found here .
- An explanation on how Git shows (combined) diffs of a merge commit
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 togit reset --hard
with some caveats.merge
will merge the commit into the current HEAD.
So what happens here?
An attempt to visualize the situation:
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:
- First, we
drop
commitD
. - Then we
fixup C
intoB
andreword B
with the PR message I want. - Finally, Comment or remove the
reset branch-point
command otherwise the HEAD will be reset back to the label point, otherwise commitsD
,C
andB
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.