Build Your Own Mini-Git: Objects, Trees, Commits, Packfiles
Git's storage model is small enough to reimplement in one afternoon: four object types, one hash recipe, and a packfile format. This guide builds that program in stdlib Python, and every hash it produces is cross-checked against real Git — the same fixture through git plumbing must give the same hash, or the program is wrong. The failures stay in, with the exact errors, because the failures are the useful part.
What you will build
A program called minigit.py, about 120 lines of stdlib Python, that writes Git objects real Git accepts. By the end it has built a two-commit history from scratch — blobs, trees, commits, an annotated tag — plus a hand-written packfile, all inside a repository created with git init. The acceptance test at every stage is hash parity: your program's hash for a fixture must equal what Git's own plumbing produces for the same fixture.
The program, in full, is at the end of the guide. Each section below builds one function and verifies it before moving on.
| Stage | What your program writes | How real Git checks it |
| write_blob | Loose blob object | git hash-object, git cat-file -p |
| build_tree | Tree object from entries | git write-tree on the same fixture, same hash |
| write_commit | Commit object with parent | git log reads the history |
| write_tag | Annotated tag object | git cat-file -t v0.1 says tag |
| write_pack | One-object v2 packfile | git index-pack accepts it, git cat-file -p reads it back |
What this guide does not build: merge, checkout, fetch, or clone. Those need separate machinery, and this guide scopes itself to what it implements and tests. It does not write delta packs by hand either — the delta format is read from the specification and demonstrated with Git's own packer, never presented as hand-encoded. SHA-256 repositories and the reftable ref format are out of scope: everything here targets SHA-1, the format Git 2.43.0 uses by default.
The toolchain reality first
Tested with Git 2.43.0 and Python 3.12.3 on Ubuntu 24.04. Python is the implementation language because its standard library already has both pieces the hash recipe needs: hashlib for SHA-1 and zlib for the object compression. No third-party package is involved, so there is no dependency decision to get wrong.
The timestamps in the program are fixed (1793872800 +0000, that is 2026-11-05 10:00:00 UTC) so that every hash in this guide is reproducible: run the same program with the same fixtures and you get the same hashes printed below. A real Git commit uses the current time; a fixed time is a testing choice, not a format requirement.
The hash recipe: one line, everything follows
Git is a content-addressable filesystem: at its core is a simple key-value store, and the key is a hash of the content plus a header. The header is <type> <size in bytes> followed by a NUL byte, and the object id is the SHA-1 of the header concatenated with the content. The loose object format states the same rule: each loose object contains a prefix <type> <size>\0, the whole thing is compressed with zlib, and the object id is the hash of the uncompressed data.
import hashlib
def object_id(objtype, payload):
"""The hash recipe: SHA-1 of `<type> <size>\0` + payload."""
header = objtype.encode() + b" " + str(len(payload)).encode() + b"\x00"
return hashlib.sha1(header + payload).hexdigest(), header + payload
Verify the recipe against Git before trusting it:
$ printf 'hello mini-git\n' > hello.txt
$ git hash-object -w hello.txt
4fcf3f7958ee3302e1c6b9c75e7d0cc4a7f09c53
$ python3 -c "
import hashlib
data = b'hello mini-git\n'
print(hashlib.sha1(b'blob ' + str(len(data)).encode() + b'\x00' + data).hexdigest())"
4fcf3f7958ee3302e1c6b9c75e7d0cc4a7f09c53
The two hashes are identical. That is the whole acceptance test for this stage, and every later stage repeats the pattern: compute it yourself, then ask Git.
Loose objects: one file per object
A loose object is one file per piece of content, stored under .git/objects, in a subdirectory named with the first two characters of the SHA-1 and a filename of the remaining 38 characters. The stored bytes are the zlib-deflated header-plus-content. The writer is three lines once the recipe exists:
import os, zlib
def write_object(gitdir, objtype, payload):
"""Write a loose object; return its id. Existing objects are kept."""
oid, store = object_id(objtype, payload)
path = os.path.join(gitdir, "objects", oid[:2], oid[2:])
if not os.path.exists(path):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(zlib.compress(store))
return oid
def write_blob(gitdir, data):
return write_object(gitdir, "blob", data)
Check what actually landed on disk. The object Git stored for hello mini-git\n decompresses to exactly the header plus the content:
$ python3 -c "
import zlib
raw = zlib.decompress(open('.git/objects/4f/cf3f7958ee3302e1c6b9c75e7d0cc4a7f09c53', 'rb').read())
print(raw)"
b'blob 15\x00hello mini-git\n'
A blob stores file content only — no filename is stored with it. That is why the next object type exists: the filename has to live somewhere, and that somewhere is the tree.
Trees: filenames, modes, and the sort order
A tree object solves the filename problem: it contains one or more entries, each the mode, type, and filename of a blob or subtree, plus that object's SHA-1. The entries are binary — each entry is the mode as ASCII, a space, the filename, a NUL byte, and the 20 raw bytes of the SHA-1 — so a tree cannot be built by joining text lines.
def build_tree(gitdir, entries):
"""entries: list of (mode, name, oid). Git sorts entries by name,
treating a directory name as if it ended in '/'."""
def sort_key(entry):
mode, name, _oid = entry
return name + ("/" if mode == "040000" else "")
body = b""
for mode, name, oid in sorted(entries, key=sort_key):
# Mode is ASCII octal WITHOUT leading zeros: a subtree is
# "40000", not "040000" -- see the failure below.
raw_mode = mode.lstrip("0").encode() or b"0"
body += raw_mode + b" " + name.encode() + b"\x00"
body += bytes.fromhex(oid)
return write_object(gitdir, "tree", body)
The valid modes for files are 100644 (normal), 100755 (executable), and 120000 (symbolic link); directories use the tree mode. Run the program on a three-entry fixture — a normal file, an executable, and a subdirectory — and then build the same fixture through Git's plumbing and compare:
$ python3 minigit.py .git demo
blob hello.txt: 4fcf3f7958ee3302e1c6b9c75e7d0cc4a7f09c53
blob lib/util.py: f772568f65f0d35e93e878fa1a9462224899e59c
blob run.sh: 85ba14df52f8c72688537de6e7555fb402217b1e
tree lib/: 4272798587a0b34cfe273d3f803dec359d4fa445
tree root: 9ca3da53d7177dd16b07080527f7fe5ecc9e2b80
$ git update-index --add hello.txt lib/util.py run.sh
$ git write-tree
9ca3da53d7177dd16b07080527f7fe5ecc9e2b80
$ git cat-file -p 9ca3da53d7177dd16b07080527f7fe5ecc9e2b80
100644 blob 4fcf3f7958ee3302e1c6b9c75e7d0cc4a7f09c53 hello.txt
040000 tree 4272798587a0b34cfe273d3f803dec359d4fa445 lib
100755 blob 85ba14df52f8c72688537de6e7555fb402217b1e run.sh
git write-tree returns 9ca3da53…, the same hash the program computed. That is the tree stage passing its acceptance test.
Two details in that output are easy to misread. First, git cat-file -p prints the subtree mode as 040000, zero-padded to six digits — but the bytes on disk are 40000, five characters with no leading zero. The first version of this program wrote 040000 into the tree, and the hash came out different from Git's:
git write-tree: 9ca3da53d7177dd16b07080527f7fe5ecc9e2b80
minigit tree root (from demo): 9137f3180672cd61044f5f0e63950b400292f111
git fsck named the exact defect:
warning in tree 79617593b31bb2be22d8b7bfd09c4b666f807902: zeroPaddedFilemode: contains zero-padded file modes
Stripping the leading zero fixed the hash. Second, the sort: entries sort by name with directory names treated as if they ended in /, so lib/ sorts between hello.txt and run.sh. A tree written in the wrong order is not just untidy — git fsck rejects it outright:
error in tree 44a34d1c46c50044b29ff0e56970614f63c70511: treeNotSorted: not properly sorted
That error came from a deliberately unsorted tree built during testing. Sort the entries, or Git will not accept the tree as valid.
Commits: a tree, a parent, and two names
A commit object specifies the top-level tree for the snapshot, the parent commits if any, the author and committer information, a blank line, and then the message. The program writes it with the same write_object as everything else:
def write_commit(gitdir, tree, parents, author, committer, message):
lines = [b"tree " + tree.encode()]
for parent in parents:
lines.append(b"parent " + parent.encode())
lines.append(b"author " + author.encode())
lines.append(b"committer " + committer.encode())
payload = b"\n".join(lines) + b"\n\n" + message.encode()
return write_object(gitdir, "commit", payload)
The demo builds a second commit on top of the first, changing one file, and points the branch at it with git update-ref — the safe writer for refs, which the Git References chapter recommends over editing ref files by hand:
$ git update-ref refs/heads/main 47812fff6c6a21abf4c3d97a31ea0cf91a6fcf22
$ git log --oneline main
47812ff Second commit
aa48c41 First commit
$ git cat-file -p 47812fff6c6a21abf4c3d97a31ea0cf91a6fcf22
tree 852ad0580a609cc3bdde83dc1fd199a8f5352810
parent aa48c412402dea514485db4069f093c4e455c062
author Mini Git <mini@example.invalid> 1793872800 +0000
committer Mini Git <mini@example.invalid> 1793872800 +0000
Second commit
That is a real Git history, built without ever running git commit. git log reads it because the objects are byte-identical to what Git itself would have written.
Refs, HEAD, and the reflog
A branch is a simple pointer to the head commit: a file under .git/refs/heads/ holding one SHA-1. HEAD is normally a symbolic reference to the branch you are on, not a hash itself:
$ cat .git/HEAD
ref: refs/heads/main
$ git symbolic-ref HEAD
refs/heads/main
The reflog records every move of a ref, but with a catch that matters for a program driving refs through plumbing: git update-ref writes no message, so the reflog entries it creates are blank. Only a real git commit writes a reflog message:
$ git reflog show main
47812ff main@{0}:
Do not show reflog messages from plumbing-driven updates in your own tooling — there are none to show. If your program needs readable reflog entries, pass a message to update-ref with -m, which this program does not do.
Tags: the fourth object type, and the peel trap
A tag object is very much like a commit object — it contains a tagger, a date, a message, and a pointer — except that it generally points to a commit rather than a tree. A lightweight tag is only a ref; an annotated tag is a tag object plus a ref. The program writes the tag object by hand:
def write_tag(gitdir, target, target_type, tagname, tagger, message):
payload = (
f"object {target}\ntype {target_type}\ntag {tagname}\n"
f"tagger {tagger}\n\n{message}"
).encode()
return write_object(gitdir, "tag", payload)
$ git update-ref refs/tags/v0.1 e817fa5760a98bd5cf5b8c849dd409d8e4ac822b
$ git cat-file -t v0.1
tag
$ git cat-file -p v0.1
object 47812fff6c6a21abf4c3d97a31ea0cf91a6fcf22
type commit
tag v0.1
tagger Mini Git <mini@example.invalid> 1793872800 +0000
Test tag
The trap is in how you read the tag back. git rev-parse v0.1 returns the tag object itself; git rev-parse v0.1^{} peels it to the commit the tag points to. They are different hashes, and confusing them is a real bug this guide's own probe caught before publication:
$ git rev-parse v0.1
e817fa5760a98bd5cf5b8c849dd409d8e4ac822b
$ git rev-parse 'v0.1^{}'
47812fff6c6a21abf4c3d97a31ea0cf91a6fcf22
Use the bare rev-parse when you mean the tag object, and ^{} only when you mean the commit it points to. A tag object need not point at a commit at all — it can point at any object — but this guide's tags point at commits.
The index file: what your program does not write
The program builds trees directly from entries, so it never writes .git/index itself — Git writes the index when the parity check runs git update-index. But a mini-git that grows past this guide will need the format, so here is what the file looks like after that parity check. The index format starts with a 12-byte header: the DIRC signature, a version number, and an entry count, followed by one sorted entry per file carrying stat data, mode, object id, and path:
$ python3 -c "
data = open('.git/index', 'rb').read(12)
print('index magic:', data[:4])
print('index version:', int.from_bytes(data[4:8], 'big'))
print('index entries:', int.from_bytes(data[8:12], 'big'))"
index magic: b'DIRC'
index version: 2
index entries: 3
$ git ls-files --stage
100644 4fcf3f7958ee3302e1c6b9c75e7d0cc4a7f09c53 0 hello.txt
100644 f772568f65f0d35e93e878fa1a9462224899e59c 0 lib/util.py
100755 85ba14df52f8c72688537de6e7555fb402217b1e 0 run.sh
Version 2 is what this Git writes. Versions 3 and 4 exist, and extensions follow the entries, so treat the index as a format to parse from the specification, not as a fixed-size struct.
Packfiles: Git's compression, and your hand-written pack
Loose objects are one file each. A packfile consolidates many objects into a single binary file plus an index that holds offsets for seeking. Git packs when you run git gc, when there are too many loose objects, or when you push. The Packfiles chapter demonstrates the saving with two near-identical versions of one file: the newest version is stored intact and the older one as a delta, because the version most likely needed gets the fast access.
Reproduce it with two 400-line versions of one file, 9,210 bytes and then 9,220 bytes after appending one line:
$ git gc
$ git count-objects -v
count: 0
size: 0
in-pack: 18
packs: 2
size-pack: 5
$ git verify-pack -v .git/objects/pack/pack-3b42ed560c9aa934a5e33b9654b8c4b6f3bf6fac.idx
df451e95a2a02fb38e1a344576d8d4ea7908cc1f blob 9210 918 1278
89d5fd673a43754718f8131d76ec0b404d4c76f3 blob 7 18 2196 1 df451e95a2a02fb38e1a344576d8d4ea7908cc1f
non delta: 15 objects
chain length = 1: 1 object
.git/objects/pack/pack-3b42ed560c9aa934a5e33b9654b8c4b6f3bf6fac.pack: ok
The second version of the file, df451e95…, is stored whole at 918 bytes for 9,210 bytes of content. The first version, 89d5fd67…, takes 7 bytes as a delta against it, chain length 1. Exactly the book's claim, measured on this Git.
One correction to the book, verified here: it says objects no commit points to are dangling and are not packed. On Git 2.43.0 that is no longer quite true. A blob written by the program and referenced by nothing, plus the deliberately unsorted tree, both survived git gc — but not as loose files. They landed in a second pack, the one carrying a .mtimes file, which is a cruft pack:
$ git verify-pack -v .git/objects/pack/pack-e22e95195a6d7e97659d2e4e0c09db66a9f06d12.idx
0f302e510e5e02a8f5317832680c607acb7cdc64 blob 31 41 12
44a34d1c46c50044b29ff0e56970614f63c70511 tree 106 114 53
$ git cat-file -p 0f302e510e5e02a8f5317832680c607acb7cdc64
never referenced by any commit
The objects still exist and are still readable — they are just packed as cruft instead of left loose. If your tooling assumes dangling objects are loose files, it will miss them on a current Git.
Writing a pack by hand
The pack format is: PACK magic, a version number (Git generates version 2), an object count, all in network byte order, then one entry per object, then a SHA-1 trailer over everything before it. Each undeltified entry is a varint type-and-length header — 3-bit type, low 4 bits of the length first, then 7 bits per continuation byte — followed by zlib data. Packed objects carry no <type> <size>\0 prefix; the object id is still computed with it. The object types are commit 1, tree 2, blob 3, tag 4, OFS_DELTA 6, REF_DELTA 7; type 5 is reserved and type 0 is invalid.
import struct
PACK_TYPES = {"commit": 1, "tree": 2, "blob": 3, "tag": 4}
def encode_pack_header(objtype, size):
"""Varint type+length: 3-bit type, low 4 bits of size first,
then 7 bits per continuation byte, MSB set except on the last."""
type_no = PACK_TYPES[objtype]
first = (type_no << 4) | (size & 0x0F)
size >>= 4
out = bytearray([first | (0x80 if size else 0)])
while size:
byte = size & 0x7F
size >>= 7
if size:
byte |= 0x80
out.append(byte)
return bytes(out)
def write_pack(objtype, payload):
"""One undeltified object in a v2 pack."""
entry = encode_pack_header(objtype, len(payload)) + zlib.compress(payload)
pack = b"PACK" + struct.pack(">II", 2, 1) + entry
pack += hashlib.sha1(pack).digest()
return pack
Feed that pack to real Git and ask it to index it:
$ python3 -c "
import minigit, subprocess
content = b'hand-packed blob\n'
pack = minigit.write_pack('blob', content)
print('pack bytes:', len(pack))
print('pack magic:', pack[:4], 'version:', int.from_bytes(pack[4:8], 'big'), 'count:', int.from_bytes(pack[8:12], 'big'))
oid = minigit.object_id('blob', content)[0]
print('expected blob id:', oid)
p = subprocess.run(['git', 'index-pack', '--stdin', '--fix-thin'], input=pack, capture_output=True)
print('index-pack rc:', p.returncode)
q = subprocess.run(['git', 'cat-file', '-p', oid], capture_output=True)
print('cat-file rc:', q.returncode, 'content:', q.stdout)"
pack bytes: 59
pack magic: b'PACK' version: 2 count: 1
expected blob id: b53a7c14553334d7a2644353a3ea5a9a7ed7c248
index-pack rc: 0
cat-file rc: 0 content: b'hand-packed blob\n'
A 59-byte hand-written pack, accepted by Git, and the blob reads back. That is the hand-rolled extension working.
Deltas: read, not hand-encoded
A deltified entry stores a base object reference — a pack-relative negative offset for OFS_DELTA, a full object name for REF_DELTA — followed by zlib-compressed delta data. The delta data starts with the base size and the target size in the size encoding, then a sequence of instructions: copy a byte range from the base, or insert new data embedded in the instruction. This guide does not hand-encode a delta. The format was read from the specification, and delta behavior is demonstrated above through Git's own packer and verify-pack output. If you extend the program to write deltas, encode one first and pass git index-pack on it before claiming it works — do not assert a hand-encoded delta you have not executed.
The complete program
#!/usr/bin/env python3
"""minigit.py -- a minimal Git object writer in stdlib Python.
Writes loose objects, trees, commits, annotated tags, and a
one-object pack that real Git (2.43.0, SHA-1) accepts. It never
reads a packfile and never encodes a delta: those are the guide's
stated limits, not missing features.
Usage (run inside a repo created with `git init -b main`):
python3 minigit.py <gitdir> demo
The demo builds a two-commit history from scratch and prints every
hash it creates, so the reader can cross-check each one with
`git cat-file` / `git log`.
"""
import hashlib
import os
import struct
import sys
import zlib
PACK_TYPES = {"commit": 1, "tree": 2, "blob": 3, "tag": 4}
def object_id(objtype, payload):
"""The hash recipe: SHA-1 of `<type> <size>\0` + payload."""
header = objtype.encode() + b" " + str(len(payload)).encode() + b"\x00"
return hashlib.sha1(header + payload).hexdigest(), header + payload
def write_object(gitdir, objtype, payload):
"""Write a loose object; return its id. Existing objects are kept."""
oid, store = object_id(objtype, payload)
path = os.path.join(gitdir, "objects", oid[:2], oid[2:])
if not os.path.exists(path):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f:
f.write(zlib.compress(store))
return oid
def write_blob(gitdir, data):
return write_object(gitdir, "blob", data)
def build_tree(gitdir, entries):
"""entries: list of (mode, name, oid). Git sorts entries by name,
treating a directory name as if it ended in '/'."""
def sort_key(entry):
mode, name, _oid = entry
return name + ("/" if mode == "040000" else "")
body = b""
for mode, name, oid in sorted(entries, key=sort_key):
# Mode is ASCII octal WITHOUT leading zeros: a subtree is
# "40000", not "040000". Writing "040000" still hashes, but
# git fsck flags it (zeroPaddedFilemode) and the tree hash
# will not match git write-tree for the same fixture.
raw_mode = mode.lstrip("0").encode() or b"0"
body += raw_mode + b" " + name.encode() + b"\x00"
body += bytes.fromhex(oid)
return write_object(gitdir, "tree", body)
def write_commit(gitdir, tree, parents, author, committer, message):
lines = [b"tree " + tree.encode()]
for parent in parents:
lines.append(b"parent " + parent.encode())
lines.append(b"author " + author.encode())
lines.append(b"committer " + committer.encode())
payload = b"\n".join(lines) + b"\n\n" + message.encode()
return write_object(gitdir, "commit", payload)
def write_tag(gitdir, target, target_type, tagname, tagger, message):
payload = (
f"object {target}\ntype {target_type}\ntag {tagname}\n"
f"tagger {tagger}\n\n{message}"
).encode()
return write_object(gitdir, "tag", payload)
def encode_pack_header(objtype, size):
"""Varint type+length: 3-bit type, low 4 bits of size first,
then 7 bits per continuation byte, MSB set except on the last."""
type_no = PACK_TYPES[objtype]
first = (type_no << 4) | (size & 0x0F)
size >>= 4
out = bytearray([first | (0x80 if size else 0)])
while size:
byte = size & 0x7F
size >>= 7
if size:
byte |= 0x80
out.append(byte)
return bytes(out)
def write_pack(objtype, payload):
"""One undeltified object in a v2 pack. Packed objects carry NO
`<type> <size>\0` prefix; the id is still computed with it."""
entry = encode_pack_header(objtype, len(payload)) + zlib.compress(payload)
pack = b"PACK" + struct.pack(">II", 2, 1) + entry
pack += hashlib.sha1(pack).digest()
return pack
IDENT = "Mini Git <mini@example.invalid> 1793872800 +0000"
def demo(gitdir):
blob1 = write_blob(gitdir, b"hello mini-git\n")
print("blob hello.txt:", blob1)
blob2 = write_blob(gitdir, b"print('hello from lib')\n")
print("blob lib/util.py:", blob2)
blob3 = write_blob(gitdir, b"#!/bin/sh\necho run\n")
print("blob run.sh:", blob3)
subtree = build_tree(gitdir, [("100644", "util.py", blob2)])
print("tree lib/:", subtree)
tree1 = build_tree(gitdir, [
("100644", "hello.txt", blob1),
("040000", "lib", subtree),
("100755", "run.sh", blob3),
])
print("tree root:", tree1)
commit1 = write_commit(gitdir, tree1, [], IDENT, IDENT, "First commit\n")
print("commit 1:", commit1)
blob1b = write_blob(gitdir, b"hello mini-git v2\n")
print("blob hello.txt v2:", blob1b)
tree2 = build_tree(gitdir, [
("100644", "hello.txt", blob1b),
("040000", "lib", subtree),
("100755", "run.sh", blob3),
])
print("tree root v2:", tree2)
commit2 = write_commit(
gitdir, tree2, [commit1], IDENT, IDENT, "Second commit\n")
print("commit 2:", commit2)
tag = write_tag(gitdir, commit2, "commit", "v0.1", IDENT, "Test tag\n")
print("tag v0.1 object:", tag)
return {
"blob1": blob1, "tree1": tree1, "commit1": commit1,
"commit2": commit2, "tag": tag,
}
if __name__ == "__main__":
if len(sys.argv) != 3 or sys.argv[2] != "demo":
print("usage: minigit.py <gitdir> demo", file=sys.stderr)
sys.exit(2)
demo(sys.argv[1])
What breaks, and the fix
| Error | Cause | Fix |
| git write-tree hash differs from your program's tree hash | Tree mode written zero-padded (040000) instead of 40000 | Strip leading zeros from the mode when encoding the entry |
| error in tree …: treeNotSorted: not properly sorted | Tree entries written in insertion order, not Git's sort order | Sort by name, treating directory names as if they ended in / |
| git rev-parse v0.1 returns a hash git log does not recognise as a commit | That hash is the tag object, not the commit | Use v0.1^{} when you mean the commit the tag points to |
| Reflog entries with no message after plumbing-driven ref moves | git update-ref writes no message | Pass -m to update-ref, or accept blank entries |
| git cat-file cannot find an object your program wrote | Wrong hash recipe, usually the NUL byte or the size missing | Hash <type> <size>\0 + payload, and compare against git hash-object first |
Frequently asked questions
Can this replace Git?
No. It writes objects Git accepts, which is a different job from being a version control system. It has no working-tree checkout, no merge, no network protocol, and no delta pack reader. What it gives you is the storage model, verified, so the rest of Git stops looking like magic.
Why Python and not C or Rust?
Because the standard library already has hashlib and zlib, so the program is one file with no dependencies. The point of the guide is the format, not the language. Port it to your language of choice and keep the same acceptance tests: same fixture, same hash.
Why SHA-1, when Git also supports SHA-256?
Because Git 2.43.0, the version tested here, defaults to SHA-1, and every hash in this guide is a 40-character SHA-1. SHA-256 repositories use 64-character ids and a different object format flag. The recipe is the same shape — header, hash, store — but the hashes in this guide will not match a SHA-256 repository, so do not mix the two.
Where do I go next?
Three natural extensions, in order of difficulty: write your own .git/index bytes from the index format and check them with git ls-files --stage; read a packfile back, starting with undeltified objects and git verify-pack -v as the oracle; then encode one delta by hand and pass git index-pack on it. Each one has the same acceptance test as this guide: Git must agree with your program, byte for byte.
Sources
- Pro Git, Git Objects (ch. 10.2) — content-addressable store, hash-object, loose-object layout, tree construction, commit format, the hash recipe and zlib storage.
- Pro Git, Git References (ch. 10.3) — refs as files under
.git/refs,update-refas the safe writer, HEAD as a symbolic reference, lightweight vs annotated tags, remote refs as read-only bookmarks. - Pro Git, Packfiles (ch. 10.4) — loose vs packed objects,
git gc,verify-packoutput, newest version stored intact with the older as a delta, dangling objects. - Git, gitformat-pack — pack header, varint type-and-length encoding, object type numbers, delta data and instruction formats, idx v2 layout with CRC32 table,
.revfiles. - Git, gitformat-loose — loose object prefix
<type> <size>\0, zlib compression, object id as the hash of the uncompressed data, the 2/38 fan-out layout. - Git, gitformat-index —
DIRCheader, versions 2, 3 and 4, index entry fields, entry sorting, extensions including the cache tree.
Facts verified 2026-11-05. Every command and code listing on this page was executed with Git 2.43.0 and Python 3.12.3 on Ubuntu 24.04. Failures are quoted verbatim from the actual tool output. The dangling-object note corrects the Pro Git text for this Git version: unreachable objects landed in a cruft pack, not as loose files.
Keep Exploring
Keep exploring the docs, samples, and free tools to continue your evaluation.
Seven tips and seven puzzles so far, each one a short runnable example with the fix explained.
Paste a [Service] section and get a plain-language verdict on every sandboxing setting it finds.
Paste sshd -G output and get a hardening verdict on the values your server actually runs.