gabriel / muse public
test_cmd_diff.py python
855 lines 35.8 KB
Raw
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 151 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 — _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 TestValidation:
698 def test_staged_and_unstaged_mutually_exclusive(self, repo: pathlib.Path) -> None:
699 result = _diff(repo, "--staged", "--unstaged")
700 assert result.exit_code == 1
701
702 def test_unknown_format_exits_1(self, repo: pathlib.Path) -> None:
703 result = _diff(repo, "--format", "xml")
704 assert result.exit_code == 1
705
706 def test_unknown_format_sanitized_in_error(self, repo: pathlib.Path) -> None:
707 result = _diff(repo, "--format", "\x1b[31mxml\x1b[0m")
708 assert "\x1b" not in result.output
709
710
711 # ──────────────────────────────────────────────────────────────────────────────
712 # Security — ANSI injection prevention
713 # ──────────────────────────────────────────────────────────────────────────────
714
715
716 class TestSecurityAnsi:
717 """Text output must never emit raw ANSI sequences from user-controlled input."""
718
719 def _has_ansi(self, s: str) -> bool:
720 return "\x1b[" in s or "\x1b]" in s
721
722 def test_ansi_in_format_flag_sanitized(self, repo: pathlib.Path) -> None:
723 result = _diff(repo, "--format", "\x1b[31mxml\x1b[0m")
724 assert not self._has_ansi(result.output)
725
726 def test_ansi_in_commit_ref_sanitized(self, repo: pathlib.Path) -> None:
727 """An ANSI escape in a commit ref must not leak into terminal output."""
728 evil = "\x1b[31mevil\x1b[0m"
729 result = _diff(repo, evil)
730 assert not self._has_ansi(result.output)
731
732 def test_ansi_in_path_filter_handled(self, repo: pathlib.Path) -> None:
733 """An ANSI escape in --path must not leak into output."""
734 result = _diff(repo, "--json", "-p", "\x1b[31mevil\x1b[0m")
735 assert not self._has_ansi(result.output)
736
737 def test_text_diff_path_headers_sanitized(self, repo: pathlib.Path) -> None:
738 """The a/path and b/path headers in unified diff must be sanitized."""
739 # We can't create files with ESC in names on most OS, so test via
740 # the sanitize_display path indirectly by verifying clean output
741 (repo / "normal.py").write_text("n = 1\n")
742 result = _diff(repo, "--text")
743 assert not self._has_ansi(result.output)
744
745
746 # ──────────────────────────────────────────────────────────────────────────────
747 # End-to-end — text output
748 # ──────────────────────────────────────────────────────────────────────────────
749
750
751 class TestTextOutput:
752 def test_no_differences_on_clean_tree(self, repo: pathlib.Path) -> None:
753 result = _diff(repo)
754 assert "No differences" in result.output
755
756 def test_summary_line_on_changes(self, repo: pathlib.Path) -> None:
757 (repo / "a.py").write_text("x = 50\n")
758 result = _diff(repo)
759 # Summary line should appear after the file listing
760 assert result.output.strip() != ""
761 assert "No differences" not in result.output
762
763 def test_deleted_file_shows_d_prefix(self, repo: pathlib.Path) -> None:
764 (repo / "b.py").write_text("b=1\n")
765 _commit(repo, "add b")
766 (repo / "b.py").unlink()
767 result = _diff(repo)
768 assert "b.py" in result.output
769 # Should show D (delete) status, not A or M
770 assert "D" in result.output or "removed" in result.output.lower()
771
772
773 # ──────────────────────────────────────────────────────────────────────────────
774 # Stress — large repos
775 # ──────────────────────────────────────────────────────────────────────────────
776
777
778 @pytest.mark.slow
779 class TestStressLargeRepo:
780 def test_diff_500_files_10_changes_under_1s(self, repo: pathlib.Path) -> None:
781 for i in range(500):
782 (repo / f"f{i:04d}.py").write_text(f"x = {i}\n")
783 _commit(repo, "base")
784 for i in range(10):
785 (repo / f"f{i:04d}.py").write_text(f"x = {i * 100}\n")
786 t0 = time.perf_counter()
787 result = _diff(repo, "--json")
788 elapsed = (time.perf_counter() - t0) * 1000
789 assert result.exit_code == 0
790 data = json.loads(result.output)
791 assert data["has_changes"] is True
792 assert elapsed < 1000, f"diff took {elapsed:.0f}ms (limit 1000ms)"
793
794 def test_diff_1000_added_files(self, repo: pathlib.Path) -> None:
795 _commit(repo, "base")
796 for i in range(1000):
797 (repo / f"g{i:04d}.py").write_text(f"y = {i}\n")
798 t0 = time.perf_counter()
799 result = _diff(repo, "--json")
800 elapsed = (time.perf_counter() - t0) * 1000
801 assert result.exit_code == 0
802 data = json.loads(result.output)
803 assert data["has_changes"] is True
804 assert elapsed < 3000, f"diff took {elapsed:.0f}ms (limit 3000ms)"
805
806 def test_diff_with_100_deleted_files_correct_categorization(
807 self, repo: pathlib.Path
808 ) -> None:
809 for i in range(100):
810 (repo / f"h{i:04d}.py").write_text(f"h = {i}\n")
811 _commit(repo, "base with 100 files")
812 for i in range(100):
813 (repo / f"h{i:04d}.py").unlink()
814 result = _diff(repo, "--json")
815 data = json.loads(result.output)
816 # All 100 must be in deleted, not modified
817 assert len(data["deleted"]) == 100
818 assert len(data["modified"]) == 0
819
820
821 @pytest.mark.slow
822 class TestStressConcurrent:
823 def test_concurrent_diffs_to_separate_repos(self, tmp_path: pathlib.Path) -> None:
824 errors: list[str] = []
825
826 def do_diff(idx: int) -> None:
827 repo_dir = tmp_path / f"repo_{idx}"
828 repo_dir.mkdir()
829 subprocess.run(
830 ["muse", "init"], cwd=str(repo_dir), capture_output=True
831 )
832 (repo_dir / "x.py").write_text(f"x = {idx}\n")
833 subprocess.run(
834 ["muse", "commit", "-m", "base"],
835 cwd=str(repo_dir), capture_output=True,
836 )
837 (repo_dir / "x.py").write_text(f"x = {idx * 2}\n")
838 r = subprocess.run(
839 ["muse", "diff", "--json"],
840 cwd=str(repo_dir), capture_output=True, text=True,
841 )
842 if r.returncode != 0:
843 errors.append(f"repo_{idx}: diff failed")
844 return
845 data = json.loads(r.stdout)
846 if not data.get("has_changes"):
847 errors.append(f"repo_{idx}: expected has_changes=true")
848
849 threads = [threading.Thread(target=do_diff, args=(i,)) for i in range(8)]
850 for t in threads:
851 t.start()
852 for t in threads:
853 t.join()
854
855 assert not errors, "Concurrent diff errors:\n" + "\n".join(errors)
File History 1 commit
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 151 days ago