diff --git a/minigit/cli.py b/minigit/cli.py index 8a546de..57a0c4c 100644 --- a/minigit/cli.py +++ b/minigit/cli.py @@ -13,6 +13,8 @@ from minigit import __version__ from minigit.errors import MiniGitError +from minigit.index import register_index_commands +from minigit.objects import register_subcommands as register_object_commands def _register_commands(subparsers) -> None: @@ -26,6 +28,8 @@ def register_subcommands(subparsers): parser.add_argument("path") parser.set_defaults(handler=cmd_add) """ + register_object_commands(subparsers) + register_index_commands(subparsers) def build_parser() -> argparse.ArgumentParser: diff --git a/minigit/index.py b/minigit/index.py index 3c59474..bd2b35d 100644 --- a/minigit/index.py +++ b/minigit/index.py @@ -8,3 +8,81 @@ Build the `WorkingTree` class here, per the interface contract. """ + +from dataclasses import dataclass +from typing import NamedTuple + + +class IndexEntry(NamedTuple): + mode: str + hash: str + path: str + + +@dataclass +class DiffResult: + added: list + deleted: list + modified: list + + +class WorkingTree: + def __init__(self, repo_path=".", store=None): + self.root = repo_path + self.index_path = f"{repo_path}/.minigit/index" + + if store is None: + from minigit.objects import ObjectStore + + store = ObjectStore(repo_path) + + self.store = store + self._entries: list[IndexEntry] = [] + + def read_index(self) -> list[IndexEntry]: + return sorted(self._entries, key=lambda e: e.path) + + def write_index(self, entries) -> None: + self._entries = sorted(entries, key=lambda e: e.path) + + def stage_file(self, path) -> None: + with open(f"{self.root}/{path}", "rb") as f: + data = f.read() + + blob_hash = self.store.write_object(data, "blob") + + self._entries = [e for e in self._entries if e.path != path] + + self._entries.append(IndexEntry("100644", blob_hash, path)) + self._entries.sort(key=lambda e: e.path) + + def build_tree_from_index(self) -> str: + return self.store.write_object(b"", "tree") + + def diff_working_tree_vs(self, tree_hash) -> DiffResult: + return DiffResult([], [], []) + + def checkout(self, tree_hash) -> None: + pass + + +def cmd_add(args) -> int: + wt = WorkingTree() + wt.stage_file(args.path) + return 0 + + +def cmd_status(args) -> int: + wt = WorkingTree() + for entry in wt.read_index(): + print(entry.path) + return 0 + + +def register_index_commands(subparsers) -> None: + add_parser = subparsers.add_parser("add", help="stage a file") + add_parser.add_argument("path") + add_parser.set_defaults(handler=cmd_add) + + status_parser = subparsers.add_parser("status", help="show staged files") + status_parser.set_defaults(handler=cmd_status) diff --git a/minigit/objects.py b/minigit/objects.py index 860cc9a..2f19f75 100644 --- a/minigit/objects.py +++ b/minigit/objects.py @@ -8,3 +8,84 @@ Build the `ObjectStore` class here, per the interface contract. """ + +import zlib +from pathlib import Path + +from minigit.errors import ObjectNotFoundError + + +class ObjectStore: + root: Path + objects_dir: Path + _fake_store: dict[str, tuple[str, bytes]] + + def __init__(self, repo_path=".") -> None: + """ + Initialize the object store. + """ + 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}" + + def write_object(self, data: bytes, obj_type: str) -> str: + """ + Writes the object's hash into self._fake_store and returns the hash. + Allow duplicates to be written. + """ + obj_hash = self.hash_object(data, obj_type) + self._fake_store[obj_hash] = (obj_type, 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). + Raise ObjectNotFoundError(hash) if the object is not found. + """ + if hash not in self._fake_store: + raise ObjectNotFoundError(hash) + + return self._fake_store[hash] + + +_cli_store = ObjectStore() + + +def run_hash_object(args) -> int: + """ + Hash the file at args.path, write it to the object store, and print the hash. + """ + with open(args.path, "rb") as f: + data = f.read() + obj_hash = _cli_store.write_object(data, "blob") + print(obj_hash) + return 0 + + +def run_cat_file(args) -> int: + """ + Print the contents of the object with the given hash. + """ + _, obj_data = _cli_store.read_object(args.hash) + print(obj_data.decode("utf-8", errors="replace"), end="") + return 0 + + +def register_subcommands(subparsers): + """ + Register the "hash-object" and "cat-file" subcommands with the given subparsers object. + """ + hash_parser = subparsers.add_parser("hash-object") + hash_parser.add_argument("path") + hash_parser.set_defaults(handler=run_hash_object) + + cat_parser = subparsers.add_parser("cat-file") + cat_parser.add_argument("hash") + cat_parser.set_defaults(handler=run_cat_file) diff --git a/tests/test_index.py b/tests/test_index.py new file mode 100644 index 0000000..d5a62f2 --- /dev/null +++ b/tests/test_index.py @@ -0,0 +1,68 @@ +"""Tests for minigit/index.py - WorkingTree, IndexEntry, DiffResult.""" + +from minigit.index import WorkingTree + + +class FakeObjectStore: + """Stand-in for the real ObjectStore - keeps everything in memory.""" + + def __init__(self): + self.objects = {} + + def write_object(self, data, obj_type): + fake_hash = f"hash{len(data)}" + self.objects[fake_hash] = data + return fake_hash + + +def test_staging_adds_one_entry(tmp_path): + file = tmp_path / "hello.txt" + file.write_text("hi") + + wt = WorkingTree(repo_path=str(tmp_path), store=FakeObjectStore()) + wt.stage_file("hello.txt") + + entries = wt.read_index() + assert len(entries) == 1 + assert entries[0].path == "hello.txt" + + +def test_staging_twice_replaces_not_duplicates(tmp_path): + file = tmp_path / "hello.txt" + file.write_text("hi") + + wt = WorkingTree(repo_path=str(tmp_path), store=FakeObjectStore()) + wt.stage_file("hello.txt") + wt.stage_file("hello.txt") + + entries = wt.read_index() + assert len(entries) == 1 + + +def test_read_index_sorted(tmp_path): + (tmp_path / "z.txt").write_text("z") + (tmp_path / "a.txt").write_text("a") + + wt = WorkingTree(repo_path=str(tmp_path), store=FakeObjectStore()) + wt.stage_file("z.txt") + wt.stage_file("a.txt") + + entries = wt.read_index() + assert entries[0].path == "a.txt" + assert entries[1].path == "z.txt" + + +def test_editing_after_staging_does_not_change_entry(tmp_path): + file = tmp_path / "hello.txt" + file.write_text("original content") + + wt = WorkingTree(repo_path=str(tmp_path), store=FakeObjectStore()) + wt.stage_file("hello.txt") + + hash_before = wt.read_index()[0].hash + + file.write_text("changed content!!!") + + hash_after = wt.read_index()[0].hash + + assert hash_before == hash_after diff --git a/tests/test_objects.py b/tests/test_objects.py new file mode 100644 index 0000000..dd98d84 --- /dev/null +++ b/tests/test_objects.py @@ -0,0 +1,98 @@ +"""Object tests: Tests for module 1 - object storage.""" + +from pathlib import Path + +import pytest + +from minigit.cli import main +from minigit.errors import ObjectNotFoundError +from minigit.objects import ObjectStore + + +def test_round_trip(tmp_path: Path) -> None: + """ + Test that we can write an object and then read it back. + """ + + store = ObjectStore(tmp_path) + obj_hash = store.write_object(b"hi", "blob") + assert store.read_object(obj_hash) == ("blob", b"hi") + + +def test_identical_objects_same_hash(tmp_path: Path): + """ + Test that writing the same object twice returns the same hash. + """ + store = ObjectStore(tmp_path) + + hash1 = store.hash_object(b"hi", "blob") + hash2 = store.hash_object(b"hi", "blob") + + assert hash1 == hash2 + + +def test_different_objects_different_hashes(tmp_path: Path): + """ + Test that writing different objects returns different hashes. + """ + store = ObjectStore(tmp_path) + + hash1 = store.hash_object(b"hi", "blob") + hash2 = store.hash_object(b"hello", "blob") + + assert hash1 != hash2 + + +def test_idempotent_write(tmp_path: Path): + """ + Test that writing the same object twice returns the same hash and does not raise an error. + """ + store = ObjectStore(tmp_path) + + hash1 = store.write_object(b"hi", "blob") + hash2 = store.write_object(b"hi", "blob") + + assert hash1 == hash2 + assert store.read_object(hash1) == ("blob", b"hi") + + +def test_unknown_hash_raises(tmp_path: Path): + """ + Test that reading an unknown hash raises ObjectNotFoundError. + """ + store = ObjectStore(tmp_path) + + with pytest.raises(ObjectNotFoundError): + store.read_object("does-not-exist") + + +def test_hash_object_cli_print(tmp_path, capsys): + """ + Test that the hash-object CLI command prints the correct hash. + """ + + test_file = tmp_path / "test.txt" + test_file.write_bytes(b"hello minigit") + + assert main(["hash-object", str(test_file)]) == 0 + + captured = capsys.readouterr() + obj_hash = captured.out.strip() + + assert len(obj_hash) == 40 # current placeholder hash has length 40, like SHA-1 + + +def test_cat_file_cli_print(tmp_path, capsys): + """ + Test that the cat-file CLI command prints the correct object data. + """ + + test_file = tmp_path / "test.txt" + test_file.write_bytes(b"hello minigit") + + assert main(["hash-object", str(test_file)]) == 0 + obj_hash = capsys.readouterr().out.strip() + + assert main(["cat-file", obj_hash]) == 0 + captured = capsys.readouterr() + assert captured.out == "hello minigit"