Git, CI/CD & GitOpsIV · Commit Graph and HistoryCommit Graph and History
Graph topology and merges — what merge commits actually encode
What you'll learn
- Identify the structural difference between a merge commit and an ordinary commit in the DAG
- Explain how the three-way merge algorithm uses the merge base and why a non-trivial history can produce conflicts
- Recognise an octopus merge commit by its parent count and understand when it is the right tool
- Distinguish `--no-ff` merges from fast-forward merges and explain why `--no-ff` preserves branch topology
- Predict whether a given merge will fast-forward from the topology alone
- Choose between merge strategies for the common cases (recursive, octopus, ours, subtree)
Prerequisites
Practice
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
A merge is the operation that turns a branch into a DAG node with more than one parent. The resulting commit encodes the topology of the convergence - which branch was checked out, which branches were merged in, and in what order - and is the unit at which parallel work becomes serial history. Understanding the topology of a merge is what makes the difference between a history that is auditable and one that lies about what happened.
What a merge commit encodes
When two branches converge, Git creates a commit with two parents: the first parent is the branch that was checked out, and the second parent is the branch that was merged in. The commit’s tree is the three-way merge result; its message records the merge event.
git checkout main
git merge feature/iam-rotation
# Merge made by the 'recursive' strategy.
# 4d2c8e0 (main) Merge branch 'feature/iam-rotation'
git cat-file -p 4d2c8e0
# tree <merged-tree-oid>
# parent <main-oid> <-- first parent (was checked out)
# parent <feature-oid> <-- second parent (was merged in)
# author Ops <ops@example.com> 1730000000 +0000
# committer Ops <ops@example.com> 1730000000 +0000
#
# Merge branch 'feature/iam-rotation'
flowchart LR
M["merge commit M\nparents: A, B\nfirst parent = A"] --> A["A (main)"]
M --> B["B (feature)"]
A --> P["earlier ancestors"]
The merge commit has two outgoing edges, one to each parent. Every
subsequent first-parent walk will follow the edge to A and skip
the edge to B; the history walker knows B is the merged-in
branch by the parent ordering convention.
Fast-forward versus merge commit
Not every merge creates a merge commit. If the branch being merged in is a direct descendant of the branch being merged into - that is, if the merge base equals the current HEAD - Git can fast-forward: it simply moves the current branch’s ref to the merged-in branch’s tip, without creating a commit.
# main at A, feature at B, B is a descendant of A
git checkout main
git merge feature/iam-rotation
# Updating 6f4e5a6..8a3f9d2
# Fast-forward
flowchart LR
A["main: A"] --> B["feature: B (descendant of A)"]
B -.fast-forward.-> A2["main: B (ref moved)"]
After the fast-forward, main points at B, and there is no merge
commit in the history. The topology is the same as if B had been
committed directly on main. No information is lost about which
commits were originally on the feature branch - they are still in
the DAG, with their parents intact - but the audit trail no longer
marks the event as a merge.
git merge --no-ff forces the creation of a merge commit even when
a fast-forward is possible. This preserves the branch topology as a
visible event in the history:
git merge --no-ff feature/iam-rotation
# Merge made by the 'recursive' strategy.
# 4d2c8e0 (main) Merge branch 'feature/iam-rotation'
flowchart LR
A["main: A"] --> M["merge commit M\nparents: A, B"]
M --> B["feature: B"]
M --> A2["main continues from M"]
The merge commit M is now in the DAG, with two parents. The
topology preserves the fact that B was on a feature branch and
was merged into main. The cost is one extra commit object in the
history; the benefit is that git log --graph --oneline --decorate shows the branch as a visible event.
Three-way merge and the recursive strategy
The default merge algorithm is recursive, which is a three-way merge: given two tips and their merge base, compute the diff from the base to each tip, then combine the two diffs into a result. The algorithm is named recursive because it can handle multiple merge bases (criss-cross merges) by recursively merging the merge bases first.
git merge-base "$OURS" "$THEIRS"
# 6f4e5a6
# What the algorithm sees:
# base: 6f4e5a6
# ours: 9f3c1d7 (main, after our changes)
# theirs: 7a1b2c3 (feature, after their changes)
# Diff base -> ours: our changes
# Diff base -> theirs: their changes
# Combined diff: the merge result
If the two diffs touch disjoint regions of the tree, the algorithm
resolves them automatically. If they touch overlapping regions, the
algorithm flags a conflict: the index is left with both versions
of the conflicted hunk, marked with <<<<<<<, =======, and
>>>>>>> markers, and the merge commit is not created until the
user resolves the conflict.
git status
# Unmerged paths:
# both modified: terraform/modules/iam/main.tf
Conflict resolution is a manual step: the engineer edits the file
to the desired state, runs git add on it, and then runs git commit to finalise the merge commit. The merge commit’s tree is
the resolved tree, and its two parents are the original tips.
Octopus merges
When more than one branch is merged at once, Git creates an octopus merge: a commit with three or more parents, one per merged-in branch. The first parent is the checked-out branch; the remainder are the merged-in branches in command-line order.
git merge feature-a feature-b feature-c
# Merge made by the 'octopus' strategy.
# 4d2c8e0 (main) Merge branches 'feature-a', 'feature-b', and 'feature-c'
git cat-file -p 4d2c8e0
# tree <merged-tree-oid>
# parent <main-oid>
# parent <feature-a-oid>
# parent <feature-b-oid>
# parent <feature-c-oid>
flowchart LR
M["octopus merge M\nparents: A, B, C, D\nfirst parent = A"] --> A["A (main)"]
M --> B["B (feature-a)"]
M --> C["C (feature-b)"]
M --> D["D (feature-c)"]
Octopus merges refuse to resolve conflicts: if any hunk conflicts, the merge is aborted and the user must fall back to a series of two-parent merges. This is by design - octopus is for “many clean branch tips, no conflicts”. It is the right tool for periodic integration of long-running topic branches that have been kept clean, and the wrong tool for active conflict resolution.
Merge strategies
git merge selects a strategy based on the situation, but every
strategy can be forced with --strategy (or -s):
| Strategy | When used | Conflict handling |
|---|---|---|
recursive | default; two tips, may have multiple bases | resolves if possible, flags conflicts |
octopus | three or more tips | refuses on any conflict |
ours | tip takes the current branch’s tree, ignores theirs | never conflicts |
theirs | tip takes the merged-in branch’s tree | never conflicts |
subtree | one branch’s tree is a subdirectory of the other | resolves if possible |
ours is sometimes used in GitOps workflows to “merge in” a
feature branch while explicitly discarding its changes - useful for
feature-flagging and for permanently retiring a branch’s work. The
strategy is named ours because it always picks the current branch
(ours) over the merged-in branch (theirs).
# Take feature branch's tree but pretend to merge it
git merge -s ours feature/iam-rotation
# Merge made by the 'ours' strategy.
Production discipline
- Decide fast-forward policy deliberately. A repository with
merge.ff = falsealways creates merge commits for topic branches; a repository withmerge.ff = truenever does. The choice should be made at the repo or org level, not per-PR. - Use
--no-fffor release branches. A release branch’s merges into main are events that auditors care about; the merge commit is the audit trail.--no-ffmakes that trail visible. - Inspect the parent list of every merge commit.
git cat-file -p <oid>on a merge commit prints the parent order, which is the convention every downstream tool assumes. - Don’t use octopus to resolve conflicts. Octopus refuses conflicts by design. If conflicts appear, run the merges sequentially with the recursive strategy.
Cross-course references
- GitOps with Argo CD - Part VI (MergeStrategies) maps
Argo CD’s sync strategies onto Git’s merge strategies: the
Mergesync strategy creates a merge commit; theReplacestrategy is analogous to a squash or fast-forward. The mapping is direct because GitOps inherits the merge vocabulary from Git. - Terraform for Production Sysadmins - Part XI (PRWorkflows)
recommends
--no-fffor every Terraform plan merge into main, so the merge commit is the auditable event that ties the plan output to the production apply. - CI/CD Pipeline Patterns - Part V (MergeQueues) describes merge queues that build every merge commit on a temporary branch before allowing the merge into main; the temporary branch’s tip is a merge commit with the same topology as the eventual production merge.
Quiz
Knowledge check · 4 questions
Q1. What is the difference between a fast-forward merge and a merge commit?
Q2. An octopus merge will refuse to complete if any of the merged-in branches has a conflict with the checked-out branch.
Q3. Which `git merge` flag forces the creation of a merge commit even when the branches could be fast-forwarded, and what does the flag preserve in the history?
Q4. Decide whether a given merge should fast-forward or create a merge commit, and justify the choice based on the team's audit requirements.
A infrastructure team merges a long-running `feature/iam-rotation` branch into `main`. The feature branch is a direct descendant of `main`'s tip (i.e. no other commits have landed on `main` since the branch was cut). The team policy is that every change to `main` must be traceable to a pull request with a single reviewable commit message. The default Git behaviour would fast-forward the merge.
Passing score: 75%. Answers are checked in this browser.