Inside the Git Object Model
Open .git with plumbing commands: blobs, trees, commits and tags, how a hash is computed, refs as files, revision syntax as graph arithmetic, and building a commit by hand.
Open .git with plumbing commands: blobs, trees, commits and tags, how a hash is computed, refs as files, revision syntax as graph arithmetic, and building a commit by hand.
You already drive Git well. You rebase, you cherry-pick, you dig
yourself out with the reflog. But somewhere under all of that
sits a black box: a .git directory you were told not to poke
at, doing work you take on faith.
That box is smaller than it looks — four object types, one hash
function, and files containing text. By the end of this lesson
you will read a repository's objects by hand, reproduce a Git
hash with shasum, and build a commit without running
git commit.
Git splits its own commands in two. Porcelain commands are
the ones you use daily — git status, git log, git merge —
built for humans, with colour, hints, and defaults that improve
each release. Plumbing sits underneath — git hash-object,
git cat-file, git rev-parse — built for scripts, with terse
output Git treats as a contract.
That contract is the point. Porcelain output changes between releases; plumbing does not, which is why every Git GUI and shell prompt reads plumbing rather than scraping porcelain.
Everything Git remembers lives in .git/objects, a
content-addressable store: an object's address is computed
from its own bytes. Picture a warehouse where the shelf number is
derived from what is in the box — bring the same thing in twice
and it lands on the same shelf. Deduplication is not a feature;
it falls out of the addressing.
There are exactly four types, and each one points only downward.
Points at another object, and adds a tag name, a tagger and a message. A lightweight tag is not an object at all — it is only a ref.
One tree hash, zero or more parent hashes, an author, a committer, and a message.
It stores no diff. Every diff you have ever read was computed on the spot from two trees.
A directory listing: a mode, type, hash and name per entry, pointing at blobs and other trees.
File contents. Only the bytes — no filename, no path, no permissions, no timestamp.
Write a blob into the store, with no file and no staging:
echo 'hello object model' | git hash-object -w --stdin772f68aa97505e114d67a4c730fadf5fc3fca8a1-w writes it; without it Git only computes the hash. Now
interrogate the store:
git cat-file -t 772f68aa # what type is this object?
git cat-file -p 772f68aa # pretty-print its contentsblob
hello object modelAny unambiguous prefix works. Now walk a real tree —
HEAD^{tree} is "the tree HEAD's commit points at":
git cat-file -p HEAD^{tree}100644 blob 772f68aa97505e114d67a4c730fadf5fc3fca8a1 README.md
100755 blob 75adf17945069812d12533afd5ddcf2d2f08dd56 build.sh
120000 blob 499258769d5766fbc8301b05de4e86837da97b44 latest.ts
040000 tree 148a8c1c71a3ae2abdf9528127cbcea6d04717aa srcMode, type, hash, name. The modes are a small fixed set, not general Unix permissions — only what Git can restore:
100644 — a regular file100755 — a regular file, executable bit set120000 — a symlink; the blob content is the target path040000 — a subdirectory, so a tree160000 — a gitlink: a submodule, stored as a commit hash
from another repository entirelyPretty-print 148a8c1c for the src listing. Trees pointing at
trees and blobs, all the way down.
A commit is text as well:
git cat-file -p HEADtree ad28b9588c62b9ea25ad79f645f28d3a6d5bd363
author Ada Lovelace <ada@example.com> 1772442871 +0000
committer Ada Lovelace <ada@example.com> 1772442871 +0000
Add README, source, and build scriptOne tree line, one parent line per parent (none on a root
commit, two or more on a merge), author, committer, blank line,
message. Diffs, "files changed", rename detection — all computed
on demand by comparing this tree to its parent's.
The author wrote the change; the committer put this exact object into the store. They match until you rewrite history. Amend, then look again:
author Ada Lovelace <ada@example.com> 1772442871 +0000
committer Ada Lovelace <ada@example.com> 1772643728 +0000Same author timestamp, new committer timestamp. That is why a rebased branch keeps its authorship dates while its commits are new objects.
The hash is not of the file. It is of a header plus the
content: the type, a space, the content's byte length, a NUL
byte, then the content. The string hello object model\n is 19
bytes, so:
printf 'blob 19\0hello object model\n' | shasum772f68aa97505e114d67a4c730fadf5fc3fca8a1 -Byte for byte, the hash Git gave you earlier (sha1sum works
too). Nothing is hidden: no filename, no timestamp, no repository
identity. Identical content anywhere on earth gets the same name,
which makes fetch and push a matter of comparing names, not
files.
Since 2017 SHA-1 has had practical collision attacks, so Git
ships sha1dc, a hardened SHA-1 that detects the byte patterns
those attacks need and refuses to hash them. There is also a full
SHA-256 format:
git init --object-format=sha256 my-repo # 64-character hashesThe caveat is real: SHA-256 repositories cannot interoperate with SHA-1 ones. You cannot push between them and most hosts reject them — finished work the ecosystem has not caught up to.
A ref is a file containing a hash. That is the mechanism.
cat .git/refs/heads/main # 40 hex characters plus a newline
wc -c .git/refs/heads/main # 41HEAD is different: a symbolic ref, a file holding another
ref's name. That indirection is all of "which branch am I on".
cat .git/HEAD # ref: refs/heads/main
git symbolic-ref HEAD # refs/heads/main
git rev-parse HEAD # 5fe405b3672567733f134b29e3412b66ee66a178Run git switch --detach <commit> and HEAD holds a raw hash
instead. That is detached HEAD, demystified. (Prefer switch
here over checkout: it does one thing, move HEAD.)
git pack-refs --all sweeps refs into .git/packed-refs, so an
empty .git/refs/heads is normal.
Once commits are nodes with parent pointers, revision syntax stops being trivia. Take this history, whose merge has two parents:
* 00deccf Merge feature
|\
| * a45ec8c Feature
* | fc32ac8 Main work
|/
* 9cb35ca Base^n selects which parent; ~n walks how far back, always
following the first parent.
| Syntax | Means |
|---|---|
HEAD^1 | first parent — fc32ac8, "Main work" |
HEAD^2 | second parent — a45ec8c, "Feature" |
HEAD~1 | one step back, first parent — fc32ac8 |
HEAD~2 | two steps back — 9cb35ca, "Base" |
@ | shorthand for HEAD |
HEAD@{2} | where HEAD was two moves ago (reflog) |
HEAD^{tree} | the tree of HEAD's commit |
v1.0^{commit} | the commit an annotated tag points at |
:/fix login | newest commit whose message matches |
Ranges are set operations. A..B is "reachable from B but not
from A". A...B is the symmetric difference: either, not both.
^A B is that first range longhand — ^ excludes, so you can
span several tips.
The staging area is not a metaphor. It is .git/index, a binary
file listing every tracked path with a mode, blob hash, and
stage number:
git ls-files -s100644 772f68aa... 0 README.md
100755 75adf179... 0 build.sh
120000 49925876... 0 latest.ts
100644 eab39ce8... 0 src/index.tsThat trailing 0 is the stage number; it stays 0 until a
conflict, when Git parks stages 1, 2, and 3 side by side for
base, ours, and theirs. git write-tree freezes the index into a
tree, git read-tree loads one back. Now build a commit with no
porcelain:
echo 'a note built by hand' > notes.md
git update-index --add notes.md # stage it, plumbing-style
TREE=$(git write-tree) # index -> tree object
NEW=$(echo "Add notes by hand" | git commit-tree "$TREE" -p HEAD)
git update-ref refs/heads/handmade "$NEW"
git log --oneline handmade4ae7626 Add notes by hand
5fe405b Add README, source, and build scriptA real commit, on a real branch, and git commit was never
invoked. Those lines hashed content into a blob, folded the index
into a tree, wrapped tree and parent into a commit, and rewrote a
41-byte file. That is all git commit does.
New objects are written loose — one zlib-compressed file per
object under .git/objects/ab/cdef.... Fine for a hundred
objects, ruinous for a million, so git gc rewrites them into a
packfile under .git/objects/pack, storing similar objects
as deltas.
git count-objects -vH # loose vs packed, and size
git gc # compact into packfiles
git verify-pack -v .git/objects/pack/*.idx | head4ae7626d... commit 226 155 12
772f68aa... blob 19 29 316
fb75ccc2... tree 176 180 463Size, packed size, offset; delta entries add a chain depth and a base hash.
An object survives because something reaches it: refs point
at commits, commits at trees and parents, trees at blobs.
Anything unreachable from a ref, the index, or a reflog entry is
garbage that git gc prunes.
git fsck --unreachable # objects currently floating freeunreachable commit ccdb5408a3bc22834a7cb54e804af9a71cf25e01This is the machinery under the reflog safety net you already
trust. "Lose" a commit to a bad reset and the object sits there
unreferenced but intact, with a reflog entry keeping git gc
away — 90 days by default, 30 for unreachable objects. Recovery
is pointing a ref back at it.
git hash-object -w --stdin # write stdin as a blob
git cat-file -t <hash> # object type
git cat-file -p <hash> # pretty-print contents
git cat-file --batch-check # type + size for stdin hashes
git cat-file -p HEAD^{tree} # list HEAD's root tree
git rev-list --objects HEAD # objects HEAD reaches
printf 'blob <len>\0<bytes>' | shasum # a hash, by hand
git init --object-format=sha256 repo # SHA-256 (no interop)
cat .git/HEAD # ref: refs/heads/main
git symbolic-ref HEAD # branch HEAD follows
git rev-parse HEAD # revision -> full hash
git update-ref refs/heads/x <sha> # create or move a ref
git pack-refs --all # refs -> packed-refs
git ls-files -s # index: mode/hash/stage/path
git update-index --add <file> # stage a file, plumbing-style
git write-tree # index -> tree object
git read-tree <tree> # tree -> index
git commit-tree <tree> -p <sha> # build a commit directly
git count-objects -vH # loose/packed counts and size
git gc # pack loose objects, prune
git verify-pack -v <pack>.idx # inspect a packfile
git fsck --unreachable # objects nothing points atYou now have a full map of Git's storage: four object types in a
content-addressable store, refs as files holding hashes, a real
index file, and packfiles as pure optimisation on top. Every
porcelain command combines those pieces, and cat-file and
rev-parse let you watch it happen.
Next comes finding regressions with git bisect, which puts
this model to work: bisect is a binary search over the commit
graph, and seeing it that way rather than as a magic bug-finder
is what lets you automate it.
Before you move on, spend twenty minutes in a scratch repository.
Hash a string by hand and match it. Walk a tree down to a blob.
Build a commit with commit-tree and put a branch on it. The
model sticks only once you have driven it.