AnyLearn
All lessons
Programmingadvanced

Refs: A Branch Is a File Containing a Hash

Hashes are unusable by hand, so Git names them. A branch is not a copy of anything, not a directory, and not a container for commits: it is a forty-character file. Once that is clear, checkout, reset, detached HEAD and the reflog stop being separate concepts and become one operation on one kind of file.

Updated · AI-authored, review-gated · how lessons are made

Not signed in: your progress and quiz score won't be saved.
Progress1 / 8

Naming the hashes

The object database is complete but unusable by hand. Nobody types forty hex characters to describe where they are working.

A ref is a name for a hash. That is the entire concept, and it is stored the way it sounds:

$ cat .git/refs/heads/feature
cc8e3e195cca577668c7e26ac6524211c422c40f

A branch is a file whose contents are a commit hash. Forty-one bytes with the newline.

That one fact resolves most branch confusion. Creating a branch writes a file, which is why it is instant regardless of repository size. Deleting one removes a file, which is why the commits survive deletion. And a branch does not contain commits: it points at one, and the rest of history is reached by following parent links.

So the mental picture of a branch as a container, inherited from centralised systems where a branch was a directory copy, is wrong in a way that makes Git seem arbitrary. A branch is a bookmark.

Full lesson text

All 8 steps on one page, for reading, reference, and search.

Show

1. Naming the hashes

The object database is complete but unusable by hand. Nobody types forty hex characters to describe where they are working.

A ref is a name for a hash. That is the entire concept, and it is stored the way it sounds:

$ cat .git/refs/heads/feature
cc8e3e195cca577668c7e26ac6524211c422c40f

A branch is a file whose contents are a commit hash. Forty-one bytes with the newline.

That one fact resolves most branch confusion. Creating a branch writes a file, which is why it is instant regardless of repository size. Deleting one removes a file, which is why the commits survive deletion. And a branch does not contain commits: it points at one, and the rest of history is reached by following parent links.

So the mental picture of a branch as a container, inherited from centralised systems where a branch was a directory copy, is wrong in a way that makes Git seem arbitrary. A branch is a bookmark.

2. HEAD, and the extra level of indirection

HEAD records where you are, and it is usually a ref to a ref rather than to a commit.

$ cat .git/HEAD
ref: refs/heads/feature

That indirection is what makes committing work. When you commit, Git creates the commit object, then updates whatever HEAD points at to the new hash. Because HEAD names the branch rather than a commit, the branch advances automatically.

Switching branches rewrites this one file and updates the working directory to match the new commit's tree.

HEAD can also hold a hash directly, which is detached HEAD:

$ git checkout cc8e3e1
$ cat .git/HEAD
cc8e3e195cca577668c7e26ac6524211c422c40f

Now committing still advances HEAD, and no branch advances with it, because HEAD is not pointing at a branch. The commits exist and nothing names them.

That is the whole of the detached HEAD warning. It is not a broken state; it is a state where new commits have no name, so switching away leaves nothing pointing at them. The fix is to create a branch, which writes a file containing that hash.

3. The shape history actually has

Commits point at parents, so history is a directed acyclic graph: directed because parent links have a direction, acyclic because a commit's hash depends on its parents and a cycle would require a hash to contain itself.

Branching is what happens when two commits share a parent.

$ git log --all --oneline --graph
* cc8e3e1 feat-change
| * c1447f0 main-change
|/
* a9cb607 base

Both feat-change and main-change have base as their parent. Neither knows about the other. The branch names are two files, each holding one of the two tip hashes.

Nothing recorded the act of branching. There is no branch-point object and no metadata saying a divergence occurred; the shape is simply what the parent pointers describe.

That is why the same command sequence produces the same graph regardless of which branch names existed, and why deleting a branch name does not alter the graph's shape. Names are a view of the graph, not part of it.

The reverse also holds: a commit reachable from no ref is invisible to ordinary commands, even though the object is still there.

4. Everything is a pointer move

Reading the list this way collapses several commands people learn as unrelated.

git reset --hard <hash> looks destructive and is a pointer move: it writes a different hash into the current branch's file and updates the working directory. The commits that were there are untouched. Nothing was deleted; a name stopped pointing at them.

That reframing is the practical payoff of the whole lesson. Operations that appear to destroy history are almost always moving a ref, and the objects remain in the database until garbage collection removes what is unreachable, which does not happen immediately.

The bottom box is the invariant worth holding: Git adds objects and moves pointers. It does not modify objects, because it cannot: an object's name is its content's hash, so a modified object is a different object.

Which means the question after an accident is never whether the data survived. It is which name to point at it.

flowchart TD
A["An operation on history"] --> B["Commit: create object, advance current ref"]
A --> C["Branch: write a new file with a hash"]
A --> D["Checkout: change HEAD, update working tree"]
A --> E["Reset: point the current ref elsewhere"]
A --> F["Merge: create a commit with two parents"]
B --> G["Objects are only ever added"]
E --> G
F --> G

5. The reflog remembers every move

If refs move and old values are forgotten, a mistaken reset would be unrecoverable. It is not, because Git records every movement.

The reflog is a per-ref log of the hashes that ref has held, with a timestamp and the command responsible.

$ git reflog
cc8e3e1 HEAD@{0}: reset: moving to cc8e3e1
c1447f0 HEAD@{1}: commit: main-change
a9cb607 HEAD@{2}: checkout: moving from feature to main

Recovering from almost any accident is reading this and pointing a ref back:

$ git reset --hard HEAD@{1}

Three properties matter. It is local, recording what your repository did rather than what happened upstream. It is not pushed, so it does not travel. And it expires, with unreachable entries pruned after a default period measured in weeks.

