Code a mini Git-style version control system
Open in the interactive app: practice, quiz, mock interview →In one minute
- A version control system is two data structures glued together: an immutable, content-addressed object store (file contents, directory snapshots, commits) and a tiny mutable table of names (branches and HEAD) that point into it. Almost every design decision follows from keeping those two apart.
- Content addressing means an object's id is the hash of its bytes. Identical files are stored once, history cannot be rewritten without changing every later id, and two copies of the store can be compared by id alone.
- A commit is a snapshot plus parent pointers, not a diff. Diffs are computed on demand by comparing two snapshots, which keeps commits simple and lets the diff algorithm change without touching stored history.
- Concurrency is easy for objects (writing the same immutable object twice is harmless) and hard for names. A branch update must be a compare-and-swap: "move
mainfrom X to Y only if it still points at X". - The shape is trust but verify plus multi-step consistency: ids that prove integrity, and a pointer flip that must happen only after everything it points to is safely stored. The same structure powers container image layers, Nix store paths, IPFS and the Merkle trees in Design Google Drive-style sync.
Requirements
Functional:
- A working tree of files that the user edits freely.
commit(message)records a snapshot of the working tree with a parent link; refuse empty commits.- Branches: create a branch at the current commit, switch to it, and see
HEADfollow it as new commits are made. checkoutreplaces the working tree with a branch's snapshot, refusing if there are uncommitted changes that would be lost.logwalks history from HEAD;difflists added, modified and deleted paths between two commits.- Identical content is stored once, however many commits or paths contain it.
Non-functional:
- Commit ids are deterministic: the same content, parent and message always produce the same id.
- Stored history is immutable; corruption is detectable by rehashing.
- Operations are proportional to the size of what changed or what is listed, not to the age of the repository.
Out of scope for the interview: merging (covered in Git, Testing and Code Review Basics, which implements merge base and three-way merge), remotes and network sync, a staging area with partial commits, and line-level diff algorithms. Name each and place it in Extensibility.
Entities and relationships
- Blob holds one file's content. Its id is the hash of that content with a type prefix. It has no name; names live in trees.
- Tree is a snapshot of a directory: a sorted list of entries
(name, mode, child id), where a child is a blob or another tree. Its id is the hash of its serialised entries, so it changes if and only if something beneath it changes. - Commit holds a root tree id, zero or more parent commit ids, an author, a timestamp and a message. It is the unit of history.
- ObjectStore maps id to object and supports
put(idempotent) andget. It never updates or deletes in normal operation. - Ref is a name (
main,feature/login) that points at a commit id. HEAD is a special ref that usually points at another ref ("I am onmain"), and only in the detached state points straight at a commit. - RefStore holds refs and offers
compareAndSet(name, expected, new). - WorkingTree is the user's mutable files. Repository is the facade that ties them together.
Relationships: commits reference trees, trees reference blobs and subtrees, commits reference parents. Nothing in the object store references a ref; refs only point inward. That one-way arrow is why branches are cheap (a 41-byte file in Git) and why deleting a branch never deletes history until garbage collection proves it unreachable.
Class diagram
classDiagram
class Repository {
-objects: ObjectStore
-refs: RefStore
-work: WorkingTree
-head: Head
+commit(message) CommitId
+branch(name) void
+checkout(name) void
+log() Commit[]
+diff(a, b) Change[]
}
class GitObject {
<<interface>>
+id() ObjectId
+serialize() bytes
}
class Blob {
+content: bytes
}
class Tree {
+entries: TreeEntry[]
}
class Commit {
+tree: ObjectId
+parents: ObjectId[]
+message: string
}
class ObjectStore {
<<interface>>
+put(obj) ObjectId
+get(id) GitObject
}
class RefStore {
<<interface>>
+get(name) ObjectId
+compareAndSet(name, old, new) bool
}
class Head {
+branch: string
+detached: ObjectId
}
GitObject <|.. Blob
GitObject <|.. Tree
GitObject <|.. Commit
Repository --> ObjectStore
Repository --> RefStore
Repository --> Head
ObjectStore "1" o-- "many" GitObject
Tree --> Blob
Tree --> Tree
Commit --> Tree
Commit --> Commit : parentsKey flows
Commit writes objects bottom-up (blobs, then trees, then the commit) and only then moves the branch. If the process dies before the last step, the new objects are unreachable garbage and the repository is still consistent.
sequenceDiagram
participant U as User
participant R as Repository
participant W as WorkingTree
participant O as ObjectStore
participant F as RefStore
U->>R: commit("fix login")
R->>W: snapshot files
R->>O: put(blob) for each file
O-->>R: blob ids (existing ones reused)
R->>O: put(tree)
O-->>R: tree id
R->>R: tree id == parent tree? refuse
R->>O: put(commit tree, parent, msg)
O-->>R: commit id C2
R->>F: compareAndSet(main, C1, C2)
F-->>R: ok
R-->>U: C2Design patterns used and why
- Composite for trees: a tree contains blobs and trees, and operations such as "list every path" or "count bytes" recurse uniformly. It is the same structure as Design In Memory File System, frozen.
- Immutable value objects for blobs, trees and commits. Immutability is what makes content addressing sound (an id must never describe two different byte strings), what lets readers skip locks, and what makes caching trivially correct.
- Repository and Facade:
ObjectStoreandRefStoreare interfaces with in-memory, loose-file and packed implementations behind them.Repositoryis the facade the commands use. - Command for user operations (
commit,checkout,branch), each a class with validation and execution. Hooks (pre-commit, post-checkout) wrap them. - Strategy for diff and merge algorithms. Git ships several line diff algorithms (Myers, patience, histogram) selectable per call; the stored data does not change. Merge strategies are pluggable the same way.
- Memento, in spirit: each commit is a memento of the working tree that the repository can restore on checkout. Unlike a classic Memento, it is shared and deduplicated.
Concurrency and edge cases
- Two writers move the same branch. Process A and process B both read
main = C1, build commits C2 and C3, and writemain. Without a check, one commit vanishes from the branch. The fix is compare-and-swap on the ref: B's update from C1 fails becausemainis now C2, and B must rebase or merge. Git implements this with a lock file (refs/heads/main.lock), a check of the old value, and an atomicrenameover the real ref file. - Concurrent object writes. Two processes writing the same blob is harmless: both produce the same bytes under the same id. Writers create a temporary file and rename it into place, so a reader never sees half an object.
- Crash mid-commit. Because the ref moves last, a crash leaves only unreferenced objects.
git gcandgit fsckfind and remove them later. Reverse the order and a crash leaves a branch pointing at a missing commit. - Empty commit. If the new root tree id equals the parent's, nothing changed; refuse unless the user explicitly allows it.
- Checkout with local changes. Overwriting edited files loses work. Compare the working tree against HEAD's tree first and refuse, or carry the changes over when they do not touch files that differ between the two branches (Git's real behaviour).
- First commit and empty branches. A fresh repository has a HEAD that names
mainbutmainpoints at nothing.logreturns nothing,branchhas nothing to point at, and the first commit has no parent. - Hash collisions. Accidental collisions are not a practical concern at 160 bits, but deliberate SHA-1 collisions are (SHAttered, 2017). Git now uses a collision-detecting SHA-1 and supports SHA-256 repositories.
Down the stack
Layer 1The contract
commit either returns a new id whose tree exactly matches the working tree and whose parent is the previous HEAD commit, with the current branch moved to it, or fails and moves nothing. The same tree, parent and message always yield the same id. A ref update succeeds only if the ref still holds the value the writer last read. checkout either replaces the working tree completely and moves HEAD, or refuses and changes nothing. diff(a, b) depends only on the two snapshots, never on the path between them.
Layer 2The mechanism
Git serialises every object as <type> <size>\0<payload> and names it by the hash of that byte string. A tree payload is a sequence of entries <mode> <name>\0<20 raw hash bytes>, sorted by name. A commit payload is text: tree <id>, one parent <id> line per parent, author, committer, a blank line and the message. Loose objects are zlib-compressed and stored at .git/objects/ab/cdef..., the first two hex digits forming 256 subdirectories so no single directory grows huge. Refs are small text files under .git/refs/heads/, many of them consolidated into packed-refs, and HEAD usually contains ref: refs/heads/main. Because trees nest per directory, a commit that changes one file deep in a large repository writes one new blob plus one new tree per directory level on its path, and reuses every other subtree id unchanged. Diffing two commits walks both trees in parallel and skips any subtree whose ids are equal, which is why git diff between nearby commits is fast in a repository with a million files.
Layer 3The machine
Git's collision-detecting SHA-1 runs at several hundred MB/s per core, so hashing a 10 KB source file costs tens of microseconds. zlib compresses source code to roughly a quarter to a third of its size. A loose object costs one inode and at least one 4 KB filesystem block regardless of size, which is why Git periodically packs them: a packfile stores objects back to back, with similar blobs stored as deltas against each other, and a companion .idx file holds sorted ids plus a 256-entry fanout table so a lookup is one table read and a binary search. Durability is a matter of fsync: Git's core.fsync setting controls whether object files and refs are flushed to disk before the ref rename, trading 0.1 to several milliseconds per commit for crash safety.
Why it's built this way
- Developers need to trust that history has not been altered, so objects are named by the hash of their content and any change produces a new name.
- Hash-named objects are immutable, so they can be shared freely: identical files and unchanged subtrees are stored once and copied between repositories by id.
- Immutable objects cannot express "the latest version", so a separate, tiny mutable layer of refs is needed, and all contention concentrates there.
- Contention on a single small name is best handled by compare-and-swap, so refs update with a lock file and an atomic rename.
- Storing snapshots rather than diffs makes every commit independently readable, and compression plus delta packing recovers the space a diff-based design would have saved.
Extensibility
- Merging: a commit with two parents. Find the merge base with a graph walk, run a three-way merge per file through a merge Strategy, and write conflicts into the working tree. The object model does not change at all.
- Staging area: an index between the working tree and the next commit, holding
(path, blob id, stat info). Commit builds trees from the index instead of the working tree, enabling partial commits and fast status checks. - Remotes: fetch is "send me every object reachable from your refs that I lack", computed by exchanging ids. Content addressing makes this safe and resumable. Push is a remote compare-and-swap on a ref.
Leadership lens
- Build first: the object store with deterministic serialisation and a test that the same snapshot always produces the same id. Any nondeterminism there (map iteration order, timestamps sneaking into trees) breaks deduplication and comparison forever.
- Ask in review: what is immutable and what is not; in what order are objects and refs written; how is a concurrent ref update detected; what happens on crash between the two; how is the hash function versioned.
- Plan the hash migration on day one. Git's move from SHA-1 to SHA-256 has taken years because ids are embedded in tools, URLs and commit messages. Put the algorithm in the object format or repository config.
Check yourself
Why does a commit store a snapshot (a root tree) instead of a diff against its parent?
Show answer
Each commit can then be read without replaying history, diffs can be computed between any two commits with any algorithm, and unchanged files cost nothing because their blob ids are reused.
Why must the branch ref be updated last when creating a commit?
Show answer
So that a crash leaves only unreachable objects, which garbage collection can remove. Updating the ref first would leave it pointing at a commit, tree or blob that may not exist.
Two people commit on top of the same
mainat the same time. Which part of the system detects the conflict, and how?Show answer
The ref store, through compare-and-swap: the second writer's update expects
mainto be the old commit, finds the first writer's commit instead, and fails, so the second writer must merge or rebase.How can comparing two commits skip most of a huge repository?
Show answer
Trees are hashed per directory, so equal subtree ids mean identical contents. The diff walks both trees together and descends only where ids differ.
Watch
- Tech Talk: Linus Torvalds on git (Google): the original design reasoning, including why content addressing and distributed history matter.
- Git Internals: Demystifying Git Objects (CodeLucky): blobs, trees, commits and tags shown on a real repository.
- Git INTERNALS part 2, the beauty of TREES (Luke Nobel): how tree objects make unchanged directories free to reuse.
Free resources
- Git Internals: Git Objects (Pro Git): the exact format of blobs, trees and commits, with commands to inspect them by hand.
- Git Internals: Git References (Pro Git): how branches, HEAD and tags are stored as small files.
- Inside .git (Julia Evans): a friendly tour of what each file in the repository directory does.
- git-gc documentation: how unreachable objects are pruned and loose objects are packed.
- Merkle tree (Wikipedia): the general structure behind hashing trees of content, used well beyond Git.
- Fowler-Noll-Vo hash function (Wikipedia): the simple hash used in the practice, and why it is fine for a toy but not for integrity.