Git Merge: Fast-forward, Three-way Merge and the Merge Commit
We see with real output the difference between fast-forward and three-way merge, the role of the common ancestor (git merge-base), the inside of a two-parent merge commit, the --no-ff and --ff-only options, the ort strategy that has been the default since Git 2.34, and cleaning up branches after a merge.
Version note: The commands in this post were tested with Git 2.54; ort has been the default merge strategy since Git 2.34.
Guess First
We are where we left off in the previous post. The bookstore repo's graph looks like this:
$ git log --oneline --graph --all
* 593811b Try a lower price
| * a170771 Add wishlist
| * 5bcf046 Add Snow Crash
| | * 86303f4 Add discount to cart total
| |/
| * 6651492 Reword README
|/
* a5a7a57 Update book prices
* 6dde20b Add cart total
* f9b9660 Add .gitignore
* 02ea975 Add book list
* 7ff0565 Add README
main currently points to 5bcf046. The wishlist branch is one commit ahead of it, while discount split off at 6651492. Now, while on main, we will type git merge wishlist first and then git merge discount. Question: which of these two commands creates a new commit? Both, one, or neither?
Fast-forward: Sliding the Pointer Forward
$ git switch main
Already on 'main'
$ git merge wishlist
Updating 5bcf046..a170771
Fast-forward
src/wishlist.js | 9 +++++++++
1 file changed, 9 insertions(+)
create mode 100644 src/wishlist.js
$ git branch -v
discount 86303f4 Add discount to cart total
* main a170771 Add wishlist
price-test 593811b Try a lower price
wishlist a170771 Add wishlist
No new commit. The commit main pointed to was a direct ancestor of wishlist; the path from main to wishlist is a straight line. In that case there is nothing left to combine. Git just slides the main pointer forward, and the two branches start pointing to the same commit. The word Fast-forward and the Updating 5bcf046..a170771 line in the output say exactly that.
Think of it as fast-forwarding in a video player. The film has already been shot; you are just moving the playhead forward. No scene is reshot.
Three-way Merge: The Common Ancestor and Two Tips
The second merge is different. main and discount went separate ways after a common ancestor, and both have commits of their own. This time sliding the pointer is not enough; you need a new snapshot that combines the changes from both sides. To do that, Git first finds the common ancestor:
$ git merge-base main discount
66514929b4fe53a14d1a73a9334836979ca13ccf
$ git merge discount
Merge made by the 'ort' strategy.
src/cart.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
This is where the name "three-way" comes from. Git looks at three commits: the common ancestor 6651492, your side a170771 and the other side 86303f4. For each file it asks: since the ancestor, who changed what? src/cart.js changed only on the discount side, so that version is taken. src/books.js and src/wishlist.js changed only on the main side, so they are kept as well. As long as the two sides do not touch the same place in the same file, Git does this merge on its own. When they do, you get a conflict; that is the topic of the next post.
Without the common ancestor this decision could not be made. If you compared only the two tips, you would see that Snow Crash is missing on discount, but you could not answer "did one side add it, or did the other delete it?". The ancestor is the reference point that lets you tell which side changed what. The three-dot git diff main...discount from the diff post was computed from exactly this ancestor too.
In real life this command opens an editor with the default message Merge branch 'discount'. Leaving the message as it is and saving is enough. If you do not want the editor to open, add --no-edit.
Inside a Merge Commit
$ git log --oneline --graph
* c3d85ee Merge branch 'discount'
|\
| * 86303f4 Add discount to cart total
* | a170771 Add wishlist
* | 5bcf046 Add Snow Crash
|/
* 6651492 Reword README
* a5a7a57 Update book prices
* 6dde20b Add cart total
* f9b9660 Add .gitignore
* 02ea975 Add book list
* 7ff0565 Add README
$ git cat-file -p HEAD
tree e5f4d0645109bec6c2e8144808e14cd66b1897a0
parent a170771d07f6cf4f7a1d038a3808bcb3c008eab2
parent 86303f4e515d8b599a9f7f53706b526f80f79d74
author Ada Lovelace <ada@example.com> 1794035520 +0300
committer Ada Lovelace <ada@example.com> 1794035520 +0300
Merge branch 'discount'
A merge commit differs from an ordinary commit in just one way: it has two parents. The first is the tip of the branch you were on when you merged (a170771), the second is the tip of the branch you merged in (86303f4). The tree line points to the combined snapshot. It is the same structure as in the post about the inside of a commit, with one extra parent line.
The order of the parents may look like a minor detail, but it will come in handy later. git log --first-parent follows only first parents and shows main's own line. The -m 1 used when reverting a merge commit also means "treat the first parent as the mainline".
--no-ff: A Merge Commit Despite Fast-forward
Fast-forward leaves a clean history but loses one piece of information: the graph no longer shows that those commits were developed on a branch. Some teams want to see the boundary of every feature in the history. That is what --no-ff is for. Let's try it with a small branch that adds an ISBN field to the Dune entry:
$ git switch -c isbn
Switched to a new branch 'isbn'
$ git commit -am "Add ISBN to Dune"
[isbn 0c535da] Add ISBN to Dune
1 file changed, 1 insertion(+), 1 deletion(-)
$ git switch main
Switched to branch 'main'
$ git merge --no-ff isbn
Merge made by the 'ort' strategy.
src/books.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
$ git log --oneline --graph -4
* 7111256 Merge branch 'isbn'
|\
| * 0c535da Add ISBN to Dune
|/
* c3d85ee Merge branch 'discount'
|\
| * 86303f4 Add discount to cart total
Since main had not moved at all, a fast-forward was possible here. --no-ff created a merge commit anyway, and the small isbn side line stays in the graph. The "Create a merge commit" option on a GitHub pull request works with this behaviour too. Which one is right is a team choice; we will come back to it in the post on branching strategies.
--ff-only: Only If It Can Fast-forward
There are also cases where you want the opposite: "fast-forward if you can, otherwise do nothing". The price-test branch we rescued from detached HEAD split off from main long ago, so it cannot be fast-forwarded:
$ git merge --ff-only price-test
hint: Diverging branches can't be fast-forwarded, you need to either:
hint:
hint: git merge --no-ff
hint:
hint: or:
hint:
hint: git rebase
hint:
hint: Disable this message with "git config set advice.diverging false"
fatal: Not possible to fast-forward, aborting.
Git stopped without touching anything and suggested two options. --ff-only is especially valuable in automation: a deploy script updating the copy on a server should not produce an unexpected merge commit. On the CI/CD side, the rule "fast-forward only, otherwise fail" keeps anyone from branching the history on the server without noticing. The same idea applies to git pull; we will meet it again in the pull post with the pull.ff only setting.
ort: The Default Strategy
The Merge made by the 'ort' strategy. line in the merge output tells you which merge strategy did the work. Before Git 2.34 the default strategy was recursive, and in older posts you will see this output as 'recursive' strategy. ort does the same job with rewritten, faster code, and makes a difference especially in large merges with renamed files. The Git 2.34 release notes say it plainly: ort is used instead of recursive as the default.
In daily work you do not need to pick a strategy. You can state it explicitly with -s ort, but in a current Git that is already the default. Separate from the strategy there are strategy options like -X ours or -X theirs; they pick one side automatically in conflicts and, used carelessly, quietly swallow the other side's change. Use them only after you have learned to resolve conflicts by hand.
Cleaning Up After a Merge
There is no point keeping feature branches after a merge; their commits are now reachable from main. You can ask Git which ones are safe to delete:
$ git branch --merged
discount
isbn
* main
wishlist
$ git branch --no-merged
price-test
$ git branch -d wishlist discount isbn
Deleted branch wishlist (was a170771).
Deleted branch discount (was 86303f4).
Deleted branch isbn (was 0c535da).
$ git branch -v
* main 7111256 Merge branch 'isbn'
price-test 593811b Try a lower price
--merged lists branches whose tips are reachable from the current branch; --no-merged lists the opposite. Since -d already refuses to delete an unmerged branch, this cleanup is safe. On GitHub, too, a button to delete the branch appears after a pull request is merged; that is the last step of GitHub flow.
Git 2.56 added a new shortcut for this: git branch --delete-merged bulk-deletes local branches that are already merged into their upstream, and --dry-run shows first what it would delete. Since the commands in this post were tested with 2.54, we do not show its output; we will explain the upstream concept in the remote post.
Common Pitfalls
The first is merging in the wrong direction. git merge discount brings discount into the branch you are on. If you type git merge main while on discount, you pull main's changes into the feature branch. Sometimes that is what you want, but usually not. Look at the first line of git status before merging.
The second is starting a merge with a dirty working tree. Git stops if there are uncommitted changes in files the merge will touch. In files it will not touch, it carries on, and if a conflict appears your own half-done work and the merge's half-done work get mixed up. Commit or stash before merging.
The third is worrying that "the branch disappeared" after a fast-forward. No commit was lost; wishlist's commit simply sits on main's line. The only thing lost is the information that this commit was written on a separate branch. If that information matters to you, use --no-ff.
The fourth is forgetting that git pull also merges behind the scenes. The answer to "I did not merge anything, where did this merge commit come from?" is usually pull. We will look at it in detail in the pull post.
A Small Experiment
★☆☆ Create a new branch from main, make two commits, then go back to main and merge it. Predict in advance whether you will see Fast-forward in the output. Then check with git log --oneline --graph.
★★☆ Repeat the same experiment, this time adding a commit to main as well, touching a different file. Before merging, find the common ancestor with git merge-base; after merging, check the two parents with git cat-file -p HEAD.
★★★ Compare the output of git log --oneline --first-parent with a plain git log --oneline. Which commits are main's "own"? Explain what merges made with --no-ff are good for in this view.
All the merges in this post went through cleanly, because the two sides never touched the same line. In real life, Ada and Alan change the price of the same book at the same time. In the next post we will see why a conflict happens, how to read the markers and how to resolve it step by step.