What happens inside .git?

COMMITe2e7213HEAD → main
PUBLISHEDMar 7, 20233y ago
READING15 min1,874 words
#git#storage·by santiago toscanini
Git internals·Part 1 of 4

A while ago I gave a tech talk about git internals. The format was simple: a slide deck, a terminal, and a .git folder that we dissected live. This post is that talk, but better, because here the .git folder is alive on the page.

Every figure below is generated by a tiny but real implementation of git running in your browser. Real SHA-1, real zlib, real object formats. The hashes you'll see are identical to the ones real git produces, and by the end of the post you can check that claim yourself, because you'll know exactly how those hashes are made. The figures are also alive: hover anything to learn what it does, click any object to open it. And when you want to change things instead of reading, the labs between sections are fully editable.

01 · Introduction

The .git folder is not black magic. It's the most understandable piece of plumbing you'll ever meet.

One of the famous UNIX values is "everything is a file". Git takes it seriously: every concept you know (branches, commits, files, tags, even where you're standing right now) is stored as a plain file. Most of them you can open and read.

So that's what we're going to do.

02 · git init

After we run git init git-tech-talk, git creates this:

That's the entire starter kit. Hover over each entry to see what it does. The highlights:

  • HEAD tells you where you are. It points to refs/heads/main, which doesn't exist yet since we have no commits. A reference to nothing, and git is fine with that.
  • config works like your .gitconfig, but only for this repository.
  • description is the project description that git web interfaces show. Almost nobody ever edits it.
  • hooks/ holds scripts that git runs on events, and it ships full of .sample examples.
  • info/exclude works like .gitignore, except it never leaves your machine.
  • objects/ and refs/ are the two folders that matter. Everything git stores lives in them, and the rest of this post is about them.

03 · The staging area

The Pro Git book puts it well:

"The staging area is a file […] that stores information about what will go into your next commit. Its technical name in Git parlance is the index, but the phrase staging area works just as well."

Let's catch git in the act. We create a file with echo 'version 1' > test.txt and stage it with git add test.txt. The figure below shows .git after those two commands, with everything that appeared highlighted in green:

Two things appeared:

  1. .git/index, the staging area itself. A binary file that lists what will go into the next commit. git ls-files reads it.
  2. A new object in .git/objects/83/baae61…. Git took the content of our file and stored it as a BLOB (Binary Large Object).

The object landed in a folder called 83. That's not random, and it's the key to everything that follows.

Content Addressable Storage

Now a strange experiment. We create a different file with the exact same content, echo 'version 1' > another-file.txt, and stage it too. Look closely at the object database:

Two files staged, and still only one object.

Git stores objects using Content Addressable Storage (CAS): the filename of an object is the SHA-1 hash of its content. The first two characters become the folder name (83) and the other 38 become the file name (baae61…). Same content, same hash, same file on disk. A thousand copies of the same file cost git exactly one blob.

It also means an object can never change. If you change the content, you didn't modify the object. You created a new one, somewhere else, with a different name. We remove the duplicate with git rm another-file.txt --force and move on.

Reading an object with our bare hands

The objects are files, so can we just cat one? Almost: they're compressed with zlib before touching disk, so cat prints garbage. Decompressing with zlib-flate reveals the real content, and git's own plumbing reads it politely with git cat-file. Here is our blob in all three forms, switch between them:

What the middle view shows is the universal shape of every git object (blob, tree, commit, tag):

$type $size\0$content

For our file that means blob 10, a NUL byte, and version 1 plus a newline. Ten bytes of content. And the line under the figure proves it: hash the decompressed bytes with openssl sha1 and you get the object's own path back. The ID is literally the SHA-1 of the bytes. Nothing more.

Don't take my word for it. The box below builds the object in real time (it's git hash-object wearing a UI). Type any content and watch the size, the bytes and the ID follow every keystroke. And it's not a toy on the side: whatever you leave here is the test.txt that the labs further down build on.

There are four types of git objects:

TypeWhat it stores
blobThe content of a file (just the content, no name, no permissions)
treeA snapshot of a directory: names, modes, and pointers to blobs and trees
commitA pointer to one tree, plus the parent commit, author and message
tagAn annotated tag: a name, a tagger and a message, pinned to another object

We already met the blob. Now the other two that matter.

04 · git commit

One detail before committing: a commit stores the author, the committer and two timestamps. Since the commit's ID is a hash of that content, committing "now" produces a different hash every second. So we pin everything with environment variables (GIT_AUTHOR_DATE, GIT_COMMITTER_DATE and friends). Deterministic input, deterministic hash. After running the pinned git commit -m 'first-commit', .git gains a lot more than objects:

  • Two new objects: the commit, and something else we'll open in a minute.
  • refs/heads/main finally exists. Our branch. It's a 41 byte text file that contains a commit ID, and that's all a branch is. Creating a branch copies 41 bytes. People say branching in git feels free because it is.
  • COMMIT_EDITMSG is the scratch file where commit messages get written: commit without -m and your editor opens this exact path. The last message stays behind in it, handy the next time a failing hook eats one you spent ten minutes on.
  • logs/ is a journal of every place your refs have pointed. It's the data behind git reflog, and the reason "I lost my commit" is almost never true.
  • MERGE_RR and rr-cache belong to rerere, the git feature that records how you resolve conflicts so it can reuse the resolution later. My global config has it enabled, it's great.

This is why we pinned those environment variables: every field of the commit is part of the hashed bytes. Prove it below (this box is git commit-tree with knobs). The tree is derived live from whatever you left in the blob box up in section 03, and every field is editable. Add a second to the date and the whole ID turns over. With the file at version 1 and the fields untouched, the math lands exactly on 76309f4, the same commit from the figure above. Nothing here is hardcoded, it just falls out of the bytes.

Now let's follow the pointers. HEAD points at the branch, the branch holds the commit ID, the commit points at a tree, and the tree points at our blob. That chain is the whole figure below. Every node opens on click, and so do the object IDs in the caption:

The commit object is plain text inside the same $type $size\0$content envelope, and it contains almost nothing:

  • a tree ID, the snapshot of the code
  • the author and committer with their timestamps
  • the message

The tree object is binary, its SHAs are stored as raw bytes, which is why the zlib-flate view prints garbage for it. Pretty printed, it reads like ls -l: for each entry you get the permissions, the object type, the object ID and the name. Note where the names live: in the tree, not in the blob. Renaming a file costs git a new tree and zero new blobs.

05 · Merkle trees

Wikipedia defines it like this:

"[A] hash tree or Merkle tree is a tree in which every leaf node is labelled with the cryptographic hash of a data block, and every node that is not a leaf […] is labelled with the cryptographic hash of the labels of its child nodes."

That's exactly what we've been building. A tree's content is the list of its children's hashes, so the tree's own hash derives from theirs, recursively, all the way down to the blobs. The same structure powers blockchains and Amazon's Dynamo. Git shipped it in 2005. (If you want the structure on its own terms — inclusion proofs, the odd-node bug that became a Bitcoin CVE, and the forgery that works against the version everyone draws — it has its own post.)

The consequence: when a file changes, the change bubbles up as new hashes, and everything untouched keeps its exact hash and gets reused. Let's prove it. We copy our old file into a subfolder with mkdir bak && cp test.txt bak/test.txt, change the original to version 2, create new.txt, and commit again. Count what's new:

Four new objects: two blobs, one tree, one commit. Interesting, there's no new tree for bak/. The graph explains why:

The new root tree points at bak with the hash d8329fc…, which is the entire root tree of commit #1, reused byte for byte. That's the pulsing node. Git didn't copy or diff anything; it never even noticed it was reusing. It computed the hash of bak's contents, found an object with that name already in the database, and moved on. CAS did all the work.

Every commit is a full snapshot of your project, and still the repository barely grows, because unchanged subtrees collapse into a single shared hash. That's the entire trick.

You can feel the cascade directly. Below is the second commit's object graph, built live on top of your commit from the lab above: your blob, your commit fields, everything carries through. Bright nodes are objects this commit had to create, dim ones already existed in the database. Type in any file and watch the change bubble up to the root. bak/test.txt starts as a copy of your blob, and that's exactly why bak/ reuses your first tree. Edit it and watch the reuse break.

06 · What's next

The labs stay live after you finish reading. Feed them your own content, your own commit fields, and watch every hash recompute. That's the whole post in one gesture: the bytes decide everything.

The story continues from here:

  • Branches, checkout and the reflog: the pointers into the database, and the reverse arrow that turns objects back into files
  • Packfiles: why git gc makes objects/ shrink, down to the bit level
  • The wire: what clone actually says over HTTP, byte by byte

The git running behind these figures is around 400 lines of TypeScript. The fun part was getting its hashes to land exactly on real git's, which by now you know is just a matter of getting the bytes right.

If you want to keep digging, I also built py-git, a small git implementation in Python that goes beyond what we covered here: write-tree, commit-tree, packfile parsing, and clone over the smart HTTP protocol. Reading it is a good preview of the next talk.

$git log --oneline public/posts/git-internals/
e2e7213Merkle trees and Bloom filters: the two structures the blog kept borrowingtoday
536605dNeural networks from scratch: a third post on pixels and vectorisationtoday
© 2026 · v2.0 · santiago toscanini