The Github docs say that
Pull requests with squashed commits are merged using the fast-forward option.
This conflicts with my mental model (illustrated in the images below) of what's happening and I would be grateful for some clarification.
Initial situation, where feature is supposed to be "squashed and merged" into master:
Situation after squashing, but before merging:
How is it possible to perform a fast forward merge now when merging feature into master?
edit: So from the comments what seems to happen is actually "squash and rebase" instead of "squash and merge". Is this correct?
What the documentation says is technically true, but somewhat misleading.
When you perform a squash and merge with GitHub, it takes the contents of your two branches and merges them, but does not create a merge commit. Instead, it creates a commit that has only one parent. This is equivalent to git merge --squash && git commit
at the command line.
In your graph, this is a merge of master
and feature
.
This temporary commit that GitHub creates is then merged into your main branch as a fast-forward merge, since it is necessarily a superset of the main branch. That's why it is technically true, but misleading. GitHub includes this message to tell you that it doesn't create a merge commit (that is, a commit with two or more parents) but instead a single commit with just the main branch as its parent.
So essentially, squash and merge is just a merge, but with a different history. There's one commit with a single parent instead of multiple commits combined with a merge commit. For example, if you have this:
A - B - C - D (dev)
\
E - F - G (feature)
and you want to merge D
and G
, a normal merge would give you this:
A - B - C - D - H (dev)
\ /
E - F - G -- (feature)
and a squash merge would give you this:
A - B - C - D - H' (dev)
The contents of H
and H'
are identical, but the commits have different numbers of parents. This H
will have been created by creating but not committing H
on a temporary branch and then fast-forward merging it into dev
.
This behavior can differ from squash and rebase if the squash happens first, since it's possible for one situation to have conflicts and the other not to. But in the case without conflicts, they should produce identical results almost all the time.
Whether squash-and-merge and squash-and-rebase differ depends on the type of rebase used. The older style am
rebase uses patches, and so it's possible for patches to misapply if the context is identical in multiple places, which is a known defect of this approach. It also does not perform rename detection, which means that merge attempts may otherwise fail. The newer style rebase uses the merge machinery under the hood and I'm not aware of any place where it wouldn't produce an identical result (although I'm not the Git merge machinery expert) because it's essentially a merge.
Note that squash-and-rebase with the merge-based rebase or squash-and-merge is qualitatively different than rebase-and-squash, because the latter involves rebasing multiple patches, each of which could have conflicts, whereas a squash-and-rebase will consider only the old base, new base, and squashed commit. If a patch in the middle would have caused a conflict but the end result wouldn't (say, because the conflicting code was removed later in the series), then rebase-and-squash will traverse the problematic commit and conflict, whereas squash-and-rebase will not and will succeed.
The thing to understand is that a GitHub squash and merge is not a merge — despite the label. The same thing is true of a git merge --squash
, which is what is used here: despite the name, it isn't a merge.
What actually happens is this. Let's start with your diagram of master
and feature
:
A -- B -- C -- D -- E (master)
\
\-- X -- Y -- Z (feature)
What a squash merge does is diff the merged branch starting at the point where it diverges from the target branch, and replay those changes as a single commit on top of the target branch's last commit. So in this case we're going to replay the diff C-->Z
on top of E:
A -- B -- C -- D -- E -- Z´ (master)
\
\-- X -- Y -- Z (feature)
I have called the new commit Z´
, but it embodies the changes from C
through Z
. Notice that feature
is not actually merged, and that in effect you have now failed to reflect in master
the historical fact that feature
ever existed. This is one of many reasons why squash merge is bad.
Now, you can call that a "fast-forward" if you like, but it isn't at all the same thing. A fast-forward "merge" would not be possible in this situation. I put the word "merge" in quotes here because a fast-forward "merge" is not a merge either! It's more of a rebase. If we start like this:
A -- B -- C -- D -- E (master)
\
\-- X -- Y -- Z (feature)
Then a fast forward merge results in this:
A -- B -- C -- D -- E -- X -- Y -- Z (master, feature)
That's problematic too, in my opinion, because again there is no separate record of feature
ever having existed, plus the whole history of feature
has infected master
, making it unnecessarily and misleadingly long.
The default pull request merge, on the other hand, is a --no-ff
merge. It is a true merge, plus it doesn't fast-forward even if it can. That is the best way, because you end up with a single true merge commit being added to the target branch (master
). The historical facts are recorded: there was a feature branch and it was merged at the this point. The branch's commits are preserved and can be examined, which is great for understanding or reversing something that they did, but they do not pollute the primary history.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With