The Git object model — build a commit by hand
Blobs, trees and commits made by hand with plumbing commands. After this, every Git command you already use stops being magic.
The Git object model — build a commit by hand, and every command stops being magic ER modelling — entities, relationships, cardinality, ER → relational
The sentence to own: Git is a content-addressed key-value store with four object types and a handful of pointers into it — and every command you have ever memorised is a thin layer over that.
You have used Git for a while. That is not the same as knowing what it does, and the difference shows the first time something goes wrong. Today you build a commit with no porcelain commands at all, and after that
rebase,resetandreflogstop needing to be memorised.
Part 1 — What is actually in .git/
.git/
├── objects/ THE DATABASE. Every version of everything, forever.
│ ├── e6/9de29b... a loose object, named by the SHA-1 of its content
│ └── pack/ compressed bundles (Part 5)
├── refs/
│ ├── heads/main A BRANCH — a 41-byte FILE containing one hash
│ └── tags/v1.0
├── HEAD "ref: refs/heads/main" — a pointer to a pointer
├── index THE STAGING AREA — a binary file (Day 085)
├── config this repo's settings
└── hooks/ scripts Git runs at events (Day 099)
git cat-file -t <sha> # what TYPE is this object
git cat-file -p <sha> # PRINT it — the single most useful plumbing command
git rev-parse HEAD # resolve any name to a hash
git count-objects -vH # how big is the database
Two categories of command, and it is worth knowing which is which: porcelain (add,
commit, merge — for humans) and plumbing (hash-object, write-tree, commit-tree — for
scripts, and for understanding). Today is entirely plumbing.
Part 2 — The four objects
EVERY OBJECT IS STORED AS: <type> <length>\0<content> → SHA-1 → zlib → a file.
⇒ THE NAME OF AN OBJECT IS THE HASH OF ITS CONTENT. That is the whole design.
1. BLOB FILE CONTENTS. Nothing else — no name, no mode, no timestamp.
2. TREE A DIRECTORY: a list of (mode, type, hash, NAME) entries.
⇒ filenames live HERE, not in the blob.
3. COMMIT one tree + PARENT(s) + author + committer + message.
⇒ a SNAPSHOT of the whole project, not a diff.
4. TAG an annotated tag — a named, signed pointer to a commit.
THREE CONSEQUENCES THAT FALL OUT OF "THE NAME IS THE HASH", AND YOU SHOULD BE
ABLE TO DERIVE ALL THREE RATHER THAN RECALL THEM:
1. IDENTICAL CONTENT IS STORED ONCE. Copy a file to five places, commit ⇒
ONE blob, five tree entries. Which is why a repo is far smaller than the
sum of its history, and why committing a 50 MB binary TWICE costs 100 MB
(its content changed, so it is a new object — and it is there forever).
2. GIT DOES NOT TRACK RENAMES. A blob has no name; a rename is just "this
tree entry vanished, that one appeared". ⇒ RENAMES ARE *DETECTED* AT
READ TIME BY SIMILARITY — a heuristic. That is why `git log --follow`
exists, why it is imperfect, and why a rename-plus-heavy-edit shows up as
a delete and an add.
3. HISTORY IS A HASH CHAIN. A commit's hash covers its PARENT's hash.
⇒ CHANGE ANYTHING IN AN OLD COMMIT AND EVERY DESCENDANT'S HASH CHANGES.
⇒ THEREFORE: `rebase`, `commit --amend` and `filter-repo` do not EDIT
history — they BUILD NEW COMMITS and move a pointer. The originals are
still in objects/ (Day 087's reflog).
⇒ THEREFORE force-push exists, and THEREFORE a leaked secret is not
removed by deleting it in a new commit (Day 085).
⇒ and it is the same structure as a blockchain, minus the consensus.
$ git cat-file -p HEAD
tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904 # the snapshot
parent 8f3a1c... # 0 = root · 1 = normal · 2+ = a MERGE
author Ramesh <r@example.com> 1755100000 +0530
committer Ramesh <r@example.com> 1755100000 +0530 # differs after a rebase or a cherry-pick
Add rate limiter
$ git cat-file -p HEAD^{tree}
100644 blob a1b2c3... app.py # 100644 file · 100755 exec · 040000 dir
040000 tree d4e5f6... src # trees nest ⇒ the whole project
Part 3 — Build a commit by hand
Do this once and Git is permanently demystified. No add, no commit.
mkdir /tmp/byhand && cd /tmp/byhand && git init
# 1. CONTENT ⇒ a blob. -w writes it into the database.
$ echo "print('hello')" | git hash-object -w --stdin
b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0
# THE DATABASE NOW HAS THE CONTENT — AND NO FILENAME ANYWHERE.
$ git cat-file -p b6fc4c
print('hello')
# 2. GIVE IT A NAME by putting it in the index, then snapshot the index as a tree.
$ git update-index --add --cacheinfo 100644,b6fc4c620b67d95f953a5c1c1230aaab5db5a1b0,app.py
$ git write-tree
9d1dcfdaf1a6857c5f83dc27019c7600e1ffaff5
$ git cat-file -p 9d1dcf
100644 blob b6fc4c... app.py # THE NAME LIVES IN THE TREE
# 3. WRAP THE TREE IN A COMMIT.
$ git commit-tree 9d1dcf -m "first commit"
c2a3e1...
# 4. POINT A BRANCH AT IT — this is all "being on a branch" means.
$ git update-ref refs/heads/main c2a3e1
$ git log --oneline # it is a REAL commit. Git cannot tell the difference.
c2a3e1 first commit
WHAT `git commit` ACTUALLY DID, EVERY TIME YOU HAVE EVER RUN IT:
1. hash-object each staged file ⇒ blobs
2. write-tree from the index ⇒ trees
3. commit-tree with HEAD as parent ⇒ a commit
4. update-ref: move the CURRENT BRANCH to the new commit
⇒ THAT IS THE ENTIRE COMMAND. Four steps, and you just did all four.
Part 4 — Refs, HEAD, and why branching is free
A BRANCH IS A FILE CONTAINING 40 HEX CHARACTERS AND A NEWLINE.
$ cat .git/refs/heads/main
c2a3e19f8b7d5a4c3e2f1a0b9c8d7e6f5a4b3c2d
⇒ CREATING A BRANCH WRITES 41 BYTES. That is why it is instant, why you
should branch freely, and why "branching is expensive" (SVN, Perforce)
is a habit worth unlearning rather than a fact about version control.
⇒ COMMITTING = writing objects, then MOVING THAT ONE POINTER FORWARD.
⇒ DELETING a branch deletes the pointer. THE COMMITS ARE STILL THERE
— which is exactly why Day 087 can get them back.
HEAD IS A POINTER TO A POINTER:
HEAD ──▶ refs/heads/main ──▶ c2a3e1 ──parent──▶ 8f3a1c ──▶ …
⇒ DETACHED HEAD = HEAD contains a HASH instead of a ref name.
Commits you make still exist — ⚠️ but NO BRANCH POINTS AT THEM, so
checking out anything else leaves them unreachable. That is the
entire warning message, and it is not an error.
⇒ THE FIX IS ALWAYS THE SAME: git switch -c newbranch (point something at it)
| Shorthand | Meaning |
|---|---|
HEAD~1 / HEAD~3 |
first parent, 1 or 3 back — "up the main line" |
HEAD^2 |
the second parent — only exists on a merge commit |
HEAD@{2} |
where HEAD was two moves ago — the reflog (Day 087) |
main..feature |
commits on feature not on main |
main...feature |
commits on either but not both |
HEAD^{tree} |
the tree of that commit |
Part 5 — Packfiles, and D-03 · ER modelling
LOOSE OBJECTS ARE ONE FILE EACH ⇒ fine for hundreds, wasteful for millions.
git gc ⇒ PACKFILES: many objects in one file, with DELTA COMPRESSION —
similar objects stored as deltas against each other.
⇒ NOTE THE ORDER: Git stores SNAPSHOTS and compresses them as deltas
AFTERWARDS, in storage. Other VCSs store deltas as the MODEL and
reconstruct snapshots. That difference is why `git checkout` of any
commit is fast, and why branching and merging are cheap here.
D-03 · ER MODELLING — THE STEP BEFORE ANY SCHEMA:
ENTITY a thing you store ⇒ becomes a TABLE
ATTRIBUTE a fact about it ⇒ a COLUMN
RELATIONSHIP a link between entities ⇒ an FK, or a TABLE (see below)
CARDINALITY 1:1 · 1:N · M:N
PARTICIPATION total (mandatory) or partial (optional) ⇒ NOT NULL or nullable
WEAK ENTITY cannot exist alone (an order LINE) ⇒ its key includes the parent's
| Cardinality | Maps to |
|---|---|
| 1:N | an FK on the N side — order.customer_id. Never a list on the 1 side |
| M:N | always a third table (student_course), PK = both FKs |
| 1:1 | one table, or an FK + UNIQUE — split only for access or security reasons |
THE M:N RULE IS ABSOLUTE, AND HERE IS WHY: a column holds ONE value from its
domain (Day 081, D-02). "Many" therefore cannot live in a column — which is
what a comma-separated `tags` column is: a list smuggled into a scalar, with no
index, no FK, and no integrity. The join table is not a workaround; it IS
the relationship, and it is where the relationship's OWN attributes go (enrolled_at,
role, quantity) — which is usually the moment people realise they needed it.
And a pleasing tie-back: the commit graph is a self-referencing M:N relationship — a commit
has many parents and many children — which is exactly why parent appears as a repeated line in a
commit object rather than as a single field.
Common mistakes
| Mistake | Correction |
|---|---|
| "Git stores diffs" | It stores snapshots; deltas are a storage optimisation in packfiles. |
| "Git tracks renames" | Blobs have no name — renames are detected by similarity. |
| Thinking a branch is heavy | It is a 41-byte file. |
| Panicking at "detached HEAD" | It means HEAD holds a hash. git switch -c name. |
| Committing a large binary | It is in history forever, on every clone. |
| Assuming a deleted branch loses commits | Only the pointer went (Day 087). |
HEAD^2 for "two back" |
^2 is the second parent; ~2 is two back. |
git gc treated as dangerous |
It repacks reachable objects. Routine. |
A comma-separated tags column |
M:N is always a join table. |
| Putting the FK on the 1 side | It goes on the N side. |
Interview questions
Q: What are Git's object types, and how are they related?
Blobs, trees, commits and tags. A blob is file content and nothing else — no name, no mode, no timestamp. A tree is a directory listing: mode, type, hash and name for each entry, and trees nest, so one tree is a whole project snapshot. A commit points at one tree, plus its parent commits, author, committer and message. All four are stored in a content-addressed database — the object's name is the SHA of its content — and that single design decision explains almost everything else about Git.
Q: Why does Git not track renames?
Because a blob has no name in it. The name lives in the tree entry, so a rename is one tree entry disappearing and another appearing with the same blob hash. Git detects renames at read time by similarity — that's a heuristic with a threshold, which is why
git log --followexists, why it sometimes loses the trail, and why renaming a file while heavily editing it shows up as a delete plus an add. It also has an upside: identical content is stored exactly once no matter how many paths point at it.
Q: Why does rebasing change commit hashes?
Because a commit's hash covers its parent's hash, so history is a hash chain. Replaying a commit onto a different base gives it a different parent, therefore different content, therefore a different hash — and every descendant changes too. Rebase doesn't edit commits, it builds new ones and moves a branch pointer; the originals stay in the object database until garbage collection, which is what makes the reflog able to rescue you. It's also why force-push exists, and why deleting a secret in a later commit does nothing — the old blob is still reachable from the old commits.
Q: What actually happens when you run git commit?
Four plumbing steps. Each staged file is hashed into a blob, the index is snapshotted into a tree, a commit object is written pointing at that tree with the current HEAD as parent, and the current branch ref is updated to the new commit. You can run all four by hand with
hash-object,write-tree,commit-treeandupdate-ref— and Git can't tell the difference afterwards, which is the demonstration that there's nothing else in there.
Q: How do you model a many-to-many relationship?
With a third table whose primary key is the pair of foreign keys. It's not a workaround — a column holds one value from its domain, so "many" can't live in a column, and a comma-separated list is a list smuggled into a scalar with no index, no foreign key and no integrity. The join table is also where the relationship's own attributes live: enrolled_at, role, quantity. That's usually the moment it becomes obvious the table was needed, because those facts belong to neither side.
Mini task
- Build the commit by hand from Part 3, start to finish, in a scratch repo. Then
git log. git cat-file -p HEAD, then-pits tree, then-pa blob. Walk the graph by hand.- Create two files with identical content, commit, and count the blobs. Explain.
git hash-objectthe same content twice. Explain why the hash is the same.cat .git/refs/heads/mainandcat .git/HEAD. Then create a branch andcatit.git update-ref refs/heads/experiment <some-old-sha>— create a branch with no Git command you have ever used before, andgit logit.git switch --detach HEAD~2, commit something, switch back, and find your commit again withgit reflog.- Commit a 20 MB file, delete it, commit again, and
git count-objects -vH. Explain the size. git log --format=raw -3and read the parent lines.- Find a merge commit and print
HEAD^1andHEAD^2. - D-03: draw the ER diagram for orders, customers, products and order lines. Mark cardinality and participation, then map it to tables and say where each FK goes.
Exit questions
Answer aloud, no notes.
- Name the four object types and what each contains.
- What is an object's name?
- Where does a filename live, and why does that matter?
- Why is identical content stored once?
- Why can't Git track renames, and what does it do instead?
- Why does changing an old commit change every later hash?
- Derive force-push from the object model.
- What is a branch, physically?
- What is detached HEAD, and what is the fix?
HEAD~2vsHEAD^2.- What are the four steps of
git commit? - Snapshots vs deltas — which does Git store, and where do deltas appear?
- Where does the FK go for 1:N, and what does M:N become?
Articulation drill
Record two minutes: "Explain how Git works to someone who uses it daily but has never looked inside."
One sentence first, and make it the structural one: "Git is a content-addressed key-value store. You put content in and get back the SHA of that content, and everything else — branches, history, merges — is pointers into that store."
Then the four objects, with the point of each: "a blob is file content with no name at all. A tree is a directory listing — names, modes, and hashes — and trees nest, so one tree is a whole project snapshot. A commit is one tree plus its parents plus a message. And a branch is a file containing a single hash, which is why creating one is instant."
Then earn it by deriving three things people usually memorise: "because the name is the hash
of the content, identical files are stored once. Because a blob has no name, Git can't track renames —
it detects them by similarity afterwards, which is why --follow is imperfect. And because a commit's
hash covers its parent's hash, you cannot change an old commit without changing every commit after it.
That last one is the whole explanation for rebase creating new commits, for force-push existing, and
for why deleting a leaked secret in a new commit doesn't remove it."
Close on the proof: "and the way I convinced myself was building a commit with plumbing —
hash-object, write-tree, commit-tree, update-ref. Four commands, and Git can't tell it from a
real one. There's genuinely nothing else in there."
Previous: Day 083 · Tomorrow: Day 085 — the staging area, add -p,
commit messages that survive review, and what to do when a secret is committed