Git Branch: Under the Hood of Git’s Lightweight Pointer System

TL;DR: In Git, a branch is not a folder copy or a heavy snapshot of your source files. It is simply a lightweight, mutable reference—a 41-byte text file containing a 40-character commit SHA-1 hash and a newline character—stored inside the .git/refs/heads/ directory. Creating, renaming, or deleting branches in Git is practically instantaneous because it only alters these reference files, rather than modifying or copying the files in your working directory.


What Does git branch Actually Do?

To appreciate why Git’s branching model is so revolutionary, it helps to contrast it with older Version Control Systems (VCS) like Subversion (SVN) or Perforce. In those legacy systems, creating a branch meant making a physical copy of the entire project directory tree. If your codebase was 5 GB, creating a branch meant copying 5 GB of files on the server. This operation took significant time, wasted disk space, and discouraged developers from creating branches for small tasks.

Git does not copy files. Instead, Git organizes your project history as a Directed Acyclic Graph (DAG) of commit objects. Every commit points to its parent commit(s), creating a tree-like structure of history. In this graph:

  • A Commit represents a snapshot of your project’s tracked files at a specific point in time, along with author metadata and parent links. See /git-commit for details on commit object structures.
  • A Branch is simply a named reference pointing to one specific commit in this graph. It is a starting point from which Git can traverse backward through parent pointers to construct the history of that branch.

Think of a branch as a sticky note attached to a specific node in a chain of commits. As you work and create new commits on the active branch, Git automatically moves the sticky note forward to point to the latest commit. Because a branch is just a reference (or “ref”), Git branching operations only require writing or modifying a small 40-character string.

For a deeper look into the general reference system of Git, read our guide on /git-refs.


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

To fully grasp the mechanics of Git branches, let us perform a step-by-step terminal walkthrough and observe how the .git/ directory changes during branching operations.

Step 1: Initializing a Clean Repository

Let us create a new repository, add a commit, and look at the structure of the .git/ directory.

$ mkdir git-branch-demo
$ cd git-branch-demo
$ git init
Initialized empty Git repository in /users/admin/git-branch-demo/.git/

At this stage, if we list the contents of .git/refs/heads/, we will find it completely empty:

$ ls -la .git/refs/heads/
total 0
drwxr-xr-x  2 admin  staff  64 Jul  7 10:25 .
drwxr-xr-x  4 admin  staff 128 Jul  7 10:25 ..

There is no default branch file created yet because no commits exist. Let’s see what .git/HEAD points to:

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

Even though refs/heads/main does not exist on disk yet, HEAD is pre-configured to point to it. This state is known as an “unborn branch.” Let us create our first commit to bring the branch into existence.

$ echo "Hello Git Branching!" > README.md
$ git add README.md
$ git commit -m "Initial commit"
[main (root-commit) f24e93e] Initial commit
 1 file changed, 1 insertion(+)
 create mode 100644 README.md

Now that a commit exists, let’s inspect .git/refs/heads/ again:

$ ls -la .git/refs/heads/
total 8
drwxr-xr-x  3 admin  staff   96 Jul  7 10:27 .
drwxr-xr-x  4 admin  staff  128 Jul  7 10:25 ..
-rw-r--r--  1 admin  staff   41 Jul  7 10:27 main

A file named main has appeared! Let’s print the contents of this file:

$ cat .git/refs/heads/main
f24e93e25b90f46c6a7e0c4a45adbb474f8e6c7c

The file contains exactly a 40-character SHA-1 commit hash followed by a newline character. Let’s verify what this hash points to using the plumbing command git cat-file:

$ git cat-file -t f24e93e25b90f46c6a7e0c4a45adbb474f8e6c7c
commit

And let’s print the contents of the commit object to see how it represents our snapshot:

$ git cat-file -p f24e93e25b90f46c6a7e0c4a45adbb474f8e6c7c
tree 5b38a7c29375ab2b4a53239a584a22c5432a138b
author User <user@example.com> 1783420000 +0000
committer User <user@example.com> 1783420000 +0000

Initial commit

Step 2: Creating a New Branch

Now, let’s create a new branch called feature-login using the git branch command:

$ git branch feature-login

Let’s check the contents of .git/refs/heads/ to see what Git did under the hood:

$ ls -la .git/refs/heads/
total 16
drwxr-xr-x  4 admin  staff  128 Jul  7 10:30 .
drwxr-xr-x  4 admin  staff  128 Jul  7 10:25 ..
-rw-r--r--  1 admin  staff   41 Jul  7 10:30 feature-login
-rw-r--r--  1 admin  staff   41 Jul  7 10:27 main

A new file named feature-login has been created. Let’s read its content:

$ cat .git/refs/heads/feature-login
f24e93e25b90f46c6a7e0c4a45adbb474f8e6c7c

It contains the exact same commit hash as main! Git didn’t copy any source files. It simply created a 41-byte text file pointing to the existing commit. This explains why branch creation is instantaneous.

Let’s check our active branch by reading .git/HEAD:

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

HEAD still points to main, meaning we are still on the main branch. To switch branches, we must update the HEAD reference, which is typically handled by commands like git checkout or git switch. Read more about this mechanism in /git-checkout.

Step 3: Committing on a Branch

Let’s switch to our new branch and make a change.

$ git checkout feature-login
Switched to branch 'feature-login'

Now let’s verify .git/HEAD:

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

HEAD has been updated to point to refs/heads/feature-login. Let’s create a new commit:

$ echo "Login Form UI" > login.html
$ git add login.html
$ git commit -m "Add login page"
[feature-login a7c8e9d] Add login page
 1 file changed, 1 insertion(+)
 create mode 100644 login.html

Let’s check the contents of our branch references now:

$ cat .git/refs/heads/feature-login
a7c8e9d8928424ef245eefb925b42d3851b2c129

$ cat .git/refs/heads/main
f24e93e25b90f46c6a7e0c4a45adbb474f8e6c7c

feature-login has moved forward to point to our new commit a7c8e9d.... However, main remains pointing to f24e93e.... If we check the history using /git-log:

$ git log --oneline --graph --all
* a7c8e9d (HEAD -> feature-login) Add login page
* f24e93e (main) Initial commit

The graph shows that feature-login has diverged. The main branch pointer is static, while feature-login moved along with HEAD. If we decide to merge these branches later, Git will read these pointers to compute their histories. Learn about this in /git-merge.


Detailed Command Options & Advanced Usage

The git branch command is not just for creating references; it is a versatile tool for managing, querying, and auditing the branch graph.

1. Listing and Filtering Branches

When managing large projects, you will often find yourself with dozens of local and remote branches. Git provides filters to keep this organized.

  • List all branches (local and remote):
    $ git branch -a
    This shows local branches in one color (e.g., green) and remote-tracking branches (stored in .git/refs/remotes/) in another (e.g., red).
  • Filter by merge status: Before cleaning up old branches, you want to know which branches have been safely merged into your current branch:
    $ git branch --merged
    To see branches that contain work not yet merged into your current branch:
    $ git branch --no-merged
    Running git branch -d on any branch in the --no-merged list will fail unless forced.
  • Verbose listing with upstream tracking:
    $ git branch -vv
    This shows the latest commit hash, the commit subject line, and the upstream tracking status (e.g., whether your local branch is ahead or behind its remote counterpart).

2. Deletion Internals and Garbage Collection

Deleting a branch is a common task, but it works differently depending on the flags used.

  • Safe Delete (-d or --delete):
    $ git branch -d feature-login
    Git will prevent you from deleting a branch if it contains commits that are not reachable from your current branch or upstream branch. This prevents accidental loss of work.
  • Force Delete (-D or -d --force):
    $ git branch -D feature-login
    This tells Git: “Delete the reference, no matter what.”

What happens to the commits?

Under the hood, deleting a branch only deletes the file .git/refs/heads/feature-login. The commit objects themselves (e.g., a7c8e9d...) are not immediately deleted from .git/objects/. They become “dangling” or orphaned commits because there is no branch or tag pointing to them. They will remain in the database until they are pruned by Git’s automatic Garbage Collection (git gc) or expire from the reflog (usually 30 to 90 days).

3. Track Upstream Branches

When collaborating, your local branches track branches on a remote server (like GitHub). This tracking configuration is stored in .git/config.

To set an upstream branch for your current active branch:

$ git branch -u origin/feature-login
# Or:
$ git branch --set-upstream-to=origin/feature-login

This modifies your .git/config file to include tracking metadata:

[branch "feature-login"]
    remote = origin
    merge = refs/heads/feature-login

