Skip to content

Basic File Version Control System

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.
  • 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.
  1. Create a folder named mini-vcs.
  2. Inside it, create basic_file_version_control_system.py.
basic_file_version_control_system.py pch.viewSource
basic_file_version_control_system.py
"""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()
command
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.

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.

diagram Diagram mermaid

Running the file exactly as it ships takes 0.2 s and prints:

python basic_file_version_control_system.py
[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.

basic_file_version_control_system.py
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.

basic_file_version_control_system.py
if file_name in self.versions and self.versions[file_name]["hash"] == file_hash:
    print("No changes detected.")
    return

Before 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”.

basic_file_version_control_system.py
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.

basic_file_version_control_system.py
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.

A real VCS keeps history. Store a list of versions per file:

history.py
# 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.

Saving the whole file every commit wastes space. Store the difference between versions:

diffs.py
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.

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:

store.py
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.

ProblemCauseFix
Only one version is keptversions[name] = ... overwritesAppend to a list per file
SyntaxError on :=Python < 3.8Upgrade, or use a normal while loop
Reverting wipes recent workRevert with no confirmationConfirm; commit current state first
Repo file grows hugeStoring full content every commitStore diffs or dedupe blobs by hash
pickle load fails / unsafeFormat change or untrusted dataSwitch to JSON index + blob store
Binary files break diffsdifflib is line-basedKeep full copies for binaries
  1. Multi-version history — revert to any commit by index or hash.
  2. status command — show which tracked files changed since last commit.
  3. Whole-directory tracking — commit a folder, not one file.
  4. Diff viewer — print a colored unified diff between two versions.
  5. Tags — name important commits (“v1.0”).
  6. Branches — keep parallel histories (the real leap toward Git).
  7. Compression — gzip stored blobs to save space.
  • 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.
  • 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.
  • Store every version in a list and revert by index.
  • Replace full copies with diffs.
  • Swap pickle for a JSON index + hashed blob store.
  • Add status, tags, and eventually branches.

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.

  • The original stored one version per file. self.versions[name] = data overwrote the previous entry on every commit, so log printed a single line and revert could 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.
  • pickle for 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 bytes205%. 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 log is the index revert takes.
  • 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%.
pch.quizTag pch.quizDefaultTitle
  1. 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

  2. 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

  3. 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

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading