Exercises — Git internals — objects (blob, tree, commit, tag), DAG structure
Level 1 — Recognition
L1.1 — Name the object
For each thing below, name which of the four object types (blob / tree / commit / tag) holds it:
- The raw bytes
hi\nof a file. - The line
100644 blob 3b18e5... hello.txt. - A message "Fix login bug", an author, and a pointer to one root directory.
- A GPG-signed, named pointer that says "this exact commit is release v2.0".
Recall Solution
- blob — pure content, no name.
- tree — a directory listing entry
(mode, type, hash, name). - commit — snapshot pointer (tree + parents + author + message).
- tag (annotated) — a named, signed pointer to another object.
L1.2 — Parent count
State how many parents each of these commits has:
- (a) the very first commit in a repository,
- (b) an ordinary commit made after editing one file,
- (c) a commit created by
git mergeof two branches.
Recall Solution
(a) 0 parents (root commit). (b) 1 parent. (c) 2 parents (this is the only place history fans in). Octopus merges can have more, but the standard two-branch merge is exactly 2.
Level 2 — Application
L2.1 — Hash an empty blob by hand
Compute the store string and confirm the SHA-1 of an empty file is the famous e69de29.... Then explain in one sentence why everyone recognises this hash.
Recall Solution
Content is "" (zero bytes), so :
It is recognisable because every empty file in every Git repo produces this same hash — content-addressing means identical (here, empty) content ⇒ identical key. See Hashing & SHA-1 for why one flipped bit changes the whole digest.
L2.2 — Hash the blob for hi\n
The file hello.txt contains the three bytes h, i, newline. Write out the store string exactly (show the byte count) and state what Git's git hash-object would return conceptually.
Recall Solution
The content is 3 bytes: h i \n. So:
Git computes . The point of the exercise is the framing: the header is blob 3\0 where 3 is the content length, not the store length. Feeding just hi\n (no header) to a raw SHA-1 tool gives a different, wrong hash.
L2.3 — Dedup accounting
You commit hello.txt = "hi\n". Later you add notes.txt = "hi\n" (identical bytes) and commit again. Count how many new blobs, new trees, and new commits each step creates.
Recall Solution
First commit: 1 new blob (hi\n → B), 1 new tree (T listing hello.txt→B), 1 new commit (C, 0 parents).
Second commit: 0 new blobs — notes.txt has the same bytes hi\n, so same store, same hash B, already stored. 1 new tree (T2 now lists two entries; its bytes changed so its hash changed). 1 new commit (C2 with tree=T2, parent=C).
Totals across both commits: 1 blob, 2 trees, 2 commits.
Level 3 — Analysis
L3.1 — Why acyclic, mechanically?
Explain, using the hash formula, why it is impossible to build a cycle in Git's commit graph — why a commit can never (even maliciously) point forward to a descendant.
Recall Solution
A commit's hash is where store includes the parent hashes. To make commit list commit as parent, 's hash must already exist — i.e. was created before . For a cycle you'd need 's hash to appear inside 's content, but was hashed before existed. You cannot embed a hash of content that mentions your own not-yet-computed hash — that's a fixed-point you cannot solve for SHA-1. Hence edges only ever point backward in time: directed + acyclic = a DAG. This is the same argument that makes persistent structures safe to share.
L3.2 — Rename detection
A repo has report.txt (blob B). You run git mv report.txt summary.txt and commit — content unchanged. State which objects are new and which are reused, and explain how git log --follow can still detect this was a rename.
Recall Solution
Blob reused: content is unchanged, so hash B is identical — no new blob. New tree: the directory listing now says ... B summary.txt instead of ... B report.txt; the tree's bytes changed → new tree hash. New commit: points to the new tree, parent = previous commit.
Rename detection works because the same blob hash B appears under a different name in old vs new tree. Git sees "a name disappeared, an identical-content name appeared" and reports a rename. If the name were baked into the blob, B would differ and this heuristic would fail.
L3.3 — Merkle integrity
Suppose an attacker flips one byte inside a deep blob far down the tree (e.g. src/util/helpers.py). List every object hash that must change, from the blob up to the branch tip, and name the property this demonstrates.
Recall Solution
The blob's content changed → its hash changes. Its parent tree lists that blob's hash, so that tree changes. Every ancestor tree up to the root tree changes in turn. The commit lists the root tree hash → the commit hash changes. Every later commit lists its parent's hash → all descendant commits change. Finally the branch ref (a pointer, see Branches and HEAD) would have to be re-pointed. This is the Merkle tree property: a single leaf change propagates hash changes all the way to the root — tamper-evidence. You cannot secretly edit history; every hash above the edit betrays it.
Level 4 — Synthesis
L4.1 — Build the DAG from a log
A repository's commits were made in this order: root C0; then C1 (parent C0); then branch off C1 into C2 (parent C1) on branch feature; meanwhile C3 (parent C1) on main; then a merge C4 of feature and main.
Draw/describe the DAG: list every edge (child → parent) and identify which commits have 0, 1, and 2 parents. See the figure below.

Recall Solution
Edges (each is "child points to parent"):
- and (merge → two edges)
Parent counts: : 0 (root). : 1 each. : 2 (merge, history fans in). Note is where history fans out (two children , ) and is where it fans in. Fan-out is branching; fan-in is merging. It is still acyclic — every arrow points to an earlier commit.
L4.2 — Ref semantics under branch delete
After L4.1, you run git branch -d feature while main points at C4. State exactly what is deleted, whether C2 (only reachable through the merge now) survives, and when C2 could actually be lost.
Recall Solution
git branch -d feature deletes only the ref .git/refs/heads/feature — a 40-char pointer file, not any object. C2 is still reachable: C4 lists C2 as a parent, and C4 is reachable from main. So C2 (and its tree/blobs) survive fully.
C2 becomes lost only if it were left unreachable from all refs and HEAD (e.g. an abandoned commit that no branch or merge references) and then git gc prunes it after its reflog entries expire. Reachable objects are never collected.
Level 5 — Mastery
L5.1 — Reconstruct a state from hashes alone
You are handed just three lines from .git/objects (decompressed, conceptually):
- object
C:commit ...withtree T, no parent, message "init". - object
T:tree ...with entry100644 blob B app.py. - object
B:blob ...with contentprint("hi")\n.
Given only the commit hash C, describe the exact walk Git performs to reproduce the entire working tree on disk, and state why no other information (no filenames stored in the commit, no diffs) is needed.
Recall Solution
Walk from C:
- Read commit
C→ get its root tree hashT(and parent list, empty here). - Read tree
T→ each entry gives(mode=100644, type=blob, hash=B, name=app.py). - For each blob entry, read blob
B→ write its contentprint("hi")\nto a file namedapp.pywith mode100644. For any sub-tree entry, recurse into that tree, creating a subdirectory.
The commit needs to store only the root tree hash because the tree is a full recursive snapshot: names, modes, and child hashes all live in trees (Merkle DAG). No diffs are needed because each commit is a complete snapshot — you reconstruct state directly, never by replaying changes. That is the whole payoff of the content-addressed, snapshot-based model.
L5.2 — Predict identical trees across commits
Two different commits C_a and C_b (different messages, different authors, made months apart) end up with byte-identical project contents and layout. Prove that they must reference the same tree hash, yet remain different commits. What single fact makes both statements true at once?
Recall Solution
The tree hash is over the directory listing bytes (modes, names, child hashes) only. If both projects have identical layout and identical file contents, every blob hash matches, so every tree entry matches, so the root tree's store bytes are identical → same tree hash T. This is deduplication one level up from blobs.
Yet C_a \ne C_b because a commit's store also includes the message, author, committer timestamps, and parent hash — all different here. So the same tree can be shared by many commits while each commit stays unique.
The single unifying fact: hashing is over exactly the bytes you frame, nothing more — trees hash their listing, commits hash their metadata + tree + parents. Same input bytes ⇒ same hash; any differing byte ⇒ different hash (Hashing & SHA-1).
Recall Feynman self-check (say it out loud)
"A blob is nameless bytes. A tree is a labelled tray of hashes. A commit is a photo of the whole room that also points at the previous photo. Because each photo points backward, I can walk history but never circle — you can't photograph a photo that doesn't exist yet. Deleting a branch tears up a sticky-note pointer, not the photos; the photos vanish only when nothing points to them and the cleaner (gc) comes by."