Git References and HEAD

Deep dive into Git references — branch refs, tag refs, HEAD, detached HEAD state, and symbolic references. Learn how Git tracks commits through the refs namespace.

published: reading time: 20 min read author: Geek Workbench updated: March 31, 2026

Introduction

Git references (refs) are human-friendly names that point to commits in the object database. Without refs, you’d need to memorize 40-character SHA-1 hashes to navigate your repository. Branches, tags, and remote-tracking branches are all refs — they’re Git’s way of naming commits.

HEAD is the most important ref. It answers “where am I?” and determines which branch receives new commits. Understanding HEAD, symbolic references, and the refs namespace is essential for mastering Git’s branching model and recovering from confusing states like detached HEAD.

When to Understand Refs and HEAD

  • Resolving detached HEAD confusion
  • Writing scripts that manipulate branches programmatically
  • Understanding how git checkout, git branch, and git tag work internally
  • Debugging missing branches or tags
  • Building Git tooling or CI/CD integrations

Don’t manipulate refs directly when:

  • Doing daily branching — use git branch and git checkout
  • Unsure — direct ref edits can lose commits
  • Handling simple tag operations — use git tag

Core Concepts

A ref is simply a file containing a 40-character SHA-1 hash (or a symbolic reference to another ref). All refs live under the refs/ namespace in .git/:


graph TD
    HEAD["HEAD\nref: refs/heads/main"] -->|symbolic ref| MAIN["refs/heads/main\nabc123..."]
    MAIN -->|points to| C3["Commit C3"]
    C3 -->|parent| C2["Commit C2"]
    C2 -->|parent| C1["Commit C1"]

    HEAD -.->|detached| C4["Commit C4\n(direct SHA)"]

    TAG["refs/tags/v1.0\nannotated tag object"] -->|points to| C3
    REMOTE["refs/remotes/origin/main\ndef456..."] -->|points to| C5["Commit C5"]

There are two types of references:

  • Symbolic refs: Point to another ref (e.g., HEAD → refs/heads/main)
  • Direct refs: Point directly to a commit SHA (e.g., refs/heads/main → abc123…)

Architecture or Flow Diagram


flowchart TD
    USER["User Command\ngit commit"] --> HEAD["HEAD Resolution"]
    HEAD -->|symbolic| BRANCH["refs/heads/branch"]
    HEAD -->|direct| DETACHED["Detached HEAD\ncommit SHA"]

    BRANCH -->|current SHA| COMMIT["Current Commit"]
    COMMIT -->|parent of| NEW["New Commit"]
    NEW -->|updates| BRANCH

    DETACHED -->|current SHA| COMMIT2["Current Commit"]
    COMMIT2 -->|parent of| NEW2["New Commit"]
    NEW2 -->|updates| DETACHED

    FETCH["git fetch"] -->|updates| REMOTE["refs/remotes/origin/*"]
    TAG_CMD["git tag"] -->|creates| TAG_REF["refs/tags/*"]

When you commit, Git creates a new commit object and updates the ref that HEAD points to. If HEAD is symbolic (pointing to a branch), the branch moves. If HEAD is detached (pointing directly to a SHA), the detached state moves.

Step-by-Step Guide / Deep Dive

Reference: The refs Namespace

Reference: Remote and Remote-Tracking References

The refs Namespace

Git organizes refs into a hierarchical namespace:


refs/
├── heads/           # Local branches
   ├── main         # Contains: abc123...
   ├── develop
   └── feature/
       └── auth
├── tags/            # Tags
   ├── v1.0.0       # Lightweight: contains SHA
   └── v2.0.0       # Annotated: points to tag object
├── remotes/         # Remote-tracking branches
   └── origin/
       ├── main
       ├── develop
       └── feature/auth
└── notes/           # Git notes
    └── commits

Each file in this tree is a ref. Reading the file gives you the commit SHA (or tag object SHA).

HEAD: The Current Reference

HEAD is a special ref stored in .git/HEAD. It can be in one of two states:

Attached (normal state):


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

HEAD symbolically references a branch. New commits update that branch.

Detached state:


$ cat .git/HEAD
abc123def456789012345678901234567890abcd

HEAD points directly to a commit SHA. New commits are not on any branch.

Symbolic References

Symbolic refs are indirection — they point to another ref rather than a commit SHA:


# Create a symbolic ref
git symbolic-ref refs/heads/current refs/heads/main

# Read what HEAD points to
git symbolic-ref HEAD
# Output: refs/heads/main

# Read the raw content
cat .git/HEAD
# Output: ref: refs/heads/main

