Software Engineering · Version Control Practice
What a Commit Contains and Why Its Hash Is Content-Addressed
The four object types, the exact bytes a commit hash covers, and why editing one old file rewrites every descendant hash.
A repository is often described as a history of changes. It is more accurate to say it is a set of immutable objects addressed by the hash of their own bytes, plus a handful of mutable names that point into that set. Branches, tags, HEAD, the index, and the reflog are bookkeeping on top. Most behaviour that looks strange at the command line — why a rebase changes commit ids, why deleting a branch frees nothing, why the same change can exist twice in history — follows from that sentence.
Four object types and the tree walk
A blob holds file content and nothing else: no filename, no permissions, no timestamp. Two files with identical bytes in different directories, or in different commits, are the same object. The hash is computed over a header and the content together:
sha1("blob " + decimal_length + "\0" + content)
The header is not decoration. It ensures that a blob and a tree that happen to contain the same bytes cannot produce the same digest, because the type word differs. git hash-object performs exactly this computation and prints the result; with -w it also writes the object into the store.
A tree holds a directory listing. Each entry is a mode, a name, and the hash of a blob or another tree. Modes are written in octal with the leading zero dropped: 100644 for a regular file, 100755 for an executable, 120000 for a symlink, 40000 for a subdirectory, and 160000 for a gitlink, the pointer a submodule occupies. Entries are sorted by name, with one wrinkle worth knowing: for sorting purposes a subdirectory’s name is treated as though it ended in a slash. That rule puts foo.txt before foo/, which a plain byte comparison of the two names would not do.
A commit holds a tree hash, zero or more parent hashes, an author line, a committer line, and a message. Author and committer are separate fields, each with a name, an address, a timestamp in seconds since the Unix epoch, and a timezone offset. The split exists because replaying a commit onto a different base preserves who wrote the change while recording who applied it. A tag object wraps a target hash with a name, an optional message, and optionally a signature; a lightweight tag is not an object at all, just a ref.
Walking from a commit therefore means reading the commit, reading its tree, recursing into subtrees, and reading blobs only when content is actually required. git cat-file -p prints any object in its decoded form, and git ls-tree stops after the directory level.
What the hash covers
The commit digest is SHA-1 over the same style of header followed by the commit body: "commit <length>\0", then the body. Every field is inside that digest — tree, parents, both identities, both timestamps, both timezones, and the message including its trailing newline. Nothing is excluded, and nothing outside the body is included, so two commits with identical content but a one-second difference in committer time are different objects.
The consequence is a cascade. Change one byte in one file and the blob hash changes; the tree entry pointing at that blob changes, so the tree hash changes, and every tree above it up to the root; the root tree hash sits inside the commit, so the commit hash changes; every commit whose parent list names the old hash changes; and so on to the tip. This is not an implementation detail that can be configured away. It is why a rebase rewrites ids, why two branches applying the same change independently produce two commits, and why a force-push is visible to every clone that has already fetched.
The same property gives deduplication for free. Because the address is a function of content alone, storing one file across a hundred commits costs one blob. Checking out an old revision reconstructs nothing; the objects are already present, byte-identical, and verified on read.
Parent pointers make a DAG
The parent list is the only structural edge in the model. One parent continues a line; two parents record that two lines were joined; more than two record an octopus merge. The root commit has none. Because hashes are immutable, the graph only grows: there is no operation that edits a commit’s parents in place, only one that creates a replacement.
Reachability is defined by following those edges backwards. A branch ref is a file containing a hash; HEAD is normally a symbolic ref naming a branch. Deleting a branch removes a name, not objects. Objects become unreachable only when no ref, reflog entry, index entry, or other object refers to them, and even then they survive until a garbage collection prunes them. The default grace period for unreachable objects is two weeks, which is what makes an accidentally deleted branch recoverable through the reflog for a while.
Two commits are related only if a path of parent edges connects them. There is no notion of file identity across commits in the object model itself: no inode, no rename record. A rename is inferred after the fact by comparing trees and noticing that a blob disappeared from one path and appeared at another. This is why rename detection has a similarity threshold and why it can be turned off, and why a heavily edited file may stop being recognised as the same file at all.
Collision resistance and the SHA-1 question
SHA-1 produces a 160-bit digest, so the output space is 2^160 and a random collision needs on the order of 2^80 work by the birthday bound. That is far beyond accidental risk, but SHA-1 is not collision-resistant against a motivated attacker. The SHAttered attack produced two different PDF documents with the same SHA-1 in 2017, and chosen-prefix collisions against the same function followed later.
Git’s response was to adopt a collision-detecting SHA-1 implementation rather than to change the hash outright. The detection tracks the internal state while hashing and flags the specific differential path that known attack constructions require, so a colliding pair is rejected at write time even though the digests agree. Git also supports an alternative object format based on SHA-256. That format changes every hash in a repository and cannot be mixed with SHA-1 objects inside one repository, so it is opt-in and existing history is not converted in place.
For day-to-day use the practical reading is narrower than the cryptography suggests. A hash is an identifier, not a security boundary. Integrity against an attacker who can write to the object store requires signatures over commits, because anyone able to create objects can compute whatever digest the format asks for.
What the model implies for practice
Three working rules follow directly. First, any operation that changes an ancestor changes every descendant, so rewriting an old commit is never local in effect. Second, because identity includes timestamps and identities, content equality is not commit equality; patch-level comparison needs a separate mechanism. Third, because branches are names and not containers, moving work between them is a matter of repointing refs, which is why reset, rebase, and cherry-pick can feel destructive while actually only adding objects and moving one pointer.
The layout is also what makes a merge base computable at all. With parent edges fixed and the graph acyclic, finding the common ancestor of two tips is a graph search over immutable data. What Git does with the result is the subject of three-way merge and merge base selection, and the ref-level consequences of rewriting appear in rebase as patch replay.
The storage layer is deliberately uninteresting at this level: objects are compressed with zlib and written to a path derived from the first byte of the hash, and a later repack regroups them into delta-compressed packs. Both representations describe the same immutable graph, and a repository can switch between them without changing a single id.
References
- Pro Git, 2nd edition — Git Internals — Chacon & Straub
- Git documentation — git-hash-object — Git project
- The first collision for full SHA-1 — Stevens, Bursztein, Karpman, Albertini & Markov, CRYPTO 2017
- FIPS 180-4: Secure Hash Standard — NIST