Git Internals: A Live Walkthrough

Why git was revolutionary, compared to the VCSs before it (CVS, SVN):

This walkthrough shows all of that from the inside: build a repo from scratch, watch .git change live, dissect every object, then see how merge and rebase differ and how packfiles work.

Live view of the git database, keep it visible throughout:

watch -d -n 1 find .git

Act 0: An empty repo is almost nothing

git init -b main demo
cd demo

Hooks are lifecycle scripts; the samples are inert, delete for a clean view:

rm .git/hooks/*.sample

Only three files have content, all plain text. Repo settings, then HEAD (which points to a branch that doesn't exist yet!), then a private never-committed .gitignore:

cat .git/config
cat .git/HEAD
cat .git/info/exclude

refs/heads/ and objects/ are empty for now. index, logs/ and COMMIT_EDITMSG don't exist yet; they appear later. The real repository is just objects/ plus refs/ and HEAD; the rest is config and convenience.

Act 1: Watch objects appear, then dissect them

echo 'first file' > first_file.txt

Watch pane: one file appears under .git/objects, before any commit. .git/index (the staging area) appears too.

git add first_file.txt

Plain cat shows zlib-compressed gibberish; cat-file is git's decoder (the sha = directory name + file name):

cat .git/objects/<xx>/<rest>
git cat-file -t <blob-sha>
git cat-file -p <blob-sha>

The index, readable: path, mode, blob sha:

git ls-files --stage

Blobs store contents only, never names or modes. Recompute the sha straight from the file, then copy the file and stage it: nothing new appears in the watch pane, and the index shows two paths sharing one sha.

git hash-object first_file.txt
cp first_file.txt copy_of_first_file.txt
git add copy_of_first_file.txt
git ls-files --stage
git rm -qf copy_of_first_file.txt

Objects are stored as <type> <size>\0<content>, SHA-1 hashed (that's the name), then zlib-compressed (that's why cat fails and cat-file works). Same bytes, same SHA, always.

Watch pane on commit: two more objects (a tree and a commit); logs/ and COMMIT_EDITMSG appear too.

git commit -m 'first commit'
cat .git/COMMIT_EDITMSG

The commit points at a tree (note its sha); the tree is a directory listing of (mode, type, sha, filename) entries pointing at blobs:

git cat-file -p HEAD
git cat-file -p <tree-sha>

A second file, repetitive on purpose. Its object is smaller than the file and fully unreadable: compression at work.

echo 'second second second second second' > second_file.txt
git add second_file.txt
cat .git/objects/<xx>/<rest>
git commit -m 'second commit'

This commit has a parent line: history is a linked list. And its tree reuses the old blob sha for first_file.txt: unchanged content is never stored twice.

git cat-file -p HEAD
git cat-file -p <tree-sha>

Act 2: A branch is a 41-byte file

A branch is a file with one SHA in it, and that SHA is just the latest commit:

cat .git/refs/heads/main
git cat-file -p <that-sha>

HEAD is a ref to a ref. Creating a branch writes exactly one new file and copies nothing; switching just rewrites HEAD:

cat .git/HEAD
git checkout -b feature
cat .git/HEAD

Act 3: Merge vs rebase, same graph twice

Diverge the two branches:

echo 'feature work' > feature.txt
git add .
git commit -m 'add feature'
git checkout main
echo 'main work' > main.txt
git add .
git commit -m 'work on main'
git log --oneline --graph --all

Merge. The result has TWO parent lines, and no existing commit changed:

git merge feature -m 'merge feature'
git cat-file -p HEAD
git log --oneline --graph --all

Undo: move the branch pointer back one; the merge commit still exists, just unreferenced:

git reset --hard HEAD^

Rebase. After rebasing, history is linear but 'add feature' has a DIFFERENT SHA, and the original commit still exists in the object database:

git checkout feature

[L1] Note the SHA of the 'add feature' commit in this output:

git log --oneline
git rebase main
git log --oneline --graph --all
git cat-file -p <add-feature-sha-from-L1>

The reflog remembers everywhere HEAD and each branch have been, and it's just a text file:

git reflog
git reflog feature
cat .git/logs/refs/heads/feature

Merge joins (new commit, two parents); rebase copies (new SHAs, old ones orphaned). Hence: don't rebase pushed history.

Fast-forward. main is now strictly behind feature, not diverged, so no combining is needed:

git log --oneline --graph --all
git checkout main
git merge feature

No new commit, nothing new in the watch pane; main's pointer just moved to feature's commit:

git log --oneline --graph --all

Act 4: Packfiles

Every object we created so far is a "loose" object: one file per object under .git/objects, each a compressed full snapshot. That's fast to write but wasteful, since near-identical versions of a file are each stored in full. git gc (garbage collection) is git's maintenance command: it packs all loose objects into a single packfile, storing similar objects as deltas against each other, and deletes orphaned objects that nothing references and that are old enough.

Count the loose objects first (count = loose, in-pack = packed):

git count-objects -v

Watch pane: the loose object directories vanish, and one .pack file (the data) plus one .idx file (its index) appear under objects/pack/:

git gc

Same objects, new home: count drops to 0 because the loose copies were deleted after being packed, and in-pack now holds them all:

git count-objects -v

How the pack compresses is too detailed to get into here; in short, similar objects are stored as byte-level deltas against each other inside the pack. This is a storage trick only: unlike older VCSs that stored diffs as the source of truth, git's model is still full snapshots, and every old SHA keeps resolving exactly as before. The same pack format is used on push and fetch; that's what the "Compressing objects..." message is.

Closing

Three object types (blob, tree, commit; annotated tags make a rarely-seen fourth) plus text files full of SHAs. Every "scary" git command is just moving those pointers.

References