"""Working-tree rename detection in ``muse status``. When a tracked file is deleted from disk and an untracked file appears with the same content_id, ``muse status`` must classify it as a rename rather than a delete + untracked. Coverage matrix --------------- R Working-tree rename detection R1 mv tracked.py → new.py appears in renamed, not in deleted or untracked R2 renamed shows up in the flat renamed dict (old → new) R3 untracked list does not include the rename target R4 deleted list does not include the rename source R5 total_changes reflects the rename (counts as 1, not 0) R6 dirty=True after a rename R7 Different content — stays deleted + untracked (not a rename) R8 Rename + simultaneous modification of another file — both show correctly R9 One source, two same-content targets — first match wins (greedy) R10 staged delete then mv on disk — staged delete is not confused for rename """ from __future__ import annotations import json import pathlib from collections.abc import Mapping import pytest from tests.cli_test_helper import CliRunner cli = None runner = CliRunner() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _env(root: pathlib.Path) -> Mapping[str, str]: return {"MUSE_REPO_ROOT": str(root)} def _status_json(root: pathlib.Path) -> Mapping[str, object]: result = runner.invoke(cli, ["status", "--json"], env=_env(root)) assert result.exit_code == 0, f"status --json failed: {result.output}" return json.loads(result.output.strip()) @pytest.fixture() def code_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: """Code-domain repo with one committed file ``foo.txt``.""" monkeypatch.chdir(tmp_path) result = runner.invoke(cli, ["init", "--domain", "code"], env=_env(tmp_path)) assert result.exit_code == 0, result.output (tmp_path / "foo.txt").write_text("hello world\n") runner.invoke(cli, ["code", "add", "."], env=_env(tmp_path)) result = runner.invoke(cli, ["commit", "-m", "initial"], env=_env(tmp_path)) assert result.exit_code == 0, result.output return tmp_path # --------------------------------------------------------------------------- # R Working-tree rename detection # --------------------------------------------------------------------------- class TestWorkingTreeRenameDetection: def test_R1_rename_appears_in_renamed_not_deleted_or_untracked( self, code_repo: pathlib.Path ) -> None: """R1: mv foo.txt foo.md → renamed field populated, not deleted/untracked.""" root = code_repo (root / "foo.txt").rename(root / "foo.md") data = _status_json(root) assert "foo.txt" in data["renamed"], "source not in renamed" assert data["renamed"]["foo.txt"] == "foo.md", "wrong target in renamed" assert "foo.txt" not in data["deleted"], "source should not appear in deleted" assert "foo.md" not in data["untracked"], "target should not appear in untracked" def test_R2_renamed_flat_dict_maps_old_to_new( self, code_repo: pathlib.Path ) -> None: """R2: renamed is a dict mapping old path → new path.""" root = code_repo (root / "foo.txt").rename(root / "foo.md") data = _status_json(root) assert isinstance(data["renamed"], dict) assert data["renamed"] == {"foo.txt": "foo.md"} def test_R3_untracked_excludes_rename_target( self, code_repo: pathlib.Path ) -> None: """R3: untracked list does not contain the rename target.""" root = code_repo (root / "foo.txt").rename(root / "foo.md") data = _status_json(root) assert "foo.md" not in data["untracked"] def test_R4_deleted_excludes_rename_source( self, code_repo: pathlib.Path ) -> None: """R4: deleted list does not contain the rename source.""" root = code_repo (root / "foo.txt").rename(root / "foo.md") data = _status_json(root) assert "foo.txt" not in data["deleted"] def test_R5_total_changes_counts_rename_as_one( self, code_repo: pathlib.Path ) -> None: """R5: total_changes == 1 after a pure rename (one file moved).""" root = code_repo (root / "foo.txt").rename(root / "foo.md") data = _status_json(root) # A rename counts once in total_changes (same as added/modified/deleted). expected = ( len(data["added"]) + len(data["modified"]) + len(data["deleted"]) + len(data["renamed"]) ) assert data["total_changes"] == expected assert data["total_changes"] == 1 def test_R6_dirty_true_after_rename(self, code_repo: pathlib.Path) -> None: """R6: dirty=True after a working-tree rename.""" root = code_repo (root / "foo.txt").rename(root / "foo.md") data = _status_json(root) assert data["dirty"] is True assert data["clean"] is False def test_R7_different_content_stays_deleted_and_untracked( self, code_repo: pathlib.Path ) -> None: """R7: Different content — not a rename; shows as deleted + untracked.""" root = code_repo # Delete the original (root / "foo.txt").unlink() # Create an untracked file with *different* content (root / "bar.txt").write_text("completely different content\n") data = _status_json(root) assert "foo.txt" in data["deleted"], "original should still be in deleted" assert "bar.txt" in data["untracked"], "different-content file should be untracked" assert data["renamed"] == {}, "no rename should be detected" def test_R8_rename_plus_other_modification(self, code_repo: pathlib.Path) -> None: """R8: Rename + simultaneous modification of another tracked file.""" root = code_repo # Add a second tracked file (root / "other.py").write_text("y = 2\n") runner.invoke(cli, ["code", "add", "."], env=_env(root)) runner.invoke(cli, ["commit", "-m", "add other.py"], env=_env(root)) # Rename foo.txt and modify other.py (root / "foo.txt").rename(root / "foo.md") (root / "other.py").write_text("y = 999\n") data = _status_json(root) assert data["renamed"] == {"foo.txt": "foo.md"} assert "other.py" in data["modified"] assert "foo.txt" not in data["deleted"] assert "foo.md" not in data["untracked"] def test_R9_one_source_two_identical_targets_first_match_wins( self, code_repo: pathlib.Path ) -> None: """R9: Two untracked files with identical content to the deleted source — greedy first match.""" root = code_repo content = (root / "foo.txt").read_text() # Delete the original (root / "foo.txt").unlink() # Create two untracked files with the same content (sorted: aaa.txt < zzz.txt) (root / "aaa.txt").write_text(content) (root / "zzz.txt").write_text(content) data = _status_json(root) # Exactly one rename — the first alphabetically wins assert len(data["renamed"]) == 1 assert "foo.txt" in data["renamed"] matched_target = data["renamed"]["foo.txt"] # The unmatched duplicate stays in untracked remaining = {"aaa.txt", "zzz.txt"} - {matched_target} assert len(remaining) == 1 assert list(remaining)[0] in data["untracked"] def test_R10_staged_delete_does_not_trigger_rename( self, code_repo: pathlib.Path ) -> None: """R10: Staged deletion + same-content untracked file is not a rename. Only unstaged deletes (working-tree disappearances of committed files) trigger rename detection. A file explicitly staged for deletion represents a deliberate user action — it should stay staged/deleted. """ root = code_repo content = (root / "foo.txt").read_text() # Stage the deletion explicitly runner.invoke(cli, ["rm", "foo.txt"], env=_env(root)) # Create an untracked file with the same content (root / "foo.md").write_text(content) data = _status_json(root) # Staged bucket should show the deletion assert "foo.txt" in data["staged"]["deleted"] # foo.md should remain untracked (user staged the delete deliberately) assert "foo.md" in data["untracked"] # No rename should be inferred assert data["renamed"] == {} # --------------------------------------------------------------------------- # RS renamed key in staged / unstaged sub-objects # # Working-tree renames are by definition unstaged — you cannot stage a rename # directly, only its component operations (rm old + add new). The unstaged # sub-object must expose a renamed key that mirrors the top-level renamed dict. # staged.renamed is always {} because staged renames don't exist as a concept. # # RS1 working-tree rename → appears in unstaged.renamed # RS2 top-level renamed == unstaged.renamed (mirror invariant) # RS3 staged.renamed is always {} (renames cannot be staged) # RS4 clean repo — unstaged.renamed == {} and staged.renamed == {} # --------------------------------------------------------------------------- class TestRenamedInStagedUnstaged: def test_RS1_rename_in_unstaged_renamed( self, code_repo: pathlib.Path ) -> None: """RS1: working-tree rename appears in unstaged.renamed.""" root = code_repo (root / "foo.txt").rename(root / "foo.md") data = _status_json(root) assert "renamed" in data["unstaged"], "unstaged must have a 'renamed' key" assert data["unstaged"]["renamed"].get("foo.txt") == "foo.md", ( "rename must appear in unstaged.renamed as {old: new}" ) def test_RS2_top_level_renamed_mirrors_unstaged_renamed( self, code_repo: pathlib.Path ) -> None: """RS2: top-level renamed == unstaged.renamed (mirror invariant).""" root = code_repo (root / "foo.txt").rename(root / "foo.md") data = _status_json(root) assert data["renamed"] == data["unstaged"]["renamed"], ( "top-level renamed and unstaged.renamed must be identical" ) def test_RS3_staged_renamed_always_empty( self, code_repo: pathlib.Path ) -> None: """RS3: staged.renamed is always {} — renames cannot be staged.""" root = code_repo (root / "foo.txt").rename(root / "foo.md") data = _status_json(root) assert "renamed" in data["staged"], "staged must have a 'renamed' key" assert data["staged"]["renamed"] == {}, ( "staged.renamed must always be empty — renames are always unstaged" ) def test_RS4_clean_repo_renamed_empty_in_both_buckets( self, code_repo: pathlib.Path ) -> None: """RS4: clean repo — unstaged.renamed == {} and staged.renamed == {}.""" data = _status_json(code_repo) assert data["unstaged"]["renamed"] == {} assert data["staged"]["renamed"] == {}