Git's Secret Life: Peeking Under the Hood
A File System That Thinks in Hashes
Picture this. You've just finished a feature. You type `git commit -m "Add user authentication"`, hit Enter, see a comforting little message like `[main a1b2c3d]`, and move on with your life. But what actually happened in that fraction of a second? Where did your code go? What *is* that `a1b2c3d` string? Why does Git seem to work like magic sometimes and like a vindictive sorcerer other times?
Here's the truth most intermediate developers never learn: Git isn't really a version control system. It's a content-addressable filesystem with a version control system built on top. Once you understand that single insight, every Git mystery from "why is my disk full after a few commits" to "how did they recover that deleted branch from 2019" becomes suddenly, satisfyingly obvious.
So before we get into branching strategies, merge conflicts, or team workflows, we're going to do something slightly subversive. We're going to ignore almost every Git command you already know. We're going to delete our mental model of "files" and "folders" and replace it with something stranger and more powerful: a database where every piece of content has a name, and that name is derived from the content itself.
Stick with me. By the end of this chapter, you'll be able to take a fresh directory, build a Git commit from raw bytes using only low-level commands, and explain exactly what `git commit` is doing when it reports back to you. This isn't academic. This is the foundation that turns Git from something you fight into something you *think with*.
---
The Three Object Types That Run the World
Open any Git repository on Earth, from a five-line hobby script to the Linux kernel, and you'll find the same four building blocks underneath. Three of them are the load-bearing walls; the fourth we'll meet properly in the next chapter.
Blob: The Humble File
A blob (binary large object) is Git's word for "the contents of a file, nothing more, nothing less." Notice what's missing from that definition: the filename. The path. The permissions. The timestamps. None of that. A blob is just bytes.
When you save a file with the text `hello world` in it and ask Git to track it, Git doesn't store "there's a file called greeting.txt in the root containing 'hello world'." It stores the bytes `hello world` and gives them a unique address. Later, when it needs to reconstruct the file, it consults higher-level objects to remember the name and location.
This is genuinely weird if you're used to thinking of files as having identity through their names. In Git's universe, two files with identical content have the *same identity*. Move a file from one folder to another? Git sees that as deleting the old one and creating a new one, because the path changed even though the bytes didn't. This isn't a bug. It's the design, and it's why Git can deduplicate aggressively across your entire history.
Tree: The Directory Listing
If blobs are the leaves, trees are the branches. A tree object represents a directory at a specific moment, and it contains a list of entries. Each entry points to either another tree (a subdirectory) or a blob (a file), along with metadata about permissions and the entry's name.
Think of a tree like the contents of a folder as you'd see it in a file manager, but frozen in time and identified by a hash. When you commit, Git doesn't just save your files; it builds a tree representing your entire project structure, top to bottom. That tree becomes the snapshot of your repository at that moment.
Here's a subtle but important point: trees reference other objects by their hash, not by path. So when you have a tree that points to blob `a3f8...`, that blob might be named "config.yml" today and "configuration.yml" tomorrow, but the underlying content stays the same. Git can detect this instantly because the hash hasn't changed.
Commit: The Snapshot, Plus the Story
A commit is where it all comes together. A commit object points to exactly one tree (representing the state of your project at that moment) and carries metadata: who made it, when, why (the message), and crucially, *which commit came before*.
That last bit, the parent commit, is what turns a sequence of snapshots into a history. Without parents, your commits would be a pile of independent photos. With parents, they're a timeline.
A commit object looks something like this, conceptually:
``` tree 4d5e6f... parent a1b2c3d... author Jane Developer <jane@example.com> 1700000000 +0000 committer Jane Developer <jane@example.com> 1700000000 +0000
Add user authentication ```
Notice what a commit *isn't*. It isn't a diff. It isn't a list of changes. It's a complete snapshot, identified by content, pointing back in time. This is one of Git's most counterintuitive design choices and one of its greatest strengths. We can come back to why later.
Tags: A Quick Mention
The fourth object type, the tag, is mostly a marker. It points to a commit and adds metadata like who tagged it and a message. Annotated tags are full objects; lightweight tags are just pointers. We won't dwell on tags here because Chapter 8 is dedicated to them, but you should know they exist so the mental model is complete.
So: blobs hold content, trees hold structure, commits hold snapshots and history, and tags mark moments. That's the entire universe.
---
SHA: The Fingerprint That Never Lies
Every object in Git has an address, and that address is a cryptographic hash of the object's contents. By default, Git uses SHA-1, which produces a 40-character hex string like `a1b2c3d4e5f6789...`. As of recent versions, Git is transitioning to SHA-256 for improved security, which produces a 64-character string. For our purposes, the algorithm doesn't matter much; what matters is the *property*.
The property is this: change a single byte anywhere in the object's content, and you get a completely different hash. This isn't approximate. It's not "mostly different." A single space character added to a file changes the hash from one end to the other. This makes Git's database tamper-evident in a mathematical sense. If you have two hashes and they don't match, the content *cannot* match. Period.
This has profound practical consequences. Suppose you push a commit to a colleague, and they fetch it. Their Git computes the hash of what they received and compares it to the hash you claimed it should have. If even one byte was corrupted in transit, the hashes won't match, and Git will refuse to pretend the commit is valid. This is how you know Git's integrity isn't a marketing promise; it's a mathematical guarantee.
Here's another consequence that pays off daily. Because hashes are derived from content, two blobs with identical bytes have the same hash. That means if you create ten commits in a row that don't change a particular file, Git stores that file's blob exactly once. Move that file across branches, across forks, across years? Same blob, same hash, zero duplication. This is why even the Linux kernel, with millions of lines and decades of history, fits comfortably on your laptop.
The immutability of hashes also means Git's history is, in a real sense, immutable. You can't secretly modify a commit's contents without the hash changing, which would invalidate every reference to that commit. That's why the only way to "change history" is to create new commits with new hashes that supersede the old ones. We'll dive deep into this when we discuss rewriting history in Chapter 12.
There's a historical footnote worth knowing. SHA-1 has known theoretical weaknesses; while nobody has yet demonstrated a practical collision attack against Git, the Git project has been transitioning to SHA-256 for several years. As of Git 2.42 and later, you can initialize repositories with `--object-format=sha256`, and the infrastructure is in place for a smooth transition. If you're starting a greenfield project to