Inside the .git Folder

Under the friendly commands, Git is a simple key–value database of four kinds of object, plus a few text files that name commits. Seeing how it fits together explains why hashes change when anything changes, why branches are cheap, and why history is so hard to lose.

Four kinds of object

ObjectStores
blobThe contents of one file — just the bytes, no name
treeA folder listing: names, file modes, and the hashes of the blobs and sub-trees inside
commitThe hash of the top-level tree, parent commit(s), author, committer and message
tagAn annotated tag: the tagged object, tagger and message

Every object is stored under the hash of its contents. That's called content addressing: the same content always gets the same ID, and different content always gets a different one.

Walking a commit

git cat-file is the low-level tool for reading objects: -t prints an object's type and -p pretty-prints its contents.

$
git cat-file -t HEAD
git cat-file -p HEAD
Output
commit
tree cdbd7af0cd8148a3ba9eeb8445fafd9c8790fe21
author Sam Rivera <sam@example.com> 1772442840 +0000
committer Sam Rivera <sam@example.com> 1772442840 +0000

Add first recipes

The commit points to a tree. Look inside it:

$
git cat-file -p HEAD^{tree}
Output
040000 tree ef2efd4b37a029b9a2e4f81be2a5769af04a903b	desserts
100644 blob a05992e78c1ae76d90fb5c1f2bc28aa249b17b5a	pancakes.txt

HEAD^{tree} means "the tree of the commit at HEAD". Each line is a mode, a type, a hash and a name: a blob for pancakes.txt and a sub-tree for the desserts folder. Follow the blob to get the file back:

$
git cat-file -p HEAD:pancakes.txt
Output
Pancakes: flour, milk, eggs

So a commit is a tree of trees and blobs — a complete snapshot, exactly as lesson 6 described.

Same content, same blob

git hash-object calculates the ID that some content would have:

$
echo "Pancakes: flour, milk, eggs" | git hash-object --stdin
git rev-parse HEAD:pancakes.txt
Output
a05992e78c1ae76d90fb5c1f2bc28aa249b17b5a
a05992e78c1ae76d90fb5c1f2bc28aa249b17b5a

The same hash: Git identifies content, not files. If ten files, or ten commits, contain identical content, it's stored once. When you change one line of one file, the next commit gets a new blob for that file, a new tree for each folder on its path, and a new commit — and reuses everything else.

It also explains why changing anything in an old commit changes its hash, and therefore the hash of every commit after it (each child includes its parent's hash). This chaining is what makes a Git history tamper-evident.

References are just files

Branches and tags live in .git/refs as tiny text files containing a hash, and HEAD usually contains the name of a branch:

$
cat .git/HEAD
cat .git/refs/heads/main
Output
ref: refs/heads/main
5a3298523474e369db656be2f92915aee0f82d6b

Creating a branch writes one 41-byte file; switching branches rewrites HEAD and updates the working tree. Over time Git may move many refs into a single file, .git/packed-refs, so don't rely on finding every branch as its own file — use git rev-parse or git for-each-ref.

Where objects are stored

$
find .git/objects -type f | sort
Output
.git/objects/5a/3298523474e369db656be2f92915aee0f82d6b
.git/objects/9b/0613831552872188ef26adac8e357a180fab89
.git/objects/a0/5992e78c1ae76d90fb5c1f2bc28aa249b17b5a
.git/objects/cd/bd7af0cd8148a3ba9eeb8445fafd9c8790fe21
.git/objects/ef/2efd4b37a029b9a2e4f81be2a5769af04a903b

New objects are stored as separate compressed files, in a folder named after the first two characters of their hash. Over time, Git packs them into packfiles that store similar objects as differences from each other, which is how a repository with years of history stays small. That happens automatically, or on demand with git gc:

$
git count-objects -v
Output
count: 5
size: 20
in-pack: 0
packs: 0
size-pack: 0
prune-packable: 0
garbage: 0
size-garbage: 0
$
git gc --quiet
git count-objects -v
Output
count: 0
size: 0
in-pack: 5
packs: 1
size-pack: 1
prune-packable: 0
garbage: 0
size-garbage: 0

A tour of .git

PathHolds
HEADThe current branch (or a hash, in detached HEAD)
configThis repository's settings, remotes and upstreams
objects/All blobs, trees, commits and tags
refs/heads/, refs/tags/, refs/remotes/Branches, tags and remote-tracking branches
indexThe staging area (a binary file)
logs/The reflogs (lesson 20)
hooks/Scripts that run on events (lesson 44)
info/excludePersonal ignore rules (lesson 14)

SHA-1 and SHA-256

Git has traditionally used the SHA-1 algorithm for its 40-character hashes. Because SHA-1 is no longer considered strong, Git hardened its implementation against known attacks and added support for SHA-256 repositories (64-character hashes), created with git init --object-format=sha256. Most hosting services still use SHA-1 repositories today, so you'll mostly see 40-character hashes for now.

Try it yourself

In a small repository, start from git cat-file -p HEAD and follow the hashes by hand — commit, tree, sub-tree, blob — until you reach a file's contents. Then predict the hash of a new file with git hash-object before adding it, and check with git ls-files -s after staging.