4.5.7 · D2Software Engineering

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

1,551 words7 min readBack to topic

Step 0 — The one word we must define first: hash

Before any Git object exists, we need one tool. A hash function eats any sequence of bytes and spits out a fixed-length fingerprint (Git uses SHA-1 → 40 hex characters).

Why this tool and not, say, a counter (1, 2, 3...)? A counter would give the same file different IDs on two computers — so they could never agree it's "the same file." A hash gives an ID derived from the content itself, so two machines that never talked reach the identical ID for identical bytes. That single property is what makes Git distributed. That's why hashing, not numbering.

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

Step 1 — Give the file's bytes an identity: the blob

WHAT. Take one file, hello.txt, whose entire contents are the 3 bytes h, i, newline — written hi\n. We wrap those bytes and hash them.

WHY. We want the content to have a name that does not depend on where the file lives or what it's called. So the blob deliberately throws away the filename — it is pure bytes.

PICTURE (annotated term-by-term):

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

Step 2 — Give the name a home: the tree

WHAT. The blob has no filename. So we make a tree — a directory listing — with one line saying "the file called hello.txt is blob B."

WHY. By separating name (tree) from bytes (blob), renaming a file only changes the tree; the blob is reused untouched. This is where Git's rename detection comes from.

PICTURE (each field explained where it sits):

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

Step 3 — Snapshot the whole project: the commit

WHAT. Wrap the root tree with authorship + a message → a commit C. Since this is the first commit, it has no parent.

WHY. From the commit you can reach every byte of the project: commit → tree → blobs. It's a photo of the entire room, not a diff.

PICTURE (line by line):

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

Step 4 — Deduplication: add an identical file

WHAT. Create notes.txt whose contents are also hi\n, then commit again.

WHY (the punchline of content-addressing): identical bytes → identical store string → identical hash B. Git stores no new blob. Only the tree changes, because the listing now has two names.

PICTURE:

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

Step 5 — The history edge: parent makes the DAG

WHAT. The second commit C2 records tree = T2 and parent = C.

WHY. That parent line is the arrow C2 → C. Follow parents backwards and you walk through time. This is exactly a directed acyclic graph.

Why acyclic, guaranteed? To create C2 you must already know C's hash — C existed first. You can never make a commit that points to a not-yet-created descendant, because you'd have to hash content mentioning a hash that doesn't exist yet. So arrows only ever point backwards in timeno cycle is possible.

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

Step 6 — Edge cases: 0, 1, and 2+ parents

WHAT. Every commit fits one of exactly three shapes.

Commit kind # parents What the DAG does
Root 0 history starts
Normal 1 history chains
Merge 2 (or more) history fans in

WHY it matters. A branch is just a movable pointer to one commit; a git merge of two branch tips makes a commit M with parent = [C2, D]two arrows leave M. That's the only place history joins back together.

Figure — Git internals — objects (blob, tree, commit, tag), DAG structure
Recall Degenerate case: the empty file

An empty file is 0 bytes → store → hash e69de29bb2d1d6434b8b29ae775ad8c2e48c5391. Even nothing has a stable identity. This exact hash appears in nearly every repo.


The one-picture summary

Blobs (bytes) ← named by trees (listings) ← snapshotted by commits (photos) ← chained by parents (time). Same bytes always share one blob; commits point backward, never forward, so the whole thing is a DAG. Because every ID is a hash of header + content + child-links, editing any old object would change every hash above it — that's tamper-evidence, and it's why this structure is a Merkle tree.

Figure — Git internals — objects (blob, tree, commit, tag), DAG structure
Recall Feynman retelling — the whole walkthrough in plain words

A magic box prints a sticker from a toy's exact shape (Step 0: same shape → same sticker, that's a hash). Put in the file's bytes and it becomes a blob with sticker B (Step 1) — no name, just stuff. A tree is a labelled tray: "sticker B is called hello.txt" (Step 2). A commit is a photo of the whole room plus the tray label (Step 3). Copy the file's contents into a second file and the box prints the same sticker B — no new toy stored, just a new label in the tray (Step 4). Take a second photo and staple a note: "the photo before me is that one" — that staple is the parent, and it's the edge in the graph (Step 5). Every photo either starts a roll (0 staples), continues it (1), or joins two rolls (2 — a merge) (Step 6). Staples always point at older photos, so you can walk backwards forever but never in a circle. And since each photo's sticker is made from everything it points at, you can't quietly edit an old photo — every later sticker would change.

Recall Quick self-check

Why does copying hi\n into a new file create no new blob? ::: Same bytes → same store string "blob 3\0hi\n" → same SHA-1 → same key. Deduplicated. Where does the filename hello.txt live? ::: In the tree entry, never in the blob. Why is Git history acyclic? ::: A parent's hash is fixed before the child exists, so arrows point only backward in time — no cycle can form. How many parents can a commit have? ::: 0 (root), 1 (normal), 2+ (merge).