Tutorial

I Built a Mini Git to Finally Understand How Version Control Works

I built a working Git in about 400 lines of Python, and real Git can read its repositories. Here is how blobs, trees, commits and refs really work.

·
15 min read
·
AI-assisted

AI tooling disclosure

Generative AI materially contributed to this article.

Human review confirmed by the author.

I Built a Mini Git to Finally Understand How Version Control Works

I had used Git for years by memorizing commands: commit, branch, checkout, and a quiet prayer whenever something went wrong. So I built a small version myself. minigit.py is a single file of about 400 lines of plain Python with no dependencies. It can init, add, commit, log, status, branch and checkout, and it stores everything in Git’s real on-disk format, so real Git can read the repositories it creates. This post walks through how it works, shows real output from it, and includes the complete code at the end. In the examples, mg is shorthand for python3 minigit.py; the setup is in the “Try it” section.

The big idea: Git is a key-value store

Underneath the commands, Git is a content-addressable filesystem: a key-value store where the key is the hash of the value. Everything else is built from a few simple pieces:

  • Blobs hold the contents of a file.

  • Trees hold a directory listing: names, modes, and the hashes of blobs and of other trees.

  • Commits point to one tree (a complete snapshot of the project) and to their parent commit, and carry an author and a message.

  • Refs are tiny files that give a name, like main, to a commit hash.

  • The index (the staging area) is the list of files that will go into the next commit.

A commit is not a diff. It points to a full snapshot, and Git avoids wasting space because identical content always has the same hash and is stored once. Diffs are computed on demand when you ask for them.

Step 1: The object store

Every object is stored the same way: a header with the type and size, a zero byte, then the content. Git hashes that with SHA-1, compresses it with zlib, and saves it under .git/objects, using the first two hex characters of the hash as a folder name. Here is my version, trimmed to the essentials:

def write_object(kind, data):
    store = kind.encode() + b" " + str(len(data)).encode() + b"\0" + data
    sha = hashlib.sha1(store).hexdigest()
    path = OBJECTS / sha[:2] / sha[2:]
    if not path.exists():  # same content -> same name -> stored only once
        path.parent.mkdir(parents=True, exist_ok=True)
        tmp = path.with_suffix(".tmp")
        tmp.write_bytes(zlib.compress(store))
        os.replace(tmp, path)  # atomic rename: never leaves a half-written object
    return sha

You can check the hash by hand. The file hello.txt contains the five letters of “hello” plus a newline, six bytes in all, so its blob header is blob 6:

import hashlib
data = b"hello\n"
print(hashlib.sha1(b"blob " + str(len(data)).encode() + b"\0" + data).hexdigest())
# ce013625030ba8dba906f756967f9e9ca394464a

Real Git gives the same answer: echo hello | git hash-object --stdin prints ce013625030ba8dba906f756967f9e9ca394464a. Decompressing the stored object shows exactly what was hashed:

>>> import zlib, pathlib
>>> zlib.decompress(pathlib.Path(".minigit/objects/ce/013625030ba8dba906f756967f9e9ca394464a").read_bytes())
b'blob 6\x00hello\n'

Because the name is the hash of the content, the same content always lands in the same place. When I added a second file with identical contents, no second object appeared; the index simply pointed at the one that was already there.

Step 2: Trees and commits

A tree is a list of entries sorted by name. Each entry is a file mode, a space, a name, a zero byte, and then the hash of the blob or subtree as 20 raw bytes. A directory is simply an entry that points to another tree. For a project with a hello.txt file and a src folder, cat-file prints:

$ mg cat-file 99555379c2fa0b5bd001e9240f3280d1b424e29b
100644 blob ce013625030ba8dba906f756967f9e9ca394464a	hello.txt
040000 tree ae6262de7e0cb6fb82ec1b206679365d55c44710	src
$ mg cat-file ae6262de7e0cb6fb82ec1b206679365d55c44710
100644 blob b80e3222ab264bd7cafb376749bd18814fd66776	main.py
040000 tree 4e6ce18496765b15fa5fd5d9ce54284bcfa814c5	utils

The code that writes a tree is short:

def write_tree(node):
    entries = []
    for name, value in node.items():
        if isinstance(value, dict):
            entries.append((name + "/", b"40000", name, write_tree(value)))
        else:
            entries.append((name, b"100644", name, value))
    # Git sorts entries bytewise, comparing directories as if their name ended in "/"
    entries.sort(key=lambda e: e[0].encode())
    body = b"".join(
        mode + b" " + name.encode() + b"\0" + bytes.fromhex(sha)
        for _, mode, name, sha in entries
    )
    return write_object("tree", body)

The sort line deserves attention. Git compares entry names as bytes, but it treats a directory as if its name ended in a slash. So in a folder containing a-b/, a.b, a/ and a0, the correct order is a-b, a.b, a, a0: the directory a sorts after a.b because / comes after . in ASCII. Sort by plain name and you get a different tree hash from real Git for the same files. I tested exactly that layout, along with a filename containing a space, a random binary file and a path three folders deep, and real Git’s git write-tree produced the identical tree hash.

A commit is a small text object. It names one tree, its parent commit if there is one, who made it and when, and the message:

def write_commit(tree, parent, message):
    lines = [f"tree {tree}"]
    if parent:
        lines.append(f"parent {parent}")
    who = identity()
    lines += [f"author {who}", f"committer {who}", "", message]
    return write_object("commit", ("\n".join(lines) + "\n").encode())

Here is a real one from my test session:

$ mg cat-file HEAD
tree b2ebf159fb11cd612e1c3e62636be14ce60424ad
parent ac81e445b2fa7e8ad7d6cb0c82ebb40d37e511c1
author Mini Git User <[email protected]> 1792911662 +0000
committer Mini Git User <[email protected]> 1792911662 +0000

say hello to the world

Because the parent’s hash is part of the text being hashed, history forms a chain. Hashing that exact text gives 84aecefd64e215f47906061afeaa4e1d54f0d081, the commit’s name. Change a single character of the parent hash and the same commit hashes to 71e59f5d44b5b906a602e17fd3b96f21a847a744. That is why amending or rebasing creates new commits instead of editing old ones: change anything, and every hash after it changes too.

I also rebuilt my first commit with real Git’s git commit-tree, feeding it the same tree, author and timestamp. The two commit hashes were identical, so my commit format matches Git’s byte for byte.

Step 3: Refs, branches and HEAD

A branch is a file that contains a commit hash. .minigit/refs/heads/main is a 41-byte file: 40 hex characters and a newline. HEAD is another file that says which branch you are on:

$ cat .minigit/HEAD
ref: refs/heads/main
$ cat .minigit/refs/heads/main
84aecefd64e215f47906061afeaa4e1d54f0d081

Committing writes the new commit’s hash into the branch file. Creating a branch writes the current hash into a new file. That is why branches are so cheap, and why branch needs only a few lines to create one. Checking out a raw commit hash instead of a branch name puts the hash straight into HEAD in place of the ref: line, and that is all a “detached HEAD” is.

Step 4: The index

The staging area is a list of every file that will be in the next commit, with the hash of its content. add stores the file as a blob and records its hash; commit turns that list into trees. Mine is a plain text file with one hash path pair per line:

$ cat .minigit/index.txt
ce013625030ba8dba906f756967f9e9ca394464a hello.txt
b80e3222ab264bd7cafb376749bd18814fd66776 src/main.py
7d4290a117a4ddcc11daae7ea675841033830c8f src/utils/helpers.py

Real Git uses a binary file called index. I learned the difference the hard way: my first version also named its text file index, and real Git’s fsck stopped with “index file corrupt” because it tried to parse my text as its binary format. Renaming mine to index.txt fixed it.

Step 5: The commands

  • commit builds the trees from the index, writes a commit object that points at the root tree and the current commit, and moves the current branch file forward. It refuses to commit when nothing has changed.

  • log starts at HEAD and follows parent pointers back to the first commit.

  • status compares three things: the last commit, the index, and the files on disk. That three-way comparison is why Git talks about changes that are “staged” and changes that are “not staged.”

  • checkout reads the target commit’s tree, deletes tracked files that are not in it, writes the files that are, rebuilds the index, and moves HEAD.

checkout is the dangerous one, because it overwrites your files. So before it touches anything, mine refuses if it would destroy work that no commit contains: staged changes, edits that are not staged, or an untracked file sitting where a file from the target commit needs to go. I tested all three cases, and each time it stops and lists the files at risk.

Try it

Save the complete code from the end of this post as minigit.py. It needs Python 3.9 or newer (I ran it on Python 3.12, on Linux). In a Linux or macOS shell, from the folder that contains the file, set up the shorthand and create an empty project folder:

alias mg="python3 $PWD/minigit.py"
mkdir demo && cd demo

Commits record an author. Set your own with environment variables, for example export MINIGIT_NAME="Ada Lovelace" and export MINIGIT_EMAIL="[email protected]"; otherwise a generic default is used. Here is a real session:

$ mg init
Initialized empty minigit repository in .minigit/
$ echo "hello" > hello.txt
$ mg add hello.txt
$ mg commit -m "add hello"
[main ac81e44] add hello
$ echo "world" >> hello.txt
$ mg status
On branch main

Changes not staged for commit:
    modified:   hello.txt
$ mg add hello.txt
$ mg commit -m "say hello to the world"
[main 84aecef] say hello to the world
$ mg log
commit 84aecefd64e215f47906061afeaa4e1d54f0d081
Author: Mini Git User <[email protected]>
Date:   Sun Oct 25 07:01:02 2026 +0000

    say hello to the world

commit ac81e445b2fa7e8ad7d6cb0c82ebb40d37e511c1
Author: Mini Git User <[email protected]>
Date:   Sun Oct 25 07:01:02 2026 +0000

    add hello

$ mg branch experiment
$ mg checkout experiment
Switched to branch 'experiment'
$ echo "something risky" >> hello.txt
$ mg add hello.txt
$ mg commit -m "try something risky"
[experiment 4ead1cc] try something risky
$ mg checkout main
Switched to branch 'main'
$ cat hello.txt
hello
world
$ mg branch
  experiment
* main

Hashes and dates in your own session will differ, because commit hashes include the time and the author. Blob and tree hashes will match mine whenever the file contents match.

Looking inside the repository

After that session the repository holds nine objects: three commits, three trees (one per commit) and three blobs (one per version of hello.txt). Each is a file named after its hash:

$ find .minigit/objects -type f | sort
.minigit/objects/4e/ad1cc0a4d605366339238d6dcccc83a2e42c82
.minigit/objects/58/b9ac5a3c54a47a5c7425164e37c22d13588671
.minigit/objects/84/aecefd64e215f47906061afeaa4e1d54f0d081
.minigit/objects/86/caec1c87153c706021a1ca1e566f43dd155e6a
.minigit/objects/94/954abda49de8615a048f8d2e64b5de848e27a1
.minigit/objects/aa/a96ced2d9a1c8e72c56b253a0e2fe78393feb7
.minigit/objects/ac/81e445b2fa7e8ad7d6cb0c82ebb40d37e511c1
.minigit/objects/b2/ebf159fb11cd612e1c3e62636be14ce60424ad
.minigit/objects/ce/013625030ba8dba906f756967f9e9ca394464a

Proof that it is real Git

Setting GIT_DIR tells real Git to use .minigit instead of .git. After that, ordinary Git commands work on a repository that real Git never created:

$ export GIT_DIR=.minigit
$ git log --oneline --all
4ead1cc try something risky
84aecef say hello to the world
ac81e44 add hello
$ git fsck --full && echo "fsck: no problems found"
fsck: no problems found

Beyond that session, I checked three things against Git 2.43.0. A blob’s hash equals the output of git hash-object. A commit rebuilt with git commit-tree has the identical hash. A tree with awkward names and nesting has the identical hash from git write-tree. I also ran 19 edge-case checks covering binary files, filenames with spaces, deleted files, the checkout safety refusals and invalid branch names, and all of them passed. After a run of commits, branch switches and checkouts, git log and minigit.py log listed the same commit hashes in the same order. To repeat the check on your own repository, GIT_DIR=.minigit git fsck --full and GIT_DIR=.minigit git log --oneline --all are all you need.

What it can’t do

