Simple VCS
A lightweight version control system built from scratch in Python — published on PyPI with CLI and Python API interfaces.
Solo. I own everything in the repo: the storage format (.svcs/ with a content-addressed
objects/ directory plus three JSON files), the SimpleVCS class in
[core.py](simple_vcs/core.py) that implements all ten operations, the Click CLI in
[cli.py](simple_vcs/cli.py) that wraps them, the packaging (setup.py + pyproject.toml,
console script svcs), and the GitHub Actions workflows. There is no second contributor —
git log shows one author under two email addresses (a university address for the 2025
commits, a personal one afterwards).
// the problem
What was broken
Git is the correct answer for source code and the wrong answer for a person who just wants
their file history back. The commands that matter to a beginner — "save this", "show me what
I saved", "put it back the way it was" — are buried under staging semantics, detached HEADs,
and a reflog you have to know exists before it can save you. People coping without it fall
back to report_final_v2_actually_final.docx and a folder of dated ZIPs, which works right
up until they need to know which copy was current on which day.
The second problem was mine: I wanted to actually understand how a version control system stores things, and reading about content-addressed objects is not the same as writing one and finding out where it leaks.
// constraints
What made it hard
It had to be installable by someone who only has pip, so no database, no daemon, no
compiled dependency — the entire store is plain files and JSON that a human can open in a
text editor when something goes wrong. It had to run identically on Windows, macOS and Linux
(the root [workflow.yml](workflow.yml) matrix covers all three), and Windows console encoding
turned out to be the single most persistent source of breakage. pyproject.toml declares
requires-python = ">=3.7", which rules out newer typing and pathlib conveniences. And it is
a side project — the gaps between the three work bursts are five months and one month, so
every design had to survive me forgetting all of it and coming back cold.
// architecture
How it fits together
Two layers, deliberately thin. [cli.py](simple_vcs/cli.py) is Click and nothing else: each
command constructs a SimpleVCS and calls one method. All state lives in
[core.py](simple_vcs/core.py). The split exists so the same class is usable as a Python API —
from simple_vcs import SimpleVCS — and the root workflow tests that path separately from the
CLI path, asserting on the boolean return of every method. That is why every public method
returns bool instead of raising: the return value *is* the API contract, and the Rich output
is a side effect on top of it.
The store is content-addressed but only halfway. _calculate_file_hash reads in 4 KB chunks
and produces a SHA-256; _store_object writes the bytes to .svcs/objects/<hash> and skips
the write if that hash already exists, so re-committing an unchanged file costs nothing. But
commits themselves are not objects — they are appended to a single commits.json array with a
monotonic integer id, and HEAD is a text file containing that integer. I chose integers
over commit hashes because svcs revert 3 is something a person can type from memory and
svcs revert a5f62c6 is not. The cost is real and I knew it going in: a JSON array means the
whole history is rewritten on every commit, and an integer counter derived from
len(commits) + 1 means there is no safe way to grow branches. Diffing inherits the same
tradeoff — show_diff compares the *file-hash sets* of two commits, so it reports added,
deleted and modified plus a byte-size delta, but never line content. Storing whole blobs
instead of deltas made that a design consequence, not an oversight.
svcs CLI (Click)
Parses argv, one command per SimpleVCS method, RichGroup overrides help rendering
SimpleVCS (core.py)
Validates repo exists, resolves paths, enforces the file-inside-repo rule
_calculate_file_hash
SHA-256 over 4KB chunks, gives the content address
.svcs/objects/<sha256>
Raw file bytes written once per unique hash, skipped if already present
.svcs/staging.json
Path to {hash, size, mtime} for what is staged but not committed
.svcs/commits.json
Append-only array; each commit snapshots the whole staging map plus a parent id
.svcs/HEAD
Plain-text integer commit id, "0" means no commits yet
Rich console
Panels and tables rendered from the same data, forced terminal mode for Windows
// challenges
Where I got stuck
The CLI entry point was dead for eight months
Commit e62ec27 changed if __name__ == '__main__': main() to main — dropping the call parentheses. Running the module did nothing at all and exited zero, which is the worst possible failure because nothing complains. It survived June 2025 to January 2026 and was only fixed in 144c16b ("fixed CLI entry point"). The reason it hid so long is that CI never invoked that path — the workflows call python -m simple_vcs.cli --help, which goes through Click's console-script entry, not through __main__.
add accepted files outside the repository
The first version did Path(file_path) without resolving, then called .relative_to(self.repo_path) on it later. A relative path from a different working directory would either resolve wrong or throw a raw ValueError at the user. The fix in 6a7cd84 was to .resolve() immediately and wrap relative_to in a try/except that turns the containment check into an explicit error message. It is now the only place in the codebase where an exception is used as a boundary test on purpose.
Windows console encoding
SimpleVCS.__init__ constructs Console(force_terminal=True, legacy_windows=False) and stores self.is_windows, and the comment above it says "Force UTF-8 output and disable emoji on Windows". Every emoji in the v1.2 output was replaced with ASCII markers — the current commit marker in show_log is ->, not an arrow glyph, and the diff table uses + Added / - Deleted / M Modified. The CHANGELOG for v1.2 still documents asterisks and colored circles that the shipped code no longer emits; the docs lost that fight and I did not go back to fix them.
flake8 choked on a UTF-16 file I had committed
run_svcs.py was written by a PowerShell redirect, so it is UTF-16 with a BOM — every character is followed by a null byte. flake8 reports that as a source error, and because CI runs flake8 . --select=E9,F63,F7,F82 as a hard gate, the build failed on a one-line convenience script. The last commit (3ec145c) added a [.flake8](.flake8) with an exclude list covering build, dist, *.egg-info, test_project and .github, and deleted the checked-in simple_vcs.egg-info/. That fixed the build. It did not fix run_svcs.py, which is still UTF-16 in the tree.
compress does not compress anything
compress_objects gzips each object over 1 KB to a .gz file, deletes the original, then immediately decompresses the .gz back to the original filename and deletes the .gz — "Decompress back to original name for compatibility". The net effect on disk is zero bytes saved, and the panel that reports "Space saved" is always reporting 0. I wrote the round-trip because nothing in the read path knows how to open a gzipped object, so leaving them compressed would have broken revert. The honest fix is a format flag in the object header and a decompressing read path; that work is not done, and the command should not have shipped in this state.
// new ground
What I hadn't used before
_store_object is what made it concrete: the if not obj_path.exists() guard is the entire deduplication story, and identity-by-content is what makes it safe. What I understand now that I did not before is why Git separates blobs from commits — I collapsed them into one JSON array and immediately lost the ability to branch.print() to Rich panels, tables and trees — the diff touched 403 lines of core.py in one commit. The lesson was not the API; it was that Rich pushed presentation logic into core.py, where it does not belong. Every method now formats its own byte sizes inline, duplicating format_file_size in [utils.py](simple_vcs/utils.py), which is now dead code.RichGroup overrides format_help to render my own help screen, then sets ctx.resilient_parsing = True to stop Click printing its version underneath. That flag is not what it is documented for — I am using a parsing hint as an output suppressor because Click gives no cleaner hook. It works and I am not proud of it.python -m build, validates with twine check, publishes to TestPyPI on tags and to real PyPI only on a published release. Learning that the TestPyPI dry run is the step that catches broken metadata — before it is permanently on the real index — was worth the setup.// takeaways
What I took away
- A CLI bug that makes the program exit successfully while doing nothing is far more dangerous than one that crashes, and CI that only tests the packaged entry point will never see it.
- Integer commit IDs bought real usability at the cost of branching; that tradeoff was correct for this project's audience and would be wrong for almost any other version control system.
- Committing build artifacts —
dist/,build/,*.egg-info/— cost me a broken lint gate and three separate cleanup commits before I added a.gitignoreat all. - Presentation code that reaches into the core spreads: once
core.pyowned the Rich console, ten methods each grew their own byte-size formatter and the shared utility died. - A feature whose output claims a result it does not produce is worse than a missing feature, because the user has no reason to doubt the number on the screen.
// the journey
How it actually went
I started this because I wanted to know what Git actually does when you type git add, and
the fastest way to find out is to try to build the smallest thing that behaves the same. The
first day produced most of what still exists — init, add, commit, log, diff, status — under
plain print(), and it worked. Then it sat for five months.
What I got wrong early is visible in the git history and it is a little embarrassing. Six of
my first seven commits are named "changes" or "complete", dist/ and __pycache__/ were
committed for months, setup.py pointed at github.com/yourusername/simple-vcs for four
days, and __init__.py credited "Your Name". Those are the marks of writing a package by
filling in a template without reading it. The single worst one is the dropped parentheses in
e62ec27 — one character, in a commit called "changes", that made python run_svcs.py a
no-op for eight months and that I did not find until I came back in January 2026 to add
features.
The moment it clicked was _store_object. Three lines: hash the content, write it under the
hash, skip if it is already there. Deduplication, integrity and immutability all fall out of
one idea, and I stopped thinking of Git's object store as machinery and started thinking of it
as a consequence. Everything I like about this codebase is downstream of that afternoon.
The v1.2 rewrite was the other kind of learning. I spent a full session converting every
print() to a Rich panel and it genuinely made the tool feel finished — but I put all of it in
core.py, so the module that owns the storage format now also owns the border style of the
commit table. Two versions later that decision has metastasized: utils.format_file_size is
dead because ten methods each inline their own copy.
What I would do differently: write the tests. There is not a single test file in this
repository, and the CI job that says "Test with pytest" runs bare pytest against zero tests
— which pytest treats as an error, so that job has been failing rather than passing vacuously.
The real testing lives in [workflow.yml](workflow.yml), which drives the CLI through a full
init/add/commit/log/diff sequence in a temp directory and asserts on the Python API's return
values. That file is a decent integration suite and it is sitting in the repo root instead of
.github/workflows/, so GitHub has never run it. Moving it and writing real unit tests around
_calculate_file_hash, quick_revert and the containment check in add_file is the first
thing I would do on picking this back up — closely followed by either fixing compress or
removing it.
// outcome
What it changed
Shipped as simple-vcs version 1.3.0 (Feb 2026), built into
dist/simple_vcs-1.3.0-py3-none-any.whl and .tar.gz, installing a svcs console script.
Ten commands: init, add, commit, status, log, diff, revert, snapshot,
restore, compress. Both a CLI and an importable Python API, verified separately by the
workflow in [workflow.yml](workflow.yml) across Python 3.7–3.12 on Ubuntu, Windows and macOS.
Version numbers are currently inconsistent across three files and that is a shipping defect,
not a rounding error: pyproject.toml and setup.py say 1.3.0, click.version_option in
[cli.py](simple_vcs/cli.py) says 1.3.0, the help footer four lines below it still prints
Version 1.2.0, and simple_vcs/__init__.__version__ is still 1.1.0.
yml](workflow.yml) only fires on a GitHub release event, but there are no tags in this repo.
github/workflows/python-package.yml` pytest step is currently red on main; the repo has no test files, so it should be, but the run history is not in the tree.
// overview
In more detail
Simple VCS is a fully functional version control system inspired by Git, built entirely from scratch in Python. It lets developers track file changes, commit history, and restore previous states without Git overhead.
Published on PyPI as simple-vcs (v1.3.0), it supports the core VCS workflow including init, add, commit, log, diff, and status commands. It features quick revert to any previous commit by ID, zip-based full project backups, and a rich terminal UI using the rich library. Cross-platform compatible with Windows and Unix.
// features
What it does
- Core VCS workflow — init, add, commit, log, diff, status
- Quick revert — instantly restore any previous commit by ID
- Snapshot & restore — zip-based full project backups
- Object compression — automatic storage optimization
- Dual interface — CLI (svcs command) + importable Python API
- Rich terminal UI — styled tables, panels, and colored output
- Cross-platform — Windows & Unix compatible
// stack