GitAdvanced

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.

A commit pointing to a tree, which points to blobs and a subtree, with the main branch and HEAD pointing at the commit
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:

Bash
$ echo 'hello' | git hash-object --stdin
ce013625030ba8dba906f756967f9e9ca394464a

Run 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:

Bash
$ 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:

Bash
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:

Bash
$ find .git/objects -type f
.git/objects/08/05455a24b6c68fbc38d0fa5d121f735984285d
.git/objects/11/b15b1a4584b08fa423a57964bdbf018b0da0d5
.git/objects/16/ae6a2afcc370fa1a06bc31edcacc3a7f2c41cc
.git/objects/60/546c2588e9d75d822392d6f296e1fd99f04c96
.git/objects/ef/6ad6746727fa1d0efcf4a020ec3caeda5ccf64

Your 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:

Bash
$ git cat-file -p HEAD
tree 60546c2588e9d75d822392d6f296e1fd99f04c96
author Ada <ada@example.com> 1793256281 +0000
committer Ada <ada@example.com> 1793256281 +0000

First commit

A commit is just a few lines of text. It names exactly one tree, the snapshot of the whole project. Follow it:

Bash
$ 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:

Bash
$ 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    src

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

main HEAD 16ae6a2ffb686060546c217b6cd9ef6ad67b6dcf64080545511b15b19f69d32 commit · First commitcommit · Greet the worldtree · project roottree · project roottree · src/tree · src/blob · README.mdblob · app.py (v1)blob · app.py (v2) parent shared by both trees
Two commits: the second points to its parent and to a new tree; the unchanged README blob is shared by both trees, and main and HEAD point to the newest commit

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.

Bash
$ cat .git/HEAD
ref: refs/heads/main

$ cat .git/refs/heads/main
ffb68609991f7ac36c595767d2d91545868364cb

HEAD 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 experiment just writes a new 41-byte file. (Git may later move refs into a single .git/packed-refs file, which works the same way.)
  • Detached HEAD means HEAD contains a commit hash directly instead of ref: 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:

Bash
# 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"
Bash
$ git log --oneline
8e0d264 Commit made by hand
ffb6860 Greet the world
16ae6a2 First commit

A 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:

Bash
$ 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 8e0d264

The 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?