Skip to main content
RunBook Academy

Git, CI/CD & GitOpsVIII · BranchingBranching

Divergence and shared history — the commit graph when two branches split

Intermediate⏱ ~19 mingit

What you'll learn

  • Identify the common ancestor of two branches using the merge-base computation
  • Distinguish a fast-forward situation from a true merge when both branches have moved forward
  • Explain why two branches can share history without sharing tips, and what that means for reachability
  • Read a divergence diagram and predict whether git merge will fast-forward or create a merge commit
  • Recognise the operational difference between "ahead" and "behind" in a remote-tracking comparison

Prerequisites

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.

Two branches in a Git repository are never independent objects. They are pointers into a single commit DAG, and the DAG has a topology that determines everything about how the branches relate. The vocabulary — common ancestor, divergence, fast-forward, merge base — is not jargon for its own sake. Each term names a property of the graph that Git uses to decide whether merging two branches is a no-op, a conflict, or a three-way merge. A production engineer who can read the DAG answers “what does merging these two branches actually do?” before running the merge. The engineer who cannot read the DAG finds out by running the merge.

Common ancestors and the merge base

When two branches share commits, those commits form a shared history. The most recent shared commit is the merge base: the youngest commit that is an ancestor of both branch tips. Git computes the merge base with git merge-base:

git merge-base main feature/iam-rotation
# 6f4e5a6f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d

In a linear history — one branch created from the other with no commits in between — the merge base is the tip of the older branch. In a branched history — both branches moving forward from a common commit — the merge base is the commit at the fork point.

flowchart LR
    A["commit 6f4e5a6\n(merge base)"] --> B["commit 8a3f9d2\n(main)"]
    A --> C["commit 9f3c1d7\n(feature/x)"]
    B --> D["commit 4d2c8e0\n(main)"]
    C --> E["commit a1b2c3d\n(feature/x)"]
    C --> F["commit 7e8f9a0\n(feature/x)"]

In the diagram, main is at 4d2c8e0 and feature/x is at a1b2c3d. Both tips are descendants of 6f4e5a6 (the merge base), but neither tip is a descendant of the other. The two branches have diverged.

Fast-forward versus true merge

The merge base computation determines what git merge does. Two cases:

  • Fast-forward. If one branch’s tip is a direct descendant of the other branch’s tip — that is, the merge base is one of the two tips — then moving the slower branch forward to the faster tip requires no merge commit. Git simply moves the pointer.
  • True merge. If neither tip is a descendant of the other, Git must create a merge commit with two parents. The merge commit records both branch tips as ancestors and the merge base as the common ground.
# Fast-forward case: feature is a descendant of main
git merge feature/iam-rotation
# Updating 8a3f9d2..a1b2c3d
# Fast-forward
# terraform/main.tf | 2 +-
# 1 file changed, 1 insertion(+), 1 deletion(-)

# True merge case: both branches have moved forward
git merge feature/iam-rotation
# Merge made by the 'recursive' strategy.
#  terraform/main.tf | 4 ++--
#  1 file changed, 2 insertions(+), 2 deletions(-)

A fast-forward is invisible in the graph: the branch pointer moves, and no new commit appears. A true merge is visible: a new commit with two parents appears at the merge point.

flowchart LR
    subgraph BEFORE["before merge"]
        A1["6f4e5a6"] --> B1["8a3f9d2 main"]
        A1 --> C1["9f3c1d7 feature"]
    end
    subgraph AFTER_FF["after fast-forward"]
        A2["6f4e5a6"] --> B2["8a3f9d2"] --> C2["9f3c1d7 main = feature"]
    end

The fast-forward diagram shows main simply moving to where feature was. No merge commit, no new history, no topology change.

flowchart LR
    subgraph BEFORE2["before merge"]
        A3["6f4e5a6 merge base"] --> B3["8a3f9d2 main"]
        A3 --> C3["9f3c1d7 feature"]
        B3 --> D3["4d2c8e0 main"]
    end
    subgraph AFTER_MERGE["after true merge"]
        A4["6f4e5a6"] --> B4["8a3f9d2"] --> D4["4d2c8e0 main"]
        A4 --> C4["9f3c1d7 feature"]
        C4 --> E4["a1b2c3d feature"]
        D4 --> F4["MERGE main"]
        E4 --> F4
    end

The true-merge diagram shows main and feature both extending from the merge base, and a new merge commit with two parents uniting them.

Why reachability matters

The merge base is a reachability question, not a time question. Two commits can be in chronological order and have neither be a descendant of the other — that is exactly what divergence is. Equally, two commits can be in time order and one be a descendant of the other (linear history).

