gabriel / muse public
test_status_rename_detection.py python
295 lines 11.1 KB
Raw
sha256:f6cd81bc71702f5c1c6890bd39aaba994fe58c75f019d7c03934724fa2739bb4 fix: carry dev changes harmony dropped in merge — detached … Sonnet 4.6 minor ⚠ breaking 16 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 2 commits
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 20 days ago