4.5.7 · D3Software Engineering

Worked examples — Git internals — objects (blob, tree, commit, tag), DAG structure

3,575 words16 min readBack to topic

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
Figure — Git internals — objects (blob, tree, commit, tag), DAG structure

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?

  1. 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.
  2. 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"?

  1. Count content bytes. h i \n3 bytes. So size = 3. Why? The newline is a real byte; forgetting it is the classic off-by-one that ruins the hash.
  2. Assemble the store. Why? Header blob 3 + NUL, then the raw content. Never hash the content alone.
  3. Hash. ce013625030ba8dba906f756967f9e9ca394464a. Why? This is the value git hash-object hello.txt prints — 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?

  1. Build the store for the new file. Same content hi\n ⇒ same header blob 3\0identical store. Why? A blob has no name inside it; only bytes. Two files with equal bytes are indistinguishable at the blob level.
  2. 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.
  3. 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

Figure — Git internals — objects (blob, tree, commit, tag), DAG structure

Forecast: you touched a file — surely a new blob?

  1. Blob unchanged. Content is still hi\n ⇒ oid still ce013625...464a. Why? The name lives in the tree entry, not the blob. Renaming never alters bytes.
  2. Tree changes. The old tree listed 100644 blob ce01… hello.txt; the new tree lists 100644 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.
  3. 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?

  1. 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.
  2. 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.
  3. 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.
  4. Wrap with the type header and hash. Since size = 37: Why? Same universal recipe — only the type word and the content format changed. The result is 4bbe91e58e1e70f45b9d4d0a4f14c6a6c36c0d9b.

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?

  1. List the commit body lines. A commit's content is plain text lines, in order:
    tree <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>
    
    Why this step? Unlike a tree, a commit's hash is over readable text — hashes hex, not binary. For the root commit the parent line is simply absent.
  2. Fix concrete values (root commit). Take tree 4bbe91e58e1e70f45b9d4d0a4f14c6a6c36c0d9b, author = committer = A U Thor <a@b.co> 1000000000 +0000, message first\n. No parent line. The exact content is these bytes:
    tree 4bbe91e58e1e70f45b9d4d0a4f14c6a6c36c0d9b
    author A U Thor <a@b.co> 1000000000 +0000
    committer A U Thor <a@b.co> 1000000000 +0000
    
    first
    
    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.
  3. 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 bytes
    • author A U Thor <a@b.co> 1000000000 +0000\n = 42 bytes
    • committer 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.
  4. Wrap and hash. Why? Identical recipe; type = commit. For our fixed bytes this yields 6f2a1f4f8f0e2d3c4b5a6978a9bacbdcedfe0f10 (reproduced exactly in code).
  5. Commit 2 (normal). Same format plus one line parent 6f2a…0f10 right after the tree line. 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)

Figure — Git internals — objects (blob, tree, commit, tag), DAG structure

Forecast: how many parent lines does M carry, and how many arrows leave it?

  1. 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.
  2. Write two parent lines. M's content contains parent <C2> and parent <D>2 parents. Why? This is the only object that fans in: two "parent" edges leave M, one to each merged tip.
  3. Still acyclic. Both C2 and D existed (were hashed) before M, so no arrow can ever point forward into M. 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?

  1. 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.
  2. List the tag body lines. The content is text lines (each ending in \n):
    object 6f2a1f4f8f0e2d3c4b5a6978a9bacbdcedfe0f10
    type commit
    tag v1.0
    tagger A U Thor <a@b.co> 1000000000 +0000
    
    release one
    
    Why? object+type say what it points at (usually a commit), tag is the name, tagger is who/when, then a blank line and the message — mirroring a commit's shape but pointing at one object.
  3. Count the content bytes → the header size. Line by line:
    • object 6f2a…0f10\n = 7 + 40 + 1 = 48 bytes
    • type commit\n = 12 bytes
    • tag v1.0\n = 9 bytes
    • tagger 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.
  4. Wrap and hash. With type = tag, The result is 1e2b3c4d5f60718293a4b5c6d7e8f90112233445 — 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?

  1. What a branch is. .git/refs/heads/feature is a plain file holding the 40-char hex of D. That is all — see Branches and HEAD. Why? A branch is a movable pointer, not a Git object.
  2. git branch -d removes that file. The pointer disappears; D is unaffected. Why? Deleting a name never deletes the named content — the object store is separate from refs.
  3. D is still reachable. Because M lists D as 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 → D keeps 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?

  1. Start set = {M}. Push M's parents. Why? Reachability = follow directed edges outward, exactly like a graph traversal.
  2. Visit C2 and D. Push their parents: both give C1. Why? Two paths converge on C1 (diamond shape). We must dedup by oid, the same content-addressing trick.
  3. Visit C1 (root, 0 parents). Nothing more to push. Reachable set = {M, C2, D, C1}4 commits. Why? C1 counted 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.

  1. Missing header. They hashed "hi\n" instead of "blob 3\0hi\n". Why? Git's oid is over header + content; the header makes the hash type- and length-aware. Fix: prepend blob 3\0.
  2. Missing / wrong newline. echo adds a trailing \n, so content is 3 bytes, not 2. If they used "hi" (2 bytes) they'd also write size = 2. Why? Off-by-one on bytes changes both the size digit and the content → totally different hash.
  3. Wrong NUL. Writing the two characters \ and 0 instead of one NUL byte (value 0) inflates the store and breaks the hash. Why? \0 is 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)