Git from First PrinciplesPost 13 of 16 · 16 of 16 written

13. Under the hood: build a commit by hand

Git · 13 of 163 min readOct 2026

Git’s everyday commands, called porcelain, are built from small low-level commands called plumbing. Building a commit by hand with plumbing is the fastest way to make Parts 2–4 permanent in your head.

Exercise: a commit without add or commit

mkdir -p ~/git-lab/plumbing && cd ~/git-lab/plumbing
git init

# 1. Write a blob into the database
echo "print('hello')" > train.py
BLOB=$(git hash-object -w train.py)
git cat-file -t $BLOB                 # blob

# 2. Put it in the index under a filename: this is what `git add` does
git update-index --add --cacheinfo 100644 $BLOB train.py
git ls-files --stage

# 3. Turn the index into a tree object
TREE=$(git write-tree)
git cat-file -p $TREE                 # 100644 blob <hash>    train.py

# 4. Create a commit object that points at the tree
C1=$(echo "First commit, by hand" | git commit-tree $TREE)
git cat-file -p $C1

# 5. Point the branch at it: this is what puts it "on main"
git update-ref refs/heads/main $C1
git log --oneline
git status                            # clean: disk, index and HEAD agree

Now a second commit with a parent:

echo "print('hello, world')" > train.py
git update-index train.py             # re-hash the file and update its index entry
TREE2=$(git write-tree)
C2=$(echo "Second commit" | git commit-tree $TREE2 -p $C1)
git update-ref refs/heads/main $C2
git log --oneline --graph

You have just done by hand everything git add and git commit do, minus the editor, hooks and reflog entry.

Porcelain, translated into plumbing

Porcelain Is roughly
git add f git hash-object -w f, then git update-index --add
git commit git write-tree, git commit-tree -p HEAD, git update-ref
git branch b git update-ref refs/heads/b HEAD
git switch b git symbolic-ref HEAD refs/heads/b, then update index and files from b’s tree
git log git rev-list plus formatting

Plumbing worth knowing for scripts

  • git rev-parse --show-toplevel gives the repository root; git rev-parse --abbrev-ref HEAD gives the current branch.
  • git cat-file -p, -t and -s print an object’s content, type and size.
  • git ls-tree -r HEAD lists every file in a commit with its blob hash.
  • git rev-list --count HEAD counts commits.
  • git for-each-ref --sort=-committerdate refs/heads/ lists branches by recent activity.
  • git status --porcelain gives stable, machine-readable status. Despite the name, --porcelain here means “safe for scripts”.

In scripts, prefer plumbing and --porcelain output. Human-facing output can change between Git versions.

Storage: loose objects, packfiles and deltas

New objects are written loose, one compressed file each. Periodically, and whenever objects travel over the network, Git bundles them into packfiles in .git/objects/pack/, storing similar objects as deltas of each other.

So Git does use deltas, but only as an invisible storage optimization. The model you work with is still snapshots. This is why repositories stay small even though every commit is a full snapshot.

git count-objects -vH                         # loose and packed sizes
git verify-pack -v .git/objects/pack/*.idx | head   # objects and their delta chains

Network transfer works the same way. fetch tells the server which commits you already have; the server sends one packfile holding only the missing objects.

Garbage collection and the life of an abandoned commit

git gc packs objects and deletes unreachable objects older than a grace period. It runs automatically in the background, and git maintenance start schedules regular upkeep.

An abandoned commit goes through these stages before it is truly gone:

  1. No branch or tag reaches it any more (after a reset, rebase or branch deletion).
  2. The reflog still lists it, which keeps it alive, by default for 30 days.
  3. The reflog entry expires.
  4. After a further grace period, by default two weeks, gc deletes the object.

git fsck verifies every object against its hash and lists dangling objects: those nothing points to.

A map of .git

Path Holds
HEAD Your current position, usually a branch name
config Repository settings and remotes
index The staging area
objects/ The database: loose objects and packfiles
refs/heads/, refs/tags/, refs/remotes/ Branches, tags, remote-tracking branches
packed-refs Many refs stored in one file
logs/ The reflogs
hooks/ Hook scripts, with samples
info/exclude Your private ignore rules
ORIG_HEAD, MERGE_HEAD, FETCH_HEAD Temporary pointers left by reset, merge and fetch

Check yourself

  • Which three plumbing commands together make up git commit?
  • If every commit is a full snapshot, why are repositories small?
  • List the stages an abandoned commit passes through before it is deleted.