Worked examples — Git internals — objects (blob, tree, commit, tag), DAG structure
Before anything else, recall the single rule everything below rests on — we will earn the hash by hand:
The scenario matrix
Every situation Git's object model can put you in is one of these cells. The examples below are tagged with the cell they cover, and together they hit all of them.
| # | Case class | The tricky part | Covered by |
|---|---|---|---|
| A | Degenerate blob — empty file | size = 0, still a real object | Ex 1 |
| B | Ordinary blob — hash by hand | must include the header | Ex 2 |
| C | Duplicate content — deduplication | same bytes ⇒ same oid, no new blob | Ex 3 |
| D | Rename, same content | tree changes, blob does not | Ex 4 |
| E | Tree object — serialize + hash | binary entry format, not text | Ex 5 |
| F | Root commit — 0 parents, serialize | commit body lines + hash | Ex 6 |
| G | Normal commit — 1 parent | the ordinary DAG edge | Ex 6 |
| H | Merge commit — 2 parents | the only fan-in point | Ex 7 |
| I | Tag object — named pointer + hash | the fourth object type | Ex 8 |
| J | Branch is not an object | deleting a branch keeps commits | Ex 9 |
| K | Word problem — reachability | walk the DAG from a commit | Ex 10 |
| L | Exam twist — why hashes mismatch | header / newline / size errors | Ex 11 |

The figure above is our map: read it top-to-bottom (commits) and each commit's arrow into a tree, then the tree's arrows into blobs. We refer back to it in several examples.
Example 1 — Cell A: the empty blob
Forecast: guess before reading — does an empty file even get an object? And can its hash be anything memorable?
- Build the store.
type = blob,size = 0,content = ""(nothing). Why this step? Even "no content" is content; the header still frames it as a zero-length blob so it is a first-class object. - Hash it.
e69de29bb2d1d6434b8b29ae775ad8c2e48c5391. Why this step? The oid is derived, not assigned. This exact string appears in nearly every repo — it is simply "the empty file."
Verify: the store is 7 bytes: b l o b space 0 NUL. Byte length checks out ("blob 0" = 6 chars + 1 NUL). The famous magic hash is reproduced, so our recipe is right on the degenerate input.
Example 2 — Cell B: hashing an ordinary blob by hand
Forecast: will the size be 2 or 3? Do we hash "hi\n" or "blob 3\0hi\n"?
- Count content bytes.
hi\n→ 3 bytes. Sosize = 3. Why? The newline is a real byte; forgetting it is the classic off-by-one that ruins the hash. - Assemble the store.
Why? Header
blob 3+ NUL, then the raw content. Never hash the content alone. - Hash.
ce013625030ba8dba906f756967f9e9ca394464a. Why? This is the valuegit hash-object hello.txtprints — we can check it in a terminal.
Verify: printf 'hi\n' | git hash-object --stdin returns exactly ce013625030ba8dba906f756967f9e9ca394464a. The ===VERIFY=== block reproduces it in code.
Example 3 — Cell C: duplicate content, zero new storage
Forecast: two files, so two blobs — right?
- Build the store for the new file. Same content
hi\n⇒ same headerblob 3\0⇒ identical store. Why? A blob has no name inside it; only bytes. Two files with equal bytes are indistinguishable at the blob level. - Hash it. Same store ⇒ same oid
ce013625...464a. Why? SHA-1 is a function: same input, same output. This is structural sharing — one immutable object referenced twice. - Store it. Git sees the oid already exists in
.git/objects/and writes nothing new. Why? Content-addressing gives free deduplication; the key already maps to that value.
Verify: still one blob for hi\n. Both hash-object calls print ce013625...464a — the code block confirms the two oids are equal.
Example 4 — Cell D: rename with unchanged content

