gabriel / muse public
test_cmd_diff.py python
1,079 lines 44.9 KB
Raw
1 """Comprehensive tests for ``muse diff``.
2
3 Coverage tiers
4 --------------
5 Unit — parser flags, _classify_patch_op, _op_category, _filter_manifest,
6 _use_color, dead-code removal.
7 Integration — HEAD vs working tree, staged, unstaged, two-commit diff,
8 path filtering, --stat, --text, added/deleted/modified counts.
9 End-to-end — CLI invocations: text and JSON output, --exit-code, --json.
10 Security — ANSI injection in paths, commit refs, 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 — _classify_patch_op
148 # ──────────────────────────────────────────────────────────────────────────────
149
150
151 class TestClassifyPatchOp:
152 def _make_insert_op(self) -> "InsertOp":
153 from muse.domain import InsertOp
154
155 return InsertOp(
156 op="insert", address="x", position=0,
157 content_id="a" * 64, content_summary="added x",
158 )
159
160 def _make_delete_op(self) -> "DeleteOp":
161 from muse.domain import DeleteOp
162
163 return DeleteOp(
164 op="delete", address="y", position=0,
165 content_id="b" * 64, content_summary="removed y",
166 )
167
168 def _make_patch(self, *child_ops: "DomainOp") -> "PatchOp":
169 from muse.domain import PatchOp
170
171 return PatchOp(
172 op="patch",
173 address="file.py",
174 child_ops=list(child_ops),
175 child_domain="code",
176 child_summary="",
177 )
178
179 def test_all_insert_returns_insert(self) -> None:
180 from muse.cli.commands.diff import _classify_patch_op
181
182 op = self._make_patch(self._make_insert_op())
183 assert _classify_patch_op(op) == "insert"
184
185 def test_all_delete_returns_delete(self) -> None:
186 from muse.cli.commands.diff import _classify_patch_op
187
188 op = self._make_patch(self._make_delete_op())
189 assert _classify_patch_op(op) == "delete"
190
191 def test_mixed_returns_replace(self) -> None:
192 from muse.cli.commands.diff import _classify_patch_op
193
194 op = self._make_patch(self._make_insert_op(), self._make_delete_op())
195 assert _classify_patch_op(op) == "replace"
196
197 def test_empty_child_ops_returns_replace(self) -> None:
198 from muse.cli.commands.diff import _classify_patch_op
199
200 op = self._make_patch()
201 assert _classify_patch_op(op) == "replace"
202
203
204 # ──────────────────────────────────────────────────────────────────────────────
205 # Unit — _op_category
206 # ──────────────────────────────────────────────────────────────────────────────
207
208
209 class TestOpCategory:
210 def _make_insert(self) -> "InsertOp":
211 from muse.domain import InsertOp
212
213 return InsertOp(
214 op="insert", address="f.py", position=0,
215 content_id="a" * 64, content_summary="added x",
216 )
217
218 def _make_delete(self) -> "DeleteOp":
219 from muse.domain import DeleteOp
220
221 return DeleteOp(
222 op="delete", address="f.py", position=0,
223 content_id="b" * 64, content_summary="removed x",
224 )
225
226 def _make_replace(self) -> "ReplaceOp":
227 from muse.domain import ReplaceOp
228
229 return ReplaceOp(
230 op="replace", address="f.py", position=None,
231 old_content_id="a" * 64, new_content_id="b" * 64,
232 old_summary="old", new_summary="new",
233 )
234
235 def _make_move(self) -> "MoveOp":
236 from muse.domain import MoveOp
237
238 return MoveOp(
239 op="move", address="f.py", from_position=0, to_position=1,
240 content_id="c" * 64,
241 )
242
243 def test_insert_op(self) -> None:
244 from muse.cli.commands.diff import _op_category
245
246 assert _op_category(self._make_insert()) == "insert"
247
248 def test_delete_op(self) -> None:
249 from muse.cli.commands.diff import _op_category
250
251 assert _op_category(self._make_delete()) == "delete"
252
253 def test_replace_op(self) -> None:
254 from muse.cli.commands.diff import _op_category
255
256 assert _op_category(self._make_replace()) == "replace"
257
258 def test_move_op_returns_replace(self) -> None:
259 from muse.cli.commands.diff import _op_category
260
261 assert _op_category(self._make_move()) == "replace"
262
263 def test_patch_with_all_inserts_returns_insert(self) -> None:
264 from muse.cli.commands.diff import _op_category
265 from muse.domain import PatchOp
266
267 op = PatchOp(
268 op="patch", address="f.py",
269 child_ops=[self._make_insert()],
270 child_domain="code", child_summary="",
271 )
272 assert _op_category(op) == "insert"
273
274 def test_patch_with_all_deletes_returns_delete(self) -> None:
275 from muse.cli.commands.diff import _op_category
276 from muse.domain import PatchOp
277
278 op = PatchOp(
279 op="patch", address="f.py",
280 child_ops=[self._make_delete()],
281 child_domain="code", child_summary="",
282 )
283 assert _op_category(op) == "delete"
284
285
286 # ──────────────────────────────────────────────────────────────────────────────
287 # Unit — _filter_manifest
288 # ──────────────────────────────────────────────────────────────────────────────
289
290
291 class TestFilterManifest:
292 def test_empty_paths_returns_all(self) -> None:
293 from muse.cli.commands.diff import _filter_manifest
294
295 m = {"a.py": "oid1", "b.py": "oid2"}
296 assert _filter_manifest(m, []) == m
297
298 def test_exact_file_match(self) -> None:
299 from muse.cli.commands.diff import _filter_manifest
300
301 m = {"a.py": "oid1", "b.py": "oid2"}
302 result = _filter_manifest(m, ["a.py"])
303 assert result == {"a.py": "oid1"}
304
305 def test_directory_prefix_match(self) -> None:
306 from muse.cli.commands.diff import _filter_manifest
307
308 m = {
309 "src/foo.py": "oid1",
310 "src/bar.py": "oid2",
311 "tests/test_foo.py": "oid3",
312 }
313 result = _filter_manifest(m, ["src"])
314 assert set(result) == {"src/foo.py", "src/bar.py"}
315
316 def test_trailing_slash_normalised(self) -> None:
317 from muse.cli.commands.diff import _filter_manifest
318
319 m = {"src/foo.py": "oid1", "other.py": "oid2"}
320 assert _filter_manifest(m, ["src/"]) == {"src/foo.py": "oid1"}
321
322 def test_multiple_paths(self) -> None:
323 from muse.cli.commands.diff import _filter_manifest
324
325 m = {"a.py": "oid1", "b.py": "oid2", "c.py": "oid3"}
326 result = _filter_manifest(m, ["a.py", "c.py"])
327 assert set(result) == {"a.py", "c.py"}
328
329 def test_no_match_returns_empty(self) -> None:
330 from muse.cli.commands.diff import _filter_manifest
331
332 m = {"a.py": "oid1"}
333 assert _filter_manifest(m, ["z.py"]) == {}
334
335
336 # ──────────────────────────────────────────────────────────────────────────────
337 # Unit — _use_color
338 # ──────────────────────────────────────────────────────────────────────────────
339
340
341 class TestUseColor:
342 def test_no_color_env_disables_color(
343 self, monkeypatch: pytest.MonkeyPatch
344 ) -> None:
345 from muse.cli.commands.diff import _use_color
346
347 monkeypatch.setenv("NO_COLOR", "1")
348 assert _use_color() is False
349
350 def test_dumb_term_disables_color(
351 self, monkeypatch: pytest.MonkeyPatch
352 ) -> None:
353 from muse.cli.commands.diff import _use_color
354
355 monkeypatch.setenv("TERM", "dumb")
356 assert _use_color() is False
357
358 def test_no_color_env_unset_does_not_force_color(
359 self, monkeypatch: pytest.MonkeyPatch
360 ) -> None:
361 from muse.cli.commands.diff import _use_color
362
363 monkeypatch.delenv("NO_COLOR", raising=False)
364 monkeypatch.delenv("TERM", raising=False)
365 # stdout is not a TTY in test; just verify the function returns a bool
366 assert isinstance(_use_color(), bool)
367
368
369 # ──────────────────────────────────────────────────────────────────────────────
370 # Integration — HEAD vs working tree
371 # ──────────────────────────────────────────────────────────────────────────────
372
373
374 class TestHeadVsWorkingTree:
375 def test_clean_tree_exits_0(self, repo: pathlib.Path) -> None:
376 result = _diff(repo)
377 assert result.exit_code == 0
378 assert "No differences" in result.output
379
380 def test_modified_file_detected(self, repo: pathlib.Path) -> None:
381 (repo / "a.py").write_text("x = 99\n")
382 result = _diff(repo)
383 assert result.exit_code == 0
384 assert "a.py" in result.output
385
386 def test_added_file_detected(self, repo: pathlib.Path) -> None:
387 (repo / "new.py").write_text("z = 0\n")
388 result = _diff(repo)
389 assert "new.py" in result.output
390
391 def test_deleted_file_detected(self, repo: pathlib.Path) -> None:
392 (repo / "b.py").write_text("b = 1\n")
393 _commit(repo, "add b")
394 (repo / "b.py").unlink()
395 result = _diff(repo)
396 assert "b.py" in result.output
397
398
399 # ──────────────────────────────────────────────────────────────────────────────
400 # Integration — JSON schema (including critical bug fix)
401 # ──────────────────────────────────────────────────────────────────────────────
402
403
404 class TestJsonSchema:
405 """All keys agents depend on must be present."""
406
407 REQUIRED_KEYS = {
408 "from_ref",
409 "to_ref",
410 "from_commit_id",
411 "to_commit_id",
412 "has_changes",
413 "summary",
414 "added",
415 "deleted",
416 "modified",
417 "total_changes",
418 }
419
420 def test_clean_tree_json_keys(self, repo: pathlib.Path) -> None:
421 result = _diff(repo, "--json")
422 assert result.exit_code == 0
423 data = json.loads(result.output)
424 missing = self.REQUIRED_KEYS - set(data)
425 assert not missing, f"Missing keys: {missing}"
426
427 def test_has_changes_false_on_clean_tree(self, repo: pathlib.Path) -> None:
428 result = _diff(repo, "--json")
429 data = json.loads(result.output)
430 assert data["has_changes"] is False
431
432 def test_has_changes_true_on_modified(self, repo: pathlib.Path) -> None:
433 (repo / "a.py").write_text("x = 99\n")
434 result = _diff(repo, "--json")
435 data = json.loads(result.output)
436 assert data["has_changes"] is True
437
438 def test_from_commit_id_present(self, repo: pathlib.Path) -> None:
439 result = _diff(repo, "--json")
440 data = json.loads(result.output)
441 # from_commit_id should be the HEAD commit SHA
442 assert data["from_commit_id"] is not None
443 assert len(data["from_commit_id"]) == 64 # SHA-256
444
445 def test_to_commit_id_null_for_workdir_diff(self, repo: pathlib.Path) -> None:
446 result = _diff(repo, "--json")
447 data = json.loads(result.output)
448 assert data["to_commit_id"] is None
449
450 # ── Critical bug fix: deleted files must be in "deleted", not "modified" ──
451
452 def test_deleted_file_in_deleted_list_not_modified(self, repo: pathlib.Path) -> None:
453 """Regression test: files deleted from the working tree must appear in
454 ``deleted``, not ``modified``. The plugin emits a ``patch`` op with
455 all-delete child ops for file deletions; the JSON categorizer must
456 recognise this."""
457 (repo / "b.py").write_text("b = 2\n")
458 _commit(repo, "add b")
459 (repo / "b.py").unlink()
460 result = _diff(repo, "--json")
461 data = json.loads(result.output)
462 assert "b.py" in data["deleted"], f"b.py not in deleted: {data}"
463 assert "b.py" not in data["modified"], f"b.py wrongly in modified: {data}"
464
465 def test_added_file_in_added_list_not_modified(self, repo: pathlib.Path) -> None:
466 """New files must appear in ``added``, not ``modified``."""
467 (repo / "new.py").write_text("n = 1\n")
468 result = _diff(repo, "--json")
469 data = json.loads(result.output)
470 assert "new.py" in data["added"], f"new.py not in added: {data}"
471 assert "new.py" not in data["modified"], f"new.py wrongly in modified: {data}"
472
473 def test_modified_file_in_modified_list(self, repo: pathlib.Path) -> None:
474 (repo / "a.py").write_text("x = 999\n")
475 result = _diff(repo, "--json")
476 data = json.loads(result.output)
477 assert "a.py" in data["modified"]
478 assert "a.py" not in data["added"]
479 assert "a.py" not in data["deleted"]
480
481 def test_combined_add_delete_modify(self, repo: pathlib.Path) -> None:
482 """All three categories correct simultaneously."""
483 (repo / "b.py").write_text("b = 2\n")
484 (repo / "c.py").write_text("c = 3\n")
485 _commit(repo, "add b and c")
486 (repo / "b.py").unlink() # deleted
487 (repo / "c.py").write_text("c = 99\n") # modified
488 (repo / "d.py").write_text("d = 4\n") # added
489 result = _diff(repo, "--json")
490 data = json.loads(result.output)
491 assert "b.py" in data["deleted"]
492 assert "c.py" in data["modified"]
493 assert "d.py" in data["added"]
494
495 def test_added_list_is_sorted(self, repo: pathlib.Path) -> None:
496 for name in ["z.py", "a2.py", "m.py"]:
497 (repo / name).write_text(f"x=1\n")
498 result = _diff(repo, "--json")
499 data = json.loads(result.output)
500 assert data["added"] == sorted(data["added"])
501
502 def test_from_ref_is_head(self, repo: pathlib.Path) -> None:
503 result = _diff(repo, "--json")
504 data = json.loads(result.output)
505 assert data["from_ref"] == "HEAD"
506
507 def test_to_ref_is_working_tree(self, repo: pathlib.Path) -> None:
508 result = _diff(repo, "--json")
509 data = json.loads(result.output)
510 assert data["to_ref"] == "working tree"
511
512 def test_total_changes_matches_op_count(self, repo: pathlib.Path) -> None:
513 (repo / "a.py").write_text("x = 50\n")
514 (repo / "b.py").write_text("b = 1\n")
515 result = _diff(repo, "--json")
516 data = json.loads(result.output)
517 total = len(data["added"]) + len(data["deleted"]) + len(data["modified"])
518 # total_changes counts plugin ops, not files; it can exceed the file count
519 # if a file has multiple symbol ops, but it should be >= file count.
520 assert data["total_changes"] >= total
521
522 def test_two_commit_diff_has_commit_ids(self, repo: pathlib.Path) -> None:
523 from muse.core.store import get_head_commit_id
524
525 cid1 = get_head_commit_id(repo, "main")
526 (repo / "b.py").write_text("b = 1\n")
527 _commit(repo, "second")
528 cid2 = get_head_commit_id(repo, "main")
529 result = _diff(repo, cid1 or "", cid2 or "", "--json")
530 data = json.loads(result.output)
531 assert data["from_commit_id"] == cid1
532 assert data["to_commit_id"] == cid2
533
534
535 # ──────────────────────────────────────────────────────────────────────────────
536 # Integration — --exit-code
537 # ──────────────────────────────────────────────────────────────────────────────
538
539
540 class TestExitCode:
541 def test_exit_code_0_on_clean_tree(self, repo: pathlib.Path) -> None:
542 result = _diff(repo, "--exit-code")
543 assert result.exit_code == 0
544
545 def test_exit_code_1_when_changes(self, repo: pathlib.Path) -> None:
546 (repo / "a.py").write_text("x = 99\n")
547 result = _diff(repo, "--exit-code")
548 assert result.exit_code == 1
549
550 def test_exit_code_with_json_clean(self, repo: pathlib.Path) -> None:
551 result = _diff(repo, "--exit-code", "--json")
552 assert result.exit_code == 0
553 data = json.loads(result.output)
554 assert data["has_changes"] is False
555
556 def test_exit_code_with_json_dirty(self, repo: pathlib.Path) -> None:
557 (repo / "a.py").write_text("x = 99\n")
558 result = _diff(repo, "--exit-code", "--json")
559 assert result.exit_code == 1
560 data = json.loads(result.output)
561 assert data["has_changes"] is True
562
563 def test_exit_code_with_stat_clean(self, repo: pathlib.Path) -> None:
564 result = _diff(repo, "--exit-code", "--stat")
565 assert result.exit_code == 0
566
567 def test_exit_code_with_stat_dirty(self, repo: pathlib.Path) -> None:
568 (repo / "a.py").write_text("x = 99\n")
569 result = _diff(repo, "--exit-code", "--stat")
570 assert result.exit_code == 1
571
572 def test_exit_code_with_text_dirty(self, repo: pathlib.Path) -> None:
573 (repo / "a.py").write_text("x = 99\n")
574 result = _diff(repo, "--exit-code", "--text")
575 assert result.exit_code == 1
576
577 def test_exit_code_with_text_clean(self, repo: pathlib.Path) -> None:
578 result = _diff(repo, "--exit-code", "--text")
579 assert result.exit_code == 0
580
581
582 # ──────────────────────────────────────────────────────────────────────────────
583 # Integration — two-commit diff
584 # ──────────────────────────────────────────────────────────────────────────────
585
586
587 class TestTwoCommitDiff:
588 def test_two_commits_exits_0(self, repo: pathlib.Path) -> None:
589 from muse.core.store import get_head_commit_id
590
591 cid1 = get_head_commit_id(repo, "main")
592 (repo / "b.py").write_text("b=1\n")
593 _commit(repo, "second")
594 cid2 = get_head_commit_id(repo, "main")
595 result = _diff(repo, cid1 or "", cid2 or "")
596 assert result.exit_code == 0
597
598 def test_two_identical_commits_no_differences(self, repo: pathlib.Path) -> None:
599 from muse.core.store import get_head_commit_id
600
601 cid = get_head_commit_id(repo, "main")
602 result = _diff(repo, cid or "", cid or "")
603 assert "No differences" in result.output
604
605 def test_invalid_commit_ref_exits_1(self, repo: pathlib.Path) -> None:
606 result = _diff(repo, "deadbeefdeadbeef")
607 assert result.exit_code == 1
608
609
610 # ──────────────────────────────────────────────────────────────────────────────
611 # Integration — --stat
612 # ──────────────────────────────────────────────────────────────────────────────
613
614
615 class TestStat:
616 def test_stat_clean_tree(self, repo: pathlib.Path) -> None:
617 result = _diff(repo, "--stat")
618 assert result.exit_code == 0
619 assert "No differences" in result.output
620
621 def test_stat_shows_summary(self, repo: pathlib.Path) -> None:
622 (repo / "a.py").write_text("x = 50\n")
623 result = _diff(repo, "--stat")
624 assert result.exit_code == 0
625 # Should contain a human-readable summary (not empty)
626 assert result.output.strip() != ""
627 assert "No differences" not in result.output
628
629
630 # ──────────────────────────────────────────────────────────────────────────────
631 # Integration — --text (unified diff)
632 # ──────────────────────────────────────────────────────────────────────────────
633
634
635 class TestTextDiff:
636 def test_text_clean_tree(self, repo: pathlib.Path) -> None:
637 result = _diff(repo, "--text")
638 assert result.exit_code == 0
639 assert "No differences" in result.output
640
641 def test_text_modified_file_shows_diff(self, repo: pathlib.Path) -> None:
642 (repo / "a.py").write_text("x = 99\n")
643 result = _diff(repo, "--text")
644 assert "a.py" in result.output
645
646 def test_text_added_file_shown(self, repo: pathlib.Path) -> None:
647 (repo / "new.py").write_text("n = 1\n")
648 result = _diff(repo, "--text")
649 assert "new.py" in result.output
650
651 def test_text_deleted_file_shown(self, repo: pathlib.Path) -> None:
652 (repo / "b.py").write_text("b=1\n")
653 _commit(repo, "add b")
654 (repo / "b.py").unlink()
655 result = _diff(repo, "--text")
656 assert "b.py" in result.output
657
658
659 # ──────────────────────────────────────────────────────────────────────────────
660 # Integration — --path filter
661 # ──────────────────────────────────────────────────────────────────────────────
662
663
664 class TestPathFilter:
665 def test_path_filter_limits_output(self, repo: pathlib.Path) -> None:
666 (repo / "a.py").write_text("x = 99\n")
667 (repo / "b.py").write_text("b = 1\n")
668 result = _diff(repo, "--json", "-p", "a.py")
669 data = json.loads(result.output)
670 # Should show a.py changes, not b.py
671 all_paths = data["added"] + data["deleted"] + data["modified"]
672 assert all(p.startswith("a") for p in all_paths)
673
674 def test_directory_prefix_filter(self, repo: pathlib.Path) -> None:
675 (repo / "src").mkdir()
676 (repo / "src" / "foo.py").write_text("f = 1\n")
677 (repo / "other.py").write_text("o = 1\n")
678 result = _diff(repo, "--json", "-p", "src")
679 data = json.loads(result.output)
680 all_paths = data["added"] + data["deleted"] + data["modified"]
681 assert all(p.startswith("src") for p in all_paths)
682
683 def test_path_filter_with_nonexistent_path_returns_clean(
684 self, repo: pathlib.Path
685 ) -> None:
686 (repo / "a.py").write_text("x = 99\n")
687 result = _diff(repo, "--json", "-p", "nonexistent.py")
688 data = json.loads(result.output)
689 assert data["has_changes"] is False
690
691
692 # ──────────────────────────────────────────────────────────────────────────────
693 # Integration — validation
694 # ──────────────────────────────────────────────────────────────────────────────
695
696
697 class TestDiffStash:
698 """muse diff --stash shows the stashed changes vs HEAD."""
699
700 def test_stash_flag_shows_stashed_changes(self, repo: pathlib.Path) -> None:
701 # Modify a tracked file, stash it, then diff the stash
702 (repo / "a.py").write_text("x = 999\n")
703 _invoke(repo, ["stash", "-m", "test stash"])
704 result = _diff(repo, "--stash")
705 assert result.exit_code == 0
706 assert "a.py" in result.output
707
708 def test_stash_flag_json_schema(self, repo: pathlib.Path) -> None:
709 (repo / "a.py").write_text("x = 999\n")
710 _invoke(repo, ["stash", "-m", "test stash"])
711 result = _diff(repo, "--stash", "--json")
712 assert result.exit_code == 0
713 data = json.loads(result.output)
714 assert "from_ref" in data
715 assert "to_ref" in data
716 assert data["from_ref"] == "HEAD"
717 assert "stash" in data["to_ref"]
718
719 def test_stash_flag_no_stash_exits_1(self, repo: pathlib.Path) -> None:
720 # No stash entries — should exit with a clear error
721 result = _diff(repo, "--stash")
722 assert result.exit_code == 1
723
724 def test_stash_flag_with_index(self, repo: pathlib.Path) -> None:
725 # Create two stash entries, diff the second (index 1).
726 # First stash: modify a.py and stash.
727 (repo / "a.py").write_text("x = 10\n")
728 _invoke(repo, ["stash", "-m", "first"])
729 # Second stash: modify a.py again (now restored to HEAD after first pop).
730 (repo / "a.py").write_text("x = 20\n")
731 _invoke(repo, ["stash", "-m", "second"])
732 # stash@{0} = second, stash@{1} = first
733 result = _diff(repo, "--stash", "1")
734 assert result.exit_code == 0
735
736 def test_stash_mutually_exclusive_with_staged(self, repo: pathlib.Path) -> None:
737 result = _diff(repo, "--stash", "--staged")
738 assert result.exit_code == 1
739
740 def test_stash_mutually_exclusive_with_unstaged(self, repo: pathlib.Path) -> None:
741 result = _diff(repo, "--stash", "--unstaged")
742 assert result.exit_code == 1
743
744
745 class TestValidation:
746 def test_staged_and_unstaged_mutually_exclusive(self, repo: pathlib.Path) -> None:
747 result = _diff(repo, "--staged", "--unstaged")
748 assert result.exit_code == 1
749
750 def test_unknown_format_exits_1(self, repo: pathlib.Path) -> None:
751 result = _diff(repo, "--format", "xml")
752 assert result.exit_code == 1
753
754 def test_unknown_format_sanitized_in_error(self, repo: pathlib.Path) -> None:
755 result = _diff(repo, "--format", "\x1b[31mxml\x1b[0m")
756 assert "\x1b" not in result.output
757
758
759 # ──────────────────────────────────────────────────────────────────────────────
760 # Security — ANSI injection prevention
761 # ──────────────────────────────────────────────────────────────────────────────
762
763
764 class TestSecurityAnsi:
765 """Text output must never emit raw ANSI sequences from user-controlled input."""
766
767 def _has_ansi(self, s: str) -> bool:
768 return "\x1b[" in s or "\x1b]" in s
769
770 def test_ansi_in_format_flag_sanitized(self, repo: pathlib.Path) -> None:
771 result = _diff(repo, "--format", "\x1b[31mxml\x1b[0m")
772 assert not self._has_ansi(result.output)
773
774 def test_ansi_in_commit_ref_sanitized(self, repo: pathlib.Path) -> None:
775 """An ANSI escape in a commit ref must not leak into terminal output."""
776 evil = "\x1b[31mevil\x1b[0m"
777 result = _diff(repo, evil)
778 assert not self._has_ansi(result.output)
779
780 def test_ansi_in_path_filter_handled(self, repo: pathlib.Path) -> None:
781 """An ANSI escape in --path must not leak into output."""
782 result = _diff(repo, "--json", "-p", "\x1b[31mevil\x1b[0m")
783 assert not self._has_ansi(result.output)
784
785 def test_text_diff_path_headers_sanitized(self, repo: pathlib.Path) -> None:
786 """The a/path and b/path headers in unified diff must be sanitized."""
787 # We can't create files with ESC in names on most OS, so test via
788 # the sanitize_display path indirectly by verifying clean output
789 (repo / "normal.py").write_text("n = 1\n")
790 result = _diff(repo, "--text")
791 assert not self._has_ansi(result.output)
792
793
794 # ──────────────────────────────────────────────────────────────────────────────
795 # End-to-end — text output
796 # ──────────────────────────────────────────────────────────────────────────────
797
798
799 class TestTextOutput:
800 def test_no_differences_on_clean_tree(self, repo: pathlib.Path) -> None:
801 result = _diff(repo)
802 assert "No differences" in result.output
803
804 def test_summary_line_on_changes(self, repo: pathlib.Path) -> None:
805 (repo / "a.py").write_text("x = 50\n")
806 result = _diff(repo)
807 # Summary line should appear after the file listing
808 assert result.output.strip() != ""
809 assert "No differences" not in result.output
810
811 def test_deleted_file_shows_d_prefix(self, repo: pathlib.Path) -> None:
812 (repo / "b.py").write_text("b=1\n")
813 _commit(repo, "add b")
814 (repo / "b.py").unlink()
815 result = _diff(repo)
816 assert "b.py" in result.output
817 # Should show D (delete) status, not A or M
818 assert "D" in result.output or "removed" in result.output.lower()
819
820
821 # ──────────────────────────────────────────────────────────────────────────────
822 # Stress — large repos
823 # ──────────────────────────────────────────────────────────────────────────────
824
825
826 @pytest.mark.slow
827 class TestStressLargeRepo:
828 def test_diff_500_files_10_changes_under_1s(self, repo: pathlib.Path) -> None:
829 for i in range(500):
830 (repo / f"f{i:04d}.py").write_text(f"x = {i}\n")
831 _commit(repo, "base")
832 for i in range(10):
833 (repo / f"f{i:04d}.py").write_text(f"x = {i * 100}\n")
834 t0 = time.perf_counter()
835 result = _diff(repo, "--json")
836 elapsed = (time.perf_counter() - t0) * 1000
837 assert result.exit_code == 0
838 data = json.loads(result.output)
839 assert data["has_changes"] is True
840 assert elapsed < 1000, f"diff took {elapsed:.0f}ms (limit 1000ms)"
841
842 def test_diff_1000_added_files(self, repo: pathlib.Path) -> None:
843 _commit(repo, "base")
844 for i in range(1000):
845 (repo / f"g{i:04d}.py").write_text(f"y = {i}\n")
846 t0 = time.perf_counter()
847 result = _diff(repo, "--json")
848 elapsed = (time.perf_counter() - t0) * 1000
849 assert result.exit_code == 0
850 data = json.loads(result.output)
851 assert data["has_changes"] is True
852 assert elapsed < 3000, f"diff took {elapsed:.0f}ms (limit 3000ms)"
853
854 def test_diff_with_100_deleted_files_correct_categorization(
855 self, repo: pathlib.Path
856 ) -> None:
857 for i in range(100):
858 (repo / f"h{i:04d}.py").write_text(f"h = {i}\n")
859 _commit(repo, "base with 100 files")
860 for i in range(100):
861 (repo / f"h{i:04d}.py").unlink()
862 result = _diff(repo, "--json")
863 data = json.loads(result.output)
864 # All 100 must be in deleted, not modified
865 assert len(data["deleted"]) == 100
866 assert len(data["modified"]) == 0
867
868
869 @pytest.mark.slow
870 class TestStressConcurrent:
871 def test_concurrent_diffs_to_separate_repos(self, tmp_path: pathlib.Path) -> None:
872 errors: list[str] = []
873
874 def do_diff(idx: int) -> None:
875 repo_dir = tmp_path / f"repo_{idx}"
876 repo_dir.mkdir()
877 subprocess.run(
878 ["muse", "init"], cwd=str(repo_dir), capture_output=True
879 )
880 (repo_dir / "x.py").write_text(f"x = {idx}\n")
881 subprocess.run(
882 ["muse", "commit", "-m", "base"],
883 cwd=str(repo_dir), capture_output=True,
884 )
885 (repo_dir / "x.py").write_text(f"x = {idx + 100}\n")
886 r = subprocess.run(
887 ["muse", "diff", "--json"],
888 cwd=str(repo_dir), capture_output=True, text=True,
889 )
890 if r.returncode != 0:
891 errors.append(f"repo_{idx}: diff failed")
892 return
893 data = json.loads(r.stdout)
894 if not data.get("has_changes"):
895 errors.append(f"repo_{idx}: expected has_changes=true")
896
897 threads = [threading.Thread(target=do_diff, args=(i,)) for i in range(8)]
898 for t in threads:
899 t.start()
900 for t in threads:
901 t.join()
902
903
904 # ──────────────────────────────────────────────────────────────────────────────
905 # TestDiffConflict — muse diff --conflict (Cohen Transform labeled diff)
906 # ──────────────────────────────────────────────────────────────────────────────
907
908
909 def _make_conflict_repo(tmp_path: pathlib.Path) -> pathlib.Path:
910 """Return a repo on *main* with an in-progress conflicting checkout -m.
911
912 The repo has:
913 - ``shared.py`` on main: line1 / line2 / line3
914 - branch ``other``: line1 / LINE2 / line3 (other changed line2)
915 - dirty workdir on main: line1 / OURS2 / line3 (ours changed line2)
916
917 Running ``checkout -m other`` produces a conflict on shared.py and writes
918 MERGE_STATE.json. The caller receives the repo in that conflicted state.
919 """
920 saved = os.getcwd()
921 try:
922 os.chdir(tmp_path)
923 runner.invoke(None, ["init"])
924 finally:
925 os.chdir(saved)
926
927 (tmp_path / "shared.py").write_text("line1\nline2\nline3\n")
928 _commit(tmp_path, "initial")
929
930 _invoke(tmp_path, ["branch", "other"])
931 _invoke(tmp_path, ["checkout", "other"])
932 (tmp_path / "shared.py").write_text("line1\nLINE2\nline3\n")
933 _commit(tmp_path, "other changes line2")
934
935 _invoke(tmp_path, ["checkout", "main"])
936 (tmp_path / "shared.py").write_text("line1\nOURS2\nline3\n")
937 # Trigger the conflicting checkout -m to create MERGE_STATE
938 _invoke(tmp_path, ["checkout", "-m", "other"])
939 return tmp_path
940
941
942 class TestDiffConflictParser:
943 """Parser-level tests for the ``--conflict`` flag."""
944
945 def _parse(self, *args: str) -> "argparse.Namespace":
946 import argparse
947
948 from muse.cli.commands.diff import register
949
950 p = argparse.ArgumentParser()
951 sub = p.add_subparsers()
952 register(sub)
953 return p.parse_args(["diff", *args])
954
955 def test_conflict_flag_parsed(self) -> None:
956 ns = self._parse("--conflict")
957 assert ns.conflict is True
958
959 def test_conflict_false_by_default(self) -> None:
960 ns = self._parse()
961 assert ns.conflict is False
962
963 def test_conflict_and_json_coexist(self) -> None:
964 ns = self._parse("--conflict", "--json")
965 assert ns.conflict is True
966 assert ns.fmt == "json"
967
968 def test_conflict_and_path_coexist(self) -> None:
969 ns = self._parse("--conflict", "--path", "src/")
970 assert ns.conflict is True
971 assert "src/" in ns.paths
972
973
974 class TestDiffConflictNoMerge:
975 """--conflict when no merge is in progress must error cleanly."""
976
977 def test_no_merge_in_progress_exits_1(self, repo: pathlib.Path) -> None:
978 r = _diff(repo, "--conflict")
979 assert r.exit_code == 1
980
981 def test_no_merge_error_message_on_stderr(self, repo: pathlib.Path) -> None:
982 r = _diff(repo, "--conflict")
983 assert "MERGE_STATE" in r.stderr or "merge" in r.stderr.lower()
984
985 def test_no_merge_json_also_exits_1(self, repo: pathlib.Path) -> None:
986 r = _diff(repo, "--conflict", "--json")
987 assert r.exit_code == 1
988
989
990 class TestDiffConflictOutput:
991 """--conflict with an active merge in progress."""
992
993 def test_exits_nonzero_when_conflicts_exist(self, tmp_path: pathlib.Path) -> None:
994 repo = _make_conflict_repo(tmp_path)
995 r = _diff(repo, "--conflict")
996 assert r.exit_code != 0
997
998 def test_output_mentions_conflict_file(self, tmp_path: pathlib.Path) -> None:
999 repo = _make_conflict_repo(tmp_path)
1000 r = _diff(repo, "--conflict")
1001 assert "shared.py" in r.output
1002
1003 def test_output_contains_ours_side(self, tmp_path: pathlib.Path) -> None:
1004 repo = _make_conflict_repo(tmp_path)
1005 r = _diff(repo, "--conflict")
1006 assert "[ours]" in r.output or "ours" in r.output.lower()
1007
1008 def test_output_contains_theirs_side(self, tmp_path: pathlib.Path) -> None:
1009 repo = _make_conflict_repo(tmp_path)
1010 r = _diff(repo, "--conflict")
1011 assert "[theirs]" in r.output or "theirs" in r.output.lower()
1012
1013 def test_output_contains_cohen_action_labels(self, tmp_path: pathlib.Path) -> None:
1014 """Cohen-style hunk labels (e.g. [branchname: modified]) must appear in @@-headers."""
1015 repo = _make_conflict_repo(tmp_path)
1016 r = _diff(repo, "--conflict")
1017 combined = r.output + r.stderr
1018 # annotate_hunk_action produces [side_label: action] suffixes on @@ headers
1019 assert any(
1020 suffix in combined
1021 for suffix in (": modified]", ": inserted]", ": deleted]")
1022 )
1023
1024 def test_json_status_is_conflict(self, tmp_path: pathlib.Path) -> None:
1025 repo = _make_conflict_repo(tmp_path)
1026 r = _diff(repo, "--conflict", "--json")
1027 data = json.loads(r.output)
1028 assert data["status"] == "conflict"
1029
1030 def test_json_conflicts_list_non_empty(self, tmp_path: pathlib.Path) -> None:
1031 repo = _make_conflict_repo(tmp_path)
1032 r = _diff(repo, "--conflict", "--json")
1033 data = json.loads(r.output)
1034 assert len(data["conflicts"]) >= 1
1035
1036 def test_json_conflict_entry_has_path_and_diffs(self, tmp_path: pathlib.Path) -> None:
1037 repo = _make_conflict_repo(tmp_path)
1038 r = _diff(repo, "--conflict", "--json")
1039 data = json.loads(r.output)
1040 entry = data["conflicts"][0]
1041 assert "path" in entry
1042 assert "ours_diff" in entry
1043 assert "theirs_diff" in entry
1044
1045 def test_json_labels_match_branches(self, tmp_path: pathlib.Path) -> None:
1046 repo = _make_conflict_repo(tmp_path)
1047 r = _diff(repo, "--conflict", "--json")
1048 data = json.loads(r.output)
1049 assert data["ours_label"] in ("other", "main") # one of the branch names
1050 assert data["theirs_label"] in ("other", "main")
1051
1052 def test_path_filter_limits_output(self, tmp_path: pathlib.Path) -> None:
1053 """``--path`` with a non-matching prefix must produce empty conflicts."""
1054 repo = _make_conflict_repo(tmp_path)
1055 r = _diff(repo, "--conflict", "--json", "--path", "nonexistent/")
1056 data = json.loads(r.output)
1057 assert len(data["conflicts"]) == 0
1058
1059 def test_path_filter_matching_includes_file(self, tmp_path: pathlib.Path) -> None:
1060 """``--path shared.py`` must include the conflicting file."""
1061 repo = _make_conflict_repo(tmp_path)
1062 r = _diff(repo, "--conflict", "--json", "--path", "shared.py")
1063 data = json.loads(r.output)
1064 assert any(e["path"] == "shared.py" for e in data["conflicts"])
1065
1066
1067 class TestDiffConflictSecurity:
1068 """Security: ANSI injection via branch names / paths must be sanitized."""
1069
1070 def test_ansi_in_conflict_path_sanitized(self, tmp_path: pathlib.Path) -> None:
1071 """ANSI escape sequences in a conflict file path must not reach the output.
1072
1073 The CliRunner strips ANSI from all output, so the raw escape code
1074 must not appear in the combined output string.
1075 """
1076 repo = _make_conflict_repo(tmp_path)
1077 r = _diff(repo, "--conflict")
1078 # CliRunner already strips ANSI; double-check no raw escape CSI leaks through
1079 assert "\x1b[" not in r.output
File History 1 commit