So the real statement is narrower than "nothing is ever lost in Git": nothing is lost while the reflog still holds it and garbage collection has not run. That is usually weeks, which covers essentially every accident, and it is not forever.

The practical instruction is simply to look. git reflog is the first command after any operation that seems to have destroyed work.

6. Remotes are refs too

Distribution reuses the same mechanism, which is why it has so few new concepts.

A remote-tracking ref such as origin/main is another file holding a hash, recording where that branch was on the remote the last time you communicated. It is a cache, not a live view.

.git/refs/heads/main            your branch
.git/refs/remotes/origin/main   where origin's main was, last you checked

That explains behaviour people find confusing.

git fetch updates remote-tracking refs and downloads objects. It changes nothing about your branches, which is why it is always safe.

git pull is fetch followed by a merge or rebase into your branch, which is why it can produce conflicts while fetch cannot.

"Your branch is ahead by 3 commits" is a comparison between two local files, computed without any network access, which is why it is instant and why it can be stale.

git push asks the remote to move its ref to your hash, and the remote refuses if that would lose commits, which is what a rejected non-fast-forward push is.

Every one of these is the same object model with an extra namespace of refs.

7. Reachability decides what survives

Since names are just files, a natural question follows: what happens to commits nothing points at?

They persist, and then eventually they do not. Git periodically runs garbage collection, which computes reachability from all refs plus the reflog, and deletes objects nothing can reach.

That is the same reachability analysis a tracing garbage collector performs, applied to a repository instead of a heap.

Two practical consequences.

A repository does not shrink by deleting a branch. The commits become unreachable, and the space returns only after collection, and only if the reflog has also expired.

Rewriting history does not remove a secret. Amending a commit that contained a credential creates a new commit; the old object is still in the database and still fetchable by hash, and anyone who cloned before the rewrite has it permanently. The only correct response is to rotate the credential.

That second point is worth stating plainly because the instinct is to fix the history and consider the matter closed. In a content-addressed store with distributed copies, removal is not something you can do.

8. The vocabulary, resolved

ThingWhat it actually is
BranchA file under refs/heads/ holding one commit hash
TagA file under refs/tags/, or an object for annotated tags
HEADA file naming a branch, or holding a hash when detached
origin/mainA cached hash of where origin's main was, last fetch
Detached HEADHEAD holding a hash directly, so commits get no name
ReflogA per-ref log of every hash that ref has held
Branch deletionRemoving a file; the commits are untouched
reset --hardWriting a different hash into the current branch's file

The second column is the whole lesson. Every row is a file containing a hash, or an operation that writes one.

That is why Git's model is described as simple despite the command line's reputation. There is one data structure, an immutable object graph, and one mutable thing, a set of files naming positions in it.

The outstanding question is what happens when two of those positions have diverged and you want one. Both branches contain work, neither is an ancestor of the other, and something must combine them.

That is a merge, and it is the only operation so far that has to make a decision rather than move a pointer.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. What is a Git branch, physically?
    • A file under refs/heads/ containing a single commit hash
    • A directory containing copies of the branch's commits
    • A list of commits stored in the object database
    • An entry in the index recording which files changed
  2. Why does committing advance your branch automatically?
    • Because the commit object records which branch created it
    • Because HEAD names the branch, and Git updates whatever HEAD points at
    • Because the index tracks the current branch name
    • Because branches are recomputed from the graph after each commit
  3. What does `git reset --hard <hash>` do to the commits it moves away from?
    • Deletes them from the object database immediately
    • Marks them for deletion at the next commit
    • Nothing: it writes a different hash into the branch file, and the objects remain
    • Converts them into unreferenced blobs
  4. What is the accurate version of 'nothing is ever lost in Git'?
    • Nothing is lost once it has been pushed to a remote
    • Nothing is lost as long as a branch points at it
    • Nothing is lost while the reflog still holds it and garbage collection has not run
    • Nothing is lost because every object is replicated on clone
  5. Why does rewriting history fail to remove a leaked credential?
    • Because the reflog is pushed along with the branch
    • Because amending creates a new commit while the old object stays in the database and in every existing clone
    • Because Git refuses to delete objects containing secrets
    • Because packfiles cannot be rewritten

Related lessons

Programming
intermediate

The Object Database: Git Is a Content-Addressed Store

Git is usually taught as a set of commands. Underneath it is a key-value store where the key is a hash of the content, and four object types built on that one idea. This lesson derives the hash by hand, shows what a commit actually contains, and explains why the design makes history tamper-evident.

8 steps·~12 min
Programming
advanced

Undo: Reset, Revert, Restore, and Getting Work Back

Git's undo commands are notoriously confusing because they are usually memorised as recipes. Read against the object model they separate cleanly: each acts on a different one of the three places content lives. This lesson maps them, covers recovery from the accidents that feel unrecoverable, and states what genuinely cannot be undone.

8 steps·~12 min
Programming
advanced

Merge and Rebase: Two Answers to Divergence

Every operation so far moved a pointer. Merging is the first that has to decide something: two branches changed the same project and one result must come out. This lesson covers the three-way merge and the merge base it depends on, what a conflict actually is, and why rebase produces different commits rather than moving them.

8 steps·~12 min
Programming
advanced

The Design Philosophy: What Go Left Out

Go is unusual for what it refuses to include. Errors are values, not exceptions. Interfaces are satisfied without declaring it. Formatting is not a choice. This lesson works through the omissions, the reasoning behind each, and the real costs, because every one of them trades expressiveness for something else.

8 steps·~12 min