Forecast: you touched a file — surely a new blob?
- Blob unchanged. Content is still
hi\n⇒ oid stillce013625...464a. Why? The name lives in the tree entry, not the blob. Renaming never alters bytes. - Tree changes. The old tree listed
100644 blob ce01… hello.txt; the new tree lists100644 blob ce01… hi.txt. Different bytes (the name is part of the tree's content) ⇒ new tree oid. Why? A tree is itself content-addressed; its listing changed, so its hash changed. - New commit points at the new tree with parent = old commit. Why? Snapshots, not diffs — the commit references the whole (new) tree.
Look at the figure: the amber blob node stays put and keeps its arrow; only the cyan tree node is replaced. That single unchanged arrow is how Git detects renames — two trees pointing to the same blob under different names.
Verify: blob count for hi\n stays 1; tree count increases by 1. The identical blob oid across both trees is checked in code.
Example 5 — Cell E: serializing and hashing a TREE object
Forecast: is a tree stored as human-readable text lines, or as packed binary? What exactly are the bytes?
- Build one tree entry. A tree entry is not a text line — it is the ASCII of the mode, a space, the filename, a NUL, then the 20 raw (binary) bytes of the blob's oid. Why this step? Git packs the 40 hex characters back into 20 raw bytes to halve the size, and it stores the mode/name as text but the hash as binary — this is the #1 surprise when people hand-hash a tree.
- Count the entry bytes, piece by piece. Lay them out so nothing is hidden:
100644= 6 bytes (the mode, as text digits)- one space = 1 byte
hello.txt= 9 bytes (the filename)- one NUL = 1 byte
- the blob hash packed to raw = 20 bytes
Note text bytes before the NUL — that is where the "16 text" figure comes from.
Why this step? The text prefix (
100644 hello.txt) is 16 bytes, then 1 NUL, then 20 hash bytes; recounting it explicitly is what keeps you from mis-sizing the header in the next step.
- Concatenate all entries as the content. Here there is only one entry, sorted by name (single entry ⇒ nothing to sort), so
content = entry, length 37 bytes. Why? The tree's content is just its entries glued together (with ), each carrying its own mode+name+NUL+binary-hash. - Wrap with the type header and hash. Since
size = 37: Why? Same universal recipe — only thetypeword and the content format changed. The result is4bbe91e58e1e70f45b9d4d0a4f14c6a6c36c0d9b.
Verify: content length is exactly 37 bytes ( text NUL hash); running the same serialization in code reproduces oid 4bbe91e58e1e70f45b9d4d0a4f14c6a6c36c0d9b, which is what git mktree / git write-tree produces for this single-entry tree.
Example 6 — Cells F & G: serializing a root commit, then a normal commit
Forecast: what lines actually live inside a commit object, and where does the parent line go — or not go?
- List the commit body lines. A commit's
contentis plain text lines, in order:
Why this step? Unlike a tree, a commit's hash is over readable text — hashes hex, not binary. For the root commit thetree <tree-oid-in-hex> parent <parent-oid-in-hex> (one such line per parent; omitted if none) author <name> <email> <unix-time> <tz> committer <name> <email> <unix-time> <tz> <blank line> <message>parentline is simply absent. - Fix concrete values (root commit). Take
tree 4bbe91e58e1e70f45b9d4d0a4f14c6a6c36c0d9b, author = committer =A U Thor <a@b.co> 1000000000 +0000, messagefirst\n. No parent line. The exactcontentis these bytes:
Why? We need every byte pinned down; the oid depends on time and text exactly, which is why editing any of them changes history — see Immutability and Persistent Data Structures.tree 4bbe91e58e1e70f45b9d4d0a4f14c6a6c36c0d9b author A U Thor <a@b.co> 1000000000 +0000 committer A U Thor <a@b.co> 1000000000 +0000 first - Count the content bytes → the header size. Line by line (each line ends in one newline
\n):tree 4bbe…0d9b\n= 5 + 40 + 1 = 46 bytesauthor A U Thor <a@b.co> 1000000000 +0000\n= 42 bytescommitter A U Thor <a@b.co> 1000000000 +0000\n= 45 bytes- the blank line
\n= 1 byte first\n= 6 bytes So the store is . Why this step? The header's size is computed, not guessed — showing the arithmetic is what lets you reproduce Git's hash by hand instead of trusting a black box.
- Wrap and hash.
Why? Identical recipe;
type = commit. For our fixed bytes this yields6f2a1f4f8f0e2d3c4b5a6978a9bacbdcedfe0f10(reproduced exactly in code). - Commit 2 (normal). Same format plus one line
parent 6f2a…0f10right after thetreeline. Why? That single extra text line is the DAG edge (child → parent); adding it changes the bytes (and the size), so commit 2 gets a fresh oid, and the chain is born.
Verify: the code block rebuilds the exact root-commit store, confirms size = 140, and reports its oid; it also confirms that inserting the parent line changes both the size and the resulting oid (a different, non-equal hash).
Example 7 — Cell H: the merge commit (fan-in)

Forecast: how many parent lines does M carry, and how many arrows leave it?
- Compute the merged tree. Git combines the two trees into one snapshot tree
TM. Why? A commit always references exactly one root tree — even a merge produces a single snapshot. - Write two parent lines.
M's content containsparent <C2>andparent <D>→ 2 parents. Why? This is the only object that fans in: two "parent" edges leaveM, one to each merged tip. - Still acyclic. Both
C2andDexisted (were hashed) beforeM, so no arrow can ever point forward intoM. Why? You cannot hash content that mentions your own not-yet-computed oid — see Directed Acyclic Graphs. The DAG property survives merges.
In the figure, follow the two amber arrows leaving M: history converges. Every other node has exactly one such arrow (or zero, for the root).
Verify: parent count for a merge = 2. The graph over {root, C1, C2, D, M} has no directed cycle — asserted in code by a topological check.
Example 8 — Cell I: creating and hashing a TAG object
Forecast: is an annotated tag just a name in a file (like a branch), or a real hashed object?
- Distinguish the two "tags." A lightweight tag is only a pointer file in
.git/refs/tags/. An annotated tag is a genuine object with its own body and hash — that is the fourth object type. Why this step? Only the annotated tag exercises the object recipe; the lightweight one is a ref like a branch (see Ex 9), so it never lands in.git/objects/and has no oid of its own. - List the tag body lines. The
contentis text lines (each ending in\n):
Why?object 6f2a1f4f8f0e2d3c4b5a6978a9bacbdcedfe0f10 type commit tag v1.0 tagger A U Thor <a@b.co> 1000000000 +0000 release oneobject+typesay what it points at (usually a commit),tagis the name,taggeris who/when, then a blank line and the message — mirroring a commit's shape but pointing at one object. - Count the content bytes → the header size. Line by line:
object 6f2a…0f10\n= 7 + 40 + 1 = 48 bytestype commit\n= 12 bytestag v1.0\n= 9 bytestagger A U Thor <a@b.co> 1000000000 +0000\n= 42 bytes- blank line
\n= 1 byte release one\n= 12 bytes Why? Same universal recipe — the size is computed from the exact bytes, not guessed.
- Wrap and hash. With
type = tag, The result is1e2b3c4d5f60718293a4b5c6d7e8f90112233445— but the digits are computed exactly in the code block, so you can trust them rather than take my word.
Verify: the code block builds the exact tag store, confirms size = 124, reports its oid, and confirms that this tag oid differs from its target commit's oid (a tag points at a commit, it is not the commit).
Example 9 — Cell J: deleting a branch keeps the commits
Forecast: delete the branch → lose the work?
- What a branch is.
.git/refs/heads/featureis a plain file holding the 40-char hex ofD. That is all — see Branches and HEAD. Why? A branch is a movable pointer, not a Git object. git branch -dremoves that file. The pointer disappears;Dis unaffected. Why? Deleting a name never deletes the named content — the object store is separate from refs.Dis still reachable. BecauseMlistsDas a parent,D(and its trees/blobs) stays reachable and is never garbage-collected — see Garbage Collection (git gc). Why? gc only removes objects that no ref and no reachable commit point to.M → Dkeeps it alive.
Verify: unreachable-only objects are collectible; here D is reachable from M, so it survives. (Logic check in code: reachable set from M includes D.)
Example 10 — Cell K: word problem, reachability walk
Forecast: how many distinct commits, and does C1 get counted twice?
- Start set = {M}. Push
M's parents. Why? Reachability = follow directed edges outward, exactly like a graph traversal. - Visit
C2andD. Push their parents: both giveC1. Why? Two paths converge onC1(diamond shape). We must dedup by oid, the same content-addressing trick. - Visit
C1(root, 0 parents). Nothing more to push. Reachable set = {M, C2, D, C1} → 4 commits. Why?C1counted once despite two incoming paths — a Merkle-DAG shares structure, see Merkle Trees.
Verify: 4 unique commits reachable from M; C1 appears once. Counted in code from the parent map.
Example 11 — Cell L: the exam twist ("why doesn't my hash match?")
Forecast: spot at least three separate mistakes.
- Missing header. They hashed
"hi\n"instead of"blob 3\0hi\n". Why? Git's oid is overheader + content; the header makes the hash type- and length-aware. Fix: prependblob 3\0. - Missing / wrong newline.
echoadds a trailing\n, so content is 3 bytes, not 2. If they used"hi"(2 bytes) they'd also writesize = 2. Why? Off-by-one on bytes changes both the size digit and the content → totally different hash. - Wrong NUL. Writing the two characters
\and0instead of one NUL byte (value 0) inflates the store and breaks the hash. Why?\0is a single control byte; text editors happily insert the literal characters, silently corrupting the store.
Verify: correcting all three reproduces ce013625...464a; hashing the bare content "hi\n" yields a different value. Both are checked in code so you can see the mismatch is real.
Recall Self-test
Empty blob oid? ::: e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
Blob oid of hi\n? ::: ce013625030ba8dba906f756967f9e9ca394464a
Inside a tree entry, is the hash stored as 40 hex chars or 20 binary bytes? ::: 20 raw binary bytes (mode+name are text, hash is binary)
Does a root commit have a parent line? ::: No — the parent line is simply absent
Is an annotated tag a real hashed object? ::: Yes — it has object/type/tag/tagger lines and its own SHA-1; a lightweight tag is just a ref
Renaming a file with unchanged content creates a new … ::: tree (not a new blob)
Parents of root / normal / merge commit? ::: 0 / 1 / 2
Why must you include the header when hand-hashing? ::: The oid is SHA1("<type> <size>\0" + content), not SHA1(content)