The only symbolic ref most users encounter is HEAD, but you can create others for advanced workflows.

Remote and Remote-Tracking References

Remote refs and remote-tracking refs are how Git tracks branches from remote repositories:


# Remote refs live in refs/remotes/
# origin/main represents the main branch on origin
cat .git/refs/remotes/origin/main

# Remote-tracking refs are local copies of remote branches
# They update when you fetch
git fetch origin

Remote-tracking branch is a local ref that remembers where a remote branch was last time you fetched. It lets you work offline while still knowing what the remote looks like.

Detached HEAD State

When you check out a commit directly instead of a branch:


# Detach HEAD by checking out a specific commit
git checkout abc123

# Or a tag
git checkout v1.0.0

# Or a remote branch
git checkout origin/main

# Check status
git status
# Output: HEAD detached at abc123

In this state:

  • You can inspect the code, run tests, and even make commits
  • New commits are not on any branch
  • Switching away may make those commits unreachable (recoverable via reflog)

To recover work from detached HEAD:


# Create a branch at the current commit
git checkout -b recovery-branch

# Or from another location
git branch recovery-branch abc123

Packed Refs

For performance, Git can pack multiple refs into a single file:


$ cat .git/packed-refs
# pack-refs with: peeled fully-peeled sorted
abc123... refs/heads/main
def456... refs/tags/v1.0.0
^789ghi...  # peeled value for annotated tag

Packed refs are created by git pack-refs and are read alongside loose refs. If a ref exists in both places, the loose ref takes precedence.

Reflog: The Safety Net

Every ref movement is logged in the reflog:


$ git reflog
abc123 HEAD@{0}: commit: Add feature
def456 HEAD@{1}: checkout: moving from main to develop
789ghi HEAD@{2}: commit: Fix bug

Reflogs are stored in .git/logs/ and enable recovery of “lost” commits. They expire after 90 days by default.

Production Failure Scenarios

ScenarioSymptomsMitigation
Detached HEAD with uncommitted work”HEAD detached at…” with modified filesCreate a branch: git checkout -b save-branch
Lost branch refBranch disappears after force pushUse git reflog to find old SHA, recreate branch
Corrupted HEAD”fatal: bad HEAD”git symbolic-ref HEAD refs/heads/main
Ref namespace collisionUnexpected branch behaviorCheck for packed refs: git show-ref
Stale remote refsRemote branches that no longer existgit remote prune origin or git fetch --prune

Trade-off Analysis

AspectAdvantageDisadvantage
Symbolic HEADBranch-agnostic operationsAdds indirection layer
Detached HEADFlexible exploration of historyEasy to lose commits
Packed refsPerformance for repos with many refsSlightly more complex lookup
ReflogRecovery safety netConsumes disk space, expires

Implementation Snippets


# List all refs
git show-ref

# List only branch refs
git show-ref --heads

# List only tag refs
git show-ref --tags

# Read HEAD's target
git symbolic-ref HEAD

# Set HEAD to a specific branch
git symbolic-ref HEAD refs/heads/main

# Check if HEAD is detached
git symbolic-ref HEAD 2>/dev/null || echo "detached"

# Create a branch ref manually
echo "abc123..." > .git/refs/heads/my-branch

# Delete a ref
git update-ref -d refs/heads/old-branch

# Move a ref
git update-ref refs/heads/main abc123...

# Pack all refs
git pack-refs --all

# View reflog for HEAD
git reflog

# View reflog for a specific branch
git reflog show main

# Expire old reflog entries
git reflog expire --expire=30.days.ago --all

Observability Checklist

  • Monitor: Number of refs with git show-ref | wc -l
  • Track: Reflog size per ref (large reflogs indicate frequent operations)
  • Alert: Detached HEAD state in CI/CD pipelines (usually unintended)
  • Verify: Remote ref consistency with git remote show origin
  • Audit: Packed refs vs loose refs ratio for performance

Security & Compliance Considerations

  • Refs are not encrypted — anyone with repo access can see branch names
  • Tag signatures (GPG/SSH) provide release authenticity verification
  • Reflog contains historical ref positions — may reveal sensitive branch names
  • Force-pushing rewrites refs — use protected branches to prevent accidental history loss
  • See Signed Commits for commit authenticity

Common Pitfalls / Anti-Patterns

  • Ignoring detached HEAD warnings — commits made here are easily lost
  • Manually editing ref files — use git update-ref instead
  • Assuming refs are always loose files — packed refs change the storage model
  • Not pruning stale remote refs — leads to phantom branches
  • Confusing git checkout <file> with git checkout <branch> — one restores files, the other moves HEAD