This configuration is what allows you to simply type git pull or git push without specifying the remote and branch name.


Real-World Scenario: Recovering a Accidentally Deleted Branch

Imagine you are cleaning up your workspace. You think you’ve merged your branch feature-payment into main, so you run:

$ git branch -D feature-payment
Deleted branch feature-payment (was c3b91a2).

Suddenly, you realize that feature-payment was not merged, and you just deleted days of work! Because the files are no longer visible in your editor, it looks like a disaster.

How to Recover It:

Since Git branches are only references, the commits themselves are still in the database. You just need to find the commit hash and attach a new branch reference to it.

  1. Search the Reflog: The reflog records every time HEAD changes position. Run:
    $ git reflog
    f24e93e HEAD@{0}: checkout: moving from feature-payment to main
    c3b91a2 HEAD@{1}: commit: Add Stripe Integration and webhooks
    f24e93e HEAD@{2}: checkout: moving from main to feature-payment
    Looking closely, we see that c3b91a2 was the commit we made on the feature-payment branch before switching back to main.
  2. Recreate the Branch Pointer: Now, run git branch followed by the new branch name and the hash you recovered:
    $ git branch feature-payment c3b91a2
    Voila! Git creates a new reference file at .git/refs/heads/feature-payment containing c3b91a2, restoring your work instantly.

Common Pitfalls and Mistakes

1. Thinking git branch Switches to the New Branch

Running git branch new-feature only creates the pointer. It does not update HEAD. If you start editing files immediately, you will write commits to your old branch (e.g., main). Always remember to run git checkout new-feature (or use git checkout -b new-feature to create and switch in one step).

2. Namespace Collisions (File vs Directory)

Because Git stores branch refs as files in .git/refs/heads/, they must follow your Operating System’s file path rules.

  • If you have a branch named feature, Git creates a file named .git/refs/heads/feature.
  • If you then try to create a branch named feature/login, Git must create a directory named feature and place a file login inside it.
  • On most systems, you cannot have a file and a directory with the exact same name in the same parent folder. This results in the following error: error: cannot lock ref 'refs/heads/feature/login': 'refs/heads/feature' exists; cannot create 'refs/heads/feature/login'
  • Fix: Avoid creating a generic branch named feature if you plan to use slash namespaces like feature/login and feature/payment.

3. Stale Local References to Deleted Remote Branches

When a teammate deletes a branch on the remote server, your local Git still keeps track of it in .git/refs/remotes/origin/. This can lead to confusion.

  • Fix: Clean up stale remote-tracking branches by running:
    $ git fetch --prune

Command Quick Reference

CommandDescriptionGit Internals Action
git branchLists all local branchesReads files inside .git/refs/heads/
git branch <name>Creates a new branch referenceCreates a 41-byte file at .git/refs/heads/<name>
git branch -d <name>Safely deletes a branch if mergedDeletes the ref file; checks reachability first
git branch -D <name>Force deletes a branchDeletes the ref file regardless of reachability
git branch -m <new-name>Renames current branchRenames the ref file in .git/refs/heads/
git branch -aLists local and remote branchesReads .git/refs/heads/ and .git/refs/remotes/
git branch -vvShows tracking status and commitsCorrelates local ref files with upstream settings

FAQ (People Also Ask)

What does “detached HEAD” mean?

A “detached HEAD” state occurs when HEAD points directly to a specific commit hash rather than a named branch reference. For example, if .git/HEAD contains f24e93e25b90f46c6a7e0c4a45adbb474f8e6c7c instead of ref: refs/heads/main. If you commit in this state, those commits will not belong to any branch. Once you switch away, they will become orphaned and vulnerable to garbage collection. To save them, you must create a new branch at that commit by running git branch <new-branch-name>.

Can two branches point to the same commit?

Yes. Multiple branches can point to the exact same commit hash. In fact, when you create a new branch from main, both branches point to the same commit until you create a new commit on one of them. Git has no issue with multiple refs containing the same 40-character SHA-1 string.

How do I push a local branch to a remote server?

To push your branch to a remote repository and set it up to track that remote, use the command:

$ git push -u origin <branch-name>

The -u (or --set-upstream) flag tells Git to create a tracking association, updating your .git/config file so that future pushes and pulls on this branch require only git push or git pull.


Test Your Knowledge

Loading quiz…