Explain Git's internal object model including blobs, trees, commits, and annotated tags. How does content-addressable storage and the Merkle DAG provide integrity guarantees?
Quick Answer
Git stores data as four object types: blobs (file contents), trees (directory listings mapping names to blob/tree hashes), commits (snapshots with metadata and parent pointers), and annotated tags (named references with signatures). All objects are SHA-1 hashed and stored in .git/objects/, forming a Merkle DAG where any tampering changes all downstream hashes.
Detailed Answer
Think of Git's object model like a notarized document chain. A blob is a raw page of text with no title. A tree is a table of contents listing which pages sit in which chapter folders. A commit is a notarized certificate that records the exact table of contents, who certified it, when, and which previous certificate it follows. Because the notary stamp (SHA-1 hash) includes the content of everything it references, altering even one word in one page changes every stamp downstream, instantly revealing tampering.
Git is fundamentally a content-addressable filesystem. When you add content, Git prepends a header (object type + byte length + null byte), computes the SHA-1 hash of the entire payload, zlib-compresses it, and stores it at .git/objects/<first-2-chars>/<remaining-38-chars>. This means identical content is stored exactly once regardless of filename or location. The four object types form a layered DAG: blobs hold raw file content with no metadata (no filename, no permissions), trees hold directory entries mapping mode + filename to blob or subtree SHA hashes, commits hold a root tree pointer plus author, committer, timestamp, message, and zero or more parent commit hashes, and annotated tag objects hold the tagger identity, date, message, GPG signature, and a pointer to the tagged object (usually a commit).
Internally, you can explore this model with plumbing commands. git cat-file -t <hash> reveals the object type, git cat-file -p <hash> pretty-prints its contents, and git hash-object computes the hash without storing. A commit object might read: 'tree d4a6b2e, parent b7e9f4a, author Sarah Chen <[email protected]> 1718140800 -0500, committer Sarah Chen ..., \n\nfeat: add payment retry.' The tree d4a6b2e might list: '100644 blob c1d8e3f README.md, 040000 tree e5f7c9a src/.' The recursive tree structure captures the entire directory hierarchy at that point in time. Because each commit includes its parent's hash, and each parent includes its parent's hash, the entire chain forms a Merkle DAG (directed acyclic graph) where modifying any historical content changes all subsequent hashes. This provides cryptographic integrity: git fsck --full recomputes every hash and verifies the chain.
Git optimizes storage through packfiles without changing the object model. Initially, each object is a loose file. Periodically, git gc packs loose objects into .pack files with .idx indices using delta compression: Git finds similar objects (typically different versions of the same file) and stores only the delta between them. This is a storage optimization, not a model change. The object model always deals in full snapshots. Pack negotiation during fetch/push uses the same format, sending only objects the receiver lacks. A repository with 100,000 commits and millions of objects might compress into a few packfiles, reducing disk usage by 10-50x. The commit-graph file further accelerates traversals by pre-computing generation numbers and Bloom filters for changed paths.
A critical gotcha: because Git hashes content not filenames, renaming a file does not create a new blob. It creates a new tree entry pointing to the same blob hash. Git's rename detection is heuristic (comparing content similarity), not explicit, which can occasionally fail during large refactors. Another nuance: lightweight tags are just refs (pointers stored in .git/refs/tags/) pointing directly to a commit, while annotated tags create a separate tag object with metadata. Only annotated tags are transferred by git push --follow-tags by default, and only annotated tags support GPG signatures. Also, the SHA-1 to SHA-256 transition (object format version 2) changes the hash algorithm but preserves the object model structure entirely, requiring hash interoperability during the migration period.