Appearance
14.1.1 — What Git Actually Stores
Most Git confusion comes from a single wrong mental model: that Git stores changes. It does not.
Git stores complete snapshots of your files, addressed by the hash of their content. A diff is something Git computes when you ask for one. Once that is clear, branches stop being mysterious, rebase stops being frightening, and the recovery commands in Chapter 14.1.2 stop looking like magic.
You can see the whole model in about ten minutes, so this page does that.
1. The four objects
Everything in .git/objects is one of four types, each stored under the SHA of its own content — content addressing, exactly as in Chapter 8.2.2.
A blob is file contents. No name, no permissions, no history. Two identical files anywhere in your history are one blob, stored once.
A tree is a directory listing: names, modes, and the hash of each entry, which is a blob or another tree.
$ git cat-file -p 4d5e6f
100644 blob a1b2c3 README.md
100644 blob d4e5f6 server.js
040000 tree 789abc srcA commit points at one tree, plus metadata.
$ git cat-file -p HEAD
tree 4d5e6f7a... ← the complete snapshot of the project
parent 9c8b7a6... ← the previous commit (two parents = a merge)
author Ana Ruiz <ana@x.com> 1754131200 +0100
committer Ana Ruiz <ana@x.com> 1754131200 +0100
Fix refund windowA tag object is an annotated tag: a name, a target, a tagger and a message, optionally signed.
Read the structure and the consequences fall out:
A commit hash covers everything. It hashes the tree, which hashes its subtrees and blobs, plus the parent hash. So a commit id fixes the entire history behind it — change one byte in one file in one old commit and every hash from there forward changes. This is a Merkle tree (Chapter 8.2.2), and it is why Git history is tamper-evident rather than merely tidy.
Renaming a file creates no new blob. The blob is unchanged; a new tree lists it under a different name. Git detects renames by comparison after the fact, which is why git log --follow is a heuristic rather than a lookup — nothing recorded the rename.
Nothing is ever modified. Objects are immutable. "Changing history" always means writing new objects and moving a pointer.
On the hash function: Git used SHA-1, which is broken for collisions (Chapter 8.2.2). Git added collision detection that rejects the known attack pattern, and SHA-256 repositories are supported. The practical risk was always low — an attacker needs write access and both colliding inputs — and the migration is under way.
2. Refs: a branch is a file
A ref is a file containing a hash. That is the entire mechanism.
$ cat .git/refs/heads/main
9c8b7a6f5e4d3c2b1a0987654321fedcba098765A branch is a 41-byte file. That is why creating one is instant, deleting one deletes nothing but a pointer, and having two hundred costs nothing.
.git/refs/heads/— local branches.git/refs/tags/— tags.git/refs/remotes/origin/— your last-known state of the remote
HEAD says where you are:
$ cat .git/HEAD
ref: refs/heads/main ← on a branchA detached HEAD is HEAD holding a hash directly instead of a ref. You are at a commit, not on a branch — so new commits have nothing pointing at them, and moving away loses them from view (recoverable via section 5).
Committing is now describable in one paragraph. Git writes blobs for changed files, writes trees for the directories containing them, writes a commit object pointing at the top tree with HEAD's commit as parent, and writes the new commit's hash into the branch file. Every branch operation is that last step in isolation.
3. The index
Between your files and the repository sits a third thing that no other version control system exposes this way, and it is where "why does Git have staging" is answered.
The index (staging area) is a binary file, .git/index, listing every tracked path with its blob hash and file metadata. It is a proposed next tree.
The three trees:
| Tree | Where | Command |
|---|---|---|
| Working directory | Your actual files | git checkout writes here |
| Index | .git/index | git add writes here |
| HEAD | The current commit | git commit writes here |
working directory ──git add──▶ index ──git commit──▶ HEAD
▲ ▲
└──── git checkout ──────────┴──── git reset ─────What the staging area buys you is the ability to commit a subset of your changes. git add -p walks changes hunk by hunk, so a session that fixed a bug and also renamed a variable becomes two coherent commits. Reviewable history is a deliberate act, and this is the tool for it.
git status compares all three, which is why it has two sections: "staged" is index versus HEAD, "not staged" is working directory versus index.
And this explains reset's three modes exactly, which otherwise have to be memorised:
git reset --soft <c> # move HEAD only → changes stay staged
git reset --mixed <c> # move HEAD + index → changes unstaged (the default)
git reset --hard <c> # move HEAD + index + working directory ← discards workOnly --hard can lose uncommitted work, and it is the only Git command that routinely does.
4. Storage: loose objects and packfiles
New objects are written as individual compressed files — one per object, the full content each time.
That would be wasteful for a large history, so git gc packs them. A packfile stores many objects together and uses delta compression: similar objects are stored as a difference from another object.
This is where "Git stores snapshots" and "Git is efficient" reconcile. The model is snapshots; the storage uses deltas as an optimisation. The deltas are between similar objects, not necessarily between consecutive versions — Git picks whatever compresses best.
Practical consequences:
Repositories get faster after git gc, which also runs automatically.
Binary files do not delta well. A 10 MB image changed fifty times is roughly 500 MB in history, forever. This is what Git LFS solves: store a pointer in Git and the file elsewhere.
Deleting a large file does not shrink the repository. It is still in history, and every clone downloads it. Removing it requires rewriting history (git filter-repo), which changes every subsequent commit hash and requires everyone to re-clone. So the rule is: never commit a large binary or a secret, because removal is expensive and, for a secret, incomplete — the revocation rule from Chapter 8.6.1 applies.
git clone --depth 1 fetches recent history only, which is the right default in CI where a full history is downloaded and never used.
5. The reflog
Almost nothing in Git is lost, and the reflog is why.
Every time a ref moves, Git records the old and new value in .git/logs/.
$ git reflog
9c8b7a6 HEAD@{0}: reset: moving to HEAD~3
1a2b3c4 HEAD@{1}: commit: Add refund handling ← "lost" workA reset --hard you regret is git reset --hard HEAD@{1}. A deleted branch is recoverable from its last hash. A botched rebase is undone by resetting to the pre-rebase entry.
The two limits worth knowing: the reflog is local, so it does not help with something that was never on your machine; and entries expire, by default after 90 days (30 for unreachable ones). Within that window, uncommitted work is the only thing genuinely unrecoverable — which is the argument for committing early and often, even messily, since Chapter 14.1.2 can tidy it later.
git fsck --lost-found finds dangling objects when you do not even have a reflog entry.
6. Configuration, and the settings worth having
Three levels, each overriding the last: system, global (~/.gitconfig), and repository (.git/config).
ini
[core]
autocrlf = input # (1)
[pull]
rebase = true # (2)
[rebase]
autosquash = true
[merge]
conflictstyle = zdiff3 # (3)
[fetch]
prune = true # (4)
[rerere]
enabled = true # (5)
[init]
defaultBranch = main(1) Line endings. On Windows, autocrlf = true converts on checkout and back on commit; input (macOS and Linux) converts on commit only. Mismatched settings across a team produce diffs where every line changed — the fix is a .gitattributes file in the repository, which is authoritative and beats relying on everyone's local configuration.
(2) Rebase instead of creating a merge commit on git pull (Chapter 14.1.2).
(3) zdiff3 shows the original text alongside both sides in a conflict, which frequently makes the correct resolution obvious. It is a one-line change and a genuine improvement over the default.
(4) Delete local references to remote branches that no longer exist, so git branch -r stays accurate.
(5) rerere — "reuse recorded resolution" — records how you resolved a conflict and replays it automatically when the same conflict appears. On a long-lived branch rebased repeatedly, this saves resolving the same conflict five times.
.gitignore is patterns for untracked files, and it does not affect files already tracked — that needs git rm --cached. A global ignore file (core.excludesFile) is the right home for editor and operating-system noise, which does not belong in a project's file.
7. Hooks
Scripts in .git/hooks/, run at defined points.
Client-side: pre-commit (lint, format, secret scan), commit-msg (enforce a message convention), pre-push (run a fast check).
Server-side: pre-receive and update — the only ones that cannot be bypassed, because client hooks are local files anyone can delete or skip with --no-verify.
So the rule is: client hooks are convenience, server-side checks are enforcement. A secret scanner in pre-commit is a helpful nudge; the control is the push protection in Chapter 8.6.1.
Hooks are not committed, since .git/ is not tracked. Tools like Husky or pre-commit keep hook definitions in the repository and install them, which is how a team shares them.
8. Looking inside
bash
git cat-file -t <hash> # what type of object is this
git cat-file -p <hash> # print it
git rev-parse HEAD # resolve a ref to a hash
git rev-parse --short HEAD
git ls-tree -r HEAD # every file in the current commit
git count-objects -vH # repository size, loose vs packed
git log --oneline --graph --all # the shape of the historySpend ten minutes with cat-file on a real repository — resolve HEAD to a commit, print it, print its tree, print a blob. The model stops being abstract, and every later command becomes predictable rather than memorised.
Recall
- Git stores snapshots, not changes. Four objects: blob (content), tree (directory listing), commit (tree + parent + metadata), tag. All content addressed by their own hash.
- A commit hash covers the whole tree and the parent, so it fixes the entire history behind it — a Merkle tree, which makes history tamper-evident. Renames create no new blob, so
--followis a heuristic. - A branch is a 41-byte file containing a hash.
HEADnames the current branch, or holds a hash directly in a detached HEAD. Committing = write blobs, write trees, write a commit, update one file. - The index is a proposed next tree. Working directory →
git add→ index →git commit→ HEAD. This is exactly whyreset --soft/--mixed/--hardmoves one, two or three of them — and only--harddiscards work. git add -pis how reviewable history gets made deliberately.- Storage packs objects with delta compression as an optimisation over the snapshot model. Binaries do not delta, deleting a large file does not shrink the repository, and removal requires rewriting every later hash — so never commit large binaries or secrets.
- The reflog records every ref movement, so a bad
reset --hard, a deleted branch or a botched rebase is recoverable. It is local and expires (90 days), and uncommitted work is the only truly unrecoverable thing. - Configure
merge.conflictstyle = zdiff3,fetch.prune,rerere.enabled, and put line-ending rules in.gitattributes, not in everyone's local config. Client hooks are convenience; only server-side hooks enforce.
Self-test: What does a commit object actually contain? · Why is a branch instant to create? · Which reset mode can lose work, and why do the other two not? · Why does deleting a big file leave the repository large? · What is the one thing the reflog cannot save? · Why can a pre-commit hook not be a security control?
Next: 14.1.2 uses this model on the operations people actually fear — merge versus rebase, what ort is, cherry-picking, bisecting, and the rescue commands for every way a repository goes wrong.