Software Engineering · Version Control Practice
Fast-Forward, Merge Commits, and What First-Parent History Hides
When a ref move is enough to integrate a branch, when a merge commit is required, and how first-parent traversal changes the history a reader sees.
A branch in Git is a movable pointer to a commit. Integration between two branches therefore has two possible implementations: move one pointer so that it points at the other, or create a new commit whose parents are both tips. The first is free and loses no data; the second costs an object and records a join. Which one happens is a decision, sometimes automatic and sometimes forced, and it determines what a later reader can reconstruct about how the work arrived.
The precondition for a pointer move
A fast-forward is possible exactly when the branch being merged into is an ancestor of the branch being merged. In that case the target ref can be advanced to the source tip without discarding any commit, because every commit the target already had remains reachable through the source’s ancestry. The check is a reachability query over parent edges, the same walk used to find a merge base; if the base equals the target tip, the merge is a fast-forward.
Nothing is written to the object store. The ref file is updated, the index and working tree are brought into line with the new tree, and HEAD follows. Because no commit is created, no author or committer line is added, no message is recorded, and no merge base exists for a future merge to prefer. The graph after the operation is indistinguishable from a graph in which the work had been committed directly on the target branch.
That indistinguishability is the point and the problem. A fast-forward is the cheapest integration and the one that leaves the cleanest ancestry, but it also erases the boundary between “this was developed on a side branch” and “this was written here”. If the side branch had a name that carried meaning — a release line, a contributor’s work, a feature that was reviewed as a unit — the name disappears from history the moment the pointer moves.
git merge --ff-only makes the fast-forward a requirement and fails otherwise, which is useful when a linear history is a policy and a merge commit would be a violation. --no-ff forces a merge commit even when the pointer move is possible. The default sits between them: fast-forward when possible, merge commit otherwise.
What a merge commit buys
A merge commit has two parents and therefore records a fact that no other object can: these two lines existed separately and were joined here, at this commit, by this committer, with this message. The second parent is a full commit id, so the branch’s pre-merge state is reachable from the merge and remains inspectable forever.
Three consequences follow. Reverting the integration is a single revert of the merge commit rather than a range of commits. The set of commits that arrived with the merge is computable as the symmetric difference between the two parents, which is what git log --right-only --cherry-pick and similar forms expose. And the merge commit’s own message and timestamp give an integration event a place in time, which matters when the question is “when did this reach the main line” rather than “when was it written”.
The cost is graph noise. Every integration adds a commit that changed no file relative to its first parent unless a conflict was resolved during the merge, and every such commit is a node that traversal must visit. In a repository with several integrations per day the first-parent line stays short while total commit count grows with both the work and the integrations.
A merge commit also has a non-obvious property: its tree is whatever the merging party produced. There is no requirement that it equal the automatic three-way result. A merge performed with a resolution that discards one side’s change is a valid object, and nothing in the graph records that a decision was made. Combined diffs exist to show the difference between the merge result and each parent, but they are only informative when the resolution was non-trivial.
First-parent traversal and the collapsed view
git log --first-parent follows only the first parent of each commit. Since the first parent of a merge is the branch that was checked out when the merge ran, this follows the main line and treats each merged branch as a single entry.
The view is a summary of integrations rather than of work. On a repository using merge-per-feature, --first-parent yields one line per feature, each with the merge message; the individual commits inside each feature become invisible unless explicitly requested. On a repository using fast-forward integration, the flag changes nothing, because there are no merges to collapse.
This is why the two choices are usually decided together. A history that mixes them is inconsistent in a way that matters to tooling: --first-parent produces a coherent release narrative on a merge-based repository and a full commit dump on a rebase-based one. The same command, run against the same number of commits, can yield a log of very different length depending on how integration was done.
There is a related asymmetry in git log range syntax. A..B selects commits reachable from B but not from A, and on a merge-containing graph that set can include commits that arrived through a merge of a third branch. A...B selects the symmetric difference, which is what “what is on B that is not on A” usually means in a review context. Neither is wrong; they answer different questions, and confusing them produces a diff of the wrong set.
Which model fits which history
The trade is between reconstructability and linearity.
A merge-based model keeps every integration event and every fork, at the cost of a graph that requires a traversal strategy to read. It suits long-lived branches, release lines maintained in parallel, and any setting where “what shipped together” is a question someone will ask later.
A rebase-based model presents history as a single line, at the cost of rewriting ids and discarding the fork. It suits short-lived work that is always integrated on top of the current tip, and it makes the log directly readable as a sequence of changes. Its weakness is that the sequence is a reconstruction, not a record: a commit that was rebased may contain conflict resolutions that never appeared in any review of the original.
A hybrid is common: short-lived branches, rebased or squashed before integration, with an explicit merge commit at the integration point so that the group is recoverable. This keeps the first-parent line meaningful while leaving the internal structure of the branch available through the second parent.
Edge cases that break the assumptions
A fast-forward can move a ref backwards in effect if the branch had commits that were discarded. Git refuses a non-fast-forward update to a remote branch unless forced, precisely because a pointer move that drops commits is not an integration but a deletion. --force-with-lease narrows the window by requiring that the remote ref still be at the value the pusher last saw, which detects a concurrent update without preventing an intentional overwrite.
An empty merge is possible: two branches whose trees are identical but whose histories are not produce a merge commit with two parents and no content change. It is a legitimate record of a join and it will appear in --first-parent output as a commit with an empty diff, which confuses readers who assume every commit changes something.
Octopus merges extend the same idea to more than two parents. They are cheap to create but expensive to reason about, since a conflict resolution in an octopus merge has to be understood against all parents at once, and most tooling displays only the first parent by default.
Finally, the choice is not fully reversible. Converting a merge-based history to a linear one by replaying commits destroys the merge objects, and while the old commits remain reachable through the reflog for a while, nothing in the new history points at them. Converting a linear history to a merge-based one is not possible at all, because the fork information was never recorded. Deciding the model late means accepting that early history will not match it, which is why the decision tends to be made once per repository rather than per branch. The mechanics of the rewrite itself are covered in rebase as patch replay, and the practical consequences of choosing branch lifetimes appear in branch lifetime and integration frequency.
References
- Git documentation — git-merge — Git project
- Git documentation — git-log — Git project
- Pro Git, 2nd edition — Basic Branching and Merging — Chacon & Straub