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
- The problem branches solve
- What a branch really is: 41 bytes
HEAD: where you are right now- Why
HEADpoints to a branch and not to a commit - How the pointer advances when you commit
- Why Git branches are cheap (and SVN's were not)
- The commit graph: the real structure
- Divergence and the common ancestor
- The branches you already have without knowing it
- 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.
- 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.
A single file, called main. Let us see what is in it:
That is all. A 40-character SHA-1 hash and a trailing newline. Let us check it with an accountant's precision:
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):
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:
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:
# 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.
HEAD: where you are right now
HEAD: where you are right nowIf 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:
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:
And if all you want is the short branch name, which is what scripts and terminal prompts use:
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:
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 |
- Why
HEAD points to a branch and not to a commit
HEAD points to a branch and not to a commitThis 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:
- The parent commit of the new commit is whatever
mainpoints to (after resolving the chain). - 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.
- 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:
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:
Now Ana commits any old change:
[main a3f5c9e] Fix the documentation link in the README 1 file changed, 1 insertion(+), 1 deletion(-)
Git has done the following, in this order:
- Written the blob objects for the modified files and the corresponding tree objects.
- Created a commit object whose
parentfield isc5d9b1e(the commitmainpointed to, obtained by resolvingHEAD). - Overwritten the file
.git/refs/heads/mainwith the new hash.
State on disk afterwards:
HEAD has not been touched. It still says exactly the same thing. What changed is the branch file. See for yourself:
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.
- 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.
- 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
parentlinks). - 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 logwalks 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:
* 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.
- 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.
- 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:
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:
- Which branch you are on.
- Which commit that branch points to.
- What that commit's message is.
- 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
- What is the common ancestor of
branch-aandbranch-b? - How many commits does
branch-ahave thatbranch-bdoes not? - Would
c3f8a21appear in the output ofgit log --onelinewithout--all? Why? - Have
mainandbranch-adiverged? - How much disk space do the three branch files take up in total?
Solutions
Solution 1:
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
# → c5d9b1e7a3f2d8b4e6c1a9f5d3b7e2c8a4f6d1b9If 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.
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:
# Make any change and commit it
echo "" >> README.md
git commit -am "Add a blank line to the README".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:
-
The common ancestor is
8f1a3b7. It is the most recent commit reachable from both branches:branch-areaches it viaa9f8e7d, andbranch-bviac3f8a21. -
Two commits:
a9f8e7dandb1a2c3d. They are the ones onbranch-a's line above the point where the branches parted. -
It would not appear. Without
--all,git logstarts fromHEAD, which points tobranch-a. Fromb1a2c3dyou reacha9f8e7dand from there8f1a3b7, but neverc3f8a21: the arrows run into the past and there is no path frombranch-atobranch-b's line. -
They have not diverged.
mainsits at8f1a3b7, which is reachable frombranch-a.mainis simply behind: it has no commits of its own thatbranch-alacks. This situation is exactly the one that will allow a fast-forward merge in lesson 03-03. -
123 bytes: three files of 41 bytes each (
main,branch-aandbranch-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 withgit rev-parse <branch>. HEADis another file, normally containing a symbolic reference of the formref: 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 --allis 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
- What Is Git?
- Installing Git
- Basic Git Terminology
- The Git Data Model
- Configuring Git
- Initial Configuration
Module 2: Basic Git Operations
- Creating a Repository
- Cloning a Repository
- The Basic Git Workflow
- Staging and Committing Changes
- Inspecting Changes with git diff
- Viewing Commit History
Module 3: Branching and Merging
- Understanding Branches
- Creating and Switching Branches
- Merging Branches
- Merge Strategies
- Resolving Merge Conflicts
- Branch Management
Module 4: Working with Remote Repositories
- Understanding Remote Repositories
- Adding a Remote Repository
- Authenticating with Remote Repositories
- Fetching and Pulling Changes
- Pushing Changes
- Tracking Branches
Module 5: Advanced Git Operations
Module 6: Git Tools and Techniques
- Using Git Hooks
- Git Bisect
- Git Blame
- Git Log and Aliases
- Git Submodules
- Multiple Working Copies with git worktree
Module 7: Collaboration and Workflow Strategies
- Forks and Pull Requests
- Code Reviews with Git
- The Git Flow Workflow
- GitHub Flow
- Trunk Based Development
- Continuous Integration with Git
Module 8: Git Best Practices and Tips
- Writing Good Commit Messages
- Keeping a Clean History
- Ignoring Files with .gitignore
- File Attributes with .gitattributes
- Security Best Practices
- Performance Tips
Module 9: Troubleshooting and Debugging
- Common Git Problems
- Undoing Changes
- Resolving Divergence with the Remote
- Recovering Lost Commits
- Dealing with Corrupted Repositories
- Advanced Debugging Techniques
