Skip to content
Merged
4 changes: 4 additions & 0 deletions minigit/cli.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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:
Expand All@@ -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:
Expand Down
78 changes: 78 additions & 0 deletions minigit/index.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
81 changes: 81 additions & 0 deletions minigit/objects.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 "<type> <len>\0<content>" 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)
68 changes: 68 additions & 0 deletions tests/test_index.py
Original file line numberDiff line numberDiff line change
@@ -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
98 changes: 98 additions & 0 deletions tests/test_objects.py
Original file line numberDiff line numberDiff line change
@@ -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"
Loading