This is a learning tool, not a replacement for Git. It has no merge, no diff, no remotes (so no clone, push or pull), no tags and no .gitignore. Every object is stored loose, while real Git also packs history into compressed packfiles. Every file is stored as a regular file, so the executable bit and symlinks are not preserved. It must be run from the repository root, and it does not handle filenames that contain a newline. For real projects, use the real thing.

What building it taught me

  • Git stores snapshots, not diffs. Every commit points at a complete tree, and shared content is stored only once because of hashing.

  • Branches and HEAD are tiny text files. The history itself is a pile of compressed objects named by their hashes.

  • A branch is a sticky note. Moving or creating one is a single small write, which is why branching costs almost nothing.

  • Hashes chain history together. Changing an old commit changes every commit after it, which is why rewriting history produces new commits.

  • Detached HEAD is not scary. It only means HEAD holds a hash instead of a branch name.

  • checkout is where Git protects your work. The check that stops it from overwriting uncommitted changes is the most important part of the command.

If you use Git every day, I recommend building even a smaller version of this. After an afternoon with objects, trees and refs, the commands stop being magic.

The complete code

Here is the whole script, exactly as I tested it. Save it as minigit.py and follow the steps in “Try it” above.

#!/usr/bin/env python3
"""
minigit.py - a tiny Git in one file (Python 3.9+, standard library only).

Objects are stored in Git's real on-disk format, so real Git can read them:
    GIT_DIR=.minigit git log
    GIT_DIR=.minigit git fsck

Commands: init, add, commit, log, status, branch, checkout, cat-file
Run every command from the repository root:  python minigit.py <command> ...
"""
import argparse
import hashlib
import os
import sys
import time
import zlib
from pathlib import Path

GIT = Path(".minigit")
OBJECTS = GIT / "objects"


def die(msg):
    sys.exit(f"minigit: {msg}")


# ------------------------------------------------------------------
# 1. The object store: a key-value store where key = hash of the value
# ------------------------------------------------------------------
def write_object(kind, data):
    """Store data as '<kind> <size>\\0<data>', zlib-compressed, named by its SHA-1."""
    store = kind.encode() + b" " + str(len(data)).encode() + b"\0" + data
    sha = hashlib.sha1(store).hexdigest()
    path = OBJECTS / sha[:2] / sha[2:]
    if not path.exists():  # same content -> same name -> stored only once
        path.parent.mkdir(parents=True, exist_ok=True)
        tmp = path.with_suffix(".tmp")
        tmp.write_bytes(zlib.compress(store))
        os.replace(tmp, path)  # atomic rename: never leaves a half-written object
    return sha


def read_object(sha):
    path = OBJECTS / sha[:2] / sha[2:]
    if not path.exists():
        die(f"object {sha} not found")
    header, _, body = zlib.decompress(path.read_bytes()).partition(b"\0")
    return header.split(b" ")[0].decode(), body


def hash_blob(data):
    """The sha a file's content would get as a blob, without writing anything."""
    return hashlib.sha1(b"blob " + str(len(data)).encode() + b"\0" + data).hexdigest()


# ------------------------------------------------------------------
# 2. Trees (directories) and commits
# ------------------------------------------------------------------
def write_tree(node):
    """node = {name: blob sha, or a nested dict for a subdirectory} -> tree sha."""
    entries = []
    for name, value in node.items():
        if isinstance(value, dict):
            entries.append((name + "/", b"40000", name, write_tree(value)))
        else:
            entries.append((name, b"100644", name, value))
    # Git sorts entries bytewise, comparing directories as if their name ended in "/"
    entries.sort(key=lambda e: e[0].encode())
    body = b"".join(
        mode + b" " + name.encode() + b"\0" + bytes.fromhex(sha)
        for _, mode, name, sha in entries
    )
    return write_object("tree", body)


def parse_tree(body):
    """Raw tree bytes -> list of (mode, name, sha)."""
    entries, i = [], 0
    while i < len(body):
        space = body.index(b" ", i)
        nul = body.index(b"\0", space)
        entries.append((body[i:space].decode(), body[space + 1 : nul].decode(), body[nul + 1 : nul + 21].hex()))
        i = nul + 21
    return entries


