The Git Mental Model: Understanding What Happens Under the Hood
The Git Mental Model: Understanding What Happens Under the Hood
The first time most developers encounter Git, they learn it as a tool for saving snapshots of their work and sharing code with others. This surface-level understanding suffices for basic version control tasks—commit your changes, push to a remote, pull down updates. But as projects grow in complexity and teams expand, the limitations of this mental model become painfully apparent. Merge conflicts seem mysterious, rebasing appears dangerous, and the advice to "never rewrite history on shared branches" feels like an arbitrary rule rather than a reasoned principle.
The root cause of this confusion is a fundamental disconnect between what users perceive and what actually happens inside Git's data structures. Git is not merely a tool for tracking file changes; it is a content-addressable filesystem with an extraordinarily sophisticated graph structure at its core. Understanding this internal architecture transforms Git from an opaque black box into a predictable, manipulable system where every operation has a clear meaning and predictable consequences.
This chapter establishes the conceptual foundation you need to master Git's advanced capabilities. We will explore Git's object model in detail, examining how commits, trees, blobs, and tags form an interconnected directed acyclic graph. You will understand precisely how Git generates unique identifiers for content and why this design choice makes Git both remarkably reliable and surprisingly efficient. By the chapter's end, you will possess a visual mental model of Git's internals that will guide your decision-making in all subsequent chapters.
The Building Blocks: Git's Four Fundamental Objects
At the heart of Git's architecture lie four primitive object types that together represent every piece of information the system manages. Understanding these objects—and their relationships to one another—is the single most important concept in this entire book. Everything else in Git, from simple commits to complex branching strategies, derives from how these four objects interact.
Blobs: The Content of Files
The simplest object in Git's universe is the blob (short for binary large object). A blob contains the raw contents of a file and nothing else—no filename, no permissions, no metadata. This design choice might seem peculiar at first. If blobs don't store filenames, how does Git know what files exist in your project?
The answer lies in how blobs relate to other objects. A blob is identified exclusively by the cryptographic hash of its contents. This means that if two files in different directories contain identical content, Git stores only one blob. The deduplication happens automatically and invisibly. Consider a scenario where your project contains two copies of a configuration file in different subdirectories—perhaps a development and production variant. If these files happen to share identical content at some point, they reference the same blob, consuming only the storage space of a single file.
```bash
Create two files with identical content
echo "database_url=localhost" > config-dev.txt echo "database_url=localhost" > config-prod.txt
Add both to Git
git add config-dev.txt config-prod.txt
Notice that both point to the same blob
The hash will be identical for both files
git ls-files --stage ```
This deduplication strategy has profound implications for Git's performance and storage efficiency. A repository containing multiple branches that modify the same file extensively will not multiply storage requirements proportionally to branch count. Only the deltas between content states need be considered conceptually (though Git's actual packing mechanisms optimize further).
Trees: Directory Layout and Metadata
If blobs lack filenames, then somewhere Git must record the mapping between content and names. This responsibility falls to tree objects. A tree is essentially a directory listing that maps names to either blobs (for files) or other trees (for subdirectories), along with metadata including file permissions and file types.
Each tree entry contains three pieces of information: a mode (permissions), a name (filename or directory name), and a reference to either a blob or another tree. The mode follows Unix conventions—`100644` for regular files, `100755` for executable files, `040000` for directories. This metadata is preserved because it matters: executable scripts must remain executable, and the distinction between files and directories is fundamental to directory structure.
```bash
Examining a tree object
git cat-file -p HEAD^{tree}
Output might look like:
100644 blob a1b2c3d4e5f6... README.md
100755 blob e7f8... script.sh
040000 tree f9a0... src
040000 tree b3c1... tests
```
When you examine a tree object directly, you see this structure laid bare. The hexadecimal strings following the modes are the SHA-1 hashes of the referenced objects—either blobs for files or trees for subdirectories. This recursive structure mirrors your actual directory hierarchy, but with a crucial difference: each tree object is immutable and content-addressed. Change a single character in any file, and the blob hash changes. That change propagates upward through every tree that references it, ultimately affecting the commit object that points to the root tree.
Commits: Snapshots with Context
If trees represent complete directory snapshots and blobs represent file contents, what makes version control meaningful? The answer is commit objects. A commit combines a snapshot (the tree) with additional metadata that provides context: who created it, when, a message describing the change, and crucially, references to parent commits.
```bash
Inspect a commit object
git cat-file -p HEAD
Typical output:
tree f4b8c2e1d9a7...
parent 3a1b4c5d6e8f... (if this isn't an initial commit)
author Jane Developer <jane@example.com> 1699574400 -0800
committer Jane Developer <jane@example.com> 1699574400 -0800
#
Implement user authentication module
#
- Add password hashing with bcrypt
- Create login/logout endpoints
- Add session management middleware
```
The parent reference(s) is what transforms a linear sequence of snapshots into a version history. A typical commit has one parent—the commit that preceded it in the current branch. Merge commits, which combine changes from multiple branches, have multiple parents. The initial commit of a repository has no parents.
This design means that every commit implicitly encodes its entire ancestry. Starting from any commit and following parent references backward, you can reconstruct the complete chain of changes that led to that state. Git's history is not a list of diffs; it is a graph of complete snapshots, where each node knows how to reach its predecessors.
Tags: Permanent References to Commits
The fourth and final primitive object type is the tag. Tags come in two flavors: lightweight and annotated. A lightweight tag is simply a reference—a named pointer to a commit, stored as a file in `.git/refs/tags/`. An annotated tag is a full object that contains additional metadata: a tagger name, email, date, message, and crucially, a digital signature.
```bash
Lightweight tag (just a reference)
git tag v1.0.0
Annotated tag (full object)
git tag -a v1.0.0 -m "Release version 1.0.0"
Inspect the difference
git cat-file -t v1.0.0 # Shows 'tag' for annotated, 'commit' for lightweight git cat-file -p v1.0.0 # Shows the tag object contents ```
Annotated tags are important for release management because they carry additional context about why a particular point in history was marked. They can be signed with GPG, providing cryptographic verification that the tag originated from a specific person. Many continuous deployment pipelines rely on annotated tags to identify release points precisely.