gabriel / muse public

test_status_rename_detection.py file-level

at sha256:2 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:4 Merge branch 'dev' into main · gabriel · Jun 17, 2026
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"] == {}