def read_tree(sha, prefix=""):
    """Tree sha -> flat {path: blob sha} for every file below it."""
    files = {}
    for mode, name, entry_sha in parse_tree(read_object(sha)[1]):
        if mode == "40000":
            files.update(read_tree(entry_sha, prefix + name + "/"))
        else:
            files[prefix + name] = entry_sha
    return files


def tree_from_index(index):
    root = {}
    for path, sha in index.items():
        *folders, filename = path.split("/")
        node = root
        for folder in folders:
            node = node.setdefault(folder, {})
        node[filename] = sha
    return write_tree(root)


def identity():
    name = os.environ.get("MINIGIT_NAME", "Mini Git User")
    email = os.environ.get("MINIGIT_EMAIL", "[email protected]")
    offset = time.localtime().tm_gmtoff
    tz = f"{'+' if offset >= 0 else '-'}{abs(offset) // 3600:02d}{abs(offset) % 3600 // 60:02d}"
    return f"{name} <{email}> {int(time.time())} {tz}"


def write_commit(tree, parent, message):
    lines = [f"tree {tree}"]
    if parent:
        lines.append(f"parent {parent}")
    who = identity()
    lines += [f"author {who}", f"committer {who}", "", message]
    return write_object("commit", ("\n".join(lines) + "\n").encode())


def read_commit(sha):
    kind, body = read_object(sha)
    if kind != "commit":
        die(f"{sha[:7]} is a {kind}, not a commit")
    header, _, message = body.decode().partition("\n\n")
    info = {"parent": None}
    for line in header.split("\n"):
        key, _, value = line.partition(" ")
        info[key] = value
    info["message"] = message.rstrip("\n")
    return info


def format_date(who):
    _, stamp, tz = who.rsplit(" ", 2)
    offset = (1 if tz[0] == "+" else -1) * (int(tz[1:3]) * 3600 + int(tz[3:5]) * 60)
    return time.strftime("%a %b %d %H:%M:%S %Y", time.gmtime(int(stamp) + offset)) + " " + tz


# ------------------------------------------------------------------
# 3. Refs: a branch is just a file holding a commit sha; HEAD points at one
# ------------------------------------------------------------------
def head_ref():
    """'refs/heads/main' if HEAD is on a branch, None if HEAD is detached."""
    text = (GIT / "HEAD").read_text().strip()
    return text[5:] if text.startswith("ref: ") else None


def head_commit():
    ref = head_ref()
    if ref is None:
        return (GIT / "HEAD").read_text().strip()
    path = GIT / ref
    return path.read_text().strip() if path.exists() else None


def resolve(name):
    """Branch name, 'HEAD', or a (short) commit sha -> full sha, or None."""
    if name == "HEAD":
        return head_commit()
    branch = GIT / "refs" / "heads" / name
    if branch.is_file():
        return branch.read_text().strip()
    if len(name) >= 4 and all(c in "0123456789abcdef" for c in name):
        folder = OBJECTS / name[:2]
        found = [name[:2] + f.name for f in folder.iterdir() if f.name.startswith(name[2:])] if folder.is_dir() else []
        if len(found) > 1:
            die(f"'{name}' is ambiguous")
        return found[0] if found else None
    return None


# ------------------------------------------------------------------
# 4. The index (staging area) and the working directory.
#    Ours is a plain text file of "<sha> <path>" lines. Git's real index is a
#    binary file named "index", so we use "index.txt" to avoid confusing it.
# ------------------------------------------------------------------
def read_index():
    path = GIT / "index.txt"
    if not path.exists():
        return {}
    return dict(line.split(" ", 1)[::-1] for line in path.read_text().splitlines() if line)


def write_index(index):
    (GIT / "index.txt").write_text("".join(f"{sha} {path}\n" for path, sha in sorted(index.items())))


def working_files():
    found = []
    for root, dirs, names in os.walk("."):
        dirs[:] = [d for d in dirs if d not in (".minigit", ".git")]
        found += [os.path.relpath(os.path.join(root, n)).replace(os.sep, "/") for n in names]
    return sorted(found)


