gabriel / muse public
test_cmd_diff.py python
1,372 lines 58.8 KB
Raw
sha256:72de78de7f68004fd4d89fd1ed31e96734ade136417ac0bc09c151c955540474 feat: muse diff <refA> <refB> labels which branch introduce… Sonnet 5 patch 2 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.
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 — musehub#218: symbol-origin attribution on two-ref diffs
481 # ──────────────────────────────────────────────────────────────────────────────
482
483
484 class TestSymbolOriginAttribution:
485 """`muse diff <refA> <refB>` labels which branch introduced each
486 symbol-level change, relative to their merge-base."""
487
488 def _two_branch_repo(self, tmp_path: pathlib.Path) -> pathlib.Path:
489 """base: greet() returns "hi". feature/friendlier changes greet()'s
490 body. feature/farewell (branched from the same base) adds a new
491 farewell() function, leaving greet() untouched. Exactly the
492 ticket's own repro."""
493 saved = os.getcwd()
494 try:
495 os.chdir(tmp_path)
496 runner.invoke(None, ["init"])
497 finally:
498 os.chdir(saved)
499 (tmp_path / "hello.py").write_text("def greet():\n return 'hi'\n")
500 _commit(tmp_path, "base")
501 _invoke(tmp_path, ["switch", "-c", "feature/friendlier"])
502 (tmp_path / "hello.py").write_text("def greet():\n return 'hello there'\n")
503 _commit(tmp_path, "friendlier")
504 _invoke(tmp_path, ["switch", "main"])
505 _invoke(tmp_path, ["switch", "-c", "feature/farewell"])
506 (tmp_path / "hello.py").write_text(
507 "def greet():\n return 'hi'\n\ndef farewell():\n return 'bye'\n"
508 )
509 _commit(tmp_path, "farewell")
510 _invoke(tmp_path, ["switch", "main"])
511 return tmp_path
512
513 def test_added_symbol_attributed_to_the_ref_that_added_it(
514 self, tmp_path: pathlib.Path
515 ) -> None:
516 repo = self._two_branch_repo(tmp_path)
517 result = _diff(repo, "feature/friendlier", "feature/farewell", "--json")
518 data = json.loads(result.output)
519 assert data["symbol_origins"]["hello.py"]["farewell"] == "feature/farewell"
520
521 def test_modified_symbol_attributed_to_the_ref_that_changed_it(
522 self, tmp_path: pathlib.Path
523 ) -> None:
524 repo = self._two_branch_repo(tmp_path)
525 result = _diff(repo, "feature/friendlier", "feature/farewell", "--json")
526 data = json.loads(result.output)
527 assert data["symbol_origins"]["hello.py"]["greet"] == "feature/friendlier"
528
529 def test_existing_symbols_shape_is_unchanged(self, tmp_path: pathlib.Path) -> None:
530 """musehub#218 is purely additive -- the existing `symbols` key's
531 shape (plain name lists) must be byte-identical to before."""
532 repo = self._two_branch_repo(tmp_path)
533 result = _diff(repo, "feature/friendlier", "feature/farewell", "--json")
534 data = json.loads(result.output)
535 assert data["symbols"]["hello.py"] == {
536 "added": ["farewell"],
537 "deleted": [],
538 "modified": ["greet"],
539 }
540
541 def test_text_output_shows_origin_label(self, tmp_path: pathlib.Path) -> None:
542 repo = self._two_branch_repo(tmp_path)
543 result = _diff(repo, "feature/friendlier", "feature/farewell")
544 assert "(feature/farewell)" in result.output
545 assert "(feature/friendlier)" in result.output
546
547 def test_both_sides_changing_the_same_symbol_is_labeled_both(
548 self, tmp_path: pathlib.Path
549 ) -> None:
550 repo = self._two_branch_repo(tmp_path)
551 _invoke(repo, ["switch", "-c", "feature/other-greet"])
552 (repo / "hello.py").write_text("def greet():\n return 'yo'\n")
553 _commit(repo, "other greet change")
554 _invoke(repo, ["switch", "main"])
555
556 result = _diff(repo, "feature/friendlier", "feature/other-greet", "--json")
557 data = json.loads(result.output)
558 assert data["symbol_origins"]["hello.py"]["greet"] == "both"
559
560 def test_head_vs_working_tree_has_no_symbol_origins(
561 self, repo: pathlib.Path
562 ) -> None:
563 """Every non-two-ref diff form must omit origin attribution --
564 there's no "other branch" to attribute a change to."""
565 (repo / "a.py").write_text("x = 2\n")
566 result = _diff(repo, "--json")
567 data = json.loads(result.output)
568 assert data["symbol_origins"] == {}
569
570 def test_single_ref_diff_has_no_symbol_origins(self, tmp_path: pathlib.Path) -> None:
571 """HEAD vs a single other ref is not the two-ref form either."""
572 repo = self._two_branch_repo(tmp_path)
573 _invoke(repo, ["switch", "feature/friendlier"])
574 result = _diff(repo, "feature/farewell", "--json")
575 data = json.loads(result.output)
576 assert data["symbol_origins"] == {}
577
578 def test_no_merge_base_omits_origins_gracefully(
579 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
580 ) -> None:
581 """No merge-base (e.g. disjoint histories) must not crash -- just
582 omit origin attribution for that pair, rendering exactly as before
583 this feature existed."""
584 repo = self._two_branch_repo(tmp_path)
585 monkeypatch.setattr(
586 "muse.core.merge_engine.find_merge_base", lambda *a, **k: None
587 )
588 result = _diff(repo, "feature/friendlier", "feature/farewell", "--json")
589 data = json.loads(result.output)
590 assert data["symbol_origins"] == {}
591 assert data["symbols"]["hello.py"]["added"] == ["farewell"]
592
593
594 # ──────────────────────────────────────────────────────────────────────────────
595 # Integration — --stat
596 # ──────────────────────────────────────────────────────────────────────────────
597
598
599 class TestStat:
600 def test_stat_clean_tree(self, repo: pathlib.Path) -> None:
601 result = _diff(repo, "--stat")
602 assert result.exit_code == 0
603 assert "No differences" in result.output
604
605 def test_stat_shows_summary(self, repo: pathlib.Path) -> None:
606 (repo / "a.py").write_text("x = 50\n")
607 result = _diff(repo, "--stat")
608 assert result.exit_code == 0
609 # Should contain a human-readable summary (not empty)
610 assert result.output.strip() != ""
611 assert "No differences" not in result.output
612
613
614 # ──────────────────────────────────────────────────────────────────────────────
615 # Integration — --text (unified diff)
616 # ──────────────────────────────────────────────────────────────────────────────
617
618
619 class TestTextDiff:
620 def test_text_clean_tree(self, repo: pathlib.Path) -> None:
621 result = _diff(repo, "--text")
622 assert result.exit_code == 0
623 assert "No differences" in result.output
624
625 def test_text_modified_file_shows_diff(self, repo: pathlib.Path) -> None:
626 (repo / "a.py").write_text("x = 99\n")
627 result = _diff(repo, "--text")
628 assert "a.py" in result.output
629
630 def test_text_added_file_shown(self, repo: pathlib.Path) -> None:
631 (repo / "new.py").write_text("n = 1\n")
632 result = _diff(repo, "--text")
633 assert "new.py" in result.output
634
635 def test_text_deleted_file_shown(self, repo: pathlib.Path) -> None:
636 (repo / "b.py").write_text("b=1\n")
637 _commit(repo, "add b")
638 (repo / "b.py").unlink()
639 result = _diff(repo, "--text")
640 assert "b.py" in result.output
641
642
643 # ──────────────────────────────────────────────────────────────────────────────
644 # Integration — --path filter
645 # ──────────────────────────────────────────────────────────────────────────────
646
647
648 class TestPathFilter:
649 def test_path_filter_limits_output(self, repo: pathlib.Path) -> None:
650 (repo / "a.py").write_text("x = 99\n")
651 (repo / "b.py").write_text("b = 1\n")
652 result = _diff(repo, "--json", "-p", "a.py")
653 data = json.loads(result.output)
654 # Should show a.py changes, not b.py
655 all_paths = data["added"] + data["deleted"] + data["modified"]
656 assert all(p.startswith("a") for p in all_paths)
657
658 def test_directory_prefix_filter(self, repo: pathlib.Path) -> None:
659 (repo / "src").mkdir()
660 (repo / "src" / "foo.py").write_text("f = 1\n")
661 (repo / "other.py").write_text("o = 1\n")
662 result = _diff(repo, "--json", "-p", "src")
663 data = json.loads(result.output)
664 all_paths = data["added"] + data["deleted"] + data["modified"]
665 assert all(p.startswith("src") for p in all_paths)
666
667 def test_path_filter_with_nonexistent_path_returns_clean(
668 self, repo: pathlib.Path
669 ) -> None:
670 (repo / "a.py").write_text("x = 99\n")
671 result = _diff(repo, "--json", "-p", "nonexistent.py")
672 data = json.loads(result.output)
673 assert data["has_changes"] is False
674
675
676 # ──────────────────────────────────────────────────────────────────────────────
677 # Integration — validation
678 # ──────────────────────────────────────────────────────────────────────────────
679
680
681 class TestDiffShelf:
682 """muse diff --shelf shows the shelved changes vs HEAD."""
683
684 def test_shelf_flag_shows_shelved_changes(self, repo: pathlib.Path) -> None:
685 (repo / "a.py").write_text("x = 999\n")
686 _invoke(repo, ["shelf", "save", "-m", "test shelf"])
687 result = _diff(repo, "--shelf")
688 assert result.exit_code == 0
689 assert "a.py" in result.output
690
691 def test_shelf_flag_json_schema(self, repo: pathlib.Path) -> None:
692 (repo / "a.py").write_text("x = 999\n")
693 _invoke(repo, ["shelf", "save", "-m", "test shelf"])
694 result = _diff(repo, "--shelf", "--json")
695 assert result.exit_code == 0
696 data = json.loads(result.output)
697 assert "from_ref" in data
698 assert "to_ref" in data
699 assert data["from_ref"] == "HEAD"
700 assert "shelf" in data["to_ref"]
701
702 def test_shelf_flag_no_shelf_exits_1(self, repo: pathlib.Path) -> None:
703 result = _diff(repo, "--shelf")
704 assert result.exit_code == 1
705
706 def test_shelf_flag_with_index(self, repo: pathlib.Path) -> None:
707 # Create two shelf entries, diff the second (index 1).
708 (repo / "a.py").write_text("x = 10\n")
709 _invoke(repo, ["shelf", "save", "-m", "first"])
710 (repo / "a.py").write_text("x = 20\n")
711 _invoke(repo, ["shelf", "save", "-m", "second"])
712 # shelf/0 = second (newest), shelf/1 = first (oldest)
713 result = _diff(repo, "--shelf", "1")
714 assert result.exit_code == 0
715
716 def test_shelf_mutually_exclusive_with_staged(self, repo: pathlib.Path) -> None:
717 result = _diff(repo, "--shelf", "--staged")
718 assert result.exit_code == 1
719
720 def test_shelf_mutually_exclusive_with_unstaged(self, repo: pathlib.Path) -> None:
721 result = _diff(repo, "--shelf", "--unstaged")
722 assert result.exit_code == 1
723
724
725 class TestValidation:
726 def test_staged_and_unstaged_mutually_exclusive(self, repo: pathlib.Path) -> None:
727 result = _diff(repo, "--staged", "--unstaged")
728 assert result.exit_code == 1
729
730 def test_unknown_flag_exits_nonzero(self, repo: pathlib.Path) -> None:
731 result = _diff(repo, "--format", "xml")
732 assert result.exit_code != 0
733
734
735 # ──────────────────────────────────────────────────────────────────────────────
736 # Security — ANSI injection prevention
737 # ──────────────────────────────────────────────────────────────────────────────
738
739
740 class TestSecurityAnsi:
741 """Text output must never emit raw ANSI sequences from user-controlled input."""
742
743 def _has_ansi(self, s: str) -> bool:
744 return "\x1b[" in s or "\x1b]" in s
745
746 def test_ansi_in_commit_ref_sanitized(self, repo: pathlib.Path) -> None:
747 """An ANSI escape in a commit ref must not leak into terminal output."""
748 malicious = "\x1b[31mmalicious\x1b[0m"
749 result = _diff(repo, malicious)
750 assert not self._has_ansi(result.output)
751
752 def test_ansi_in_path_filter_handled(self, repo: pathlib.Path) -> None:
753 """An ANSI escape in --path must not leak into output."""
754 result = _diff(repo, "--json", "-p", "\x1b[31mmalicious\x1b[0m")
755 assert not self._has_ansi(result.output)
756
757 def test_text_diff_path_headers_sanitized(self, repo: pathlib.Path) -> None:
758 """The a/path and b/path headers in unified diff must be sanitized."""
759 # We can't create files with ESC in names on most OS, so test via
760 # the sanitize_display path indirectly by verifying clean output
761 (repo / "normal.py").write_text("n = 1\n")
762 result = _diff(repo, "--text")
763 assert not self._has_ansi(result.output)
764
765
766 # ──────────────────────────────────────────────────────────────────────────────
767 # End-to-end — text output
768 # ──────────────────────────────────────────────────────────────────────────────
769
770
771 class TestTextOutput:
772 def test_no_differences_on_clean_tree(self, repo: pathlib.Path) -> None:
773 result = _diff(repo)
774 assert "No differences" in result.output
775
776 def test_summary_line_on_changes(self, repo: pathlib.Path) -> None:
777 (repo / "a.py").write_text("x = 50\n")
778 result = _diff(repo)
779 # Summary line should appear after the file listing
780 assert result.output.strip() != ""
781 assert "No differences" not in result.output
782
783 def test_deleted_file_shows_d_prefix(self, repo: pathlib.Path) -> None:
784 (repo / "b.py").write_text("b=1\n")
785 _commit(repo, "add b")
786 (repo / "b.py").unlink()
787 result = _diff(repo)
788 assert "b.py" in result.output
789 # Should show D (delete) status, not A or M
790 assert "D" in result.output or "removed" in result.output.lower()
791
792
793 # ──────────────────────────────────────────────────────────────────────────────
794 # Stress — large repos
795 # ──────────────────────────────────────────────────────────────────────────────
796
797
798 @pytest.mark.slow
799 class TestStressLargeRepo:
800 def test_diff_500_files_10_changes_under_1s(self, repo: pathlib.Path) -> None:
801 for i in range(500):
802 (repo / f"f{i:04d}.py").write_text(f"x = {i}\n")
803 _commit(repo, "base")
804 for i in range(10):
805 (repo / f"f{i:04d}.py").write_text(f"x = {i * 100}\n")
806 t0 = time.perf_counter()
807 result = _diff(repo, "--json")
808 elapsed = (time.perf_counter() - t0) * 1000
809 assert result.exit_code == 0
810 data = json.loads(result.output)
811 assert data["has_changes"] is True
812 assert elapsed < 1000, f"diff took {elapsed:.0f}ms (limit 1000ms)"
813
814 def test_diff_1000_added_files(self, repo: pathlib.Path) -> None:
815 _commit(repo, "base")
816 for i in range(1000):
817 (repo / f"g{i:04d}.py").write_text(f"y = {i}\n")
818 t0 = time.perf_counter()
819 result = _diff(repo, "--json")
820 elapsed = (time.perf_counter() - t0) * 1000
821 assert result.exit_code == 0
822 data = json.loads(result.output)
823 assert data["has_changes"] is True
824 assert elapsed < 3000, f"diff took {elapsed:.0f}ms (limit 3000ms)"
825
826 def test_diff_with_100_deleted_files_correct_categorization(
827 self, repo: pathlib.Path
828 ) -> None:
829 for i in range(100):
830 (repo / f"h{i:04d}.py").write_text(f"h = {i}\n")
831 _commit(repo, "base with 100 files")
832 for i in range(100):
833 (repo / f"h{i:04d}.py").unlink()
834 result = _diff(repo, "--json")
835 data = json.loads(result.output)
836 # All 100 must be in deleted, not modified
837 assert len(data["deleted"]) == 100
838 assert len(data["modified"]) == 0
839
840
841 @pytest.mark.slow
842 class TestStressConcurrent:
843 def test_concurrent_diffs_to_separate_repos(self, tmp_path: pathlib.Path) -> None:
844 errors: list[str] = []
845
846 def do_diff(idx: int) -> None:
847 repo_dir = tmp_path / f"repo_{idx}"
848 repo_dir.mkdir()
849 subprocess.run(
850 ["muse", "init"], cwd=str(repo_dir), capture_output=True
851 )
852 (repo_dir / "x.py").write_text(f"x = {idx}\n")
853 subprocess.run(
854 ["muse", "commit", "-m", "base"],
855 cwd=str(repo_dir), capture_output=True,
856 )
857 (repo_dir / "x.py").write_text(f"x = {idx + 100}\n")
858 r = subprocess.run(
859 ["muse", "diff", "--json"],
860 cwd=str(repo_dir), capture_output=True, text=True,
861 )
862 if r.returncode != 0:
863 errors.append(f"repo_{idx}: diff failed")
864 return
865 data = json.loads(r.stdout)
866 if not data.get("has_changes"):
867 errors.append(f"repo_{idx}: expected has_changes=true")
868
869 threads = [threading.Thread(target=do_diff, args=(i,)) for i in range(8)]
870 for t in threads:
871 t.start()
872 for t in threads:
873 t.join()
874
875
876 # ──────────────────────────────────────────────────────────────────────────────
877 # TestDiffConflict — muse diff --conflict (Cohen Transform labeled diff)
878 # ──────────────────────────────────────────────────────────────────────────────
879
880
881 def _make_conflict_repo(tmp_path: pathlib.Path) -> pathlib.Path:
882 """Return a repo on *main* with an in-progress conflicting checkout -m.
883
884 The repo has:
885 - ``shared.py`` on main: line1 / line2 / line3
886 - branch ``other``: line1 / LINE2 / line3 (other changed line2)
887 - dirty workdir on main: line1 / OURS2 / line3 (ours changed line2)
888
889 Running ``checkout -m other`` produces a conflict on shared.py and writes
890 MERGE_STATE.json. The caller receives the repo in that conflicted state.
891 """
892 saved = os.getcwd()
893 try:
894 os.chdir(tmp_path)
895 runner.invoke(None, ["init"])
896 finally:
897 os.chdir(saved)
898
899 (tmp_path / "shared.py").write_text("line1\nline2\nline3\n")
900 _commit(tmp_path, "initial")
901
902 _invoke(tmp_path, ["branch", "other"])
903 _invoke(tmp_path, ["checkout", "other"])
904 (tmp_path / "shared.py").write_text("line1\nLINE2\nline3\n")
905 _commit(tmp_path, "other changes line2")
906
907 _invoke(tmp_path, ["checkout", "main"])
908 (tmp_path / "shared.py").write_text("line1\nOURS2\nline3\n")
909 # Trigger the conflicting checkout -m to create MERGE_STATE
910 _invoke(tmp_path, ["checkout", "-m", "other"])
911 return tmp_path
912
913
914 class TestDiffConflictParser:
915 """Parser-level tests for the ``--conflict`` flag."""
916
917 def _parse(self, *args: str) -> "argparse.Namespace":
918 import argparse
919
920 from muse.cli.commands.diff import register
921
922 p = argparse.ArgumentParser()
923 sub = p.add_subparsers()
924 register(sub)
925 return p.parse_args(["diff", *args])
926
927 def test_conflict_flag_parsed(self) -> None:
928 ns = self._parse("--conflict")
929 assert ns.conflict is True
930
931 def test_conflict_false_by_default(self) -> None:
932 ns = self._parse()
933 assert ns.conflict is False
934
935 def test_conflict_and_json_coexist(self) -> None:
936 ns = self._parse("--conflict", "--json")
937 assert ns.conflict is True
938 assert ns.json_out is True
939
940 def test_conflict_and_path_coexist(self) -> None:
941 ns = self._parse("--conflict", "--path", "src/")
942 assert ns.conflict is True
943 assert "src/" in ns.paths
944
945
946 class TestDiffConflictNoMerge:
947 """--conflict when no merge is in progress must error cleanly."""
948
949 def test_no_merge_in_progress_exits_1(self, repo: pathlib.Path) -> None:
950 r = _diff(repo, "--conflict")
951 assert r.exit_code == 1
952
953 def test_no_merge_error_message_on_stderr(self, repo: pathlib.Path) -> None:
954 r = _diff(repo, "--conflict")
955 assert "MERGE_STATE" in r.stderr or "merge" in r.stderr.lower()
956
957 def test_no_merge_json_also_exits_1(self, repo: pathlib.Path) -> None:
958 r = _diff(repo, "--conflict", "--json")
959 assert r.exit_code == 1
960
961
962 class TestDiffConflictOutput:
963 """--conflict with an active merge in progress."""
964
965 def test_exits_nonzero_when_conflicts_exist(self, tmp_path: pathlib.Path) -> None:
966 repo = _make_conflict_repo(tmp_path)
967 r = _diff(repo, "--conflict")
968 assert r.exit_code != 0
969
970 def test_output_mentions_conflict_file(self, tmp_path: pathlib.Path) -> None:
971 repo = _make_conflict_repo(tmp_path)
972 r = _diff(repo, "--conflict")
973 assert "shared.py" in r.output
974
975 def test_output_contains_ours_side(self, tmp_path: pathlib.Path) -> None:
976 repo = _make_conflict_repo(tmp_path)
977 r = _diff(repo, "--conflict")
978 assert "[ours]" in r.output or "ours" in r.output.lower()
979
980 def test_output_contains_theirs_side(self, tmp_path: pathlib.Path) -> None:
981 repo = _make_conflict_repo(tmp_path)
982 r = _diff(repo, "--conflict")
983 assert "[theirs]" in r.output or "theirs" in r.output.lower()
984
985 def test_output_contains_cohen_action_labels(self, tmp_path: pathlib.Path) -> None:
986 """Cohen-style hunk labels (e.g. [branchname: modified]) must appear in @@-headers."""
987 repo = _make_conflict_repo(tmp_path)
988 r = _diff(repo, "--conflict")
989 combined = r.output + r.stderr
990 # annotate_hunk_action produces [side_label: action] suffixes on @@ headers
991 assert any(
992 suffix in combined
993 for suffix in (": modified]", ": inserted]", ": deleted]")
994 )
995
996 def test_json_status_is_conflict(self, tmp_path: pathlib.Path) -> None:
997 repo = _make_conflict_repo(tmp_path)
998 r = _diff(repo, "--conflict", "--json")
999 data = json.loads(r.output)
1000 assert data["status"] == "conflict"
1001
1002 def test_json_conflicts_list_non_empty(self, tmp_path: pathlib.Path) -> None:
1003 repo = _make_conflict_repo(tmp_path)
1004 r = _diff(repo, "--conflict", "--json")
1005 data = json.loads(r.output)
1006 assert len(data["conflicts"]) >= 1
1007
1008 def test_json_conflict_entry_has_path_and_diffs(self, tmp_path: pathlib.Path) -> None:
1009 repo = _make_conflict_repo(tmp_path)
1010 r = _diff(repo, "--conflict", "--json")
1011 data = json.loads(r.output)
1012 entry = data["conflicts"][0]
1013 assert "path" in entry
1014 assert "ours_diff" in entry
1015 assert "theirs_diff" in entry
1016
1017 def test_json_labels_match_branches(self, tmp_path: pathlib.Path) -> None:
1018 repo = _make_conflict_repo(tmp_path)
1019 r = _diff(repo, "--conflict", "--json")
1020 data = json.loads(r.output)
1021 assert data["ours_label"] in ("other", "main") # one of the branch names
1022 assert data["theirs_label"] in ("other", "main")
1023
1024 def test_path_filter_limits_output(self, tmp_path: pathlib.Path) -> None:
1025 """``--path`` with a non-matching prefix must produce empty conflicts."""
1026 repo = _make_conflict_repo(tmp_path)
1027 r = _diff(repo, "--conflict", "--json", "--path", "nonexistent/")
1028 data = json.loads(r.output)
1029 assert len(data["conflicts"]) == 0
1030
1031 def test_path_filter_matching_includes_file(self, tmp_path: pathlib.Path) -> None:
1032 """``--path shared.py`` must include the conflicting file."""
1033 repo = _make_conflict_repo(tmp_path)
1034 r = _diff(repo, "--conflict", "--json", "--path", "shared.py")
1035 data = json.loads(r.output)
1036 assert any(e["path"] == "shared.py" for e in data["conflicts"])
1037
1038
1039 class TestDiffConflictSecurity:
1040 """Security: ANSI injection via branch names / paths must be sanitized."""
1041
1042 def test_ansi_in_conflict_path_sanitized(self, tmp_path: pathlib.Path) -> None:
1043 """ANSI escape sequences in a conflict file path must not reach the output.
1044
1045 The CliRunner strips ANSI from all output, so the raw escape code
1046 must not appear in the combined output string.
1047 """
1048 repo = _make_conflict_repo(tmp_path)
1049 r = _diff(repo, "--conflict")
1050 # CliRunner already strips ANSI; double-check no raw escape CSI leaks through
1051 assert "\x1b[" not in r.output
1052
1053
1054 # ──────────────────────────────────────────────────────────────────────────────
1055 # Unit — PatchOp.file_change field
1056 # ──────────────────────────────────────────────────────────────────────────────
1057
1058
1059 class TestPatchOpFileChangeField:
1060 """PatchOp gains a file_change field: 'added' | 'deleted' | 'modified'.
1061
1062 This field is set by build_diff_ops based on which path bucket the file
1063 belongs to — not inferred from child op direction after the fact.
1064 """
1065
1066 def _make_patch(self, file_change: str | None = None) -> "PatchOp":
1067 from muse.domain import PatchOp
1068
1069 kwargs = dict(
1070 op="patch",
1071 address="file.py",
1072 child_ops=[],
1073 child_domain="code",
1074 child_summary="",
1075 )
1076 if file_change is not None:
1077 kwargs["file_change"] = file_change
1078 return PatchOp(**kwargs)
1079
1080 def test_patch_op_accepts_file_change_added(self) -> None:
1081 op = self._make_patch("added")
1082 assert op["file_change"] == "added"
1083
1084 def test_patch_op_accepts_file_change_deleted(self) -> None:
1085 op = self._make_patch("deleted")
1086 assert op["file_change"] == "deleted"
1087
1088 def test_patch_op_accepts_file_change_modified(self) -> None:
1089 op = self._make_patch("modified")
1090 assert op["file_change"] == "modified"
1091
1092 def test_patch_op_file_change_is_optional(self) -> None:
1093 """Existing call sites without file_change must still work."""
1094 op = self._make_patch()
1095 assert "file_change" not in op
1096
1097
1098 # ──────────────────────────────────────────────────────────────────────────────
1099 # Unit — build_diff_ops sets file_change from path bucket
1100 # ──────────────────────────────────────────────────────────────────────────────
1101
1102
1103 class TestBuildDiffOpsFileChange:
1104 """build_diff_ops sets PatchOp.file_change from the path bucket (added /
1105 removed / modified), never from child op direction.
1106 """
1107
1108 def _trees_with_symbols(self, path: str, names: list[str]) -> Mapping[str, object]:
1109 """Minimal SymbolTree with one entry per name."""
1110 return {
1111 f"{path}::{name}": {
1112 "name": name,
1113 "kind": "function",
1114 "qualified_name": f"{path}::{name}",
1115 "lineno": 1,
1116 "end_lineno": 2,
1117 "content_id": f"c_{name}",
1118 "body_hash": f"b_{name}",
1119 "signature_id": f"s_{name}",
1120 }
1121 for name in names
1122 }
1123
1124 def test_added_file_patch_has_file_change_added(self) -> None:
1125 from muse.plugins.code.symbol_diff import build_diff_ops
1126
1127 base_files = {}
1128 target_files = {"new.py": "oid1"}
1129 base_trees = {}
1130 target_trees = {"new.py": self._trees_with_symbols("new.py", ["alpha", "beta"])}
1131
1132 ops = build_diff_ops(base_files, target_files, base_trees, target_trees)
1133 patch_ops = [o for o in ops if o["op"] == "patch"]
1134 assert len(patch_ops) == 1
1135 assert patch_ops[0]["file_change"] == "added"
1136
1137 def test_removed_file_patch_has_file_change_deleted(self) -> None:
1138 from muse.plugins.code.symbol_diff import build_diff_ops
1139
1140 base_files = {"old.py": "oid1"}
1141 target_files = {}
1142 base_trees = {"old.py": self._trees_with_symbols("old.py", ["alpha", "beta"])}
1143 target_trees = {}
1144
1145 ops = build_diff_ops(base_files, target_files, base_trees, target_trees)
1146 patch_ops = [o for o in ops if o["op"] == "patch"]
1147 assert len(patch_ops) == 1
1148 assert patch_ops[0]["file_change"] == "deleted"
1149
1150 def test_modified_file_patch_has_file_change_modified(self) -> None:
1151 from muse.plugins.code.symbol_diff import build_diff_ops
1152
1153 base_files = {"mod.py": "oid1"}
1154 target_files = {"mod.py": "oid2"}
1155 base_trees = {"mod.py": self._trees_with_symbols("mod.py", ["alpha"])}
1156 target_trees = {"mod.py": self._trees_with_symbols("mod.py", ["alpha", "beta"])}
1157
1158 ops = build_diff_ops(base_files, target_files, base_trees, target_trees)
1159 patch_ops = [o for o in ops if o["op"] == "patch"]
1160 assert len(patch_ops) == 1
1161 assert patch_ops[0]["file_change"] == "modified"
1162
1163 def test_modified_file_all_symbol_deletions_still_file_change_modified(self) -> None:
1164 """The critical case: a living file that lost all its symbols must carry
1165 file_change='modified', not 'deleted'. Child op direction must NOT
1166 determine file status."""
1167 from muse.plugins.code.symbol_diff import build_diff_ops
1168
1169 base_files = {"shrunk.py": "oid1"}
1170 target_files = {"shrunk.py": "oid2"} # file still exists
1171 base_trees = {"shrunk.py": self._trees_with_symbols("shrunk.py", ["alpha", "beta", "gamma"])}
1172 target_trees = {"shrunk.py": {}} # all symbols gone, but file lives
1173
1174 ops = build_diff_ops(base_files, target_files, base_trees, target_trees)
1175 patch_ops = [o for o in ops if o["op"] == "patch"]
1176 assert len(patch_ops) == 1, f"Expected 1 PatchOp, got: {ops}"
1177 assert patch_ops[0]["file_change"] == "modified", (
1178 f"Living file with all-delete children must be 'modified', "
1179 f"got {patch_ops[0].get('file_change')!r}"
1180 )
1181
1182 def test_modified_file_all_symbol_additions_still_file_change_modified(self) -> None:
1183 """A living file that gained all new symbols is 'modified', not 'added'."""
1184 from muse.plugins.code.symbol_diff import build_diff_ops
1185
1186 base_files = {"grew.py": "oid1"}
1187 target_files = {"grew.py": "oid2"}
1188 base_trees = {"grew.py": {}} # was empty (no symbols)
1189 target_trees = {"grew.py": self._trees_with_symbols("grew.py", ["alpha", "beta"])}
1190
1191 ops = build_diff_ops(base_files, target_files, base_trees, target_trees)
1192 patch_ops = [o for o in ops if o["op"] == "patch"]
1193 assert len(patch_ops) == 1
1194 assert patch_ops[0]["file_change"] == "modified", (
1195 f"Living file with all-insert children must be 'modified', "
1196 f"got {patch_ops[0].get('file_change')!r}"
1197 )
1198
1199
1200 # ──────────────────────────────────────────────────────────────────────────────
1201 # Integration — file-level sigil correctness (the AX bug fix)
1202 # ──────────────────────────────────────────────────────────────────────────────
1203
1204
1205 class TestFileSignilCorrectnessTextOutput:
1206 """Text output: the file-level sigil (A/D/M/R) must reflect whether the
1207 file was added, deleted, or modified — never inferred from child op counts.
1208
1209 Historically, a modified file that lost all its functions showed 'D' in
1210 the diff output (misread as 'file deleted' by agents). After the fix,
1211 that file must show 'M'.
1212 """
1213
1214 def test_modified_file_losing_all_symbols_shows_M_sigil(
1215 self, repo: pathlib.Path
1216 ) -> None:
1217 """Living file that lost all named functions → M, not D."""
1218 (repo / "funcs.py").write_text(
1219 "def alpha():\n return 1\n\ndef beta():\n return 2\n"
1220 )
1221 _commit(repo, "add funcs")
1222
1223 # Overwrite with content that has no recognised symbols.
1224 (repo / "funcs.py").write_text("# no functions here\nPLACEHOLDER = True\n")
1225
1226 result = _diff(repo)
1227 lines = result.output.splitlines()
1228 file_line = next(
1229 (l for l in lines if "funcs.py" in l
1230 and not l.strip().startswith("├─")
1231 and not l.strip().startswith("└─")),
1232 None,
1233 )
1234 assert file_line is not None, f"funcs.py not in diff:\n{result.output}"
1235 assert file_line.strip().startswith("M"), (
1236 f"Expected 'M funcs.py' (modified), got: {file_line!r}\n"
1237 f"Full output:\n{result.output}"
1238 )
1239
1240 def test_modified_file_gaining_all_symbols_shows_M_sigil(
1241 self, repo: pathlib.Path
1242 ) -> None:
1243 """Living file that gained functions → M, not A."""
1244 # Start with a file that has no functions
1245 (repo / "empty.py").write_text("PLACEHOLDER = True\n")
1246 _commit(repo, "add empty.py")
1247
1248 # Add functions to it
1249 (repo / "empty.py").write_text(
1250 "def alpha():\n return 1\n\ndef beta():\n return 2\n"
1251 )
1252
1253 result = _diff(repo)
1254 lines = result.output.splitlines()
1255 file_line = next(
1256 (l for l in lines if "empty.py" in l
1257 and not l.strip().startswith("├─")
1258 and not l.strip().startswith("└─")),
1259 None,
1260 )
1261 assert file_line is not None, f"empty.py not in diff:\n{result.output}"
1262 assert file_line.strip().startswith("M"), (
1263 f"Expected 'M empty.py' (modified), got: {file_line!r}\n"
1264 f"Full output:\n{result.output}"
1265 )
1266
1267 def test_actually_deleted_file_still_shows_D_sigil(
1268 self, repo: pathlib.Path
1269 ) -> None:
1270 """Sanity check: a file that is truly gone still shows D."""
1271 (repo / "gone.py").write_text("def foo(): pass\n")
1272 _commit(repo, "add gone")
1273 (repo / "gone.py").unlink()
1274
1275 result = _diff(repo)
1276 lines = result.output.splitlines()
1277 file_line = next(
1278 (l for l in lines if "gone.py" in l
1279 and not l.strip().startswith("├─")
1280 and not l.strip().startswith("└─")),
1281 None,
1282 )
1283 assert file_line is not None
1284 assert file_line.strip().startswith("D"), (
1285 f"Expected 'D gone.py' (deleted), got: {file_line!r}"
1286 )
1287
1288 def test_newly_added_file_still_shows_A_sigil(
1289 self, repo: pathlib.Path
1290 ) -> None:
1291 """Sanity check: a brand-new file still shows A."""
1292 (repo / "brand_new.py").write_text("def foo(): pass\n")
1293
1294 result = _diff(repo)
1295 lines = result.output.splitlines()
1296 file_line = next(
1297 (l for l in lines if "brand_new.py" in l
1298 and not l.strip().startswith("├─")
1299 and not l.strip().startswith("└─")),
1300 None,
1301 )
1302 assert file_line is not None
1303 assert file_line.strip().startswith("A"), (
1304 f"Expected 'A brand_new.py' (added), got: {file_line!r}"
1305 )
1306
1307
1308 class TestFileSignilCorrectnessJsonOutput:
1309 """JSON output must categorize files by actual existence, not child op direction."""
1310
1311 def test_modified_file_losing_all_symbols_in_modified_not_deleted(
1312 self, repo: pathlib.Path
1313 ) -> None:
1314 (repo / "funcs.py").write_text(
1315 "def alpha():\n return 1\n\ndef beta():\n return 2\n"
1316 )
1317 _commit(repo, "add funcs")
1318 (repo / "funcs.py").write_text("# no functions here\nPLACEHOLDER = True\n")
1319
1320 result = _diff(repo, "--json")
1321 data = json.loads(result.output)
1322 assert "funcs.py" in data["modified"], (
1323 f"funcs.py must be in modified, got: {data}"
1324 )
1325 assert "funcs.py" not in data["deleted"], (
1326 f"funcs.py must NOT be in deleted (file still exists), got: {data}"
1327 )
1328
1329 def test_modified_file_gaining_all_symbols_in_modified_not_added(
1330 self, repo: pathlib.Path
1331 ) -> None:
1332 (repo / "empty.py").write_text("PLACEHOLDER = True\n")
1333 _commit(repo, "add empty.py")
1334 (repo / "empty.py").write_text(
1335 "def alpha():\n return 1\n\ndef beta():\n return 2\n"
1336 )
1337
1338 result = _diff(repo, "--json")
1339 data = json.loads(result.output)
1340 assert "empty.py" in data["modified"], (
1341 f"empty.py must be in modified, got: {data}"
1342 )
1343 assert "empty.py" not in data["added"], (
1344 f"empty.py must NOT be in added (file pre-existed), got: {data}"
1345 )
1346
1347
1348 # ──────────────────────────────────────────────────────────────────────────────
1349 # Dead-code removal — _classify_patch_op and _op_category must be deleted
1350 # ──────────────────────────────────────────────────────────────────────────────
1351
1352
1353 class TestInferenceHelpersRemoved:
1354 """_classify_patch_op and _op_category exist only to infer file status from
1355 child op counts. Once PatchOp.file_change carries authoritative status,
1356 both helpers are dead code and must be deleted.
1357 """
1358
1359 def test_classify_patch_op_deleted(self) -> None:
1360 import muse.cli.commands.diff as m
1361
1362 assert not hasattr(m, "_classify_patch_op"), (
1363 "_classify_patch_op is dead code — file status is now read from "
1364 "PatchOp.file_change, not inferred from child ops"
1365 )
1366
1367 def test_op_category_deleted(self) -> None:
1368 import muse.cli.commands.diff as m
1369
1370 assert not hasattr(m, "_op_category"), (
1371 "_op_category is dead code — it existed only to wrap _classify_patch_op"
1372 )
File History 1 commit
sha256:72de78de7f68004fd4d89fd1ed31e96734ade136417ac0bc09c151c955540474 feat: muse diff <refA> <refB> labels which branch introduce… Sonnet 5 patch 2 days ago