Ask most engineers what Git is and you’ll get an answer built out of commands: branch, commit, merge, rebase, push. That’s the porcelain — the user-facing verbs bolted onto something smaller and stranger underneath. The Git object model is that underneath: a content-addressable object store holding exactly four kinds of immutable object, plus a directed acyclic graph those objects form by embedding each other’s hashes, plus a thin, separate layer of mutable text files — refs — that name points in that graph. Nothing in the storage layer knows what a “commit” or a “branch” means to a human; it only knows objects and the hashes that address them.
That distinction is worth taking seriously, because almost everything engineers credit to “Git being fast” or “Git being clever” is a direct, structural consequence of the Git object model, not a separately engineered feature bolted on top. O(1) branch creation, cheap checkout of any point in history, automatic deduplication of identical content, and — the flagship correction this piece keeps returning to — the fact that Git stores complete snapshots and never diffs, all fall out of one decision: separate immutable content, addressed by its own hash, from mutable naming, addressed by convention. This article works through that model bottom-up: the four object types and their on-disk format, why refs are the only mutable thing in the entire system, how the staging area actually works, what “deleting” something in Git really does, and how to build git commit yourself from five lower-level primitives — closing with an honest account of where the model shows its age, and who actually built it.

The Four Objects Git Actually Stores
The Git object model has exactly four building blocks, and every one of them is stored identically at the storage layer, regardless of type: a small text header, a NUL byte, and raw content, the whole thing zlib-compressed and written as a single file. The gitformat-loose specification defines the header precisely: <type> <size>\0, where type is the literal string blob, tree, commit, or tag, and size is the decimal ASCII byte count of the content that follows. The object’s ID — the SHA-1 hash everyone calls “the commit hash” or “the blob hash” — is the hash of that uncompressed header-plus-content, never of the compressed bytes on disk. (Every hash in this article is SHA-1, Git’s long-standing default. Git also supports SHA-256 repositories as a separate, still-maturing capability with its own protocol-interoperability constraints — the object model itself is agnostic to which hash function names its objects, and that transition is its own topic, out of scope here.)
What differs between the four types is only the shape of the content inside that envelope:
- Blob — raw file content, and nothing else. No filename, no permissions, no path. Two files with identical bytes anywhere in your repository, in any commit, in any branch, are the same blob.
- Tree — a directory snapshot: a sorted list of
<mode> <type> <sha> <name>entries, each one pointing at either a blob (a file) or another tree (a subdirectory). Pro Git’s Git Internals chapter walks through the pretty-printed form; on disk, each entry packs more tightly, as<mode> SP <name> NUL <20 raw hash bytes>— binary, not hex — inside the same zlib envelope as any other object. - Commit —
tree <sha>, zero or moreparent <sha>lines, author and committer lines with timestamps, a blank line, then the message. Nothing in a commit object is a diff. It references exactly one complete tree. - Tag object — what Git actually creates for an annotated tag: a pointer at another object, carrying a message and optionally a PGP signature (a “signed tag object,” in the gitglossary‘s own phrasing). A lightweight tag, by contrast, creates no object at all — it’s just a ref, the same mutable pointer mechanism covered later in this piece.
| Object type | What it stores | What it points to | Typically created by |
|---|---|---|---|
blob | Raw file content, unnamed | Nothing — a leaf | git add, or git hash-object -w directly |
tree | A sorted directory listing: mode, type, hash, name | Blobs and/or other trees | git commit (via write-tree), or git write-tree directly |
commit | One tree hash, zero-or-more parent hashes, author/committer/message | One tree, zero-or-more parent commits | git commit, or git commit-tree directly |
tag (annotated) | A pointer, message, optional PGP signature | Any other object (usually a commit) | git tag -a |
You can inspect any of these directly. git hash-object -w computes an object’s ID and writes it into the store; git cat-file -t <sha> reports its type; git cat-file -p <sha> pretty-prints its content by dispatching on that type — the same plumbing command handles a blob, a tree, a commit, or a tag, because the four types share one storage shape and only differ in how they’re rendered back out. Run git cat-file -p against a tree and you get exactly the sorted-entry listing described above; run it against a commit and you get the tree/parent/author/committer/message block, verbatim.

