Skip to main content
RunBook Academy

← All labs in Git, CI/CD & GitOps

Lab · foundation · ~45 min

Lab 2: Visualise the commit DAG

C · SimulationB · Nested virtualisation

Objectives

  • Build a repository with a realistic branch topology: main, two feature branches, a merge commit, and an annotated tag
  • Use `git log --graph --oneline --decorate --all` to draw the resulting DAG in ASCII
  • Read a graph visualisation: identify merge commits, octopus-style merges, and dangling topic branches
  • Combine `--graph` with `--format` and `--date` to build a presentation-quality log line per commit
  • Use `git rev-list --topo-order --parents` to confirm the structure that `--graph` is rendering

Prerequisites

Objective

By the end of this lab you will have built a small but realistic repository topology — main, two feature branches, a merge commit, an octopus-style merge into a release branch, and an annotated tag — and read the resulting commit DAG back from git log --graph. You will also be able to reconstruct the same graph in Mermaid, with every branch and tag labelled, so that a code review document or a post-incident note can carry the same picture in a tool that does not speak Git.

The graph is not decoration. A merge commit with two parents is not “two commits squashed together”; it is a single commit that names both parents. Reading the DAG is the only way to answer questions like “which feature did this hotfix come from?” without guessing.

Architecture

The repository has one long-lived branch (main), two short-lived feature branches (feature/observability and feature/rbac-defaults), one release branch (release/2026q3), and one annotated tag (v2026.3.0). The graph ends with a two-parent merge and an octopus-style three-parent merge.

gitGraph
    commit id: "C0"
    commit id: "C1"
    branch feature/observability
    checkout main
    commit id: "C2"
    branch feature/rbac-defaults
    checkout feature/observability
    commit id: "O1"
    commit id: "O2"
    checkout feature/rbac-defaults
    commit id: "R1"
    commit id: "R2"
    checkout main
    merge feature/rbac-defaults id: "M1"
    branch release/2026q3
    commit id: "REL1"
    checkout feature/observability
    commit id: "O3"
    checkout release/2026q3
    merge main id: "M2"
    merge feature/observability id: "M3"
    commit id: "REL2"
    checkout main
    merge release/2026q3 tag: "v2026.3.0" id: "M4"

The graph is built in this order so that --graph has a non-trivial shape to draw: each merge commit has a different number of parents, and the release branch starts and ends at different points on main.

Requirements

  • Git 2.55.x on Linux or macOS.
  • A clean working directory. Nothing outside $HOME/git-graph-lab is touched.
  • No network access. Everything is local.

Scenario

A junior engineer has asked you to explain why a code review they opened shows two parallel histories behind main. Their understanding is “feature branches get merged in”, which is true and useless. Your job in this lab is to be able to point at a graph and say: “this commit on feature/observability is reachable from main through that merge commit, which has those two parents, and the annotated tag on release/2026q3 was placed on the merge commit, not on a commit purely on main.” You cannot do that from a git log of a single branch; you can do it from --graph --all.

Tasks

Task 1 — Build the empty repository and the linear base

LAB="$HOME/git-graph-lab"
rm -rf "$LAB"
mkdir -p "$LAB"
cd "$LAB"

git init -b main
git config user.email 'ops@example.com'
git config user.name  'Ops'

# C0: initial commit
echo '# runbook infra' > README.md
git add README.md
git commit -m 'C0: initial repository'

# C1: add a directory the next commits can modify
mkdir -p modules/network
echo 'placeholder' > modules/network/main.tf
git add modules/network/main.tf
git commit -m 'C1: add network module placeholder'

git log --oneline --decorate

The first two commits form the linear trunk that every later branch will fork from. Without them, the --graph output has no anchor and the merge commits have nothing to “join”.

Task 2 — Create the two feature branches

cd "$HOME/git-graph-lab"

# C2: a commit on main between the two branches, so the topology has
# at least three distinct points along main.
echo 'locals { region = "eu-west-1" }' > main.tf
git add main.tf
git commit -m 'C2: pin region on main'

# Branch feature/observability from C1 — note the explicit <sha>
git branch feature/observability "$(git rev-parse HEAD~1)"

# Branch feature/rbac-defaults from C2 (the new HEAD)
git branch feature/rbac-defaults

# Move onto observability, write two commits
git switch feature/observability
echo 'observability = { enabled = true }' > modules/network/observability.tf
git add modules/network/observability.tf
git commit -m 'O1: enable observability in network module'

