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