Quick Recap Checklist

  • Refs are named pointers to commits (or tag objects)
  • HEAD is a symbolic ref pointing to the current branch (usually)
  • Detached HEAD means HEAD points directly to a commit SHA
  • Branch refs live in refs/heads/, tags in refs/tags/
  • Remote-tracking refs live in refs/remotes/
  • Packed refs improve performance for large ref counts
  • Reflog records all ref movements for recovery
  • Use git update-ref for safe ref manipulation

Reference Pointer Chains (Clean Architecture)


graph TD
    HEAD["HEAD\nref: refs/heads/main"] -->|symbolic| MAIN["refs/heads/main\n→ abc123..."]
    HEAD -.->|detached mode| DIRECT["Direct SHA\n→ def456..."]

    MAIN -->|points to| C5["Commit C5\n(latest on main)"]
    C5 -->|parent| C4["Commit C4"]
    C4 -->|parent| C3["Commit C3"]

    TAG["refs/tags/v1.0\n→ tag object"] -->|points to| C3
    TAG_LW["refs/tags/latest\n→ SHA directly"] -->|lightweight| C5

    REMOTE["refs/remotes/origin/main\n→ 111222..."] -->|tracks| OC5["Origin's C5"]

    PACKED["packed-refs\n(batch file)"] -.->|fallback| MAIN

Production Failure: Detached HEAD Data Loss

Scenario: Lost commits after checkout


# What happened:
$ git checkout abc123  # Detached HEAD
$ # Made 3 commits here
$ git checkout main    # Switched away — commits now unreachable!

# Symptoms
$ git log
# The 3 commits are gone from git log!
$ git status
HEAD detached at abc123

# Recovery (act fast — before gc prunes):

# 1. Find the lost commits via reflog
$ git reflog
abc123 HEAD@{0}: checkout: moving from abc123 to main
xyz789 HEAD@{1}: commit: Third work commit
uvw456 HEAD@{2}: commit: Second work commit
rst123 HEAD@{3}: commit: First work commit
abc123 HEAD@{4}: checkout: moving from main to abc123

# 2. Create a branch at the lost commit
$ git branch recovery-work xyz789

# 3. Verify the recovery
$ git log recovery-work --oneline
xyz789 Third work commit
uvw456 Second work commit
rst123 First work commit
abc123 Original detached commit

# 4. Merge or cherry-pick as needed
$ git checkout main
$ git merge recovery-work

# Prevention:
# - Always create a branch before working in detached HEAD
# - git checkout -b temp-work <sha> instead of git checkout <sha>
# - Set longer reflog expiry: git config gc.reflogExpire 180.days.ago

Trade-offs: Symbolic Refs vs Direct SHA References

AspectSymbolic RefsDirect SHA References
Readabilityref: refs/heads/main — human-friendlyabc123def456... — opaque
MaintenanceAuto-updates when branch movesStale immediately after new commits
PortabilityWorks across all Git versionsUniversal, but fragile
Use caseHEAD, active branchesTags, historical references, scripts
SafetyGit manages the indirectionEasy to reference wrong commit
PerformanceOne extra file readDirect lookup
ReflogFull movement history trackedNo reflog for bare SHAs
Scriptinggit symbolic-ref HEADgit rev-parse HEAD

Recommendation: Use symbolic refs for anything that should track moving targets (branches). Use direct SHAs for immutable references (tags, historical analysis, CI pinning).

Quick Recap: Reference Health Check


# === 1. Check for packed refs ===
git show-ref --head
# Lists all refs (both loose and packed)

# === 2. Find stale branches ===
# Branches not merged into main
git branch --no-merged main

# Branches not touched in 90+ days
git for-each-ref --sort=-committerdate --format='%(refname:short) %(committerdate:relative)' refs/heads/

# === 3. Detect dangling tags ===
git fsck --dangling 2>&1 | grep "dangling tag"

# === 4. Verify ref consistency ===
# Compare loose refs with packed refs
git show-ref > /tmp/all-refs.txt
cat .git/packed-refs 2>/dev/null | grep -v '^#' > /tmp/packed.txt
# Any ref in packed but not in loose is using packed storage

# === 5. Clean up stale remote refs ===
git remote prune origin --dry-run  # Preview
git remote prune origin             # Execute

# === 6. Verify HEAD is valid ===
git symbolic-ref HEAD 2>/dev/null && echo "Attached" || echo "Detached"
git rev-parse --verify HEAD && echo "HEAD points to valid commit"

# === 7. Check reflog health ===
git reflog expire --dry-run --expire-unreachable=now --all
# Shows what would be pruned without actually doing it