echo 'dashboards = ["golden-signals"]' >> modules/network/observability.tf
git add modules/network/observability.tf
git commit -m 'O2: declare golden-signals dashboards'

# Move onto rbac-defaults, write two commits
git switch feature/rbac-defaults
echo 'rbac = { default_role = "viewer" }' > rbac.tf
git add rbac.tf
git commit -m 'R1: add default rbac viewer role'

echo 'rbac = { default_role = "viewer", cluster_admin = false }' > rbac.tf
git add rbac.tf
git commit -m 'R2: explicitly disable cluster-admin default'

git branch --all

Note the deliberate decision to branch feature/observability from HEAD~1, not from HEAD. That puts it on the same starting point as feature/rbac-defaults would be — except rbac will branch off later, so it has C2 in its ancestry and observability does not. The graph will show this as two parallel lines that start at different points on main.

Task 3 — First merge: rbac-defaults into main

The first merge commit is a two-parent merge on main. After this task, --graph --all will show a fork and a rejoin.

cd "$HOME/git-graph-lab"

git switch main
git merge --no-ff feature/rbac-defaults \
  -m 'M1: merge rbac-defaults (rbac viewer default, no cluster-admin)'

git log --oneline --decorate --graph --all

The --no-ff flag is the only reason this is a merge commit. Without it, git merge would fast-forward main to R2, no merge commit would be created, and the graph would show a straight line. In a production team that wants every merge to be auditable as a discrete event, --no-ff (or a config setting like branch.&lt;name&gt;.mergeOptions) is how you enforce that.

Task 4 — Release branch, then the octopus merge

cd "$HOME/git-graph-lab"

# Release branch from the merge commit
git branch release/2026q3

# Single commit on release: a hotfix that is only in the release line.
git switch release/2026q3
echo 'changelog = "see release notes"' > CHANGELOG.md
git add CHANGELOG.md
git commit -m 'REL1: pin release notes reference'

# Add a commit on feature/observability that nobody else has, so the
# octopus merge has three distinct second parents.
git switch feature/observability
echo 'alerts = { slack_channel = "#ops-alerts" }' \
  >> modules/network/observability.tf
git add modules/network/observability.tf
git commit -m 'O3: route alerts to slack'

# Now the octopus: merge main, then merge feature/observability.
git switch release/2026q3
git merge --no-ff main \
  -m 'M2: bring release up to current main'

git merge --no-ff feature/observability \
  -m 'M3: bring observability feature into release'

# One more release-only commit so the tag is not on the merge commit
echo 'patchlevel = 0' >> CHANGELOG.md
git add CHANGELOG.md
git commit -m 'REL2: bump patchlevel'

git log --oneline --decorate --graph --all

M3 is the only commit in the repository with three parents: the previous release commit (REL1), the merge of main (M2), and the tip of feature/observability (O3). That is the octopus shape the graph will draw, and it is the one to look for when you need to know whether a branch caught everything you meant it to catch.

Task 5 — Annotated tag and the final merge into main

cd "$HOME/git-graph-lab"

# Annotated tag on the release-branch tip — note this is on REL2, not on
# the M3 octopus commit. The tag points at a specific snapshot, not at
# the merge event.
TAG_TARGET="$(git rev-parse release/2026q3)"
git tag -a 'v2026.3.0' "$TAG_TARGET" \
  -m 'release 2026 Q3 — observability and rbac defaults'

# Final merge: bring the release into main, no fast-forward, so the
# merge commit exists as a discrete event in main's history.
git switch main
git merge --no-ff release/2026q3 \
  -m 'M4: bring release/2026q3 into main'

git log --oneline --decorate --graph --all

The tag is annotated rather than lightweight, which means it is its own object in the store — a tag object that names the commit OID, the tag type, the tagger, and the message. git cat-file -p v2026.3.0 will print all four. A lightweight tag is just a ref that points at a commit directly, and git cat-file cannot print it as a standalone object.

Task 6 — Capture the canonical graph

The canonical output for this lab is the graph as --graph --all renders it, captured to a file. That file is the reference you will compare against when you want to know what the graph looked like at the moment the lab ended.

cd "$HOME/git-graph-lab"

git log --graph --oneline --decorate --all \
  > graph.txt

cat graph.txt

The --decorate flag is what shows the branch and tag labels in parentheses next to each commit. Without it, --graph still draws the topology, but you cannot tell which line is main and which is release/2026q3. For a hand-rendered diagram of the same graph, Task 7’s Mermaid output is the durable artefact.

Task 7 — Render the same graph in Mermaid

Translate the --graph --all output into a Mermaid gitGraph, so that a code review or post-incident document can carry the picture without requiring Git at the receiving end.

gitGraph
    commit id: "C0"
    commit id: "C1"
    branch feature/observability
    checkout main
    commit id: "C2"
    branch feature/rbac-defaults
    checkout feature/observability
    commit id: "O1"
    commit id: "O2"
    checkout feature/rbac-defaults
    commit id: "R1"
    commit id: "R2"
    checkout main
    merge feature/rbac-defaults id: "M1"
    branch release/2026q3
    commit id: "REL1"
    checkout feature/observability
    commit id: "O3"
    checkout release/2026q3
    merge main id: "M2"
    merge feature/observability id: "M3"
    commit id: "REL2"
    checkout main
    merge release/2026q3 tag: "v2026.3.0" id: "M4"

The two diagrams are the same graph: every commit in one is a node in the other, every branch is a lane, and the merge commits connect the lanes. The Mermaid form is what you paste into a PR description or a runbook; the git log --graph form is what you capture when the history is the source of truth.

Task 8 — Confirm the topology with git rev-list

git log --graph is for humans. git rev-list --topo-order --parents is for tooling: it lists commit OIDs in a parent-before-child order that is guaranteed to produce the same graph --graph would draw.

cd "$HOME/git-graph-lab"

# Topological order, with parents printed after each commit OID.
git rev-list --topo-order --parents --all \
  > rev-list-topo.txt

# Each line is: <commit-sha> [<parent-sha>...]
# A merge commit has more than one parent on the same line.
head -25 rev-list-topo.txt

Read the file from top to bottom: every commit that appears on a line has all of its parents on the same line. A merge commit has more than one parent SHA. The order is topological, which means every commit appears before any of its descendants — the same order in which --graph would draw the lines left-to-right.

Task 9 — Capture deliverables

# check-shell-blocks: allow-invalid
cd "$HOME/git-graph-lab"

# Deliverable 1: the canonical graph
git log --graph --oneline --decorate --all > graph.txt

# Deliverable 2: the Mermaid form of the same graph
{
  echo '```mermaid'
  echo 'gitGraph'
  echo '    commit id: "C0"'
  echo '    commit id: "C1"'
  echo '    branch feature/observability'
  echo '    checkout main'
  echo '    commit id: "C2"'
  echo '    branch feature/rbac-defaults'
  echo '    checkout feature/observability'
  echo '    commit id: "O1"'
  echo '    commit id: "O2"'
  echo '    checkout feature/rbac-defaults'
  echo '    commit id: "R1"'
  echo '    commit id: "R2"'
  echo '    checkout main'
  echo '    merge feature/rbac-defaults id: "M1"'
  echo '    branch release/2026q3'
  echo '    commit id: "REL1"'
  echo '    checkout feature/observability'
  echo '    commit id: "O3"'
  echo '    checkout release/2026q3'
  echo '    merge main id: "M2"'
  echo '    merge feature/observability id: "M3"'
  echo '    commit id: "REL2"'
  echo '    checkout main'
  echo '    merge release/2026q3 tag: "v2026.3.0" id: "M4"'
  echo '```'
} > topology-map.txt

# Deliverable 3: the topological parent listing
git rev-list --topo-order --parents --all > rev-list-topo.txt

ls -l graph.txt topology-map.txt rev-list-topo.txt

Validation

  • git branch --all lists main, feature/observability, feature/rbac-defaults, and release/2026q3. There is no branch named master; the lab was initialised with -b main.
  • git rev-list --all --count returns 12: C0, C1, C2, O1, O2, R1, R2, REL1, O3, REL2, M1, M2, M3, M4 — actually 14 commits total in the topology. If the count differs, an earlier task did not run cleanly.
  • git rev-list --parents --all | grep -c ' ' (lines with at least one parent SHA) returns a number equal to “all merges + child commits”. There should be exactly 4 merge commits (M1, M2, M3, M4).
  • git cat-file -t v2026.3.0 returns tag — the annotated tag is a full object, not a lightweight ref.
  • git cat-file -p v2026.3.0 lists the commit it points at (REL2), the tagger, the date, and the message.
  • git log --graph --oneline --decorate --all produces a graph that has the shape described in the Architecture section. Every merge commit has the expected number of parents.
  • The deliverables graph.txt, topology-map.txt, and rev-list-topo.txt exist and are non-empty.

Expected Outcome

A repository whose graph can be reproduced from text alone, and a lab artefact that captures the graph twice: once in Git’s native ASCII form (graph.txt) and once in Mermaid (topology-map.txt).

$HOME/git-graph-lab/
├── .git/
│   ├── refs/
│   │   ├── heads/main
│   │   ├── heads/feature/observability
│   │   ├── heads/feature/rbac-defaults
│   │   ├── heads/release/2026q3
│   │   └── tags/v2026.3.0
│   └── objects/                # 14 commits + 1 tag object
├── graph.txt                   # git log --graph --all
├── rev-list-topo.txt           # git rev-list --topo-order --parents --all
└── topology-map.txt            # Mermaid gitGraph block

You can point at any commit on the graph and answer four questions: which branch was its tip when it was committed, which other branches had been merged into its ancestry at that point, which merge commit brought it into main, and which tag (if any) was placed on a commit reachable from it.

Troubleshooting

The graph has no branches and looks like a straight line. The git branch calls in Task 2 ran but no commits were added on the branches before merging. Re-run Task 2 and Task 3 with at least one commit on each branch before the merge; the commits on the branches are the visual evidence that the merge had two parents.

git merge fast-forwards even with --no-ff. --no-ff only takes effect when the branch being merged has commits that are not already on the target. If you ran the lab twice or re-merged a branch whose contents are already in main, Git will fast-forward quietly. Confirm with git log --oneline --graph after each merge: a merge commit produces two lines for that one commit.

git tag -a complains the tag already exists. A previous run of this lab left the tag in place. Delete it with git tag -d v2026.3.0 and re-run Task 5. If a different command created a tag of the same name on a different OID, the second git tag -a will overwrite the local ref but not the remote one if you have already pushed.

git rev-list --topo-order --parents --all lists commits in an order you do not recognise. --topo-order is a strict partial order; commits that are unrelated by ancestry may appear in any order, so two runs of the same command against the same repository can produce different orderings of unrelated commits but the same overall topology. What --topo-order guarantees is that every parent appears before its child.

The Mermaid gitGraph block fails to render. Mermaid parsers vary on which branch names they accept without quoting. If the diagram refuses to render, wrap each branch name in double quotes inside the branch and checkout directives: branch "feature/observability" and checkout "feature/observability". Older Mermaid renderers also require that the commit id: values not contain spaces or hyphens, which is why this lab uses single-token IDs like M1.

Cleanup

The lab is entirely local. Nothing to undo on a remote, no credentials to revoke.

LAB="$HOME/git-graph-lab"

# Keep the deliverables if you want them.
mv "$LAB"/graph.txt "$LAB"/topology-map.txt "$LAB"/rev-list-topo.txt \
   "$HOME"/ 2>/dev/null

rm -rf "$LAB"

find "$HOME" -maxdepth 1 -name 'git-graph-lab' -print
# expected: (no output)

If you ran the lab inside an existing repository by accident, the branches and tags persist on it. Remove them with:

cd /path/to/that-repo
git branch -D feature/observability feature/rbac-defaults release/2026q3
git tag -d v2026.3.0
git log --oneline --graph --all
# Confirm the cleanup left main where it was.

What You Learned

  • The graph is the history. git log --graph --all is the authoritative rendering of the commit DAG, and --decorate is what makes branch and tag labels visible on each commit.
  • --no-ff produces an explicit merge commit. Without it, fast-forward merges produce a straight line and lose the merge event as a discrete point in the audit trail.
  • Octopus merges are visually distinctive and rare. A commit with three parents is drawn as a single point with three incoming lines; sequential merges look similar but each has only two parents.
  • Annotated tags are objects. A lightweight tag is a ref that points at a commit; an annotated tag is a tag object in the store that names a commit, a tagger, and a message.
  • Mermaid gitGraph is a portable rendering of the same topology. Paste the equivalent block into a PR or runbook and reviewers see the same picture without running Git.
  • git rev-list --topo-order --parents is the tooling form. Every commit appears with all of its parents on the same line, which is what --graph renders and what a custom graph walker must reproduce.
  • Reading a graph is a debugging skill. “Where did this commit come from?” is the most common post-incident question, and the graph is the only place it has a literal answer.

Deliverables

  • · graph.txt — the output of `git log --graph --oneline --decorate --all` for the final repository
  • · topology-map.txt — a hand-drawn Mermaid equivalent of the same graph, with branch and tag labels
  • · rev-list-topo.txt — the output of `git rev-list --topo-order --parents --all`

Verification status

Last reviewed
2026-08-25
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.