Git from First PrinciplesPost 2 of 16 · 16 of 16 written
- How to use this guide
- 01Why version control exists
- 02Git's data model: a content-addressed object database
- 03References: branches, HEAD and tags are pointers
- 04The three areas: working tree, index, repository
- 05Daily commands, mapped onto the model
- 06Branching and integration: merge, rebase, cherry-pick
- 07Undoing things safely
- 08Remotes and collaboration
- 09Workflows and conventions
- 10Git for AI and ML work
- 11Git with AI coding agents
- 12Power tools
- 13Under the hood: build a commit by hand
- 14Debugging mindset and recovery playbook
- 15Practice path, fluency checklist and glossary
- 16Sources
2. Git's data model: a content-addressed object database
Inside .git/objects, Git stores exactly four kinds of objects, each named by a hash of its own contents. Branches, merges and history are all built from these four.
Content addressing
A hash function turns any bytes into a fixed-size fingerprint. The same input always gives the same output, a one-byte change gives a completely different output, and finding two inputs with the same output is practically impossible.
Content addressing means an object’s name is the hash of its content. Four consequences follow, and each one explains a piece of Git’s behaviour:
- Free deduplication. The same file content in 1,000 commits is stored once, because it always gets the same name.
- Integrity. If a byte on disk flips, the content no longer matches its name.
git fsckdetects this. - Immutability. You cannot edit an object. Changing content produces a new object with a new name, so “rewriting history” always means creating new objects.
- Global names. Two clones holding the same hash hold the same content. That is how
fetchandpushwork out what to send: they compare lists of hashes.
Try it in your lab:
echo "hello" | git hash-object --stdin
# ce013625030ba8dba906f756967f9e9ca394464a
# Git hashes a small header plus the content, not the content alone:
printf 'blob 6\0hello\n' | sha1sum
# ce013625030ba8dba906f756967f9e9ca394464a -Run the first command on any machine in the world and you get the same 40 characters. That is the whole idea.
SHA-1 now, SHA-256 next. New repositories still use SHA-1 (40 hex characters) by default in Git 2.56. The planned Git 3.0 switches new repositories to SHA-256 (64 characters) and the default branch to main, with no release date set (Git BreakingChanges). You can opt in today with git init --object-format=sha256, but check that your host and tools support it first.
The four object types
| Object | What it stores | Think of it as |
|---|---|---|
| blob | The raw bytes of one file. No name, no permissions, no date. | File contents |
| tree | A list of entries: mode, type, hash, name. Entries point to blobs (files) or other trees (subfolders). | One directory listing |
| commit | One top-level tree hash, zero or more parent commit hashes, author, committer, timestamps, message. | A snapshot plus its place in history |
| tag (annotated) | A pointer to an object, usually a commit, plus tagger, message and optional signature. | A permanent, signed release label |
Notice where the filename lives: in the tree, not the blob. Renaming a file creates a new tree but reuses the same blob. Git never records “rename”; it detects renames afterwards by comparing content similarity, which is why git log --follow exists.
Look at real objects in any repository with at least one commit:
git cat-file -p HEAD # the commit object
# tree 9c6e1f...
# parent 4b1a07...
# author Your Name <you@example.com> 1761840000 +0100
# committer Your Name <you@example.com> 1761840000 +0100
#
# Add data loader
git cat-file -p 'HEAD^{tree}' # the root tree it points to
# 100644 blob a1b2c3... README.md
# 040000 tree d4e5f6... src
git cat-file -t a1b2c3 # prints the type: blobObjects sit on disk as compressed files: hash ce0136… lives at .git/objects/ce/0136…. Run find .git/objects -type f after each commit in Part 5 and watch the count grow.
[embedded content: object graph · 2 commits, 2 trees, 3 blobs]
Commit B changed only train.py, so its tree points to one new blob and reuses the README.md blob from commit A.
Why one commit hash covers all of history
A commit’s hash is computed over its text, and that text contains the tree hash and the parent hashes. The tree hash covers every file; the parent hash covers the parent’s tree and its parent, and so on back to the first commit. This structure is called a Merkle DAG.
So a single commit hash fingerprints the entire project and its entire past. Two consequences matter every day:
- Rewriting is visible. Change one byte anywhere in history and every later commit gets a new hash. That is why
amendandrebaseproduce new commits, and why rewriting shared history breaks your collaborators (Part 7). - It is the ideal reproducibility ID. Log the commit hash next to a trained model and you have pinned the exact code, exactly (Part 10).
The commit graph
Commits point backwards to their parents, never forwards. A commit cannot know its children: they are created later, and adding a child pointer would change its hash. The result is a directed acyclic graph (DAG).
- A normal commit has one parent. The first commit has none. A merge commit has two or more.
- The history of a commit is every commit reachable by following parent pointers from it.
Reachability is the key idea of this whole guide. What git log shows, what push sends, and what garbage collection deletes are all defined by what is reachable from somewhere.
Check yourself
- Why does a blob not store its filename, and what does that buy you?
- Why does fixing a typo in a commit message change the commit’s hash?
- Two branches each contain the same 2 GB file. How many copies are stored?