GitGroundworkGit
2 / 18 written chapters

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.

12 min read3 diagrams10 sections
10On this page
  1. Watch the objects appear
  2. Blobs, and how an ID is computed
  3. Trees
  4. Commits
  5. Tags
  6. Build a commit by hand
  7. Refs: the names on top
  8. The index is a file too
  9. Loose objects and packfiles
  10. SHA-1, collisions and SHA-256
Fresher Mid Senior

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.

In one minute

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.

ObjectWhat it holdsEveryday name
BlobThe raw contents of a file. No filename, no permissions, just bytes.A file's content
TreeA directory listing: names, modes, and pointers to blobs and other trees.A folder
CommitOne tree pointer, parent pointer(s), author, committer, timestamps, message.A snapshot
TagAn 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.

HEADER + CONTENT blob 6\0 hello\n SHA-1 OBJECT ID ce013625030ba8dba906 f756967f9e9ca394464a zlib STORED AT .git/objects/ce/ 013625030ba8… \0 is a NUL byte. 6 is the content length in bytes, counting the newline.
The type is part of what gets hashed, so a blob and a tree with identical bytes still get different IDs. The first two hex digits become a folder, the other 38 the file name.

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
ModeMeans
100644A normal file.
100755An executable file. This single bit is the only permission Git records.
120000A symbolic link. The blob holds the link's target path.
040000A subdirectory, pointing at another tree.
160000A 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.

tag v1.0 fffdaf0 commit 550728f parent commit 6ae0720 tree / 258c079 tree / 8c82d9d tree src/ 7dcc306 blob README ce01362 tree src/ 3dcdbc9 blob cart.js total = 1 blob cart.js total = 0 README.md did not change, so both root trees point at the same blob.
The real object graph for the two-commit repository above. Commits point at trees, trees at blobs and other trees. A new commit only creates objects for what changed, plus new trees on the path from the root down to each changed file.

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).

RefWhereWhat it is
Branchesrefs/heads/*Your local branches. Move when you commit.
Tagsrefs/tags/*Release names. Not expected to move.
Remote-trackingrefs/remotes/origin/*Your last-known view of the remote. Move only on fetch.
HEAD.git/HEADUsually ref: refs/heads/main. A raw ID means detached.
ORIG_HEAD.git/ORIG_HEADWhere 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.

For seniors: files versus reftable

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.

LOOSE: A FILE EACH git gc cart.js, newest: stored whole older cart.js: delta (copy + insert) oldest: delta against the one above zlib only, no deltas .pack holds the objects; .idx maps each ID to an offset
A delta is a list of "copy bytes 0–4100 from the base, then insert these 30 bytes". Git keeps the newest version whole because that is the one you read most, and makes older versions pay the cost of rebuilding.
$ 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 start schedules 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, run git 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 --unreachable lists them, and git fsck --lost-found recovers 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.

Common misconception

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.