Module 3 — How Git Stores Your Work (objects, refs, git cat-file)
Updated 8 September 2026
🧠 concept → 🧩 analogy → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (hidden)
Part A — The object database
A1. Plumbing and porcelain
Git is two layers. The commands you have used so far — add, commit, status — are porcelain: the polished, human-facing layer (the term is bathroom humor: porcelain is the fixture you touch). Underneath is plumbing: low-level commands that operate directly on Git's storage, one small job each. Porcelain commands are combinations of plumbing calls. In this module you will use two plumbing commands — git hash-object (compute an object's ID) and git cat-file (read an object back) — not because daily work needs them, but because they let you watch the machine run. Everyday Git never requires plumbing; understanding Git deeply requires having used it once.
Where does plumbing operate? Inside the .git directory from Module 2. Look at its layout — every name in it will mean something by the end of this page:
🧪 Exercise 3.1 — the filing room's floor plan
cd ~/git-course/kitchen
ls .git✅ Expected result — click to reveal
COMMIT_EDITMSG
HEAD
branches
config
description
hooks
index
info
logs
objects
refsWhat to read out of it (branches and description are legacy leftovers you can ignore): config is the local config file from Module 1's D2. index is the staging area from Module 2 — a single binary file, not a folder of copies. COMMIT_EDITMSG holds the last commit message (that is what your editor was actually editing in Exercise 2.11). hooks and logs get their own treatment later (Modules 15 and 7). The two stars of this module: objects — the snapshot database itself — and refs plus the HEAD file, which give hashes human-usable names. Everything Git knows lives in these few entries.
A2. Blobs — content under its own fingerprint
Git's database stores objects. The simplest kind is the blob: a file's content, nothing else. Not its filename, not its timestamp, not who wrote it — bytes only. Every object is stored under a name that Git computes from the content itself: the SHA-1 hash, 40 hexadecimal characters. Feed identical bytes to the hash, get the identical name, on any machine, any year. Change one character and the name changes completely. This scheme is called content-addressed storage, and it is the single design decision that everything else in Git falls out of.
Two consequences arrive immediately. Deduplication: if two files (or two commits' versions of one file) have identical content, they hash to the same name — so the database physically stores that content once. Integrity: the name is a checksum. If a disk error flips a bit inside an object, its content no longer matches its name, and Git notices the moment it reads the object.
🧪 Exercise 3.2 — hash content and see determinism
cd ~/git-course/kitchen
echo "flour, water, salt" | git hash-object --stdin # hash these bytes
echo "flour, water, salt" | git hash-object --stdin # ...again
echo "flour, water, salt!" | git hash-object --stdin # one character added✅ Expected result — click to reveal
0fd697a2b12cf580b93c1a645e9d7ad8fab27f54
0fd697a2b12cf580b93c1a645e9d7ad8fab27f54
8e0b7573b257e8b863bd1336d03522a42a9e0823What to read out of it: these hashes are not examples — your machine prints these exact strings, because the input bytes are identical. Same content, same hash, twice; then one added ! produces a hash sharing nothing with the first. Now the payoff: look back at Module 2, Exercise 2.6 — the diff header read index 0fd697a..8c3fb3f. That 0fd697a was this blob: the recorded content flour, water, salt from your first commit. Diff headers have been telling you object names since before you knew what objects were.
A3. cat-file — reading any object back
git hash-object writes (or just computes); git cat-file reads. Two flags do everything: -t <id> prints an object's type, -p <id> pretty-prints its content. IDs can be abbreviated to any unambiguous prefix — seven characters is customary. The objects themselves live under .git/objects/, filed by the first two characters of their hash: blob 0fd697a2… sits at .git/objects/0f/d697a2…. They are zlib-compressed binary — which the next exercise proves the honest way, by looking.
🧪 Exercise 3.3 — objects on disk are compressed, and cat-file is the reader
cd ~/git-course/kitchen
find .git/objects -type f | head -3 # where objects physically live
f=$(find .git/objects -type f | head -1)
head -c 40 "$f" # try reading one directly — garbage
echo
git cat-file -p 0fd697a # the proper reader (abbreviated ID)
git cat-file -p deadbeef # and a deliberate failure
echo "exit code: $?"✅ Expected result — click to reveal (the last command fails on purpose)
.git/objects/04/776eb6b5ed6e367dc3eff4115eaccd7ed7b4a0
.git/objects/0f/d697a2b12cf580b93c1a645e9d7ad8fab27f54
.git/objects/35/c40f25cb1836cc9cc2d237e6593fc5be646d91
x☺KÊÉOR0☻cHÎÉ/-ÒQ(O,I☻Rʼn9%
flour, water, salt
fatal: Not a valid object name deadbeef
exit code: 128What to read out of it: your find list will contain different commit hashes but the same blob hashes (like 0f/d697a2…) — content-derived names are reproducible, commit names are not (D1 explains the difference). The raw head -c 40 line is zlib-compressed bytes — your terminal will render different garbage, and that is the lesson: never read or edit .git/objects directly; the files are not text, and their names must keep matching their content. cat-file -p decompresses and prints the real content. The failure line shows Git refusing a name that matches no object — deadbeef is valid hex, but nothing in this database hashes to it; exit 128, fatal-error convention.
🎯 Interview questions — Part A
🎯 "What are the four core Git object types?" — asked verbatim in LabEx's Git Interview Questions and Answers, 2025
Blob, tree, commit, and tag. A blob stores one file's content — bytes only, no name or metadata. A tree represents one directory: a list of entries, each pairing a name and permission mode with the hash of a blob (a file) or another tree (a subdirectory). A commit stores a snapshot's metadata: the hash of the root tree, the parent commit's hash (or hashes), author, committer, timestamps, and the message. An annotated tag (Module 12) wraps a commit hash with a name, tagger, date, and message — used for releases. All four are stored the same way: content-addressed by hash in .git/objects, immutable once written.
The details that separate candidates: an average answer lists the four names. A strong answer can say what each contains and how they compose — commit → tree → sub-trees → blobs — and states the shared storage rule (content-addressed, immutable). The distinction that reliably impresses: filenames live in trees, never in blobs, which is why identical content dedupes across any number of names and why renames are detected rather than recorded.
🎯 "What is the purpose of the '.git' directory?" — asked verbatim in LabEx's Git Interview Questions and Answers, 2025
.git is the repository: it holds the complete object database (objects/ — every blob, tree, and commit ever recorded), the references that name useful commits (refs/, plus the HEAD file saying where you currently are), the staging area (index), repository-local configuration (config), hooks (hooks/), and reference history logs (logs/). The working files you see in the project directory are just one checked-out snapshot; delete .git and only history and settings vanish — delete everything but .git and the entire project history can be reconstructed from it.
The details that separate candidates: an average answer says "it stores Git's metadata." A strong answer names the specific contents and their roles, and can reason from them: the index being a single file explains staging speed; objects being immutable and content-addressed explains integrity; refs being tiny text files explains why branching is instant (Module 5). Operational credibility: mentioning you never hand-edit objects/ (compressed, checksummed), while config is a normal editable INI file.
Part B — Trees and commits
B1. A commit object, opened
Time to open a commit and read what it actually is. You have commit IDs already: every git commit in Module 2 printed one, like [main 04776eb] Add baking temperature and time — use your most recent one below (every commit ID on this page will differ on your machine; D1 explains exactly why, and it is the point of this Part).
🧪 Exercise 3.4 — read your latest commit object
cd ~/git-course/kitchen
git cat-file -t 04776eb # YOUR last commit's short ID, from its [main ...] line
git cat-file -p 04776eb✅ Expected result — click to reveal
commit
tree ad4a6c688334f159bcec25fcf8e205a3672253d1
parent 9a73162feb3e3337760f0e93b6a981cfe24c199b
author Aisha Rahman <[email protected]> 1788798467 +0000
committer Aisha Rahman <[email protected]> 1788798467 +0000
Add baking temperature and time
Bread was coming out pale at 180C. 220C with steam for the
first 10 minutes gives proper crust color.What to read out of it: a commit is astonishingly small — five headers and your message. tree names the snapshot itself: one hash pointing at the project's root directory as of this commit (B2 opens it). parent names the previous commit — this single line is what turns isolated snapshots into history: each commit knows the one before it, forming a chain walkable backwards. author and committer are your Module 1 identity plus a Unix timestamp and timezone; they diverge whenever a commit is re-recorded after the fact — your own --amend a minute later keeps the original author time but stamps a fresh committer time (Exercise 2.9's Date: line was exactly this), and rebase/cherry-pick (Module 10) can additionally make the two identities differ when one person re-applies another's work. And there is Exercise 2.11's full message — subject, blank line, body — stored forever. That is all a commit is: a pointer to a tree, a pointer to a parent, identity, time, message.
B2. Tree objects — where filenames live
The commit's tree line names a tree object. A tree is Git's directory listing: one line per entry — permission mode, object type, object hash, filename. Files point at blobs; subdirectories point at further trees. Filenames exist only here, which is why A2's blob had none.
🧪 Exercise 3.5 — open the tree, then a blob inside it
cd ~/git-course/kitchen
git cat-file -p ad4a6c6 # YOUR tree hash from Exercise 3.4's "tree" line
git cat-file -p 35c40f2 # then any blob hash from the listing (this one is baking.txt's)✅ Expected result — click to reveal
100644 blob 35c40f25cb1836cc9cc2d237e6593fc5be646d91 baking.txt
100644 blob 4134f931e78f103c9f32803c3b30039fd54ef6fb bread.txt
100644 blob ccaa999b2895e82b75a127fd5e9ace2ca5a4d220 method.txt
100644 blob 44e0f28ff2655dd11a1839e404f52d2e0aadd12a serving.txt
oven: 220C, 25 minWhat to read out of it: unlike commit IDs, this tree's ID and its four blob hashes should all match yours exactly if your files hold the same bytes — trees hash their entries (names, modes, blob hashes), which are all deterministic here, so content-addressing reproduces them too (if one differs, cat-file -p it and you will find a typo in that file; fix, git commit -a, re-run). 100644 is the recorded permission mode you saw at every create mode line in Module 2 (executables get 100755). Each line reads: this name, in this directory, has this content. The final line is the whole file-reading pipeline landed: commit → tree → blob → your bytes. Every file in every commit in every Git repository on earth is reached by exactly that walk.
B3. The graph, assembled
Put B1 and B2 together across two commits, and Git's whole storage story fits in one picture (hashes abbreviated; unchanged files are shared, not copied):
Diagram source
flowchart LR
C2["commit 04776eb<br>Add baking temp"] -->|"parent"| C1["commit 9a73162<br>Add kneading method"]
C2 -->|"tree"| T2["tree ad4a6c6"]
C1 -->|"tree"| T1["tree 55e8b68"]
T2 --> B1b["blob 35c40f2<br>baking.txt"]
T2 --> B2b["blob 4134f93<br>bread.txt"]
T1 --> B2b
B3b["blob ccaa999<br>method.txt"]
T2 --> B3b
T1 --> B3bCommit 04776eb added one file. So its tree is new (one more entry), the new file's blob is new — and every unchanged file's blob is pointed to by both trees. That is Module 1's "full snapshot per commit, yet not wasteful" promise, kept mechanically: a snapshot is a tree of pointers, and unchanged content is the same object under the same hash. Nothing is copied; nothing needs to be.
The object database is a warehouse where every box (blob) is labeled with a fingerprint of its contents. A tree is a packing list: "this shipment contains box #35c…, called baking.txt; box #413…, called bread.txt." A commit is the cover sheet stapled on top: who packed it, when, why — and a pointer to the previous shipment's cover sheet. New shipment, mostly unchanged goods? The new packing list simply lists the same box numbers again. The warehouse never stores two boxes with identical contents.
Where the analogy stops working. In a warehouse, two packing lists naming one box fight over who owns it — take the box and the other list is now wrong. Git's objects are immutable and never "taken": any number of trees can reference one blob forever, and deleting a file from the next commit deletes nothing from the warehouse — it just stops listing it. That is why history stays intact when files are deleted (the old snapshots still reference their blobs), and why "removing a secret from the repo" is genuinely hard (Module 2's Ticket 3, and Module 10).
🎯 Interview questions — Part B
🎯 "Explain the difference between a 'blob' and a 'tree' object in Git." — asked verbatim in LabEx's Git Interview Questions and Answers, 2025
A blob stores file content — raw bytes, compressed, named by their SHA-1 hash — with no filename, path, or metadata whatsoever. A tree stores structure: a directory's listing, each entry holding a permission mode, an object type, a hash, and a name. Tree entries pointing at blobs are files; entries pointing at other trees are subdirectories. So one tree plus recursion describes an entire project layout, while blobs hold the bytes it is made of.
The details that separate candidates: an average answer says "blob = file, tree = folder." A strong answer states where names live (trees only) and derives the consequences: identical content under different names or paths is one blob; renames are inferred by blob reuse, not recorded; and a file's mode (100644 vs 100755) is tracked in the tree — which is why "why did my whole repo show as modified after a chmod" is a tree-level question. Naming git cat-file -p as the way to inspect either object shows you have actually looked.
🎯 "What is the Git object model?" — asked verbatim in GeeksforGeeks' 70+ Git Interview Questions, updated Jul 2026
Git's storage is an immutable, content-addressed object database with four object types. Blobs hold file contents; trees hold directory listings mapping names to blobs and sub-trees; commits bind a root tree to parent commit(s), author/committer identity, timestamps, and a message; annotated tags wrap a commit with a signed, named label. Every object's ID is the SHA-1 hash of its own content, so objects deduplicate automatically and cannot be silently altered — changing anything produces a different object. History is the chain of commits following parent pointers; everything else in Git (branches, HEAD, tags) is just a named pointer into this graph.
The details that separate candidates: an average answer lists the four types. A strong answer adds the two properties that make the model work — content-addressing and immutability — and traces one concrete walk: commit → tree → blob → bytes. The closing move interviewers remember: "a commit doesn't contain changes; it contains a complete tree — diffs are computed between snapshots on demand." That single sentence corrects the most common Git misconception in one line.
Part C — Names for hashes: refs and HEAD
C1. A ref is a 41-byte text file
Hashes are perfect names for machines and terrible names for humans. Git's fix is the ref: a tiny text file whose filename is a memorable name and whose content is a commit hash. Your repository has had one all along: main — the word appearing in On branch main and every [main abc1234] commit line since Module 2. It lives at .git/refs/heads/main, and it is nothing more than the hash of your latest commit plus a newline.
This is the fact Module 5 is built on, so meet it in the flesh now: a branch is a file containing one hash. When you commit, Git writes the new commit object, then overwrites this file with the new hash. That is the entire mechanism behind "the branch moved forward."
🧪 Exercise 3.6 — read the branch file
cd ~/git-course/kitchen
cat .git/refs/heads/main
wc -c .git/refs/heads/main # count its bytes✅ Expected result — click to reveal
04776eb6b5ed6e367dc3eff4115eaccd7ed7b4a0
41 .git/refs/heads/mainWhat to read out of it: the file contains your latest commit's full hash — compare it with Exercise 3.4's short ID; they are the same commit. And it is 41 bytes: 40 hex characters plus one newline. Sit with that: the thing your team will one day argue about ("who force-pushed main?!") is a 41-byte text file naming one node in the object graph. Everything expensive is in objects/; names are nearly free — which is why Git can afford to let you create as many as you like (Module 5 does exactly that). One caveat for later: Git sometimes packs refs into a single .git/packed-refs file for efficiency, so if this cat ever says "No such file", the ref still exists — read it with plumbing (git rev-parse main) rather than assuming it is gone.
C2. HEAD — the "you are here" marker
One question remains: when you commit, how does Git know which branch file to advance? Answer: another tiny file, .git/HEAD — but this one usually contains not a hash, but a pointer to a ref: the line ref: refs/heads/main. HEAD answers "where am I right now"; the chain resolves HEAD → branch file → commit → tree → blobs. Commands you have used constantly consult it silently: git status reads HEAD to print On branch main; git commit writes the new commit, then advances whatever branch HEAD names; git diff --staged compares the index against HEAD's commit. A reference that points at another reference is called a symbolic ref — and HEAD is the one symbolic ref you deal with daily. (HEAD can also hold a raw hash directly — the "detached HEAD" state — which Module 5 teaches properly.)
Refs are labeled bookmarks in the company archive: the bookmark named main is clipped to one specific report. HEAD is a sticky note on the archivist's desk that says "currently working from: the main bookmark." File a new report and the archivist moves the bookmark HEAD names onto it — the sticky note itself never changes, it still just says "main". Switch projects (Module 5) and only the sticky note is rewritten.
Where the analogy stops working. A sticky note is documentation; HEAD is load-bearing. Git does not consult your memory of where you are — commands mechanically resolve HEAD, and several (commit, diff --staged, reset in Module 7) are defined in terms of it. If HEAD names a branch, committing moves the branch; that rigid rule, applied to a HEAD holding a raw hash instead, is exactly what produces the detached-HEAD surprises Module 5 defuses.
C3. The full map: HEAD, index, working tree
You now hold all three of Git's everyday locations with full precision, so let's restate Module 2's three trees exactly. HEAD resolves to the last committed snapshot — immutable objects in the database. The index (.git/index) is the draft of the next snapshot. The working tree is the real files. git status is three comparisons among these; git diff is working-tree-vs-index; git diff --staged is index-vs-HEAD; git commit turns the index into objects and advances HEAD's branch. Nothing in daily Git falls outside this map.
🧪 Exercise 3.7 — watch HEAD stay put while the branch moves
cd ~/git-course/kitchen
cat .git/HEAD
cp serving.txt serving-copy.txt # identical content, new name — dedup test too
git add serving-copy.txt
git commit -m "Add copy of serving info"
cat .git/HEAD # unchanged
cat .git/refs/heads/main # changed — the branch moved✅ Expected result — click to reveal
ref: refs/heads/main
[main a9d81ce] Add copy of serving info
1 file changed, 1 insertion(+)
create mode 100644 serving-copy.txt
ref: refs/heads/main
a9d81ce50f6b62a5e2c3ff992d10a4b1c8e30d55What to read out of it: HEAD reads identically before and after — committing never rewrites HEAD while it names a branch. What changed is refs/heads/main: it now holds the new commit's hash (matching the [main a9d81ce] line; your hash differs). The division of labor is total: HEAD says which branch you are on; the branch says which commit is its tip; the commit owns everything else. Keep serving-copy.txt around — the next exercise uses it to prove the dedup claim.
🎯 Interview questions — Part C
🎯 "What is a 'ref' in Git, and give an example?" — asked verbatim in LabEx's Git Interview Questions and Answers, 2025
A ref is a named pointer to a commit: physically, a small text file under .git/refs/ whose path is the name and whose content is a commit hash. Branches are refs under refs/heads/ (e.g. refs/heads/main — the file behind the name main); tags live under refs/tags/; remote-tracking branches under refs/remotes/ (Module 8). HEAD is the special symbolic ref that usually points at another ref rather than directly at a commit. Refs make hashes humane: main is easier to type, and unlike a hash it moves — committing advances the current branch's ref to the new commit.
The details that separate candidates: an average answer says "a pointer to a commit, like a branch." A strong answer distinguishes the three namespaces (heads/tags/remotes), knows a ref is ~41 bytes on disk (hence branching being free), and mentions packed-refs — refs get consolidated into one file for performance, so tooling should read refs via git rev-parse/git for-each-ref, never by cat-ing files. That last habit is the difference between scripts that work and scripts that break on the first packed repo.
🎯 "Explain what 'HEAD' refers to in Git." — asked verbatim in LabEx, 2025; also "What is the HEAD in Git?" in GeeksforGeeks, updated Jul 2026
HEAD is the repository's "current position" marker: the file .git/HEAD, normally containing ref: refs/heads/<branch> — a symbolic pointer to the branch you are on, which in turn points to its tip commit. Commands are defined against it: commit creates a child of HEAD's commit and advances the branch HEAD names; diff --staged compares the index to HEAD; status prints the branch HEAD names. When HEAD contains a raw commit hash instead of a ref, you are in detached-HEAD state — legitimately useful for inspecting old snapshots, with rules Module 5 covers.
The details that separate candidates: an average answer says "points to the latest commit." That is subtly wrong, and correcting it precisely is the differentiator: HEAD points to a branch (which points to a commit) — the indirection is what makes "committing moves the branch, not HEAD" true, and its absence is what defines detached HEAD. Knowing the resolution chain (HEAD → ref → commit → tree) and where each step physically lives signals someone who has read the machine, not the flashcards.
🎯 "What is the difference between 'HEAD', 'working tree', and 'index' in Git?" — asked verbatim in Interview Coder's 90+ Git questions, Sep 2025
Three locations, three roles. HEAD: the current commit — the last recorded, immutable snapshot, reached via .git/HEAD → current branch → tip commit. Index (staging area): the draft of the next commit, a binary file listing what commit will record. Working tree: the actual editable files on disk. The everyday commands are movements between them: add copies working-tree content into the index; commit seals the index into objects and advances HEAD's branch; diff and diff --staged compare adjacent pairs; the restore/reset family (Module 7) moves content the other direction.
The details that separate candidates: an average answer defines the three in isolation. A strong answer presents them as a pipeline with commands as the arrows, then demonstrates command-level precision: commit reads only the index (never the working tree), which is why a file edited after staging commits as its staged version. Mentioning that reset's three modes are literally named by which of the three they touch (Module 7) shows the model generalizing — exactly what the interviewer is probing for.
Part D — Integrity, identity, and efficiency
D1. Why every commit ID is unique — and why yours differ from this page's
An object's ID is the SHA-1 hash of its content. Apply that rule to each type and a pattern falls out. A blob's content is file bytes — so identical files hash identically on every machine on earth (your Exercise 3.2 and 3.5 hashes matched this page). A commit's content is its headers plus message — and the headers include a timestamp and your identity. Your commits carry your name and your second-of-commit, so no two people ever produce the same commit hash, even for identical changes. That is why this track keeps saying "your commit IDs will differ": it is not sloppiness, it is the definition.
The same rule explains Module 2's amend mystery rigorously: --amend built a commit with a different message — different content, therefore necessarily a different hash. And because each commit's content includes its parent's hash, change any historical commit and every descendant's hash changes in cascade. Tamper-evidence is not a feature bolted onto Git; it is arithmetic.
D2. Deduplication and compression, proven
Module 1 promised that full-snapshot storage is cheap for two reasons: identical content is stored once, and objects are compressed. You have now seen the compression (Exercise 3.3's zlib garbage). Time to prove the deduplication with the copy you committed in Exercise 3.7.
🧪 Exercise 3.8 — two files, one blob
cd ~/git-course/kitchen
git cat-file -p "$(cat .git/refs/heads/main)" | head -1 # the latest commit's tree line
git cat-file -p 23244d7 | grep serving # that tree's hash — yours should match this one
git count-objects -H # total loose objects✅ Expected result — click to reveal
tree 23244d77c3729a71e227008a675dfaa817e373c5
100644 blob 44e0f28ff2655dd11a1839e404f52d2e0aadd12a serving-copy.txt
100644 blob 44e0f28ff2655dd11a1839e404f52d2e0aadd12a serving.txt
28 objects, 112.00 KiBWhat to read out of it: first, notice the tree hash 23244d7… should match yours exactly — trees hash their entries (names + modes + blob hashes), all of which are deterministic here, so unlike commit hashes this one reproduces across machines. Then the payoff: two different filenames, one identical blob hash — serving.txt and serving-copy.txt both point at 44e0f28…. The copy cost the repository zero content storage: committing it added exactly two objects (one new commit, one new tree — check: 26 objects before, 28 after; your total may differ slightly if you experimented). The count-objects size counts filesystem blocks per loose object, so small objects still cost a disk block each — later, Git packs loose objects into packfiles with delta-compression between similar (not just identical) content, which is Module 15's territory. Deduplication is the everyday saver; packing is the deep-storage saver.
🎯 Interview questions — Part D
🎯 "How does Git ensure data integrity and immutability?" — asked verbatim in LabEx's Git Interview Questions and Answers, 2025
By content-addressing everything. Every object's name is the SHA-1 hash of its content, so corruption is self-revealing: if stored bytes change, they no longer hash to their filename, and Git reports the mismatch when reading (git fsck audits the whole database for exactly this). Immutability follows from the same rule — you cannot "change" an object, only write a different object with a different hash. And because commits embed their parent's hash, and trees embed blob hashes, the entire history is a hash chain: altering any ancient object changes every descendant commit ID, making silent tampering impossible.
The details that separate candidates: an average answer says "Git checksums everything with SHA-1." A strong answer explains the mechanism — name equals hash of content, so verification is recomputation — and the chain property that turns per-object integrity into whole-history tamper-evidence. Mature candidates add the boundaries: SHA-1's cryptographic weakness and Git's SHA1DC collision detection, integrity ≠ authenticity (that needs signed commits/tags), and the operational tool (git fsck) plus the fact that every clone being a full copy is itself an integrity strategy.
🎯 "What is a Git commit hash?" — asked verbatim in GeeksforGeeks' 70+ Git Interview Questions, updated Jul 2026
A commit hash is the commit object's SHA-1 — 40 hex characters computed over the commit's entire content: root tree hash, parent hash(es), author and committer identities with timestamps, and the message. It uniquely identifies that exact snapshot and its entire ancestry (the parent hash chains recursively), and it is what you pass to commands to name a commit: git cat-file -p <hash>, git diff <hash1> <hash2>. Any unambiguous prefix works — 7 characters customarily.
The details that separate candidates: an average answer says "a unique ID for a commit." A strong answer can enumerate what is hashed — and therefore explain why two people committing identical changes get different hashes (identity and timestamp are in the content), why amend/rebase/cherry-pick always mint new hashes, and why a hash pins down the full history behind it, not just one diff. Practical touch: short hashes can become ambiguous as repos grow; scripts should store full hashes and let humans abbreviate.
🎯 "How does Git store file versions efficiently, rather than full copies?" — asked verbatim in LabEx's Git Interview Questions and Answers, 2025
Three stacked mechanisms. Deduplication: objects are content-addressed, so any unchanged file re-referenced by later commits — and any identical content under different names — is stored exactly once; a commit of a 1,000-file project that touched one file adds one blob, a few trees, and a commit. Compression: every object is zlib-compressed on disk. Packing: periodically (git gc, or on push/fetch) Git bundles loose objects into packfiles, storing similar objects as deltas against each other — this is where "diff-like" storage exists in Git, purely as a storage-layer optimization beneath the snapshot model.
The details that separate candidates: an average answer says "Git stores diffs" — which is the classic misconception, inverted. A strong answer keeps the layers straight: the model is full snapshots (what you reason about); the storage adds dedup, compression, and delta-packing (what the disk sees). Knowing that packfile deltas are between arbitrary similar objects — not necessarily consecutive versions of one file — and that binaries defeat delta-compression (hence Git LFS) shows storage-level understanding interviewers rarely hear.
Part E — Production practice
E1. Symptom → cause → diagnosis → fix
Click the symptom you're seeing.
⚠️ fatal: Not a valid object name <x>
What is really happening: The name/prefix matches no object in this repository — a typo, or the object lives in someone else's clone and was never shared.
Diagnose: git cat-file -t <x> · re-check where the hash came from
The fix: Use a hash that exists here; if it should exist, it has not been fetched yet (Module 8).
⚠️ error: short object ID <x> is ambiguous
What is really happening: The abbreviation now matches several objects — repos grow, prefixes collide.
Diagnose: Git lists the candidates in the error itself
The fix: Use more characters; in scripts always store full 40-char hashes.
⚠️ cat .git/refs/heads/<branch> says No such file, but the branch exists
What is really happening: Refs were packed into .git/packed-refs — routine housekeeping, nothing lost.
Diagnose: git rev-parse <branch> · grep <branch> .git/packed-refs
The fix: Read refs with git rev-parse / git for-each-ref, never by cat-ing files.
⚠️ Whole repo shows modified after running a permissions script / copying from Windows
What is really happening: File modes are recorded in trees (100644 vs 100755) — a chmod is a content change to the tree.
Diagnose: git diff (shows old mode/new mode lines)
The fix: Restore the modes, or set git config core.fileMode false where a filesystem cannot store them.
⚠️ error: object file ... is empty / fatal: loose object ... is corrupt
What is really happening: Disk-level damage (power loss, full disk) — the content no longer matches its hash-name, and Git refuses to lie.
Diagnose: git fsck (full audit; Module 15 covers recovery)
The fix: Restore the damaged objects from any other clone — every clone carries the full database (Module 1).
⚠️ Repo size exploding despite small source files
What is really happening: Repeatedly-committed binaries are the culprit: each version is a whole new immutable blob, and delta-packing barely helps opaque formats.
Diagnose: git count-objects -vH · find big blobs (Module 15 shows how)
The fix: Stop committing generated/binary artifacts and adopt Git LFS (Module 14); scrubbing history is Module 10/15 surgery.
E2. Capstone — four tickets
Worked answer: both are real, and the difference is definitional. A commit hash covers the commit's full content: tree, parent, author, committer, timestamps, message. The two commits differ at minimum in committer identity and second-of-creation — different content, different hash, by arithmetic rather than malfunction. What the auditor probably wants is proof the changes are identical: compare the patches (git diff a1b2c3d~ a1b2c3d vs the same for the other — Module 4 gives the ~ syntax; git patch-id computes a hash of the patch itself for exactly this comparison). The mature closing point: identical trees would also hash identically — if both engineers ended with the same resulting content, their tree hashes match even while commit hashes differ. Hash equality means content equality at whichever layer you compare.
Worked answer: two mechanisms, one honest caveat. Mechanism one — the hash chain: every commit embeds its parent's hash, so altering any historical commit changes every later commit ID; if the release tag's hash today equals the hash recorded in last year's release notes/CI logs/signed artifacts, the entire history behind it is bit-for-bit unchanged. One 40-character comparison audits a year. Mechanism two — signatures (Module 12): a signed tag or commit binds that hash to a key, so you do not even need last year's notes to be trustworthy. The caveat that separates seniors: the chain proves this copy is self-consistent back from the hash you checked; it cannot prove a replacement chain was not force-pushed somewhere else — that requires the external anchor (the recorded hash, signature, or host audit logs, Module 8/9). Integrity is arithmetic; provenance needs an anchor outside the repo.
Worked answer (the paragraph): Git stores complete snapshots, not diffs. Every commit points at a tree describing the entire project, with every file's full content stored as a content-addressed blob. Efficiency comes from three storage-layer tricks: unchanged content keeps the same hash and is stored once (deduplication); objects are zlib-compressed; and packfiles later delta-compress similar objects against each other. The diffs you see from git diff are computed on demand between snapshots. Practical consequences: checking out any commit is fast (no replay), any two commits diff instantly in either order, and repeatedly-committed binaries bloat the repo because none of the three tricks helps opaque changed content. — The last sentence is what makes the correction operational rather than pedantic: it predicts real behavior the team will hit.
Worked answer: the script confused a ref (the concept) with a loose ref file (one of its two storage forms). Git periodically consolidates refs into .git/packed-refs (one text file listing many refs) — after which the individual file legitimately vanishes. Any git gc, clone, or housekeeping run can trigger it, hence "worked for months". Fix: git rev-parse release (or git for-each-ref refs/heads/release) — plumbing that resolves refs regardless of storage form, packed or loose, and follows every future storage change (newer Git versions also have a reftable backend — a script using plumbing never notices). The general lesson for infra code: .git's layout is an implementation detail; the plumbing commands are the stable API. Anything that parses .git internals by hand is a time bomb with a pleasant countdown.
E3. Documentation reference
| Topic | Official source | What it covers |
|---|---|---|
| Plumbing vs porcelain | Git Book §10.1 — Plumbing and Porcelain | The two command layers; .git directory tour |
| Objects: blobs, trees, commits | Git Book §10.2 — Git Objects | Builds the object model by hand, with plumbing |
| Refs and HEAD | Git Book §10.3 — Git References | Branch files, HEAD, symbolic refs, tags namespace |
| git cat-file | git-cat-file manual | -t, -p, batch modes for scripting |
| git hash-object | git-hash-object manual | Computing IDs; -w to actually write objects |
E4. Self-assessment
Try each question aloud, from memory, before opening its answer — the gap between your answer and the hidden one is what to study.
1. Name the four object types and what each contains.
Blob, tree, commit, and tag. A blob stores one file's content — bytes only, no name or metadata. A tree represents one directory: a list of entries, each pairing a permission mode and name with the hash of a blob (a file) or another tree (a subdirectory). A commit binds a root tree hash to its parent commit's hash, author and committer identity with timestamps, and the message; an annotated tag (Module 12) wraps a commit hash with a name, tagger, date, and message. All four are stored the same way: content-addressed by hash in .git/objects, immutable once written.
2. What does "content-addressed" mean, and which two big properties fall out of it for free?
Every object is stored under a name that Git computes from the content itself — the SHA-1 hash of its bytes: identical content always produces the identical name, on any machine, any year. Two properties follow. Deduplication: identical content hashes to the same name, so the database physically stores it once. Integrity: the name is a checksum — if a disk error flips a bit, the content no longer matches its name and Git notices the moment it reads the object.
3. Why does a blob contain no filename, and where do filenames actually live?
A blob is a file's bytes and nothing more — no name, timestamp, or author — so identical content under any number of names costs one blob. Filenames live one level up, in tree objects, where each entry pairs a name and mode with a blob or sub-tree hash. That is why rename detection is inference: a rename is never recorded anywhere — the same blob simply appears under a different name in the next tree, and Git spots the reuse.
4. Walk the full path from .git/HEAD to the bytes of one file — every hop.
.git/HEAD holds ref: refs/heads/main — a symbolic pointer to the current branch. The branch file .git/refs/heads/main holds the tip commit's hash. That commit object's tree line names the project's root tree; the tree's entries map filenames to blob hashes (and subdirectories to further trees). The blob holds the file's actual bytes. Commit → tree → blob → your bytes: every file in every commit in every Git repository on earth is reached by exactly that walk.
5. Why do your commit hashes differ from this page's, while your blob hashes match?
An object's ID is the SHA-1 of its content. A blob's content is file bytes only, so identical files hash identically on every machine on earth. A commit's content includes its headers — and those headers contain your identity and the second-of-commit timestamp, so no two people ever produce the same commit hash, even for identical changes. Tree hashes reproduce too, because trees hash their entries (names, modes, blob hashes), which are all deterministic.
6. Why did git commit --amend in Module 2 produce a new commit ID? Derive it from the hashing rule.
The rule: an object's ID is the SHA-1 hash of its content, and objects are immutable. Amend built a commit with a different message — different content, therefore necessarily a different hash. You cannot "change" an object; you can only write a different object with a different name, which is exactly what amend does.
7. A colleague claims someone could edit an old commit "if they're careful". What happens to every later commit ID, and why?
Every descendant's hash changes in cascade. Each commit's content includes its parent's hash, so altering any historical commit gives it a new ID — which changes the next commit's content, which changes its ID, and so on through every later commit. Tamper-evidence is not a feature bolted onto Git; it is arithmetic.
8. What physically is the branch main? Size, location, content.
A 41-byte text file at .git/refs/heads/main: 40 hex characters of the latest commit's hash plus one newline. When you commit, Git writes the new commit object and then overwrites this file with the new hash — that is the entire mechanism behind "the branch moved forward". One caveat: Git sometimes packs refs into .git/packed-refs, so read refs with git rev-parse rather than cat-ing files.
9. What is in .git/HEAD normally, and what does git commit move — HEAD, the branch file, or both?
Normally .git/HEAD contains not a hash but a pointer to a ref: the line ref: refs/heads/main — a symbolic ref naming the branch you are on. git commit moves only the branch file: it writes the new commit, then advances whatever branch HEAD names to the new hash. HEAD itself reads identically before and after — the division of labor is total: HEAD says which branch you are on; the branch says which commit is its tip.
10. Git "stores snapshots, not diffs" — yet delta-compression exists. Reconcile the two statements in two sentences.
The model is full snapshots: every commit points at a complete tree, with every file's content stored as a content-addressed blob, and the diffs you see are computed on demand between snapshots. Delta-compression exists purely at the storage layer: packfiles bundle loose objects and store similar objects as deltas against each other — an optimization beneath the snapshot model, not a change to it.
E5. Sources
🗒️ Cheat sheet — Module 3
| Command | What it does |
|---|---|
| git hash-object --stdin · git hash-object <file> | Compute the blob ID for given content (add -w to store it) |
| git cat-file -t <id> | Print an object's type: blob, tree, commit, or tag |
| git cat-file -p <id> | Pretty-print an object's content (works on any type; prefixes OK) |
| git rev-parse <name> | Resolve any name (branch, HEAD, prefix) to a full hash — the script-safe ref reader |
| git count-objects -H | How many loose objects, and their disk usage |
| cat .git/HEAD · cat .git/refs/heads/<branch> | Look at the raw pointer files (learning only — scripts use rev-parse) |
Key concepts: four object types — blob (file bytes), tree (directory: names→hashes+modes), commit (tree + parent + identity + time + message), tag · object ID = SHA-1 of content ⇒ dedup (same content stored once) and integrity (name is a checksum) · filenames live in trees, never blobs — renames are inferred · history = commits chained by parent hashes ⇒ tampering cascades through every later ID · a branch is a 41-byte file holding a commit hash; HEAD is a symbolic ref naming the current branch; commit advances the branch HEAD names · commit hashes are unique per person/time; blob and tree hashes reproduce anywhere · snapshots are the model, dedup + zlib + packfile deltas are the storage · never hand-edit .git/objects; read refs via plumbing.