Interview Questions

1. What happens when you run `git checkout main`?

Git writes ref: refs/heads/main to .git/HEAD (if not already there), then updates the working tree and index to match the commit that refs/heads/main points to. HEAD becomes a symbolic reference to the main branch, so subsequent commits will update main.

2. How do you recover commits made in detached HEAD state?

Run git reflog to find the SHA of the detached HEAD commits. Then create a branch pointing to that SHA: git branch recovery <sha>. The commits are still in the object database — they're just unreachable from any named ref. As long as git gc hasn't pruned them, they're recoverable.

3. What's the difference between `git branch -d` and `git update-ref -d`?

git branch -d performs safety checks — it refuses to delete branches with unmerged commits and updates the reflog. git update-ref -d is a plumbing command that directly removes the ref file without any safety checks. Use the porcelain command unless you're scripting and know exactly what you're doing.

4. Why does `git show-ref` show different output than listing .git/refs/?

git show-ref reads both loose refs (individual files in .git/refs/) and packed refs (from .git/packed-refs). Listing the directory only shows loose refs. When refs are packed for performance, they disappear from the directory tree but remain accessible through git show-ref.

5. What is the difference between a lightweight tag and an annotated tag?

A lightweight tag is just a direct ref pointing to a commit SHA — a simple file in refs/tags/. An annotated tag is a full Git object that contains a message, author, date, and optionally a GPG/SSH signature. Annotated tags are created with git tag -a and are meant for releases because they form a separate object in the database that outlives the commit they reference.

6. How does `git fetch` update remote-tracking refs?

When you run git fetch origin, Git connects to the remote and downloads any new commits, then updates the refs in refs/remotes/origin/ to point to the new positions of the remote branches. The remote-tracking refs (origin/main, origin/develop, etc.) act as local bookmarks — they only move during fetch, never during local operations. This lets you compare local work against the remote without actually connecting.

7. Why does switching branches with uncommitted changes cause a conflict in Git?

Git's index and working tree are shared across all branches. When you switch branches (git checkout feature), Git must update the index and working tree to reflect the new branch's commit. If you have uncommitted changes that conflict with files the other branch modified, Git refuses the switch to prevent data loss. Either commit your changes, stash them (git stash), or use git checkout -b to create a branch from the current HEAD first.

8. What does `git rev-parse HEAD` return when HEAD is symbolic vs detached?

git rev-parse HEAD always resolves to a full SHA-1 hash. If HEAD is symbolic (ref: refs/heads/main), it follows the indirection and returns the commit SHA that refs/heads/main points to. If HEAD is detached (already a raw SHA), it returns that SHA directly. Use git symbolic-ref HEAD to distinguish the two states — it succeeds for symbolic refs and fails for detached HEAD.

9. How does `git pack-refs --all` improve performance?

Large repositories can have thousands of refs, each as a separate file in .git/refs/. Opening thousands of files is slow on some filesystems. git pack-refs --all collects all refs (except the current HEAD and a few others) into a single .git/packed-refs file, reducing file count dramatically. Git falls back to packed-refs when a loose ref doesn't exist, so the behavior is transparent — but the lookup hits the packed file instead of scanning directories.

10. What happens to the reflog when you amend a commit with `git commit --amend`?

When you amend a commit, Git creates a brand-new commit with a different SHA and updates the branch ref to point to it. The old commit's SHA remains in the reflog under the old ref position — it doesn't disappear immediately. git reflog shows the old position as HEAD@{n}, so you can recover the original commit if you haven't yet pushed. The reflog entry for the old commit expires based on gc.reflogExpire (default 90 days).

11. Can you create a symbolic ref that points to a remote-tracking branch? When is this useful?

Yes — git symbolic-ref refs/heads/snapshot refs/remotes/origin/main creates a local branch that mirrors a remote-tracking branch. This is useful for CI scripts that need a stable branch name to checkout, or for pinning a local branch to a remote's state without fetching. However, the symbolic ref won't automatically update on fetch — it only moves when you explicitly update it or when it drifts from the remote.

12. What is the " peeled" value shown for annotated tags in `git show-ref` output?

In git show-ref output, an annotated tag appears twice: the first line is the tag ref itself (pointing to a tag object SHA), and the second line (prefixed with ^) is the peeled value — the commit SHA that the tag object ultimately points to. For example: abc123 refs/tags/v1.0.0 and ^def456 means the tag object is abc123 and the commit it tags is def456. The peeled value lets tools resolve tags to commits without dereferencing manually.

13. Why might `git branch --set-upstream-to=origin/main` fail on a fresh clone?

