diff --git a/minigit/index.py b/minigit/index.py index 3566ca2..8ecdcd5 100644 --- a/minigit/index.py +++ b/minigit/index.py @@ -91,17 +91,84 @@ def stage_file(self, path) -> None: self.write_index(entries) def build_tree_from_index(self) -> str: - return self.store.write_object(b"", "tree") + from minigit.objects import TreeEntry + + entries = self.read_index() + + root: dict = {} + for entry in entries: + parts = entry.path.split("/") + current = root + for part in parts[:-1]: + current = current.setdefault(part, {}) + current[parts[-1]] = entry + + def write_dir(node: dict) -> str: + tree_entries = [] + for name in sorted(node.keys()): + value = node[name] + if isinstance(value, dict): + subtree_hash = write_dir(value) + tree_entries.append( + TreeEntry(mode="40000", type="tree", hash=subtree_hash, name=name) + ) + else: + tree_entries.append( + TreeEntry(mode=value.mode, type="blob", hash=value.hash, name=name) + ) + return self.store.write_tree(tree_entries) + + return write_dir(root) + + def read_tree_entries(self, tree_hash: str) -> list[IndexEntry]: + entries = [] + + def walk(hash_, prefix): + for te in self.store.read_tree(hash_): + path = f"{prefix}/{te.name}" if prefix else te.name + if te.type == "tree": + walk(te.hash, path) + else: + entries.append(IndexEntry(te.mode, te.hash, path)) + + walk(tree_hash, "") + return sorted(entries, key=lambda e: e.path) def diff_working_tree_vs(self, tree_hash) -> DiffResult: - return DiffResult([], [], []) + result = DiffResult([], [], []) + tree_entries = self.read_tree_entries(tree_hash) + known_paths = {e.path for e in tree_entries} + + for entry in tree_entries: + full_path = os.path.join(self.root, entry.path) + if not os.path.isfile(full_path): + result.deleted.append(entry.path) + else: + with open(full_path, "rb") as f: + data = f.read() + current_hash = self.store.hash_object(data, "blob") + current_mode = "100755" if os.access(full_path, os.X_OK) else "100644" + if current_hash != entry.hash or current_mode != entry.mode: + result.modified.append(entry.path) + + for dirpath, dirnames, filenames in os.walk(self.root): + dirnames[:] = [d for d in dirnames if d != ".minigit"] + for filename in filenames: + full_path = os.path.join(dirpath, filename) + rel_path = os.path.relpath(full_path, self.root).replace(os.sep, "/") + if rel_path not in known_paths: + result.added.append(rel_path) + + result.added.sort() + result.deleted.sort() + result.modified.sort() + return result def _working_status(self) -> DiffResult: result = DiffResult([], [], []) entries = self.read_index() known_paths = {e.path for e in entries} - # check staged entries against disk for entry in entries: full_path = f"{self.root}/{entry.path}" if not os.path.isfile(full_path): @@ -109,11 +176,11 @@ def _working_status(self) -> DiffResult: else: with open(full_path, "rb") as f: data = f.read() - current_hash = self.store.write_object(data, "blob") - if current_hash != entry.hash: + current_hash = self.store.hash_object(data, "blob") + current_mode = "100755" if os.access(full_path, os.X_OK) else "100644" + if current_hash != entry.hash or current_mode != entry.mode: result.modified.append(entry.path) - # walk the working tree for untracked files for dirpath, dirnames, filenames in os.walk(self.root): dirnames[:] = [d for d in dirnames if d != ".minigit"] for filename in filenames: @@ -136,14 +203,53 @@ def cmd_add(args) -> int: def cmd_status(args) -> int: wt = WorkingTree() - result = wt._working_status() - entries = wt.read_index() - - # build each category once, upfront - changed_paths = result.modified + result.deleted - staged = [e.path for e in entries if e.path not in changed_paths] - not_staged = sorted(changed_paths) - untracked = sorted(result.added) + index_entries = wt.read_index() + index_by_path = {e.path: e for e in index_entries} + + from minigit.commits import CommitManager + + head_tree_hash = CommitManager(wt.root, store=wt.store, tree=wt).get_head_tree() + head_entries = wt.read_tree_entries(head_tree_hash) if head_tree_hash else [] + head_by_path = {e.path: e for e in head_entries} + + # staged: HEAD tree vs index + staged_paths = set() + for path, entry in index_by_path.items(): + head_entry = head_by_path.get(path) + if head_entry is None or head_entry.hash != entry.hash or head_entry.mode != entry.mode: + staged_paths.add(path) + for path in head_by_path: + if path not in index_by_path: + staged_paths.add(path) + + # not staged: index vs disk + not_staged_paths = set() + for entry in index_entries: + full_path = os.path.join(wt.root, entry.path) + if not os.path.isfile(full_path): + not_staged_paths.add(entry.path) + else: + with open(full_path, "rb") as f: + data = f.read() + current_hash = wt.store.hash_object(data, "blob") + current_mode = "100755" if os.access(full_path, os.X_OK) else "100644" + if current_hash != entry.hash or current_mode != entry.mode: + not_staged_paths.add(entry.path) + + # untracked: on disk, absent from the index + known_paths = set(index_by_path) + untracked_paths = set() + for dirpath, dirnames, filenames in os.walk(wt.root): + dirnames[:] = [d for d in dirnames if d != ".minigit"] + for filename in filenames: + full_path = os.path.join(dirpath, filename) + rel_path = os.path.relpath(full_path, wt.root).replace(os.sep, "/") + if rel_path not in known_paths: + untracked_paths.add(rel_path) + + staged = sorted(staged_paths) + not_staged = sorted(not_staged_paths) + untracked = sorted(untracked_paths) printed_anything = False diff --git a/tests/test_index.py b/tests/test_index.py index f2c4e18..8e0df5d 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -1,125 +1,261 @@ -"""Tests for minigit/index.py - WorkingTree, IndexEntry, DiffResult.""" +"""Tests for minigit/index.py - WorkingTree, trees, diffing, and status.""" -from minigit.index import WorkingTree +from minigit.commits import CommitManager +from minigit.index import WorkingTree, cmd_status from minigit.objects import ObjectStore -def test_index_works_new_workingtree(tmp_path): - (tmp_path / "hello.txt").write_text("hi") +def test_build_tree_empty_index(tmp_path): store = ObjectStore(str(tmp_path)) + wt = WorkingTree(repo_path=str(tmp_path), store=store) - wt1 = WorkingTree(repo_path=str(tmp_path), store=store) - wt1.stage_file("hello.txt") + tree_hash = wt.build_tree_from_index() - # a brand new WorkingTree, simulating a separate CLI invocation - wt2 = WorkingTree(repo_path=str(tmp_path), store=store) - entries = wt2.read_index() + obj_type, data = store.read_object(tree_hash) + assert obj_type == "tree" + assert data == b"" - assert len(entries) == 1 - assert entries[0].path == "hello.txt" +def test_build_tree_nested_directories(tmp_path): + (tmp_path / "a.txt").write_text("hello") + (tmp_path / "src").mkdir() + (tmp_path / "src" / "b.txt").write_text("world") -def test_staging_twice(tmp_path): - (tmp_path / "hello.txt").write_text("hi") store = ObjectStore(str(tmp_path)) wt = WorkingTree(repo_path=str(tmp_path), store=store) + wt.stage_file("a.txt") + wt.stage_file("src/b.txt") - wt.stage_file("hello.txt") - wt.stage_file("hello.txt") + tree_hash = wt.build_tree_from_index() + entries = wt.read_tree_entries(tree_hash) - entries = wt.read_index() - assert len(entries) == 1 + assert len(entries) == 2 + paths = [e.path for e in entries] + assert "a.txt" in paths + assert "src/b.txt" in paths -def test_staging_equivalent_paths_updates_one_entry(tmp_path): - file = tmp_path / "hello.txt" - file.write_text("original") - wt = WorkingTree(repo_path=str(tmp_path)) - wt.stage_file("hello.txt") +def test_build_tree_deterministic(tmp_path): + (tmp_path / "a.txt").write_text("hello") + store = ObjectStore(str(tmp_path)) + wt = WorkingTree(repo_path=str(tmp_path), store=store) + wt.stage_file("a.txt") + + hash1 = wt.build_tree_from_index() + hash2 = wt.build_tree_from_index() - file.write_text("updated") - wt.stage_file("./hello.txt") + assert hash1 == hash2 + + +def test_build_tree_path_with_space(tmp_path): + (tmp_path / "my notes.txt").write_text("hi") + store = ObjectStore(str(tmp_path)) + wt = WorkingTree(repo_path=str(tmp_path), store=store) + wt.stage_file("my notes.txt") + + tree_hash = wt.build_tree_from_index() + entries = wt.read_tree_entries(tree_hash) - entries = wt.read_index() assert len(entries) == 1 - assert entries[0].path == "hello.txt" - assert wt.store.read_object(entries[0].hash) == ("blob", b"updated") - status = wt._working_status() - assert status.added == [] - assert status.modified == [] + assert entries[0].path == "my notes.txt" -def test_index_file(tmp_path): - (tmp_path / "z.txt").write_text("z") +def test_build_tree_executable_mode_preserved(tmp_path): + file = tmp_path / "run.sh" + file.write_text("#!/bin/sh\necho hi") + file.chmod(0o755) + store = ObjectStore(str(tmp_path)) wt = WorkingTree(repo_path=str(tmp_path), store=store) + wt.stage_file("run.sh") - wt.stage_file("z.txt") + tree_hash = wt.build_tree_from_index() + entries = wt.read_tree_entries(tree_hash) + + assert len(entries) == 1 + assert entries[0].mode == "100755" - index_path = tmp_path / ".minigit" / "index" - lines = index_path.read_text(encoding="utf-8").splitlines() - assert len(lines) == 1 +def test_read_tree_entries_sorted(tmp_path): + (tmp_path / "z.txt").write_text("z") (tmp_path / "a.txt").write_text("a") + + store = ObjectStore(str(tmp_path)) + wt = WorkingTree(repo_path=str(tmp_path), store=store) + wt.stage_file("z.txt") wt.stage_file("a.txt") - lines = index_path.read_text(encoding="utf-8").splitlines() - assert len(lines) == 2 - assert lines[0].endswith("a.txt") - assert lines[1].endswith("z.txt") + tree_hash = wt.build_tree_from_index() + entries = wt.read_tree_entries(tree_hash) + assert entries[0].path == "a.txt" + assert entries[1].path == "z.txt" + + +def test_diff_detects_modified_after_staging(tmp_path): + file = tmp_path / "hello.txt" + file.write_text("original") -def test_path_with_space(tmp_path): - (tmp_path / "my notes.txt").write_text("hello") store = ObjectStore(str(tmp_path)) wt = WorkingTree(repo_path=str(tmp_path), store=store) + wt.stage_file("hello.txt") - wt.stage_file("my notes.txt") + tree_hash = wt.build_tree_from_index() - entries = wt.read_index() - assert len(entries) == 1 - assert entries[0].path == "my notes.txt" + file.write_text("changed!!!") + diff = wt.diff_working_tree_vs(tree_hash) + assert "hello.txt" in diff.modified + assert "hello.txt" not in diff.added + assert "hello.txt" not in diff.deleted -def test_edit_after_staging(tmp_path): + +def test_diff_detects_deleted(tmp_path): file = tmp_path / "hello.txt" - file.write_text("original") + file.write_text("hi") + store = ObjectStore(str(tmp_path)) wt = WorkingTree(repo_path=str(tmp_path), store=store) + wt.stage_file("hello.txt") + tree_hash = wt.build_tree_from_index() + file.unlink() + + diff = wt.diff_working_tree_vs(tree_hash) + assert "hello.txt" in diff.deleted + + +def test_diff_detects_added(tmp_path): + (tmp_path / "hello.txt").write_text("hi") + + store = ObjectStore(str(tmp_path)) + wt = WorkingTree(repo_path=str(tmp_path), store=store) wt.stage_file("hello.txt") - hash_before = wt.read_index()[0].hash + tree_hash = wt.build_tree_from_index() - file.write_text("changed!!!") + (tmp_path / "extra.txt").write_text("new file, never staged") - hash_after = wt.read_index()[0].hash - assert hash_before == hash_after # snapshot rule still holds + diff = wt.diff_working_tree_vs(tree_hash) + assert "extra.txt" in diff.added + assert "hello.txt" not in diff.added - status = wt._working_status() - assert "hello.txt" in status.modified +def test_diff_excludes_minigit_folder(tmp_path): + (tmp_path / "hello.txt").write_text("hi") -def test_untracked(tmp_path): + store = ObjectStore(str(tmp_path)) + wt = WorkingTree(repo_path=str(tmp_path), store=store) + wt.stage_file("hello.txt") + tree_hash = wt.build_tree_from_index() + + diff = wt.diff_working_tree_vs(tree_hash) + assert not any(p.startswith(".minigit") for p in diff.added) + + +def test_diff_no_changes_reports_nothing(tmp_path): (tmp_path / "hello.txt").write_text("hi") - (tmp_path / "extra.txt").write_text("not staged at all") + store = ObjectStore(str(tmp_path)) wt = WorkingTree(repo_path=str(tmp_path), store=store) + wt.stage_file("hello.txt") + tree_hash = wt.build_tree_from_index() + + diff = wt.diff_working_tree_vs(tree_hash) + assert diff.added == [] + assert diff.deleted == [] + assert diff.modified == [] + + +# --- cmd_status against real commit objects --- + + +class _Args: + """Minimal stand-in for argparse's Namespace, since cmd_status(args) + doesn't actually read any attributes off args.""" + +def test_status_clean_after_commit(tmp_path, monkeypatch, capsys): + (tmp_path / "hello.txt").write_text("hi") + + store = ObjectStore(str(tmp_path)) + wt = WorkingTree(repo_path=str(tmp_path), store=store) wt.stage_file("hello.txt") + tree_hash = wt.build_tree_from_index() + + monkeypatch.chdir(tmp_path) + CommitManager(tmp_path).create_commit(tree_hash, [], "Test", "initial") - status = wt._working_status() - assert "extra.txt" in status.added - assert "hello.txt" not in status.added + cmd_status(_Args()) + output = capsys.readouterr().out + assert output.strip() == "clean" -def test_deleted(tmp_path): + +def test_status_staged_and_unstaged_on_one_file(tmp_path, monkeypatch, capsys): file = tmp_path / "hello.txt" - file.write_text("hi") + file.write_text("v1") + store = ObjectStore(str(tmp_path)) wt = WorkingTree(repo_path=str(tmp_path), store=store) + wt.stage_file("hello.txt") + committed_tree_hash = wt.build_tree_from_index() # simulates "last commit" + # stage a change (now differs from the committed tree -> staged) + file.write_text("v2") + wt.stage_file("hello.txt") + + # edit again without re-staging (now differs from index -> not staged) + file.write_text("v3") + + monkeypatch.chdir(tmp_path) + CommitManager(tmp_path).create_commit(committed_tree_hash, [], "Test", "initial") + + cmd_status(_Args()) + + output = capsys.readouterr().out + assert "staged:" in output + assert "not staged:" in output + assert "hello.txt" in output + + +def test_status_unborn_head_shows_all_staged(tmp_path, monkeypatch, capsys): + (tmp_path / "hello.txt").write_text("hi") + + store = ObjectStore(str(tmp_path)) + wt = WorkingTree(repo_path=str(tmp_path), store=store) wt.stage_file("hello.txt") - file.unlink() - status = wt._working_status() - assert "hello.txt" in status.deleted + monkeypatch.chdir(tmp_path) + + cmd_status(_Args()) + + output = capsys.readouterr().out + assert "staged:" in output + assert "hello.txt" in output + + +def test_status_and_diff_do_not_store_unstaged_content(tmp_path, monkeypatch, capsys): + file = tmp_path / "file" + file.write_text("committed") + wt = WorkingTree(tmp_path) + wt.stage_file("file") + tree = wt.build_tree_from_index() + CommitManager(tmp_path).create_commit(tree, [], "Test", "initial") + before = set(wt.store.objects_dir.rglob("*")) + file.write_text("unstaged") + monkeypatch.chdir(tmp_path) + cmd_status(_Args()) + assert capsys.readouterr().out == "not staged:\n file\n" + assert wt.diff_working_tree_vs(tree).modified == ["file"] + assert set(wt.store.objects_dir.rglob("*")) == before + + +def test_status_reports_file_removed_from_index_as_untracked(tmp_path, monkeypatch, capsys): + (tmp_path / "file").write_text("content") + wt = WorkingTree(tmp_path) + wt.stage_file("file") + CommitManager(tmp_path).create_commit(wt.build_tree_from_index(), [], "Test", "initial") + wt.write_index([]) + monkeypatch.chdir(tmp_path) + cmd_status(_Args()) + assert capsys.readouterr().out == "staged:\n file\nuntracked:\n file\n" diff --git a/tests/test_index_persistence.py b/tests/test_index_persistence.py new file mode 100644 index 0000000..f2c4e18 --- /dev/null +++ b/tests/test_index_persistence.py @@ -0,0 +1,125 @@ +"""Tests for minigit/index.py - WorkingTree, IndexEntry, DiffResult.""" + +from minigit.index import WorkingTree +from minigit.objects import ObjectStore + + +def test_index_works_new_workingtree(tmp_path): + (tmp_path / "hello.txt").write_text("hi") + store = ObjectStore(str(tmp_path)) + + wt1 = WorkingTree(repo_path=str(tmp_path), store=store) + wt1.stage_file("hello.txt") + + # a brand new WorkingTree, simulating a separate CLI invocation + wt2 = WorkingTree(repo_path=str(tmp_path), store=store) + entries = wt2.read_index() + + assert len(entries) == 1 + assert entries[0].path == "hello.txt" + + +def test_staging_twice(tmp_path): + (tmp_path / "hello.txt").write_text("hi") + store = ObjectStore(str(tmp_path)) + wt = WorkingTree(repo_path=str(tmp_path), store=store) + + wt.stage_file("hello.txt") + wt.stage_file("hello.txt") + + entries = wt.read_index() + assert len(entries) == 1 + + +def test_staging_equivalent_paths_updates_one_entry(tmp_path): + file = tmp_path / "hello.txt" + file.write_text("original") + wt = WorkingTree(repo_path=str(tmp_path)) + wt.stage_file("hello.txt") + + file.write_text("updated") + wt.stage_file("./hello.txt") + + entries = wt.read_index() + assert len(entries) == 1 + assert entries[0].path == "hello.txt" + assert wt.store.read_object(entries[0].hash) == ("blob", b"updated") + status = wt._working_status() + assert status.added == [] + assert status.modified == [] + + +def test_index_file(tmp_path): + (tmp_path / "z.txt").write_text("z") + store = ObjectStore(str(tmp_path)) + wt = WorkingTree(repo_path=str(tmp_path), store=store) + + wt.stage_file("z.txt") + + index_path = tmp_path / ".minigit" / "index" + lines = index_path.read_text(encoding="utf-8").splitlines() + assert len(lines) == 1 + + (tmp_path / "a.txt").write_text("a") + wt.stage_file("a.txt") + + lines = index_path.read_text(encoding="utf-8").splitlines() + assert len(lines) == 2 + assert lines[0].endswith("a.txt") + assert lines[1].endswith("z.txt") + + +def test_path_with_space(tmp_path): + (tmp_path / "my notes.txt").write_text("hello") + store = ObjectStore(str(tmp_path)) + wt = WorkingTree(repo_path=str(tmp_path), store=store) + + wt.stage_file("my notes.txt") + + entries = wt.read_index() + assert len(entries) == 1 + assert entries[0].path == "my notes.txt" + + +def test_edit_after_staging(tmp_path): + file = tmp_path / "hello.txt" + file.write_text("original") + store = ObjectStore(str(tmp_path)) + wt = WorkingTree(repo_path=str(tmp_path), store=store) + + wt.stage_file("hello.txt") + hash_before = wt.read_index()[0].hash + + file.write_text("changed!!!") + + hash_after = wt.read_index()[0].hash + assert hash_before == hash_after # snapshot rule still holds + + status = wt._working_status() + assert "hello.txt" in status.modified + + +def test_untracked(tmp_path): + (tmp_path / "hello.txt").write_text("hi") + (tmp_path / "extra.txt").write_text("not staged at all") + store = ObjectStore(str(tmp_path)) + wt = WorkingTree(repo_path=str(tmp_path), store=store) + + wt.stage_file("hello.txt") + + status = wt._working_status() + assert "extra.txt" in status.added + assert "hello.txt" not in status.added + + +def test_deleted(tmp_path): + file = tmp_path / "hello.txt" + file.write_text("hi") + store = ObjectStore(str(tmp_path)) + wt = WorkingTree(repo_path=str(tmp_path), store=store) + + wt.stage_file("hello.txt") + file.unlink() + + status = wt._working_status() + assert "hello.txt" in status.deleted