- Rust 100%
| .github | ||
| docs | ||
| src | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| Jit.pdf | ||
| PROPOSAL.md | ||
| README.md | ||
| REPORT.md | ||
Jit — A Version Control System Inspired by Git
Authors: Adam Alberty, David Blanco, Maksim Babkou
1. Introduction
Jit is a minimal, cross-platform version control system implemented in Rust, inspired by Git. The project was undertaken to understand how VCSes work at a low technical level — specifically, how content-addressable storage, snapshotting, and branching are implemented — while also gaining hands-on experience building CLI tools in Rust.
Like Git, Jit stores all data in a hidden .jit directory inside the repository root. Every object (file content, directory structure, or commit metadata) is identified by the SHA-256 hash of its contents. This approach guarantees deduplication: identical files are stored exactly once regardless of how many commits reference them.
The project grew well beyond the original "local-only" scope defined in the proposal. We successfully implemented a full remote workflow (clone, push, pull, fetch) backed by a custom TCP daemon, three-way merging with conflict markers, rebase, cherry-pick, and several plumbing commands useful for inspecting repository internals.
2. Requirements
The following commands were planned in the proposal and fully implemented:
Basic workflow
init— initialize a new repositoryadd— stage files or directories for the next commitremove— unstage filescommit— snapshot the current index statestatus— show changes since the last commithistory— display the commit log (analogous togit log)
Branching and navigation
branch— create, delete, list, or rename branchesswitch— switch to a branch or detach HEAD at a specific commit
Merging and history rewriting
merge— three-way merge with automatic conflict detection and conflict markersdiff— show line-level differences between commits or working-tree staterebase— replay commits on top of another branchcherry-pick— apply a single commit onto the current branch
Remote operations (bonus, beyond original proposal)
daemon— start a TCP server to accept incoming connectionsremote— manage tracked remote repositoriesfetch— download objects and refs without mergingpull— fetch + fast-forward or mergepush— send local objects and branch pointers to a remoteclone— clone a remote repository into a new directory
Plumbing / inspection
hash— compute the object hash for a file without storing itshow— display the raw contents of an object by its hashlist-index— inspect the staging arealist-tree— inspect a tree object
Repository structure
A .jit/ directory contains:
objects/— compressed, content-addressed blobs, trees, and commits (zstd, SHA-256 layout)refs/heads/— one file per branch, each containing a commit hashHEAD— either a symbolic ref (ref: refs/heads/main) or a detached commit hashindex.json— the staging area (maps file paths to blob hashes, sizes, and mtimes)config.toml— author name, email, default branch, and remote definitions
3. Architecture
The codebase is split into four layers: CLI handles argument parsing and user output, App contains all business logic, Domain defines pure data types with no I/O, and Storage is the only layer that reads and writes .jit/ on disk. This separation means commands can be tested without a real CLI, and the storage backend can be swapped without touching business logic.
All data is stored as objects identified by their SHA-256 hash. There are three object types: Blob (raw file bytes), Tree (directory snapshot — a list of blob/tree name hash lines), and Commit (author, timestamp, message, pointer to a root tree, and a list of parent commit hashes). Objects are stored compressed with zstd under .jit/objects/ in a two-level directory layout (ab/cdef...).
When the user runs jit commit, the App layer loads the staging index, builds an in-memory tree, recursively hashes and stores every tree object, then creates and stores the commit object, and finally updates the branch reference in .jit/refs/.
Remote operations are backed by a custom TCP daemon (jit daemon). The client connects over TCP and exchanges length-prefixed binary frames serialized with postcard. During a push, the client walks the local commit graph, checks which objects the remote is missing, sends only those, and finally updates the remote HEAD.
4. Design Choices
Content-addressable storage with SHA-256
Every object is named by the SHA-256 hash of its header + content. The alternative was sequential IDs. SHA-256 was chosen because it makes deduplication automatic, enables integrity verification, and matches Git's mental model. The hash is split into a two-level directory layout — the first two hex characters form the directory name, the remaining 62 form the filename (e.g. ab/cdef1234...) — to avoid filesystem inode limits on large repositories.
Zstd compression instead of zlib Git uses zlib for object compression. We chose zstd because it offers significantly better compression ratios at comparable or faster speeds. The tradeoff is incompatibility with the Git object format, but since Jit has its own format this was not a concern.
JSON for the index, binary (postcard) for the network protocol
The staging area (index.json) is stored as human-readable JSON, which made debugging during development much easier. For the network protocol we used postcard (a compact binary format on top of serde), because minimizing wire size matters more than readability when transferring large numbers of objects over TCP.
Custom TCP daemon instead of HTTP/SSH We implemented a simple length-prefixed TCP protocol rather than layering on top of HTTP or SSH. The benefit is simplicity and full control; the drawback is that it lacks authentication and encryption. For a production tool this would need TLS and access control, but for the scope of this project a raw TCP daemon was the right tradeoff.
Rayon for parallel hashing during add
When staging a large directory, each file must be read and hashed independently, so we parallelized them with Rayon's par_iter. This reduced hashing time by approximately 2×. The index cache (timestamp + file size) further avoids re-hashing files that haven't changed since the last add.
Layered architecture (CLI / App / Domain / Storage) Early in the project the code was mostly flat. As complexity grew, separating concerns into four layers paid off: the App layer can be tested without a real CLI, and the Storage layer can be swapped without touching business logic. A single large module would have made testing and future changes much harder.
Three-way merge using LCS-based diff The merge algorithm finds the common ancestor of two branches, computes the LCS (Longest Common Subsequence) of each branch against the base, and produces a merged file or conflict markers. A simpler two-way diff cannot distinguish "one side deleted a line" from "both sides changed it differently," leading to spurious conflicts. Three-way merge gives correct results in the common cases at the cost of being more complex to implement.
5. Dependencies
| Crate | Purpose |
|---|---|
clap |
CLI argument parsing and help generation. The derive feature lets us define the entire command tree with Rust structs rather than builder calls. |
anyhow |
Ergonomic error handling. Wraps any error type into a single anyhow::Error and allows ? propagation across all layers without defining custom error enums for every module. |
serde + serde_json |
Serialization framework. Used to serialize the index to JSON and commits to JSON. The derive macros auto-generate Serialize/Deserialize for domain types. |
postcard |
Compact binary serialization for the TCP remote protocol. Produces smaller payloads than JSON with zero configuration. |
toml |
Parses and writes config.toml. |
sha2 |
SHA-256 hashing for content-addressable object IDs. |
hex |
Converts raw SHA-256 byte arrays to/from the 64-character hex string representation used on disk. |
zstd |
Compresses objects before writing to disk and decompresses on load. |
chrono |
Timestamp handling in commit objects. Serializes timestamps as Unix seconds. |
rayon |
Data-parallelism for the add command — files are hashed on a thread pool via par_iter. |
colored |
ANSI color output in the terminal (e.g., green for new files, red for deleted in status). |
uuid |
Generates random temporary filenames during atomic object writes to avoid partial writes corrupting the store. |
tempfile |
Creates temporary directories in tests without manual cleanup. |
6. Evaluation
What went well
The layered architecture paid dividends throughout the project. Adding remote functionality — originally listed as a "probably not" stretch goal — was tractable precisely because the App and Storage layers were already clearly separated. Adding new commands meant writing a thin CLI handler and a few App-layer functions; nothing else needed to change.
Rust's type system caught a large class of bugs at compile time. The ObjectId newtype prevented raw String hashes from being passed where ObjectId was expected, and exhaustive pattern matching on the Reference enum (Symbolic vs Direct) forced every call site to handle both HEAD states correctly. These would have been runtime bugs in Python or Go.
Rayon's par_iter was trivially easy to add and produced a measurable speedup without any manual thread management. The borrow checker ensured the parallel closures don't share mutable state, so the parallelism is correct by construction.
The three-way merge with LCS diff works correctly across all tested cases, including non-overlapping edits, one-sided deletions, and genuine conflicts with proper <<<<<<< HEAD / ======= / >>>>>>> markers.
What went not so well
Rebase was non-trivial to implement — it requires finding the common ancestor, collecting commits in topological order, and replaying each one via three-way merge onto the new base. The current implementation is non-interactive: if a conflict is encountered it simply aborts and asks the user to resolve manually. A proper interactive rebase with continue/abort support was out of scope.
The borrow checker also caused friction in a few places — particularly in recursive tree traversal, where mutably borrowing nested entries while iterating required restructuring the code in non-obvious ways.
Implementing a larger project in Rust vs. other languages
Rust's learning curve is steeper than Python or JavaScript, and the borrow checker requires thinking carefully about ownership upfront — something that does not feel natural coming from garbage-collected languages. Early in the project, fighting the borrow checker for tree traversal and index mutation took significant time.
That said, once the ownership model "clicked", development velocity was comparable to Python, with the added benefit that refactors were safe: if it compiled, it almost always ran correctly. The absence of null pointers, exhaustive pattern matching, and zero-cost abstractions (iterators, generics) make Rust a genuinely good fit for systems tools like a VCS. The crate ecosystem (serde, rayon, clap, zstd) is mature and ergonomic.
Overall, implementing a project of this scale in Rust feels rewarding. The initial friction of the type system turns into a long-term advantage: the codebase is reliable, concurrency is correct, and the binary is fast with no garbage collection pauses.


