Most developers use Git every day and understand it as a set of commands: commit, push, pull, merge. That's enough to get by — until it isn't, until you hit a detached HEAD, a confusing rebase conflict, or wonder why creating a new branch is instant even in a repo with 100,000 commits.
The truth is that Git isn't really a version control tool with some clever commands bolted on. Underneath, it's a surprisingly simple key-value data store, and once you see that model, most of Git's "weird" behavior stops being weird.
Git Is a Content-Addressable Filesystem
Strip away the commands and Git is, at its core, a system for storing content and retrieving it by the hash of that content — not by filename, not by path. Every piece of data Git tracks gets hashed (SHA-1, historically, with SHA-256 support arriving in newer versions), and that hash becomes its permanent address inside the .git/objects folder.
This one idea — content-addressable storage — explains an enormous amount of what makes Git fast, safe, and space-efficient.
The Three Object Types That Do Almost Everything
Git builds its entire history out of just a few object types.
Blobs A blob stores the raw content of a file — nothing else, no filename, no permissions, no path. Two files with identical content, even in totally different folders, produce the exact same blob and get stored only once.
Trees A tree is Git's version of a directory listing. It maps names to other objects — blobs for files, or other trees for subdirectories — along with file modes (permissions). A tree is essentially a snapshot of a folder structure at a point in time.
Commits A commit is a small object that points to exactly one tree (the root of the project at that moment), zero or more parent commits (none for the very first commit in a repo, more than one for a merge), and metadata: author, timestamp, and message. Notably, a commit doesn't store a diff — it stores a full snapshot reference. Git just happens to be very good at storing those snapshots efficiently through deduplication and compression.
Seeing It For Yourself
You don't need to take this on faith — you can inspect it directly. In any Git repo:
echo "hello world" > file.txt
git add file.txt
git commit -m "Add file"
# Find the hash Git assigned to your file's content
git rev-parse HEAD:file.txtThat returns 3b18e512dba79e4c8300dd08aeb37f8e728b8dad — a SHA-1 hash. That's not a filename; it's the address of the blob containing "hello world\n". You can go look at it directly:
git cat-file -p 3b18e512dba79e4c8300dd08aeb37f8e728b8dad
# hello worldYou can inspect any object the same way — commits, trees, and blobs are all readable with git cat-file -p <hash>. Peek at your latest commit object and you'll see it's just a tree pointer, a parent pointer, and metadata — nothing more mysterious than that.
Where git add Actually Puts Things
There's a fourth piece we skipped: the staging area, also called the index. It's a single file at .git/index that lists exactly what will go into your next commit.
When you run git add file.txt, Git doesn't touch the object database in any dramatic way — it just writes a blob for file.txt's current content, and records that blob's hash in the index alongside the filename. git commit then builds a tree straight from whatever the index currently contains, not from your working directory.
This explains a few things that confuse newcomers. Stage a file, then edit it again without re-staging, and git status --short will show MM next to it — modified in the index and modified in the working directory, because the two now genuinely disagree. It's also why git diff and git diff --staged show different things: plain git diff compares your working directory against the index, while --staged compares the index against HEAD.
Which raises the obvious follow-up: if commits don't store diffs, how does git diff produce one? By computing it on the fly. Git walks the two trees (or blobs) being compared and generates the diff in that moment. A diff in Git is a view, computed on demand — never a fact stored on disk.
Branches Are Just Pointers
Here's the part that changes how people think about Git: a branch is not a copy of your code. A branch is a plain text file containing a single commit hash.
cat .git/refs/heads/main
# 9fceb02d0ae598e95dc970b74767f19372d61afThat's it. That's the entire branch. When you run git checkout -b feature, Git creates a new, tiny text file pointing at the same commit you're currently on. No files get copied, no history gets duplicated. That's why creating a branch in Git is instantaneous regardless of repo size — you're writing a 40-character string to a file, not cloning a project.
HEAD works the same way, one level up: it's a pointer to whichever branch you currently have checked out (or, in a "detached HEAD" state, directly to a commit). That's the entire mystery behind detached HEAD — you've pointed HEAD straight at a commit instead of at a branch, so new commits won't move any branch pointer forward. They just float, unreferenced, until something points at them again, or Git eventually garbage-collects them.
The Whole Picture
flowchart TD
Branch["refs/heads/main"] --> C2["Commit (tree + parent + message)"]
C2 -->|parent| C1["Commit (tree, no parent)"]
C2 --> T2[Tree]
T2 --> B1["Blob: file.txt"]
T2 --> B2["Blob: readme.md"]
C1 --> T1[Tree]
A branch points to a commit. A commit points to a tree and its parent commit. A tree points to blobs and other trees. Everything below the branch pointer is immutable and content-addressed; only the pointer itself ever moves.
Merging, Rebasing, and Why History Changes
Once you see commits as immutable, content-addressed snapshots, a few common confusions resolve themselves:
A merge commit is just a normal commit object with two parents instead of one.
Rebasing rewrites history because a commit's hash is derived from its content, including its parent. Change the parent, and you get a brand-new commit with a brand-new hash, even if the code is identical. This is why force-pushing after a rebase is necessary, and why rebasing shared branches causes pain: you're not editing old commits, you're creating new ones and abandoning the old ones.
Fast-forward merges happen when your branch pointer can simply slide forward along an existing line of commits — no new commit needed at all.
Why This Actually Matters
Understanding Git's object model turns a lot of "just memorize the commands" situations into things you can reason about:
Recovering "lost" commits. Because objects stick around until garbage collected,
git reflogandgit fsck --lost-foundcan often recover commits that no branch points to anymore.Understanding
.gitfolder size. Since identical content is stored once, Git repos are often far smaller than you'd expect, even with deep history.Debugging confusing merge/rebase states. Once you know a commit is just a snapshot plus parent pointers, conflicts stop feeling arbitrary — you can trace exactly which snapshots are being compared.
The Takeaway
Git's command-line interface makes it look like a tool for tracking changes and diffs. Underneath, it's closer to a simple, elegant database: content-addressed objects (blobs, trees, commits) and lightweight pointers (branches, HEAD, tags) that reference them. Every confusing Git moment — a detached HEAD, a surprising rebase, an "impossible" merge conflict — gets easier to debug once you stop thinking in commands and start thinking in objects and pointers.
Next time a coworker asks what a branch actually is, you'll know: it's a 40-character string in a text file, pointing at a snapshot of your code.
Comments (2)
Login to post a comment.
Igor Ganapolsky
Good walkthrough — the "commit stores a snapshot, not a diff" point is the one that unlocks everything else. Two things that made this click for people on my team: (1) `git cat-file -p HEAD` then follow the tree hashes down by hand once — seeing a tree literally list `blob <sha> file.txt` beats any diagram; (2) `git hash-object -w` a file you haven't added, then note the object appears in .git/objects even with a clean `git status` — that's what makes garbage collection and "lost" commits after a hard reset make sense (they're still reachable via `git reflog` until gc prunes them). Worth adding a line on packfiles too: loose objects explain the model, but after `git gc` your objects live in a packfile with delta compression, which is why people who go looking in .git/objects on a real repo find almost nothing there and think the article lied to them.