Skip to content
Path to Engineer
All lessons
PythonTooling18 min read

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.

Day 84 of the 488-day pathway. Published in full — nothing held back.

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, reset and reflog stop 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 sideorder.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 --follow exists, 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-tree and update-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

  1. Build the commit by hand from Part 3, start to finish, in a scratch repo. Then git log.
  2. git cat-file -p HEAD, then -p its tree, then -p a blob. Walk the graph by hand.
  3. Create two files with identical content, commit, and count the blobs. Explain.
  4. git hash-object the same content twice. Explain why the hash is the same.
  5. cat .git/refs/heads/main and cat .git/HEAD. Then create a branch and cat it.
  6. git update-ref refs/heads/experiment <some-old-sha>create a branch with no Git command you have ever used before, and git log it.
  7. git switch --detach HEAD~2, commit something, switch back, and find your commit again with git reflog.
  8. Commit a 20 MB file, delete it, commit again, and git count-objects -vH. Explain the size.
  9. git log --format=raw -3 and read the parent lines.
  10. Find a merge commit and print HEAD^1 and HEAD^2.
  11. 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.

  1. Name the four object types and what each contains.
  2. What is an object's name?
  3. Where does a filename live, and why does that matter?
  4. Why is identical content stored once?
  5. Why can't Git track renames, and what does it do instead?
  6. Why does changing an old commit change every later hash?
  7. Derive force-push from the object model.
  8. What is a branch, physically?
  9. What is detached HEAD, and what is the fix?
  10. HEAD~2 vs HEAD^2.
  11. What are the four steps of git commit?
  12. Snapshots vs deltas — which does Git store, and where do deltas appear?
  13. 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