How Git Works (Snapshots, Not Diffs)
Every time you run git commit, most beginners imagine Git quietly recording “what changed” — a diff, like a patch file. That mental model is wrong, and it causes real confusion later on: about branching, about why git log -p can show you a diff even though none is stored, about why copying a file costs almost nothing. Git actually records a complete snapshot of your entire project at every commit, and it uses content hashing to make that cheap. This lesson explains exactly what Git stores on disk, how a branch is really just a pointer, and why understanding this model makes every other Git command make more sense.
Overview: How Git Actually Stores Your Project
Git’s storage is built on a few object types, all stored as content-addressed files inside .git/objects:
- Blob — the raw contents of a single file (no filename, no metadata — just bytes).
- Tree — a snapshot of a directory: a list of entries, each pointing to a blob (a file) or another tree (a subdirectory), along with the filename and file mode for that entry.
- Commit — a snapshot record: a pointer to one root tree (the state of the entire project at that moment), a pointer to the parent commit(s), the author, the committer, a timestamp, and the commit message.
- Tag (annotated tags) — a named, often signed pointer to a commit, typically used for releases.
Every one of these objects is identified by the SHA-1 hash of its own content — not by filename, not by timestamp, purely by what’s inside it. This is the key insight behind “snapshots, not diffs”: when you commit, Git doesn’t compute a delta against the previous commit. It builds a new tree object for every directory that changed, reuses (by hash) the tree and blob objects for anything that didn’t change, and writes one new commit object pointing at the resulting root tree. The “diff” you see in git log -p or git diff is computed on the fly by comparing two snapshots — it is a presentation, never a stored artifact.
Because objects are addressed by content hash, identical content is automatically deduplicated: two files with the same bytes anywhere in your project’s history — even in totally unrelated commits — point to the exact same blob object on disk. This is also why renaming a file costs Git nothing extra: a blob’s hash doesn’t depend on its filename, only the tree entry (which does hold the filename) changes.
The Index (Staging Area)
Between your working tree and a commit sits the index (also called the staging area), stored in .git/index. It’s a snapshot-in-progress: a list of paths, each pointing to the blob that path will have in the next commit. git add reads a file from your working tree, hashes its content into a blob object, and updates the index entry for that path to point at the new blob. git commit doesn’t look at your working tree at all — it builds the new commit’s tree entirely from what’s currently in the index. This is precisely why edits you haven’t git add-ed don’t show up in your commit: the index, not the working tree, defines the next snapshot.
Branches, HEAD, and Detached HEAD
A branch is nothing more than a 40-character SHA-1 hash saved in a small text file under .git/refs/heads/. main is just the conventional name for one such file; there’s nothing structurally special about it. “Creating a branch” writes a new tiny file; “committing on a branch” overwrites the 40 bytes in that file with the new commit’s hash. That’s why branching and committing in Git are so fast compared to systems that copy entire file trees.
HEAD, stored in .git/HEAD, normally contains a symbolic reference like ref: refs/heads/main — it points at a branch, and the branch points at a commit. When you run git switch or git checkout to a branch name, Git updates this symbolic reference and rewrites your working tree and index to match that branch’s commit snapshot. If you instead check out a specific commit hash (or a tag) directly, Git can’t attach that to a branch, so it writes the commit hash straight into .git/HEAD — this is detached HEAD. You can still look around and even commit from there, but since no branch pointer is following you, those commits are only reachable through HEAD itself; switch to another branch and they become effectively orphaned (recoverable for a while via git reflog, then eventually garbage collected).
Loose Objects, Packfiles, and Why This Doesn’t Waste Disk Space
Storing a full tree snapshot per commit sounds wasteful, but it isn’t in practice. First, unchanged files across commits reuse the exact same blob by hash — no duplication. Second, Git periodically runs housekeeping (git gc, automatically or manually) that repacks loose objects into packfiles, which do apply delta compression between similar objects for storage efficiency. This is purely a disk-space optimization layered on top of the model — conceptually, and from every command’s point of view, each commit is still a full, independent snapshot of the whole project.
Syntax
You don’t need special commands to look inside Git’s object database — the same low-level “plumbing” commands Git itself uses are available to you:
git cat-file -p "<object>"
git cat-file -t "<object>"
git cat-file -s "<object>"
| Form | What it does |
|---|---|
git cat-file -p <object> |
Pretty-prints the content of any object — a blob’s file contents, a tree’s entry list, or a commit’s metadata. <object> can be a full or abbreviated SHA-1, or a ref like HEAD. |
git cat-file -t <object> |
Prints the object’s type: blob, tree, commit, or tag. |
git cat-file -s <object> |
Prints the object’s size in bytes. |
git ls-tree <tree-ish> |
Lists the entries of a tree object: mode, type, hash, and filename for each blob or subtree. |
git rev-parse HEAD |
Prints the full 40-character SHA-1 that HEAD currently resolves to. |
cat .git/HEAD |
Not a Git command, just a file read — shows whether HEAD points at a branch (ref: refs/heads/main) or directly at a commit (detached). |
Examples
Example 1: A First Commit Is a Full Snapshot
Initialize a repository and make one commit, then inspect the commit object Git actually wrote:
git init snapshot-demo
cd snapshot-demo
echo "version 1" > notes.txt
git add notes.txt
git commit -m "feat: add notes.txt with first snapshot"
git cat-file -p HEAD
Output:
Initialized empty Git repository in /home/jane/snapshot-demo/.git/
[main (root-commit) 3f2c9a1] feat: add notes.txt with first snapshot
1 file changed, 1 insertion(+)
create mode 100644 notes.txt
tree 8c3b1e2f4a9d7c6b5e4f3a2d1c0b9a8f7e6d5c4b
author Jane Doe <jane@example.com> 1700000000 -0500
committer Jane Doe <jane@example.com> 1700000000 -0500
feat: add notes.txt with first snapshot
The last command, git cat-file -p HEAD, prints the raw commit object. Notice what it contains: a single tree line pointing at the root tree hash, author/committer info, and the message — no parent line (this is the repository’s very first, “root,” commit), and critically, no diff. The tree line represents the entire content of the project at this commit; to see the file itself you’d follow it with git cat-file -p 8c3b1e2f... on that tree hash.
Example 2: Identical Content Shares One Blob
Copy the file and commit the copy, then look at the tree Git built:
cp notes.txt notes-copy.txt
git add notes-copy.txt
git commit -m "feat: add notes-copy.txt (identical content)"
git ls-tree HEAD
Output:
[main 5a6b7c8] feat: add notes-copy.txt (identical content)
1 file changed, 1 insertion(+)
create mode 100644 notes-copy.txt
100644 blob 4b0f6a1e2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f notes-copy.txt
100644 blob 4b0f6a1e2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f notes.txt
git ls-tree HEAD lists the current root tree’s entries, and both files show the exact same blob hash, 4b0f6a1e.... Git hashed the content once and both tree entries reference it — direct, observable proof that Git addresses content, not files or diffs. If you edited notes-copy.txt to say something different, it would get its own new blob hash on the next commit; the old, shared blob would still exist untouched, still referenced by whichever earlier commits used it.
Example 3: A Branch Is Just a Pointer
Create a branch, commit on it, and compare the raw ref files:
git switch -c feature/login-page
echo "console.log('login page');" > login.js
git add login.js
git commit -m "feat: add login page stub"
cat .git/refs/heads/main
cat .git/refs/heads/feature/login-page
Output:
Switched to a new branch 'feature/login-page'
[feature/login-page 9e1f0a2] feat: add login page stub
1 file changed, 1 insertion(+)
create mode 100644 login.js
5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b
9e1f0a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f
git switch -c created a new ref file, .git/refs/heads/feature/login-page, initially holding the same commit hash as main. Committing on the new branch overwrote that one file with the new commit’s hash — nothing about main‘s ref, or any file elsewhere in the repository, was touched. main still points at the older commit (5a6b7c8...), while feature/login-page now points at the new one (9e1f0a2...). Switching branches later just rewrites .git/HEAD to reference a different one of these files and updates your working tree to match whatever commit it now resolves to.
How It Works Step by Step
Walking through exactly what happens for git add followed by git commit:
git add <file>reads the file’s current bytes from the working tree and computes its SHA-1 hash.- Git writes a new blob object into
.git/objectsunder that hash — unless an object with that exact hash already exists, in which case nothing new is written at all. - The index (
.git/index) is updated so the entry for that path now points at the new blob hash. Nothing outside.githas changed from Git’s perspective — the working tree file and the staged blob just happen to currently match. git commitreads the entire index and builds a tree object for the root directory (and, recursively, one tree object per subdirectory with any staged change), reusing existing tree/blob hashes for anything unchanged.- Git writes a new commit object containing: the new root tree’s hash, the hash of the current commit as “parent,” author and committer identity and timestamp, and your commit message.
- Git moves the current branch’s ref file (e.g.
.git/refs/heads/main) to contain the new commit’s hash. SinceHEADis a symbolic pointer to that branch,HEADnow resolves to the new commit too, without.git/HEADitself changing.
Notice that at no point does Git compute or store a diff. Steps four and five build a brand-new, complete tree and commit; the only “savings” come from object reuse via identical hashes, a side effect of content addressing, not diffing.
Common Mistakes
Mistake 1: Assuming the Working Tree, Not the Index, Defines the Commit
echo "corrected wording" > notes.txt
git commit -m "fix: update notes with corrected wording"
git status
git add notes.txt
git commit -m "fix: update notes with corrected wording"
The first git commit fails to include the edit, because notes.txt was modified but never staged with git add. Git prints no changes added to commit (use "git add" and/or "git commit -a") and creates no new commit at all — the previous commit’s tree is reused as-is. Since git commit only ever snapshots the index, staging the file first (the last two lines above) is required before the new content is actually recorded.
Mistake 2: Committing in Detached HEAD and Losing the Work
git checkout 3f2c9a1
echo "console.log('experiment');" > scratch.js
git add scratch.js
git commit -m "wip: try an experiment"
git switch main
git log --oneline --all
Checking out a commit hash directly (instead of a branch name) puts you in detached HEAD — git checkout warns about this. The wip commit is real and has a hash, but no branch ref points at it. After git switch main, that commit is unreachable from any branch and won’t appear in git log --oneline --all; eventually it can be garbage collected. The fix is to create a branch before leaving detached HEAD: run git switch -c hotfix/experiment while still detached, which writes a proper ref file pointing at that commit and keeps it permanently reachable.
Mistake 3: Trusting git commit -a to Catch New Files
echo "console.log('new feature');" > feature.js
git commit -am "feat: add new feature file"
git status
The -a flag only auto-stages modifications and deletions to files Git already tracks — it never stages new, untracked files. feature.js is left out of the commit entirely, and git status afterward still lists it under “Untracked files.” The fix is to run git add feature.js (or git add -A to stage everything, including new and deleted files) before committing.
Best Practices
- Run
git statusandgit diff --stagedright before every commit — the diff is a comparison of two snapshots, and reviewing it confirms exactly what the next snapshot will contain. - Commit early and often; because each commit is a cheap, deduplicated snapshot, small, frequent commits cost almost nothing extra in storage.
- Use
git add -pto stage part of a file’s changes when a single edit mixes unrelated concerns — the index lets you build a snapshot that’s more precise than “everything currently in the working tree.” - Write commit messages in a consistent style, such as Conventional Commits (
feat: …,fix: …,docs: …,refactor: …) — since a commit is a permanent snapshot record, a clear message is what makes the history navigable later. - Never fear creating a branch — it’s a 40-byte pointer, not a copy of your project.
- If you ever land in detached HEAD (Git will tell you explicitly), create a branch immediately with
git switch -c <name>before doing any work you don’t want to risk losing. - Use
git cat-file -pandgit ls-treewhen you want to actually verify what Git recorded, rather than guessing from behavior alone.
Practice Exercises
- Initialize a new repository, create a file, and commit it. Use
git cat-file -p HEADto find the root tree’s hash, then usegit cat-file -pon that tree hash to find your file’s blob hash, then usegit cat-file -pa third time on the blob hash to print the file’s raw contents directly from Git’s object database — without ever opening the file itself. - Create a file, commit it, then create a copy of that file under a different name and commit the copy. Use
git ls-tree HEADto confirm both filenames reference the same blob hash. Then change the content of one of the two files and commit again — confirm the two files now have different blob hashes while the older, shared blob remains intact and referenced by earlier history. - Make a commit on
main, note its hash withgit log --oneline, then rungit checkout <that-hash>to enter detached HEAD. Make a new commit there. Without creating a branch, switch back tomainand rungit log --oneline --all— confirm your detached commit doesn’t appear. Then usegit reflogto find it again and create a branch pointing at it to recover the work.
Summary
- Git stores full snapshots at every commit, not diffs — diffs are computed on demand for display only.
- Every object (blob, tree, commit) is identified by the SHA-1 hash of its own content, stored in
.git/objects. - A blob holds file content, a tree holds a directory listing of blobs/trees, and a commit points to one root tree plus its parent commit and metadata.
- Identical content is automatically deduplicated across files and history because it’s addressed by hash, not by filename or position.
- The index (staging area) — not the working tree — defines exactly what the next commit’s snapshot will contain; that’s why
git addmatters. - A branch is a small file holding a commit hash;
HEADnormally points at a branch, which is why moving branches and creating new ones is nearly instant. - Detached HEAD means
HEADpoints directly at a commit instead of a branch — commits made there can become unreachable if you switch away without creating a branch first. - Packfiles and delta compression are storage optimizations Git applies later — they don’t change the snapshot model itself.
