Git from First PrinciplesPost 13 of 16 · 16 of 16 written
- How to use this guide
- 01Why version control exists
- 02Git's data model: a content-addressed object database
- 03References: branches, HEAD and tags are pointers
- 04The three areas: working tree, index, repository
- 05Daily commands, mapped onto the model
- 06Branching and integration: merge, rebase, cherry-pick
- 07Undoing things safely
- 08Remotes and collaboration
- 09Workflows and conventions
- 10Git for AI and ML work
- 11Git with AI coding agents
- 12Power tools
- 13Under the hood: build a commit by hand
- 14Debugging mindset and recovery playbook
- 15Practice path, fluency checklist and glossary
- 16Sources
13. Under the hood: build a commit by hand
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 agreeNow 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 --graphYou 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-toplevelgives the repository root;git rev-parse --abbrev-ref HEADgives the current branch.git cat-file -p,-tand-sprint an object’s content, type and size.git ls-tree -r HEADlists every file in a commit with its blob hash.git rev-list --count HEADcounts commits.git for-each-ref --sort=-committerdate refs/heads/lists branches by recent activity.git status --porcelaingives stable, machine-readable status. Despite the name,--porcelainhere 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 chainsNetwork 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:
- No branch or tag reaches it any more (after a reset, rebase or branch deletion).
- The reflog still lists it, which keeps it alive, by default for 30 days.
- The reflog entry expires.
- After a further grace period, by default two weeks,
gcdeletes 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.