test_status_rename_detection.py
python
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b
fix: try fetch/presign before fetch/mpack to avoid Cloudfla…
Sonnet 4.6
patch
6 days ago
| 1 | """Working-tree rename detection in ``muse status``. |
| 2 | |
| 3 | When a tracked file is deleted from disk and an untracked file appears with |
| 4 | the same content_id, ``muse status`` must classify it as a rename rather than |
| 5 | a delete + untracked. |
| 6 | |
| 7 | Coverage matrix |
| 8 | --------------- |
| 9 | R Working-tree rename detection |
| 10 | R1 mv tracked.py → new.py appears in renamed, not in deleted or untracked |
| 11 | R2 renamed shows up in the flat renamed dict (old → new) |
| 12 | R3 untracked list does not include the rename target |
| 13 | R4 deleted list does not include the rename source |
| 14 | R5 total_changes reflects the rename (counts as 1, not 0) |
| 15 | R6 dirty=True after a rename |
| 16 | R7 Different content — stays deleted + untracked (not a rename) |
| 17 | R8 Rename + simultaneous modification of another file — both show correctly |
| 18 | R9 One source, two same-content targets — first match wins (greedy) |
| 19 | R10 staged delete then mv on disk — staged delete is not confused for rename |
| 20 | """ |
| 21 | |
| 22 | from __future__ import annotations |
| 23 | |
| 24 | import json |
| 25 | import pathlib |
| 26 | from collections.abc import Mapping |
| 27 | |
| 28 | import pytest |
| 29 | |
| 30 | from tests.cli_test_helper import CliRunner |
| 31 | |
| 32 | cli = None |
| 33 | runner = CliRunner() |
| 34 | |
| 35 | |
| 36 | # --------------------------------------------------------------------------- |
| 37 | # Helpers |
| 38 | # --------------------------------------------------------------------------- |
| 39 | |
| 40 | |
| 41 | def _env(root: pathlib.Path) -> Mapping[str, str]: |
| 42 | return {"MUSE_REPO_ROOT": str(root)} |
| 43 | |
| 44 | |
| 45 | def _status_json(root: pathlib.Path) -> Mapping[str, object]: |
| 46 | result = runner.invoke(cli, ["status", "--json"], env=_env(root)) |
| 47 | assert result.exit_code == 0, f"status --json failed: {result.output}" |
| 48 | return json.loads(result.output.strip()) |
| 49 | |
| 50 | |
| 51 | @pytest.fixture() |
| 52 | def code_repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: |
| 53 | """Code-domain repo with one committed file ``foo.txt``.""" |
| 54 | monkeypatch.chdir(tmp_path) |
| 55 | result = runner.invoke(cli, ["init", "--domain", "code"], env=_env(tmp_path)) |
| 56 | assert result.exit_code == 0, result.output |
| 57 | (tmp_path / "foo.txt").write_text("hello world\n") |
| 58 | runner.invoke(cli, ["code", "add", "."], env=_env(tmp_path)) |
| 59 | result = runner.invoke(cli, ["commit", "-m", "initial"], env=_env(tmp_path)) |
| 60 | assert result.exit_code == 0, result.output |
| 61 | return tmp_path |
| 62 | |
| 63 | |
| 64 | # --------------------------------------------------------------------------- |
| 65 | # R Working-tree rename detection |
| 66 | # --------------------------------------------------------------------------- |
| 67 | |
| 68 | |
| 69 | class TestWorkingTreeRenameDetection: |
| 70 | def test_R1_rename_appears_in_renamed_not_deleted_or_untracked( |
| 71 | self, code_repo: pathlib.Path |
| 72 | ) -> None: |
| 73 | """R1: mv foo.txt foo.md → renamed field populated, not deleted/untracked.""" |
| 74 | root = code_repo |
| 75 | (root / "foo.txt").rename(root / "foo.md") |
| 76 | |
| 77 | data = _status_json(root) |
| 78 | |
| 79 | assert "foo.txt" in data["renamed"], "source not in renamed" |
| 80 | assert data["renamed"]["foo.txt"] == "foo.md", "wrong target in renamed" |
| 81 | assert "foo.txt" not in data["deleted"], "source should not appear in deleted" |
| 82 | assert "foo.md" not in data["untracked"], "target should not appear in untracked" |
| 83 | |
| 84 | def test_R2_renamed_flat_dict_maps_old_to_new( |
| 85 | self, code_repo: pathlib.Path |
| 86 | ) -> None: |
| 87 | """R2: renamed is a dict mapping old path → new path.""" |
| 88 | root = code_repo |
| 89 | (root / "foo.txt").rename(root / "foo.md") |
| 90 | |
| 91 | data = _status_json(root) |
| 92 | |
| 93 | assert isinstance(data["renamed"], dict) |
| 94 | assert data["renamed"] == {"foo.txt": "foo.md"} |
| 95 | |
| 96 | def test_R3_untracked_excludes_rename_target( |
| 97 | self, code_repo: pathlib.Path |
| 98 | ) -> None: |
| 99 | """R3: untracked list does not contain the rename target.""" |
| 100 | root = code_repo |
| 101 | (root / "foo.txt").rename(root / "foo.md") |
| 102 | |
| 103 | data = _status_json(root) |
| 104 | |
| 105 | assert "foo.md" not in data["untracked"] |
| 106 | |
| 107 | def test_R4_deleted_excludes_rename_source( |
| 108 | self, code_repo: pathlib.Path |
| 109 | ) -> None: |
| 110 | """R4: deleted list does not contain the rename source.""" |
| 111 | root = code_repo |
| 112 | (root / "foo.txt").rename(root / "foo.md") |
| 113 | |
| 114 | data = _status_json(root) |
| 115 | |
| 116 | assert "foo.txt" not in data["deleted"] |
| 117 | |
| 118 | def test_R5_total_changes_counts_rename_as_one( |
| 119 | self, code_repo: pathlib.Path |
| 120 | ) -> None: |
| 121 | """R5: total_changes == 1 after a pure rename (one file moved).""" |
| 122 | root = code_repo |
| 123 | (root / "foo.txt").rename(root / "foo.md") |
| 124 | |
| 125 | data = _status_json(root) |
| 126 | |
| 127 | # A rename counts once in total_changes (same as added/modified/deleted). |
| 128 | expected = ( |
| 129 | len(data["added"]) |
| 130 | + len(data["modified"]) |
| 131 | + len(data["deleted"]) |
| 132 | + len(data["renamed"]) |
| 133 | ) |
| 134 | assert data["total_changes"] == expected |
| 135 | assert data["total_changes"] == 1 |
| 136 | |
| 137 | def test_R6_dirty_true_after_rename(self, code_repo: pathlib.Path) -> None: |
| 138 | """R6: dirty=True after a working-tree rename.""" |
| 139 | root = code_repo |
| 140 | (root / "foo.txt").rename(root / "foo.md") |
| 141 | |
| 142 | data = _status_json(root) |
| 143 | |
| 144 | assert data["dirty"] is True |
| 145 | assert data["clean"] is False |
| 146 | |
| 147 | def test_R7_different_content_stays_deleted_and_untracked( |
| 148 | self, code_repo: pathlib.Path |
| 149 | ) -> None: |
| 150 | """R7: Different content — not a rename; shows as deleted + untracked.""" |
| 151 | root = code_repo |
| 152 | # Delete the original |
| 153 | (root / "foo.txt").unlink() |
| 154 | # Create an untracked file with *different* content |
| 155 | (root / "bar.txt").write_text("completely different content\n") |
| 156 | |
| 157 | data = _status_json(root) |
| 158 | |
| 159 | assert "foo.txt" in data["deleted"], "original should still be in deleted" |
| 160 | assert "bar.txt" in data["untracked"], "different-content file should be untracked" |
| 161 | assert data["renamed"] == {}, "no rename should be detected" |
| 162 | |
| 163 | def test_R8_rename_plus_other_modification(self, code_repo: pathlib.Path) -> None: |
| 164 | """R8: Rename + simultaneous modification of another tracked file.""" |
| 165 | root = code_repo |
| 166 | # Add a second tracked file |
| 167 | (root / "other.py").write_text("y = 2\n") |
| 168 | runner.invoke(cli, ["code", "add", "."], env=_env(root)) |
| 169 | runner.invoke(cli, ["commit", "-m", "add other.py"], env=_env(root)) |
| 170 | |
| 171 | # Rename foo.txt and modify other.py |
| 172 | (root / "foo.txt").rename(root / "foo.md") |
| 173 | (root / "other.py").write_text("y = 999\n") |
| 174 | |
| 175 | data = _status_json(root) |
| 176 | |
| 177 | assert data["renamed"] == {"foo.txt": "foo.md"} |
| 178 | assert "other.py" in data["modified"] |
| 179 | assert "foo.txt" not in data["deleted"] |
| 180 | assert "foo.md" not in data["untracked"] |
| 181 | |
| 182 | def test_R9_one_source_two_identical_targets_first_match_wins( |
| 183 | self, code_repo: pathlib.Path |
| 184 | ) -> None: |
| 185 | """R9: Two untracked files with identical content to the deleted source — greedy first match.""" |
| 186 | root = code_repo |
| 187 | content = (root / "foo.txt").read_text() |
| 188 | # Delete the original |
| 189 | (root / "foo.txt").unlink() |
| 190 | # Create two untracked files with the same content (sorted: aaa.txt < zzz.txt) |
| 191 | (root / "aaa.txt").write_text(content) |
| 192 | (root / "zzz.txt").write_text(content) |
| 193 | |
| 194 | data = _status_json(root) |
| 195 | |
| 196 | # Exactly one rename — the first alphabetically wins |
| 197 | assert len(data["renamed"]) == 1 |
| 198 | assert "foo.txt" in data["renamed"] |
| 199 | matched_target = data["renamed"]["foo.txt"] |
| 200 | # The unmatched duplicate stays in untracked |
| 201 | remaining = {"aaa.txt", "zzz.txt"} - {matched_target} |
| 202 | assert len(remaining) == 1 |
| 203 | assert list(remaining)[0] in data["untracked"] |
| 204 | |
| 205 | def test_R10_staged_delete_does_not_trigger_rename( |
| 206 | self, code_repo: pathlib.Path |
| 207 | ) -> None: |
| 208 | """R10: Staged deletion + same-content untracked file is not a rename. |
| 209 | |
| 210 | Only unstaged deletes (working-tree disappearances of committed files) |
| 211 | trigger rename detection. A file explicitly staged for deletion |
| 212 | represents a deliberate user action — it should stay staged/deleted. |
| 213 | """ |
| 214 | root = code_repo |
| 215 | content = (root / "foo.txt").read_text() |
| 216 | # Stage the deletion explicitly |
| 217 | runner.invoke(cli, ["rm", "foo.txt"], env=_env(root)) |
| 218 | # Create an untracked file with the same content |
| 219 | (root / "foo.md").write_text(content) |
| 220 | |
| 221 | data = _status_json(root) |
| 222 | |
| 223 | # Staged bucket should show the deletion |
| 224 | assert "foo.txt" in data["staged"]["deleted"] |
| 225 | # foo.md should remain untracked (user staged the delete deliberately) |
| 226 | assert "foo.md" in data["untracked"] |
| 227 | # No rename should be inferred |
| 228 | assert data["renamed"] == {} |
| 229 | |
| 230 | |
| 231 | # --------------------------------------------------------------------------- |
| 232 | # RS renamed key in staged / unstaged sub-objects |
| 233 | # |
| 234 | # Working-tree renames are by definition unstaged — you cannot stage a rename |
| 235 | # directly, only its component operations (rm old + add new). The unstaged |
| 236 | # sub-object must expose a renamed key that mirrors the top-level renamed dict. |
| 237 | # staged.renamed is always {} because staged renames don't exist as a concept. |
| 238 | # |
| 239 | # RS1 working-tree rename → appears in unstaged.renamed |
| 240 | # RS2 top-level renamed == unstaged.renamed (mirror invariant) |
| 241 | # RS3 staged.renamed is always {} (renames cannot be staged) |
| 242 | # RS4 clean repo — unstaged.renamed == {} and staged.renamed == {} |
| 243 | # --------------------------------------------------------------------------- |
| 244 | |
| 245 | |
| 246 | class TestRenamedInStagedUnstaged: |
| 247 | def test_RS1_rename_in_unstaged_renamed( |
| 248 | self, code_repo: pathlib.Path |
| 249 | ) -> None: |
| 250 | """RS1: working-tree rename appears in unstaged.renamed.""" |
| 251 | root = code_repo |
| 252 | (root / "foo.txt").rename(root / "foo.md") |
| 253 | |
| 254 | data = _status_json(root) |
| 255 | |
| 256 | assert "renamed" in data["unstaged"], "unstaged must have a 'renamed' key" |
| 257 | assert data["unstaged"]["renamed"].get("foo.txt") == "foo.md", ( |
| 258 | "rename must appear in unstaged.renamed as {old: new}" |
| 259 | ) |
| 260 | |
| 261 | def test_RS2_top_level_renamed_mirrors_unstaged_renamed( |
| 262 | self, code_repo: pathlib.Path |
| 263 | ) -> None: |
| 264 | """RS2: top-level renamed == unstaged.renamed (mirror invariant).""" |
| 265 | root = code_repo |
| 266 | (root / "foo.txt").rename(root / "foo.md") |
| 267 | |
| 268 | data = _status_json(root) |
| 269 | |
| 270 | assert data["renamed"] == data["unstaged"]["renamed"], ( |
| 271 | "top-level renamed and unstaged.renamed must be identical" |
| 272 | ) |
| 273 | |
| 274 | def test_RS3_staged_renamed_always_empty( |
| 275 | self, code_repo: pathlib.Path |
| 276 | ) -> None: |
| 277 | """RS3: staged.renamed is always {} — renames cannot be staged.""" |
| 278 | root = code_repo |
| 279 | (root / "foo.txt").rename(root / "foo.md") |
| 280 | |
| 281 | data = _status_json(root) |
| 282 | |
| 283 | assert "renamed" in data["staged"], "staged must have a 'renamed' key" |
| 284 | assert data["staged"]["renamed"] == {}, ( |
| 285 | "staged.renamed must always be empty — renames are always unstaged" |
| 286 | ) |
| 287 | |
| 288 | def test_RS4_clean_repo_renamed_empty_in_both_buckets( |
| 289 | self, code_repo: pathlib.Path |
| 290 | ) -> None: |
| 291 | """RS4: clean repo — unstaged.renamed == {} and staged.renamed == {}.""" |
| 292 | data = _status_json(code_repo) |
| 293 | |
| 294 | assert data["unstaged"]["renamed"] == {} |
| 295 | assert data["staged"]["renamed"] == {} |
File History
1 commit
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b
fix: try fetch/presign before fetch/mpack to avoid Cloudfla…
Sonnet 4.6
patch
6 days ago