diff --git a/minigit/commits.py b/minigit/commits.py index 13877c2..d060ef6 100644 --- a/minigit/commits.py +++ b/minigit/commits.py @@ -12,7 +12,12 @@ import os import time -from minigit.errors import MiniGitError, ObjectCorruptError, RefExistsError, RefNotFoundError +from minigit.errors import ( + MiniGitError, + ObjectCorruptError, + RefExistsError, + RefNotFoundError, +) from minigit.index import WorkingTree from minigit.objects import ObjectStore @@ -76,6 +81,13 @@ def _cmd_checkout(args) -> int: return 0 +def _cmd_merge(args) -> int: + manager = CommitManager() + manager.merge(args.branch) + print(f"Merged '{args.branch}' into '{manager._current_branch()}'") + return 0 + + def register_subcommands(subparsers) -> None: """ Register the three commands: commit, branch, checkout @@ -95,6 +107,10 @@ def register_subcommands(subparsers) -> None: log_parser = subparsers.add_parser("log", help="show commit history") log_parser.set_defaults(handler=_cmd_log) + merge_parser = subparsers.add_parser("merge", help="fast-forward the current branch") + merge_parser.add_argument("branch", help="branch name to merge in") + merge_parser.set_defaults(handler=_cmd_merge) + class CommitData: def __init__(self, tree: str, parents: list[str], author: str, committer: str, message: str): @@ -188,8 +204,32 @@ def _current_branch(self) -> str: """Return the name of the branch HEAD currently points at.""" return self.read_head() or "main" - def read_commit(self, commit_hash: str) -> CommitData: + def _empty_tree_hash(self) -> str: + """Return the hash of empty tree, used to stand in for an unborn HEAD""" + return self.store.hash_object(b"", "tree") + + def _require_no_staged_changes(self) -> None: + """Raise MiniGitError if the index differs from the tree HEAD points at. + + An unborn HEAD counts as an empty tree, so anything staged before the + first commit is a staged change + """ + head_tree = self.get_head_tree() or self._empty_tree_hash() + if self.tree.build_tree_from_index() != head_tree: + raise MiniGitError("staged changes present; commit them first") + + def switch_branch(self, name) -> None: + """ + Point HEAD at name and restore the working tree from that branch's commit + """ + if not os.path.exists(self._ref_path(name)): + raise RefNotFoundError(name) + self._require_no_staged_changes() + commit = self.read_commit(self.read_ref(name)) + self.tree.checkout(commit.tree) + self.write_head(name) + def read_commit(self, commit_hash: str) -> CommitData: obj_type, data = self.store.read_object(commit_hash) if obj_type != "commit": raise ObjectCorruptError(commit_hash) @@ -259,6 +299,10 @@ def walk_history(self, start_hash: str) -> list[str]: return visited + def is_ancestor(self, ancestor_hash: str, descendant_hash: str) -> bool: + """Return True if ancestor_hash is reachable from descendant_hash""" + return ancestor_hash in self.walk_history(descendant_hash) + def create_commit(self, tree_hash, parents, author, message) -> str: """ Create a new commit object, write it to the object store, and advance @@ -283,14 +327,6 @@ def create_branch(self, name, commit_hash) -> None: raise RefExistsError(name) self.write_ref(name, commit_hash) - def switch_branch(self, name) -> None: - """Switch to a branch""" - # Week 4 - also resolve ref -> commit -> tree and call self.tree.checkout(tree_hash) - if not os.path.exists(self._ref_path(name)): - raise RefNotFoundError(name) - else: - self.write_head(name) - def list_branches(self) -> list[str]: if not os.path.isdir(self._refs_dir()): return [] @@ -299,7 +335,29 @@ def list_branches(self) -> list[str]: def merge(self, branch_name) -> str | None: # Week 4 fast-forward / Week 5 three-way - return None + """Fast-forward the current branch to `branch_name` when history allows it + + Raises RefNotFoundError for an + unknown branch, and MiniGitError when the two histories have diverged. + """ + if not os.path.exists(self._ref_path(branch_name)): + raise RefNotFoundError(branch_name) + + target = self.read_ref(branch_name) + current = self._current_branch() + current_hash = self.read_ref(current) + + if current_hash is not None and ( + target == current_hash or self.is_ancestor(target, current_hash) + ): + return None + if current_hash is None or self.is_ancestor(current_hash, target): + self._require_no_staged_changes() + self.tree.checkout(self.read_commit(target).tree) + self.write_ref(current, target) + return None + + raise MiniGitError("three-way merge is not implemented yet") def log(self) -> list[str]: """Return one summary line per commit reachable from HEAD, newest first""" diff --git a/tests/test_commits.py b/tests/test_commits.py index a6dd70c..e6fef7a 100644 --- a/tests/test_commits.py +++ b/tests/test_commits.py @@ -1,10 +1,19 @@ # Run to test: scripts/test.sh tests/test_commits.py # Testing for Module 3 +from pathlib import Path + import pytest from minigit.commits import CommitManager -from minigit.errors import ObjectCorruptError, ObjectNotFoundError, RefExistsError, RefNotFoundError +from minigit.errors import ( + MiniGitError, + ObjectCorruptError, + ObjectNotFoundError, + RefExistsError, + RefNotFoundError, +) +from minigit.index import WorkingTree from minigit.objects import ObjectStore AUTHOR = "Daniel " @@ -13,15 +22,33 @@ class FakeWorkingTree: """Minimal stand-in for WorkingTree. No filesystem operations.""" + def __init__(self, store, index_tree=None, fail=False): + self.store = store + self.index_tree = index_tree + self.fail = fail + self.checked_out = [] + + def build_tree_from_index(self) -> str: + """ + Return the configured staged tree hash defaulting to empty + """ + if self.index_tree is None: + return self.store.hash_object(b"", "tree") + return self.index_tree + def checkout(self, tree_hash: str) -> None: - """checkout call""" + "record checkout" + if self.fail: + raise MiniGitError("checkout failed") + self.checked_out.append(tree_hash) -def make_manager(temp_path): - """Return CommitManager for testing""" - return CommitManager( - repo_path=str(temp_path), store=ObjectStore(temp_path), tree=FakeWorkingTree() - ) +def make_manager(temp_path, tree=None): + """Return CommitManager with real store and fake workign tree""" + store = ObjectStore(temp_path) + if tree is None: + tree = FakeWorkingTree(store) + return CommitManager(repo_path=str(temp_path), store=store, tree=tree) def make_tree(m) -> str: @@ -250,7 +277,8 @@ def test_switch_branch_unknown_raises(tmp_path): def test_switch_branch_updates_current_branch(tmp_path): m = make_manager(tmp_path) - m.create_branch("feature", "a" * 40) + commit = m.create_commit(make_tree(m), [], AUTHOR, "init") + m.create_branch("feature", commit) m.switch_branch("feature") assert m._current_branch() == "feature" @@ -318,3 +346,287 @@ def test_read_commit_rejects_invalid_headers(tmp_path, header): obj_hash = manager.store.write_object((header + "\n\nmessage").encode(), "commit") with pytest.raises(ObjectCorruptError): manager.read_commit(obj_hash) + + +# week 4 helpers + + +def count_object_files(temp_path) -> int: + """Return how many objects sit on disk, to prove no new commit was written.""" + objects_dir = temp_path / ".minigit" / "objects" + return len([p for p in objects_dir.rglob("*") if p.is_file()]) + + +def setup_fast_forward(m): + """Leave 'main' at commit A and 'feature' at its child B, with HEAD on main. + + Moves HEAD with write_head rather than switch_branch, so merge tests do not + depend on checkout behaviour while setting themselves up. + """ + tree = make_tree(m) + a = m.create_commit(tree, [], AUTHOR, "a") + m.create_branch("feature", a) + m.write_head("feature") + b = m.create_commit(tree, [a], AUTHOR, "b") + m.write_head("main") + return a, b + + +def make_real_manager(temp_path): + """Return a CommitManager wired to a real WorkingTree in an initialized repo.""" + metadata_dir = temp_path / ".minigit" + (metadata_dir / "objects").mkdir(parents=True) + (metadata_dir / "refs" / "heads").mkdir(parents=True) + (metadata_dir / "index").write_bytes(b"") + (metadata_dir / "HEAD").write_text("ref: refs/heads/main\n") + store = ObjectStore(temp_path) + return CommitManager( + repo_path=str(temp_path), store=store, tree=WorkingTree(str(temp_path), store=store) + ) + + +def commit_file(m, path, contents, message): + """Write `contents` to `path`, stage it, and commit on the current branch.""" + (Path(m.root) / path).write_text(contents) + m.tree.stage_file(path) + parent = m.read_ref(m._current_branch()) + parents = [parent] if parent else [] + return m.create_commit(m.tree.build_tree_from_index(), parents, AUTHOR, message) + + +# testing is_ancestor + + +def test_is_ancestor_of_itself(tmp_path): + """A commit counts as its own ancestor.""" + m = make_manager(tmp_path) + a = m.create_commit(make_tree(m), [], AUTHOR, "a") + assert m.is_ancestor(a, a) is True + + +def test_is_ancestor_linear_history(tmp_path): + """An older commit is an ancestor of a newer one, but not the reverse.""" + m = make_manager(tmp_path) + tree = make_tree(m) + a = m.create_commit(tree, [], AUTHOR, "a") + b = m.create_commit(tree, [a], AUTHOR, "b") + c = m.create_commit(tree, [b], AUTHOR, "c") + assert m.is_ancestor(a, c) is True + assert m.is_ancestor(c, a) is False + + +def test_is_ancestor_through_second_parent(tmp_path): + """Ancestry follows every parent, not just the first.""" + m = make_manager(tmp_path) + tree = make_tree(m) + base = m.create_commit(tree, [], AUTHOR, "base") + left = m.create_commit(tree, [base], AUTHOR, "left") + right = m.create_commit(tree, [base], AUTHOR, "right") + merge = m.create_commit(tree, [left, right], AUTHOR, "merge") + assert m.is_ancestor(right, merge) is True + assert m.is_ancestor(base, merge) is True + assert m.is_ancestor(merge, left) is False + + +def test_is_ancestor_unrelated_branches(tmp_path): + """Two root commits are not ancestors of each other.""" + m = make_manager(tmp_path) + tree = make_tree(m) + a = m.create_commit(tree, [], AUTHOR, "a") + b = m.create_commit(tree, [], AUTHOR, "b") + assert m.is_ancestor(a, b) is False + assert m.is_ancestor(b, a) is False + + +# testing switch_branch checkout + + +def test_switch_branch_checks_out_target_tree(tmp_path): + """Switching restores the target commit's tree and then moves HEAD.""" + m = make_manager(tmp_path) + tree = make_tree(m) + a = m.create_commit(tree, [], AUTHOR, "a") + m.create_branch("feature", a) + m.switch_branch("feature") + assert m.tree.checked_out == [tree] + assert m.read_head() == "feature" + + +def test_switch_branch_with_staged_changes_leaves_head_and_refs(tmp_path): + """Staged changes raise MiniGitError before anything is touched.""" + m = make_manager(tmp_path) + a = m.create_commit(make_tree(m), [], AUTHOR, "a") + m.create_branch("feature", a) + m.write_head("main") + m.tree.index_tree = m.store.hash_object(b"staged", "tree") + with pytest.raises(MiniGitError): + m.switch_branch("feature") + assert m.read_head() == "main" + assert m.read_ref("main") == a + assert m.read_ref("feature") == a + assert m.tree.checked_out == [] + + +def test_switch_branch_failed_checkout_leaves_head_and_refs(tmp_path): + """A checkout that raises leaves HEAD and every ref where they were.""" + m = make_manager(tmp_path) + a = m.create_commit(make_tree(m), [], AUTHOR, "a") + m.create_branch("feature", a) + m.write_head("main") + m.tree.fail = True + with pytest.raises(MiniGitError): + m.switch_branch("feature") + assert m.read_head() == "main" + assert m.read_ref("main") == a + assert m.read_ref("feature") == a + + +# testing merge fast-forward + + +def test_merge_missing_branch_raises(tmp_path): + """Merging an unknown branch raises RefNotFoundError.""" + m = make_manager(tmp_path) + m.create_commit(make_tree(m), [], AUTHOR, "a") + with pytest.raises(RefNotFoundError): + m.merge("nope") + + +def test_merge_fast_forward_moves_current_ref_without_a_commit(tmp_path): + """A fast-forward moves the current ref to the target and writes no commit.""" + m = make_manager(tmp_path) + a, b = setup_fast_forward(m) + objects_before = count_object_files(tmp_path) + assert m.merge("feature") is None + assert m.read_ref("main") == b + assert m.read_ref("feature") == b + assert count_object_files(tmp_path) == objects_before + assert m.read_ref("main") != a + + +def test_merge_fast_forward_checks_out_target_tree(tmp_path): + """A fast-forward restores the target commit's tree and keeps HEAD in place.""" + m = make_manager(tmp_path) + _, b = setup_fast_forward(m) + m.merge("feature") + assert m.tree.checked_out == [m.read_commit(b).tree] + assert m.read_head() == "main" + + +def test_merge_same_tip_changes_nothing(tmp_path): + """Merging a branch that points at the current tip is a no-op.""" + m = make_manager(tmp_path) + a = m.create_commit(make_tree(m), [], AUTHOR, "a") + m.create_branch("feature", a) + m.write_head("main") + assert m.merge("feature") is None + assert m.read_ref("main") == a + assert m.read_ref("feature") == a + assert m.read_head() == "main" + assert m.tree.checked_out == [] + + +def test_merge_already_merged_target_changes_nothing(tmp_path): + """Merging an ancestor of the current tip is a no-op.""" + m = make_manager(tmp_path) + tree = make_tree(m) + a = m.create_commit(tree, [], AUTHOR, "a") + m.create_branch("feature", a) + b = m.create_commit(tree, [a], AUTHOR, "b") + assert m.merge("feature") is None + assert m.read_ref("main") == b + assert m.read_ref("feature") == a + assert m.tree.checked_out == [] + + +def test_merge_into_unborn_branch_creates_the_ref(tmp_path): + """An unborn current branch fast-forwards to the target and keeps HEAD.""" + m = make_manager(tmp_path) + tree = make_tree(m) + a = m.create_commit(tree, [], AUTHOR, "a") + m.create_branch("feature", a) + (tmp_path / ".minigit" / "refs" / "heads" / "main").unlink() + assert m.merge("feature") is None + assert m.read_ref("main") == a + assert m._current_branch() == "main" + assert m.tree.checked_out == [tree] + + +def test_merge_diverged_history_raises_and_changes_nothing(tmp_path): + """Diverged histories raise MiniGitError and leave both refs and HEAD alone.""" + m = make_manager(tmp_path) + tree = make_tree(m) + a = m.create_commit(tree, [], AUTHOR, "a") + m.create_branch("feature", a) + m.write_head("feature") + b = m.create_commit(tree, [a], AUTHOR, "b") + m.write_head("main") + c = m.create_commit(tree, [a], AUTHOR, "c") + with pytest.raises(MiniGitError, match="three-way merge is not implemented yet"): + m.merge("feature") + assert m.read_ref("main") == c + assert m.read_ref("feature") == b + assert m.read_head() == "main" + assert m.tree.checked_out == [] + + +def test_merge_with_staged_changes_leaves_refs_unchanged(tmp_path): + """Staged changes block a fast-forward and leave the refs where they were.""" + m = make_manager(tmp_path) + a, b = setup_fast_forward(m) + m.tree.index_tree = m.store.hash_object(b"staged", "tree") + with pytest.raises(MiniGitError): + m.merge("feature") + assert m.read_ref("main") == a + assert m.read_ref("feature") == b + assert m.read_head() == "main" + assert m.tree.checked_out == [] + + +def test_merge_failed_checkout_leaves_refs_unchanged(tmp_path): + """A checkout that raises during a fast-forward leaves the current ref alone.""" + m = make_manager(tmp_path) + a, b = setup_fast_forward(m) + m.tree.fail = True + with pytest.raises(MiniGitError): + m.merge("feature") + assert m.read_ref("main") == a + assert m.read_ref("feature") == b + assert m.read_head() == "main" + + +# testing against the real working tree + + +def test_switch_between_real_snapshots(tmp_path): + """Switching between two committed snapshots restores files, index, and HEAD.""" + m = make_real_manager(tmp_path) + a = commit_file(m, "file.txt", "A\n", "A") + m.create_branch("feature", a) + m.switch_branch("feature") + b = commit_file(m, "file.txt", "B\n", "B") + m.switch_branch("main") + assert (tmp_path / "file.txt").read_text() == "A\n" + assert [e.path for e in m.tree.read_index()] == ["file.txt"] + assert m.tree.read_index()[0].hash == m.store.hash_object(b"A\n", "blob") + assert m.read_head() == "main" + assert m.read_ref("main") == a + assert m.read_ref("feature") == b + + +def test_real_fast_forward_restores_target_files(tmp_path): + """The team checkpoint: a fast-forward brings the files and both refs to B.""" + m = make_real_manager(tmp_path) + a = commit_file(m, "file.txt", "A\n", "A") + m.create_branch("feature", a) + m.switch_branch("feature") + b = commit_file(m, "file.txt", "B\n", "B") + m.switch_branch("main") + objects_before = count_object_files(tmp_path) + assert m.merge("feature") is None + assert (tmp_path / "file.txt").read_text() == "B\n" + assert m.read_ref("main") == b + assert m.read_ref("feature") == b + assert m.read_head() == "main" + assert count_object_files(tmp_path) == objects_before + assert a != b