On a fresh clone, your local main branch has no commits in common with origin/main yet — the remote-tracking ref exists but points to a commit your local branch doesn't know. Git needs at least one shared commit to set upstream tracking. Additionally, if the local branch hasn't been pushed to the remote yet, there's no basis for the upstream relationship. Use git push -u origin main first, then the tracking relationship is established.

14. How does the `ORIG_HEAD` ref differ from `HEAD`?

ORIG_HEAD is a direct ref (not symbolic) that Git sets before operations that move HEAD dramatically — merge, rebase, reset, or checkout with branch changes. It remembers the commit you were on before the operation, so you can recover with git reset ORIG_HEAD. HEAD always points to your current position. ORIG_HEAD is overwritten each time a destructive operation runs, so it's not a permanent historical record — use reflog for that.

15. What is the purpose of `FETCH_HEAD` and when is it created?

FETCH_HEAD is a special ref that records the state of the remote's branches at the time of your last git fetch. It's created automatically by git fetch and points to the same commits as the remote-tracking refs (refs/remotes/origin/*), but in a single file. FETCH_HEAD is useful when you want to quickly inspect what changed on the remote without updating all remote-tracking branches — it's a snapshot, not a persistent tracking relationship.

16. How does `git update-ref` handle atomic ref updates?

git update-ref -m "reason" refs/heads/main <new> <old> performs an atomic compare-and-swap: it only updates the ref if its current value matches <old>. This prevents race conditions in multi-writer environments — if another process updated the ref between your read and write, the update fails. This is how Git itself updates refs internally and is the basis for reference transaction safety in bare repositories.

17. What causes a "fatal: bad HEAD" error and how do you fix it?

This error occurs when .git/HEAD contains a value that is neither a valid ref: path/to/ref symbolic reference nor a valid SHA-1 hash. Common causes: manual editing mistakes, corruption, or a partial Git upgrade. Fix it by manually writing a valid reference: git symbolic-ref HEAD refs/heads/main (if you know a valid branch) or by directly writing a commit SHA: echo "abc123..." > .git/HEAD as a last resort.

18. Can refs contain anything other than SHA-1 hashes? What about future Git versions?

Currently, direct refs must contain a valid SHA-1 hash (40 hex characters). However, Git's ref mechanism is designed to be extensible — the file format supports arbitrary ref names and the storage layer is pluggable. SHA-256 support (Git v2.29+) uses SHA-256 hashes for objects but refs still use the same namespace mechanism. Future hash algorithms would likely extend the ref storage format, but the refs namespace API (git symbolic-ref, git update-ref) remains the interface.

19. How does `gitNotes` work differently from tags as a reference system?

Git notes live in refs/notes/commits and attach metadata to existing commits without changing the commit SHA. Unlike tags (which reference a commit or tag object), notes are themselves objects that annotate a commit — you can have multiple notes per commit, and they can be added, modified, or deleted without affecting the commit graph. Notes are useful for CI annotations, code review comments, or external metadata that shouldn't be part of the commit itself.

20. What is the difference between `git for-each-ref` and `git show-ref`?

git show-ref lists refs with their SHA-1 values and is optimized for scripting ref existence checks. git for-each-ref is a more powerful porcelain command that formats ref output using format strings, supports sorting (--sort=-committerdate), and can filter by pattern or ref type. Use git for-each-ref when you need specific fields (author date, subject line, upstream branch) or custom sorting — use git show-ref for simple existence checks or raw SHA listings.

Further Reading

Official Documentation:

Advanced Reading:

Related Articles on GeekWorkBench:

Community Resources:

Conclusion

References are human-readable bookmarks into Git’s object database, and HEAD is the most important one — it tells you where you are. Getting the difference between a branch pointer, a tag pointer, and detached HEAD state eliminates the most common sources of Git confusion.

Category

Related Posts

Git Objects: Blobs, Trees, Commits, Tags

Understanding Git's four object types — blobs, trees, commits, and annotated tags — how they relate through content-addressable storage, and how to inspect them with plumbing commands.

#git #version-control #git-objects

Semantic Versioning and Git Tags: SemVer, Tag Types, and Management Strategies

Master semantic versioning (SemVer 2.0.0), lightweight vs annotated git tags, tag management strategies, and automated versioning workflows for production software releases.

#git #version-control #semver

Centralized vs Distributed VCS: Architecture, Trade-offs, and When to Use Each

Compare centralized (SVN, CVS) vs distributed (Git, Mercurial) version control systems — their architectures, trade-offs, and when to use each approach.

#git #version-control #svn