Git Refs (References)

TL;DR: The .git/objects/ database is a collection of immutable, content-addressed files named after 40-character SHA-1 hashes. Refs (References) are simple, human-readable text files stored in .git/refs/ that map names (like main, feature, or v1.0.0) to specific commit hashes, shielding you from having to memorize SHA-1 strings.


If you look at the raw object database of a Git repository, it is a massive web of SHA-1 hashes. Every commit, tree, and blob is given an address like a1b2c3d4e5f6.... Because humans are not designed to memorize 40-character hexadecimal strings, Git uses a simple mapping system: References, or Refs.

Under the hood, a Git reference is nothing more than a file containing a commit hash. When you run commands like git branch or git tag, you are simply interacting with tiny text files in the .git/ directory.

In this tutorial, we will explore the internal mechanics of references, understand the difference between branches and tags, look at the special role of HEAD, and learn how to manage and recover references using low-level plumbing commands.


What Do Git References Actually Do?

To understand how Git references work, it helps to look at them as “bookmarks” or “aliases” in your version control system. Instead of telling Git to checkout 8c474d28d..., you tell it to checkout main. Git looks up the alias, resolves it to the correct hash, and performs the checkout.

There are three primary categories of references in Git:

graph TD
    HEAD[HEAD: Symbolic Ref <br> ref: refs/heads/main] --> Branch[Branch Ref: refs/heads/main <br> Contains: 8c474d2...]
    Branch --> Commit[Commit Object: 8c474d2...]
    Tag[Tag Ref: refs/tags/v1.0 <br> Contains: 8c474d2...] --> Commit
    Remote[Remote Ref: refs/remotes/origin/main <br> Contains: 8c474d2...] --> Commit

1. Branches (Local References)

In Git, a branch is not a folder or a heavy copy of your codebase. It is a lightweight, mutable pointer.

  • Mutable: A branch reference changes automatically every time you commit. If you make a new commit on main, Git updates the main reference file with the new commit’s hash.
  • Location: Local branch files are stored inside .git/refs/heads/.

2. Tags (Stationary References)

A tag is a permanent bookmark to a specific point in your history.

  • Immutable: Unlike branches, tags do not move. Once you tag a commit as v1.0.0, that tag points to the same commit forever, unless you explicitly delete and recreate it.
  • Types: There are two types of tags: lightweight tags (which are simple references containing a commit hash) and annotated tags (which point to a full tag object in the .git/objects/ database, as described in git-objects).
  • Location: Tags are stored inside .git/refs/tags/.

3. Remote-Tracking References

These references track the state of branches on remote repositories (like GitHub or GitLab).

  • ReadOnly locally: You cannot write to these references directly. They are updated automatically when you run git fetch, git pull, or git push.
  • Location: Remote references are stored inside .git/refs/remotes/[remote-name]/ (e.g., .git/refs/remotes/origin/main).

4. HEAD: The Special Symbolic Reference

HEAD is the reference that tells Git which branch is currently checked out and where your next commit should go.

  • Symbolic Ref: Unlike standard references, HEAD does not usually contain a commit hash. Instead, it contains a pointer to another reference (e.g., ref: refs/heads/main).
  • Detached HEAD: If you check out a specific commit hash, a remote branch, or a tag instead of a local branch, HEAD becomes “detached”. In this state, the HEAD file directly contains a raw commit hash instead of a symbolic pointer.

What Happens Inside .git/ (Hands-on Exploration)

Let’s build a repository step-by-step and inspect how the files inside .git/refs/ are created and modified.

Step 1: Initialize a Repo and Create a Commit

First, initialize a new Git repository:

$ mkdir git-refs-demo
$ cd git-refs-demo
$ git init
Initialized empty Git repository in /path/to/git-refs-demo/.git/

Immediately after initialization, let’s look inside .git/HEAD:

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

(Depending on your Git configuration, it might say ref: refs/heads/master.)

Notice that .git/refs/heads/main does not exist yet! Git sets up HEAD to point to refs/heads/main so that it knows where to write the reference once the first commit is made.

Now, let’s create a file and commit it:

$ echo "Refs are simple" > readme.txt
$ git add readme.txt
$ git commit -m "Initial commit"
[main (root-commit) a4b3c2d] Initial commit
 1 file changed, 1 insertion(+)
 create mode 100644 readme.txt

Now let’s check the contents of .git/refs/heads/main:

$ cat .git/refs/heads/main
a4b3c2d58547cb826ef89b3bc01b4431e21b8f1d

(The file contains exactly one line: the 40-character SHA-1 hash of the commit we just created.)

