diff --git a/minigit/cli.py b/minigit/cli.py index 8883138..bc9c5d6 100644 --- a/minigit/cli.py +++ b/minigit/cli.py @@ -11,9 +11,10 @@ import sys from collections.abc import Sequence -from minigit import __version__ +from minigit import __version__, commits from minigit.errors import MiniGitError -from minigit.objects import register_subcommands +from minigit.index import register_index_commands +from minigit.objects import register_subcommands as register_object_commands def _register_commands(subparsers) -> None: @@ -27,8 +28,9 @@ def register_subcommands(subparsers): parser.add_argument("path") parser.set_defaults(handler=cmd_add) """ - - register_subcommands(subparsers) + register_object_commands(subparsers) + register_index_commands(subparsers) + commits.register_subcommands(subparsers) def build_parser() -> argparse.ArgumentParser: diff --git a/minigit/commits.py b/minigit/commits.py index 6b89555..74c6203 100644 --- a/minigit/commits.py +++ b/minigit/commits.py @@ -8,3 +8,110 @@ Build the `CommitManager` class here, per the interface contract. """ + +import os +import time + +from minigit.errors import RefNotFoundError +from minigit.index import WorkingTree +from minigit.objects import ObjectStore + + +def _cmd_commit(args) -> int: + """Handle the 'minigit commit -m ' command""" + manager = CommitManager() + stub_tree = "0" * 40 + manager.create_commit( + stub_tree, + [], + "Daniel ", + args.message, + ) + return 0 + + +def _cmd_branch(args) -> int: + """Handle 'minigit branch ' command""" + manager = CommitManager() + if args.name: + manager.create_branch(args.name, "0" * 40) + else: + for branch in manager.list_branches(): + if branch == manager._head: + marker = "* " + else: + marker = " " + print(f"{marker}{branch}") + return 0 + + +def _cmd_checkout(args) -> int: + """ + Handle 'minigit checkout command' + """ + manager = CommitManager() + manager.switch_branch(args.name) + print(f"Switched to branch '{args.name}'") + return 0 + + +def register_subcommands(subparsers) -> None: + """ + Register the three commands: commit, branch, checkout + """ + commit_parser = subparsers.add_parser("commit", help="record changes to the repository") + commit_parser.add_argument("-m", dest="message", required=True, help="commit message") + commit_parser.set_defaults(handler=_cmd_commit) + + branch_parser = subparsers.add_parser("branch", help="create or list branches") + branch_parser.add_argument("name", nargs="?", help="branch name to create") + branch_parser.set_defaults(handler=_cmd_branch) + + checkout_parser = subparsers.add_parser("checkout", help="switch branches") + checkout_parser.add_argument("name", help="branch name to switch to") + checkout_parser.set_defaults(handler=_cmd_checkout) + + +class CommitManager: + def __init__(self, repo_path=".", store=None, tree=None): + self.root = os.path.abspath(repo_path) + self._refs = {} + self._head = "main" + self.store = store if store is not None else ObjectStore(repo_path) + self.tree = tree if tree is not None else WorkingTree(repo_path) + + def _format_commit(self, tree_hash, parents, author, message) -> str: + """Format commit object as a string""" + lines = [] + timestamp = int(time.time()) + lines.append(f"tree {tree_hash}") + for parent in parents: + lines.append(f"parent {parent}") + lines.append(f"author {author} {timestamp}") + lines.append(f"committer {author} {timestamp}") + lines.append("") + lines.append(message) + return "\n".join(lines) + + def create_commit(self, tree_hash, parents: list[str], author, message) -> str: + """ + Create a new commit object and write it to the object store + """ + body = self._format_commit(tree_hash, parents, author, message) + return self.store.write_object(body.encode(), "commit") + + def create_branch(self, name, commit_hash) -> None: + """Create a new branch that points at commit_hash""" + self._refs[name] = commit_hash + + def switch_branch(self, name) -> None: + """Switch to a branch""" + if name not in self._refs: + raise RefNotFoundError(name) + self._head = name + + def list_branches(self) -> list[str]: + return sorted(self._refs) + + def merge(self, branch_name) -> str | None: + return None 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/tests/test_commits.py b/tests/test_commits.py new file mode 100644 index 0000000..6b54f11 --- /dev/null +++ b/tests/test_commits.py @@ -0,0 +1,107 @@ +# Run to test: scripts/test.sh tests/test_commits.py +# Testing for Module 3 + +import pytest + +from minigit.commits import CommitManager +from minigit.errors import RefNotFoundError + + +class FakeObjectStore: + def __init__(self): + """Initialize with an empty record of written objects.""" + self.written = [] + + def write_object(self, data: bytes, obj_type: str) -> str: + """Record the write and return a deterministic fake hash.""" + self.written.append((obj_type, data)) + return "a" * 40 + + +class FakeWorkingTree: + """Minimal stand-in for WorkingTree. No filesystem operations.""" + + def checkout(self, tree_hash: str) -> None: + """Accept a checkout call without doing anything.""" + + +def make_manager(): + """Return CommitManager for testing""" + return CommitManager(store=FakeObjectStore(), tree=FakeWorkingTree()) + + +# testing commits + + +def test_contains_tree_line(): + m = make_manager() + body = m._format_commit("abc" * 13 + "a", [], "Daniel ", "init") + assert body.startswith("tree ") + + +def test_contains_author_line(): + m = make_manager() + body = m._format_commit("a" * 40, [], "Daniel ", "init") + assert any(line.startswith("author ") for line in body.splitlines()) + + +def test_blank_line_before_message(): + m = make_manager() + body = m._format_commit("a" * 40, [], "Daniel ", "hello") + lines = body.splitlines() + assert lines[-2] == "" + assert lines[-1] == "hello" + + +def test_root_commit_no_parent_lines(): + m = make_manager() + body = m._format_commit("a" * 40, [], "Daniel ", "root") + assert "parent" not in body + + +def test_normal_commit_one_parent_line(): + m = make_manager() + body = m._format_commit("a" * 40, ["b" * 40], "Daniel ", "second") + parent_lines = [line for line in body.splitlines() if line.startswith("parent ")] + assert len(parent_lines) == 1 + assert "b" * 40 in parent_lines[0] + + +def test_merge_commit_two_parent_lines_in_order(): + m = make_manager() + p1 = "1" * 40 + p2 = "2" * 40 + body = m._format_commit("0" * 40, [p1, p2], "Daniel ", "merge") + parent_lines = [line for line in body.splitlines() if line.startswith("parent ")] + assert len(parent_lines) == 2 + assert p1 in parent_lines[0] + assert p2 in parent_lines[1] + + +def test_create_commit_returns_hash(): + m = make_manager() + result = m.create_commit("0" * 40, [], "Daniel ", "init") + assert len(result) == 40 + + +# branches test: + + +def test_create_and_list_branches(): + m = make_manager() + m.create_branch("feature1", "a" * 40) + m.create_branch("feature2", "b" * 40) + assert m.list_branches() == ["feature1", "feature2"] + + +def test_switch_branch_unknown_raises(): + m = make_manager() + with pytest.raises(RefNotFoundError): + m.switch_branch("nope") + + +def test_switch_branch_updates_head(): + m = make_manager() + m.create_branch("feature", "a" * 40) + m.switch_branch("feature") + assert m._head == "feature" 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