Git from First Principles
Git tutorials usually start with commands and hope the model forms on its own. This page goes the other way: git is a small, strange filesystem with a database underneath, and once you see the database, every command becomes obvious. Written for people fluent in filesystems, pointers, and processes — no prior git assumed.
Sanitized — no real hosts, paths, or project names. Examples use the sensor-logger project shared by the rest of this series. If you haven’t read Start-Here-What-Git-Is, start there — it sets up why this page comes before any workflow; Remotes-and-Tracking-Branches is next.
The one idea: a content-addressed filesystem
Everything in git lives in one place: the object database, under .git/objects/. It stores four kinds of object, and the rule for all of them is the same — an object’s name is the SHA-1 hash of its contents. You don’t choose where things are stored; the content itself is the address.
That single rule buys three properties that define how git behaves:
- Deduplication for free. Two identical files — in different directories, different commits, different decades — are one object. Same bytes, same hash, same address.
- Immutability. You cannot edit an object; changing the content changes the hash, which is a different object. The database is append-only.
- Integrity. If a disk flips a bit, the hash no longer matches the content, and git notices.
The three object types that matter (the fourth, annotated tags, can wait):
| Object | Is | Filesystem analogy |
|---|---|---|
| blob | File contents — just the bytes, no name, no mode | An inode’s data blocks |
| tree | A directory listing: names → (mode, blob-or-tree hash) | A directory |
| commit | Pointer to one tree + parent commit(s) + author, date, message | A whole-filesystem snapshot, timestamped and signed |
You can inspect any object directly — this is the debugger for everything that follows:
$ git cat-file -p HEAD
tree 8f2a41c9b6...
parent 3d1c07aa42...
author A. Developer <adev@example.com> 1720000000 -0500
committer A. Developer <adev@example.com> 1720000000 -0500
fix: clamp negative sensor readings
$ git cat-file -p 8f2a41c9 # the tree that commit points at
100644 blob e4a3f2... README.md
100644 blob 91bb02... sensor.c
040000 tree 77ac1e... tests
A commit points at a tree; the tree points at blobs and subtrees. Follow the pointers down and you reconstruct the entire project as of that commit.
The consequence people miss: a commit is a snapshot, not a diff. Every commit points at a complete tree of the whole project. When git show displays a commit “as a diff,” git is computing that diff on the fly by comparing the commit’s tree with its parent’s tree. Diffs are a view; snapshots are the storage. (Deduplication is why this isn’t wasteful — an unchanged file is the same blob, so consecutive snapshots share almost everything, like hard links.)
And because each commit holds a pointer to its parent, the commits form a chain — history is a linked list of snapshots. (When merges enter the picture it becomes a DAG; that’s Merging-Rebasing-and-Reading-History.)
Computing the name yourself
That rule is worth verifying rather than believing, and it takes one command. Git’s plumbing will tell you what it would call a file’s contents without storing anything:
$ printf 'hello\n' > greeting.txt
$ git hash-object greeting.txt
ce013625030ba8dba906f756967f9e9ca394464a
Now hash the file yourself, and you get a different answer:
$ sha1sum greeting.txt
f572d396fae9206628714fb2ce00f72e94f2258f
That isn’t a contradiction, it’s a refinement. Git doesn’t hash the raw bytes: it prepends a small header giving the object’s type and its length, separated from the content by a NUL byte.
"blob" SP <bytelength> NUL <content>
Hash that, and you land exactly where git did:
$ printf 'blob 6\0hello\n' | sha1sum
ce013625030ba8dba906f756967f9e9ca394464a
So the precise rule is name = SHA-1(type + length + NUL + content). The header is what keeps a blob and a commit that happen to contain identical bytes from collapsing into the same object.
And the name is the location. Commit the file and it is on disk under its own hash, with the first two hex characters split off into a directory — a fanout so that no single directory ends up holding a million entries, not a semantic:
$ git add greeting.txt && git commit -m "add greeting"
$ find .git/objects -type f
.git/objects/57/e9529754dc514a3ec10db2ff882018fbe1fcbf
.git/objects/91/161ee6347c71cd91554e274818f36ee856f250
.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a
Three objects for a one-file commit, which is the model exactly: the blob (ce0136…, your content), the tree that names it, and the commit that points at the tree.
What you will actually find in .git/objects
One caveat before you go exploring, because that directory does not stay so readable. Storing every object as its own compressed file is fine for a handful and wasteful for a hundred thousand — every one-line edit mints a whole new object. So git repacks periodically, and git gc collapses the loose files into a packfile that stores objects as deltas against one another:
$ git gc
$ find .git/objects -type f
.git/objects/info/commit-graph
.git/objects/info/packs
.git/objects/pack/pack-cbc6ab17255af06f2a5a9c721687bea8387e14d0.idx
.git/objects/pack/pack-cbc6ab17255af06f2a5a9c721687bea8387e14d0.pack
.git/objects/pack/pack-cbc6ab17255af06f2a5a9c721687bea8387e14d0.rev
Every loose object is gone. Nothing was lost and nothing about the model changed:
$ git cat-file -p ce013625030ba8dba906f756967f9e9ca394464a
hello
The address still resolves; only the arrangement of bytes behind it is different. This matters more than it sounds, because a repository you cloned arrives packed — clone the lab repo and .git/objects holds one packfile and zero loose objects, so the tidy ce/0136… layout above is something you will only see in a repo whose commits you made yourself.
Worth taking as a general shape rather than a piece of git trivia: the content-addressed store is an interface, and the on-disk layout is an implementation detail behind it. The rest of this series talks about the interface, because that is the part that doesn’t change. You’ll meet the same split again with refs, which are loose files until git packs them into .git/packed-refs — Remotes-and-Tracking-Branches runs into it directly.
Renames don’t exist
Here is a consequence that surprises people, and it falls straight out of addressing by content. Move a file without changing it, and the blob is the same object:
$ git hash-object sensor.c
16934bcb43760bbf13c2fe6af4997b2aa941169e
$ mv sensor.c probe.c
$ git hash-object probe.c
16934bcb43760bbf13c2fe6af4997b2aa941169e
The content didn’t change, so the address didn’t change. The only thing that changed is which name a tree entry gives it — and a tree is just a directory listing. There is nothing else to record, so git does not store renames. There is no rename object, no from/to field, nothing in the commit that says a file moved.
Which raises an obvious question, because git status plainly says otherwise:
$ mv sensor.c probe.c # note: plain mv, not git mv
$ git add -A
$ git status --short
R sensor.c -> probe.c
That R is inferred, at display time, by comparing content. Git noticed a file disappeared, a file appeared, and their contents are similar, and it chose to describe that as a rename because it’s more useful to you. This is also why git mv is a convenience rather than a necessity — it runs mv and updates the index, and the result is byte-for-byte what plain mv plus git add -A produces.
Since it’s a similarity judgement, it has a threshold, and the threshold is adjustable. Take one commit that moved a file and edited a few lines of it:
$ git diff --summary -M HEAD~1 HEAD
rename sensor.c => probe.c (81%)
$ git diff --summary -M95% HEAD~1 HEAD
create mode 100644 probe.c
delete mode 100644 sensor.c
Same commit, same stored objects, two different stories — because the second one demanded 95% similarity and 81% didn’t clear the bar. Nothing about history changed; only the description did. (The default is 50%, so heavily rewriting a file while moving it will quietly stop being reported as a rename at all.)
Take the general lesson: a rename is a rendering of history, not a fact in it. That is why git log --follow is a heuristic and comes with caveats, and why the answer to “why did git lose track of my file across a rename?” is always “the similarity fell below the threshold,” never “the metadata was corrupted.”
The one assumption underneath all of this
Everything above rests on a single premise: that two different pieces of content never produce the same hash. If they did, the store would silently alias them — one object would shadow the other and history would be quietly, undetectably wrong. That is not a structural guarantee like the rest of this page. It is a cryptographic one, and cryptographic assumptions have a shelf life.
SHA-1’s collision resistance was broken in 2017, when researchers produced two different PDFs with the same SHA-1. Git’s response was twofold. It adopted a hardened SHA-1 that detects the manipulation pattern such attacks require and refuses to hash those inputs, so the published collision doesn’t work against git. And it added support for repositories that use SHA-256 throughout, which you can create today:
$ git init --object-format=sha256 newrepo
$ git -C newrepo rev-parse HEAD
8e297ac0362cd5888c7deea33226cb2ce7e5bd29bc0d667ecb2c0186bfeb0449
Sixty-four hex characters instead of forty. The reason you have almost certainly never seen one is interoperability — the two object formats cannot talk to each other at all:
$ git fetch origin
fatal: mismatched algorithms: client sha256; server sha1
So a SHA-256 repository can’t fetch from, push to, or be cloned by the SHA-1 world, which is every forge and every colleague you have. In practice that makes SHA-256 a thing that exists rather than a thing you use, and it will stay that way until the hosting platforms move.
None of this is a risk to your repository this week. It’s here because it’s the one load-bearing assumption in a design that is otherwise all structure, and knowing which part of a system rests on an assumption rather than a proof is the difference between understanding it and reciting it.
Refs: names for hashes
Nobody wants to type 3d1c07aa42.... A ref is a name for a hash — and it is exactly what it sounds like to a filesystem person:
$ cat .git/refs/heads/main
3d1c07aa42f6b19e77021d3c8f0a4be2277fd9ab
A branch is a 41-byte file containing a commit hash. That’s the entire implementation. Creating a branch costs nothing and materializes nothing — it’s writing one small file, like creating a symlink.
The one thing that makes a branch more than a bookmark: it moves. When you commit while “on” a branch, git advances that ref to the new commit automatically. A branch is a pointer that follows your work; a tag (refs/tags/) is the same kind of file that doesn’t move.
This is also the moment to fix the mental picture most tutorials break: the branch is not a container, not a folder of commits, not a copy of the code. The commits live in the object database regardless; the branch is one pointer at the tip. Deleting a branch (git branch -d) deletes a 41-byte file — the commits are untouched (whether they stay reachable is a different question; see Undoing-Things-in-Git).
HEAD: where you are
HEAD is a pointer to your current position in history. It’s a one-line file (.git/HEAD) that usually contains a reference to a branch — not a commit hash directly:
$ cat .git/HEAD
ref: refs/heads/main
So it’s a pointer to a pointer: HEAD → branch → commit. That indirection is why committing “moves your branch”: git commit follows HEAD to find the branch, then advances that branch to the new commit.
When HEAD contains a raw hash instead of a branch ref, that’s detached HEAD — you’re standing on a commit with no branch under you, so new commits advance nothing and become unreachable when you move away. Git prints a long warning when this happens; now you know what it means.
The index: what you’re about to commit
The index (also called the staging area or cache) is a binary file (.git/index) holding a complete snapshot of what the next commit will contain — every file path with the hash of its staged content. It is not a diff and not a list of changed files; it’s the full proposed tree.
git add sensor.c doesn’t mark the file — it copies the file’s current contents into the object database as a blob and updates the index entry to point at that blob. Edit the file again afterward and the index still holds the old version; that’s why you sometimes git add the same file twice before one commit.
A C analogy that holds up: the index is a write buffer. Working-tree edits are like writes to a FILE* — buffered, visible to you, but not durable. git add is writing into the buffer; git commit is the fflush() that makes it permanent history.
The three trees
At any moment there are three versions of your project, and nearly every everyday command is a data move between two of them:
HEAD (last commit) ←── index (next commit) ←── working tree (your files)
git statusis just two diffs: index vs. HEAD (“changes to be committed”) and working tree vs. index (“changes not staged”).git addcopies working tree → index.git committurns the index into a real commit and advances HEAD’s branch. Nothing is read from the working tree at commit time — only the index. If it’s staged, it’s in; if not, it isn’t.git diffshows working tree vs. index;git diff --cachedshows index vs. HEAD.
Hold onto this diagram — Undoing-Things-in-Git maps git reset’s three modes directly onto it, and the whole family of “undo” commands stops being folklore.
Branch vs. working tree
One more distinction the default setup hides. The object database plus its refs is the repo; the working tree — the checked-out files you edit — is just a view of one commit, materialized as files so you can work on them. A normal clone has exactly one, so “switching branches” (git checkout fix-42) feels like the branch is the workspace. What actually happened: your single working tree repositioned itself onto a different pointer and rewrote its files to match.
The two concepts are fully separable — 50 branches with 1 working tree is the everyday case, and one repo can host several working trees at once, each standing on its own branch with its own private HEAD and index. That’s git worktree, covered in Git-Worktrees-and-Parallel-Agents — read the rest of the series first.
Why “nothing committed is ever lost”
Putting the pieces together: objects are immutable and append-only, and the only mutable things in the whole system are the little pointer files. Every scary git operation — reset, rebase, branch -D — moves pointers; none of them delete commits. A commit that no ref can reach becomes invisible, not gone, and git keeps a journal of every pointer move (the reflog) for around 90 days before garbage-collecting unreachable objects.
That turns every “I lost my work” panic into a retrieval problem, never a recovery problem. The retrieval toolbox is Undoing-Things-in-Git.
Recap: the whole model in five lines
- Objects (blobs, trees, commits): immutable, content-addressed, append-only. Commits are snapshots, chained by parent pointers.
- Branch: a moving 41-byte pointer to a commit. Cheap, disposable, not a container.
- HEAD: pointer to the branch you’re on (pointer → pointer → commit).
- Index: the complete proposed next commit.
addfills it;commitflushes it. - Working tree: a materialized view of one commit — the only part of git that isn’t a database.
Next: Remotes-and-Tracking-Branches — what changes once a second copy of this database exists somewhere else.