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
| Object | Stores |
|---|---|
| blob | The contents of one file — just the bytes, no name |
| tree | A folder listing: names, file modes, and the hashes of the blobs and sub-trees inside |
| commit | The hash of the top-level tree, parent commit(s), author, committer and message |
| tag | An 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
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}
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
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
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
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
.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
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
count: 0 size: 0 in-pack: 5 packs: 1 size-pack: 1 prune-packable: 0 garbage: 0 size-garbage: 0
A tour of .git
| Path | Holds |
|---|---|
HEAD | The current branch (or a hash, in detached HEAD) |
config | This 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 |
index | The staging area (a binary file) |
logs/ | The reflogs (lesson 20) |
hooks/ | Scripts that run on events (lesson 44) |
info/exclude | Personal 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.