Learning Objectives
By the end of this module, you will be able to:
- Describe the commit graph as a directed acyclic graph (DAG) with backward-pointing parent references
- Explain why the tree/branch metaphor is "leaky" and how the graph model is more accurate
- Demonstrate that a branch is nothing more than a movable pointer to a commit
- Explain what HEAD is, how it moves, and when it becomes "detached"
- Distinguish between local branches, remote-tracking branches, and tags
1. The Commit Graph
In Module 3, you learned that every commit object points to a parent commit — the commit that came immediately before it. This parent reference is how Git records history. But history isn't a straight line — it branches and merges. The resulting structure is a directed acyclic graph (DAG).
From Linked Lists to Graphs
A simple linear history is a singly linked list. Each commit points back to exactly one parent:
○ ◄── ○ ◄── ○ ◄── ○
C1 C2 C3 C4
Time flows left to right →
Pointers point right to left ←
Why do pointers point backwards? Because commits are immutable. When C2 is created, C1 already exists — so C2 can record C1's hash as its parent. But C1 can't be modified to record C2 as its child. Immutability means references always point to the past.
When two developers work in parallel and later merge, the graph gets a branch and a merge point:
○ ◄── ○ (feature branch)
C3 C4
╱ ╲
○ ◄── ○ ○ (merge commit — two parents)
C1 C2 C6
╱
○ ◄── ○ (another branch)
C3' C5
The merge commit (C6) has two parents — that's how Git knows a merge occurred. No metadata flag, no label. Two parents = merge.
It's a DAG, Not a Tree
The word "branch" comes from the tree metaphor, but Git's history is not a tree. Trees cannot have cycles or convergence points. Git's commit history is a directed acyclic graph:
- Directed — edges (parent references) have a direction (child → parent)
- Acyclic — you can never follow parent references and arrive back where you started
- Graph — nodes can have multiple incoming edges (merge commits) and multiple outgoing edges (branch points)
Tree (no convergence): DAG (convergence allowed):
○ ○
/ \ / \
○ ○ ○ ○
/ \ \ /
○ ○ ○
/ \
○ ○
The tree metaphor breaks down the moment you merge. After that, you're working with a graph. Understanding this matters because many Git operations — log traversal, rebase, merge-base calculation — are graph algorithms, not tree algorithms.
Anatomy of a Commit in the Graph
Every commit node in the graph carries:
┌─────────────────────────────────┐
│ Commit: a1b2c3d │
├─────────────────────────────────┤
│ tree: 8f3e2a1 (snapshot) │
│ parent: e4f5a6b (prev commit)│
│ parent: 7c8d9e0 (2nd parent │
│ if merge) │
│ author: Jane Doe │
│ date: 2024-01-15 10:30:00 │
│ message: "Merge feature into…" │
└─────────────────────────────────┘
- Zero parents — the very first commit (root commit). A repository can have multiple root commits (rare, but happens with
git merge --allow-unrelated-histories). - One parent — a normal commit.
- Two parents — a merge commit. The first parent is the branch you were on; the second parent is the branch you merged in.
- Three+ parents — an octopus merge (rare, advanced).
Reading the Graph with git log
# Linear log
git log --oneline
# a1b2c3d Merge feature-auth into main
# 7c8d9e0 Add login page
# e4f5a6b Add signup form
# 3d4e5f6 Initial commit
# Graph visualization
git log --oneline --graph --all
# * a1b2c3d Merge feature-auth into main
# |\
# | * 7c8d9e0 Add login page
# | * b2c3d4e Add auth middleware
# |/
# * e4f5a6b Add signup form
# * 3d4e5f6 Initial commitThe --graph flag draws ASCII art showing the DAG structure. The --all flag includes commits from all branches, not just the current one. This is one of the most useful commands in Git.
2. What a Branch Really Is
This is the single most important concept in Git's branching model, and it's surprisingly simple:
A branch is a pointer. A 40-character text file that contains the hash of a commit.
That's it. A branch is not a container of commits. It's not a copy of your files. It's not a timeline or a history line. It is a pointer — a label stuck on one commit.
Proof
cat .git/refs/heads/main
# a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0The file .git/refs/heads/main contains one line: the SHA-1 hash of the commit that main currently points to. Creating a branch means creating one of these tiny files. Deleting a branch means deleting it.
Branches Are Cheap
Because a branch is just a 41-byte file (40 hex chars + newline), creating a branch is nearly instantaneous and costs almost nothing. This is revolutionary compared to SVN, where creating a branch meant copying the entire directory tree.
# Create a branch — Git creates a 41-byte file
git branch feature-login
# That's equivalent to:
# echo "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0" > .git/refs/heads/feature-loginBranches Move When You Commit
The special thing about a branch isn't that it exists — it's that it moves forward automatically when you commit (if HEAD points to it). This is what makes branches useful for tracking ongoing work:
Before commit:
○ ◄── ○ ◄── ○
C1 C2 C3
▲
main
▲
HEAD
After "git commit":
○ ◄── ○ ◄── ○ ◄── ○
C1 C2 C3 C4
▲
main ← moved forward
▲
HEAD
The commit was created, and main automatically moved to point to it. HEAD still points to main.
Creating a Branch Doesn't Switch to It
git branch feature-login # creates the pointer, HEAD stays where it is
git switch feature-login # moves HEAD to point to feature-loginOr in one step:
git switch -c feature-login # create + switch
git checkout -b feature-login # older syntax, same effectVisualizing Multiple Branches
○ ◄── ○ ◄── ○ ◄── ○
C1 C2 C3 C4
▲ ▲
main feature-login
▲
HEAD
Both main and feature-login point to commits in the same graph. There's no separate container or copy. Commits aren't "on" a branch — rather, a branch points to a commit, and all ancestors of that commit are reachable from the branch.
What Happens When Branches Diverge
# On feature-login, make two commits:
git switch feature-login
# ... make changes, commit twice ...
# Switch back to main, make a commit:
git switch main
# ... make changes, commit ... ○ ◄── ○ feature-login
C4 C5
╱
○ ◄── ○ ◄── ○
C1 C2 C3 ◄── ○ main ← HEAD
C6
Main and feature-login have diverged — they share C1–C3 as common ancestors, but each has commits the other doesn't. This is normal and expected. Merging (Module 7) or rebasing (Module 9) will bring them back together.
3. HEAD — Your Current Position
HEAD is a special pointer that tells Git where you are right now. It determines:
- Which branch's history
git logshows by default - Which commit new commits will be attached to
- Which branch pointer moves forward when you commit
Normal State: HEAD Points to a Branch
cat .git/HEAD
# ref: refs/heads/mainHEAD doesn't point to a commit — it points to a branch name, which in turn points to a commit. This indirection is what allows the branch to move when you commit.
HEAD ──► main ──► C4
When you commit:
HEAD ──► main ──► C5 (new commit, main moved, HEAD followed)
How HEAD Moves
| Action | What Happens to HEAD |
|---|---|
git switch main | HEAD → refs/heads/main |
git switch feature | HEAD → refs/heads/feature |
git commit | HEAD stays on current branch; branch pointer moves forward |
git checkout <hash> | HEAD → commit hash directly (detached!) |
git reset --hard <hash> | Current branch moves to <hash>; HEAD follows |
The Dance of Commit
When you run git commit, this is the sequence:
- Git reads HEAD → finds
refs/heads/main→ finds the commit hash - That hash becomes the parent of the new commit
- Git creates the new commit object (tree + parent + metadata)
- Git updates
refs/heads/mainto point to the new commit - HEAD still points to
refs/heads/main— butmainnow points to the new commit
HEAD itself didn't change (it still says ref: refs/heads/main). But the commit HEAD resolves to did change, because main moved.
4. Detached HEAD State
Normally, HEAD points to a branch name. But you can point HEAD directly at a commit:
git checkout a1b2c3d # checkout a specific commit by hash
git checkout v1.0.0 # checkout a tag
git checkout HEAD~3 # checkout 3 commits agoGit will warn you:
You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by switching back to a branch.
What Detached HEAD Looks Like
cat .git/HEAD
# a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0 ← raw hash, not a refNormal: Detached:
HEAD ──► main ──► C4 HEAD ──► C2 (directly)
○ ◄── ○ ◄── ○ ◄── ○ ○ ◄── ○ ◄── ○ ◄── ○
C1 C2 C3 C4 C1 C2 C3 C4
▲ ▲ ▲
main HEAD main
▲
HEAD
Why Detached HEAD Is "Dangerous"
If you make commits in detached HEAD state, no branch pointer moves to track them:
○ ◄── ○ ◄── ○ ◄── ○ ← orphaned commits
C2 C5 C6 C7
▲
HEAD
○ ◄── ○ ◄── ○ ◄── ○
C1 C2 C3 C4
▲
main
If you then git switch main, HEAD moves to main, and commits C5–C7 become unreachable — no branch points to them. They'll eventually be garbage collected (after ~2 weeks). They're not lost immediately — you can find them with git reflog — but they're in danger.
When Detached HEAD Is Useful
- Inspecting old commits — you want to see the project at a specific point in history without creating a branch
- Running old code — testing whether a bug existed in a previous version
- Temporary experiments — try something without committing to a branch; switch back to discard
Recovering from Detached HEAD
If you made commits in detached HEAD state and want to keep them:
# While still in detached state, create a branch:
git switch -c my-experiment
# Now those commits are safe — a branch points to themIf you already switched away:
git reflog
# Find the hash of your detached commit
git switch -c my-experiment <hash>5. Remote-Tracking Branches
When you clone a repository, Git creates remote-tracking branches — read-only pointers that represent the state of branches on the remote server.
git branch -a
# * main ← local branch
# feature-login ← local branch
# remotes/origin/main ← remote-tracking branch
# remotes/origin/feature-login ← remote-tracking branchHow They Work
Remote (GitHub) Local Machine
┌──────────────┐ ┌──────────────────────┐
│ main ──► C4 │ │ main ──► C4 │ ← local branch
│ │ │ origin/main ──► C4 │ ← remote-tracking
└──────────────┘ └──────────────────────┘
Remote-tracking branches:
- Are stored at
refs/remotes/<remote>/<branch>(e.g.,refs/remotes/origin/main) - Are updated by
git fetch(andgit pull, which includes a fetch) - Cannot be checked out directly — if you try, you get a detached HEAD
- Represent Git's last known state of the remote — they may be stale if you haven't fetched recently
The Fetch-Then-Compare Pattern
git fetch origin # update all remote-tracking branches
git log main..origin/main --oneline # show commits on remote that you don't have
git log origin/main..main --oneline # show commits you have that remote doesn'tAfter a fetch, if origin/main has moved ahead of your local main:
○ ◄── ○ ◄── ○ ◄── ○ ◄── ○
C1 C2 C3 C4 C5
▲ ▲
main origin/main
▲
HEAD
Your local main is at C4; the remote has a new commit C5. You need git merge origin/main or git pull to catch up.
Creating a Local Branch from a Remote-Tracking Branch
git switch feature-login
# If there's no local 'feature-login' but there IS 'origin/feature-login',
# Git automatically creates a local branch tracking the remote one.Or explicitly:
git switch -c feature-login origin/feature-login6. Tags — Named Snapshots
Tags are pointers to specific commits, typically used to mark releases. Unlike branches, tags don't move — they permanently mark a point in history.
Lightweight Tags
A lightweight tag is just a named pointer to a commit — identical to a branch, except it never moves:
git tag v1.0.0 # tags the current HEAD commit
git tag v0.9.0 a1b2c3d # tags a specific commitStored as a simple file:
cat .git/refs/tags/v1.0.0
# a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0Annotated Tags
An annotated tag is a full Git object with its own metadata:
git tag -a v1.0.0 -m "Release version 1.0.0"git cat-file -p v1.0.0
# object a1b2c3d4...
# type commit
# tag v1.0.0
# tagger Jane Doe <jane@example.com> 1705334400 +0000
#
# Release version 1.0.0Annotated tags store the tagger's name, email, date, and a message. They can also be GPG/SSH signed for verification.
When to Use Which
| Lightweight | Annotated | |
|---|---|---|
| Stored as | A file pointing to a commit | A Git object with metadata |
| Has a message | No | Yes |
| Has author info | No | Yes |
| Can be signed | No | Yes |
| Use case | Temporary/private markers | Releases, public milestones |
Recommendation: Use annotated tags (git tag -a) for anything you'll share with others. Use lightweight tags for personal bookmarks.
Listing and Managing Tags
git tag # list all tags
git tag -l "v1.*" # list tags matching a pattern
git show v1.0.0 # show tag details + commit
git tag -d v1.0.0 # delete a local tag
git push origin v1.0.0 # push a specific tag to remote
git push origin --tags # push all tags to remote
git push origin --delete v1.0.0 # delete a remote tag7. Putting It All Together — The Pointer Map
Here's the complete picture of how all pointers relate:
.git/HEAD
│
▼
ref: refs/heads/main ← "I'm on the main branch"
│
▼
.git/refs/heads/main
│
▼
a1b2c3d ← commit hash
│
▼
┌─────────────────┐
│ Commit a1b2c3d │
│ tree: 8f3e2a1 │──────► tree object (project snapshot)
│ parent: e4f5a6b │──────► previous commit
└─────────────────┘
.git/refs/heads/feature-login
│
▼
7c8d9e0 ← different commit hash
.git/refs/remotes/origin/main
│
▼
a1b2c3d ← same hash as local main (in sync)
.git/refs/tags/v1.0.0
│
▼
3d4e5f6 ← permanently fixed to this commit
Everything resolves to a commit hash. Branches, tags, HEAD, remote-tracking branches — they're all pointers into the same commit graph.
Command Reference
| Command | Description |
|---|---|
git log --oneline --graph --all | Visualize the full commit graph with ASCII art |
git log --oneline --graph --all --decorate | Same, with branch/tag labels (default in modern Git) |
git branch | List local branches |
git branch -a | List all branches (local + remote-tracking) |
git branch -v | List branches with last commit message |
git branch <name> | Create a branch (don't switch to it) |
git branch -d <name> | Delete a branch (safe — refuses if unmerged) |
git branch -D <name> | Force-delete a branch (even if unmerged) |
git branch -m <old> <new> | Rename a branch |
git switch <branch> | Switch to a branch (moves HEAD) |
git switch -c <branch> | Create a branch and switch to it |
git checkout <branch> | Switch to a branch (older syntax) |
git checkout -b <branch> | Create and switch (older syntax) |
git checkout <hash> | Detach HEAD at a specific commit |
git tag <name> | Create a lightweight tag at HEAD |
git tag -a <name> -m "msg" | Create an annotated tag at HEAD |
git tag -l "pattern" | List tags matching a glob pattern |
git tag -d <name> | Delete a local tag |
git rev-parse HEAD | Show the full hash that HEAD resolves to |
git rev-parse <ref> | Show the full hash that any ref resolves to |
cat .git/HEAD | See what HEAD currently points to |
cat .git/refs/heads/<branch> | See what commit a branch points to |
Hands-On Lab: Exploring the Commit Graph and Pointer Movement
This lab builds a small repository with branches, merges, and tags, then inspects the graph and pointer mechanics directly.
Setup
mkdir ~/git-graph-lab
cd ~/git-graph-lab
git initStep 1: Build a Linear History
echo "# Graph Lab" > README.md
git add README.md
git commit -m "C1: Initial commit"
echo "line 2" >> README.md
git add README.md
git commit -m "C2: Add line 2"
echo "line 3" >> README.md
git add README.md
git commit -m "C3: Add line 3"Checkpoint:
git log --oneline --graph --all
# * abc1234 C3: Add line 3
# * def5678 C2: Add line 2
# * 9ab0123 C1: Initial commitThree commits in a straight line. main points to C3.
Step 2: Inspect the Pointers
cat .git/HEAD
# ref: refs/heads/main
cat .git/refs/heads/main
# (hash of C3)
git rev-parse HEAD
# (same hash)HEAD → main → C3. Verify they all resolve to the same hash.
Step 3: Create a Branch and Observe
git branch featureCheckpoint:
cat .git/refs/heads/feature
cat .git/refs/heads/mainBoth files contain the same hash — both branches point to C3. No commits were copied, no directories duplicated. Just a new 41-byte file.
git log --oneline --graph --all
# * abc1234 (HEAD -> main, feature) C3: Add line 3
# * def5678 C2: Add line 2
# * 9ab0123 C1: Initial commitNotice (HEAD -> main, feature) — both branches and HEAD are at C3.
Step 4: Switch and Commit on the Feature Branch
git switch featureCheckpoint:
cat .git/HEAD
# ref: refs/heads/feature ← HEAD now points to 'feature', not 'main'Now make two commits:
echo "feature work 1" > feature.txt
git add feature.txt
git commit -m "C4: Feature work 1"
echo "feature work 2" >> feature.txt
git add feature.txt
git commit -m "C5: Feature work 2"Checkpoint:
git log --oneline --graph --all
# * 111aaa (HEAD -> feature) C5: Feature work 2
# * 222bbb C4: Feature work 1
# * abc1234 (main) C3: Add line 3
# * def5678 C2: Add line 2
# * 9ab0123 C1: Initial commitfeature moved forward to C5. main stayed at C3. HEAD is on feature.
Step 5: Switch Back to Main and Diverge
git switch mainNotice the working directory changes — feature.txt disappears because it doesn't exist in C3.
ls
# README.md (no feature.txt!)Make a commit on main:
echo "main work" > main-only.txt
git add main-only.txt
git commit -m "C6: Main-only work"Checkpoint:
git log --oneline --graph --all
# * 333ccc (HEAD -> main) C6: Main-only work
# | * 111aaa (feature) C5: Feature work 2
# | * 222bbb C4: Feature work 1
# |/
# * abc1234 C3: Add line 3
# * def5678 C2: Add line 2
# * 9ab0123 C1: Initial commitThe branches have diverged. The graph shows two paths from C3.
Step 6: Merge and Observe a Two-Parent Commit
git merge feature -m "C7: Merge feature into main"Checkpoint:
git log --oneline --graph --all
# * 444ddd (HEAD -> main) C7: Merge feature into main
# |\
# | * 111aaa (feature) C5: Feature work 2
# | * 222bbb C4: Feature work 1
# * | 333ccc C6: Main-only work
# |/
# * abc1234 C3: Add line 3
# * def5678 C2: Add line 2
# * 9ab0123 C1: Initial commitInspect the merge commit:
git cat-file -p HEADYou should see two parent lines — this is what makes it a merge commit.
ls
# README.md feature.txt main-only.txt ← all files from both branchesStep 7: Create and Inspect Tags
git tag v1.0.0 # lightweight tag at HEAD
git tag -a v0.1.0 9ab0123 -m "Alpha" # annotated tag at C1 (use your C1 hash)Checkpoint:
cat .git/refs/tags/v1.0.0
# (hash of C7 — the merge commit)
git cat-file -t v0.1.0
# tag (it's an object, not just a pointer)
git cat-file -p v0.1.0
# object 9ab0123...
# type commit
# tag v0.1.0
# tagger ...
#
# AlphaStep 8: Experience Detached HEAD
git checkout def5678 # use your C2 hashGit warns you about detached HEAD.
cat .git/HEAD
# def5678... ← raw hash, not a branch ref
git log --oneline --graph --all
# Look for (HEAD) — it's floating, not attached to a branch nameMake a commit in detached state:
echo "detached experiment" > experiment.txt
git add experiment.txt
git commit -m "Detached commit"Now switch back:
git switch mainGit warns that you're leaving a detached commit behind. Note the hash it mentions.
Checkpoint:
git log --oneline --graph --all
# The detached commit is NOT visible — no branch points to itRecover it:
git reflog
# Find the "Detached commit" entry and note its hash
git switch -c recovered-experiment <hash>
git log --oneline --graph --all
# Now it's visible again — the branch 'recovered-experiment' points to itStep 9: Delete a Branch and Understand Reachability
git switch main
git branch -d featureCheckpoint:
git log --oneline --graph --allThe commits C4 and C5 are still there — they're reachable from the merge commit C7 (which is on main). Deleting the feature branch only removed the pointer. The commits are safe because another path leads to them.
Now try:
git branch -d recovered-experimentIf the branch had unmerged commits, Git refuses with -d. You'd need -D to force it. The commits would then become unreachable (visible only in reflog until garbage collection).
Challenge
-
Create three branches from the same commit. Make one commit on each. Visualize the graph with
git log --oneline --graph --all. Observe that three branches diverge from the same point. -
Use
git merge-base main <branch>to find the common ancestor of two branches. Verify withgit cat-file -p. -
Create a situation where deleting a branch would lose commits (the only branch pointing to them). Use
git reflogto find them and recover them.
Cleanup
rm -rf ~/git-graph-labCommon Pitfalls & Troubleshooting
| Pitfall | Explanation |
|---|---|
| "I lost commits when I deleted a branch" | You didn't lose them — they're still in the object database. Use git reflog to find the hash, then git switch -c <name> <hash> to recover. Unreachable commits survive for ~2 weeks before garbage collection. |
| Panicking at "detached HEAD" | It's not an error. You're just looking at a specific commit without a branch. If you want to make changes, create a branch first: git switch -c my-branch. |
| Thinking branches copy files | Branches copy nothing. They're 41-byte pointers. All the data lives in the object database, shared by all branches. |
Confusing origin/main with main | origin/main is a remote-tracking branch — a read-only snapshot of where main is on the remote. main is your local branch. They may point to the same or different commits. Use git fetch to update origin/main. |
| "My branch is out of date" | Your remote-tracking branches are stale. Run git fetch to update them, then check git log main..origin/main --oneline to see what's new. |
Not understanding HEAD~ vs HEAD^ | HEAD~n follows the first parent n times (goes n commits back along the main line). HEAD^2 selects the second parent of a merge commit. HEAD~2 = grandparent. HEAD^2 = second parent of HEAD. |
Pro Tips
-
Alias the graph view. You'll use this constantly:
git config --global alias.graph "log --oneline --graph --all --decorate"Now just type
git graph. -
Understand reachability. A commit is "safe" if at least one branch, tag, or the reflog points to it (directly or through ancestors).
git gconly deletes unreachable objects. When you delete a branch, ask yourself: "Is this commit reachable from any other pointer?" -
Use
git switchovergit checkout. Thecheckoutcommand is overloaded — it switches branches, restores files, and detaches HEAD. Git 2.23 introducedgit switch(for branches) andgit restore(for files) to reduce confusion. -
HEAD~vsHEAD^cheat sheet:HEAD~1 = HEAD^ = parent (first parent) HEAD~2 = HEAD^^ = grandparent (first parent of first parent) HEAD^2 = second parent (of a merge commit) HEAD~2^2 = second parent of the grandparent -
Remote-tracking branches update on fetch, not on pull.
git pull=git fetch+git merge(or rebase). If you just want to see what's new without changing your working directory, usegit fetchfirst, then inspect withgit log. -
Branches are your undo mechanism. Before doing anything risky (rebase, reset, merge), create a temporary branch pointing to your current position:
git branch backup-before-rebase. If things go wrong, you cangit switch backup-before-rebaseand you're back to exactly where you were.
Quiz / Self-Assessment
1. What data structure is Git's commit history?
Answer
2. What is a branch in Git, physically?
Answer
.git/refs/heads/ that contains the 40-character SHA-1 hash of a commit. That's it — a 41-byte text file (40 chars + newline).
3. What is HEAD?
Answer
.git/HEAD that indicates your current position. Normally it contains a reference to a branch (e.g., ref: refs/heads/main). When detached, it contains a raw commit hash.
4. What happens to the branch pointer when you run git commit?
Answer
5. What is "detached HEAD" state?
Answer
6. What's the difference between git branch feature and git switch -c feature?
Answer
git branch feature creates the branch but doesn't switch to it — HEAD stays where it is. git switch -c feature creates the branch AND moves HEAD to it.
7. How does Git know that a commit is a merge commit?
Answer
8. What is a remote-tracking branch?
Answer
refs/remotes/<remote>/<branch> that represents Git's last known state of a branch on the remote server. It's updated by git fetch. You can't commit to it directly.
9. What's the difference between a lightweight tag and an annotated tag?
Answer
10. After deleting a branch, are the commits it pointed to deleted?
Answer
gc.pruneExpire) before garbage collection removes them. Use git reflog to find and recover unreachable commits.