def is_modified(path, sha):
    return not os.path.isfile(path) or hash_blob(Path(path).read_bytes()) != sha


# ------------------------------------------------------------------
# 5. Commands
# ------------------------------------------------------------------
def cmd_init(_):
    if GIT.exists():
        die("repository already exists")
    (GIT / "objects").mkdir(parents=True)
    (GIT / "refs" / "heads").mkdir(parents=True)
    (GIT / "HEAD").write_text("ref: refs/heads/main\n")
    print("Initialized empty minigit repository in .minigit/")


def cmd_add(args):
    index = read_index()
    for arg in args.paths:
        rel = os.path.relpath(arg).replace(os.sep, "/")
        if rel == ".." or rel.startswith("../") or rel.split("/")[0] in (".minigit", ".git"):
            die(f"cannot add '{arg}'")
        if os.path.isdir(rel):
            inside = lambda p: rel == "." or p.startswith(rel + "/")
            targets = [p for p in working_files() if inside(p)]
            gone = [p for p in index if inside(p) and not os.path.exists(p)]
        elif os.path.isfile(rel):
            targets, gone = [rel], []
        elif rel in index:  # file was deleted: staging that removes it
            targets, gone = [], [rel]
        else:
            die(f"pathspec '{arg}' did not match any files")
        for path in gone:
            del index[path]
        for path in targets:
            index[path] = write_object("blob", Path(path).read_bytes())
    write_index(index)


def cmd_commit(args):
    if not args.message.strip():
        die("empty commit message")
    index = read_index()
    parent = head_commit()
    if not index and not parent:
        die("nothing staged: use `add` first")
    tree = tree_from_index(index)
    if parent and tree == read_commit(parent)["tree"]:
        die("nothing to commit: no changes staged since the last commit")
    sha = write_commit(tree, parent, args.message)
    ref = head_ref()
    (GIT / ref if ref else GIT / "HEAD").write_text(sha + "\n")
    print(f"[{ref.removeprefix('refs/heads/') if ref else 'detached HEAD'} {sha[:7]}] {args.message.splitlines()[0]}")


def cmd_log(_):
    sha = head_commit()
    if not sha:
        die("no commits yet")
    while sha:
        commit = read_commit(sha)
        print(f"commit {sha}")
        print(f"Author: {commit['author'].rsplit(' ', 2)[0]}")
        print(f"Date:   {format_date(commit['author'])}\n")
        print("\n".join("    " + line for line in commit["message"].splitlines()) + "\n")
        sha = commit["parent"]


def cmd_status(_):
    index = read_index()
    head = head_commit()
    committed = read_tree(read_commit(head)["tree"]) if head else {}
    ref = head_ref()
    print(f"On branch {ref.removeprefix('refs/heads/')}" if ref else f"HEAD detached at {head[:7]}")

    staged = []
    for path in sorted(set(index) | set(committed)):
        if path not in committed:
            staged.append(f"{'new file:':<12}{path}")
        elif path not in index:
            staged.append(f"{'deleted:':<12}{path}")
        elif index[path] != committed[path]:
            staged.append(f"{'modified:':<12}{path}")
    unstaged = []
    for path in sorted(index):
        if is_modified(path, index[path]):
            label = "modified:" if os.path.isfile(path) else "deleted:"
            unstaged.append(f"{label:<12}{path}")
    untracked = [p for p in working_files() if p not in index]

    for title, items in (
        ("Changes to be committed", staged),
        ("Changes not staged for commit", unstaged),
        ("Untracked files", untracked),
    ):
        if items:
            print(f"\n{title}:")
            for item in items:
                print("   ", item)
    if not (staged or unstaged or untracked):
        print("\nnothing to commit, working tree clean")


def cmd_branch(args):
    heads = GIT / "refs" / "heads"
    if args.name is None:
        for path in sorted(heads.rglob("*")):
            if path.is_file():
                name = path.relative_to(heads).as_posix()
                print(("* " if head_ref() == f"refs/heads/{name}" else "  ") + name)
        return
    if ".." in args.name or " " in args.name or args.name.startswith(("/", "-")):
        die("invalid branch name")
    sha = head_commit()
    if not sha:
        die("cannot create a branch before the first commit")
    path = heads / args.name
    if path.exists():
        die(f"branch '{args.name}' already exists")
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(sha + "\n")


