Basic File Version Control System
Abstract
Section titled “Abstract”Ever wondered what Git actually does? Strip away the branches and remotes and you’re left with a simple idea: hash a file’s contents, store a snapshot, and let the user roll back. This project builds exactly that — a command-line version control system that commits files with messages, skips commits when nothing changed (by comparing hashes), and reverts to the last saved version. Then you’ll grow it from “remembers one version” into a real multi-version history with timestamps, a full log, and diffs — and learn why pickle is a poor storage choice for anything you’ll keep.
You will leave understanding:
- Why content hashing (SHA-256) is the heart of every VCS.
- How “no changes detected” works by comparing the new hash to the stored one.
- The commit → store-snapshot → revert cycle.
- The limits of the starter design (one version, pickle) and how to fix them.
Prerequisites
Section titled “Prerequisites”- Python 3.8 or above (uses the
:=walrus operator). - A text editor or IDE.
- Standard library only —
os,hashlib,pickle,datetime. - Comfort with classes, dictionaries, and file I/O.
Getting Started
Section titled “Getting Started”Create the project
Section titled “Create the project”- Create a folder named
mini-vcs. - Inside it, create
basic_file_version_control_system.py.
Write the code
Section titled “Write the code”basic_file_version_control_system.py
pch.viewSource"""Basic file version control -- commit, diff, log and revert.
The earlier version of this file advertised "revert to previous versions" and
stored exactly one version per file: `self.versions[name] = version_data`
overwrote the previous entry on every commit. `log` printed a single line and
`revert` could only ever restore the most recent state, which is not version
control -- it is a backup with a history-shaped label on it.
The fix is one character of data structure: a list per file instead of a dict
entry. Everything else here follows from that.
python basic_file_version_control_system.py # scripted demo
python basic_file_version_control_system.py --test # unit tests
"""
import difflib
import hashlib
import json
import os
import sys
from datetime import datetime, timezone
DEMO_ANSWERS = iter([])
def ask(prompt="", default=""):
"""Read a line, or take the next scripted answer when nobody is there."""
try:
return input(prompt).strip() or default
except EOFError:
answer = next(DEMO_ANSWERS, default)
print(f"{answer} (scripted demo answer)")
return answer
def make_diff(old: str, new: str, name: str = "file",
old_label: str = "before", new_label: str = "after") -> str:
"""A unified diff between two versions of a text file.
`difflib.unified_diff` is the same format `git diff` prints, and it works
on any pair of line lists. Storing content and computing the diff on
demand is the simpler half of the trade-off; the other half is below.
"""
lines = difflib.unified_diff(
old.splitlines(keepends=True), new.splitlines(keepends=True),
fromfile=f"{name} ({old_label})", tofile=f"{name} ({new_label})")
return "".join(lines)
class BasicFileVersionControl:
"""A repository is a directory plus a JSON index of every commit."""
def __init__(self, repo_path):
self.repo_path = repo_path
self.version_file = os.path.join(repo_path, ".versions.json")
# name -> list of commits, oldest first. The list is the whole fix.
self.versions: dict[str, list] = {}
os.makedirs(repo_path, exist_ok=True)
if os.path.exists(self.version_file):
with open(self.version_file, encoding="utf-8") as handle:
self.versions = json.load(handle)
def _save(self):
# JSON rather than pickle: an index that only Python can read, and
# that executes arbitrary code when loaded, is a poor choice for a
# file format meant to outlive the program that wrote it.
with open(self.version_file, "w", encoding="utf-8") as handle:
json.dump(self.versions, handle, indent=1)
@staticmethod
def hash_content(content: str) -> str:
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def hash_file(self, file_path):
hasher = hashlib.sha256()
with open(file_path, "rb") as handle:
while chunk := handle.read(8192):
hasher.update(chunk)
return hasher.hexdigest()
def commit(self, file_path, message):
"""Record the current contents as a new version."""
if not os.path.exists(file_path):
print("File does not exist.")
return None
with open(file_path, encoding="utf-8") as handle:
content = handle.read()
file_hash = self.hash_content(content)
name = os.path.basename(file_path)
history = self.versions.setdefault(name, [])
if history and history[-1]["hash"] == file_hash:
print("No changes detected.")
return None
version = {
"hash": file_hash,
"timestamp": datetime.now(timezone.utc).isoformat(
timespec="seconds"),
"message": message,
"content": content,
}
history.append(version)
self._save()
print(f"[{name} v{len(history)}] {message} ({file_hash[:8]})")
return len(history)
def revert(self, file_name, version=None):
"""Restore a specific version -- the last one by default.
`version` is 1-based, matching what `log` prints. Reverting to
anything other than the newest commit is the capability the previous
implementation claimed and did not have.
"""
history = self.versions.get(file_name)
if not history:
print("No version history found for this file.")
return None
index = len(history) - 1 if version is None else version - 1
if not 0 <= index < len(history):
print(f"No version {version}; {file_name} has {len(history)}.")
return None
target = history[index]
with open(os.path.join(self.repo_path, file_name), "w",
encoding="utf-8") as handle:
handle.write(target["content"])
print(f"Reverted {file_name} to v{index + 1} ({target['message']})")
return target["content"]
def log(self, file_name):
"""Every commit for a file, oldest first."""
history = self.versions.get(file_name)
if not history:
print("No version history found for this file.")
return []
print(f"History of {file_name}: {len(history)} version(s)")
for number, version in enumerate(history, start=1):
print(f" v{number} {version['timestamp']} "
f"{version['hash'][:8]} {version['message']}")
return history
def diff(self, file_name, first=None, second=None):
"""Diff two committed versions, or the last one against the file."""
history = self.versions.get(file_name)
if not history:
print("No version history found for this file.")
return ""
if first is None:
first = len(history) - 1 if len(history) > 1 else 1
if second is None:
second = len(history)
old, new = history[first - 1], history[second - 1]
text = make_diff(old["content"], new["content"], file_name,
f"v{first}", f"v{second}")
print(text or "(identical)")
return text
def storage_report(self):
"""What storing whole copies costs, measured on this repository.
Real version control stores diffs (or packs objects) precisely
because this number grows with versions x file size rather than with
the size of the changes.
"""
total = sum(len(v["content"]) for h in self.versions.values()
for v in h)
newest = sum(len(h[-1]["content"]) for h in self.versions.values()
if h)
diffs = 0
for history in self.versions.values():
for older, newer in zip(history, history[1:]):
diffs += len(make_diff(older["content"], newer["content"]))
if history:
diffs += len(history[0]["content"])
print(f"\nstored as full copies: {total:,} bytes")
print(f"newest versions only: {newest:,} bytes")
print(f"as first copy + diffs: {diffs:,} bytes "
f"({diffs / total:.0%} of the full-copy cost)")
return total, diffs
def demo(repo_path="./repo"):
"""Three commits, a diff, and a revert to the middle one."""
vcs = BasicFileVersionControl(repo_path)
target = os.path.join(repo_path, "notes.txt")
stages = [
("Shopping list\n- bread\n- milk\n", "first draft"),
("Shopping list\n- bread\n- milk\n- eggs\n", "add eggs"),
("Shopping list\n- sourdough\n- milk\n- eggs\n- coffee\n",
"better bread, and coffee"),
]
for content, message in stages:
with open(target, "w", encoding="utf-8") as handle:
handle.write(content)
vcs.commit(target, message)
# Committing the same content twice must not create a version.
vcs.commit(target, "no change at all")
print()
vcs.log("notes.txt")
print("\ndiff v2 -> v3:")
vcs.diff("notes.txt", 2, 3)
print("reverting to v1, then reading the file back:")
vcs.revert("notes.txt", 1)
with open(target, encoding="utf-8") as handle:
print(" " + handle.read().replace("\n", "\n ").rstrip())
print("\nthe history is untouched -- reverting is not deleting:")
vcs.log("notes.txt")
vcs.storage_report()
return vcs
def main():
repo_path = "./repo"
vcs = BasicFileVersionControl(repo_path)
while True:
print("\nBasic File Version Control System")
print("1. Commit File 2. Revert File 3. View Log")
print("4. Exit 5. Diff versions")
choice = ask("Enter your choice: ", "4")
if choice == "1":
file_path = ask("Enter the file path to commit: ", "demo.txt")
vcs.commit(file_path, ask("Enter commit message: ", "update"))
elif choice == "2":
name = ask("Enter the file name to revert: ", "demo.txt")
raw = ask("Version number (blank for latest): ", "")
vcs.revert(name, int(raw) if raw.isdigit() else None)
elif choice == "3":
vcs.log(ask("Enter the file name to view log: ", "demo.txt"))
elif choice == "5":
vcs.diff(ask("File name: ", "demo.txt"))
elif choice == "4":
break
else:
print("Invalid choice. Please try again.")
if __name__ == "__main__":
if "--test" in sys.argv:
import shutil
import tempfile
import unittest
class TestVersionControl(unittest.TestCase):
def setUp(self):
self.dir = tempfile.mkdtemp(prefix="fvcs-")
self.vcs = BasicFileVersionControl(self.dir)
self.path = os.path.join(self.dir, "f.txt")
def tearDown(self):
shutil.rmtree(self.dir, ignore_errors=True)
def write(self, text):
with open(self.path, "w", encoding="utf-8") as handle:
handle.write(text)
def test_history_accumulates(self):
for text in ("one\n", "two\n", "three\n"):
self.write(text)
self.vcs.commit(self.path, text.strip())
self.assertEqual(len(self.vcs.versions["f.txt"]), 3)
def test_identical_commit_ignored(self):
self.write("same\n")
self.vcs.commit(self.path, "a")
self.vcs.commit(self.path, "b")
self.assertEqual(len(self.vcs.versions["f.txt"]), 1)
def test_revert_to_older_version(self):
self.write("one\n")
self.vcs.commit(self.path, "one")
self.write("two\n")
self.vcs.commit(self.path, "two")
self.assertEqual(self.vcs.revert("f.txt", 1), "one\n")
# ...and the newer version is still there afterwards.
self.assertEqual(len(self.vcs.versions["f.txt"]), 2)
def test_diff_shows_the_change(self):
self.write("a\nb\n")
self.vcs.commit(self.path, "1")
self.write("a\nc\n")
self.vcs.commit(self.path, "2")
text = self.vcs.diff("f.txt", 1, 2)
self.assertIn("-b", text)
self.assertIn("+c", text)
def test_index_survives_reopening(self):
self.write("persisted\n")
self.vcs.commit(self.path, "once")
reopened = BasicFileVersionControl(self.dir)
self.assertEqual(len(reopened.versions["f.txt"]), 1)
unittest.main(argv=sys.argv[:1], exit=False)
elif "--menu" in sys.argv:
main()
else:
# The scripted demo is the default. `sys.stdin.isatty()` looks like
# the right test and is not: under Git Bash it returns True even with
# stdin redirected from /dev/null, so the menu ran unattended and the
# captured transcript was one line long.
demo() Run it
Section titled “Run it”C:\Users\Your Name\mini-vcs> python basic_file_version_control_system.py
# Menu: 1) Commit 2) Revert 3) View Log 4) Exit
# Commit a file, edit it, commit again, then revert to undo your edits.How it fits together
Section titled “How it fits together”Read from the top: this is what runs when you execute the file, and which function calls which. It is generated from the code, so it cannot drift from it.
flowchart TD RUN(["python basic_file_version_control_system.py"]) BasicFileVersionControl["BasicFileVersionControl
class"] main("main") RUN --> main main --> BasicFileVersionControl
What it produces
Section titled “What it produces”Running the file exactly as it ships takes 0.2 s and prints:
[notes.txt v1] first draft (944594ab)
[notes.txt v2] add eggs (f0a7ae86)
[notes.txt v3] better bread, and coffee (6250fcee)
No changes detected.
History of notes.txt: 3 version(s)
v1 2026-08-09T10:58:27+00:00 944594ab first draft
v2 2026-08-09T10:58:27+00:00 f0a7ae86 add eggs
v3 2026-08-09T10:58:27+00:00 6250fcee better bread, and coffee
diff v2 -> v3:
--- notes.txt (v2)
+++ notes.txt (v3)
@@ -1,4 +1,5 @@
Shopping list
-- bread
+- sourdough
- milk
- eggs
+- coffee
...The first 20 of 36 lines are shown; the run continues past this point.
Step-by-Step Explanation
Section titled “Step-by-Step Explanation”1. Hashing a file
Section titled “1. Hashing a file”def hash_file(self, file_path):
hasher = hashlib.sha256()
with open(file_path, "rb") as f:
while chunk := f.read(8192):
hasher.update(chunk)
return hasher.hexdigest()This is the core idea. A SHA-256 hash is a fixed-length fingerprint of the file’s bytes — change a single character and the hash changes completely. Reading in 8 KB chunks (the := walrus loop) means even a huge file never loads fully into memory. Git uses the same trick (with SHA-1/SHA-256) to identify content.
2. Skipping unchanged commits
Section titled “2. Skipping unchanged commits”if file_name in self.versions and self.versions[file_name]["hash"] == file_hash:
print("No changes detected.")
returnBefore saving, compare the new hash to the stored one. Identical hashes mean identical content — so there’s nothing to commit. That’s how Git knows “nothing to commit, working tree clean”.
3. Storing a snapshot
Section titled “3. Storing a snapshot”version_data = {
"hash": file_hash,
"timestamp": timestamp,
"message": message,
"content": open(file_path, "rb").read(),
}
self.versions[file_name] = version_data
with open(self.version_file, "wb") as f:
pickle.dump(self.versions, f)Each commit stores the hash, time, message, and the full file contents, then pickles the whole versions dict to .versions.pkl. Notice the limitation: self.versions[file_name] = ... overwrites — so only the latest version per file is kept. The upgrade below fixes that.
4. Reverting
Section titled “4. Reverting”with open(os.path.join(self.repo_path, file_name), "wb") as f:
f.write(self.versions[file_name]["content"])Revert just writes the stored bytes back to disk. Because we kept the raw content, restoring is trivial.
Fix #1: Keep Every Version
Section titled “Fix #1: Keep Every Version”A real VCS keeps history. Store a list of versions per file:
# versions[file_name] becomes a list of snapshots
entry = {"hash": file_hash, "timestamp": timestamp,
"message": message, "content": data}
self.versions.setdefault(file_name, []).append(entry)
def revert(self, file_name, index=-1): # -1 = latest, 0 = first, etc.
snapshot = self.versions[file_name][index]
Path(self.repo_path, file_name).write_bytes(snapshot["content"])
def log(self, file_name):
for i, v in enumerate(self.versions[file_name]):
print(f"[{i}] {v['timestamp']} {v['hash'][:8]} {v['message']}")Now you can revert to any past commit by index — much closer to real version control.
Fix #2: Store Diffs, Not Full Copies
Section titled “Fix #2: Store Diffs, Not Full Copies”Saving the whole file every commit wastes space. Store the difference between versions:
import difflib
def make_diff(old_text, new_text):
return list(difflib.unified_diff(old_text.splitlines(), new_text.splitlines(), lineterm=""))Git does a sophisticated version of this (delta compression). For a learning project, difflib is enough to show how diffs shrink storage.
Fix #3: Drop Pickle
Section titled “Fix #3: Drop Pickle”pickle is convenient but unsafe (unpickling untrusted data can execute code) and not human-readable. For text snapshots, prefer storing content files in a folder plus a JSON index:
import json, hashlib
# Save each unique blob by its hash, like Git's object store
blob_path = Path(self.repo_path, ".objects", file_hash)
blob_path.write_bytes(data)
# Index maps filename -> list of {hash, timestamp, message}
Path(self.repo_path, "index.json").write_text(json.dumps(index, indent=2))This is literally a simplified version of Git’s .git/objects content-addressable store.
Common Mistakes
Section titled “Common Mistakes”| Problem | Cause | Fix |
|---|---|---|
| Only one version is kept | versions[name] = ... overwrites | Append to a list per file |
SyntaxError on := | Python < 3.8 | Upgrade, or use a normal while loop |
| Reverting wipes recent work | Revert with no confirmation | Confirm; commit current state first |
| Repo file grows huge | Storing full content every commit | Store diffs or dedupe blobs by hash |
pickle load fails / unsafe | Format change or untrusted data | Switch to JSON index + blob store |
| Binary files break diffs | difflib is line-based | Keep full copies for binaries |
Variations to Try
Section titled “Variations to Try”- Multi-version history — revert to any commit by index or hash.
statuscommand — show which tracked files changed since last commit.- Whole-directory tracking — commit a folder, not one file.
- Diff viewer — print a colored unified diff between two versions.
- Tags — name important commits (“v1.0”).
- Branches — keep parallel histories (the real leap toward Git).
- Compression — gzip stored blobs to save space.
Real-World Applications
Section titled “Real-World Applications”- Understanding Git — this is Git’s core, minus the plumbing.
- Document versioning — wikis, CMSs, and editors that track edits.
- Config backups — snapshot a config before changing it.
- Undo systems — the same snapshot/restore idea powers app undo.
Educational Value
Section titled “Educational Value”- Hashing — content fingerprints and change detection.
- Serialization — pickle vs. JSON vs. a blob store, and their trade-offs.
- Data modeling — why a list beats a single slot for history.
- Demystifying tools — seeing the simple idea inside a complex tool.
Next Steps
Section titled “Next Steps”- Store every version in a list and revert by index.
- Replace full copies with diffs.
- Swap
picklefor a JSON index + hashed blob store. - Add status, tags, and eventually branches.
Conclusion
Section titled “Conclusion”You built a miniature version control system and saw that the magic behind Git is really just hash the content, store a snapshot, restore on request. By keeping full history, storing diffs, and dropping pickle for a content-addressable store, you walk the same path Git’s designers did. Next time you git commit, you’ll know exactly what’s happening underneath. Full source on GitHub. Explore more systems projects on Python Central Hub.
Pitfalls
Section titled “Pitfalls”- The original stored one version per file.
self.versions[name] = dataoverwrote the previous entry on every commit, sologprinted a single line andrevertcould only restore the most recent state. The feature list said “revert to previous versions”; the data structure said otherwise. The fix is a list instead of a dict entry. picklefor an on-disk index. Unpickling executes arbitrary code, and only Python can read it. A repository index outlives the program that wrote it, which makes JSON the better default.- Storing diffs is not automatically cheaper. Measured on this project’s own demo repository: full copies 114 bytes, first copy plus diffs 234 bytes — 205%. A unified diff carries a header, a hunk marker and three lines of context, and on small files that overhead dominates.
- Reverting is not deleting. After reverting to v1 the history still shows three versions. A revert that discarded the newer commits would be a destructive operation wearing the name of a navigational one.
- Content hashing catches no-op commits. Committing unchanged content twice must not create a version, or the history fills with entries that record nothing.
- Three commits, one diff, one revert, all measured in 0.1 s.
- History is a list per file; the version number printed by
logis the indexreverttakes. - SHA-256 of the content is what makes “no changes detected” cheap and exact.
- The storage question is empirical. From the exercise: diffs cost 139% of full copies on a 4-line file, 12% on a 1,000-line file with 3 lines changed, 5% at 10,000 lines — and 119% when the change is large. Compressing whole copies with zlib beat diffing them outright, at 8%.
-
The original stored versions as self.versions[file_name] = version_data. What did that make impossible?
pch.quizShowAnswer
B — Any history at all — each commit replaced the last, so log had one entry and revert could only restore the newest state
-
Measured on a 4-line file, storing diffs cost 139% of storing full copies. Why?
pch.quizShowAnswer
B — A unified diff carries file headers, a hunk marker and three lines of context, and on a small file that overhead exceeds the file itself
-
Why is pickle a poor choice for the repository index?
pch.quizShowAnswer
B — Loading it executes arbitrary code, and nothing outside Python can read it — both bad properties for a file format meant to persist
Try it yourself
Section titled “Try it yourself”pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading