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