Git Refs and HEAD: How Git Manages Pointers
AI generated
git
HEAD
Git · Version Control · Refs · Internals · Developer Basics
Git Refs and HEAD: How Git Manages Pointers
Branches, tags and HEAD as movable pointers in the commit graph

A commit without a ref is practically invisible to Git. Branches, tags and HEAD are all just named pointers that turn a loose pile of objects into a navigable history. This article shows how .git/refs/heads, .git/refs/tags and packed-refs are laid out on disk, how HEAD resolves to a commit in two hops, and how to inspect and manipulate refs directly with git show-ref, git symbolic-ref and git update-ref.

13 min read Branches · Tags · HEAD · packed-refs Git 2.x · CLI · Internals

1. Why Refs Are the Actual Map of the Commit Graph

A commit object in Git is essentially useless on its own once nothing knows where it sits in the history. It is refs, named pointers to commits, that turn a loose collection of objects in the object store into a navigable graph. Without a ref pointing at a commit hash, that commit still physically exists under .git/objects, but it becomes unreachable for commands like git log, git branch, or git merge. Git internally calls this state "unreachable", a state that eventually makes an object eligible for garbage collection.

This insight flips the usual beginner perspective: the commit is not the central unit of Git, the ref pointing at it is. A branch name like main is ultimately nothing more than a label attached to one specific point in the commit graph, one that automatically moves forward with every new commit. Once you understand that refs/heads/main, refs/tags/v1.0, and HEAD all follow the same simple principle, namely a file containing either a hash or another ref name, you have the foundation for practically every advanced Git operation.

2. Branches: Movable Pointers to a Commit

A branch is technically nothing more than a text file under .git/refs/heads/, whose entire content is a single 40-character SHA-1 hash (or 64 characters in SHA-256 repositories), followed by a newline. A file named .git/refs/heads/main literally contains only the hash of the current commit on that branch, nothing else. That is what makes branches in Git exceptionally cheap: git branch feature-x merely creates a tiny, few-bytes file containing the current commit hash, without touching the object store at all.

The key mechanism: whenever a new commit is created while HEAD points at a branch, Git automatically updates that exact one file with the hash of the new commit. The branch pointer moves along, while older commits remain reachable through the parent reference stored inside the commit object. This simplicity of implementation is the actual reason branching and merging are so fast in Git, compared to older version control systems that often had to create a full copy of the directory tree for a branch. A branch's tracking relationship to a remote is not stored in the ref itself either, but separately in the configuration file.


# .git/config: tracking configuration ties a local branch to a ref
[branch "main"]
	remote = origin
	merge = refs/heads/main

[branch "feature-x"]
	remote = origin
	merge = refs/heads/feature-x
	rebase = true

