Skip to main content
RunBook Academy

Git, CI/CD & GitOpsIV · Commit Graph and HistoryCommit Graph and History

Graph topology and merges — what merge commits actually encode

Intermediate⏱ ~22 min🧪 Lab requiredgit

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

Not yet marked complete on this device.

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):

StrategyWhen usedConflict handling
recursivedefault; two tips, may have multiple basesresolves if possible, flags conflicts
octopusthree or more tipsrefuses on any conflict
ourstip takes the current branch’s tree, ignores theirsnever conflicts
theirstip takes the merged-in branch’s treenever conflicts
subtreeone branch’s tree is a subdirectory of the otherresolves 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

  1. Decide fast-forward policy deliberately. A repository with merge.ff = false always creates merge commits for topic branches; a repository with merge.ff = true never does. The choice should be made at the repo or org level, not per-PR.
  2. Use --no-ff for release branches. A release branch’s merges into main are events that auditors care about; the merge commit is the audit trail. --no-ff makes that trail visible.
  3. Inspect the parent list of every merge commit. git cat-file -p &lt;oid&gt; on a merge commit prints the parent order, which is the convention every downstream tool assumes.
  4. 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 Merge sync strategy creates a merge commit; the Replace strategy 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-ff for 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

  1. Q1. What is the difference between a fast-forward merge and a merge commit?

  2. Q2. An octopus merge will refuse to complete if any of the merged-in branches has a conflict with the checked-out branch.

  3. 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?

  4. 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.