diff --git a/minigit/commits.py b/minigit/commits.py index 74c6203..2479882 100644 --- a/minigit/commits.py +++ b/minigit/commits.py @@ -12,7 +12,7 @@ import os import time -from minigit.errors import RefNotFoundError +from minigit.errors import MiniGitError, RefExistsError, RefNotFoundError from minigit.index import WorkingTree from minigit.objects import ObjectStore @@ -20,31 +20,52 @@ def _cmd_commit(args) -> int: """Handle the 'minigit commit -m ' command""" manager = CommitManager() - stub_tree = "0" * 40 - manager.create_commit( - stub_tree, - [], - "Daniel ", + tree_hash = manager.tree.build_tree_from_index() + branch = manager._current_branch() + last_commit = manager.read_ref(branch) + parents = [last_commit] if last_commit else [] + author = manager.read_author() + commit_hash = manager.create_commit( + tree_hash, + parents, + author, args.message, ) + print(f"[{branch} {commit_hash[:7]}] {args.message}") return 0 def _cmd_branch(args) -> int: """Handle 'minigit branch ' command""" manager = CommitManager() + current = manager._current_branch() if args.name: - manager.create_branch(args.name, "0" * 40) + commit_hash = manager.read_ref(current) + if commit_hash is None: + raise MiniGitError(f"branch '{args.name}': '{current}' has no commits yet") + manager.create_branch(args.name, commit_hash) else: for branch in manager.list_branches(): - if branch == manager._head: + if branch == current: marker = "* " else: - marker = " " + marker = " " print(f"{marker}{branch}") return 0 +def _cmd_log(args) -> int: + """Handle the log command""" + m = CommitManager() + lines = m.log() + if not lines: + print("no commits yet") + return 0 + for line in lines: + print(line) + return 0 + + def _cmd_checkout(args) -> int: """ Handle 'minigit checkout command' @@ -71,12 +92,13 @@ def register_subcommands(subparsers) -> None: checkout_parser.add_argument("name", help="branch name to switch to") checkout_parser.set_defaults(handler=_cmd_checkout) + log_parser = subparsers.add_parser("log", help="show commit history") + log_parser.set_defaults(handler=_cmd_log) + 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) @@ -93,25 +115,128 @@ def _format_commit(self, tree_hash, parents, author, message) -> str: lines.append(message) return "\n".join(lines) - def create_commit(self, tree_hash, parents: list[str], author, message) -> str: + def _config_path(self) -> str: + """Return path of the repo-local config file""" + return os.path.join(self.root, ".minigit", "config") + + def read_author(self) -> str: + """Return the configured author string, or a default if no config exists. + + The config file, if present, contains the author string on its own + (e.g. "Daniel "), stripped of surrounding whitespace. + """ + config_path = self._config_path() + if not os.path.exists(config_path): + return "minigit " + with open(config_path) as f: + author = f.read().strip() + return author or "minigit " + + def _refs_dir(self) -> str: + """Return the dir that holds one file per branch""" + return os.path.join(self.root, ".minigit", "refs", "heads") + + def read_ref(self, name: str) -> str | None: + """Return the commit hash a branch points at""" + ref_path = self._ref_path(name) + if not os.path.exists(ref_path): + return None + with open(ref_path) as f: + return f.read().strip() + + def write_ref(self, name: str, commit_hash: str) -> None: + """Point a branch's ref file at the given commit hash, creating refs/heads/ if needed""" + os.makedirs(self._refs_dir(), exist_ok=True) + with open(self._ref_path(name), "w") as f: + f.write(f"{commit_hash}\n") + + def _ref_path(self, name: str) -> str: + """Return the file path for a single branch's ref""" + return os.path.join(self._refs_dir(), name) + + def _head_path(self) -> str: + """Return path of file that is the current branch""" + return os.path.join(self.root, ".minigit", "HEAD") + + def read_head(self) -> str | None: + """Return the current branch name + Returns None if HEAD not written yet (before the first commit/checkout). """ - Create a new commit object and write it to the object store + if not os.path.exists(self._head_path()): + return None + with open(self._head_path()) as f: + content = f.read().strip() + if content.startswith("ref: refs/heads/"): + return content[len("ref: refs/heads/") :] + return content # detached HEAD: a raw commit hash + + def write_head(self, name: str) -> None: + """Point HEAD at the given branch.""" + with open(self._head_path(), "w") as f: + f.write(f"ref: refs/heads/{name}\n") + + def _current_branch(self) -> str: + """Return the name of the branch HEAD currently points at.""" + return self.read_head() or "main" + + def create_commit(self, tree_hash, parents, author, message) -> str: """ + Create a new commit object, write it to the object store, and advance + the current branch's ref to point at the new commit. + + The caller is responsible for resolving `parents` (e.g. via `read_ref` + on the current branch, or `[]` for a root commit). + """ + branch = self._current_branch() body = self._format_commit(tree_hash, parents, author, message) - return self.store.write_object(body.encode(), "commit") + commit_hash = self.store.write_object(body.encode(), "commit") + self.write_ref(branch, commit_hash) + return commit_hash def create_branch(self, name, commit_hash) -> None: """Create a new branch that points at commit_hash""" - self._refs[name] = commit_hash + if os.path.exists(self._ref_path(name)): + raise RefExistsError(name) + self.write_ref(name, commit_hash) def switch_branch(self, name) -> None: """Switch to a branch""" - if name not in self._refs: + # 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) - self._head = name + else: + self.write_head(name) def list_branches(self) -> list[str]: - return sorted(self._refs) + if not os.path.isdir(self._refs_dir()): + return [] + else: + return sorted(os.listdir(self._refs_dir())) def merge(self, branch_name) -> str | None: + # Week 4 fast-forward / Week 5 three-way return None + + def log(self) -> list[str]: + """Return one summary line per commit reachable from HEAD, newest first""" + + branch = self._current_branch() + commit_hash = self.read_ref(branch) + if not commit_hash: + return [] + + lines = [] + while commit_hash: + _, data = self.store.read_object(commit_hash) + body = data.decode() + message = body.split("\n\n", 1)[1].splitlines()[0] + lines.append(f"{commit_hash[:7]} {message}") + + parent_hash = None + for line in body.splitlines(): + if line.startswith("parent "): + parent_hash = line.split(" ", 1)[1] + break + commit_hash = parent_hash + + return lines diff --git a/minigit/errors.py b/minigit/errors.py index bb29083..3ffd5e6 100644 --- a/minigit/errors.py +++ b/minigit/errors.py @@ -22,6 +22,10 @@ class RefNotFoundError(MiniGitError): """A ref (branch, HEAD target, tag) does not exist.""" +class RefExistsError(MiniGitError): + """A branch or ref with this name already exists.""" + + class MergeConflictError(MiniGitError): """A merge could not complete automatically. diff --git a/tests/test_commits.py b/tests/test_commits.py index 6b54f11..0a59d66 100644 --- a/tests/test_commits.py +++ b/tests/test_commits.py @@ -4,71 +4,63 @@ 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 +from minigit.errors import RefExistsError, RefNotFoundError +from minigit.objects import ObjectStore class FakeWorkingTree: """Minimal stand-in for WorkingTree. No filesystem operations.""" def checkout(self, tree_hash: str) -> None: - """Accept a checkout call without doing anything.""" + """checkout call""" -def make_manager(): +def make_manager(temp_path): """Return CommitManager for testing""" - return CommitManager(store=FakeObjectStore(), tree=FakeWorkingTree()) + return CommitManager( + repo_path=str(temp_path), store=ObjectStore(temp_path), tree=FakeWorkingTree() + ) # testing commits -def test_contains_tree_line(): - m = make_manager() +def test_contains_tree_line(tmp_path): + m = make_manager(tmp_path) body = m._format_commit("abc" * 13 + "a", [], "Daniel ", "init") assert body.startswith("tree ") -def test_contains_author_line(): - m = make_manager() +def test_contains_author_line(tmp_path): + m = make_manager(tmp_path) 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() +def test_blank_line_before_message(tmp_path): + m = make_manager(tmp_path) 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() +def test_root_commit_no_parent_lines(tmp_path): + m = make_manager(tmp_path) body = m._format_commit("a" * 40, [], "Daniel ", "root") assert "parent" not in body -def test_normal_commit_one_parent_line(): - m = make_manager() +def test_normal_commit_one_parent_line(tmp_path): + m = make_manager(tmp_path) 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() +def test_merge_commit_two_parent_lines_in_order(tmp_path): + m = make_manager(tmp_path) p1 = "1" * 40 p2 = "2" * 40 body = m._format_commit("0" * 40, [p1, p2], "Daniel ", "merge") @@ -78,30 +70,94 @@ def test_merge_commit_two_parent_lines_in_order(): assert p2 in parent_lines[1] -def test_create_commit_returns_hash(): - m = make_manager() +# testing create_commit + refs +def test_create_commit_returns_hash(tmp_path): + m = make_manager(tmp_path) result = m.create_commit("0" * 40, [], "Daniel ", "init") assert len(result) == 40 -# branches test: +def test_first_commit_has_no_parent_lines(tmp_path): + m = make_manager(tmp_path) + commit_hash = m.create_commit("a" * 40, [], "Daniel ", "init") + _, body = m.store.read_object(commit_hash) + assert "parent" not in body.decode() + + +def test_first_commit_creates_ref_file(tmp_path): + m = make_manager(tmp_path) + m.create_commit("a" * 40, [], "Daniel ", "init") + ref_file = tmp_path / ".minigit" / "refs" / "heads" / "main" + assert ref_file.exists() + + +def test_second_commit_has_one_parent_line_pointing_at_first(tmp_path): + m = make_manager(tmp_path) + first = m.create_commit("a" * 40, [], "Daniel ", "init") + second = m.create_commit("b" * 40, [first], "Daniel ", "second") + _, body = m.store.read_object(second) + parent_lines = [line for line in body.decode().splitlines() if line.startswith("parent ")] + assert len(parent_lines) == 1 + assert first in parent_lines[0] + + +def test_second_commit_moves_the_ref_file(tmp_path): + m = make_manager(tmp_path) + first = m.create_commit("a" * 40, [], "Daniel ", "init") + second = m.create_commit("b" * 40, [first], "Daniel ", "second") + ref_file = tmp_path / ".minigit" / "refs" / "heads" / "main" + assert ref_file.read_text() == second + "\n" + assert ref_file.read_text() != first -def test_create_and_list_branches(): - m = make_manager() +# testing branches +def test_create_and_list_branches(tmp_path): + m = make_manager(tmp_path) 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() +def test_create_branch_twice_raises(tmp_path): + m = make_manager(tmp_path) + m.create_branch("feature", "a" * 40) + with pytest.raises(RefExistsError): + m.create_branch("feature", "b" * 40) + + +def test_switch_branch_unknown_raises(tmp_path): + m = make_manager(tmp_path) with pytest.raises(RefNotFoundError): m.switch_branch("nope") -def test_switch_branch_updates_head(): - m = make_manager() +def test_switch_branch_updates_current_branch(tmp_path): + m = make_manager(tmp_path) m.create_branch("feature", "a" * 40) m.switch_branch("feature") - assert m._head == "feature" + assert m._current_branch() == "feature" + + +def test_branches_point_at_different_hashes_after_switch(tmp_path): + m = make_manager(tmp_path) + first = m.create_commit("a" * 40, [], "Daniel ", "on main") + m.create_branch("feature", first) + m.switch_branch("feature") + second = m.create_commit("b" * 40, [first], "Daniel ", "on feature") + m.switch_branch("main") + main_ref = tmp_path / ".minigit" / "refs" / "heads" / "main" + feature_ref = tmp_path / ".minigit" / "refs" / "heads" / "feature" + assert main_ref.read_text() == first + "\n" + assert feature_ref.read_text() == second + "\n" + assert main_ref.read_text() != feature_ref.read_text() + + +# testing log +def test_log_has_one_line_per_commit_newest_first(tmp_path): + m = make_manager(tmp_path) + first = m.create_commit("a" * 40, [], "Daniel ", "first") + m.create_commit("b" * 40, [first], "Daniel ", "second") + lines = m.log() + assert len(lines) == 2 + assert "second" in lines[0] + assert "first" in lines[1]