Git, CI/CD & GitOpsIV · Commit Graph and HistoryCommit Graph and History
Ancestry and reachability — what `reachable` actually means
What you'll learn
- Define reachability in the commit DAG and explain why it is the basis for every history query
- Use `git merge-base` and `git merge-base --is-ancestor` to answer ancestry questions
- Distinguish the two-dot range `A..B` from the three-dot range `A...B` and identify which queries each supports
- Explain why `git log A..B` returns commits reachable from B but not from A, and why this is the canonical "what is on this branch but not the other" view
- Recognise when `git rev-list --count A..B` is the right count to compare against a deployment window
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
Every history query in Git is a reachability question. “What commits are on this branch but not that one?” “What is the common ancestor of two branches?” “Has this commit been merged yet?” All three reduce to one operation: starting from some commit and following parent edges, can I reach some other commit? The vocabulary and commands in this lesson are how every GitOps controller, every CI diff, and every release script asks that question.
Reachability in the commit DAG
A commit B is reachable from a commit A if there exists a sequence of parent edges starting at A that ends at B. A is reachable from itself (the empty walk), and reachability is a property of the graph, not of any particular ref. Refs are just starting points.
flowchart LR
A --> C
A --> B
B --> D
C --> D
D --> E
E --> F
In the diagram:
Fis reachable from every other node (the graph is connected).Dis reachable fromA,B,C,E, andFbut not from any node earlier thanA.Ais reachable only from itself; no edge points intoA.
The graph is acyclic, so reachability is a partial order: either A reaches B, B reaches A, neither reaches the other, or both reach each other (which is impossible in a DAG unless they are the same node). This is the foundation of every ancestry question.
# Test ancestry: is $COMMIT_A an ancestor of $COMMIT_B?
git merge-base --is-ancestor "$COMMIT_A" "$COMMIT_B"
echo $? # 0 if reachable, 1 if not, 128 on error
git merge-base --is-ancestor returns exit code 0 when the first
commit is reachable from the second (i.e. is an ancestor of the
second), 1 when it is not, and a non-zero status other than 1 if
either commit does not exist. This is the cleanest, script-friendliest
ancestry test Git offers.
Merge-base: the deepest common ancestor
Given two commits A and B, the merge base is the deepest commit that is an ancestor of both - deepest in the sense of graph distance from the root. There can be more than one merge base if the two commits converged from different roots (the “criss-cross merge” case), in which case Git returns all of them.
git merge-base "$A" "$B"
# 6f4e5a6
flowchart LR
A --> M["merge-base\n6f4e5a6"]
B --> M
M --> P["earlier ancestors"]
M --> A2["A's later commits"]
M --> B2["B's later commits"]
The merge base is the natural three-way merge point. To merge A
into B, Git computes the merge base of A and B, diffs the base
against A (“their changes”), diffs the base against B (“our
changes”), and combines the two diffs into a tree that is the merge
result. The merge base is therefore not just an informational
quantity - it is the input to the merge algorithm.
# Compute merge base and diff against one side
MERGE_BASE=$(git merge-base "$A" "$B")
git diff "$MERGE_BASE" "$A" # "their" changes (from A's perspective)
git diff "$MERGE_BASE" "$B" # "our" changes
The two-dot range: A..B
The expression A..B is shorthand for “commits reachable from B
but not reachable from A”. It is the canonical way to enumerate
“what is on B but not on A”:
# Commits on B but not on A
git log --oneline "$A".."$B"
# 8a3f9d2 bump terraform module to v1.4.0
# 4d2c8e0 Merge branch 'feature/iam-rotation' into main
# Count: how many commits is B ahead of A?
git rev-list --count "$A".."$B"
# 2
The semantics are exact: A..B is the set difference of B’s
reachability closure and A’s reachability closure. If A is an
ancestor of B, this is “the commits B added after A”. If A and B
have diverged, this is “the commits B has that A does not”.
# Reverse range: A's commits not on B
git log --oneline "$B".."$A"
# (empty if B contains A, otherwise A's diverging commits)
The three-dot range: A…B
The expression A...B is shorthand for “commits reachable from
either A or B but not from both”. The pivot is the merge base of A
and B, and the range includes the commits on each side of the
pivot:
# Commits on either side of the merge base
git log --oneline "$A"..."$B"
# 9f3c1d7 (feature/iam-rotation) rotate iam keys
# 7a1b2c3 add rotation cron
# 8a3f9d2 bump terraform module to v1.4.0
flowchart LR
MB["merge-base"] --> A_only["A's commits"]
MB --> B_only["B's commits"]
In the diagram, A...B includes both “A-only” commits and “B-only”
commits - everything from the merge base forward, excluding the
merge base itself and any common ancestors.
The three-dot range is most useful for code review: “show me every commit on either side of the merge base, so the reviewer sees the full divergence”. The two-dot range is most useful for “what’s on B that isn’t on A”: releases, deployments, and pending work.
Symmetric difference versus simple difference
| Range | Pivot | Includes |
|---|---|---|
A..B | none (set diff) | commits on B but not on A |
B..A | none (set diff) | commits on A but not on B |
A...B | merge-base | commits on A xor B (not both, not ancestor) |
A^@ | none | parents of A (one entry per parent) |
A^! | none | A but not its parents |
The negation prefix ^ (caret) excludes a set from the result. ^A B means “reachable from B but not from A” - exactly the same as
A..B.
rev-list: the underlying traversal
git log is a wrapper around git rev-list. git rev-list walks
the DAG and prints every OID that matches the range, with no
formatting. It is the right tool for scripts:
# List every OID on B not on A
git rev-list "$A".."$B"
# 8a3f9d2...
# 4d2c8e0...
# Count them
git rev-list --count "$A".."$B"
# 2
# Limit to the trunk (first-parent only)
git rev-list --first-parent --count "$A".."$B"
# 2
git rev-list --count A..B is the canonical “how many commits is B
ahead of A” query, and it is the basis for every “is the trunk
behind?” alert, “how many commits since last release?” query, and
“is feature branch up to date?” check.
Production discipline
- Use
A..Bfor “what is on B but not A”. Releases, pending work, deployment windows. The two-dot range is the answer. - Use
A...Bfor code review. The reviewer wants to see both sides of the divergence. The three-dot range is the answer. - Test ancestry with
git merge-base --is-ancestor. It is the cleanest exit-code-based ancestry test and is reliable in scripts. Avoid parsinggit logoutput to test ancestry. - Always inspect the merge-base before approving a CI plan. A merge-base that is wrong is a CI plan that is wrong.
Cross-course references
- GitOps with Argo CD - Part V (SyncWindows) uses
git rev-list --countbetween the cluster’s last-synced commit and the desired commit to compute drift, the operational signal that something has diverged from Git. - Terraform for Production Sysadmins - Part XII (State)
describes
terraform planagainst a merge-base as the infrastructure analogue ofgit diff merge-base branch- both compute the change that a merge would apply. - CI/CD Pipeline Patterns - Part IV (MergeChecks) requires every merge pipeline to verify the merge-base and to refuse the merge if the merge-base has changed since the plan was approved.
Quiz
Knowledge check · 4 questions
Q1. Which statement about Git's reachability in the commit DAG is correct?
Q2. `git log A..B` and `git log A...B` return the same set of commits when B is a descendant of A.
Q3. Which `git merge-base` flag returns an exit-code-only answer to the question 'is A an ancestor of B?', and what does each exit code mean?
Q4. Diagnose whether a CI plan ran against the correct merge-base, and identify what would produce an incorrect merge-base.
A CI pipeline approves a Terraform plan for a merge from `feature/iam-rotation` into `main`. After the merge is performed, the actual diff applied to the cluster includes changes that were not in the approved plan. The team suspects the merge-base used by the pipeline was wrong. The merge-base at plan time was `6f4e5a6`. The merge commit's parents, from `git cat-file -p`, are `9f3c1d72` (main's previous tip) and `7a1b2c3a` (feature/iam-rotation's tip).
Passing score: 75%. Answers are checked in this browser.