Refs, HEAD, and Branches
An engineer runs
git checkout a1b2c3d4to look at a commit from last week. Git prints a scary-looking warning:You are in 'detached HEAD' state. You can look around, make experimental commits.... They panic, quicklygit checkout main, and tell their team to never do that. They have spent three years avoiding one of Git's most useful features because of a wall of yellow text. Meanwhile, a senior engineer drops into detached HEAD a dozen times a week to inspect old code, test a commit in isolation, or branch off from a specific point — without fanfare, because they understand that detached HEAD is just HEAD pointing at a commit SHA directly instead of at a branch name.Branches, HEAD, tags, and remote tracking references are all refs — and a ref is nothing but a file in
.git/refs/containing a SHA-1. That is the whole concept. A branch is a pointer; HEAD is a pointer to a pointer; a tag is a pointer that does not move. Once you can open.git/refs/heads/mainwithcatand see a 40-character hex string, the mystique of branches dissolves.
A Branch Is a File
Create a fresh repo and inspect:
mkdir /tmp/reftest && cd /tmp/reftest
git init
echo "hi" > a.txt
git add a.txt && git commit -m "first"
ls .git/refs/heads/
# main
cat .git/refs/heads/main
# a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
# ↑ that is literally the entire file contents: a 40-char SHA
That is a branch. main is a file whose only content is a commit SHA. Create a new branch and inspect:
git branch feature
ls .git/refs/heads/
# feature main
cat .git/refs/heads/feature
# a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
# ↑ same SHA as main — both branches currently point at the same commit
A new branch is a new file. Creating a branch allocates ~41 bytes of disk. Deleting a branch removes one file. git branch -d is essentially rm .git/refs/heads/<name>.
A branch is a file containing a commit SHA. That is the entire data structure. Everything else — "the current branch," "branch history," "merged into main" — is derived from this one fact plus the commit graph. Once you see this, Git operations that previously felt magical (creating branches, switching them, deleting them) become trivial file operations on .git/refs/heads/.
HEAD Is a Pointer to a Pointer
HEAD is also a file, but usually it does not contain a SHA directly — it contains a ref name:
cat .git/HEAD
# ref: refs/heads/main
HEAD is an indirection: "the current commit is whatever refs/heads/main points at right now." When you commit, Git:
- Creates the new commit object.
- Follows HEAD →
refs/heads/main. - Writes the new commit's SHA into
refs/heads/main.
So main advances automatically. HEAD does not "move" — it still points at refs/heads/main. But the ref itself is updated.
Checking out a different branch
git checkout feature
cat .git/HEAD
# ref: refs/heads/feature
The only thing that changed on disk is .git/HEAD. Git also updates your working directory to match the tree of that branch's commit — but the branch-switch itself is a single-file write.
Detached HEAD
git checkout a1b2c3d4
# You are in 'detached HEAD' state. ...
cat .git/HEAD
# a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
# ↑ the SHA directly; no ref name
Detached HEAD means HEAD contains a commit SHA directly, not a ref reference. That is the entire thing. You can look around, make commits, do anything you want — but since no branch ref points at this state, any commits you make here are referenced only by HEAD, and if you later git checkout main, HEAD moves and those commits are unreachable unless you noted their SHAs (or the reflog helps you — Lesson 4).
The fix for a detached HEAD with commits you want to keep: git branch new-branch-name creates a ref pointing at your current commit. Now those commits are named and safe. You can git checkout main without losing anything. Detached HEAD + forgetting this step is how people "lose work," but reflog usually saves them.
The Full Ref Layout
.git/
├── HEAD
├── refs/
│ ├── heads/ ← local branches (main, feature, bugfix-123)
│ ├── tags/ ← tags (v1.0.0, v1.2.3-rc1)
│ ├── remotes/
│ │ └── origin/ ← remote-tracking branches (origin/main, origin/feature)
│ └── stash ← most-recent stash (if any)
└── packed-refs ← refs that have been packed into a single file
Each directory holds one file per ref. When you git push, Git updates refs/remotes/origin/<branch> locally and sends the commits to the remote.
Packed refs
For large repos with many refs, Git packs them into a single file:
cat .git/packed-refs
# # pack-refs with: peeled fully-peeled sorted
# a1b2c3d... refs/heads/main
# d4e5f6a... refs/remotes/origin/main
# 789abc0... refs/tags/v1.0
git pack-refs --all packs; Git does this automatically during maintenance. Loose refs in .git/refs/<kind>/ override packed entries of the same name. If you see a packed-refs file with hundreds of entries, that is normal for a repo with lots of branches and remotes.
The Pointer Graph
Three layers of indirection:
- HEAD → a ref name (e.g.,
refs/heads/main) — unless detached. - Ref → a commit SHA (e.g.,
a1b2c3d...). - Commit → its tree + its parents (via the object graph).
Every command that "moves a branch" just rewrites the ref file. Every command that "switches branches" just rewrites HEAD. The commits themselves never change.
Remote-Tracking Branches
When you git fetch or git pull, Git updates remote-tracking refs:
git fetch origin
# From github.com:myorg/myrepo
# a1b2c3d..d4e5f6a main -> origin/main
# (new branch) feature -> origin/feature
ls .git/refs/remotes/origin/
# HEAD main feature
cat .git/refs/remotes/origin/main
# d4e5f6a... ← what origin's main was as of our last fetch
origin/main is a local ref. It tracks your last known state of the remote's main branch. It does not update automatically when the remote changes — only when you git fetch.
This is why git pull is "fetch + merge": first update your idea of what origin has, then integrate those changes into your local branch.
# What does the remote have that I don't?
git fetch
git log main..origin/main # commits on origin not yet on local
# (three lines of commits)
# What do I have that the remote doesn't?
git log origin/main..main # commits on local not on origin
# (one line — your unpushed commit)
origin/main is not the same as origin main. The former (with slash) is your local tracking ref — updated by git fetch. The latter (with space, in git push origin main) is a remote specification. Same repo, different things.
Tags Are Refs That Do Not Move
git tag v1.0.0
ls .git/refs/tags/
# v1.0.0
cat .git/refs/tags/v1.0.0
# a1b2c3d... ← the commit at the time of tagging
Unlike branches, tags are meant to stay fixed. Git discourages moving them — git tag -f exists but requires explicit force, and git push does not update existing remote tags by default.
Two kinds of tags:
- Lightweight tag: just a ref (a file with a SHA).
git tag v1.0. - Annotated tag: a separate tag object in
.git/objects/with tagger, date, and message — then the ref points at that tag object.git tag -a v1.0 -m "release".
For release tags, use annotated (signed if possible). For quick local bookmarks, lightweight is fine.
Working With Refs Directly
# List every ref Git knows about
git for-each-ref
# a1b2c3d... commit refs/heads/main
# d4e5f6a... commit refs/heads/feature
# 789abc0... commit refs/tags/v1.0
# a1b2c3d... commit refs/remotes/origin/main
# Lookup what any ref points at
git rev-parse HEAD
git rev-parse main
git rev-parse origin/main
git rev-parse v1.0
# Resolve a symbolic ref
git symbolic-ref HEAD
# refs/heads/main
git rev-parse is the "resolve ref → SHA" tool. It handles branches, tags, relative refs (HEAD~3, main^2), and short SHAs.
Relative references
HEAD # current commit
HEAD~ # one parent back (same as HEAD~1)
HEAD~3 # three parents back, along the first-parent chain
HEAD^ # same as HEAD~1 (first parent)
HEAD^2 # for merge commits: the SECOND parent
HEAD~2^2 # two back, then the second parent of that
main@{yesterday} # what main pointed at yesterday (uses reflog)
main@{2} # what main pointed at 2 reflog entries ago
These are not file paths; they are the notation git rev-parse understands. Useful for git reset --hard HEAD~1, git show HEAD~3:app.py, etc.
Checkout vs Switch vs Restore
Modern Git splits the overloaded git checkout into focused commands:
# Old, versatile, confusing:
git checkout main # switch branch
git checkout <sha> # detached HEAD
git checkout -- file.py # discard working-dir changes
git checkout HEAD -- file.py # restore to HEAD version
# Modern, focused:
git switch main # branch switching
git switch -c new-branch # create + switch
git switch --detach <sha> # explicit detached HEAD
git restore file.py # discard working-dir changes
git restore --staged file.py # unstage
git restore --source=HEAD~3 file.py # file from an old commit
git checkout still works. But switch and restore are clearer about intent. Teams often standardize on the new ones for greppable scripts and less ambiguity.
Creating and Deleting Branches
git branch feature # new branch at HEAD
git branch feature a1b2c3d # new branch at specific commit
git switch feature # switch to it
git switch -c feature # create + switch in one command
# Rename the current branch
git branch -m new-name
# Delete (safe: refuses if not merged somewhere reachable)
git branch -d feature
# Delete (force)
git branch -D feature
# Delete a remote branch
git push origin --delete feature
git branch -d is safe; it refuses to delete a branch whose commits are not already in another branch (so you do not lose work). -D forces deletion — useful when you know the commits are safe (or you want them gone).
A developer ran git branch -D big-refactor on a branch with a week of work that they intended to rebase later. The branch ref was deleted. Panic. Two minutes later: git reflog showed every recent HEAD position including the tip of the deleted branch. git branch recovered a1b2c3d created a new branch at that SHA. Week of work recovered. Lesson: git reflog is your safety net (Module 4), and -D is not as scary as it seems as long as you act within the reflog window (~30 days by default).
Common Patterns
"What branches point at this commit?"
git branch --contains a1b2c3d
# * main
# feature-x
"What commits are only on this branch?"
git log main..feature # what's on feature but not main
git log main...feature # what's on either but not both (symmetric)
"Delete all branches already merged into main"
git branch --merged main | grep -v '^\*\|main\|develop' | xargs -r git branch -d
"Where did I just come from?"
git rev-parse HEAD@{1}
# Resolves to the previous HEAD position (before your last checkout/reset)
git switch - # like cd -: toggle to the previous branch
Key Concepts Summary
- A branch is a file in
.git/refs/heads/<name>containing one commit SHA. - HEAD is a pointer to a pointer. Normally
ref: refs/heads/<branch>; when detached, a SHA directly. - Refs live in
.git/refs/(or in.git/packed-refsafter packing).git for-each-reflists them all. - Tags are refs that should not move. Annotated tags have their own objects + signatures.
- Remote-tracking refs (
origin/main) are your local idea of the remote; updated bygit fetch. git rev-parseresolves any ref (branch, tag,HEAD~3,main@{yesterday}) to a SHA.git switch/git restoreare the modern replacements for the overloadedgit checkout.- Creating a branch allocates 41 bytes. Deleting a branch is rm-ing a file. Branches are cheap.
- Detached HEAD is safe.
git branch <name>anywhere turns the current commit into a named ref. git reflogremembers every HEAD change for 30 days — the safety net for mistakes.
Common Mistakes
- Treating detached HEAD as dangerous and never exploring history. It is just HEAD at a SHA.
- Assuming
origin/mainupdates live. It updates only ongit fetch/git pull. - Confusing
main..feature(commits unique to feature) withmain...feature(commits on either but not both). git branch -Don important work without noting the SHA. Even then, the reflog usually saves you.- Using
git checkoutfor everything and getting bitten by its ambiguity. Preferswitch+restore. - Manually editing
.git/HEADor.git/refs/*. It works but creates opportunities to mismatch. Use porcelain commands. - Confusing lightweight and annotated tags. For releases, always use annotated (
-a) + signed (-s) tags. - Forgetting that
git pushdoes not push tags by default.git push --tagsorgit push origin v1.2.3for a specific one. - Running
git checkout a1b2c3dwithout realizing you are now detached. Check HEAD withgit statusif unsure.
You are on branch `main` at commit A. You `git checkout a1b2c3d` (an older commit) and make two commits. You `git checkout main`. Your two new commits seem to have disappeared — they are not on main and not on any other branch. Are they really gone, and how do you recover them?