From 46d2cc79209d7a0c00e9963ef620cad89b6e2127 Mon Sep 17 00:00:00 2001 From: mahis1067 Date: Fri, 4 Sep 2026 23:17:44 -0400 Subject: [PATCH 1/3] Implement disk-backed object store --- minigit/cli.py | 18 +++++++++++++++ minigit/objects.py | 52 ++++++++++++++++++++++++++++++++----------- tests/test_objects.py | 52 ++++++++++++++++++++++++++++++++++++++++++- tests/test_smoke.py | 19 ++++++++++++++++ 4 files changed, 127 insertions(+), 14 deletions(-) diff --git a/minigit/cli.py b/minigit/cli.py index c4e151b..7066510 100644 --- a/minigit/cli.py +++ b/minigit/cli.py @@ -10,6 +10,7 @@ import argparse import sys from collections.abc import Sequence +from pathlib import Path from minigit import __version__, commits from minigit.errors import MiniGitError @@ -18,6 +19,20 @@ from minigit.remote import register_subcommands as register_remote_commands +def _init_repository(args) -> int: + metadata_dir = Path(".minigit") + if metadata_dir.exists(): + print("already a minigit repository") + return 0 + + (metadata_dir / "objects").mkdir(parents=True) + (metadata_dir / "refs" / "heads").mkdir(parents=True) + (metadata_dir / "index").write_bytes(b"") + (metadata_dir / "config").write_bytes(b"") + (metadata_dir / "HEAD").write_text("ref: refs/heads/main\n") + return 0 + + def _register_commands(subparsers) -> None: """Attach each module's subcommands to the parser. @@ -29,6 +44,9 @@ def register_subcommands(subparsers): parser.add_argument("path") parser.set_defaults(handler=cmd_add) """ + init_parser = subparsers.add_parser("init", help="create a minigit repository") + init_parser.set_defaults(handler=_init_repository) + register_object_commands(subparsers) register_index_commands(subparsers) commits.register_subcommands(subparsers) diff --git a/minigit/objects.py b/minigit/objects.py index 2f19f75..c3e1bdb 100644 --- a/minigit/objects.py +++ b/minigit/objects.py @@ -9,16 +9,16 @@ Build the `ObjectStore` class here, per the interface contract. """ +import hashlib import zlib from pathlib import Path -from minigit.errors import ObjectNotFoundError +from minigit.errors import ObjectCorruptError, ObjectNotFoundError class ObjectStore: root: Path objects_dir: Path - _fake_store: dict[str, tuple[str, bytes]] def __init__(self, repo_path=".") -> None: """ @@ -26,36 +26,62 @@ def __init__(self, repo_path=".") -> None: """ self.root = Path(repo_path) self.objects_dir = self.root / ".minigit" / "objects" - self._fake_store = {} def hash_object(self, data: bytes, obj_type: str) -> str: """ Return the SHA-1 hash of the object, given its data and type. """ - # placeholder - real SHA-1 of " \0" lands Week 2 - return f"{zlib.crc32(obj_type.encode() + data):040x}" + + return hashlib.sha1(f"{obj_type} {len(data)}".encode() + b"\0" + data).hexdigest() def write_object(self, data: bytes, obj_type: str) -> str: """ - Writes the object's hash into self._fake_store and returns the hash. + Write the object to the object store and return its hash. Allow duplicates to be written. """ obj_hash = self.hash_object(data, obj_type) - self._fake_store[obj_hash] = (obj_type, data) + object_path = self._object_path(obj_hash) + + if object_path.exists(): + return obj_hash + + header = f"{obj_type} {len(data)}".encode() + object_path.parent.mkdir(parents=True, exist_ok=True) + object_path.write_bytes(zlib.compress(header + b"\0" + data)) return obj_hash def read_object(self, hash: str) -> tuple[str, bytes]: """ - Reads the object from self._fake_store and returns a tuple of (type, data). + Reads the object from the object store and returns a tuple of (type, data). Raise ObjectNotFoundError(hash) if the object is not found. + Raise ObjectCorruptError(hash) if the object is corrupt. """ - if hash not in self._fake_store: + object_path = self._object_path(hash) + + if not object_path.exists(): raise ObjectNotFoundError(hash) - return self._fake_store[hash] + try: + raw_object = zlib.decompress(object_path.read_bytes()) + header, content = raw_object.split(b"\0", 1) + obj_type, obj_length = header.decode().split(" ", 1) + + if int(obj_length) != len(content): + raise ObjectCorruptError(hash) + except (ValueError, UnicodeDecodeError, zlib.error) as error: + raise ObjectCorruptError(hash) from error -_cli_store = ObjectStore() + if self.hash_object(content, obj_type) != hash: + raise ObjectCorruptError(hash) + + return obj_type, content + + def _object_path(self, hash: str) -> Path: + """ + Return the path to the object with the given hash. + """ + return self.objects_dir / hash[:2] / hash[2:] def run_hash_object(args) -> int: @@ -64,7 +90,7 @@ def run_hash_object(args) -> int: """ with open(args.path, "rb") as f: data = f.read() - obj_hash = _cli_store.write_object(data, "blob") + obj_hash = ObjectStore(".").write_object(data, "blob") print(obj_hash) return 0 @@ -73,7 +99,7 @@ def run_cat_file(args) -> int: """ Print the contents of the object with the given hash. """ - _, obj_data = _cli_store.read_object(args.hash) + _, obj_data = ObjectStore(".").read_object(args.hash) print(obj_data.decode("utf-8", errors="replace"), end="") return 0 diff --git a/tests/test_objects.py b/tests/test_objects.py index dd98d84..d3c9558 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -5,7 +5,7 @@ import pytest from minigit.cli import main -from minigit.errors import ObjectNotFoundError +from minigit.errors import ObjectCorruptError, ObjectNotFoundError from minigit.objects import ObjectStore @@ -96,3 +96,53 @@ def test_cat_file_cli_print(tmp_path, capsys): assert main(["cat-file", obj_hash]) == 0 captured = capsys.readouterr() assert captured.out == "hello minigit" + + +def test_hash_correctness(tmp_path): + """ + Test that the hash of a known object is correct. + """ + + store = ObjectStore(tmp_path) + + assert store.hash_object(b"hi", "blob") == "32f95c0d1244a78b2be1bab8de17906fabb2c4a8" + assert store.hash_object(b"", "blob") == "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391" + + +def test_object_compressed(tmp_path): + """ + Test that the compressed object does not equal the content. + """ + + store = ObjectStore(tmp_path) + obj_hash = store.write_object(b"hi", "blob") + object_path = store._object_path(obj_hash) + + compressed_data = object_path.read_bytes() + assert compressed_data != b"hi" + + +def test_file_overwrite(tmp_path): + """ + Test that overwriting an existing object raises ObjectCorruptError. + """ + + store = ObjectStore(tmp_path) + obj_hash = store.write_object(b"hi", "blob") + + store._object_path(obj_hash).write_bytes(b"junk") + + with pytest.raises(ObjectCorruptError): + store.read_object(obj_hash) + + +def test_duplicate_write_leaves_one_object_file(tmp_path): + store = ObjectStore(tmp_path) + + obj_hash = store.write_object(b"hi", "blob") + assert store.write_object(b"hi", "blob") == obj_hash + + assert list((tmp_path / ".minigit" / "objects").rglob("*")) == [ + store._object_path(obj_hash).parent, + store._object_path(obj_hash), + ] diff --git a/tests/test_smoke.py b/tests/test_smoke.py index b0ee305..fd007d7 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -23,6 +23,25 @@ def test_cli_without_a_command_prints_help(capsys): assert "usage: minigit" in capsys.readouterr().out +def test_init_creates_repository_layout(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + + assert main(["init"]) == 0 + + assert (tmp_path / ".minigit").is_dir() + assert (tmp_path / ".minigit" / "objects").is_dir() + assert (tmp_path / ".minigit" / "refs" / "heads").is_dir() + assert (tmp_path / ".minigit" / "index").read_bytes() == b"" + assert (tmp_path / ".minigit" / "config").read_bytes() == b"" + assert (tmp_path / ".minigit" / "HEAD").read_text() == "ref: refs/heads/main\n" + + sentinel = tmp_path / ".minigit" / "config" + sentinel.write_bytes(b"keep me") + assert main(["init"]) == 0 + assert sentinel.read_bytes() == b"keep me" + assert "already a minigit repository" in capsys.readouterr().out + + def test_merge_conflict_error_carries_paths(): error = MergeConflictError(["src/a.py", "src/b.py"]) From 89fcd52b8910a83077417a6d3df3c4a73828be25 Mon Sep 17 00:00:00 2001 From: mahis1067 Date: Fri, 4 Sep 2026 23:27:55 -0400 Subject: [PATCH 2/3] Fix documentation --- minigit/cli.py | 5 +++++ tests/test_objects.py | 3 +++ tests/test_smoke.py | 3 +++ 3 files changed, 11 insertions(+) diff --git a/minigit/cli.py b/minigit/cli.py index 7066510..cff7f65 100644 --- a/minigit/cli.py +++ b/minigit/cli.py @@ -20,7 +20,12 @@ def _init_repository(args) -> int: + """ + Initialize a new minigit repository in the current directory. + If a repository already exists, print a message and return 0. + """ metadata_dir = Path(".minigit") + if metadata_dir.exists(): print("already a minigit repository") return 0 diff --git a/tests/test_objects.py b/tests/test_objects.py index d3c9558..04e84fb 100644 --- a/tests/test_objects.py +++ b/tests/test_objects.py @@ -137,6 +137,9 @@ def test_file_overwrite(tmp_path): def test_duplicate_write_leaves_one_object_file(tmp_path): + """ + Test that writing the same object twice leaves only one object file in the object store. + """ store = ObjectStore(tmp_path) obj_hash = store.write_object(b"hi", "blob") diff --git a/tests/test_smoke.py b/tests/test_smoke.py index fd007d7..098b442 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -24,6 +24,9 @@ def test_cli_without_a_command_prints_help(capsys): def test_init_creates_repository_layout(tmp_path, monkeypatch, capsys): + """ + Test that the init command creates the expected repository layout. + """ monkeypatch.chdir(tmp_path) assert main(["init"]) == 0 From bd53b7be0d47f2247e7e4414b9b82c0e468e61b1 Mon Sep 17 00:00:00 2001 From: mahis1067 Date: Fri, 4 Sep 2026 23:56:48 -0400 Subject: [PATCH 3/3] Fix documentation --- minigit/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/minigit/cli.py b/minigit/cli.py index cff7f65..512230d 100644 --- a/minigit/cli.py +++ b/minigit/cli.py @@ -21,11 +21,11 @@ def _init_repository(args) -> int: """ - Initialize a new minigit repository in the current directory. + Initialize a new minigit repository in the current directory. If a repository already exists, print a message and return 0. """ metadata_dir = Path(".minigit") - + if metadata_dir.exists(): print("already a minigit repository") return 0