gabriel / muse public
test_cmd_diff.py python
1,256 lines 53.3 KB
Raw
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.
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 collections.abc import Mapping
23 from typing import TYPE_CHECKING
24
25 import pytest
26
27 from tests.cli_test_helper import CliRunner, InvokeResult
28
29 if TYPE_CHECKING:
30 import argparse
31
32 from muse.domain import DeleteOp, DomainOp, InsertOp, MoveOp, PatchOp, ReplaceOp
33
34 runner = CliRunner()
35
36 # ──────────────────────────────────────────────────────────────────────────────
37 # Helpers
38 # ──────────────────────────────────────────────────────────────────────────────
39
40
41 def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult:
42 saved = os.getcwd()
43 try:
44 os.chdir(repo)
45 return runner.invoke(None, args)
46 finally:
47 os.chdir(saved)
48
49
50 def _diff(repo: pathlib.Path, *extra: str) -> InvokeResult:
51 return _invoke(repo, ["diff", *extra])
52
53
54 def _commit(repo: pathlib.Path, msg: str) -> InvokeResult:
55 return _invoke(repo, ["commit", "-m", msg])
56
57
58 @pytest.fixture()
59 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
60 """Initialised repo with one tracked file and one commit."""
61 saved = os.getcwd()
62 try:
63 os.chdir(tmp_path)
64 runner.invoke(None, ["init"])
65 finally:
66 os.chdir(saved)
67 (tmp_path / "a.py").write_text("x = 1\n")
68 _commit(tmp_path, "first")
69 return tmp_path
70
71
72 # ──────────────────────────────────────────────────────────────────────────────
73 # Unit — parser flags
74 # ──────────────────────────────────────────────────────────────────────────────
75
76
77 class TestRegisterFlags:
78 def _parse(self, *args: str) -> "argparse.Namespace":
79 import argparse
80
81 from muse.cli.commands.diff import register
82
83 p = argparse.ArgumentParser()
84 sub = p.add_subparsers()
85 register(sub)
86 return p.parse_args(["diff", *args])
87
88 def test_default_json_out_is_false(self) -> None:
89 ns = self._parse()
90 assert ns.json_out is False
91
92 def test_json_flag_sets_json_out(self) -> None:
93 ns = self._parse("--json")
94 assert ns.json_out is True
95
96 def test_j_shorthand_sets_json_out(self) -> None:
97 ns = self._parse("-j")
98 assert ns.json_out is True
99
100 def test_exit_code_long_flag(self) -> None:
101 ns = self._parse("--exit-code")
102 assert ns.exit_code is True
103
104 def test_exit_code_short_flag(self) -> None:
105 ns = self._parse("-z")
106 assert ns.exit_code is True
107
108 def test_exit_code_default_false(self) -> None:
109 ns = self._parse()
110 assert ns.exit_code is False
111
112 def test_staged_flag(self) -> None:
113 ns = self._parse("--staged")
114 assert ns.staged is True
115
116 def test_unstaged_flag(self) -> None:
117 ns = self._parse("--unstaged")
118 assert ns.unstaged is True
119
120 def test_stat_flag(self) -> None:
121 ns = self._parse("--stat")
122 assert ns.stat is True
123
124 def test_text_flag(self) -> None:
125 ns = self._parse("--text")
126 assert ns.text is True
127
128 def test_path_flag(self) -> None:
129 ns = self._parse("-p", "foo.py")
130 assert "foo.py" in ns.paths
131
132 def test_path_flag_repeatable(self) -> None:
133 ns = self._parse("-p", "a.py", "-p", "b.py")
134 assert "a.py" in ns.paths and "b.py" in ns.paths
135
136
137 # ──────────────────────────────────────────────────────────────────────────────
138 # Unit — dead-code removal
139 # ──────────────────────────────────────────────────────────────────────────────
140
141
142 class TestDeadCodeRemoved:
143 def test_read_branch_removed(self) -> None:
144 import muse.cli.commands.diff as m
145
146 assert not hasattr(m, "_read_branch"), (
147 "_read_branch was a dead wrapper; it should have been deleted"
148 )
149
150
151 # ──────────────────────────────────────────────────────────────────────────────
152 # Unit — _filter_manifest
153 # ──────────────────────────────────────────────────────────────────────────────
154
155
156 class TestFilterManifest:
157 def test_empty_paths_returns_all(self) -> None:
158 from muse.cli.commands.diff import _filter_manifest
159
160 m = {"a.py": "oid1", "b.py": "oid2"}
161 assert _filter_manifest(m, []) == m
162
163 def test_exact_file_match(self) -> None:
164 from muse.cli.commands.diff import _filter_manifest
165
166 m = {"a.py": "oid1", "b.py": "oid2"}
167 result = _filter_manifest(m, ["a.py"])
168 assert result == {"a.py": "oid1"}
169
170 def test_directory_prefix_match(self) -> None:
171 from muse.cli.commands.diff import _filter_manifest
172
173 m = {
174 "src/foo.py": "oid1",
175 "src/bar.py": "oid2",
176 "tests/test_foo.py": "oid3",
177 }
178 result = _filter_manifest(m, ["src"])
179 assert set(result) == {"src/foo.py", "src/bar.py"}
180
181 def test_trailing_slash_normalised(self) -> None:
182 from muse.cli.commands.diff import _filter_manifest
183
184 m = {"src/foo.py": "oid1", "other.py": "oid2"}
185 assert _filter_manifest(m, ["src/"]) == {"src/foo.py": "oid1"}
186
187 def test_multiple_paths(self) -> None:
188 from muse.cli.commands.diff import _filter_manifest
189
190 m = {"a.py": "oid1", "b.py": "oid2", "c.py": "oid3"}
191 result = _filter_manifest(m, ["a.py", "c.py"])
192 assert set(result) == {"a.py", "c.py"}
193
194 def test_no_match_returns_empty(self) -> None:
195 from muse.cli.commands.diff import _filter_manifest
196
197 m = {"a.py": "oid1"}
198 assert _filter_manifest(m, ["z.py"]) == {}
199
200
201 # ──────────────────────────────────────────────────────────────────────────────
202 # Unit — _use_color
203 # ──────────────────────────────────────────────────────────────────────────────
204
205
206 class TestUseColor:
207 def test_no_color_env_disables_color(
208 self, monkeypatch: pytest.MonkeyPatch
209 ) -> None:
210 from muse.cli.commands.diff import _use_color
211
212 monkeypatch.setenv("NO_COLOR", "1")
213 assert _use_color() is False
214
215 def test_dumb_term_disables_color(
216 self, monkeypatch: pytest.MonkeyPatch
217 ) -> None:
218 from muse.cli.commands.diff import _use_color
219
220 monkeypatch.setenv("TERM", "dumb")
221 assert _use_color() is False
222
223 def test_no_color_env_unset_does_not_force_color(
224 self, monkeypatch: pytest.MonkeyPatch
225 ) -> None:
226 from muse.cli.commands.diff import _use_color
227
228 monkeypatch.delenv("NO_COLOR", raising=False)
229 monkeypatch.delenv("TERM", raising=False)
230 # stdout is not a TTY in test; just verify the function returns a bool
231 assert isinstance(_use_color(), bool)
232
233
234 # ──────────────────────────────────────────────────────────────────────────────
235 # Integration — HEAD vs working tree
236 # ──────────────────────────────────────────────────────────────────────────────
237
238
239 class TestHeadVsWorkingTree:
240 def test_clean_tree_exits_0(self, repo: pathlib.Path) -> None:
241 result = _diff(repo)
242 assert result.exit_code == 0
243 assert "No differences" in result.output
244
245 def test_modified_file_detected(self, repo: pathlib.Path) -> None:
246 (repo / "a.py").write_text("x = 99\n")
247 result = _diff(repo)
248 assert result.exit_code == 0
249 assert "a.py" in result.output
250
251 def test_added_file_detected(self, repo: pathlib.Path) -> None:
252 (repo / "new.py").write_text("z = 0\n")
253 result = _diff(repo)
254 assert "new.py" in result.output
255
256 def test_deleted_file_detected(self, repo: pathlib.Path) -> None:
257 (repo / "b.py").write_text("b = 1\n")
258 _commit(repo, "add b")
259 (repo / "b.py").unlink()
260 result = _diff(repo)
261 assert "b.py" in result.output
262
263
264 # ──────────────────────────────────────────────────────────────────────────────
265 # Integration — JSON schema (including critical bug fix)
266 # ──────────────────────────────────────────────────────────────────────────────
267
268
269 class TestJsonSchema:
270 """All keys agents depend on must be present."""
271
272 REQUIRED_KEYS = {
273 "from_ref",
274 "to_ref",
275 "from_commit_id",
276 "to_commit_id",
277 "has_changes",
278 "summary",
279 "added",
280 "deleted",
281 "modified",
282 "total_changes",
283 "duration_ms",
284 "exit_code",
285 }
286
287 def test_clean_tree_json_keys(self, repo: pathlib.Path) -> None:
288 result = _diff(repo, "--json")
289 assert result.exit_code == 0
290 data = json.loads(result.output)
291 missing = self.REQUIRED_KEYS - set(data)
292 assert not missing, f"Missing keys: {missing}"
293
294 def test_has_changes_false_on_clean_tree(self, repo: pathlib.Path) -> None:
295 result = _diff(repo, "--json")
296 data = json.loads(result.output)
297 assert data["has_changes"] is False
298
299 def test_has_changes_true_on_modified(self, repo: pathlib.Path) -> None:
300 (repo / "a.py").write_text("x = 99\n")
301 result = _diff(repo, "--json")
302 data = json.loads(result.output)
303 assert data["has_changes"] is True
304
305 def test_from_commit_id_present(self, repo: pathlib.Path) -> None:
306 result = _diff(repo, "--json")
307 data = json.loads(result.output)
308 # from_commit_id should be the HEAD commit SHA
309 assert data["from_commit_id"] is not None
310 assert data["from_commit_id"].startswith("sha256:")
311 assert len(data["from_commit_id"]) == len("sha256:") + 64
312
313 def test_to_commit_id_null_for_workdir_diff(self, repo: pathlib.Path) -> None:
314 result = _diff(repo, "--json")
315 data = json.loads(result.output)
316 assert data["to_commit_id"] is None
317
318 # ── Critical bug fix: deleted files must be in "deleted", not "modified" ──
319
320 def test_deleted_file_in_deleted_list_not_modified(self, repo: pathlib.Path) -> None:
321 """Regression test: files deleted from the working tree must appear in
322 ``deleted``, not ``modified``. The plugin emits a ``patch`` op with
323 all-delete child ops for file deletions; the JSON categorizer must
324 recognise this."""
325 (repo / "b.py").write_text("b = 2\n")
326 _commit(repo, "add b")
327 (repo / "b.py").unlink()
328 result = _diff(repo, "--json")
329 data = json.loads(result.output)
330 assert "b.py" in data["deleted"], f"b.py not in deleted: {data}"
331 assert "b.py" not in data["modified"], f"b.py wrongly in modified: {data}"
332
333 def test_added_file_in_added_list_not_modified(self, repo: pathlib.Path) -> None:
334 """New files must appear in ``added``, not ``modified``."""
335 (repo / "new.py").write_text("n = 1\n")
336 result = _diff(repo, "--json")
337 data = json.loads(result.output)
338 assert "new.py" in data["added"], f"new.py not in added: {data}"
339 assert "new.py" not in data["modified"], f"new.py wrongly in modified: {data}"
340
341 def test_modified_file_in_modified_list(self, repo: pathlib.Path) -> None:
342 (repo / "a.py").write_text("x = 999\n")
343 result = _diff(repo, "--json")
344 data = json.loads(result.output)
345 assert "a.py" in data["modified"]
346 assert "a.py" not in data["added"]
347 assert "a.py" not in data["deleted"]
348
349 def test_combined_add_delete_modify(self, repo: pathlib.Path) -> None:
350 """All three categories correct simultaneously."""
351 (repo / "b.py").write_text("b = 2\n")
352 (repo / "c.py").write_text("c = 3\n")
353 _commit(repo, "add b and c")
354 (repo / "b.py").unlink() # deleted
355 (repo / "c.py").write_text("c = 99\n") # modified
356 (repo / "d.py").write_text("d = 4\n") # added
357 result = _diff(repo, "--json")
358 data = json.loads(result.output)
359 assert "b.py" in data["deleted"]
360 assert "c.py" in data["modified"]
361 assert "d.py" in data["added"]
362
363 def test_added_list_is_sorted(self, repo: pathlib.Path) -> None:
364 for name in ["z.py", "a2.py", "m.py"]:
365 (repo / name).write_text(f"x=1\n")
366 result = _diff(repo, "--json")
367 data = json.loads(result.output)
368 assert data["added"] == sorted(data["added"])
369
370 def test_from_ref_is_head(self, repo: pathlib.Path) -> None:
371 result = _diff(repo, "--json")
372 data = json.loads(result.output)
373 assert data["from_ref"] == "HEAD"
374
375 def test_to_ref_is_working_tree(self, repo: pathlib.Path) -> None:
376 result = _diff(repo, "--json")
377 data = json.loads(result.output)
378 assert data["to_ref"] == "working tree"
379
380 def test_total_changes_matches_op_count(self, repo: pathlib.Path) -> None:
381 (repo / "a.py").write_text("x = 50\n")
382 (repo / "b.py").write_text("b = 1\n")
383 result = _diff(repo, "--json")
384 data = json.loads(result.output)
385 total = len(data["added"]) + len(data["deleted"]) + len(data["modified"])
386 # total_changes counts plugin ops, not files; it can exceed the file count
387 # if a file has multiple symbol ops, but it should be >= file count.
388 assert data["total_changes"] >= total
389
390 def test_two_commit_diff_has_commit_ids(self, repo: pathlib.Path) -> None:
391 from muse.core.refs import get_head_commit_id
392
393 cid1 = get_head_commit_id(repo, "main")
394 (repo / "b.py").write_text("b = 1\n")
395 _commit(repo, "second")
396 cid2 = get_head_commit_id(repo, "main")
397 result = _diff(repo, cid1 or "", cid2 or "", "--json")
398 data = json.loads(result.output)
399 assert data["from_commit_id"] == cid1
400 assert data["to_commit_id"] == cid2
401
402
403 # ──────────────────────────────────────────────────────────────────────────────
404 # Integration — --exit-code
405 # ──────────────────────────────────────────────────────────────────────────────
406
407
408 class TestExitCode:
409 def test_exit_code_0_on_clean_tree(self, repo: pathlib.Path) -> None:
410 result = _diff(repo, "--exit-code")
411 assert result.exit_code == 0
412
413 def test_exit_code_1_when_changes(self, repo: pathlib.Path) -> None:
414 (repo / "a.py").write_text("x = 99\n")
415 result = _diff(repo, "--exit-code")
416 assert result.exit_code == 1
417
418 def test_exit_code_with_json_clean(self, repo: pathlib.Path) -> None:
419 result = _diff(repo, "--exit-code", "--json")
420 assert result.exit_code == 0
421 data = json.loads(result.output)
422 assert data["has_changes"] is False
423
424 def test_exit_code_with_json_dirty(self, repo: pathlib.Path) -> None:
425 (repo / "a.py").write_text("x = 99\n")
426 result = _diff(repo, "--exit-code", "--json")
427 assert result.exit_code == 1
428 data = json.loads(result.output)
429 assert data["has_changes"] is True
430
431 def test_exit_code_with_stat_clean(self, repo: pathlib.Path) -> None:
432 result = _diff(repo, "--exit-code", "--stat")
433 assert result.exit_code == 0
434
435 def test_exit_code_with_stat_dirty(self, repo: pathlib.Path) -> None:
436 (repo / "a.py").write_text("x = 99\n")
437 result = _diff(repo, "--exit-code", "--stat")
438 assert result.exit_code == 1
439
440 def test_exit_code_with_text_dirty(self, repo: pathlib.Path) -> None:
441 (repo / "a.py").write_text("x = 99\n")
442 result = _diff(repo, "--exit-code", "--text")
443 assert result.exit_code == 1
444
445 def test_exit_code_with_text_clean(self, repo: pathlib.Path) -> None:
446 result = _diff(repo, "--exit-code", "--text")
447 assert result.exit_code == 0
448
449
450 # ──────────────────────────────────────────────────────────────────────────────
451 # Integration — two-commit diff
452 # ──────────────────────────────────────────────────────────────────────────────
453
454
455 class TestTwoCommitDiff:
456 def test_two_commits_exits_0(self, repo: pathlib.Path) -> None:
457 from muse.core.refs import get_head_commit_id
458
459 cid1 = get_head_commit_id(repo, "main")
460 (repo / "b.py").write_text("b=1\n")
461 _commit(repo, "second")
462 cid2 = get_head_commit_id(repo, "main")
463 result = _diff(repo, cid1 or "", cid2 or "")
464 assert result.exit_code == 0
465
466 def test_two_identical_commits_no_differences(self, repo: pathlib.Path) -> None:
467 from muse.core.refs import get_head_commit_id
468
469 cid = get_head_commit_id(repo, "main")
470 result = _diff(repo, cid or "", cid or "")
471 assert "No differences" in result.output
472
473 def test_invalid_commit_ref_exits_1(self, repo: pathlib.Path) -> None:
474 result = _diff(repo, "deadbeefdeadbeef")
475 assert result.exit_code == 1
476
477
478 # ──────────────────────────────────────────────────────────────────────────────
479 # Integration — --stat
480 # ──────────────────────────────────────────────────────────────────────────────
481
482
483 class TestStat:
484 def test_stat_clean_tree(self, repo: pathlib.Path) -> None:
485 result = _diff(repo, "--stat")
486 assert result.exit_code == 0
487 assert "No differences" in result.output
488
489 def test_stat_shows_summary(self, repo: pathlib.Path) -> None:
490 (repo / "a.py").write_text("x = 50\n")
491 result = _diff(repo, "--stat")
492 assert result.exit_code == 0
493 # Should contain a human-readable summary (not empty)
494 assert result.output.strip() != ""
495 assert "No differences" not in result.output
496
497
498 # ──────────────────────────────────────────────────────────────────────────────
499 # Integration — --text (unified diff)
500 # ──────────────────────────────────────────────────────────────────────────────
501
502
503 class TestTextDiff:
504 def test_text_clean_tree(self, repo: pathlib.Path) -> None:
505 result = _diff(repo, "--text")
506 assert result.exit_code == 0
507 assert "No differences" in result.output
508
509 def test_text_modified_file_shows_diff(self, repo: pathlib.Path) -> None:
510 (repo / "a.py").write_text("x = 99\n")
511 result = _diff(repo, "--text")
512 assert "a.py" in result.output
513
514 def test_text_added_file_shown(self, repo: pathlib.Path) -> None:
515 (repo / "new.py").write_text("n = 1\n")
516 result = _diff(repo, "--text")
517 assert "new.py" in result.output
518
519 def test_text_deleted_file_shown(self, repo: pathlib.Path) -> None:
520 (repo / "b.py").write_text("b=1\n")
521 _commit(repo, "add b")
522 (repo / "b.py").unlink()
523 result = _diff(repo, "--text")
524 assert "b.py" in result.output
525
526
527 # ──────────────────────────────────────────────────────────────────────────────
528 # Integration — --path filter
529 # ──────────────────────────────────────────────────────────────────────────────
530
531
532 class TestPathFilter:
533 def test_path_filter_limits_output(self, repo: pathlib.Path) -> None:
534 (repo / "a.py").write_text("x = 99\n")
535 (repo / "b.py").write_text("b = 1\n")
536 result = _diff(repo, "--json", "-p", "a.py")
537 data = json.loads(result.output)
538 # Should show a.py changes, not b.py
539 all_paths = data["added"] + data["deleted"] + data["modified"]
540 assert all(p.startswith("a") for p in all_paths)
541
542 def test_directory_prefix_filter(self, repo: pathlib.Path) -> None:
543 (repo / "src").mkdir()
544 (repo / "src" / "foo.py").write_text("f = 1\n")
545 (repo / "other.py").write_text("o = 1\n")
546 result = _diff(repo, "--json", "-p", "src")
547 data = json.loads(result.output)
548 all_paths = data["added"] + data["deleted"] + data["modified"]
549 assert all(p.startswith("src") for p in all_paths)
550
551 def test_path_filter_with_nonexistent_path_returns_clean(
552 self, repo: pathlib.Path
553 ) -> None:
554 (repo / "a.py").write_text("x = 99\n")
555 result = _diff(repo, "--json", "-p", "nonexistent.py")
556 data = json.loads(result.output)
557 assert data["has_changes"] is False
558
559
560 # ──────────────────────────────────────────────────────────────────────────────
561 # Integration — validation
562 # ──────────────────────────────────────────────────────────────────────────────
563
564
565 class TestDiffShelf:
566 """muse diff --shelf shows the shelved changes vs HEAD."""
567
568 def test_shelf_flag_shows_shelved_changes(self, repo: pathlib.Path) -> None:
569 (repo / "a.py").write_text("x = 999\n")
570 _invoke(repo, ["shelf", "save", "-m", "test shelf"])
571 result = _diff(repo, "--shelf")
572 assert result.exit_code == 0
573 assert "a.py" in result.output
574
575 def test_shelf_flag_json_schema(self, repo: pathlib.Path) -> None:
576 (repo / "a.py").write_text("x = 999\n")
577 _invoke(repo, ["shelf", "save", "-m", "test shelf"])
578 result = _diff(repo, "--shelf", "--json")
579 assert result.exit_code == 0
580 data = json.loads(result.output)
581 assert "from_ref" in data
582 assert "to_ref" in data
583 assert data["from_ref"] == "HEAD"
584 assert "shelf" in data["to_ref"]
585
586 def test_shelf_flag_no_shelf_exits_1(self, repo: pathlib.Path) -> None:
587 result = _diff(repo, "--shelf")
588 assert result.exit_code == 1
589
590 def test_shelf_flag_with_index(self, repo: pathlib.Path) -> None:
591 # Create two shelf entries, diff the second (index 1).
592 (repo / "a.py").write_text("x = 10\n")
593 _invoke(repo, ["shelf", "save", "-m", "first"])
594 (repo / "a.py").write_text("x = 20\n")
595 _invoke(repo, ["shelf", "save", "-m", "second"])
596 # shelf/0 = second (newest), shelf/1 = first (oldest)
597 result = _diff(repo, "--shelf", "1")
598 assert result.exit_code == 0
599
600 def test_shelf_mutually_exclusive_with_staged(self, repo: pathlib.Path) -> None:
601 result = _diff(repo, "--shelf", "--staged")
602 assert result.exit_code == 1
603
604 def test_shelf_mutually_exclusive_with_unstaged(self, repo: pathlib.Path) -> None:
605 result = _diff(repo, "--shelf", "--unstaged")
606 assert result.exit_code == 1
607
608
609 class TestValidation:
610 def test_staged_and_unstaged_mutually_exclusive(self, repo: pathlib.Path) -> None:
611 result = _diff(repo, "--staged", "--unstaged")
612 assert result.exit_code == 1
613
614 def test_unknown_flag_exits_nonzero(self, repo: pathlib.Path) -> None:
615 result = _diff(repo, "--format", "xml")
616 assert result.exit_code != 0
617
618
619 # ──────────────────────────────────────────────────────────────────────────────
620 # Security — ANSI injection prevention
621 # ──────────────────────────────────────────────────────────────────────────────
622
623
624 class TestSecurityAnsi:
625 """Text output must never emit raw ANSI sequences from user-controlled input."""
626
627 def _has_ansi(self, s: str) -> bool:
628 return "\x1b[" in s or "\x1b]" in s
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 malicious = "\x1b[31mmalicious\x1b[0m"
633 result = _diff(repo, malicious)
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[31mmalicious\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.json_out is True
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(
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]) -> Mapping[str, object]:
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 = {}
1012 target_files = {"new.py": "oid1"}
1013 base_trees = {}
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 = {}
1026 base_trees = {"old.py": self._trees_with_symbols("old.py", ["alpha", "beta"])}
1027 target_trees = {}
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 = {"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 = {"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