How Git Really Works: Blobs, Trees, Commits and Refs
Open the .git folder and see Git for what it is: a content-addressed object store with movable pointers on top. Build a commit by hand and finally understand rebase, reset and the reflog.
On this page
Most people learn Git as a list of commands to memorise. That works until something goes wrong: a rebase rewrites history you didn't expect, a reset --hard "deletes" a day's work, or you find yourself in a detached HEAD. Then the commands stop making sense.
Underneath, Git is surprisingly simple. It's a key-value store of immutable objects, plus a handful of movable pointers called refs. Once you've seen both with your own eyes, every Git command becomes "add some objects, then move a pointer".
You'll get the most from this if you're comfortable with everyday branching. If not, read A Practical Git Branching Workflow for Beginners first.
Content-addressed storage#
Git stores everything (file contents, directories, commits) as objects, and each object's name is the SHA-1 hash of its content. Ask Git to hash some text:
$ echo 'hello' | git hash-object --stdin
ce013625030ba8dba906f756967f9e9ca394464aRun that on any machine, in any repository, and you get the same hash. Git hashes a small header (the word blob, a space, the size in bytes and a null byte) followed by the content, and you can reproduce it without Git at all:
$ printf 'blob 6\0hello\n' | sha1sum
ce013625030ba8dba906f756967f9e9ca394464a -This design has big consequences. Identical content is stored only once, however many files or commits contain it. And since the name depends on the content, an object can never change: change one byte and you get a different object with a different name.
The four object types#
| Type | Stores | Points to |
|---|---|---|
| blob | the contents of one file (no name, no permissions) | nothing |
| tree | one directory: names, modes and the hashes of its entries | blobs and other trees |
| commit | a snapshot: author, committer, date and message | one tree and its parent commit(s) |
| tag | an annotated tag: tagger, message, optional signature | usually a commit |
Let's make a tiny repository and look at real objects:
git init -b main demo && cd demo
mkdir src
echo "# Demo" > README.md
echo 'print("hello")' > src/app.py
git add . && git commit -m "First commit"That one commit created five objects, stored as compressed files under .git/objects/, named by the first two characters of the hash, then the rest:
$ find .git/objects -type f
.git/objects/08/05455a24b6c68fbc38d0fa5d121f735984285d
.git/objects/11/b15b1a4584b08fa423a57964bdbf018b0da0d5
.git/objects/16/ae6a2afcc370fa1a06bc31edcacc3a7f2c41cc
.git/objects/60/546c2588e9d75d822392d6f296e1fd99f04c96
.git/objects/ef/6ad6746727fa1d0efcf4a020ec3caeda5ccf64Your hashes will mostly match
Blobs and trees depend only on content, so if you used the same files, those four hashes will be identical on your machine. The commit hash won't be: it includes your name, email and the exact time.
Reading objects with cat-file#
git cat-file -p pretty-prints any object. Start from the commit that HEAD points to:
$ git cat-file -p HEAD
tree 60546c2588e9d75d822392d6f296e1fd99f04c96
author Ada <ada@example.com> 1793256281 +0000
committer Ada <ada@example.com> 1793256281 +0000
First commitA commit is just a few lines of text. It names exactly one tree, the snapshot of the whole project. Follow it:
$ git cat-file -p 60546c2
100644 blob 0805455a24b6c68fbc38d0fa5d121f735984285d README.md
040000 tree ef6ad6746727fa1d0efcf4a020ec3caeda5ccf64 src
$ git cat-file -p ef6ad67
100644 blob 11b15b1a4584b08fa423a57964bdbf018b0da0d5 app.py
$ git cat-file -p 11b15b1
print("hello")Notice where the file names live: in the trees, not in the blobs. Renaming a file creates a new tree but reuses the same blob.
Now change one file and commit again:
$ echo 'print("hello, world")' > src/app.py
$ git commit -am "Greet the world"
$ git cat-file -p HEAD
tree 17b6cd9de955edb28d8a9b0e4668f588ba3c664a
parent 16ae6a2afcc370fa1a06bc31edcacc3a7f2c41cc
...
$ git cat-file -p 'HEAD^{tree}'
100644 blob 0805455a24b6c68fbc38d0fa5d121f735984285d README.md
040000 tree b6dcf64039da33b42fb025d2cc49c6d070c65686 srcThe new commit has a parent line pointing to the first commit. Its root tree has a new src tree (because app.py changed), but README.md still points to the very same blob, 0805455. Unchanged files cost nothing.
Snapshots, not diffs
Each commit stores a full snapshot (a tree), not a list of changes. git diff and git show compute changes on the fly by comparing two trees. To save space on disk, git gc later packs objects into packfiles and delta-compresses similar ones, but that's a storage detail, invisible to everything else.
Refs: branches are 41-byte files#
If objects never change, how does a branch "move"? Branches aren't objects. They're refs: tiny files that contain a commit hash.
$ cat .git/HEAD
ref: refs/heads/main
$ cat .git/refs/heads/main
ffb68609991f7ac36c595767d2d91545868364cbHEAD usually holds the name of the current branch, and the branch file holds a commit hash. When you commit, Git writes the new objects and then rewrites that one branch file with the new hash. That's all a commit is, from the branch's point of view.
This explains several things that otherwise feel like magic:
- Creating a branch is instant, whatever the size of the repository:
git branch experimentjust writes a new 41-byte file. (Git may later move refs into a single.git/packed-refsfile, which works the same way.) - Detached HEAD means
HEADcontains a commit hash directly instead ofref: refs/heads/.... New commits still work, but no branch moves to remember them. - Tags are refs under
refs/tags/that, by convention, never move.
Build a commit by hand#
To prove there's nothing more to it, let's commit a file without git add or git commit, using only Git's low-level "plumbing" commands:
# 1. Store the content as a blob (-w writes it into .git/objects)
blob=$(echo "Made without git add" | git hash-object -w --stdin)
# 2. Put it into the staging area (the index) under a file name
git update-index --add --cacheinfo 100644 "$blob" NOTES.txt
# 3. Turn the index into a tree object
tree=$(git write-tree)
# 4. Create a commit object pointing to that tree, with HEAD as its parent
commit=$(git commit-tree "$tree" -p HEAD -m "Commit made by hand")
# 5. Move the branch pointer
git update-ref refs/heads/main "$commit"$ git log --oneline
8e0d264 Commit made by hand
ffb6860 Greet the world
16ae6a2 First commitA completely normal commit. Run git status now and you'll see NOTES.txt listed as deleted: it's in the commit and the index, but we never wrote it to the working directory. git restore NOTES.txt fixes that. Those are Git's three places for a file: the object database, the index, and your working directory.
What this explains#
Rebase creates new commits. A commit's hash covers its parent's hash. When you rebase a branch, every commit gets a new parent, so Git has to create brand-new commits with new hashes, even if the code changes are identical. The old commits still exist; the branch simply points somewhere else now. That's why rebasing commits that others have already pulled causes trouble: they still have the old ones.
reset just moves a pointer. git reset --hard HEAD~2 points the current branch two commits back (and updates your files to match). The commits themselves aren't deleted.
The reflog remembers where pointers were. Every time HEAD or a branch moves, Git logs it. So a "lost" commit is usually one command away:
$ git reset --hard HEAD~2 # oops
$ git reflog
16ae6a2 HEAD@{0}: reset: moving to HEAD~2
8e0d264 HEAD@{1}: ...
$ git reset --hard 8e0d264 # or: git branch rescue 8e0d264The safety net has an expiry date
Commits that no branch, tag or reflog entry can reach are eventually deleted by git gc. By default, reflog entries for unreachable commits expire after 30 days (90 days for reachable ones). Recover lost work soon, and never assume an unpushed commit is safe forever.
Integrity is built in. Because every commit's hash covers its tree, and every tree's hash covers its blobs, one commit hash vouches for the entire history behind it. Change a single byte anywhere in the past and every hash after it changes. (Git is gradually moving from SHA-1 to SHA-256 for exactly this reason; you can already try it with git init --object-format=sha256.)
Plumbing commands worth knowing#
| Command | What it does |
|---|---|
git cat-file -t <obj> |
Show an object's type |
git cat-file -p <obj> |
Pretty-print an object |
git ls-tree -r HEAD |
List every file in a commit's tree, recursively |
git rev-parse main |
Turn a name (branch, tag, HEAD~2…) into a hash |
git hash-object -w <file> |
Store a file as a blob |
git update-ref <ref> <hash> |
Point a ref at a commit |
git count-objects -vH |
Show how many objects there are and how much space they use |
Recap#
- Git is a content-addressed object store: every object's name is the hash of its content, so objects never change.
- Blobs hold file contents, trees hold directories and names, commits point to one tree and their parents.
- Commits are snapshots; unchanged files are shared between them for free.
- Branches, tags and HEAD are refs: small files containing a hash or another ref's name.
- Rebase, reset and amend create new objects and move pointers. They don't edit history in place, which is why the reflog can usually bring things back.
Next time a Git command surprises you, ask two questions: which objects did it create, and which pointer did it move?