We can resolve references to hashes using the plumbing command git rev-parse:

$ git rev-parse main
a4b3c2d58547cb826ef89b3bc01b4431e21b8f1d

$ git rev-parse HEAD
a4b3c2d58547cb826ef89b3bc01b4431e21b8f1d

Step 2: Create a Branch

Now, let’s create a new branch named feature using git branch:

$ git branch feature

Let’s see what happened inside .git/refs/heads/:

$ ls -la .git/refs/heads/
-rw-r--r--  1 user  group  41 Jul  7 10:30 feature
-rw-r--r--  1 user  group  41 Jul  7 10:30 main

If we view the contents of the feature file, we see it is identical to main:

$ cat .git/refs/heads/feature
a4b3c2d58547cb826ef89b3bc01b4431e21b8f1d

Creating a branch in Git is incredibly fast because it only involves creating a 41-byte text file containing a 40-character hash followed by a newline character. No code files are duplicated.

Step 3: Switch Branches (Updating HEAD)

Let’s switch to our new branch using git checkout:

$ git checkout feature
Switched to branch 'feature'

If we read .git/HEAD now:

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

HEAD has been updated to point to the new branch reference.

Step 4: Make a Commit on the Branch

Let’s make a new commit on our feature branch:

$ echo "Working on feature" >> readme.txt
$ git commit -am "Implement feature"
[feature c9d8e7f] Implement feature
 1 file changed, 1 insertion(+)

Let’s check the contents of both branch references:

$ cat .git/refs/heads/feature
c9d8e7f849b28a2a0bc4049d5bf1c38fa85b19d2

$ cat .git/refs/heads/main
a4b3c2d58547cb826ef89b3bc01b4431e21b8f1d

The feature reference file was overwritten with the hash of the new commit c9d8e7f.... The main reference file remains completely untouched.

Step 5: Create a Tag

Let’s create a tag on our current commit:

$ git tag v1.0.0-alpha

Now let’s check .git/refs/tags/:

$ cat .git/refs/tags/v1.0.0-alpha
c9d8e7f849b28a2a0bc4049d5bf1c38fa85b19d2

The tag is a file containing the same commit hash. If we make more commits on the feature branch, the feature ref will move forward, but the v1.0.0-alpha ref will remain pointed at c9d8e7f... forever.


Detailed Command Options & Advanced Usage

Git has a set of low-level plumbing commands specifically designed to read and write references. Using these commands, you can bypass porcelain commands entirely.

1. Manipulating References with git update-ref

You don’t need git branch or git tag to manage references. You can do it directly using git update-ref.

  • Create or update a ref:
    $ git update-ref refs/heads/manual-branch c9d8e7f849b28a2a0bc4049d5bf1c38fa85b19d2
    This creates a new branch named manual-branch pointing to the specified commit, exactly as if we had run git branch manual-branch c9d8e7f.
  • Delete a ref:
    $ git update-ref -d refs/heads/manual-branch
    This deletes the manual-branch ref file from the system.

2. Inspecting Symbolic Refs with git symbolic-ref

If you want to read or write the symbolic link of HEAD programmatically without editing files manually, you use git symbolic-ref.

  • Read a symbolic ref:
    $ git symbolic-ref HEAD
    refs/heads/feature
  • Point HEAD to another branch:
    $ git symbolic-ref HEAD refs/heads/main
    This changes your active branch to main without updating your working directory files (which can lead to index inconsistencies; therefore, porcelain commands like git checkout or git switch are preferred for workspace operations).

3. Packed References (packed-refs)

When a repository accumulates hundreds or thousands of branches and tags, looking up references across thousands of individual loose files can degrade disk I/O performance. To optimize this, Git automatically packages loose refs into a single file.

If you run git gc (garbage collection), Git deletes the individual files in .git/refs/heads/ and .git/refs/tags/ and writes their data into a single text file located at .git/packed-refs.

The .git/packed-refs file looks like this:

# pack-refs with: peeled fully-peeled sorted 
a4b3c2d58547cb826ef89b3bc01b4431e21b8f1d refs/heads/main
c9d8e7f849b28a2a0bc4049d5bf1c38fa85b19d2 refs/heads/feature
^c9d8e7f849b28a2a0bc4049d5bf1c38fa85b19d2 refs/tags/v1.0.0-alpha

(The line starting with ^ indicates the peeled commit hash that the annotated tag object points to.)

When you modify a packed branch, Git writes a loose file back to .git/refs/heads/branch-name. Loose files always override entries in .git/packed-refs.


Real-World Scenario

