diff --git a/minigit/cli.py b/minigit/cli.py index c4e151b..512230d 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,25 @@ from minigit.remote import register_subcommands as register_remote_commands +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 + + (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 +49,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..04e84fb 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,56 @@ 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): + """ + 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") + 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..098b442 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -23,6 +23,28 @@ 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): + """ + Test that the init command creates the expected repository layout. + """ + 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"])