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-labis 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.<name>.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 --alllistsmain,feature/observability,feature/rbac-defaults, andrelease/2026q3. There is no branch namedmaster; the lab was initialised with-b main.git rev-list --all --countreturns 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.0returnstag— the annotated tag is a full object, not a lightweight ref.git cat-file -p v2026.3.0lists the commit it points at (REL2), the tagger, the date, and the message.git log --graph --oneline --decorate --allproduces 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, andrev-list-topo.txtexist 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 --allis the authoritative rendering of the commit DAG, and--decorateis what makes branch and tag labels visible on each commit. --no-ffproduces 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
gitGraphis 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 --parentsis the tooling form. Every commit appears with all of its parents on the same line, which is what--graphrenders 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.