diff --git a/minigit/cli.py b/minigit/cli.py index 8883138..c4e151b 100644 --- a/minigit/cli.py +++ b/minigit/cli.py @@ -11,9 +11,11 @@ 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 +from minigit.remote import register_subcommands as register_remote_commands def _register_commands(subparsers) -> None: @@ -27,8 +29,10 @@ 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) + register_remote_commands(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/minigit/remote.py b/minigit/remote.py index b6f12ca..22f6556 100644 --- a/minigit/remote.py +++ b/minigit/remote.py @@ -8,3 +8,108 @@ Build the `RemoteClient` class here, per the interface contract. """ + +# from .objects import ObjectStore (Module 1 & 3) +# from .commits import CommitManager +import os + +from minigit.errors import NetworkProtocolError + + +class RemoteClient: + """Push and pull commits between two minigit repos over a TCP connection.""" + + def __init__(self, repo_path=".", store=None, commits=None): + + self.repo_path = repo_path + self.config_path = os.path.join(self.repo_path, ".minigit", "config") + self.store = store + self.commits = commits + # Below is correct but need name of function within module 1 & 3 + # self.store = store if store is not None else ObjectStore(self.repo_path) + # self.commits = commits if commits is not None else CommitManager(self.repo_path) + + def _parse_address(self, address: str) -> tuple[str, int]: + """split a string address by host part(string) and the port part(integer)""" + + parts = address.rsplit(":", 1) + if len(parts) != 2: + raise NetworkProtocolError(f"address must be host:port, got {address!r}") + + host, port = parts + if host and port: + if port.isnumeric(): + int_port = int(port) + if 1 <= int_port <= 65535: + return host, int_port + else: + raise NetworkProtocolError(f"port out of range 1-65535: {port}") + else: + raise NetworkProtocolError(f"port is not a number: {port!r}") + else: + raise NetworkProtocolError(f"address must be host:port, got {address!r}") + + def push(self, remote_address: str, branch: str, token: str) -> None: + """Send local commits on `branch` to the remote, rejecting if it has diverged.""" + + host, port = self._parse_address(remote_address) + if len(token) == 0: + raise NetworkProtocolError("push needs a token: pass --token") + print(f"push: would push {branch} to {host}:{port}") + + # connect over TCP + # ask remote for its current hash for + # remote hash not an ancestor of local -> someone else pushed first -> NetworkProtocolError + # walk local commit graph from remote's hash up to local -> collect reachable objects + # send only the missing objects + # move the remote ref LAST, only after every object arrived + + def pull(self, remote_address: str, branch: str, token: str) -> None: + """Fetch `branch` from the remote and update the matching local ref.""" + + host, port = self._parse_address(remote_address) + if len(token) == 0: + raise NetworkProtocolError("pull needs a token: pass --token") + print(f"pull: would pull {branch} from {host}:{port}") + # Week 6 - same exchange in reverse + + +# Wire protocol (draft only - Week 2 makes this real): +# One message per line, UTF-8 encoded, terminated with "\n". +# +# AUTH - client authenticates the connection with its token +# REF - ask for / report the commit hash a branch currently points to +# WANT - request the object with this hash +# OBJ - announces an object is coming next: its type and byte length +# DONE - no more messages from this side +# ERR - something went wrong + + +def register_subcommands(subparsers) -> None: + """Register the `push` and `pull` subcommands with the CLI parser.""" + + push_parser = subparsers.add_parser("push", help="push a branch to a remote") + push_parser.add_argument("address") + push_parser.add_argument("branch") + push_parser.add_argument("--token", default="") + push_parser.set_defaults(handler=cmd_push) + + pull_parser = subparsers.add_parser("pull", help="pull a branch to a local") + pull_parser.add_argument("address") + pull_parser.add_argument("branch") + pull_parser.add_argument("--token", default="") + pull_parser.set_defaults(handler=cmd_pull) + + +def cmd_push(args) -> int: + """Handle `minigit push` from the CLI.""" + + RemoteClient().push(args.address, args.branch, args.token) + return 0 + + +def cmd_pull(args) -> int: + """Handle `minigit pull` from the CLI.""" + + RemoteClient().pull(args.address, args.branch, args.token) + return 0 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 diff --git a/tests/test_remote.py b/tests/test_remote.py new file mode 100644 index 0000000..3b21e1c --- /dev/null +++ b/tests/test_remote.py @@ -0,0 +1,56 @@ +import pytest + +from minigit.errors import NetworkProtocolError +from minigit.remote import RemoteClient + + +class FakeObjectStore: + pass + + +class FakeCommitManager: + pass + + +def make_client(): + return RemoteClient(store=FakeObjectStore(), commits=FakeCommitManager()) + + +def test_parse_address_valid(): + client = make_client() + assert client._parse_address("127.0.0.1:9418") == ("127.0.0.1", 9418) + + +def test_parse_address_no_colon_raises(): + client = make_client() + with pytest.raises(NetworkProtocolError): + client._parse_address("localhost") + + +def test_parse_address_non_numeric_port_raises(): + client = make_client() + with pytest.raises(NetworkProtocolError): + client._parse_address("host:abc") + + +def test_parse_address_port_zero_raises(): + client = make_client() + with pytest.raises(NetworkProtocolError): + client._parse_address("host:0") + + +def test_parse_address_empty_raises(): + client = make_client() + with pytest.raises(NetworkProtocolError): + client._parse_address("") + + +def test_push_empty_token_raises(): + client = make_client() + with pytest.raises(NetworkProtocolError): + client.push("127.0.0.1:9418", "main", "") + + +def test_push_valid_address_and_token_does_not_raise(): + client = make_client() + client.push("127.0.0.1:9418", "main", "sometoken")