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:
-w writes it; without it Git only computes the hash. Now
interrogate the store:
Any unambiguous prefix works. Now walk a real tree —
HEAD^{tree} is "the tree HEAD's commit points at":
Mode, 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:
One 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:
Same 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:
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:
The 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.
HEAD is different: a symbolic ref, a file holding another
ref's name. That indirection is all of "which branch am I on".
Run 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:
^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:
That 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:
A 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.
Size, 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.
This 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.
You 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.
772f68aa97505e114d67a4c730fadf5fc3fca8a1blob
hello object model100644 blob 772f68aa97505e114d67a4c730fadf5fc3fca8a1 README.md
100755 blob 75adf17945069812d12533afd5ddcf2d2f08dd56 build.sh
120000 blob 499258769d5766fbc8301b05de4e86837da97b44 latest.ts
040000 tree 148a8c1c71a3ae2abdf9528127cbcea6d04717aa srctree ad28b9588c62b9ea25ad79f645f28d3a6d5bd363
author Ada Lovelace <ada@example.com> 1772442871 +0000
committer Ada Lovelace <ada@example.com> 1772442871 +0000
Add README, source, and build scriptauthor Ada Lovelace <ada@example.com> 1772442871 +0000
committer Ada Lovelace <ada@example.com> 1772643728 +0000772f68aa97505e114d67a4c730fadf5fc3fca8a1 -* 00deccf Merge feature
|\
| * a45ec8c Feature
* | fc32ac8 Main work
|/
* 9cb35ca Base100644 772f68aa... 0 README.md
100755 75adf179... 0 build.sh
120000 49925876... 0 latest.ts
100644 eab39ce8... 0 src/index.ts4ae7626 Add notes by hand
5fe405b Add README, source, and build script4ae7626d... commit 226 155 12
772f68aa... blob 19 29 316
fb75ccc2... tree 176 180 463unreachable commit ccdb5408a3bc22834a7cb54e804af9a71cf25e01echo 'hello object model' | git hash-object -w --stdingit cat-file -t 772f68aa # what type is this object?
git cat-file -p 772f68aa # pretty-print its contentsgit cat-file -p HEAD^{tree}git cat-file -p HEADprintf 'blob 19\0hello object model\n' | shasumgit init --object-format=sha256 my-repo # 64-character hashescat .git/refs/heads/main # 40 hex characters plus a newline
wc -c .git/refs/heads/main # 41cat .git/HEAD # ref: refs/heads/main
git symbolic-ref HEAD # refs/heads/main
git rev-parse HEAD # 5fe405b3672567733f134b29e3412b66ee66a178git ls-files -secho '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 handmadegit count-objects -vH # loose vs packed, and size
git gc # compact into packfiles
git verify-pack -v .git/objects/pack/*.idx | headgit fsck --unreachable # objects currently floating freegit 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 at