We closed module 2 with a promise: we told you that you already knew what a branch was, even if you did not quite realise it. "A file with a hash inside", we said. This lesson keeps that promise down to the last byte.

Branches are, without exaggeration, the reason Git won. Other version control systems had branches before Git, but they were expensive, slow and frightening. In Git they are so cheap that creating one literally means writing 41 bytes to disk. That change in cost is not a technical footnote: it is what lets a team like Ana, Bruno and Carla's open a branch for every idea, every fix and every experiment without thinking twice.

To use them with confidence, though, you need to understand what they really are, not the tree metaphor that turns up in every tutorial. In this lesson we lift the bonnet: we will look at the file, at the hash, at what HEAD is and why it points to a branch rather than to a commit, and we will pin down what it means for two branches to "diverge". We are not creating any branches yet — that is the next lesson. Here the job is to understand the machinery.

Contents

  1. The problem branches solve
  2. What a branch really is: 41 bytes
  3. HEAD: where you are right now
  4. Why HEAD points to a branch and not to a commit
  5. How the pointer advances when you commit
  6. Why Git branches are cheap (and SVN's were not)
  7. The commit graph: the real structure
  8. Divergence and the common ancestor
  9. The branches you already have without knowing it

  1. The problem branches solve

Remember the dead end we left the team in. Ana and Bruno were both working on main, each on their own laptop. Ana was building the task counter; Bruno, the pending filter. Neither could see the other's work, and when they came to put it together they would find two versions of app.js that had evolved separately.

Without branches, a developer has exactly three bad options:

Option What it means Why it is bad
Commit everything on the single line Every commit lands straight in the main project The project sits half broken for as long as the feature is unfinished
Do not commit until it is finished Days of work with no save point at all You throw away everything Git is for: no history, no way back
Copy the whole folder task-manager-v2, task-manager-test This is the manual versioning from lesson 01-01, with all its problems

A branch solves all three at once: you can commit often on an isolated line of work that bothers nobody, and then integrate it into the main project once it is ready.

The question is how Git makes that cheap. The answer lies in the data model you have already studied.

  1. What a branch really is: 41 bytes

Let us go to Ana's repository and look inside .git/. Remember from the data model that the entire repository database lives in there.

cd ~/projects/task-manager
ls .git/refs/heads/
main

A single file, called main. Let us see what is in it:

cat .git/refs/heads/main
c5d9b1e7a3f2d8b4e6c1a9f5d3b7e2c8a4f6d1b9

That is all. A 40-character SHA-1 hash and a trailing newline. Let us check it with an accountant's precision:

wc -c .git/refs/heads/main
41 .git/refs/heads/main

Forty-one bytes. Forty hexadecimal characters plus the final \n. That is a branch in Git: a plain-text file whose contents are the hash of a commit.

No metadata, no list of commits, no copy of the files, nothing else. The main branch does not "contain" the project's four commits: it points at the last one, and the rest are reached by following the parent links stored inside each commit.

The clean way to query it

Reading files out of .git/ by hand is excellent for understanding, but for day-to-day work Git has a command that does the same job reliably (and that also works when the reference has been "packed", something we will come to in a moment):

git rev-parse main
c5d9b1e7a3f2d8b4e6c1a9f5d3b7e2c8a4f6d1b9

The same hash. git rev-parse is the command that turns any way of naming a commit into its full hash. You already used it in module 1 as git rev-parse HEAD; it behaves identically with branch names, with HEAD~2, with tags and with everything else we covered in lesson 02-06.

And if you want to confirm that the hash really is a commit and not something else:

git cat-file -t c5d9b1e
commit
git cat-file -p c5d9b1e
tree 8c4f2a1e9b7d3f5a6c8e0b2d4f6a8c0e2b4d6f8a
parent 4e7f2a9c8b1d5e3f7a2c9d4b6e8f1a3c5d7b9e2f
author Ana Ferrer <ana.ferrer@example.com> 1753876800 +0200
committer Ana Ferrer <ana.ferrer@example.com> 1753876800 +0200

Document installation in the README

Nothing new: it is the commit object you already know how to read. The conclusion is what matters.

A branch is a movable pointer to a commit. Not a folder, not a copy, not a container. A human-readable name that stores a hash.

The case of packed references

A word of warning so you are not alarmed if the file goes missing one day. To avoid keeping thousands of tiny files in large repositories, Git gathers the references that rarely change into a single file:

cat .git/packed-refs
# pack-refs with: peeled fully-peeled sorted
c5d9b1e7a3f2d8b4e6c1a9f5d3b7e2c8a4f6d1b9 refs/heads/main

If git gc has run (Git does it by itself from time to time), .git/refs/heads/main may have vanished and its information may live here instead. The meaning is identical: branch name → hash. That is why git rev-parse is more reliable than cat: it looks in both places.

  1. HEAD: where you are right now

If a branch is a pointer to a commit, something has to record which branch you are working on. That something is HEAD, and it is a file too:

cat .git/HEAD
ref: refs/heads/main

Look closely, because the whole lesson hinges on this: HEAD does not contain a hash. It contains the string ref: refs/heads/main, which is a symbolic reference: it points to another reference, not to an object.

Here is the full chain of indirection:

graph LR
    HEAD[".git/HEAD<br/>ref: refs/heads/main"] --> BRANCH[".git/refs/heads/main<br/>c5d9b1e…"]
    BRANCH --> COMMIT["commit object c5d9b1e<br/>tree + parent + author + message"]
    COMMIT --> TREE["tree object<br/>index.html, styles.css,<br/>app.js, README.md"]

Three hops: HEAD → branch → commit → file tree. When you run git status and read On branch main, Git is literally reading the first line of that file.

There is a command for this too, so you need not open any files:

git symbolic-ref HEAD
refs/heads/main

And if all you want is the short branch name, which is what scripts and terminal prompts use:

git branch --show-current
main

HEAD as a way of naming a commit

You have already used HEAD many times — git diff HEAD, HEAD~1, git show HEAD — as though it were the current commit. And it works, because Git resolves the whole chain for you:

git rev-parse HEAD
c5d9b1e7a3f2d8b4e6c1a9f5d3b7e2c8a4f6d1b9

The same hash as git rev-parse main, because HEAD points to main and main points to that commit. They are different things, though:

Expression What it returns What it is on disk
git rev-parse HEAD The hash of the current commit (resolving the whole chain) .git/HEAD → holds a symbolic reference
git symbolic-ref HEAD The name of the current branch The literal contents of .git/HEAD
git rev-parse main The hash the main branch points to .git/refs/heads/main

  1. Why HEAD points to a branch and not to a commit

This is the question that separates people who "use" Git from people who understand it. Why the indirection? Why does HEAD not simply store the hash of the current commit, which would be simpler?

Because HEAD has to know which branch to advance when you commit.

Imagine HEAD stored c5d9b1e directly. Ana makes a new commit. Git creates the commit object… and then what? Which branch should it update? main? Some other branch that also pointed there? It would have no way of telling.

By storing ref: refs/heads/main, Git knows exactly two things:

  1. The parent commit of the new commit is whatever main points to (after resolving the chain).
  2. The branch to update once the commit exists is main.

That indirection is what turns a branch into a movable pointer instead of a plain label. It is also the reason a special state called detached HEAD exists, in which HEAD does contain a hash directly and so there is no branch to advance. We will look at it in detail in the next lesson.

  1. How the pointer advances when you commit

Let us walk through the exact mechanics. Ana is on main, with the repository as it stood at the end of module 2:

git log --oneline
c5d9b1e (HEAD -> main) Document installation in the README
4e7f2a9 Add task deletion to the list
8b6d3c2 Add base styles for the list
1a4c8d6 Add initial task manager structure

That (HEAD -> main) you have seen so many times is no longer decoration: it is Git drawing you the chain of indirection. It reads literally as "HEAD points to main, and main is here".

State on disk before committing:

.git/HEAD                → ref: refs/heads/main
.git/refs/heads/main     → c5d9b1e…

Now Ana commits any old change:

git commit -m "Fix the documentation link in the README"
[main a3f5c9e] Fix the documentation link in the README
 1 file changed, 1 insertion(+), 1 deletion(-)

Git has done the following, in this order:

  1. Written the blob objects for the modified files and the corresponding tree objects.
  2. Created a commit object whose parent field is c5d9b1e (the commit main pointed to, obtained by resolving HEAD).
  3. Overwritten the file .git/refs/heads/main with the new hash.

State on disk afterwards:

.git/HEAD                → ref: refs/heads/main      ← UNCHANGED
.git/refs/heads/main     → a3f5c9e…                  ← UPDATED

HEAD has not been touched. It still says exactly the same thing. What changed is the branch file. See for yourself:

cat .git/refs/heads/main
a3f5c9e2b1d47f8e0c6a2b9d3e5f7a1c4b6d8e0f

This is Git's fundamental operation and it never varies: create a commit and move 41 bytes. Whether there is one branch or fifty makes no difference.

This is also the moment to recall something from module 1: commits are immutable, branches are not. Once created, a commit never changes — changing it would produce a different hash and therefore a different commit. Branches, by contrast, are pointers that move all the time. That asymmetry underpins everything in this module and in module 5.

  1. Why Git branches are cheap (and SVN's were not)

Now that you know a branch is 41 bytes, the comparison with earlier systems explains itself.

In Subversion (SVN), the centralised system that dominated before Git and that we already compared in lesson 01-01, there was no concept of a branch as such. A branch was a copy of a directory inside the repository itself, by convention in a folder called branches/:

repository/
├── trunk/                    ← the main line
├── branches/
│   ├── task-counter/         ← a complete copy of the project
│   └── pending-filter/       ← another complete copy
└── tags/

Creating a branch meant running svn copy and waiting. SVN did implement lazy copies and did not physically duplicate every byte, true, but the operation still required talking to the server, was still slow on large projects and, above all, still carried an enormous conceptual cost: merging a branch back into the trunk was a delicate, error-prone operation that many teams actively avoided.

The detailed comparison:

Aspect Subversion Git
What a branch is A copy of a directory in the repository A 41-byte file holding a hash
Cost of creating one A server-side operation, seconds or minutes Writing 41 bytes locally, milliseconds
Does it need the network? Yes No
Switching branches Updating the working copy from the server Rewriting the working tree files, locally
Merging Historically fragile; before 1.5 you tracked it by hand A first-class operation, with automatic ancestry tracking
Typical team practice Few branches, long-lived, feared Many branches, short-lived, routine

Time it yourself when you create your first branch in the next lesson: the operation is instant because nothing is copied. Every commit, every tree and every blob is already in the object database, shared by all branches. The only new thing is the name.

That change in cost had an enormous cultural consequence. When a branch costs nothing, you stop asking whether it is worth it: you open one to try a ten-minute idea, and if it does not work you delete it. That is the mindset this module aims to install in you.

  1. The commit graph: the real structure

So far we have seen history as a list, because there was only one branch. With several branches, history becomes what it always really was: a directed acyclic graph, or DAG.

Let us take that name apart, because it sounds worse than it is:

  • Graph: nodes (the commits) joined by edges (the parent links).
  • Directed: the edges have a direction, and the direction is backwards. Every commit knows its parent; no commit knows its children. That is why git log walks history into the past and never into the future.
  • Acyclic: there are no cycles. A commit cannot possibly be its own ancestor, because its hash depends on its parent's hash; a cycle would require knowing a hash before computing it.

Here is Ana's repository right now, with a single line:

gitGraph
   commit id: "1a4c8d6"
   commit id: "8b6d3c2"
   commit id: "4e7f2a9"
   commit id: "c5d9b1e"

And here is how it will look two lessons from now, when Ana's and Bruno's work live side by side in the same repository on different branches:

gitGraph
   commit id: "1a4c8d6"
   commit id: "8b6d3c2"
   commit id: "4e7f2a9"
   commit id: "c5d9b1e"
   branch task-counter
   checkout task-counter
   commit id: "6f2b9d4"
   commit id: "9d1e4b7"
   checkout main
   branch pending-filter
   checkout pending-filter
   commit id: "3d5b8e1"
   commit id: "7c1f4a9"
   commit id: "b2e6d3f"

Notice one important detail in the diagram: the three starting points (main, task-counter, pending-filter) share the first four commits. There are not three copies of 1a4c8d6: there is a single commit object in .git/objects/ reachable from three different names. That is why branches take up no space.

In the terminal you view that same graph with the command you already know from module 2, which from now on will be your main tool:

git log --oneline --graph --all
* b2e6d3f (pending-filter) Restore field focus after adding a task
* 7c1f4a9 Apply style to completed tasks
* 3d5b8e1 Add a pending tasks filter
| * 9d1e4b7 (task-counter) Mark tasks as done on click
| * 6f2b9d4 Add the pending task counter
|/
* c5d9b1e (HEAD -> main) Document installation in the README
* 4e7f2a9 Add task deletion to the list
* 8b6d3c2 Add base styles for the list
* 1a4c8d6 Add initial task manager structure

Pay attention to --all. Without it, git log shows only what is reachable from HEAD, that is, only the four commits on main: the other branches' work would be invisible. With --all, Git starts from every reference. It is such a common slip that it is worth memorising now.

And look at the |/ line: that is where the two lines of work meet going backwards. That point has a name of its own, and it is the star of the next section.

  1. Divergence and the common ancestor

Two branches diverge when each has commits the other does not. Put precisely: when neither is reachable from the other by following parent links.

It is worth distinguishing three different situations, because they determine how the merge in lesson 03-03 will behave:

Situation Description Example
Branch behind Every commit on A is on B, but B has more main sits at c5d9b1e; task-counter has two commits on top
Branch ahead Every commit on B is on A, but A has more The same thing seen from the other side
Diverged branches Each has commits of its own since they parted ways task-counter and pending-filter relative to each other

In the graph above, task-counter has two commits pending-filter does not, and pending-filter has three that task-counter does not. They have diverged.

The common ancestor

When two branches diverge, the point where they parted ways is called the common ancestor (the merge base, in Git's own terminology). It is the most recent commit reachable from both branches.

In our graph, the common ancestor of task-counter and pending-filter is c5d9b1e: it is the last commit both of them have in their history.

graph RL
    A["1a4c8d6"]
    B["8b6d3c2"] --> A
    C["4e7f2a9"] --> B
    D["c5d9b1e<br/><b>common ancestor</b>"] --> C
    E["6f2b9d4"] --> D
    F["9d1e4b7<br/>task-counter"] --> E
    G["3d5b8e1"] --> D
    H["7c1f4a9"] --> G
    I["b2e6d3f<br/>pending-filter"] --> H

(The arrows point into the past, as they do in reality: every commit refers to its parent.)

Why does this concept matter so much? Because it is the piece that makes merging possible. To bring two diverged branches together, Git needs to know, for every line of every file, who changed it: if only one branch changed it, that version wins; if both changed it differently, there is a conflict. And to know "who changed it" you need a neutral point of reference: the common ancestor.

With three versions of every file — the ancestor's, one branch's and the other's — Git can reason. With only two it could not tell a change from a deletion. Hence the name three-way merge, which we will meet in lesson 03-03.

Let us preview the logic with a concrete example on one line of README.md:

Version Contents of the line Git's conclusion
Ancestor c5d9b1e # Task Manager The starting point
Ana's branch # Task Manager She did not touch it
Bruno's branch # Team Task Manager He changed it

The result: Bruno's version wins, with no conflict and no questions asked. Git knows Ana did not touch that line because it matches the ancestor. If both versions differed from the ancestor and from each other, we would have a conflict and would need a human being: that is lesson 03-05.

  1. The branches you already have without knowing it

Let us finish by tying up two loose ends you have been carrying since module 1.

First: there is nothing special about main. It is a branch exactly like any other, with the same 41-byte file in the same directory. Git gives it no preferential treatment; its importance is purely a team convention. In fact, you chose its name yourself in lesson 01-06, by setting init.defaultBranch = main (which is why Ana's project is not called master).

Second: the initial branch exists before it has any content. When Ana ran git init in lesson 02-01, git status said On branch main even though .git/refs/heads/ was empty. Now you know exactly why:

# In a freshly initialised repository, before the first commit:
cat .git/HEAD
ref: refs/heads/main
ls .git/refs/heads/
(empty)

HEAD points to a branch that does not yet exist as a file. Git calls this an unborn branch. The file .git/refs/heads/main is created at the moment of the first commit, and that is what makes that commit special: it has no parent, and git commit announces it with (root-commit).

It is an elegant consequence of the design: because HEAD stores a name rather than a hash, it can happily point at something that does not exist yet.

Common Mistakes and Tips

Mistake 1: believing that a branch "contains" commits. This is the most common confusion and the source of nearly every later misunderstanding. A branch is a pointer to one commit; the rest of the history follows from the parent links. The practical consequence: the same commit can sit on ten branches at once without taking up more space, and deleting a branch deletes no commits (only the pointer, as we will see in lesson 03-06).

Mistake 2: believing that deleting a branch deletes the work. This follows from the previous point. Deleting .git/refs/heads/experiment removes 41 bytes. The commits stay in .git/objects/, and if no reference reaches them any more they will eventually be removed by garbage collection, but not immediately and not silently. There is a safety net.

Mistake 3: forgetting --all in git log --graph. Without --all, Git shows you only what is reachable from HEAD and gives you the false impression that the other branches are empty. Get into the habit of typing git log --oneline --graph --all as a single unit; in lesson 06-04 we will turn it into an alias.

Mistake 4: confusing HEAD with the current branch. HEAD is the file that records which branch is current. In 99% of cases you can use them interchangeably, but once you enter detached HEAD (next lesson) the difference becomes critical: there you have a HEAD but no branch.

Tip 1: mind the capitals in HEAD. On Linux and macOS, head in lower case is not the same as HEAD and Git will give you an unknown-revision error. On Windows, and on macOS with a case-insensitive file system, it may work by accident, which makes the failure all the more treacherous when the script reaches another machine. Always write it in capitals.

Tip 2: do not edit the files in .git/refs/ by hand. We have read them to understand the machinery, and reading them is perfectly safe. Writing them is not: Git has locks, change logs (the reflog, which we will see in module 9) and validations that you would be bypassing. To move a branch by hand there is git update-ref.

Tip 3: put a branch indicator in your terminal. Knowing at all times which branch you are on prevents half the frights in this module. Many modern shell setups include one; if yours does not, git branch --show-current is the usual command for building it.

Exercises

Exercise 1: the autopsy of a branch

Without using git log or git branch, and using only "plumbing" commands and file reading, work out in your own repository:

  1. Which branch you are on.
  2. Which commit that branch points to.
  3. What that commit's message is.
  4. What the hash of its parent commit is.

Exercise 2: verifying that HEAD does not move

Demonstrate empirically what section 5 claims: that committing changes the branch file but not .git/HEAD. Design the sequence of commands that proves it and explain what you expect to see at each step.

Exercise 3: reasoning about the graph

Given this history, answer without running anything:

* d4e5f6a (branch-b) Adjust the footer
* c3f8a21 Add the footer
| * b1a2c3d (HEAD -> branch-a) Fix the heading
| * a9f8e7d Add the header
|/
* 8f1a3b7 (main) Initial structure
  1. What is the common ancestor of branch-a and branch-b?
  2. How many commits does branch-a have that branch-b does not?
  3. Would c3f8a21 appear in the output of git log --oneline without --all? Why?
  4. Have main and branch-a diverged?
  5. How much disk space do the three branch files take up in total?

Solutions

Solution 1:

# 1. Which branch you are on: read HEAD directly
cat .git/HEAD
# → ref: refs/heads/main

HEAD holds a symbolic reference, and the last part of the path is the branch name: main.

# 2. Which commit that branch points to
cat .git/refs/heads/main
# → c5d9b1e7a3f2d8b4e6c1a9f5d3b7e2c8a4f6d1b9

If the file did not exist because the references are packed, the reliable alternative is git rev-parse main, or looking for the matching line in .git/packed-refs.

# 3 and 4. Message and parent: read the commit object
git cat-file -p c5d9b1e
tree 8c4f2a1e9b7d3f5a6c8e0b2d4f6a8c0e2b4d6f8a
parent 4e7f2a9c8b1d5e3f7a2c9d4b6e8f1a3c5d7b9e2f
author Ana Ferrer <ana.ferrer@example.com> 1753876800 +0200
committer Ana Ferrer <ana.ferrer@example.com> 1753876800 +0200

Document installation in the README

The message is Document installation in the README and the parent is 4e7f2a9…. Notice that you have walked by hand exactly the same path git log walks: HEAD → branch → commit → parent.

Solution 2:

# Initial state: note down the two values
cat .git/HEAD
cat .git/refs/heads/main
ref: refs/heads/main
c5d9b1e7a3f2d8b4e6c1a9f5d3b7e2c8a4f6d1b9
# Make any change and commit it
echo "" >> README.md
git commit -am "Add a blank line to the README"
[main a3f5c9e] Add a blank line to the README
 1 file changed, 1 insertion(+)
# Look at those same two files again
cat .git/HEAD
cat .git/refs/heads/main
ref: refs/heads/main
a3f5c9e2b1d47f8e0c6a2b9d3e5f7a1c4b6d8e0f

.git/HEAD is identical; .git/refs/heads/main has changed. Committing does not move HEAD: it moves the branch HEAD points to. That is precisely why HEAD stores a name and not a hash.

(If you want to undo the test commit, git reset --hard HEAD~1 puts everything back as it was; we will cover it thoroughly in lesson 09-02.)

Solution 3:

  1. The common ancestor is 8f1a3b7. It is the most recent commit reachable from both branches: branch-a reaches it via a9f8e7d, and branch-b via c3f8a21.

  2. Two commits: a9f8e7d and b1a2c3d. They are the ones on branch-a's line above the point where the branches parted.

  3. It would not appear. Without --all, git log starts from HEAD, which points to branch-a. From b1a2c3d you reach a9f8e7d and from there 8f1a3b7, but never c3f8a21: the arrows run into the past and there is no path from branch-a to branch-b's line.

  4. They have not diverged. main sits at 8f1a3b7, which is reachable from branch-a. main is simply behind: it has no commits of its own that branch-a lacks. This situation is exactly the one that will allow a fast-forward merge in lesson 03-03.

  5. 123 bytes: three files of 41 bytes each (main, branch-a and branch-b). The six commits, their trees and their blobs are stored once in .git/objects/ and shared by all three branches. That is the entire "cost" of having three lines of work open.

Conclusion

We have lifted the bonnet, and inside there was no magic — just a very simple design taken to its logical conclusion:

  • A branch is a 41-byte file in .git/refs/heads/ (or a line in .git/packed-refs) containing the hash of a commit. Nothing more. Query it reliably with git rev-parse <branch>.
  • HEAD is another file, normally containing a symbolic reference of the form ref: refs/heads/main. It points to a branch, not to a commit, and that indirection is deliberate: it is how Git knows which pointer to move when you commit.
  • Committing moves the branch, not HEAD. The commit object is created with the current commit as its parent, and the branch file is overwritten with the new hash. Commits are immutable; branches are movable.
  • Branches are cheap because they copy nothing. Unlike Subversion's svn copy, which needed a server and time, here the whole history lives in a shared object database and a branch only adds a name. That change in cost is cultural, not merely technical.
  • History is a directed acyclic graph: commits point backwards, to their parents. git log --oneline --graph --all is how you see it; do not forget the --all.
  • Two branches diverge when each has commits of its own since the point they parted. That point is the common ancestor, and it is the piece that makes merging possible: with three versions of a file — ancestor, branch A and branch B — Git can work out who changed what.

What comes next

You now know what a branch is. Time to create them and move between them, which is where the practical questions appear: what is the difference between git branch and git switch -c? Why does git switch exist if git checkout already did? What happens to my half-finished changes if I switch branches? And what on earth does that HEAD detached at 4e7f2a9 warning mean, the one that frightens everybody?

In the next lesson, Creating and Switching Branches, Ana will finally open feature/task-counter and Bruno feature/pending-filter, and the repository will stop having a single line of work. Everything you have learned here — the 41-byte file, the HEAD indirection, the pointer advancing — you are about to watch happen live.

Mastering Git: From Beginner to Advanced

Module 1: Introduction to Git

Module 2: Basic Git Operations

Module 3: Branching and Merging

Module 4: Working with Remote Repositories

Module 5: Advanced Git Operations

Module 6: Git Tools and Techniques

Module 7: Collaboration and Workflow Strategies

Module 8: Git Best Practices and Tips

Module 9: Troubleshooting and Debugging

Module 10: Git in the Real World

© Copyright 2026. All rights reserved