def cmd_checkout(args):
    sha = resolve(args.target)
    if sha is None:
        die(f"unknown branch or commit '{args.target}'")
    target = read_tree(read_commit(sha)["tree"])
    index = read_index()
    head = head_commit()
    committed = read_tree(read_commit(head)["tree"]) if head else {}

    # Refuse to continue if checkout could destroy work that no commit contains.
    unsafe = {p for p in set(index) | set(committed) if index.get(p) != committed.get(p)}  # staged
    unsafe |= {p for p in index if is_modified(p, index[p])}  # edited or deleted, not staged
    unsafe |= {p for p in target if p not in index and os.path.exists(p)}  # untracked file in the way
    if unsafe:
        die("checkout would lose changes to: " + ", ".join(sorted(unsafe)) + "\ncommit or remove them first")

    for path in index:  # remove files that don't exist in the target commit
        if path not in target:
            os.remove(path)
            folder = os.path.dirname(path)
            while folder:  # and any directories left empty
                try:
                    os.rmdir(folder)
                except OSError:
                    break
                folder = os.path.dirname(folder)
    for path, blob in target.items():  # write files from the target commit
        if index.get(path) != blob:
            os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
            Path(path).write_bytes(read_object(blob)[1])
    write_index(target)

    if (GIT / "refs" / "heads" / args.target).is_file():
        (GIT / "HEAD").write_text(f"ref: refs/heads/{args.target}\n")
        print(f"Switched to branch '{args.target}'")
    else:
        (GIT / "HEAD").write_text(sha + "\n")
        print(f"HEAD is now at {sha[:7]} (detached)")


def cmd_cat_file(args):
    sha = resolve(args.object)
    if sha is None:
        die(f"unknown object '{args.object}'")
    kind, body = read_object(sha)
    if kind == "tree":
        for mode, name, entry in parse_tree(body):
            print(f"{mode.zfill(6)} {'tree' if mode == '40000' else 'blob'} {entry}\t{name}")
    else:
        sys.stdout.write(body.decode(errors="replace"))


def main():
    parser = argparse.ArgumentParser(prog="minigit", description="A tiny Git.")
    sub = parser.add_subparsers(dest="command", required=True)
    sub.add_parser("init").set_defaults(fn=cmd_init)
    p = sub.add_parser("add")
    p.add_argument("paths", nargs="+")
    p.set_defaults(fn=cmd_add)
    p = sub.add_parser("commit")
    p.add_argument("-m", "--message", required=True)
    p.set_defaults(fn=cmd_commit)
    sub.add_parser("log").set_defaults(fn=cmd_log)
    sub.add_parser("status").set_defaults(fn=cmd_status)
    p = sub.add_parser("branch")
    p.add_argument("name", nargs="?")
    p.set_defaults(fn=cmd_branch)
    p = sub.add_parser("checkout")
    p.add_argument("target")
    p.set_defaults(fn=cmd_checkout)
    p = sub.add_parser("cat-file")
    p.add_argument("object")
    p.set_defaults(fn=cmd_cat_file)
    args = parser.parse_args()
    if args.command != "init" and not GIT.is_dir():
        die("not a minigit repository (run `init` first, and run from the repo root)")
    args.fn(args)


if __name__ == "__main__":
    main()

Credits and sources

  • Git internals: the structure of blobs, trees, commits and refs follows Git’s own design as described in the Pro Git book by Scott Chacon and Ben Straub, chapter 10: Git Internals – Git Objects.

  • Prior art: Mary Rose Cook’s Gitlet, a Git implementation in JavaScript written to learn how Git works, and her article Introducing Gitlet. The script in this post is a much smaller take on the same idea, in Python.

  • Tested with: Git 2.43.0 and Python 3.12 on Linux.

Comments(0)

Join the discussion by logging into your account.

No comments yet. Be the first to comment!

Sanju Singh

Passionate developer sharing knowledge about modern web technologies and best practices.

Subscribe to Sanju Singh's Newsletter

Direct email dispatches when new stories are published. Zero algorithms.