The layout of a Git repository, explained

Understanding what lives inside .git is one of the most direct ways to demystify how Git actually works. Here's a practical tour of the key components, starting with the five objects that form the core of every repository.

HEAD and branches: where commits point

HEAD is a tiny file that simply names your current branch. Its contents look something like:

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

If HEAD instead contains a raw commit ID, you're in "detached HEAD state."

A branch is similarly small: a file under .git/refs/heads/ holding exactly one commit ID. For example, .git/refs/heads/main might contain:

$ cat .git/refs/heads/main
1093da429f08e0e54cdc2b31526159e745d98ce0

Both branches and tags use the same "ref" format — a plain file with a commit hash — but Git treats them very differently. Creating new commits updates branches but not tags. A lightweight tag is just a fixed pointer in .git/refs/tags/:

$ cat .git/refs/tags/v1.0
1093da429f08e0e54cdc2b31526159e745d98ce0

The object store: commits, trees, and blobs

Git's real data lives under .git/objects/, where content is stored as compressed, content-addressed files. A commit is one such object. It records its parent commit(s), a message, the author, and a pointer to a tree. To read these compressed objects, use git cat-file -p HASH. A typical commit looks like:

$ git cat-file -p 1093da429f08e0e54cdc2b31526159e745d98ce0
tree 9f83ee7550919867e9219a75c23624c92ab5bd83
parent 33a0481b440426f0268c613d036b820bc064cdea
author Julia Evans <[email protected]> 1706120622 -0500
committer Julia Evans <[email protected]> 1706120622 -0500

add hello.py

A tree object is a directory listing. It maps file names to blobs (which hold the actual file content) and to sub-trees for subdirectories:

$  git cat-file -p 9f83ee7550919867e9219a75c23624c92ab5bd83
100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391	.gitignore
100644 blob 665c637a360874ce43bf74018768a96d2d4d219a	hello.py
040000 tree 24420a1530b1f4ec20ddb14c76df8c78c48f76a6	lib

The permission numbers in tree entries resemble Unix permissions, but they're actually far more restrictive — only 644 (files) and 755 (executables and directories) are permitted. Blobs themselves are simple content files:

$ git cat-file -p 665c637a360874ce43bf74018768a96d2d4d219a	
print("hello world!")

Because every change can generate a new blob, Git periodically runs git gc to pack objects efficiently under .git/objects/pack.

References beyond branches: remotes, tags, and the stash

Remote-tracking branches live in .git/refs/remotes/origin/ and hold the last commit ID Git saw for a branch on a remote. When git status says you're "up to date with origin/main," it's consulting this file — which can be stale until you run git fetch origin main.

The stash is a reference stored in .git/refs/stash. Running git stash creates a commit whose ID is written there:

cat .git/refs/stash
62caf3d918112d54bcfa24f3c78a94c224283a78

Like other refs, the stash's history is kept in a reflog — here at .git/logs/refs/stash:

cat .git/logs/refs/stash
62caf3d9 e85c950f Julia Evans <[email protected]> 1706290652 -0500	WIP on main: 1093da4 add hello.py
00000000 62caf3d9 Julia Evans <[email protected]> 1706290668 -0500	WIP on main: 1093da4 add hello.py

The stash behaves differently from other refs, though. Popping a stash commit removes it from the reflog, making it nearly impossible to recover. While branch reflog entries expire after about 90 days, stash entries can be deleted almost immediately after creation.

Reflogs: tracking reference changes

For nearly every file under .git/refs, there's a corresponding log under .git/logs/refs/. The reflog for main tracks every movement of that branch:

$ tail -n 1 .git/logs/refs/heads/main
33a0481b440426f0268c613d036b820bc064cdea
1093da429f08e0e54cdc2b31526159e745d98ce0
Julia Evans <[email protected]>
1706119866 -0500
commit: add hello.py

Each line records the before/after commit IDs, the user, a timestamp, and a log message. They're normally single-line entries, though they wrap for readability here.

Repository configuration and hooks

Local repository settings — including your configured remotes — are stored in .git/config. Global settings live separately in ~/.gitconfig:

[remote "origin"] 
url = [email protected]: jvns/int-exposed 
fetch = +refs/heads/*: refs/remotes/origin/* 
[branch "main"] 
remote = origin 
merge refs/heads/main

Hooks are optional scripts that Git runs at predefined points in a workflow, such as before a commit. They live in .git/hooks/, named for their trigger (e.g., pre-commit):

#!/bin/bash 
any-commands-you-want

The staging area

The index — Git's staging area — is stored as a binary file at .git/index, unlike most refs and objects which are near-plain-text. The most readable way to inspect it is git ls-files --stage:

$ git ls-files --stage
100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0	.gitignore
100644 665c637a360874ce43bf74018768a96d2d4d219a 0	hello.py
100644 e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 0	lib/empty.py

What this tour doesn't cover

A few things in .git were deliberately left out (like FETCH_HEAD, worktrees, and the info directory) because they're less useful for building intuition.

More importantly, knowing the file layout is not a complete Git education. The mechanics of merges and rebases, how remotes actually exchange objects during push and pull, and how to resolve conflicts all require deeper understanding than a directory map can provide.

Further reading