[remote "origin"]
	url = git@github.com:example/shop.git
	fetch = +refs/heads/*:refs/remotes/origin/*

3. Tags: Immutable References for Milestones

Tags mark a commit permanently and, unlike branches, are not supposed to move once created. Git distinguishes between two fundamentally different types. A lightweight tag is structurally identical to a branch: a file under .git/refs/tags/ that points directly at the hash of a commit. git tag v1.0.0 without any further options produces exactly that, a simple name reference with no additional metadata.

An annotated tag, on the other hand, created with git tag -a v1.0.0 -m "Release 1.0.0", creates a dedicated tag object in the object store that stores the tagger's name, email, timestamp, a message, and optionally a GPG signature. The file under .git/refs/tags/v1.0.0 does not point directly at the commit in this case, but at the hash of this tag object, which in turn points at the commit, an extra hop of indirection. For releases, annotated tags are therefore the recommended choice, since they can be signed and provide a traceable history, while lightweight tags are better suited to private, temporary markers.

HEAD itself is neither a branch nor a commit, it is a pointer to a pointer. In the normal case, the file .git/HEAD does not contain a hash at all, but the line ref: refs/heads/main, a so-called symbolic reference. To get from the current HEAD to the actual commit, Git has to resolve two steps: first HEAD to refs/heads/main, then that file to its content, the actual commit hash. This double indirection is the reason a commit automatically moves the current branch forward without HEAD itself ever needing to change.

This two-level design is deliberate. If HEAD instead contained a commit hash directly, every commit would need to update two files, HEAD and the branch. Instead, it is enough to update only refs/heads/main, while HEAD stays unchanged, still pointing at the symbolic name. Commands like git switch main, or the older git checkout main, do nothing more at their core than rewrite the content of .git/HEAD to ref: refs/heads/main and then adjust the working directory and index to the new target commit.


$ git show-ref --heads --tags
a1b2c3d4e5f6789012345678901234567890abcd refs/heads/main
b2c3d4e5f6789012345678901234567890abcde1 refs/heads/feature-x
c3d4e5f6789012345678901234567890abcdef12 refs/tags/v1.0.0
d4e5f6789012345678901234567890abcdef1234 refs/tags/v1.1.0

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

# HEAD is a symbolic reference, not a hash: Git resolves it in two hops
# HEAD -> refs/heads/main -> a1b2c3d4e5f6789012345678901234567890abcd

5. Detached HEAD: When HEAD Points Directly at a Commit

If you check out a commit hash or a tag directly instead, for example with git checkout a1b2c3d, Git no longer writes a symbolic reference into HEAD, but the commit hash itself. This state is called detached HEAD, because HEAD is detached from every branch. Commits remain technically possible in this state, they get regular hashes and end up in the object store, but no branch pointer points at them.

The real risk appears at the next branch switch: without a ref pointing at the new commits, they become invisible to standard commands like git log and risk being removed by garbage collection after 30 to 90 days, the default value of gc.reflogExpire. The safety net against this is the reflog: it logs every movement of HEAD locally, so lost commits can almost always be recovered with git reflog followed by git switch -c rescue-branch <hash>, as long as the reflog retention period has not yet expired.


$ git checkout a1b2c3d
Note: switching to 'a1b2c3d'.

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.

HEAD is now at a1b2c3d Fix checkout redirect for guest customers

$ git log --oneline -3
a1b2c3d (HEAD detached at a1b2c3d) Fix checkout redirect for guest customers
9f8e7d6 Add wishlist AJAX handler
7c6b5a4 Update Hyva_Theme layout for sticky header

# Commit while detached: valid object, but no branch points to it yet
$ echo "fix" >> Checkout.php
$ git commit -am "Hotfix: correct redirect URL"
[detached HEAD c5d6e7f] Hotfix: correct redirect URL

# Rescue the work before switching branches and losing the pointer
$ git switch -c hotfix/checkout-redirect
Switched to a new branch 'hotfix/checkout-redirect'

6. .git/refs on Disk: Directory Structure and File Format

Physically, .git/refs is a simple directory with two main subfolders, heads/ for branches and tags/ for tags, complemented by remotes/ for remote-tracking branches. Every single reference corresponds to exactly one file, whose path relative to .git/refs matches the full ref name: a branch feature/checkout-redesign lives under .git/refs/heads/feature/checkout-redesign, where the slash in the name even creates a real subdirectory. The file content is deliberately minimal, a single hash as plain text, with no binary format and no additional metadata.

This design is elegant for small repositories, but scales poorly as the number of refs grows. A repository with ten thousand branches or tags produces ten thousand individual, tiny files on the filesystem, which noticeably slows down filesystem operations like recursively listing all refs, especially on network drives or in Docker volumes with high per-file I/O overhead. This exact scaling problem was the original reason packed-refs was introduced.

7. packed-refs: Compressing Refs for Large Repositories

The file .git/packed-refs solves the scaling problem by consolidating many individual loose refs into a single text file, one hash and one ref name per line. The command git pack-refs --all scans .git/refs, writes every reference it finds into .git/packed-refs, and then removes the original individual files. Annotated tags additionally get a line prefixed with a caret character, pointing at the actual commit behind the tag object, so tools can find it without an extra object resolution step.

What matters most to understand is how both mechanisms coexist: a loose ref under .git/refs always overrides the entry of the same name in packed-refs, never the other way around. If an already packed branch gets updated, for example through a new commit, Git writes a loose file under .git/refs/heads/ again, which effectively shadows the stale entry in packed-refs without immediately cleaning up the packed file itself. Only a fresh git pack-refs run clears up this overlap again. git clone and git gc call pack-refs automatically as a matter of routine, so most developers never have to trigger this mechanism manually.

8. Inspecting Refs Directly: show-ref, symbolic-ref, and update-ref

git show-ref by default lists every local ref with its full hash and name, regardless of whether it is stored loose or packed, Git treats both sources transparently the same. The flags --heads or --tags narrow the output specifically to branches or tags, and an optional pattern argument additionally filters by name, for example git show-ref --heads 'feature/*'. git symbolic-ref HEAD reads the raw content of the symbolic reference, while git symbolic-ref HEAD refs/heads/main sets it directly, exactly the step that git switch performs internally.

For automation and scripts, git update-ref is the right tool, because it creates, moves, or deletes refs transactionally and without the detour of a checkout, without touching the working directory or the index. A CI script could, for instance, run git update-ref refs/heads/deploy-snapshot HEAD to pin a state to a branch, without any developer ever switching to it. To delete safely, git update-ref -d refs/heads/old-branch should always be used instead of manually removing the file, because update-ref can check the expected old value and avoid race conditions under concurrent access.


# Read the raw symbolic reference stored in .git/HEAD
$ git symbolic-ref HEAD
refs/heads/main

# Write it directly: this is what "git switch main" does internally
$ git symbolic-ref HEAD refs/heads/main

# Attempting this while HEAD is detached fails, because HEAD then
# holds a raw commit hash, not a symbolic reference
$ git symbolic-ref HEAD
fatal: ref HEAD is not a symbolic ref

# Create a branch pointer without checking it out
$ git update-ref refs/heads/feature-x a1b2c3d4e5f6789012345678901234567890abcd
$ git show-ref --heads feature-x
a1b2c3d4e5f6789012345678901234567890abcd refs/heads/feature-x

# Delete a ref safely: update-ref checks the current value first
$ git update-ref -d refs/heads/feature-x

# Force-move an existing branch to a specific commit, scripted
$ git update-ref refs/heads/main c5d6e7f8901234567890abcdef1234567890abc

9. Ref Types Compared Side by Side

Every ref type covered in this article has its own storage location, its own mutability behavior, and a typical use case in day-to-day development. The following table summarizes the differences and resolves the most common misconception, namely that all refs could be treated the same way.

Ref Type Storage Location Mutable? Typical Use
Branch .git/refs/heads/<name> Yes, on every commit Active feature and release development
Lightweight Tag .git/refs/tags/<name> Yes, but only with -f Quick, private markers without metadata
Annotated Tag .git/refs/tags/<name> + tag object No, fixed by convention Official releases with signature and message
HEAD (attached) .git/HEAD (symbolic reference) Yes, follows the branch Normal working state on a branch
HEAD (detached) .git/HEAD (raw commit hash) Yes, but unsecured Inspecting history, bisect, CI checkouts

Once these differences click, every ref-related error message, such as "not a valid ref" or "ambiguous refname", becomes something you can pin down precisely, instead of a cryptic accident to shrug off.

Mironsoft

Git workflows, repository hygiene, and CI/CD pipelines for PHP and Magento teams

Want refs, branches, and the reflog firmly under control?

We help development teams set up branching strategies, release tagging, and script automation around Git refs cleanly and reliably, without lost commits and without wild ref sprawl.

Git Training

Hands-on workshops on refs, HEAD, the reflog, and safe ref manipulation

Release Tagging Setup

Establish consistent, signed tags and branch conventions across the team

CI/CD Integration

Build ref-based deploy pipelines with git update-ref and automation

10. Summary

Refs are the actual foundation that turns a pile of loose commit objects into a navigable history. Branches are movable pointers under .git/refs/heads/, which automatically move forward with every commit. Tags mark milestones permanently, with annotated tags additionally creating their own signable object in the repository. HEAD itself is a pointer to a pointer, usually a symbolic reference to the current branch, which resolves to a commit in two hops.

On disk, every ref starts out as a single, tiny file under .git/refs, but gets consolidated into one file via packed-refs as the number of refs grows, with loose refs always taking priority over packed entries. With git show-ref, git symbolic-ref, and git update-ref, all of these mechanisms can be inspected directly and even manipulated in a scriptable way, without the detour of a checkout.

Git Refs and HEAD: The Essentials at a Glance

Branches

Movable pointers under .git/refs/heads/ that store a single commit hash as plain text.

Tags

Lightweight points directly at a commit, annotated creates its own signable object.

HEAD

ref: refs/heads/main in .git/HEAD, resolved to a commit in two hops.

packed-refs

Consolidates many loose refs into one file, loose refs always override packed entries.

11. FAQ: Git Refs and HEAD

1What exactly is a ref in Git?
A named pointer to a commit or another object, usually a text file under .git/refs containing a hash. Branches, tags, and HEAD are all variants of it.
2What is the difference between a branch and a tag?
A branch moves automatically with every commit, a tag is meant to stay fixed. An annotated tag additionally creates its own object with metadata.
3Lightweight vs. annotated tag?
Lightweight points directly at a commit with no metadata. Annotated creates its own tag object with tagger, timestamp, message, and an optional signature.
4What exactly is stored in .git/HEAD?
Normally ref: refs/heads/main, a symbolic reference. In the detached HEAD state, it instead contains a raw commit hash.
5Is detached HEAD dangerous?
HEAD points directly at a commit instead of a branch. Commits stay possible, but become hard to find without a branch pointer. The reflog acts as a safety net.
6How do I recover lost commits?
git reflog shows earlier HEAD positions. git switch -c rescue-branch <hash> attaches a lost commit to a new branch.
7What is packed-refs?
A single file that consolidates many loose refs for performance reasons. git pack-refs --all creates it manually, git gc and git clone call it automatically.
8Loose ref and packed-refs at the same time?
The loose ref always takes priority over the same-named entry in packed-refs. This typically happens after a new commit on an already packed branch.
9What is git update-ref used for?
Creates, moves, or deletes refs directly and transactionally, without a checkout. Especially useful for scripts and CI pipelines.
10How do I list all refs with their hash?
git show-ref lists every local ref. --heads or --tags narrow it to branches or tags, and a pattern argument additionally filters by name.