The operational consequences:

  • A branch that has not received new commits since it was created from main is a descendant of main and can be fast-forwarded into main.
  • A branch that has received new commits on main and has new commits of its own is not a descendant of main; it has diverged and requires a true merge.

The reachability property is computed by walking parent edges in the DAG. The merge base is the youngest commit that both branch tips can reach. The cost of the computation is linear in the depth of the graph in the worst case, but in practice the merge base is usually close to the branch tips.

Reading divergence in practice

git status and git branch -vv both report the ahead/behind relationship between a branch and its upstream. This is the same reachability question, expressed for a remote-tracking comparison:

git status
# On branch feature/iam-rotation
# Your branch is ahead of 'origin/feature/iam-rotation' by 3 commits.
# (use "git push" to publish your local commits)

git branch -vv
# * feature/iam-rotation   9f3c1d7 [origin/feature/iam-rotation: ahead 3] rotate iam keys
#   main                   8a3f9d2 [origin/main: behind 2] bump terraform module

“Ahead” means local has commits that the upstream does not (“local is not a descendant of upstream”). “Behind” means upstream has commits that local does not (“local is not a descendant of upstream”). When both are true — local has commits the upstream does not, and the upstream has commits local does not — the branch has diverged from its upstream and a git pull will produce a merge commit rather than a fast-forward.

# Diverged: ahead 3, behind 2
git pull
# Merge made by the 'recursive' strategy.
# (no-fast-forward is the default for diverged branches)

The pull output reports a true merge because neither side is a descendant of the other. The reachability check that determines this is the same merge-base computation that git merge runs.

The shared-history property

Two branches share history when their merge base is not one of their tips — that is, when they have a common ancestor older than the branch points. Sharing history does not mean sharing tips: the tips can be hundreds of commits apart, on completely different lines of work, and still share a common ancestor from months ago.

flowchart LR
    A["6f4e5a6\n(months ago)"] --> B["main: 8a3f9d2"]
    A --> C["feature/x: a1b2c3d"]
    A --> D["hotfix/y: e7f8a9b"]
    A --> E["experiment/z: 1c2d3e4"]

The four branches in this diagram have not diverged from each other — they share the merge base 6f4e5a6 — but their tips have no relationship to each other. Any pair of them can be merged with a true merge commit; none of them is a descendant of any other.

The shared-history property is what makes git log --graph useful: the graph compresses the shared history into a single backbone and the per-branch commits into individual lines, which is the visual signature of a healthy branching workflow.

Production discipline

  1. Always read git merge-base before merging. The merge base tells you what the merge is going to do: if it equals one of the two tips, you get a fast-forward; otherwise you get a true merge commit.
  2. Use --no-ff when the audit trail matters. A true merge commit records “this feature was integrated at this commit” in the graph; a fast-forward records only that the work landed. Choose based on whether the team reads the graph.
  3. Treat diverged upstream branches as an alarm. A branch that is ahead and behind its upstream has been worked on while the upstream moved; the next pull will produce a merge commit, and any review should account for both sides of the merge.
  4. Document branch lifecycles with merge-base output. A release script that wants to know “which feature branches landed in this release?” should compute the merge base against the previous release tag and list branches whose tip is reachable from the new release.

Cross-course references

  • Ansible for Production Sysadmins - Part XXXVIII (Review) uses git merge-base to detect when a feature branch has diverged from main and to scope the PR review to the diverged range.
  • GitOps with Argo CD - Part IV (AppSources) discusses divergence in the context of multi-source applications, where each source branch has its own upstream relationship.
  • Terraform for Production Sysadmins - Part XII (State) draws the analogy between the merge base and the Terraform state version: both are reference points that determine what a “forward” operation actually does.

Quiz

Knowledge check · 4 questions

  1. Q1. Branch `feature/x` was created from `main` at commit `8a3f9d2`. Since then, three commits have been added to `feature/x` and two to `main`. What happens when you run `git merge feature/x` from `main`?

  2. Q2. When two branches share a common ancestor but neither is a descendant of the other, merging them always requires a merge commit and cannot be fast-forwarded.

  3. Q3. What is the merge base, and how does it determine whether `git merge` fast-forwards or creates a merge commit?

  4. Q4. Diagnose a release script that produces an unexpected commit count and recommend a fix.

    A release script cuts a release branch from main, then runs `git merge --no-ff` for each of 14 feature branches to integrate them. The release is supposed to add 14 commits (one merge per feature). Instead, the release branch shows 28 commits: 14 merge commits plus 14 feature tips, and the team suspects every feature branch was force-pushed with a new tip. The release is now behind schedule and the on-call is investigating.

Passing score: 75%. Answers are checked in this browser.