Skip to main content
RunBook Academy

← All labs in Git, CI/CD & GitOps

Lab · foundation · ~60 min

Lab 1: Inspect git objects with plumbing commands

C · SimulationB · Nested virtualisation

Objectives

  • Initialise a repository with `git init` and inspect the empty `.git/` layout
  • Hash a file to a blob with `git hash-object` and confirm the OID matches what Git stores on disk
  • Build a tree object by hand with `git mktree` and read it back with `git cat-file -p`
  • Construct a commit object with `git commit-tree` that points at the tree and a parent OID
  • Trace the relationship between a working-tree file, the blob OID, the tree OID, and the commit OID with `git cat-file --batch`

Prerequisites

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-lab is 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 -l returns 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" returns tree. The same command against any of the three blob OIDs returns blob. Against $COMMIT_OID it returns commit.
  • git cat-file -p "$COMMIT_OID" lists the tree line with $TREE_OID and a blank line before the message body.
  • git log --oneline --decorate shows the commit on main and nothing else — there is exactly one commit reachable from HEAD.
  • git rev-parse HEAD and git rev-parse main return the same OID, and it equals $COMMIT_OID.
  • cat .git/refs/heads/main prints $COMMIT_OID and nothing else.
  • The three files in the working tree still read Hello, infrastructure., region = eu-west-1, and name = runbook. Plumbing commands do not touch the working tree.
  • The deliverables objects-inventory.txt, handmade-commit.txt, and oid-chain.txt exist 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 storegit 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 &lt;oid&gt; 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: &lt;oid&gt; 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 add and git commit are syntactic sugar over hash-object -w, write-tree, and commit-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 -t is 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-ref is the most powerful friendly plumbing command — and the most dangerous. In a normal workflow, prefer git branch -f or git reset --hard to 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.

Deliverables

  • · objects-inventory.txt — every OID with its type and size, captured from `git cat-file --batch-all-objects`
  • · handmade-commit.txt — the full output of `git cat-file -p` against the commit built in Task 4
  • · oid-chain.txt — the chain blob → tree → commit, with the parent and root-tree OIDs labelled

Verification status

Last reviewed
2026-08-25
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.