Developer Tools · Version Control Internals

Git's Object Model: Content-Addressed Blobs, Trees, and Commits

How Git frames and hashes objects, why a rename leaves no trace in storage, and what the four object types cost when content changes.

A commit that renames a file and adds a single line to it stores the whole file again. Nothing in the repository records that the file previously had another name, or that only one line changed. Readers arriving from a delta-based version control system expect the opposite, and the difference is not an optimisation shortcut or a missing feature: the storage format has no field in which either fact could be written. What Git stores instead is a directed acyclic graph of immutable objects, each named by the hash of its own bytes, and nearly every other property of the system follows from that arrangement.

The hash is the name

An object is a header followed by a payload. The header is the type name, a space, the payload length in decimal ASCII, and a NUL byte: blob 12\0, tree 143\0, commit 284\0. The object identifier is the SHA-1 digest of the entire frame, header included, so the length is authenticated along with the content. The length in the header is what makes the encoding unambiguous; without it, a blob whose bytes happen to look like a header would be indistinguishable from one.

A loose object is written to .git/objects/ under a two-level path: the first two hex digits of the identifier form the directory, the remaining thirty-eight form the filename. The payload is compressed with zlib’s default deflate settings, and the file is created under a temporary name and renamed into place, so a reader never observes a partially written object. Once written, the file is never modified. The identifier is a function of the content, so modifying the file would change the name it is stored under.

Three properties come for free from this arrangement. Identical content is stored once no matter how many paths or commits contain it; the empty file, a licence text, and a vendored header each exist as a single object regardless of how often they appear in history. Reading is self-verifying, because a reader can rehash what it decoded and compare against the requested name. And the graph is immutable, so objects can be shared between repositories, copied, cached, and served without coordination.

The cost is the mirror image. There is no incremental storage in the loose format: a one-byte change to a ten-megabyte file produces a new ten-megabyte object. Deduplication happens only at whole-object granularity, so files that are similar but not identical share nothing. Both costs are addressed later by packfiles, which store one object as a set of instructions against another, but the logical model never changes; packing is a transport and storage layer beneath content addressing, not a replacement for it.

A minimal implementation of the identifier computation fits in a few lines, and reproducing it is the fastest way to confirm that the model is exactly as described:

import hashlib, zlib, os

def object_id(kind: str, payload: bytes) -> str:
    header = f"{kind} {len(payload)}".encode("ascii") + b"\0"
    return hashlib.sha1(header + payload).hexdigest()

payload = b"hello\n"
oid = object_id("blob", payload)

path = os.path.join(".git", "objects", oid[:2], oid[2:])
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
    # The stored bytes are the same frame that was hashed, then deflated.
    f.write(zlib.compress(b"blob %d\0" % len(payload) + payload))

Trees encode directories without metadata

A tree object is a sequence of entries, each of which is a mode in ASCII octal, a space, a path component, a NUL byte, and the twenty raw bytes of the child object’s identifier. There is no count field and no separator between entries; the fixed-width digest and the NUL after each name make the format self-delimiting.

The mode is a string such as 100644, 100755, 120000, 40000, or 160000, written without a leading zero. Only three distinctions survive from the filesystem: regular file, executable file, and symbolic link, the last stored as a blob whose content is the link target. 40000 marks a subtree, and 160000 marks a gitlink, a commit identifier recorded in a parent repository for a submodule. Ownership, permission bits beyond the executable flag, timestamps, and extended attributes are all discarded. Two checkouts of the same commit on different machines therefore produce identical trees.

Entries are sorted by name as raw bytes, with one twist: a subtree’s name is compared as though it ended with a slash. Because . is 0x2E and / is 0x2F, the file foo.txt sorts before the directory foo, even though the plain byte comparison of foo and foo.txt would put the directory first. The rule exists so that a reader walking a tree in order visits entries in the same order a recursive directory walk would produce, and so that tree comparison can be a single merge-like scan.

Directories that contain no files cannot be represented. Git tracks content, and an empty directory has none, so it vanishes on checkout. Projects that need one conventionally store a placeholder file. The same reasoning explains why a file’s history cannot be followed through a rename by inspecting a tree: the tree names the child, and the child knows nothing of the name.

Commits are snapshots with declared ancestry

A commit object is text: a tree line naming the root tree, zero or more parent lines, an author line, a committer line, optional headers such as encoding, gpgsig, or mergetag, a blank line, and the message. Each identity line carries a name, an address in angle brackets, a timestamp in seconds since the Unix epoch, and a numeric UTC offset. Both the wall-clock reading and the offset are stored, so the same instant recorded in two zones produces different bytes and therefore different commits.

Author and committer are separate because they answer different questions. The author wrote the change; the committer applied it to this history. A rebase preserves the author and rewrites the committer, which is why a rebased branch shows new identifiers for old work.

Parent order is meaningful. The first parent is the branch that was checked out when the merge happened, so git log --first-parent follows the mainline and skips the side branches folded into it. A commit may have more than two parents; the octopus strategy produces them, and the object format places no limit on the count.

A commit stores no diff. The difference between two commits is computed on demand by comparing their root trees, which is why git show on a merge commit prints a combined diff rather than a patch against one parent, and why the cost of displaying a commit is proportional to the size of the trees being compared rather than to any stored delta.

Annotated tags are the fourth object type. A tag object names an object, its type, a tag name, a tagger identity, and a message, and it is itself hashed and addressable. A reference may point at the tag object, which in turn points at a commit; that indirection is what allows a tag to carry a signature over the tagger and the message. A lightweight tag is not an object at all, merely a reference holding a commit identifier, and it can carry no metadata.

What the model refuses to do

The design has consequences that surface as surprises. Rename detection is a heuristic applied at display time, comparing deleted and added paths for similarity, because no rename was recorded. File modes other than the executable bit are lost, so a checkout cannot restore a setgid bit or an ACL. Empty directories disappear. And because the identity of a commit depends on its parents, rewriting history rewrites every descendant; there is no way to amend an old commit in place.

Collision resistance is the assumption underneath all of it. Git originally chose SHA-1 for speed, and the 2017 SHAttered demonstration of a practical chosen-prefix collision made the assumption contestable. Modern Git uses a collision-detecting SHA-1 implementation, and an alternative repository format using SHA-256 exists, but the object model itself is unchanged: the identifier is still the hash of the frame. Repositories using different hash functions cannot share objects, which is why the hash function is a property of the repository format rather than of an individual object.

The graph is also the reason that references carry all the meaning of “where the work is”. Objects do not point at the branches that contain them, and nothing marks an object as current. Reachability from a set of named starting points is the only notion of liveness, which is why deleting a branch does not immediately delete anything, and why the reclamation of unreachable objects is a separate, conservative process.

The staging area sits between the working tree and this graph. It is the only mutable structure in the repository core, and it is what allows a commit to be assembled rather than merely observed; its format is described in the index layout.

References

  1. Pro Git — Git Internals: Git Objects — Chacon & Straub, Apress
  2. Git source: object.h — Git project
  3. Git from the Bottom Up — John Wiegley