G2 · Part 1 · Foundations
What Git actually stores
A key-value store of four object types, named by the hash of their contents, with a thin layer of pointers on top.
10On this page
Underneath the commands, Git is a content-addressable
key-value store. You hand it some bytes, it hands back a
key computed from those bytes, and later you can get the bytes
back with the key. Everything in .git/objects is one
of only four kinds of object. Knowing them separates people who
use Git from people who understand it.
Git keeps a folder of objects in .git/objects. A
blob is one file's content, a tree is one
folder, and a commit points at the top tree plus the
commit before it. Each object is named by a hash of its bytes,
so the same content always gets the same name and is stored
once. Branches and tags are just names that point at commits.
You never need to touch these files, but knowing they exist
explains why commits cannot change, why renames are guessed,
and why "deleted" work can usually be found again.
| Object | What it holds | Everyday name |
|---|---|---|
| Blob | The raw contents of a file. No filename, no permissions, just bytes. | A file's content |
| Tree | A directory listing: names, modes, and pointers to blobs and other trees. | A folder |
| Commit | One tree pointer, parent pointer(s), author, committer, timestamps, message. | A snapshot |
| Tag | An annotated pointer to another object, with its own message and optional signature. | A release marker |
Watch the objects appear
The fastest way to believe this is to make a tiny repository and look. Two files, one folder, one commit:
$ git init -q shop && cd shop
$ mkdir src
$ echo 'hello' > README.md
$ echo 'export const total = 0;' > src/cart.js
$ git add . && git commit -qm "Initial commit"
$ find .git/objects -type f
.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a
.git/objects/61/63e1adb649b845bdbc1aed214980c2807e014b
.git/objects/3d/cdbc9c963a9bbedfa0387bb1d81828c2c42706
.git/objects/8c/82d9d78771cc89157a7cb03e3fdbfb5d406a0c
.git/objects/6a/e07203cc790b57b95f573fdc803992b4aa3986
Five objects: two blobs (the two files), two trees (the root and
src/), and one commit. Each is a file named after
its ID, with the first two hex characters used as a folder so no
single directory ends up with millions of entries. Your IDs for
the blobs will match these exactly, because a blob's ID depends
only on its content. The commit's ID will differ, because yours
has a different author and timestamp.
Blobs, and how an ID is computed
Every object's ID is a hash of its own contents. Identical content anywhere in history produces the same ID and is stored once. It also means history is tamper-evident: change one byte in an old commit and its hash changes, which changes its child's hash, and so on down the chain. That cascade is exactly why rewriting history gives every downstream commit a new ID.
The hash is not taken over the file alone. Git first writes a short header: the object type, a space, the content length in bytes, and a NUL byte. Then it hashes header plus content with SHA-1, compresses the same bytes with zlib, and writes the result to disk.
prove it: Git's hash and a plain SHA-1 agree
$ echo 'hello' | git hash-object --stdin
ce013625030ba8dba906f756967f9e9ca394464a
$ printf 'blob 6\0hello\n' | shasum
ce013625030ba8dba906f756967f9e9ca394464a -
$ python3 -c "import zlib; print(zlib.decompress(open('.git/objects/ce/013625030ba8dba906f756967f9e9ca394464a','rb').read()))"
b'blob 6\x00hello\n'
A blob has no name. Rename README.md and the blob
does not change at all; only the tree that lists it does. Copy
the same logo into ten folders and Git stores it once. Every
repository in the world that contains a file whose content is
exactly hello and a newline has the object
ce01362.
Trees
A tree is one directory. Each entry has a mode, a type, an object ID and a name. Subdirectories are entries that point at other trees, which is how a whole project becomes one tree at the top.
$ git cat-file -p HEAD^{tree}
100644 blob ce013625030ba8dba906f756967f9e9ca394464a README.md
040000 tree 3dcdbc9c963a9bbedfa0387bb1d81828c2c42706 src
$ git cat-file -p 3dcdbc9
100644 blob 6163e1adb649b845bdbc1aed214980c2807e014b cart.js
| Mode | Means |
|---|---|
100644 | A normal file. |
100755 | An executable file. This single bit is the only permission Git records. |
120000 | A symbolic link. The blob holds the link's target path. |
040000 | A subdirectory, pointing at another tree. |
160000 | A submodule: a commit ID from a different repository, called a gitlink. |
Two consequences show up in daily work. Git records no owner, no
group and no read/write bits, only "executable or not", which is
why chmod +x shows up in a diff as
old mode 100644 / new mode 100755. And a tree is
built from files, so an empty directory has nothing to list and
cannot be committed. That is the whole reason people commit an
empty .gitkeep file.
Commits
A commit is short. It names one tree (the root of the snapshot),
zero or more parents, two identities with timestamps, and the
message. Here is a second commit that changed
src/cart.js:
$ git cat-file -t HEAD
commit
$ git cat-file -p HEAD
tree 258c07957443ecf77599b71ce4190186c7219cdd
parent 6ae07203cc790b57b95f573fdc803992b4aa3986
author Aisha Khan <aisha@corp.dev> 1754216400 +0530
committer Aisha Khan <aisha@corp.dev> 1754216400 +0530
Fix cart total when coupon is expired
The timestamp is Unix seconds plus the author's UTC offset.
Author is who wrote the change;
committer is who last wrote this commit object.
They differ after a rebase, a cherry-pick, or when a maintainer
applies your patch. A signed commit carries one more header,
gpgsig, holding the signature over everything else.
Note what is not in there: no branch name, no diff, no
list of changed files. The diff is computed by comparing this
commit's tree with its parent's tree.
Tags
There are two kinds of tag, and only one is an object. A
lightweight tag is a ref, a file under
refs/tags/ holding a commit ID, exactly like a
branch that never moves. An annotated tag
(git tag -a or -s) creates a tag object
with its own author, date and message, and the ref points at
that.
$ git tag -a v1.0 -m "First release"
$ git cat-file -p v1.0
object 550728f6c956638ff077ccb0fb293038ec04e933
type commit
tag v1.0
tagger Aisha Khan <aisha@corp.dev> 1754220000 +0530
First release
$ git rev-parse v1.0 v1.0^{}
fffdaf0757acf8370b1985b1ea6e40302304cc33
550728f6c956638ff077ccb0fb293038ec04e933
v1.0 resolves to the tag object;
v1.0^{} "peels" it to the commit underneath. Use
annotated tags for releases: git describe ignores
lightweight tags by default, and only annotated tags can carry a
signature.
Build a commit by hand
Porcelain commands like add and commit
are built on plumbing commands that each do one step.
Running them yourself once makes the model concrete:
$ git init -q -b main by-hand && cd by-hand
$ echo 'hello' | git hash-object -w --stdin
ce013625030ba8dba906f756967f9e9ca394464a
$ git update-index --add --cacheinfo 100644,ce013625030ba8dba906f756967f9e9ca394464a,README.md
$ git write-tree
853694aae8816094a0d875fee7ea26278dbf5d0f
$ git commit-tree 853694a -m "Built by hand"
91ccfe599e0f75155a6428a0cfc5faccb6dad501
$ git update-ref refs/heads/main 91ccfe599e0f75155a6428a0cfc5faccb6dad501
$ git log --oneline
91ccfe5 (HEAD -> main) Built by hand
That is git commit in five steps: store the blob,
record it in the index, turn the index into a tree, wrap the tree
in a commit, then move the branch. Your commit ID will differ
because of your name and the time; the blob and tree IDs will
not.
Refs: the names on top
Objects are immutable and anonymous. Refs are the mutable, human-readable layer: files whose content is an object ID (or, for a symbolic ref, the name of another ref).
| Ref | Where | What it is |
|---|---|---|
| Branches | refs/heads/* | Your local branches. Move when you commit. |
| Tags | refs/tags/* | Release names. Not expected to move. |
| Remote-tracking | refs/remotes/origin/* | Your last-known view of the remote. Move only on fetch. |
HEAD | .git/HEAD | Usually ref: refs/heads/main. A raw ID means detached. |
ORIG_HEAD | .git/ORIG_HEAD | Where HEAD was before a reset, rebase or merge. A quick undo point. |
FETCH_HEAD, MERGE_HEAD | .git/ | What the last fetch brought in; what is being merged right now. |
One file per ref gets slow with tens of thousands of tags, so
git gc packs them into a single
.git/packed-refs file. A loose file, if present,
wins over the packed entry.
$ cat .git/packed-refs
# pack-refs with: peeled fully-peeled sorted
550728f6c956638ff077ccb0fb293038ec04e933 refs/heads/main
fffdaf0757acf8370b1985b1ea6e40302304cc33 refs/tags/v1.0
^550728f6c956638ff077ccb0fb293038ec04e933
The line starting ^ is the peeled value of the tag
above it, cached so Git does not have to open the tag object.
Git 2.45 added a second backend, reftable
(git init --ref-format=reftable), a binary format
that stays fast with millions of refs and updates many refs
atomically. Either way, never edit ref files by hand; use
git update-ref, which also writes the reflog.
The classic files backend has real failure modes at
scale. Deleting one ref rewrites the whole
packed-refs file, which can be hundreds of
megabytes on a busy server. A transaction that moves many refs
is not atomic, so a reader can see a half-applied update. And
because each ref is a file path, two branches that differ only
in case (Fix and fix) collide on
macOS and Windows. Reftable stores refs in sorted binary tables
with compaction after each write, which removes all three
problems. An existing repository can switch with
git refs migrate --ref-format=reftable (Git 2.46
and later), and git rev-parse --show-ref-format
tells you which one you have. The Git project plans to make
reftable the default for new repositories in Git 3.0. Until
then, check that every tool touching the repository (IDE
plugins, tools built on other Git libraries, your CI image) understands
it before you migrate.
The index is a file too
The staging area is .git/index, a binary file that
starts with the magic bytes DIRC. It holds one entry
per tracked file: path, mode, blob ID, a stage number, and cached
file-system stats (size, modification time, inode) so
git status can skip hashing files that have not been
touched.
$ git ls-files --stage
100644 ce013625030ba8dba906f756967f9e9ca394464a 0 README.md
100644 0a5b761ba428dcd2cf481221b5d9a0df20c3c715 0 src/cart.js
$ xxd .git/index | head -2
00000000: 4449 5243 0000 0002 0000 0002 6ab6 c608 DIRC........j...
00000010: 0e8f 1eb9 6ab6 c608 0e8f 1eb9 0100 0011 ....j...........
After DIRC come the format version (2) and the entry
count (2). The 0 column in ls-files is
the stage. During a merge conflict one path gets up to three
entries: stage 1 is the merge base, 2 is yours, 3 is theirs.
Resolving a conflict means collapsing those back to a single
stage 0 entry, which is what git add does.
The three areas covers the index from the
user's side.
Loose objects and packfiles
Every object starts life as a loose object: one zlib file. That is simple but wasteful. A 20 KB file edited 300 times would be 300 nearly identical compressed copies. So Git periodically moves objects into a packfile, where similar objects are stored as deltas against each other.
$ git count-objects -v
count: 10
size: 40
in-pack: 0
packs: 0
size-pack: 0
prune-packable: 0
garbage: 0
size-garbage: 0
$ git gc
$ git count-objects -v
count: 0
size: 0
in-pack: 10
packs: 1
size-pack: 2
prune-packable: 0
garbage: 0
size-garbage: 0
count and size are loose objects and
their disk use in KiB; in-pack and
size-pack are the same for packs. Add
-H for human-readable sizes. To see inside a pack,
git verify-pack -v .git/objects/pack/pack-*.idx
lists every object with its type, size, packed size and offset,
and for deltas, the chain depth and the base object.
Some details that come up in practice:
-
Deltas are not "previous version" diffs. Git
picks bases by similarity, sorting objects by type, file name
and size, then trying each against a sliding window of
neighbours (
pack.window, default 10). A delta can be against any similar object, even a different file. -
Chains are capped.
pack.depth(default 50) limits how many deltas deep a chain goes, trading disk space for read speed. -
You rarely run gc yourself. Many commands run
git gc --auto, which acts when there are more than about 6,700 loose objects (gc.auto) or more than 50 packs (gc.autoPackLimit). For large repositories,git maintenance startschedules smaller background tasks instead and turns the automatic foreground gc off for that repository. Do not mix the two by hand: if you want a full gc in a repository under scheduled maintenance, rungit maintenance run --task=gc, which takes the same lock as the background jobs. - The network speaks packs. A clone, fetch or push builds a pack of exactly the objects the other side is missing, deltas included. That is why pushing a one-line change to a huge repository is fast.
-
Unreachable objects linger. Recent versions of
Git move unreachable objects into a cruft pack and only
delete them once they are older than
gc.pruneExpire(two weeks by default).git fsck --unreachablelists them, andgit fsck --lost-foundrecovers dangling blobs and commits.
SHA-1, collisions and SHA-256
Git was designed around SHA-1, which is no longer collision-resistant: in 2017 the SHAttered attack produced two different PDFs with the same SHA-1. Since version 2.13 Git uses a hardened implementation that detects the known collision technique and refuses such objects, so the practical risk for a normal repository is very low. The hash is there to name content and detect corruption, not to be your security boundary; use signed commits and tags for that.
The long-term fix is SHA-256. Since Git 2.29 you
can create a repository with
git init --object-format=sha256, where object IDs are
64 hex characters. The catch is interoperability: a SHA-256
repository cannot exchange objects with a SHA-1 one, and hosting
support is still limited. The Git project plans to make SHA-256
the default for new repositories in Git 3.0, which has no release
date yet, and it has no plan to drop SHA-1 repositories. For now,
write tooling that does not assume IDs are 40 characters long:
git rev-parse --show-object-format tells a script
which hash a repository uses.
Git does not track renames. No object records "this
file used to be called that". Git notices renames after the
fact by comparing content similarity between two snapshots
(50% similar by default, tunable with -M). That's
why a rename plus heavy edits in the same commit sometimes
shows up as a delete plus an add. Rename in one commit, edit in
the next, and git log --follow keeps working.
Finished reading?
0 of 18 chapters read in this series