Scenario A: Recovering a Deleted Branch

A developer is cleaning up old local branches and runs git branch -D feature-login, only to realize they had unmerged work on that branch. Since the branch is deleted, git log no longer shows it, and the file .git/refs/heads/feature-login is gone.

Step-by-Step Recovery:

  1. Every time a reference changes, Git logs the transaction in the reflog. Let’s look at the reflog:
    $ git reflog
    a4b3c2d HEAD@{0}: checkout: moving from feature-login to main
    f9c8d7e HEAD@{1}: commit: Implement user authentication
  2. The reflog shows that before switching to main, we were at commit f9c8d7e on the feature-login branch.
  3. We can reconstruct the branch reference using git branch or git update-ref:
    $ git branch feature-login f9c8d7e
  4. The branch is fully restored, complete with all its commits, because the commit objects themselves in .git/objects/ were never deleted.

Scenario B: Resolving a Detached HEAD State

A developer runs git checkout origin/main to check remote work. They see a warning: You are in 'detached HEAD' state. They write several commits, only to realize their commits are not on any branch.

Under the Hood:

In a detached HEAD state, .git/HEAD contains a raw commit hash instead of a path:

$ cat .git/HEAD
f9c8d7e35b7190849202bb04c8f2a3bc6e01a88b

Any commits made here will have parent pointers linking backward, but no branch points to them. If the developer checks out main, these new commits will become dangling.

The Recovery:

Before leaving the detached HEAD state, create a new branch:

$ git checkout -b feature-saved

This updates .git/HEAD to point to a new local branch file:

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

The commits are now safely anchored to a named reference.


Common Pitfalls and Mistakes

1. Manually Editing Refs in a Text Editor

The Mistake: Opening .git/refs/heads/main in an editor and manually changing the commit hash. Why it fails: This bypasses Git’s safe transactional locks. If you make a typo or reference a non-existent commit hash, you will corrupt the reference. Git will throw a fatal: bad revision error. The Fix: Always use git update-ref or standard branch/reset commands to update references.

2. Confusion: Deleting a Branch Does Not Delete Code

The Mistake: Deleting a branch (git branch -d branch-name) and believing the code changes have been instantly wiped from the hard drive. Why it’s wrong: Deleting a branch only deletes the tiny reference file (the pointer). The actual commit, tree, and blob objects remain inside .git/objects/ intact. They will not be deleted until Git runs garbage collection (git gc), which typically happens weeks later. You can recover the branch at any point before garbage collection.

3. Detached HEAD Commit Loss

The Mistake: Working in a detached HEAD state, making commits, and checking out another branch (git checkout main) without saving. Why it happens: Because no branch ref points to the commits made during the detached state, they become orphaned. They will not show up in git-log. The Fix: Check git reflog immediately to find the hash of the last commit made in the detached state, and create a branch pointing to it.


Command Quick Reference

CommandCategoryDescription
git rev-parse <ref>PlumbingResolves a reference (like HEAD or a branch) to its 40-character SHA-1 hash.
git symbolic-ref <ref>PlumbingReads or writes a symbolic reference like HEAD.
git update-ref <ref> <hash>PlumbingCreates or updates a reference to point to a specific commit hash.
git show-refPlumbingLists all references in the repository along with their hashes.
git reflogPorcelainShows a log of all movements of HEAD and branch references.
git branch <name> <hash>PorcelainCreates a branch pointing to the specified commit hash.

FAQ (People Also Ask)

What is the difference between a branch and a reference in Git?

A branch is a specific type of reference located in the refs/heads/ directory that automatically moves forward when you create a new commit while active on it. A reference is a generic term for any pointer that maps a human-readable name to a Git object hash. This includes branches, tags, remote-tracking refs, and notes.

What is a symbolic reference (symref) in Git?

A symbolic reference is a special reference that points to another reference rather than directly to a commit hash. The most common symbolic reference is HEAD, which points to the active branch (such as refs/heads/main). When Git reads HEAD, it resolves the symbolic link to find the active branch, and then reads that branch to find the commit hash.

How does Git know which branch is currently active?

Git knows which branch is active by reading the .git/HEAD file. If the file contains ref: refs/heads/feature, then the active branch is feature. If you switch branches, Git overwrites the content of .git/HEAD with the path to the new branch.

What happens when a branch is deleted?

When you delete a branch, Git simply removes the text file associated with that branch from .git/refs/heads/ (or deletes its line inside .git/packed-refs). The commits and historical files remain in the object database until they are automatically purged by Git’s garbage collection process.


Test Your Knowledge

Loading quiz…