Anatomy of a Loose Object: <type> <size>\0 Plus zlib
The header-and-hash design has a direct, visible consequence: the object’s address on disk is derived from its own hash, with no separate index or lookup table required. Take the famous worked example from Pro Git’s internals chapter:
$ echo 'test content' | git hash-object -w --stdind670460b4b4aece5915caf5c68d12f560a9fe3e4That single command does two things: it computes the SHA-1 of the header blob 13\0 concatenated with the content bytes — thirteen, not twelve, because echo appends a trailing newline; printf 'test content' would hash twelve bytes and produce a different object entirely — and — because of -w — it writes the zlib-compressed result to disk. The path it lands at comes straight from the hash itself, split at a fixed point: the first two hex characters become a subdirectory under .git/objects/, and the remaining thirty-eight become the filename. This object lives at .git/objects/d6/70460b4b4aece5915caf5c68d12f560a9fe3e4 — no database, no manifest, nothing to keep in sync. The name is the lookup path.
This is also where “content-addressable” earns its name in the most literal sense: you cannot ask Git to store an object at an address of your choosing. The address is a pure function of the bytes. Write the same content twice — from different files, different commits, different branches — and you get the identical path, because you get the identical hash. There is nothing to deduplicate after the fact; duplication is structurally impossible at the object-store layer. Linus Torvalds put it directly in a May 2007 Google Tech Talk on Git’s design: “since git internally uses a content addressable filesystem, if there are identical files with identical contents, it will actually use the exact same object for them.” The gitglossary formalizes the property this depends on: an object, by construction, “cannot be changed.” Change one byte of test content and you get a different hash, a different path, and a brand-new object sitting next to the old one — the old one untouched, because nothing ever mutates a loose object in place.

Content Addressing Makes Deduplication Free, Not Engineered
It’s worth pausing on a distinction that’s easy to blur: Git doesn’t have a deduplication feature. Deduplication is a side effect of how addresses are assigned, and side effects don’t need code paths, edge cases, or maintenance. A ten-thousand-line configuration file that never changes across two thousand commits is stored exactly once — not because some background process noticed the repetition and collapsed it, but because every one of those two thousand commits’ tree objects simply repeats the same blob hash for that file’s entry. The tree entry is <mode> <type> <sha> <name>; if the file’s content hasn’t changed, its <sha> hasn’t changed, and there is nothing new to write.
This is also why renaming a file is, at the object-storage layer, invisible. A tree entry only ever records <mode> <name> <hash> — there’s no field for “this used to be called something else.” Move config.yml to settings.yml without touching a byte inside it, and the new tree’s entry for settings.yml points at the exact same blob hash as the old tree’s entry for config.yml. Git didn’t “track” the rename; it simply never had a reason to write a new blob, because the content addressing that makes dedup free is the identical mechanism that makes rename-tracking structurally absent from the object model. (git log --follow, diff -M, and blame‘s heuristics infer renames after the fact by comparing content — a point this piece returns to later, because it’s a real limitation, not a footnote.)

The Flagship Correction: Snapshots, Never Diffs
Here is the single most consequential misconception about Git, and the one this article exists partly to correct: Git does not store diffs between commits. Every commit’s tree field points at a complete, walkable snapshot of the entire tracked directory structure at that moment — never a delta against a parent, never a patch to be replayed forward. Reconstructing any historical revision, no matter how old, is a direct tree-walk starting from one hash. There is no earlier baseline to fetch first, and no chain of patches to apply in sequence to arrive there.
This single design decision is the actual source of most of what people experience as “Git is fast,” and it’s worth separating from the thing people usually credit instead — clever compression. Compression happens too, but it happens later, and it happens to a copy: git gc and git repack may rewrite loose objects into a packfile using a similarity-and-delta-chain heuristic, purely as a storage and transfer optimization. The packfile still represents the same immutable snapshot objects, reconstructed transparently the moment anything reads them. The object model itself has no concept of a diff anywhere in it — deltas are a packing-time implementation detail, layered on top of a model that doesn’t need them to function.
Two corollaries fall directly out of the snapshot model, and neither needed to be separately engineered:
- Branch creation is O(1). A branch is a 41-byte text file containing one hash. Creating one is a single filesystem write, entirely independent of how large the repository’s history is — a thousand-commit history and a million-commit history cost the same one write.
- Checking out an arbitrary historical commit is a tree-walk, not a replay. Because every commit already points at a complete snapshot, jumping to a commit from three years ago costs the same tree-walk as jumping to yesterday’s commit. Nothing needs to be reconstructed from a sequence of prior diffs, because nothing was ever stored as a sequence of diffs.
| Delta-chain model (e.g. classic centralized VCS) | Git’s snapshot model | |
|---|---|---|
| What a revision stores | A diff against the previous revision (or against a base + forward/reverse deltas) | A complete tree of the entire project at that moment |
| Reconstructing revision N | Replay a chain of patches from a baseline up to N | Walk one hash directly — no replay |
| Cost of creating a branch | Proportional to project size in some implementations; a real, first-class operation | One small file write — independent of history size |
| Cost of checking out an old revision | Proportional to distance from the nearest stored baseline | Constant relative to history depth — a tree-walk from one hash |
| Unchanged-file storage | Depends on the diffing scheme | Free — an untouched file’s tree entry repeats the same blob hash |
| Where compression happens | Often on the hot path, as part of the storage model itself | Deferred, asynchronous (gc/repack), and invisible to the model |
That last row is the part worth internalizing as a systems-design move, not just a Git trivia fact: representation cost gets paid where the operation is rare — background packing — instead of on the hot path where the operation is common — every single commit. A model that tried to keep storage minimal on every write would have made branching and historical checkout slower to buy back space it can just as easily reclaim later, off the critical path.

Refs and HEAD: Immutable Content, Mutable Naming
Everything so far describes objects that never change once written. That immutability is only useful if something else can point at the current tip of a moving line of work — and that something is a ref, and refs are the only mutable part of the entire model. This is the central lesson of the whole object model, and it’s worth stating as plainly as possible: Git separates immutable content, addressed by its own hash, from mutable naming, addressed by convention — and nearly every property people associate with Git is a consequence of keeping that boundary sharp.
Structurally, commit objects form a graph purely by embedding a parent’s hash inside their own content. Because a commit is only valid once it’s been hashed, a hash can never point at something that doesn’t exist yet — which means the resulting graph is necessarily acyclic. The gitglossary states this precisely: a directed acyclic graph is one where “there is no chain which begins and ends with the same object.” The precise term for what Git builds is a content-addressed Merkle DAG — a structure related to, but distinct from, a strict Merkle tree (a commit can have multiple parents, so it’s a DAG, not a tree) — not a blockchain. There’s no consensus protocol, no proof-of-work, and no notion of a canonical single chain anywhere in the object format; “blockchain” is a genuinely imprecise analogy for what’s happening here.
A ref (refs/heads/<branch>, refs/tags/<tag>, refs/remotes/<remote>/<branch>, and HEAD) is, in the base case, nothing more exotic than a plain text file holding one hash — commonly cited, per Pro Git’s own example (echo 1a410ef... > .git/refs/heads/master), as 41 bytes: 40 hex characters plus a trailing newline. Per the gitrepository-layout specification, refs/heads/<name> “records tip-of-the-tree commit objects,” refs/tags/<name> “records any object name,” and in large repositories with thousands of refs, Git transparently collapses most of them into one packed-refs file for efficiency — a storage optimization, not a semantic change to what a ref is.
HEAD is normally a symbolic ref: a file whose content is literally ref: refs/heads/<branch> — one extra layer of indirection that makes “which branch am I on” itself a piece of mutable state, separate from “which commit is that branch pointing at.” Check out a raw commit hash instead of a branch name and HEAD stops being symbolic; it becomes a direct hash, and Git calls this state “detached HEAD” specifically because the usual pointer-to-a-pointer indirection has been removed.

