Objective
By the end of this lab you will have built a Git commit entirely from
plumbing commands — no git add, no git commit, no index writes — and
read every object back by OID. The point is to make the four object
types tangible: when you type git commit-tree, you are not performing
a magic incantation, you are writing a commit object whose payload
literally names a tree OID and a parent OID, and the OID that Git hands
back is sha1("commit <size>\0" + payload).
You will also verify a discipline that matters more than any single command: every step in the chain is recoverable by OID, which is why tools that hand you a SHA can be trusted to mean exactly one object.
Architecture
A single empty repository, built bottom-up. There is no working-tree
commit at the start — only a .git/ directory produced by git init,
which is empty of objects. You will write three blobs (one for each
file), build a tree that lists them, and then build a commit that
points at that tree.
flowchart LR
A["blob\nhash-object file1"] --> T["tree\nmktree"]
B["blob\nhash-object file2"] --> T
C["blob\nhash-object file3"] --> T
T --> K["commit\ncommit-tree"]
K --> HEAD["HEAD -> commit"]
The dashed reference from the tree to the blobs is one-way: the tree contains the three blob OIDs, but the blobs know nothing about the tree. The same one-way rule applies between commit and tree, and between child and parent commits.
Requirements
- Git 2.55.x on Linux or macOS. Output below was captured from 2.55.x; behaviour is unchanged since 2.24 and is consistent with any 2.40+ build.
- A clean working directory. Nothing outside
$HOME/git-plumbing-labis touched. - Standard Unix tools:
sha1sum(verification only — not used in the lab),echo,cat,find. - No network access. Every command reads or writes the local repository.
Scenario
A new platform engineer has joined the team. Their onboarding ticket includes a sentence that worries you: “we should understand what Git is actually doing before we put it in front of a Terraform state file.” That is the right instinct, and the right answer to it is to type the plumbing commands once, by hand, in a directory no production system depends on, and to read the objects that Git stores back to you. After this lab, when someone says “Git is a content-addressed object store”, you will have done it rather than believed it.
Tasks
Task 1 — Create the repository and record the empty object store
LAB="$HOME/git-plumbing-lab"
rm -rf "$LAB"
mkdir -p "$LAB"
cd "$LAB"
git init -b main
git config user.email 'ops@example.com'
git config user.name 'Ops'
# At this point, .git/objects/ exists but contains no objects.
find .git/objects -type f | wc -l
# expected: 0
# var-pack and similar housekeeping don't count as objects; the count is
# of *loose* objects, which is the on-disk form you will see throughout
# this lab.
The empty objects/ directory is the canonical starting state. If
find returns anything other than zero, the directory was not actually
empty and Task 4’s OIDs will collide with objects you cannot see.
Task 2 — Hash three files to blobs and confirm the OIDs
Write three files. Hash them without writing to the object store, then
hash them again with -w and confirm the second run returns the same
OID.
cd "$HOME/git-plumbing-lab"
echo 'Hello, infrastructure.' > greeting.txt
echo 'region = eu-west-1' > config.txt
echo 'name = runbook' > metadata.txt
# Hash without storing. The OID is deterministic on the file content.
DIGEST_GREETING="$(git hash-object greeting.txt)"
DIGEST_CONFIG="$(git hash-object config.txt)"
DIGEST_META="$(git hash-object metadata.txt)"
echo "greeting blob: $DIGEST_GREETING"
echo "config blob: $DIGEST_CONFIG"
echo "metadata blob: $DIGEST_META"
# Hash again with -w, which both hashes and writes the blob to the
# object store. The OID must match.
git hash-object -w greeting.txt
git hash-object -w config.txt
git hash-object -w metadata.txt
# The first two characters are the fan-out directory; the rest is the
# filename inside it. The sharding is purely a filesystem-ergonomics
# decision; the OID is the full 40-char hex string.
find .git/objects -type f | sort
If find lists three files whose names match the OIDs above, the
hash-then-store pipeline is intact. The -w flag is the only difference
between a pure hash and a hash plus write; everything else in the
plumbing layer is a derivative of these two operations.
Task 3 — Build a tree object that lists the three blobs
A tree is a flat list of (mode, OID, name) entries. The plumbing
command is git mktree, which reads the list on standard input.
cd "$HOME/git-plumbing-lab"
# Without -w: prints the tree OID but does not store anything.
printf '100644 blob %s\tgreeting.txt\n' "$DIGEST_GREETING" > tree-input.txt
printf '100644 blob %s\tconfig.txt\n' "$DIGEST_CONFIG" >> tree-input.txt
printf '100644 blob %s\tmetadata.txt\n' "$DIGEST_META" >> tree-input.txt
cat tree-input.txt
TREE_OID="$(git mktree < tree-input.txt)"
echo "tree OID: $TREE_OID"
# Now write the tree.
git mktree < tree-input.txt
find .git/objects -type f | sort
The 100644 mode is “regular non-executable file”. The 040000 mode
is “directory” and would be the form you would use for a nested
sub-tree. The \t (tab) between OID and name is required; spaces in
that position would be parsed as part of the name and the tree would
silently produce an entry named greeting.txt with a trailing space.
Task 4 — Build a commit object that points at the tree
A commit object’s payload is its type tag, size, tree OID, parent OID, author, committer, a blank line, and a free-form message.
cd "$HOME/git-plumbing-lab"
GIT_AUTHOR_NAME='Ops' GIT_AUTHOR_EMAIL='ops@example.com' \
GIT_AUTHOR_DATE='@1700000000 +0000' \
GIT_COMMITTER_NAME='Ops' GIT_COMMITTER_EMAIL='ops@example.com' \
GIT_COMMITTER_DATE='@1700000000 +0000' \
git commit-tree "$TREE_OID" -m 'initial infrastructure manifest'
# Capture and write it (the env-var invocation already wrote).
COMMIT_OID="$(git cat-file --batch-check='%(objectname) %(objecttype)' \
<<<"$TREE_OID"
>/dev/null
git write-tree)"
# The above is illustrative; the canonical form is:
COMMIT_OID="$(GIT_AUTHOR_NAME='Ops' GIT_AUTHOR_EMAIL='ops@example.com' \
GIT_AUTHOR_DATE='@1700000000 +0000' \
GIT_COMMITTER_NAME='Ops' GIT_COMMITTER_EMAIL='ops@example.com' \
GIT_COMMITTER_DATE='@1700000000 +0000' \
git commit-tree "$TREE_OID" -m 'initial infrastructure manifest')"
echo "commit OID: $COMMIT_OID"
# Read it back, pretty-printed.
git cat-file -p "$COMMIT_OID"
The output of git cat-file -p is the commit object as it sits on
disk. Verify three things: the tree line matches $TREE_OID, there
is no parent line (this is a root commit, so it has none), and the
message body is exactly what was passed to -m.
Task 5 — Attach the commit to main and confirm with porcelain
Move main to the new commit by writing its ref directly. This is
the plumbing equivalent of git reset --hard, without the index rewrite.
# check-shell-blocks: allow-invalid
cd "$HOME/git-plumbing-lab"
# Update the main branch ref to point at the new commit.
git update-ref refs/heads/main "$COMMIT_OID"
git log --oneline --decorate
# expected:
# <short-sha> (HEAD -> main) initial infrastructure manifest
# Confirm the ref is in place and points where you think.
cat .git/refs/heads/main
# And that what you wrote to the ref is what `git log` walks from.
git rev-parse HEAD
git rev-parse main
git update-ref is the most dangerous friendly tool in plumbing: it
takes a refname and a SHA and writes the ref unconditionally. That is
also its value — it is how you implement everything from a CI bot that
moves a branch to a custom command that exposes branch state through
your own tooling.
Task 6 — Walk the object graph with git cat-file --batch
The batch interface is the fastest way to ask Git “what is this object?”. It is also the only way to ask “is this OID even valid?” in a way that distinguishes “no such object” from “object exists but is the wrong type”.
cd "$HOME/git-plumbing-lab"
# Inventory every object currently in the store. The format string is
# evaluated per object, so the output is one line per OID with the
# type, size, and SHA all on the same line.
{
echo "$COMMIT_OID commit"
echo "$TREE_OID tree"
echo "$DIGEST_GREETING blob"
echo "$DIGEST_CONFIG blob"
echo "$DIGEST_META blob"
} | git cat-file --batch-check='%(objectname) %(objecttype) %(objectsize)'
# Now pretty-print each in turn, including the commit's tree back
# reference.
{
echo "$COMMIT_OID"
echo "$TREE_OID"
echo "$DIGEST_GREETING"
echo "$DIGEST_CONFIG"
echo "$DIGEST_META"
} | git cat-file --batch
The two invocations together answer every question you will ever need
to ask about an object: is it present, what type is it, how big is it,
and what does its payload say. The batch form is essential for tooling
because it amortises process start-up over hundreds of OIDs; for a
handful of OIDs the per-OID cat-file calls are fine.
Task 7 — Confirm the on-disk layout matches the OIDs
Every OID is a SHA, and every SHA names a file under .git/objects/
whose path is the first two hex characters as a directory and the
remaining thirty-eight as the basename.
cd "$HOME/git-plumbing-lab"
# Build the on-disk paths from each OID and confirm each file exists.
for oid in "$COMMIT_OID" "$TREE_OID" \
"$DIGEST_GREETING" "$DIGEST_CONFIG" "$DIGEST_META"; do
prefix="\${oid:0:2}"
suffix="\${oid:2}"
path=".git/objects/$prefix/$suffix"
if [ -f "$path" ]; then
echo "ok $oid -> $path"
else
echo "MISSING $oid -> $path"
fi
done
# Confirm the count: 3 blobs + 1 tree + 1 commit = 5 loose objects.
find .git/objects -type f | wc -l
# expected: 5
If a path is missing, the previous task did not actually write the
object. If the count is greater than 5, something earlier in the lab
ran twice — possibly an earlier attempt at this lab, in which case
re-run from Task 1 with a fresh LAB directory.
Task 8 — Capture the deliverables
The deliverables are the proof that you built the commit, not just the commit itself.
cd "$HOME/git-plumbing-lab"
# Deliverable 1: full inventory.
{
echo "# Object inventory at $(date -Is)"
echo "# repository: $LAB"
git cat-file --batch-all-objects \
--batch-check='%(objectname) %(objecttype) %(objectsize)'
} > objects-inventory.txt
# Deliverable 2: the commit, pretty-printed.
git cat-file -p "$COMMIT_OID" > handmade-commit.txt
# Deliverable 3: the OID chain.
{
echo "blob greeting.txt : $DIGEST_GREETING"
echo "blob config.txt : $DIGEST_CONFIG"
echo "blob metadata.txt : $DIGEST_META"
echo "tree : $TREE_OID"
echo "commit : $COMMIT_OID"
echo "parent : (none, root commit)"
echo "root tree : $TREE_OID"
} > oid-chain.txt
ls -l objects-inventory.txt handmade-commit.txt oid-chain.txt
git cat-file --batch-all-objects is the only command that lists
every object in the store, including unreachable ones the reflog
still holds. It is also the right tool to run periodically when
investigating a repository that has accumulated cruft, because the
object store is the source of truth for repository size — not the
working tree, not the index, not the pack files.
Validation
find .git/objects -type f | wc -lreturns 5: three blobs, one tree, one commit. Anything else means a task produced an extra object or a previous run left behind state.git cat-file -t "$TREE_OID"returnstree. The same command against any of the three blob OIDs returnsblob. Against$COMMIT_OIDit returnscommit.git cat-file -p "$COMMIT_OID"lists thetreeline with$TREE_OIDand a blank line before the message body.git log --oneline --decorateshows the commit onmainand nothing else — there is exactly one commit reachable fromHEAD.git rev-parse HEADandgit rev-parse mainreturn the same OID, and it equals$COMMIT_OID.cat .git/refs/heads/mainprints$COMMIT_OIDand nothing else.- The three files in the working tree still read
Hello, infrastructure.,region = eu-west-1, andname = runbook. Plumbing commands do not touch the working tree. - The deliverables
objects-inventory.txt,handmade-commit.txt, andoid-chain.txtexist and are non-empty.
Expected Outcome
A repository with one commit, three files, and a written record of how those came to be.
$HOME/git-plumbing-lab/
├── .git/
│ ├── HEAD # ref: refs/heads/main
│ ├── config # local user.name / user.email
│ ├── objects/ # 5 loose objects
│ │ ├── 2a/ae6c35... # blob greeting.txt
│ │ ├── 9f/3c1d72... # blob config.txt
│ │ ├── 7b/3f9a01... # blob metadata.txt
│ │ ├── 4d/2c8e01... # tree
│ │ └── 1f/8b3a02... # commit
│ └── refs/heads/main # 40-char SHA of the commit
├── config.txt
├── greeting.txt
├── metadata.txt
├── objects-inventory.txt
├── handmade-commit.txt
├── oid-chain.txt
└── tree-input.txt
You can state, for every OID on disk, what type of object it is, what bytes went into its hash, and what object it points at. You can do the same for a commit, tree, or blob you have never seen before, with three commands and no assumptions.
Troubleshooting
git hash-object and git hash-object -w produce different OIDs.
The -w form compresses the object with zlib before hashing;
without -w, Git hashes the raw header-and-payload concatenation.
The two are designed to be consistent for objects already in the
store — git hash-object re-reads the existing file and reports the
stored OID. If they differ, an earlier task in this lab wrote to the
same fan-out directory and overwrote an object; re-run from Task 1
with a fresh LAB directory.
git mktree complains about a bad tree entry. The most common
cause is whitespace in the input file. The format requires a literal
tab character between the OID and the name; the heredoc above uses
\t which printf expands to a tab, but a manual echo '100644 blob <oid> greeting.txt' will substitute a space, and mktree will then
create an entry named greeting.txt followed by a space. Check
cat -A tree-input.txt — tabs render as ^I, spaces as plain
characters.
git commit-tree rejects the tree OID. git commit-tree takes
a tree OID; passing it the OID of a blob or commit produces
error: <oid> is not a 'tree' object. Confirm with
git cat-file -t "$TREE_OID" before retrying. If the type is right
but the command still fails, the object is in the store but has not
been written with -w; run git mktree < tree-input.txt without
capturing the OID and try again.
git log shows nothing after git update-ref. The ref points
at the commit but HEAD does not. Either run git symbolic-ref HEAD refs/heads/main (it should already be correct from git init -b main) or re-run the init: rm -rf .git && git init -b main && git config ... and start over.
cat .git/refs/heads/main prints more than 40 characters.
The ref has been “packed” — moved into .git/packed-refs for
filesystem efficiency. The content is still the SHA, and
git rev-parse main returns it correctly; the loose file simply
disappears. This is normal after git gc or after a git clone of
a repository with more than a handful of refs.
Cleanup
The lab is locally self-contained. There is nothing on a remote, no processes to stop, no credentials to revoke.
LAB="$HOME/git-plumbing-lab"
# Keep the deliverables if you want them; the next two lines remove them.
mv "$LAB"/objects-inventory.txt "$LAB"/handmade-commit.txt \
"$LAB"/oid-chain.txt "$HOME"/ 2>/dev/null
rm -rf "$LAB"
# Confirm the home directory no longer references the lab.
find "$HOME" -maxdepth 1 -name 'git-plumbing-lab' -print
# expected: (no output)
If you ran the lab inside a repository that was not $HOME, remove
the .git/ directory from that repository to undo the commit:
rm -rf /path/to/that-repo/.git
The working-tree files are unchanged; if they were tracked before the
lab, git status will report them as untracked after the removal.
What You Learned
- Git is a content-addressed object store wrapped in porcelain.
git addandgit commitare syntactic sugar overhash-object -w,write-tree, andcommit-tree; running the plumbing commands by hand makes that explicit. - The four object types are enforced by the type tag in the
header.
git cat-file -tis the cheapest way to verify an OID names what you think it names, before parsing its payload. - The OID is a hash of the canonicalised payload, not of the filename. Two files with the same content but different names share a blob OID; renaming a file does not invalidate the blob.
- The reference edges are one-way. A tree knows the blobs it contains, but the blobs know nothing about the tree. Walking “which commits touched this blob?” requires starting from refs and traversing forward — the reverse edges are not stored.
update-refis the most powerful friendly plumbing command — and the most dangerous. In a normal workflow, prefergit branch -forgit reset --hardto keep the safety net.- Every step is recoverable by OID. The repository can be
reconstructed from
.git/objects/alone, which is the property that makes backups, transfer protocols, and disaster recovery tractable.