gabriel / muse public
test_cmd_diff.py python
1,256 lines 53.4 KB
Raw
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor ⚠ breaking 149 days ago
1 """Comprehensive tests for ``muse diff``.
2
3 Coverage tiers
4 --------------
5 Unit — parser flags, _classify_patch_op, _op_category, _filter_manifest,
6 _use_color, dead-code removal.
7 Integration — HEAD vs working tree, staged, unstaged, two-commit diff,
8 path filtering, --stat, --text, added/deleted/modified counts.
9 End-to-end — CLI invocations: text and JSON output, --exit-code, --json.
10 Security — ANSI injection in paths, commit refs, format flag.
11 Stress — 500-file repos, many changes, concurrent reads.
12 """
13
14 from __future__ import annotations
15
16 import json
17 import os
18 import pathlib
19 import subprocess
20 import threading
21 import time
22 from typing import TYPE_CHECKING
23
24 import pytest
25
26 from tests.cli_test_helper import CliRunner, InvokeResult
27
28 if TYPE_CHECKING:
29 import argparse
30
31 from muse.domain import DeleteOp, DomainOp, InsertOp, MoveOp, PatchOp, ReplaceOp
32
33 runner = CliRunner()
34
35 # ──────────────────────────────────────────────────────────────────────────────
36 # Helpers
37 # ──────────────────────────────────────────────────────────────────────────────
38
39
40 def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult:
41 saved = os.getcwd()
42 try:
43 os.chdir(repo)
44 return runner.invoke(None, args)
45 finally:
46 os.chdir(saved)
47
48
49 def _diff(repo: pathlib.Path, *extra: str) -> InvokeResult:
50 return _invoke(repo, ["diff", *extra])
51
52
53 def _commit(repo: pathlib.Path, msg: str) -> InvokeResult:
54 return _invoke(repo, ["commit", "-m", msg])
55
56
57 @pytest.fixture()
58 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
59 """Initialised repo with one tracked file and one commit."""
60 saved = os.getcwd()
61 try:
62 os.chdir(tmp_path)
63 runner.invoke(None, ["init"])
64 finally:
65 os.chdir(saved)
66 (tmp_path / "a.py").write_text("x = 1\n")
67 _commit(tmp_path, "first")
68 return tmp_path
69
70
71 # ──────────────────────────────────────────────────────────────────────────────
72 # Unit — parser flags
73 # ──────────────────────────────────────────────────────────────────────────────
74
75
76 class TestRegisterFlags:
77 def _parse(self, *args: str) -> "argparse.Namespace":
78 import argparse
79
80 from muse.cli.commands.diff import register
81
82 p = argparse.ArgumentParser()
83 sub = p.add_subparsers()
84 register(sub)
85 return p.parse_args(["diff", *args])
86
87 def test_json_flag(self) -> None:
88 ns = self._parse("--json")
89 assert ns.fmt == "json"
90
91 def test_format_flag(self) -> None:
92 ns = self._parse("--format", "json")
93 assert ns.fmt == "json"
94
95 def test_exit_code_long_flag(self) -> None:
96 ns = self._parse("--exit-code")
97 assert ns.exit_code is True
98
99 def test_exit_code_short_flag(self) -> None:
100 ns = self._parse("-z")
101 assert ns.exit_code is True
102
103 def test_exit_code_default_false(self) -> None:
104 ns = self._parse()
105 assert ns.exit_code is False
106
107 def test_staged_flag(self) -> None:
108 ns = self._parse("--staged")
109 assert ns.staged is True
110
111 def test_unstaged_flag(self) -> None:
112 ns = self._parse("--unstaged")
113 assert ns.unstaged is True
114
115 def test_stat_flag(self) -> None:
116 ns = self._parse("--stat")
117 assert ns.stat is True
118
119 def test_text_flag(self) -> None:
120 ns = self._parse("--text")
121 assert ns.text is True
122
123 def test_path_flag(self) -> None:
124 ns = self._parse("-p", "foo.py")
125 assert "foo.py" in ns.paths
126
127 def test_path_flag_repeatable(self) -> None:
128 ns = self._parse("-p", "a.py", "-p", "b.py")
129 assert "a.py" in ns.paths and "b.py" in ns.paths
130
131
132 # ──────────────────────────────────────────────────────────────────────────────
133 # Unit — dead-code removal
134 # ──────────────────────────────────────────────────────────────────────────────
135
136
137 class TestDeadCodeRemoved:
138 def test_read_branch_removed(self) -> None:
139 import muse.cli.commands.diff as m
140
141 assert not hasattr(m, "_read_branch"), (
142 "_read_branch was a dead wrapper; it should have been deleted"
143 )
144
145
146 # ──────────────────────────────────────────────────────────────────────────────
147 # Unit — _filter_manifest
148 # ──────────────────────────────────────────────────────────────────────────────
149
150
151 class TestFilterManifest:
152 def test_empty_paths_returns_all(self) -> None:
153 from muse.cli.commands.diff import _filter_manifest
154
155 m = {"a.py": "oid1", "b.py": "oid2"}
156 assert _filter_manifest(m, []) == m
157
158 def test_exact_file_match(self) -> None:
159 from muse.cli.commands.diff import _filter_manifest
160
161 m = {"a.py": "oid1", "b.py": "oid2"}
162 result = _filter_manifest(m, ["a.py"])
163 assert result == {"a.py": "oid1"}
164
165 def test_directory_prefix_match(self) -> None:
166 from muse.cli.commands.diff import _filter_manifest
167
168 m = {
169 "src/foo.py": "oid1",
170 "src/bar.py": "oid2",
171 "tests/test_foo.py": "oid3",
172 }
173 result = _filter_manifest(m, ["src"])
174 assert set(result) == {"src/foo.py", "src/bar.py"}
175
176 def test_trailing_slash_normalised(self) -> None:
177 from muse.cli.commands.diff import _filter_manifest
178
179 m = {"src/foo.py": "oid1", "other.py": "oid2"}
180 assert _filter_manifest(m, ["src/"]) == {"src/foo.py": "oid1"}
181
182 def test_multiple_paths(self) -> None:
183 from muse.cli.commands.diff import _filter_manifest
184
185 m = {"a.py": "oid1", "b.py": "oid2", "c.py": "oid3"}
186 result = _filter_manifest(m, ["a.py", "c.py"])
187 assert set(result) == {"a.py", "c.py"}
188
189 def test_no_match_returns_empty(self) -> None:
190 from muse.cli.commands.diff import _filter_manifest
191
192 m = {"a.py": "oid1"}
193 assert _filter_manifest(m, ["z.py"]) == {}
194
195
196 # ──────────────────────────────────────────────────────────────────────────────
197 # Unit — _use_color
198 # ──────────────────────────────────────────────────────────────────────────────
199
200
201 class TestUseColor:
202 def test_no_color_env_disables_color(
203 self, monkeypatch: pytest.MonkeyPatch
204 ) -> None:
205 from muse.cli.commands.diff import _use_color
206
207 monkeypatch.setenv("NO_COLOR", "1")
208 assert _use_color() is False
209
210 def test_dumb_term_disables_color(
211 self, monkeypatch: pytest.MonkeyPatch
212 ) -> None:
213 from muse.cli.commands.diff import _use_color
214
215 monkeypatch.setenv("TERM", "dumb")
216 assert _use_color() is False
217
218 def test_no_color_env_unset_does_not_force_color(
219 self, monkeypatch: pytest.MonkeyPatch
220 ) -> None:
221 from muse.cli.commands.diff import _use_color
222
223 monkeypatch.delenv("NO_COLOR", raising=False)
224 monkeypatch.delenv("TERM", raising=False)
225 # stdout is not a TTY in test; just verify the function returns a bool
226 assert isinstance(_use_color(), bool)
227
228
229 # ──────────────────────────────────────────────────────────────────────────────
230 # Integration — HEAD vs working tree
231 # ──────────────────────────────────────────────────────────────────────────────
232
233
234 class TestHeadVsWorkingTree:
235 def test_clean_tree_exits_0(self, repo: pathlib.Path) -> None:
236 result = _diff(repo)
237 assert result.exit_code == 0
238 assert "No differences" in result.output
239
240 def test_modified_file_detected(self, repo: pathlib.Path) -> None:
241 (repo / "a.py").write_text("x = 99\n")
242 result = _diff(repo)
243 assert result.exit_code == 0
244 assert "a.py" in result.output
245
246 def test_added_file_detected(self, repo: pathlib.Path) -> None:
247 (repo / "new.py").write_text("z = 0\n")
248 result = _diff(repo)
249 assert "new.py" in result.output
250
251 def test_deleted_file_detected(self, repo: pathlib.Path) -> None:
252 (repo / "b.py").write_text("b = 1\n")
253 _commit(repo, "add b")
254 (repo / "b.py").unlink()
255 result = _diff(repo)
256 assert "b.py" in result.output
257
258
259 # ──────────────────────────────────────────────────────────────────────────────
260 # Integration — JSON schema (including critical bug fix)
261 # ──────────────────────────────────────────────────────────────────────────────
262
263
264 class TestJsonSchema:
265 """All keys agents depend on must be present."""
266
267 REQUIRED_KEYS = {
268 "from_ref",
269 "to_ref",
270 "from_commit_id",
271 "to_commit_id",
272 "has_changes",
273 "summary",
274 "added",
275 "deleted",
276 "modified",
277 "total_changes",
278 }
279
280 def test_clean_tree_json_keys(self, repo: pathlib.Path) -> None:
281 result = _diff(repo, "--json")
282 assert result.exit_code == 0
283 data = json.loads(result.output)
284 missing = self.REQUIRED_KEYS - set(data)
285 assert not missing, f"Missing keys: {missing}"
286
287 def test_has_changes_false_on_clean_tree(self, repo: pathlib.Path) -> None:
288 result = _diff(repo, "--json")
289 data = json.loads(result.output)
290 assert data["has_changes"] is False
291
292 def test_has_changes_true_on_modified(self, repo: pathlib.Path) -> None:
293 (repo / "a.py").write_text("x = 99\n")
294 result = _diff(repo, "--json")
295 data = json.loads(result.output)
296 assert data["has_changes"] is True
297
298 def test_from_commit_id_present(self, repo: pathlib.Path) -> None:
299 result = _diff(repo, "--json")
300 data = json.loads(result.output)
301 # from_commit_id should be the HEAD commit SHA
302 assert data["from_commit_id"] is not None
303 assert len(data["from_commit_id"]) == 64 # SHA-256
304
305 def test_to_commit_id_null_for_workdir_diff(self, repo: pathlib.Path) -> None:
306 result = _diff(repo, "--json")
307 data = json.loads(result.output)
308 assert data["to_commit_id"] is None
309
310 # ── Critical bug fix: deleted files must be in "deleted", not "modified" ──
311
312 def test_deleted_file_in_deleted_list_not_modified(self, repo: pathlib.Path) -> None:
313 """Regression test: files deleted from the working tree must appear in
314 ``deleted``, not ``modified``. The plugin emits a ``patch`` op with
315 all-delete child ops for file deletions; the JSON categorizer must
316 recognise this."""
317 (repo / "b.py").write_text("b = 2\n")
318 _commit(repo, "add b")
319 (repo / "b.py").unlink()
320 result = _diff(repo, "--json")
321 data = json.loads(result.output)
322 assert "b.py" in data["deleted"], f"b.py not in deleted: {data}"
323 assert "b.py" not in data["modified"], f"b.py wrongly in modified: {data}"
324
325 def test_added_file_in_added_list_not_modified(self, repo: pathlib.Path) -> None:
326 """New files must appear in ``added``, not ``modified``."""
327 (repo / "new.py").write_text("n = 1\n")
328 result = _diff(repo, "--json")
329 data = json.loads(result.output)
330 assert "new.py" in data["added"], f"new.py not in added: {data}"
331 assert "new.py" not in data["modified"], f"new.py wrongly in modified: {data}"
332
333 def test_modified_file_in_modified_list(self, repo: pathlib.Path) -> None:
334 (repo / "a.py").write_text("x = 999\n")
335 result = _diff(repo, "--json")
336 data = json.loads(result.output)
337 assert "a.py" in data["modified"]
338 assert "a.py" not in data["added"]
339 assert "a.py" not in data["deleted"]
340
341 def test_combined_add_delete_modify(self, repo: pathlib.Path) -> None:
342 """All three categories correct simultaneously."""
343 (repo / "b.py").write_text("b = 2\n")
344 (repo / "c.py").write_text("c = 3\n")
345 _commit(repo, "add b and c")
346 (repo / "b.py").unlink() # deleted
347 (repo / "c.py").write_text("c = 99\n") # modified
348 (repo / "d.py").write_text("d = 4\n") # added
349 result = _diff(repo, "--json")
350 data = json.loads(result.output)
351 assert "b.py" in data["deleted"]
352 assert "c.py" in data["modified"]
353 assert "d.py" in data["added"]
354
355 def test_added_list_is_sorted(self, repo: pathlib.Path) -> None:
356 for name in ["z.py", "a2.py", "m.py"]:
357 (repo / name).write_text(f"x=1\n")
358 result = _diff(repo, "--json")
359 data = json.loads(result.output)
360 assert data["added"] == sorted(data["added"])
361
362 def test_from_ref_is_head(self, repo: pathlib.Path) -> None:
363 result = _diff(repo, "--json")
364 data = json.loads(result.output)
365 assert data["from_ref"] == "HEAD"
366
367 def test_to_ref_is_working_tree(self, repo: pathlib.Path) -> None:
368 result = _diff(repo, "--json")
369 data = json.loads(result.output)
370 assert data["to_ref"] == "working tree"
371
372 def test_total_changes_matches_op_count(self, repo: pathlib.Path) -> None:
373 (repo / "a.py").write_text("x = 50\n")
374 (repo / "b.py").write_text("b = 1\n")
375 result = _diff(repo, "--json")
376 data = json.loads(result.output)
377 total = len(data["added"]) + len(data["deleted"]) + len(data["modified"])
378 # total_changes counts plugin ops, not files; it can exceed the file count
379 # if a file has multiple symbol ops, but it should be >= file count.
380 assert data["total_changes"] >= total
381
382 def test_two_commit_diff_has_commit_ids(self, repo: pathlib.Path) -> None:
383 from muse.core.store import get_head_commit_id
384
385 cid1 = get_head_commit_id(repo, "main")
386 (repo / "b.py").write_text("b = 1\n")
387 _commit(repo, "second")
388 cid2 = get_head_commit_id(repo, "main")
389 result = _diff(repo, cid1 or "", cid2 or "", "--json")
390 data = json.loads(result.output)
391 assert data["from_commit_id"] == cid1
392 assert data["to_commit_id"] == cid2
393
394
395 # ──────────────────────────────────────────────────────────────────────────────
396 # Integration — --exit-code
397 # ──────────────────────────────────────────────────────────────────────────────
398
399
400 class TestExitCode:
401 def test_exit_code_0_on_clean_tree(self, repo: pathlib.Path) -> None:
402 result = _diff(repo, "--exit-code")
403 assert result.exit_code == 0
404
405 def test_exit_code_1_when_changes(self, repo: pathlib.Path) -> None:
406 (repo / "a.py").write_text("x = 99\n")
407 result = _diff(repo, "--exit-code")
408 assert result.exit_code == 1
409
410 def test_exit_code_with_json_clean(self, repo: pathlib.Path) -> None:
411 result = _diff(repo, "--exit-code", "--json")
412 assert result.exit_code == 0
413 data = json.loads(result.output)
414 assert data["has_changes"] is False
415
416 def test_exit_code_with_json_dirty(self, repo: pathlib.Path) -> None:
417 (repo / "a.py").write_text("x = 99\n")
418 result = _diff(repo, "--exit-code", "--json")
419 assert result.exit_code == 1
420 data = json.loads(result.output)
421 assert data["has_changes"] is True
422
423 def test_exit_code_with_stat_clean(self, repo: pathlib.Path) -> None:
424 result = _diff(repo, "--exit-code", "--stat")
425 assert result.exit_code == 0
426
427 def test_exit_code_with_stat_dirty(self, repo: pathlib.Path) -> None:
428 (repo / "a.py").write_text("x = 99\n")
429 result = _diff(repo, "--exit-code", "--stat")
430 assert result.exit_code == 1
431
432 def test_exit_code_with_text_dirty(self, repo: pathlib.Path) -> None:
433 (repo / "a.py").write_text("x = 99\n")
434 result = _diff(repo, "--exit-code", "--text")
435 assert result.exit_code == 1
436
437 def test_exit_code_with_text_clean(self, repo: pathlib.Path) -> None:
438 result = _diff(repo, "--exit-code", "--text")
439 assert result.exit_code == 0
440
441
442 # ──────────────────────────────────────────────────────────────────────────────
443 # Integration — two-commit diff
444 # ──────────────────────────────────────────────────────────────────────────────
445
446
447 class TestTwoCommitDiff:
448 def test_two_commits_exits_0(self, repo: pathlib.Path) -> None:
449 from muse.core.store import get_head_commit_id
450
451 cid1 = get_head_commit_id(repo, "main")
452 (repo / "b.py").write_text("b=1\n")
453 _commit(repo, "second")
454 cid2 = get_head_commit_id(repo, "main")
455 result = _diff(repo, cid1 or "", cid2 or "")
456 assert result.exit_code == 0
457
458 def test_two_identical_commits_no_differences(self, repo: pathlib.Path) -> None:
459 from muse.core.store import get_head_commit_id
460
461 cid = get_head_commit_id(repo, "main")
462 result = _diff(repo, cid or "", cid or "")
463 assert "No differences" in result.output
464
465 def test_invalid_commit_ref_exits_1(self, repo: pathlib.Path) -> None:
466 result = _diff(repo, "deadbeefdeadbeef")
467 assert result.exit_code == 1
468
469
470 # ──────────────────────────────────────────────────────────────────────────────
471 # Integration — --stat
472 # ──────────────────────────────────────────────────────────────────────────────
473
474
475 class TestStat:
476 def test_stat_clean_tree(self, repo: pathlib.Path) -> None:
477 result = _diff(repo, "--stat")
478 assert result.exit_code == 0
479 assert "No differences" in result.output
480
481 def test_stat_shows_summary(self, repo: pathlib.Path) -> None:
482 (repo / "a.py").write_text("x = 50\n")
483 result = _diff(repo, "--stat")
484 assert result.exit_code == 0
485 # Should contain a human-readable summary (not empty)
486 assert result.output.strip() != ""
487 assert "No differences" not in result.output
488
489
490 # ──────────────────────────────────────────────────────────────────────────────
491 # Integration — --text (unified diff)
492 # ──────────────────────────────────────────────────────────────────────────────
493
494
495 class TestTextDiff:
496 def test_text_clean_tree(self, repo: pathlib.Path) -> None:
497 result = _diff(repo, "--text")
498 assert result.exit_code == 0
499 assert "No differences" in result.output
500
501 def test_text_modified_file_shows_diff(self, repo: pathlib.Path) -> None:
502 (repo / "a.py").write_text("x = 99\n")
503 result = _diff(repo, "--text")
504 assert "a.py" in result.output
505
506 def test_text_added_file_shown(self, repo: pathlib.Path) -> None:
507 (repo / "new.py").write_text("n = 1\n")
508 result = _diff(repo, "--text")
509 assert "new.py" in result.output
510
511 def test_text_deleted_file_shown(self, repo: pathlib.Path) -> None:
512 (repo / "b.py").write_text("b=1\n")
513 _commit(repo, "add b")
514 (repo / "b.py").unlink()
515 result = _diff(repo, "--text")
516 assert "b.py" in result.output
517
518
519 # ──────────────────────────────────────────────────────────────────────────────
520 # Integration — --path filter
521 # ──────────────────────────────────────────────────────────────────────────────
522
523
524 class TestPathFilter:
525 def test_path_filter_limits_output(self, repo: pathlib.Path) -> None:
526 (repo / "a.py").write_text("x = 99\n")
527 (repo / "b.py").write_text("b = 1\n")
528 result = _diff(repo, "--json", "-p", "a.py")
529 data = json.loads(result.output)
530 # Should show a.py changes, not b.py
531 all_paths = data["added"] + data["deleted"] + data["modified"]
532 assert all(p.startswith("a") for p in all_paths)
533
534 def test_directory_prefix_filter(self, repo: pathlib.Path) -> None:
535 (repo / "src").mkdir()
536 (repo / "src" / "foo.py").write_text("f = 1\n")
537 (repo / "other.py").write_text("o = 1\n")
538 result = _diff(repo, "--json", "-p", "src")
539 data = json.loads(result.output)
540 all_paths = data["added"] + data["deleted"] + data["modified"]
541 assert all(p.startswith("src") for p in all_paths)
542
543 def test_path_filter_with_nonexistent_path_returns_clean(
544 self, repo: pathlib.Path
545 ) -> None:
546 (repo / "a.py").write_text("x = 99\n")
547 result = _diff(repo, "--json", "-p", "nonexistent.py")
548 data = json.loads(result.output)
549 assert data["has_changes"] is False
550
551
552 # ──────────────────────────────────────────────────────────────────────────────
553 # Integration — validation
554 # ──────────────────────────────────────────────────────────────────────────────
555
556
557 class TestDiffShelf:
558 """muse diff --shelf shows the shelved changes vs HEAD."""
559
560 def test_shelf_flag_shows_shelved_changes(self, repo: pathlib.Path) -> None:
561 (repo / "a.py").write_text("x = 999\n")
562 _invoke(repo, ["shelf", "save", "-m", "test shelf"])
563 result = _diff(repo, "--shelf")
564 assert result.exit_code == 0
565 assert "a.py" in result.output
566
567 def test_shelf_flag_json_schema(self, repo: pathlib.Path) -> None:
568 (repo / "a.py").write_text("x = 999\n")
569 _invoke(repo, ["shelf", "save", "-m", "test shelf"])
570 result = _diff(repo, "--shelf", "--json")
571 assert result.exit_code == 0
572 data = json.loads(result.output)
573 assert "from_ref" in data
574 assert "to_ref" in data
575 assert data["from_ref"] == "HEAD"
576 assert "shelf" in data["to_ref"]
577
578 def test_shelf_flag_no_shelf_exits_1(self, repo: pathlib.Path) -> None:
579 result = _diff(repo, "--shelf")
580 assert result.exit_code == 1
581
582 def test_shelf_flag_with_index(self, repo: pathlib.Path) -> None:
583 # Create two shelf entries, diff the second (index 1).
584 (repo / "a.py").write_text("x = 10\n")
585 _invoke(repo, ["shelf", "save", "-m", "first"])
586 (repo / "a.py").write_text("x = 20\n")
587 _invoke(repo, ["shelf", "save", "-m", "second"])
588 # shelf/0 = second (newest), shelf/1 = first (oldest)
589 result = _diff(repo, "--shelf", "1")
590 assert result.exit_code == 0
591
592 def test_shelf_mutually_exclusive_with_staged(self, repo: pathlib.Path) -> None:
593 result = _diff(repo, "--shelf", "--staged")
594 assert result.exit_code == 1
595
596 def test_shelf_mutually_exclusive_with_unstaged(self, repo: pathlib.Path) -> None:
597 result = _diff(repo, "--shelf", "--unstaged")
598 assert result.exit_code == 1
599
600
601 class TestValidation:
602 def test_staged_and_unstaged_mutually_exclusive(self, repo: pathlib.Path) -> None:
603 result = _diff(repo, "--staged", "--unstaged")
604 assert result.exit_code == 1
605
606 def test_unknown_format_exits_1(self, repo: pathlib.Path) -> None:
607 result = _diff(repo, "--format", "xml")
608 assert result.exit_code == 1
609
610 def test_unknown_format_sanitized_in_error(self, repo: pathlib.Path) -> None:
611 result = _diff(repo, "--format", "\x1b[31mxml\x1b[0m")
612 assert "\x1b" not in result.output
613
614
615 # ──────────────────────────────────────────────────────────────────────────────
616 # Security — ANSI injection prevention
617 # ──────────────────────────────────────────────────────────────────────────────
618
619
620 class TestSecurityAnsi:
621 """Text output must never emit raw ANSI sequences from user-controlled input."""
622
623 def _has_ansi(self, s: str) -> bool:
624 return "\x1b[" in s or "\x1b]" in s
625
626 def test_ansi_in_format_flag_sanitized(self, repo: pathlib.Path) -> None:
627 result = _diff(repo, "--format", "\x1b[31mxml\x1b[0m")
628 assert not self._has_ansi(result.output)
629
630 def test_ansi_in_commit_ref_sanitized(self, repo: pathlib.Path) -> None:
631 """An ANSI escape in a commit ref must not leak into terminal output."""
632 evil = "\x1b[31mevil\x1b[0m"
633 result = _diff(repo, evil)
634 assert not self._has_ansi(result.output)
635
636 def test_ansi_in_path_filter_handled(self, repo: pathlib.Path) -> None:
637 """An ANSI escape in --path must not leak into output."""
638 result = _diff(repo, "--json", "-p", "\x1b[31mevil\x1b[0m")
639 assert not self._has_ansi(result.output)
640
641 def test_text_diff_path_headers_sanitized(self, repo: pathlib.Path) -> None:
642 """The a/path and b/path headers in unified diff must be sanitized."""
643 # We can't create files with ESC in names on most OS, so test via
644 # the sanitize_display path indirectly by verifying clean output
645 (repo / "normal.py").write_text("n = 1\n")
646 result = _diff(repo, "--text")
647 assert not self._has_ansi(result.output)
648
649
650 # ──────────────────────────────────────────────────────────────────────────────
651 # End-to-end — text output
652 # ──────────────────────────────────────────────────────────────────────────────
653
654
655 class TestTextOutput:
656 def test_no_differences_on_clean_tree(self, repo: pathlib.Path) -> None:
657 result = _diff(repo)
658 assert "No differences" in result.output
659
660 def test_summary_line_on_changes(self, repo: pathlib.Path) -> None:
661 (repo / "a.py").write_text("x = 50\n")
662 result = _diff(repo)
663 # Summary line should appear after the file listing
664 assert result.output.strip() != ""
665 assert "No differences" not in result.output
666
667 def test_deleted_file_shows_d_prefix(self, repo: pathlib.Path) -> None:
668 (repo / "b.py").write_text("b=1\n")
669 _commit(repo, "add b")
670 (repo / "b.py").unlink()
671 result = _diff(repo)
672 assert "b.py" in result.output
673 # Should show D (delete) status, not A or M
674 assert "D" in result.output or "removed" in result.output.lower()
675
676
677 # ──────────────────────────────────────────────────────────────────────────────
678 # Stress — large repos
679 # ──────────────────────────────────────────────────────────────────────────────
680
681
682 @pytest.mark.slow
683 class TestStressLargeRepo:
684 def test_diff_500_files_10_changes_under_1s(self, repo: pathlib.Path) -> None:
685 for i in range(500):
686 (repo / f"f{i:04d}.py").write_text(f"x = {i}\n")
687 _commit(repo, "base")
688 for i in range(10):
689 (repo / f"f{i:04d}.py").write_text(f"x = {i * 100}\n")
690 t0 = time.perf_counter()
691 result = _diff(repo, "--json")
692 elapsed = (time.perf_counter() - t0) * 1000
693 assert result.exit_code == 0
694 data = json.loads(result.output)
695 assert data["has_changes"] is True
696 assert elapsed < 1000, f"diff took {elapsed:.0f}ms (limit 1000ms)"
697
698 def test_diff_1000_added_files(self, repo: pathlib.Path) -> None:
699 _commit(repo, "base")
700 for i in range(1000):
701 (repo / f"g{i:04d}.py").write_text(f"y = {i}\n")
702 t0 = time.perf_counter()
703 result = _diff(repo, "--json")
704 elapsed = (time.perf_counter() - t0) * 1000
705 assert result.exit_code == 0
706 data = json.loads(result.output)
707 assert data["has_changes"] is True
708 assert elapsed < 3000, f"diff took {elapsed:.0f}ms (limit 3000ms)"
709
710 def test_diff_with_100_deleted_files_correct_categorization(
711 self, repo: pathlib.Path
712 ) -> None:
713 for i in range(100):
714 (repo / f"h{i:04d}.py").write_text(f"h = {i}\n")
715 _commit(repo, "base with 100 files")
716 for i in range(100):
717 (repo / f"h{i:04d}.py").unlink()
718 result = _diff(repo, "--json")
719 data = json.loads(result.output)
720 # All 100 must be in deleted, not modified
721 assert len(data["deleted"]) == 100
722 assert len(data["modified"]) == 0
723
724
725 @pytest.mark.slow
726 class TestStressConcurrent:
727 def test_concurrent_diffs_to_separate_repos(self, tmp_path: pathlib.Path) -> None:
728 errors: list[str] = []
729
730 def do_diff(idx: int) -> None:
731 repo_dir = tmp_path / f"repo_{idx}"
732 repo_dir.mkdir()
733 subprocess.run(
734 ["muse", "init"], cwd=str(repo_dir), capture_output=True
735 )
736 (repo_dir / "x.py").write_text(f"x = {idx}\n")
737 subprocess.run(
738 ["muse", "commit", "-m", "base"],
739 cwd=str(repo_dir), capture_output=True,
740 )
741 (repo_dir / "x.py").write_text(f"x = {idx + 100}\n")
742 r = subprocess.run(
743 ["muse", "diff", "--json"],
744 cwd=str(repo_dir), capture_output=True, text=True,
745 )
746 if r.returncode != 0:
747 errors.append(f"repo_{idx}: diff failed")
748 return
749 data = json.loads(r.stdout)
750 if not data.get("has_changes"):
751 errors.append(f"repo_{idx}: expected has_changes=true")
752
753 threads = [threading.Thread(target=do_diff, args=(i,)) for i in range(8)]
754 for t in threads:
755 t.start()
756 for t in threads:
757 t.join()
758
759
760 # ──────────────────────────────────────────────────────────────────────────────
761 # TestDiffConflict — muse diff --conflict (Cohen Transform labeled diff)
762 # ──────────────────────────────────────────────────────────────────────────────
763
764
765 def _make_conflict_repo(tmp_path: pathlib.Path) -> pathlib.Path:
766 """Return a repo on *main* with an in-progress conflicting checkout -m.
767
768 The repo has:
769 - ``shared.py`` on main: line1 / line2 / line3
770 - branch ``other``: line1 / LINE2 / line3 (other changed line2)
771 - dirty workdir on main: line1 / OURS2 / line3 (ours changed line2)
772
773 Running ``checkout -m other`` produces a conflict on shared.py and writes
774 MERGE_STATE.json. The caller receives the repo in that conflicted state.
775 """
776 saved = os.getcwd()
777 try:
778 os.chdir(tmp_path)
779 runner.invoke(None, ["init"])
780 finally:
781 os.chdir(saved)
782
783 (tmp_path / "shared.py").write_text("line1\nline2\nline3\n")
784 _commit(tmp_path, "initial")
785
786 _invoke(tmp_path, ["branch", "other"])
787 _invoke(tmp_path, ["checkout", "other"])
788 (tmp_path / "shared.py").write_text("line1\nLINE2\nline3\n")
789 _commit(tmp_path, "other changes line2")
790
791 _invoke(tmp_path, ["checkout", "main"])
792 (tmp_path / "shared.py").write_text("line1\nOURS2\nline3\n")
793 # Trigger the conflicting checkout -m to create MERGE_STATE
794 _invoke(tmp_path, ["checkout", "-m", "other"])
795 return tmp_path
796
797
798 class TestDiffConflictParser:
799 """Parser-level tests for the ``--conflict`` flag."""
800
801 def _parse(self, *args: str) -> "argparse.Namespace":
802 import argparse
803
804 from muse.cli.commands.diff import register
805
806 p = argparse.ArgumentParser()
807 sub = p.add_subparsers()
808 register(sub)
809 return p.parse_args(["diff", *args])
810
811 def test_conflict_flag_parsed(self) -> None:
812 ns = self._parse("--conflict")
813 assert ns.conflict is True
814
815 def test_conflict_false_by_default(self) -> None:
816 ns = self._parse()
817 assert ns.conflict is False
818
819 def test_conflict_and_json_coexist(self) -> None:
820 ns = self._parse("--conflict", "--json")
821 assert ns.conflict is True
822 assert ns.fmt == "json"
823
824 def test_conflict_and_path_coexist(self) -> None:
825 ns = self._parse("--conflict", "--path", "src/")
826 assert ns.conflict is True
827 assert "src/" in ns.paths
828
829
830 class TestDiffConflictNoMerge:
831 """--conflict when no merge is in progress must error cleanly."""
832
833 def test_no_merge_in_progress_exits_1(self, repo: pathlib.Path) -> None:
834 r = _diff(repo, "--conflict")
835 assert r.exit_code == 1
836
837 def test_no_merge_error_message_on_stderr(self, repo: pathlib.Path) -> None:
838 r = _diff(repo, "--conflict")
839 assert "MERGE_STATE" in r.stderr or "merge" in r.stderr.lower()
840
841 def test_no_merge_json_also_exits_1(self, repo: pathlib.Path) -> None:
842 r = _diff(repo, "--conflict", "--json")
843 assert r.exit_code == 1
844
845
846 class TestDiffConflictOutput:
847 """--conflict with an active merge in progress."""
848
849 def test_exits_nonzero_when_conflicts_exist(self, tmp_path: pathlib.Path) -> None:
850 repo = _make_conflict_repo(tmp_path)
851 r = _diff(repo, "--conflict")
852 assert r.exit_code != 0
853
854 def test_output_mentions_conflict_file(self, tmp_path: pathlib.Path) -> None:
855 repo = _make_conflict_repo(tmp_path)
856 r = _diff(repo, "--conflict")
857 assert "shared.py" in r.output
858
859 def test_output_contains_ours_side(self, tmp_path: pathlib.Path) -> None:
860 repo = _make_conflict_repo(tmp_path)
861 r = _diff(repo, "--conflict")
862 assert "[ours]" in r.output or "ours" in r.output.lower()
863
864 def test_output_contains_theirs_side(self, tmp_path: pathlib.Path) -> None:
865 repo = _make_conflict_repo(tmp_path)
866 r = _diff(repo, "--conflict")
867 assert "[theirs]" in r.output or "theirs" in r.output.lower()
868
869 def test_output_contains_cohen_action_labels(self, tmp_path: pathlib.Path) -> None:
870 """Cohen-style hunk labels (e.g. [branchname: modified]) must appear in @@-headers."""
871 repo = _make_conflict_repo(tmp_path)
872 r = _diff(repo, "--conflict")
873 combined = r.output + r.stderr
874 # annotate_hunk_action produces [side_label: action] suffixes on @@ headers
875 assert any(
876 suffix in combined
877 for suffix in (": modified]", ": inserted]", ": deleted]")
878 )
879
880 def test_json_status_is_conflict(self, tmp_path: pathlib.Path) -> None:
881 repo = _make_conflict_repo(tmp_path)
882 r = _diff(repo, "--conflict", "--json")
883 data = json.loads(r.output)
884 assert data["status"] == "conflict"
885
886 def test_json_conflicts_list_non_empty(self, tmp_path: pathlib.Path) -> None:
887 repo = _make_conflict_repo(tmp_path)
888 r = _diff(repo, "--conflict", "--json")
889 data = json.loads(r.output)
890 assert len(data["conflicts"]) >= 1
891
892 def test_json_conflict_entry_has_path_and_diffs(self, tmp_path: pathlib.Path) -> None:
893 repo = _make_conflict_repo(tmp_path)
894 r = _diff(repo, "--conflict", "--json")
895 data = json.loads(r.output)
896 entry = data["conflicts"][0]
897 assert "path" in entry
898 assert "ours_diff" in entry
899 assert "theirs_diff" in entry
900
901 def test_json_labels_match_branches(self, tmp_path: pathlib.Path) -> None:
902 repo = _make_conflict_repo(tmp_path)
903 r = _diff(repo, "--conflict", "--json")
904 data = json.loads(r.output)
905 assert data["ours_label"] in ("other", "main") # one of the branch names
906 assert data["theirs_label"] in ("other", "main")
907
908 def test_path_filter_limits_output(self, tmp_path: pathlib.Path) -> None:
909 """``--path`` with a non-matching prefix must produce empty conflicts."""
910 repo = _make_conflict_repo(tmp_path)
911 r = _diff(repo, "--conflict", "--json", "--path", "nonexistent/")
912 data = json.loads(r.output)
913 assert len(data["conflicts"]) == 0
914
915 def test_path_filter_matching_includes_file(self, tmp_path: pathlib.Path) -> None:
916 """``--path shared.py`` must include the conflicting file."""
917 repo = _make_conflict_repo(tmp_path)
918 r = _diff(repo, "--conflict", "--json", "--path", "shared.py")
919 data = json.loads(r.output)
920 assert any(e["path"] == "shared.py" for e in data["conflicts"])
921
922
923 class TestDiffConflictSecurity:
924 """Security: ANSI injection via branch names / paths must be sanitized."""
925
926 def test_ansi_in_conflict_path_sanitized(self, tmp_path: pathlib.Path) -> None:
927 """ANSI escape sequences in a conflict file path must not reach the output.
928
929 The CliRunner strips ANSI from all output, so the raw escape code
930 must not appear in the combined output string.
931 """
932 repo = _make_conflict_repo(tmp_path)
933 r = _diff(repo, "--conflict")
934 # CliRunner already strips ANSI; double-check no raw escape CSI leaks through
935 assert "\x1b[" not in r.output
936
937
938 # ──────────────────────────────────────────────────────────────────────────────
939 # Unit — PatchOp.file_change field
940 # ──────────────────────────────────────────────────────────────────────────────
941
942
943 class TestPatchOpFileChangeField:
944 """PatchOp gains a file_change field: 'added' | 'deleted' | 'modified'.
945
946 This field is set by build_diff_ops based on which path bucket the file
947 belongs to — not inferred from child op direction after the fact.
948 """
949
950 def _make_patch(self, file_change: str | None = None) -> "PatchOp":
951 from muse.domain import PatchOp
952
953 kwargs: dict = dict(
954 op="patch",
955 address="file.py",
956 child_ops=[],
957 child_domain="code",
958 child_summary="",
959 )
960 if file_change is not None:
961 kwargs["file_change"] = file_change
962 return PatchOp(**kwargs)
963
964 def test_patch_op_accepts_file_change_added(self) -> None:
965 op = self._make_patch("added")
966 assert op["file_change"] == "added"
967
968 def test_patch_op_accepts_file_change_deleted(self) -> None:
969 op = self._make_patch("deleted")
970 assert op["file_change"] == "deleted"
971
972 def test_patch_op_accepts_file_change_modified(self) -> None:
973 op = self._make_patch("modified")
974 assert op["file_change"] == "modified"
975
976 def test_patch_op_file_change_is_optional(self) -> None:
977 """Existing call sites without file_change must still work."""
978 op = self._make_patch()
979 assert "file_change" not in op
980
981
982 # ──────────────────────────────────────────────────────────────────────────────
983 # Unit — build_diff_ops sets file_change from path bucket
984 # ──────────────────────────────────────────────────────────────────────────────
985
986
987 class TestBuildDiffOpsFileChange:
988 """build_diff_ops sets PatchOp.file_change from the path bucket (added /
989 removed / modified), never from child op direction.
990 """
991
992 def _trees_with_symbols(self, path: str, names: list[str]) -> dict:
993 """Minimal SymbolTree with one entry per name."""
994 return {
995 f"{path}::{name}": {
996 "name": name,
997 "kind": "function",
998 "qualified_name": f"{path}::{name}",
999 "lineno": 1,
1000 "end_lineno": 2,
1001 "content_id": f"c_{name}",
1002 "body_hash": f"b_{name}",
1003 "signature_id": f"s_{name}",
1004 }
1005 for name in names
1006 }
1007
1008 def test_added_file_patch_has_file_change_added(self) -> None:
1009 from muse.plugins.code.symbol_diff import build_diff_ops
1010
1011 base_files: dict = {}
1012 target_files = {"new.py": "oid1"}
1013 base_trees: dict = {}
1014 target_trees = {"new.py": self._trees_with_symbols("new.py", ["alpha", "beta"])}
1015
1016 ops = build_diff_ops(base_files, target_files, base_trees, target_trees)
1017 patch_ops = [o for o in ops if o["op"] == "patch"]
1018 assert len(patch_ops) == 1
1019 assert patch_ops[0]["file_change"] == "added"
1020
1021 def test_removed_file_patch_has_file_change_deleted(self) -> None:
1022 from muse.plugins.code.symbol_diff import build_diff_ops
1023
1024 base_files = {"old.py": "oid1"}
1025 target_files: dict = {}
1026 base_trees = {"old.py": self._trees_with_symbols("old.py", ["alpha", "beta"])}
1027 target_trees: dict = {}
1028
1029 ops = build_diff_ops(base_files, target_files, base_trees, target_trees)
1030 patch_ops = [o for o in ops if o["op"] == "patch"]
1031 assert len(patch_ops) == 1
1032 assert patch_ops[0]["file_change"] == "deleted"
1033
1034 def test_modified_file_patch_has_file_change_modified(self) -> None:
1035 from muse.plugins.code.symbol_diff import build_diff_ops
1036
1037 base_files = {"mod.py": "oid1"}
1038 target_files = {"mod.py": "oid2"}
1039 base_trees = {"mod.py": self._trees_with_symbols("mod.py", ["alpha"])}
1040 target_trees = {"mod.py": self._trees_with_symbols("mod.py", ["alpha", "beta"])}
1041
1042 ops = build_diff_ops(base_files, target_files, base_trees, target_trees)
1043 patch_ops = [o for o in ops if o["op"] == "patch"]
1044 assert len(patch_ops) == 1
1045 assert patch_ops[0]["file_change"] == "modified"
1046
1047 def test_modified_file_all_symbol_deletions_still_file_change_modified(self) -> None:
1048 """The critical case: a living file that lost all its symbols must carry
1049 file_change='modified', not 'deleted'. Child op direction must NOT
1050 determine file status."""
1051 from muse.plugins.code.symbol_diff import build_diff_ops
1052
1053 base_files = {"shrunk.py": "oid1"}
1054 target_files = {"shrunk.py": "oid2"} # file still exists
1055 base_trees = {"shrunk.py": self._trees_with_symbols("shrunk.py", ["alpha", "beta", "gamma"])}
1056 target_trees: dict = {"shrunk.py": {}} # all symbols gone, but file lives
1057
1058 ops = build_diff_ops(base_files, target_files, base_trees, target_trees)
1059 patch_ops = [o for o in ops if o["op"] == "patch"]
1060 assert len(patch_ops) == 1, f"Expected 1 PatchOp, got: {ops}"
1061 assert patch_ops[0]["file_change"] == "modified", (
1062 f"Living file with all-delete children must be 'modified', "
1063 f"got {patch_ops[0].get('file_change')!r}"
1064 )
1065
1066 def test_modified_file_all_symbol_additions_still_file_change_modified(self) -> None:
1067 """A living file that gained all new symbols is 'modified', not 'added'."""
1068 from muse.plugins.code.symbol_diff import build_diff_ops
1069
1070 base_files = {"grew.py": "oid1"}
1071 target_files = {"grew.py": "oid2"}
1072 base_trees: dict = {"grew.py": {}} # was empty (no symbols)
1073 target_trees = {"grew.py": self._trees_with_symbols("grew.py", ["alpha", "beta"])}
1074
1075 ops = build_diff_ops(base_files, target_files, base_trees, target_trees)
1076 patch_ops = [o for o in ops if o["op"] == "patch"]
1077 assert len(patch_ops) == 1
1078 assert patch_ops[0]["file_change"] == "modified", (
1079 f"Living file with all-insert children must be 'modified', "
1080 f"got {patch_ops[0].get('file_change')!r}"
1081 )
1082
1083
1084 # ──────────────────────────────────────────────────────────────────────────────
1085 # Integration — file-level sigil correctness (the AX bug fix)
1086 # ──────────────────────────────────────────────────────────────────────────────
1087
1088
1089 class TestFileSignilCorrectnessTextOutput:
1090 """Text output: the file-level sigil (A/D/M/R) must reflect whether the
1091 file was added, deleted, or modified — never inferred from child op counts.
1092
1093 Historically, a modified file that lost all its functions showed 'D' in
1094 the diff output (misread as 'file deleted' by agents). After the fix,
1095 that file must show 'M'.
1096 """
1097
1098 def test_modified_file_losing_all_symbols_shows_M_sigil(
1099 self, repo: pathlib.Path
1100 ) -> None:
1101 """Living file that lost all named functions → M, not D."""
1102 (repo / "funcs.py").write_text(
1103 "def alpha():\n return 1\n\ndef beta():\n return 2\n"
1104 )
1105 _commit(repo, "add funcs")
1106
1107 # Overwrite with content that has no recognised symbols.
1108 (repo / "funcs.py").write_text("# no functions here\nPLACEHOLDER = True\n")
1109
1110 result = _diff(repo)
1111 lines = result.output.splitlines()
1112 file_line = next(
1113 (l for l in lines if "funcs.py" in l
1114 and not l.strip().startswith("├─")
1115 and not l.strip().startswith("└─")),
1116 None,
1117 )
1118 assert file_line is not None, f"funcs.py not in diff:\n{result.output}"
1119 assert file_line.strip().startswith("M"), (
1120 f"Expected 'M funcs.py' (modified), got: {file_line!r}\n"
1121 f"Full output:\n{result.output}"
1122 )
1123
1124 def test_modified_file_gaining_all_symbols_shows_M_sigil(
1125 self, repo: pathlib.Path
1126 ) -> None:
1127 """Living file that gained functions → M, not A."""
1128 # Start with a file that has no functions
1129 (repo / "empty.py").write_text("PLACEHOLDER = True\n")
1130 _commit(repo, "add empty.py")
1131
1132 # Add functions to it
1133 (repo / "empty.py").write_text(
1134 "def alpha():\n return 1\n\ndef beta():\n return 2\n"
1135 )
1136
1137 result = _diff(repo)
1138 lines = result.output.splitlines()
1139 file_line = next(
1140 (l for l in lines if "empty.py" in l
1141 and not l.strip().startswith("├─")
1142 and not l.strip().startswith("└─")),
1143 None,
1144 )
1145 assert file_line is not None, f"empty.py not in diff:\n{result.output}"
1146 assert file_line.strip().startswith("M"), (
1147 f"Expected 'M empty.py' (modified), got: {file_line!r}\n"
1148 f"Full output:\n{result.output}"
1149 )
1150
1151 def test_actually_deleted_file_still_shows_D_sigil(
1152 self, repo: pathlib.Path
1153 ) -> None:
1154 """Sanity check: a file that is truly gone still shows D."""
1155 (repo / "gone.py").write_text("def foo(): pass\n")
1156 _commit(repo, "add gone")
1157 (repo / "gone.py").unlink()
1158
1159 result = _diff(repo)
1160 lines = result.output.splitlines()
1161 file_line = next(
1162 (l for l in lines if "gone.py" in l
1163 and not l.strip().startswith("├─")
1164 and not l.strip().startswith("└─")),
1165 None,
1166 )
1167 assert file_line is not None
1168 assert file_line.strip().startswith("D"), (
1169 f"Expected 'D gone.py' (deleted), got: {file_line!r}"
1170 )
1171
1172 def test_newly_added_file_still_shows_A_sigil(
1173 self, repo: pathlib.Path
1174 ) -> None:
1175 """Sanity check: a brand-new file still shows A."""
1176 (repo / "brand_new.py").write_text("def foo(): pass\n")
1177
1178 result = _diff(repo)
1179 lines = result.output.splitlines()
1180 file_line = next(
1181 (l for l in lines if "brand_new.py" in l
1182 and not l.strip().startswith("├─")
1183 and not l.strip().startswith("└─")),
1184 None,
1185 )
1186 assert file_line is not None
1187 assert file_line.strip().startswith("A"), (
1188 f"Expected 'A brand_new.py' (added), got: {file_line!r}"
1189 )
1190
1191
1192 class TestFileSignilCorrectnessJsonOutput:
1193 """JSON output must categorize files by actual existence, not child op direction."""
1194
1195 def test_modified_file_losing_all_symbols_in_modified_not_deleted(
1196 self, repo: pathlib.Path
1197 ) -> None:
1198 (repo / "funcs.py").write_text(
1199 "def alpha():\n return 1\n\ndef beta():\n return 2\n"
1200 )
1201 _commit(repo, "add funcs")
1202 (repo / "funcs.py").write_text("# no functions here\nPLACEHOLDER = True\n")
1203
1204 result = _diff(repo, "--json")
1205 data = json.loads(result.output)
1206 assert "funcs.py" in data["modified"], (
1207 f"funcs.py must be in modified, got: {data}"
1208 )
1209 assert "funcs.py" not in data["deleted"], (
1210 f"funcs.py must NOT be in deleted (file still exists), got: {data}"
1211 )
1212
1213 def test_modified_file_gaining_all_symbols_in_modified_not_added(
1214 self, repo: pathlib.Path
1215 ) -> None:
1216 (repo / "empty.py").write_text("PLACEHOLDER = True\n")
1217 _commit(repo, "add empty.py")
1218 (repo / "empty.py").write_text(
1219 "def alpha():\n return 1\n\ndef beta():\n return 2\n"
1220 )
1221
1222 result = _diff(repo, "--json")
1223 data = json.loads(result.output)
1224 assert "empty.py" in data["modified"], (
1225 f"empty.py must be in modified, got: {data}"
1226 )
1227 assert "empty.py" not in data["added"], (
1228 f"empty.py must NOT be in added (file pre-existed), got: {data}"
1229 )
1230
1231
1232 # ──────────────────────────────────────────────────────────────────────────────
1233 # Dead-code removal — _classify_patch_op and _op_category must be deleted
1234 # ──────────────────────────────────────────────────────────────────────────────
1235
1236
1237 class TestInferenceHelpersRemoved:
1238 """_classify_patch_op and _op_category exist only to infer file status from
1239 child op counts. Once PatchOp.file_change carries authoritative status,
1240 both helpers are dead code and must be deleted.
1241 """
1242
1243 def test_classify_patch_op_deleted(self) -> None:
1244 import muse.cli.commands.diff as m
1245
1246 assert not hasattr(m, "_classify_patch_op"), (
1247 "_classify_patch_op is dead code — file status is now read from "
1248 "PatchOp.file_change, not inferred from child ops"
1249 )
1250
1251 def test_op_category_deleted(self) -> None:
1252 import muse.cli.commands.diff as m
1253
1254 assert not hasattr(m, "_op_category"), (
1255 "_op_category is dead code — it existed only to wrap _classify_patch_op"
1256 )
File History 1 commit
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 149 days ago