Put the two halves together and you get the model’s most load-bearing fact: commit objects are immutable and effectively permanent (until garbage collected — the next section covers exactly when that happens), while refs are the only mutable state in the entire system, and moving one is just a filesystem write. branch, merge, and rebase are all, structurally, the same two-step operation: compute new immutable objects, then move a pointer to reference them. This is also why “rewriting history” is a misleading phrase for what rebase --onto, commit --amend, or filter-repo actually do. None of them mutate a single existing object. Every one of them creates new objects and then repoints a ref at the new tip — the old objects don’t get edited, they simply stop being referenced by anything, which is a different guarantee than “gone,” and the next section is about exactly that gap.
The Index: A Stat Cache That Moonlights as the Merge-Conflict Workspace
Between the working directory and the object store sits .git/index — and it’s worth being precise that this is a real, distinct binary file, not a Git object and not a metaphor for “staging.” Per the gitformat-index specification, it opens with a 12-byte header (a DIRC signature, a version number, a 32-bit entry count), followed by sorted per-file entries, followed by a trailing checksum.
Each entry carries considerably more than “this file is staged.” It stores the full stat(2) metadata Git captured the last time it touched the file — ctime, mtime (seconds and nanoseconds), device, inode, mode, uid, gid, and size — alongside the blob’s hash, a flags field encoding the path’s name length and a 2-bit stage, and the path itself. That stat metadata is the actual reason git status feels instantaneous even in a repository with tens of thousands of files: Git can usually decide “this file is unchanged” by comparing cheap stat() results against the cached values in the index, and only falls back to re-hashing the file’s actual content when the stat comparison is ambiguous. The index isn’t primarily a staging list that happens to also cache stat data — the stat-cache role is what makes the whole thing fast, and staging content rides along on the same structure.
That same 2-bit stage field has a second, structurally unrelated job. Stage 0 is the normal, unconflicted case. During a merge conflict, the same path can carry multiple entries simultaneously at higher stages: stage 1 holds the common ancestor’s version, stage 2 holds “ours,” stage 3 holds “theirs” — three versions of one file coexisting in the same flat structure until git add <path> collapses them back down to a single stage-0 entry, resolving the conflict from the index’s point of view.

