Basic File Version Control System
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 picklepickle 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
- Python 3.8 or above (uses the
:=:=walrus operator). - A text editor or IDE.
- Standard library only —
osos,hashlibhashlib,picklepickle,datetimedatetime. - Comfort with classes, dictionaries, and file I/O.
Getting Started
Create the project
- Create a folder named
mini-vcsmini-vcs. - Inside it, create
basic_file_version_control_system.pybasic_file_version_control_system.py.
Write the code
basic_file_version_control_system.py
Source"""
Basic File Version Control System
A Python-based version control system with the following features:
- Track changes to files
- Commit changes with messages
- Revert to previous versions
"""
import os
import hashlib
import pickle
from datetime import datetime
class BasicFileVersionControl:
def __init__(self, repo_path):
self.repo_path = repo_path
self.version_file = os.path.join(repo_path, ".versions.pkl")
self.versions = {}
if not os.path.exists(repo_path):
os.makedirs(repo_path)
if os.path.exists(self.version_file):
with open(self.version_file, "rb") as f:
self.versions = pickle.load(f)
def hash_file(self, file_path):
"""Generate a hash for the given file."""
hasher = hashlib.sha256()
with open(file_path, "rb") as f:
while chunk := f.read(8192):
hasher.update(chunk)
return hasher.hexdigest()
def commit(self, file_path, message):
"""Commit changes to the file."""
if not os.path.exists(file_path):
print("File does not exist.")
return
file_hash = self.hash_file(file_path)
file_name = os.path.basename(file_path)
if file_name in self.versions and self.versions[file_name]["hash"] == file_hash:
print("No changes detected.")
return
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
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)
print(f"Committed {file_name} at {timestamp} with message: {message}")
def revert(self, file_name):
"""Revert the file to the last committed version."""
if file_name not in self.versions:
print("No version history found for this file.")
return
with open(os.path.join(self.repo_path, file_name), "wb") as f:
f.write(self.versions[file_name]["content"])
print(f"Reverted {file_name} to the last committed version.")
def log(self, file_name):
"""Display the commit history for the file."""
if file_name not in self.versions:
print("No version history found for this file.")
return
version_data = self.versions[file_name]
print(f"File: {file_name}")
print(f"Last Commit: {version_data['timestamp']}")
print(f"Message: {version_data['message']}")
def main():
repo_path = "./repo"
vcs = BasicFileVersionControl(repo_path)
while True:
print("\nBasic File Version Control System")
print("1. Commit File")
print("2. Revert File")
print("3. View Log")
print("4. Exit")
choice = input("Enter your choice: ")
if choice == "1":
file_path = input("Enter the file path to commit: ")
message = input("Enter commit message: ")
vcs.commit(file_path, message)
elif choice == "2":
file_name = input("Enter the file name to revert: ")
vcs.revert(file_name)
elif choice == "3":
file_name = input("Enter the file name to view log: ")
vcs.log(file_name)
elif choice == "4":
break
else:
print("Invalid choice. Please try again.")
if __name__ == "__main__":
main()
"""
Basic File Version Control System
A Python-based version control system with the following features:
- Track changes to files
- Commit changes with messages
- Revert to previous versions
"""
import os
import hashlib
import pickle
from datetime import datetime
class BasicFileVersionControl:
def __init__(self, repo_path):
self.repo_path = repo_path
self.version_file = os.path.join(repo_path, ".versions.pkl")
self.versions = {}
if not os.path.exists(repo_path):
os.makedirs(repo_path)
if os.path.exists(self.version_file):
with open(self.version_file, "rb") as f:
self.versions = pickle.load(f)
def hash_file(self, file_path):
"""Generate a hash for the given file."""
hasher = hashlib.sha256()
with open(file_path, "rb") as f:
while chunk := f.read(8192):
hasher.update(chunk)
return hasher.hexdigest()
def commit(self, file_path, message):
"""Commit changes to the file."""
if not os.path.exists(file_path):
print("File does not exist.")
return
file_hash = self.hash_file(file_path)
file_name = os.path.basename(file_path)
if file_name in self.versions and self.versions[file_name]["hash"] == file_hash:
print("No changes detected.")
return
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
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)
print(f"Committed {file_name} at {timestamp} with message: {message}")
def revert(self, file_name):
"""Revert the file to the last committed version."""
if file_name not in self.versions:
print("No version history found for this file.")
return
with open(os.path.join(self.repo_path, file_name), "wb") as f:
f.write(self.versions[file_name]["content"])
print(f"Reverted {file_name} to the last committed version.")
def log(self, file_name):
"""Display the commit history for the file."""
if file_name not in self.versions:
print("No version history found for this file.")
return
version_data = self.versions[file_name]
print(f"File: {file_name}")
print(f"Last Commit: {version_data['timestamp']}")
print(f"Message: {version_data['message']}")
def main():
repo_path = "./repo"
vcs = BasicFileVersionControl(repo_path)
while True:
print("\nBasic File Version Control System")
print("1. Commit File")
print("2. Revert File")
print("3. View Log")
print("4. Exit")
choice = input("Enter your choice: ")
if choice == "1":
file_path = input("Enter the file path to commit: ")
message = input("Enter commit message: ")
vcs.commit(file_path, message)
elif choice == "2":
file_name = input("Enter the file name to revert: ")
vcs.revert(file_name)
elif choice == "3":
file_name = input("Enter the file name to view log: ")
vcs.log(file_name)
elif choice == "4":
break
else:
print("Invalid choice. Please try again.")
if __name__ == "__main__":
main()
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.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.Step-by-Step Explanation
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()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
if file_name in self.versions and self.versions[file_name]["hash"] == file_hash:
print("No changes detected.")
returnif 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
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)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 versionsversions dict to .versions.pkl.versions.pkl. Notice the limitation: self.versions[file_name] = ...self.versions[file_name] = ... overwrites — so only the latest version per file is kept. The upgrade below fixes that.
4. Reverting
with open(os.path.join(self.repo_path, file_name), "wb") as f:
f.write(self.versions[file_name]["content"])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
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']}")# 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
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=""))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, difflibdifflib is enough to show how diffs shrink storage.
Fix #3: Drop Pickle
picklepickle 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))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.git/objects content-addressable store.
Common Mistakes
| Problem | Cause | Fix |
|---|---|---|
| Only one version is kept | versions[name] = ...versions[name] = ... overwrites | Append to a list per file |
SyntaxErrorSyntaxError on :=:= | Python < 3.8 | Upgrade, or use a normal whilewhile 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 |
picklepickle load fails / unsafe | Format change or untrusted data | Switch to JSON index + blob store |
| Binary files break diffs | difflibdifflib is line-based | Keep full copies for binaries |
Variations to Try
- Multi-version history — revert to any commit by index or hash.
statusstatuscommand — 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
- 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
- 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
- Store every version in a list and revert by index.
- Replace full copies with diffs.
- Swap
picklepicklefor a JSON index + hashed blob store. - Add status, tags, and eventually branches.
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 commitgit commit, you’ll know exactly what’s happening underneath. Full source on GitHub. Explore more systems projects on Python Central Hub.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
