Every git project has a hidden .git folder, and somehow that one folder holds every version of every file you've ever committed. Branches, history, the lot. I've used git for years without really knowing what was in there.

So I built a small version of it from scratch in Python, called PyGit, to find out. It turns out the core idea is simple enough to explain with a thumbprint, a scrapbook and a diary.

pip install fromscratch-pygit 

Make a repository

$ pygit init Initialized empty pygit repository in /home/you/story 

That creates a hidden .pygit folder, which is PyGit's version of .git:

.pygit/ HEAD which branch you're on index the staging area objects/ every version of every file, ever refs/heads/ one tiny file per branch 

Almost everything interesting lives in objects/. Let's put something in it.

Fingerprints for files

$ echo "Once upon a time" > story.txt $ pygit add story.txt Staged story.txt (12abed85) 

That 12abed85 is a hash: a fingerprint of the file's contents. Press your thumb on an ink pad and you get a print that's unique to you, and the same every time. A hash works the same way for data. The same content always gives the same fingerprint, and change one letter and you get a completely different one.

Here's the fun part. Ask the real git for the fingerprint of the same text:

$ echo "Once upon a time" | git hash-object --stdin 12abed85bde98c2c401eb221069e59386ae3fd2e 

Identical. PyGit uses the same recipe as git: put the label blob <size> in front of the content and run it through SHA-1.

A scrapbook of photos

The file's content is now saved under its fingerprint:

$ cat .pygit/objects/12/abed85bde98c2c401eb221069e59386ae3fd2e blob 17 Once upon a time 

This is a blob. Think of it as a photo of a page in a scrapbook. It stores the content, labelled with its fingerprint, but it doesn't know the file's name or where it lives. (The first two characters of the hash become a folder name so that no single folder ends up with millions of files in it.)

Storing things by fingerprint has a lovely side effect. Copy the file and add the copy:

$ cp story.txt copy.txt $ pygit add copy.txt Staged copy.txt (12abed85) $ find .pygit/objects -type f | wc -l 1 

Same content, same fingerprint, so it's stored once. Two names, one photo. That's a big part of why a git repository stays smaller than you'd expect.

A table of contents and a diary

If blobs don't know their names, something else must. That's a tree: a table of contents that lists each filename and the fingerprint of its blob, with sub-trees for folders.

Then a commit is a diary entry pointing at a tree:

$ pygit commit -m "Start the story" [main 2c1e492d] Start the story 

Make a change and commit again, then look inside the second commit (hashes shortened here):

$ cat .pygit/objects/de/811a25ee2a3bd0b859c6b983d5bc39e5208c37 commit 205 {"tree": "accd9d19...", "parent": "2c1e492d...", "author": "Anonymous", "timestamp": "2026-10-27T16:02:24+00:00", "message": "Add a dragon"} 

Everything a diary entry needs: what the project looked like (the tree), who, when, why, and crucially what came before (the parent). Each commit points back to its parent, so the history is just a chain you can walk backwards:

$ pygit log # author and date lines trimmed commit de811a25ee2a3bd0b859c6b983d5bc39e5208c37 Add a dragon commit 2c1e492d2642f1769b58c26dbfd7a2d2e8acfd66 Start the story 

And because a commit's fingerprint covers its parent's fingerprint, you can't quietly change history: alter an old commit and every fingerprint after it changes too.

A branch is just a sticky note

This was the bit that surprised me most. A branch is a tiny text file containing one commit's fingerprint. That's it.

$ cat .pygit/refs/heads/main de811a25ee2a3bd0b859c6b983d5bc39e5208c37 

Making a branch writes one more of those files. Committing on a branch just moves its sticky note forward:

from pygit.repository import Repository repo = Repository("story") repo.create_branch("happy-ending") repo.switch_branch("happy-ending") # ...edit story.txt... repo.add("story.txt") repo.commit("Give it a happy ending") [c.message for c in repo.log()] # ['Give it a happy ending', 'Add a dragon', 'Start the story'] 

Switch back to main and its log still stops at "Add a dragon". The happy ending only exists on its own branch, like a page in a Choose Your Own Adventure book.

Spot the difference

Finally, diff compares two versions line by line, like a spot-the-difference puzzle:

from pygit.diff import diff_lines, format_diff before = "Once upon a time\nthere was a dragon\n" after = before + "who became a friend\n" print(format_diff(diff_lines(before, after))) 
 Once upon a time there was a dragon + who became a friend 

Lines with + were added, - removed, and everything else is unchanged.

What real git does that PyGit doesn't

PyGit is built to be read, so I kept the simple version of a few things. Each one makes a good exercise:

  • Compression. Real git squeezes every object with zlib. PyGit stores them as plain text so you can cat them.
  • Tree and commit format. Blob hashes match git exactly, but PyGit writes trees and commits as simple JSON, so those hashes differ from git's.
  • Checkout. Switching branches in PyGit moves HEAD but doesn't rewrite your files to match. Real git does both.
  • Merging and packfiles. Not there yet. Merging two branches is the next big idea to build.

Even so, the core of git turned out to be four ideas stacked on top of each other: fingerprints, blobs, trees and commits. Once you've built them, that .git folder stops being a mystery.

Try it

PyGit is part of From scratch in Python, a series of 18 projects that rebuild real systems: a database, an operating system, a search engine, a tiny GPT and more. Get the whole series with pip install fromscratch-pystack, or start at https://robcox2910.github.io/