This dual role is worth naming as a legitimate design trade-off rather than glossing over it as an implementation detail. One on-disk structure is simultaneously (a) the staged content that will become the next commit’s tree, (b) a stat-based performance cache that makes everyday commands fast, and (c) the merge-conflict workspace. That coupling is real and it has real cost: operations that only care about “what content will I commit” — a partial git add -p, a sparse checkout that only wants part of the tree — still have to reason about stat-cache invalidation, because it’s the same file doing both jobs. It works, and it’s been stable for two decades, but a structure with three unrelated responsibilities is a smell worth naming even when the code around it is sound.
Reachability, the Reflog, and Why “Delete” Really Means “Unreference”
Because objects are immutable, Git needs a separate concept to answer “is this object still needed,” and that concept is reachability. The gitglossary defines it precisely: an object is reachable from another “by a chain that follows tags to whatever they tag, commits to their parents or trees, and trees to the trees or blobs that they contain,” with every chain starting from a ref. An object is unreachable if no such chain exists from any branch, tag, or other ref; it’s dangling if it’s unreachable and nothing else unreachable points to it either — a dangling blob with no tree pointing at it is a stronger kind of orphan than a merely-unreachable commit that some other unreachable commit still references.
This machinery answers a question that trips people up constantly: what actually happens when you delete a branch, force-push over a commit, or run rebase and lose what looks like history? Almost never does Git erase bytes at that moment. What actually happens is that a ref moves or disappears, and the commits it used to point at simply become unreachable — they stay on disk, untouched, for as long as nothing in refs/*, the index, a remote-tracking branch, or the reflog can still reach them, per git-gc‘s own definition of what counts as “live” for pruning purposes. “Delete,” in this model, means unreference, not erase.
The reflog is the specific mechanism that turns this into a practical safety net rather than a theoretical one. Independently of the ref graph itself, git reflog records every local update to a ref’s tip — branch switches, resets, rebases, amends — which is why a commit you just amended away, or a branch you just force-pushed over, is usually still recoverable through HEAD@{2} or master@{one.week.ago} for some window afterward.
That window is bounded, and it’s bounded by two independent, stacked expiry periods, not one:
| Grace period | Default | Governing setting | What it governs |
|---|---|---|---|
| Loose-object pruning | 2 weeks | gc.pruneExpire (git gc‘s default prune --expire) | How long an unreferenced object survives before git gc may delete it from disk |
| Reflog entries, reachable | 90 days | gc.reflogExpire | How long a reflog entry for a commit still reachable some other way survives |
| Reflog entries, unreachable | 30 days | gc.reflogExpireUnreachable | How long a reflog entry survives once its commit is otherwise unreachable |
Two grace periods stacking between “unreferenced” and “actually gone” is a deliberate, tunable design, not an accident — --prune=now skips the grace period entirely when you genuinely want immediate collection. It’s also worth stating what this machinery is not: it is not a security boundary. A secret committed and pushed is compromised the instant it’s pushed, regardless of any later rebase, filter-repo run, or force-push. Those operations create new history locally; they do nothing to a clone made before the rewrite, a fork on someone else’s machine, or a forge’s cache of the original objects. The correct response to a leaked credential is rotating it, not “cleaning” history and hoping every copy received the memo — the same logic that makes a lightweight tag a mutable pointer rather than a trust anchor (more on that later) applies here too: naming something differently doesn’t retroactively un-publish the bytes a prior name once pointed at.

Building git commit by Hand: A Tour of the Plumbing
Because Git “was initially a toolkit for a version control system rather than a full user-friendly VCS,” in Pro Git’s own framing, it exposes low-level subcommands designed to be chained together Unix-style or called from scripts — plumbing — underneath the roughly thirty user-facing subcommands most engineers use daily — porcelain. The gitglossary puts the split in two words: porcelain gives “high level access to core Git”; plumbing simply is “core Git.”
The payoff of that split is concrete: git commit is not a primitive operation. It’s a fixed composition of exactly five plumbing calls, and you can run each one yourself. This is the same sequence Pro Git’s internals chapter builds by hand, using nothing but plumbing:
$ echo 'test content' | git hash-object -w --stdind670460b4b4aece5915caf5c68d12f560a9fe3e4 $ git update-index --add --cacheinfo 100644 \ d670460b4b4aece5915caf5c68d12f560a9fe3e4 test.txt $ git write-treed8329fc1cc938780ffdd9f94e0d364e0ea74f579 $ git commit-tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579 -m "first commit"fdf4fc3 $ git update-ref refs/heads/master fdf4fc3Five distinct primitives, each doing exactly one job:
git hash-object -wcomputes an object’s hash and writes it into the store — this is the operation underneathgit add, minus the index bookkeeping.git update-index --add --cacheinfo <mode> <sha> <path>registers that blob into the index at a given path and mode. Its own documentation points togit addas the friendlier porcelain wrapper around exactly this call.git write-treeserializes the current index into a tree object and returns its hash.git commit-tree <tree> [-p <parent>]wraps a tree hash (plus optional parent hashes) into a commit object. Its own man page is unusually candid about its own role: “this is usually not what an end user wants to run directly.”git update-ref refs/heads/<branch> <new-sha>atomically — and optionally conditionally, to guard against a concurrent update — moves a ref to point at the new commit.
Repeat steps 1–4 with a modified tree and -p fdf4fc3 on the next commit-tree call, and you get a second, chained commit; Pro Git’s own worked walkthrough continues this exact sequence to produce cac0cab, then a third time to produce 1a410ef — each one linked to its predecessor purely by embedding the predecessor’s hash. A final git update-ref refs/heads/master 1a410ef moves the branch pointer to the new tip. Nothing about this required a special “commit” primitive at the storage layer — it required a hash-object, an update-index, a write-tree, a commit-tree, and an update-ref, called in sequence, exactly once each.

Plumbing vs. Porcelain: Why the Model Composes
The reason this matters beyond a party trick is composability. Because git commit decomposes into five orthogonal, independently useful, independently documented primitives, wildly different tools — IDEs, GUI clients, history-rewriting tools, custom merge drivers, entire alternative Git implementations — can be built directly against the storage layer without forking Git itself or reverse-engineering an opaque black box. A handful of the everyday porcelain commands map onto plumbing this cleanly:
| Porcelain command | What it actually composes underneath |
|---|---|
git add <file> | hash-object -w the file’s content, then update-index to stage it at stage 0 |
git commit | write-tree the current index, commit-tree that tree with HEAD as parent, update-ref HEAD (or the branch it points to) at the new commit |
git branch <name> | update-ref refs/heads/<name> <current commit> — one ref creation, nothing more |
git tag <name> | update-ref refs/tags/<name> <object> for a lightweight tag; a new tag object plus that same update-ref for an annotated one |
git show <sha> | cat-file -p <sha>, dispatched by type, formatted for a human |
git checkout <branch> | Move HEAD‘s symref, then read the target tree into the working directory and index — one of several operations this single porcelain verb historically overloaded |
A high-level operation that is a fixed, documented composition of small primitives is a fundamentally different kind of thing than a high-level operation that merely resembles one from the outside. The first can be recomposed by anyone; the second can only be used as shipped. Git chose the first shape almost everywhere, and it’s a large part of why the ecosystem around it — from libgit2 to GitHub’s own infrastructure — didn’t have to fork the reference implementation to build on it.

Who Actually Built This: Linus’s Two Weeks, Junio Hamano’s Twenty Years
The object model above didn’t accrete gradually — it was designed under acute pressure, in days, and it’s worth getting the timeline and the credit precise, because both get flattened in casual retellings.
The Linux kernel project had used the proprietary BitKeeper tool free of charge since 2002. In April 2005, BitMover withdrew that free license after Andrew Tridgell released a tool called SourcePuller; BitMover’s Larry McVoy said SourcePuller had reverse-engineered BitKeeper’s wire protocol in violation of the license terms, while Tridgell disputed that characterization of what his own tool did. Contemporary reporting from The Register covered the dispute as it unfolded; it remains genuinely contested rather than a settled story with one clear villain, and it’s worth resisting the urge to compress it into one.
What followed was fast even by the standards of the code it produced. Development on what would become Git began April 3, 2005. Torvalds announced it publicly on April 6. By April 7, 2005, the project was self-hosting — Git tracking its own source — and Torvalds’s own initial-commit README, preserved as object e83c5163316f89bfbde7d9ab23ca2e25604af290 in git.git’s own history, describes the new tool as “a stupid (but extremely fast) directory content manager,” built on “a content-addressable collection of objects” with “cryptographically strong signatures at all levels” against corruption — the object model, in the words of the person who wrote the first version of it, on day four. The project’s own stated design goals, recorded in Pro Git’s short history of Git, were “speed. Simple design. Strong support for non-linear development (thousands of parallel branches). Fully distributed. Able to handle large projects like the Linux kernel efficiently.” Git was not the only answer to BitKeeper’s withdrawal, either: Mercurial began the same month, independently — Matt Mackall released version 0.1 on April 19, 2005, a separate project solving a related problem for a related reason.

The part that gets flattened most often in “Git was a weekend project” retellings is what happened next. Torvalds maintained Git directly for a few months, then handed it off. In his own email to the git mailing list, titled “Meet the new maintainer..” and dated July 27, 2005, he announced that the kernel.org project’s owner field “says ‘Junio C Hamano’ instead” of his own name, adding plainly that “Junio was the obvious choice.” Hamano has maintained Git continuously ever since — roughly two decades of sustained work at the time of this writing. Packfiles, the evolution of the index format itself, commit-graph files, partial clone, protocol v2, and Git’s SHA-256 repository support are all post-handoff engineering, built by a community Hamano has stewarded, not features of the tool Torvalds handed over in the summer of 2005. Crediting Git accurately means crediting both: Linus Torvalds designed and wrote the object model in roughly two weeks under real deadline pressure; Junio Hamano has maintained, extended, and hardened everything built on top of it for twenty years since.
Where the Model Shows Its Age
None of the above is an argument that the design is flawless, and a hub that only praised Git wouldn’t be worth reading. The object model has real, citable edges — some inherent to the design, some just organic growth in the surface built on top of it.
| Limitation | Why it exists | The workaround, and its cost |
|---|---|---|
CLI verb inconsistency — checkout alone can switch branches, restore files, or create a branch, depending on flags | Porcelain grew organically on top of a stable object model; the verb surface absorbed complexity the model underneath never had | switch and restore were split out in newer Git specifically to un-overload checkout — an admission the old surface had a real problem |
| Submodules model “another repository” as a special tree-entry mode | A tree entry can’t natively express “this points into a separate object store” | The gitlink mode (1110 in the index) pins an exact commit hash of an external repository — friction shows up as detached-HEAD submodule checkouts and an easy-to-forget --recurse-submodules flag |
Renames are never stored — a tree entry is only <mode> <name> <hash> | Content addressing has no field for provenance, only for current state | -M/-C infer renames from post-hoc similarity scoring (diff‘s default threshold is 50%, capped by diff.renameLimit, default 1000 files) — real false positives and false negatives at scale, never a stored fact |
| Empty directories can’t be represented | A tree only contains entries pointing at blobs or other trees; nothing exists to point at in an empty one | The .gitkeep convention — an otherwise meaningless file added purely to give the directory a tree entry |
| Large binaries are structurally hostile to the model | A one-byte change to a large binary produces an entirely new, unrelated blob every time; delta compression only claws this back later, at pack time | Git LFS, partial clone, and sparse checkout exist specifically as bolt-ons because the object model itself has no native answer |

That renames row is worth one more sentence, because it’s the limitation people misdescribe most often: git log --follow and git blame both infer a rename by comparing content similarity after the fact, and they don’t even agree with each other on how — blame‘s heuristics lean on character-count thresholds where diff and log lean on percentage thresholds. Two tools inferring the same underlying event from the same underlying objects, using different math, is a direct symptom of the fact that no rename was ever actually recorded anywhere. Calling that “rename tracking” overstates what the object model does by exactly the amount that matters.
The submodule row deserves a second look through the same lens as the refs section above, because it’s a place where the immutable-content/mutable-naming boundary gets genuinely harder to use correctly. A gitlink entry pins an exact commit — immutable, by the same guarantee as everything else in the object model — but a lightweight tag is itself just a ref, a mutable pointer rather than an object. Pinning a dependency, a GitHub Action, or a submodule to a tag like v4 trusts whatever commit that tag currently happens to point at; pinning to the full commit SHA trusts an immutable object that cannot be silently repointed. The object model hands you the tool to make that distinction correctly. Whether any given workflow actually uses the immutable form or the mutable one is a separate, human decision the model itself has no opinion about.
What This Piece Deliberately Leaves Out
Four adjacent subjects are load-bearing enough to deserve their own full treatment rather than a rushed paragraph here, so this piece stops at the object model itself and points rather than wanders: packfiles and performance — delta compression, the pack format, bitmaps, and commit-graph files are all storage and transport optimizations layered on top of the snapshot model described above, not a different data model; querying history — bisect, blame, log -S/-G, and the rename-inference machinery touched on above all deserve a dedicated treatment of how you search a DAG once you have one; the security surface — Git’s hashes provide tamper-evidence for corruption detection, not tamper-proofing against a deliberate adversary, and that distinction, plus signing, CVEs, and the trust model, is a substantial topic on its own; and collaboration — how a distributed object store turns into pull requests, code review, and merge strategy is a social and protocol layer Git itself barely touches.
The Lesson Worth Taking Past Git
Pull back from the mechanics and one move repeats at every layer of this design: separate what must never change from what must be free to change, and let as much as possible fall out as a consequence of that separation rather than as a feature you build separately. Objects are immutable and addressed by their own hash; refs are mutable and addressed by convention. Branching is cheap because nothing about creating one touches an object. Deduplication is free because nothing about storing content lets you choose where it lands. “Delete” means “unreference, with a tunable grace period” because permanence and reclamation are two separate decisions, not one. And the whole thing composes, because the one operation everyone calls a primitive — commit — was never actually a primitive at all.
That’s the transferable part, and it generalizes well past version control. Any system where you’re tempted to add a “make this fast” feature, a “make this safe to delete” feature, or a “make this composable” feature is worth a second look: could tightening what’s immutable, and shrinking what’s mutable down to the minimum needed for naming, buy you that property for free instead? Git is the best-documented, longest-running case study available of an architecture that bet heavily on “yes.”
❓ Frequently Asked Questions
Does Git store diffs between commits?
No. Every commit points at a complete, walkable snapshot tree of the entire project at that moment — never a diff against a parent. Deltas exist only inside packfiles, as an asynchronous storage and transport optimization applied later by git gc/repack; the object model itself has no concept of a diff, and any diff you see in git diff or git show is computed on the fly by comparing two snapshots, not retrieved from storage.
What’s actually the difference between a Git object and a Git ref?
An object — blob, tree, commit, or tag — is immutable, content-addressed by its own SHA-1 hash, and stored once regardless of how many commits or branches reference it. A ref — a branch, a lightweight tag, or HEAD — is a small mutable text file that just holds a hash, pointing at a position in the object graph. Objects never change once written; refs are the only thing in the whole model that’s designed to change, and moving one is a single filesystem write.
Why is git status fast even in a repository with tens of thousands of files?
Because .git/index isn’t just a staging list — it’s also a stat cache. Each entry stores the stat(2) metadata (mtime, ctime, size, inode, and more) Git captured the last time it touched that file, so git status can usually decide a file is unchanged by comparing cheap stat() calls against those cached values, only falling back to re-hashing the file’s content when the comparison is ambiguous.
What actually happens when you delete a branch or force-push over a commit?
Almost never does Git erase any bytes at that moment. The ref moves or disappears, and the commits it used to point at become unreachable — they stay on disk until nothing in refs/*, the index, a remote-tracking branch, or the reflog can still reach them. git gc prunes unreferenced loose objects after a default two-week grace period (gc.pruneExpire), and the reflog itself expires separately, at 90 days for still-reachable entries and 30 for unreachable ones. “Delete” means “unreference, with a bounded grace period,” not “erase.”
Can you build a Git commit without running git commit?
Yes, and doing so once is a genuinely useful way to internalize the object model. git commit is a fixed composition of five plumbing primitives: git hash-object -w to write a blob, git update-index --add --cacheinfo to stage it, git write-tree to snapshot the index into a tree object, git commit-tree to wrap that tree (plus a parent hash) into a commit object, and git update-ref to move a branch to point at it. Pro Git’s own internals chapter builds a multi-commit history this way using nothing but these five commands.
Who actually created Git, and who maintains it today?
Linus Torvalds designed and wrote the original implementation in roughly the first two weeks of April 2005, in direct response to BitKeeper’s free license being withdrawn from the Linux kernel project. He handed maintainership to Junio C Hamano by his own email dated July 27, 2005, and Hamano has maintained Git continuously since — packfiles, commit-graph, partial clone, protocol v2, and SHA-256 support are all engineering built after that handoff, not part of what Torvalds originally wrote.
📚 Further Reading
- What Is a Container? The Idea That Powers Modern Software (and AI) — a different corner of the software world that also builds on content-addressed, immutable layers, for the same structural reasons.
- Learning the Brilliance of Graph Theory: From Mazes to Complex Networks — the graph-theory background underneath what “Git is a DAG” actually means.
- Mastering Graph Cloning: A Beginner’s Guide to Solving the Clone Graph Challenge on LeetCode — hands-on practice with the traversal patterns that reachability checks in a DAG are built from.
- The Best Books on Distributed Systems (Beginner to Expert) — for the broader immutability-and-log-structured-storage reading that this piece’s transferable lesson connects to.
- MCP Server Supply Chain Security: Hardening Agent Skills — the same mutable-naming-vs-immutable-content trust boundary this piece raises with Git tags, showing up in a completely different supply chain.
📚 Recommended Resources
Disclosure: some links below are Amazon affiliate links. If you buy through them, I earn a small commission — at no extra cost to you — which helps keep this site running.
Pro Git (2nd ed.)
Scott Chacon & Ben Straub · Book
The primary source this article builds on — chapter 10, “Git Internals,” walks through the exact loose-object format, the hash-object/write-tree/commit-tree sequence, and the refs mechanics covered here. The full text is free online at git-scm.com/book under CC BY-NC-SA 3.0 — read it there first; the print edition is a convenient reference if you’d rather have it off-screen.
Version Control with Git (3rd ed.)
Prem Kumar Ponuthorai & Jon Loeliger · Book
The deepest print treatment of Git’s command surface and plumbing layer, including the object-model internals this article decomposes git commit into. A good next step once the underlying model in this piece has clicked.
Designing Data-Intensive Applications (2nd ed.)
Martin Kleppmann & Chris Riccomini · Book
The book that makes the transferable lesson of this article click into place for systems well outside version control: separate immutable content, addressed by its own hash, from mutable naming, addressed by convention. Merkle trees, content-addressed storage, and log-structured designs get the several-hundred-page treatment here.
The Cathedral & the Bazaar
Eric S. Raymond · Book
The essay collection that theorized the open-source development model Git was built to serve, written years before the BitKeeper dispute that produced Git in April 2005. The title essay is free at catb.org under the Open Publication License — read that first; this edition collects the full book.
For the object model itself rather than the porcelain around it, James Coglan’s Building Git rebuilds Git’s internals from scratch in Ruby, object store and all — no Amazon listing exists for it (it’s sold ebook-only, direct from the author). If that angle interests you, it’s worth buying directly from shop.jcoglan.com/building-git — a non-Amazon purchase, no affiliate link here.