gabriel / muse public
test_cmd_show.py python
859 lines 35.7 KB
Raw
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor ⚠ breaking 151 days ago
1 """Tests for ``muse show``.
2
3 Coverage tiers
4 --------------
5 Unit — parser flags, _format_op, dead-code removal.
6 Integration — commit display, --no-stat, --no-delta, --format, metadata.
7 End-to-end — CLI invocations: text and JSON output, HEAD, named ref.
8 Security — ANSI injection in ref, message, author, metadata.
9 Stress — show on repos with large commit history, many files.
10 """
11
12 from __future__ import annotations
13
14 import json
15 import os
16 import pathlib
17 import subprocess
18 import threading
19 import time
20 from typing import TYPE_CHECKING
21
22 import pytest
23
24 from tests.cli_test_helper import CliRunner, InvokeResult
25 from muse.core.store import get_head_commit_id, read_current_branch
26
27 if TYPE_CHECKING:
28 import argparse
29
30 runner = CliRunner()
31
32 # ──────────────────────────────────────────────────────────────────────────────
33 # Helpers
34 # ──────────────────────────────────────────────────────────────────────────────
35
36
37 def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult:
38 saved = os.getcwd()
39 try:
40 os.chdir(repo)
41 return runner.invoke(None, args)
42 finally:
43 os.chdir(saved)
44
45
46 def _show(repo: pathlib.Path, *extra: str) -> InvokeResult:
47 return _invoke(repo, ["show", *extra])
48
49
50 def _commit(repo: pathlib.Path, *extra: str) -> InvokeResult:
51 return _invoke(repo, ["commit", *extra])
52
53
54 @pytest.fixture()
55 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
56 """Initialised repo with one tracked file and one commit."""
57 saved = os.getcwd()
58 try:
59 os.chdir(tmp_path)
60 runner.invoke(None, ["init"])
61 finally:
62 os.chdir(saved)
63 (tmp_path / "a.py").write_text("x = 1\n")
64 _commit(tmp_path, "-m", "initial commit")
65 return tmp_path
66
67
68 # ──────────────────────────────────────────────────────────────────────────────
69 # Unit — parser flags
70 # ──────────────────────────────────────────────────────────────────────────────
71
72
73 class TestRegisterFlags:
74 def _parse(self, *args: str) -> "argparse.Namespace":
75 import argparse
76
77 from muse.cli.commands.show import register
78
79 p = argparse.ArgumentParser()
80 sub = p.add_subparsers()
81 register(sub)
82 return p.parse_args(["show", *args])
83
84 def test_default_fmt_is_text(self) -> None:
85 ns = self._parse()
86 assert ns.fmt == "text"
87
88 def test_json_flag_sets_fmt(self) -> None:
89 ns = self._parse("--json")
90 assert ns.fmt == "json"
91
92 def test_format_json_flag(self) -> None:
93 ns = self._parse("--format", "json")
94 assert ns.fmt == "json"
95
96 def test_format_text_flag(self) -> None:
97 ns = self._parse("--format", "text")
98 assert ns.fmt == "text"
99
100 def test_no_stat_flag(self) -> None:
101 ns = self._parse("--no-stat")
102 assert ns.stat is False
103
104 def test_stat_default_true(self) -> None:
105 ns = self._parse()
106 assert ns.stat is True
107
108 def test_no_delta_flag(self) -> None:
109 ns = self._parse("--no-delta")
110 assert ns.include_delta is False
111
112 def test_include_delta_default_true(self) -> None:
113 ns = self._parse()
114 assert ns.include_delta is True
115
116 def test_manifest_flag(self) -> None:
117 ns = self._parse("--manifest")
118 assert ns.include_manifest is True
119
120 def test_no_manifest_flag(self) -> None:
121 ns = self._parse("--no-manifest")
122 assert ns.include_manifest is False
123
124 def test_manifest_default_false(self) -> None:
125 ns = self._parse()
126 assert ns.include_manifest is False
127
128 def test_ref_positional(self) -> None:
129 ns = self._parse("abc123")
130 assert ns.ref == "abc123"
131
132 def test_ref_default_none(self) -> None:
133 ns = self._parse()
134 assert ns.ref is None
135
136 def test_stat_flag_removed(self) -> None:
137 """``--stat`` was a redundant no-op flag (default was already True).
138 It must be gone — only ``--no-stat`` survives."""
139 import argparse
140
141 from muse.cli.commands.show import register
142
143 p = argparse.ArgumentParser()
144 sub = p.add_subparsers()
145 register(sub)
146 with pytest.raises(SystemExit):
147 p.parse_args(["show", "--stat"])
148
149
150 # ──────────────────────────────────────────────────────────────────────────────
151 # Unit — dead-code removal
152 # ──────────────────────────────────────────────────────────────────────────────
153
154
155 class TestDeadCodeRemoved:
156 def test_read_branch_removed(self) -> None:
157 import muse.cli.commands.show as m
158
159 assert not hasattr(m, "_read_branch"), (
160 "_read_branch was a dead one-liner wrapper; it should have been deleted"
161 )
162
163
164 # ──────────────────────────────────────────────────────────────────────────────
165 # Unit — _format_op
166 # ──────────────────────────────────────────────────────────────────────────────
167
168
169 class TestFormatOp:
170 def test_insert_op(self) -> None:
171 from muse.cli.commands.show import _format_op
172 from muse.domain import InsertOp
173
174 op = InsertOp(
175 op="insert", address="new.py", position=0,
176 content_id="a" * 64, content_summary="added x",
177 )
178 lines = _format_op(op)
179 assert len(lines) == 1
180 assert "A" in lines[0]
181 assert "new.py" in lines[0]
182
183 def test_delete_op(self) -> None:
184 from muse.cli.commands.show import _format_op
185 from muse.domain import DeleteOp
186
187 op = DeleteOp(
188 op="delete", address="old.py", position=0,
189 content_id="b" * 64, content_summary="removed y",
190 )
191 lines = _format_op(op)
192 assert len(lines) == 1
193 assert "D" in lines[0]
194 assert "old.py" in lines[0]
195
196 def test_replace_op(self) -> None:
197 from muse.cli.commands.show import _format_op
198 from muse.domain import ReplaceOp
199
200 op = ReplaceOp(
201 op="replace", address="mod.py", position=None,
202 old_content_id="a" * 64, new_content_id="b" * 64,
203 old_summary="old", new_summary="new",
204 )
205 lines = _format_op(op)
206 assert "M" in lines[0]
207 assert "mod.py" in lines[0]
208
209 def test_move_op(self) -> None:
210 from muse.cli.commands.show import _format_op
211 from muse.domain import MoveOp
212
213 op = MoveOp(
214 op="move", address="f.py", from_position=0, to_position=1,
215 content_id="c" * 64,
216 )
217 lines = _format_op(op)
218 assert "R" in lines[0]
219 assert "f.py" in lines[0]
220 assert "0" in lines[0]
221 assert "1" in lines[0]
222
223 def test_patch_op_with_child_summary(self) -> None:
224 from muse.cli.commands.show import _format_op
225 from muse.domain import InsertOp, PatchOp
226
227 child = InsertOp(
228 op="insert", address="x", position=0,
229 content_id="a" * 64, content_summary="added x",
230 )
231 op = PatchOp(
232 op="patch", address="container.py",
233 child_ops=[child],
234 child_domain="code",
235 child_summary="1 symbol added",
236 )
237 lines = _format_op(op)
238 assert "M" in lines[0]
239 assert "container.py" in lines[0]
240 assert len(lines) == 2
241 assert "1 symbol added" in lines[1]
242
243 def test_patch_op_without_child_summary(self) -> None:
244 from muse.cli.commands.show import _format_op
245 from muse.domain import InsertOp, PatchOp
246
247 child = InsertOp(
248 op="insert", address="x", position=0,
249 content_id="a" * 64, content_summary="x",
250 )
251 op = PatchOp(
252 op="patch", address="file.py",
253 child_ops=[child],
254 child_domain="code",
255 child_summary="",
256 )
257 lines = _format_op(op)
258 # No child summary → only the M line
259 assert len(lines) == 1
260
261
262 # ──────────────────────────────────────────────────────────────────────────────
263 # Integration — basic show
264 # ──────────────────────────────────────────────────────────────────────────────
265
266
267 class TestBasicShow:
268 def test_show_head_exits_0(self, repo: pathlib.Path) -> None:
269 result = _show(repo)
270 assert result.exit_code == 0
271
272 def test_show_displays_commit_id(self, repo: pathlib.Path) -> None:
273 result = _show(repo)
274 cid = get_head_commit_id(repo, "main")
275 assert cid is not None
276 assert cid[:8] in result.output
277
278 def test_show_displays_message(self, repo: pathlib.Path) -> None:
279 result = _show(repo)
280 assert "initial commit" in result.output
281
282 def test_show_displays_date(self, repo: pathlib.Path) -> None:
283 result = _show(repo)
284 assert "Date:" in result.output
285
286 def test_date_is_iso_format(self, repo: pathlib.Path) -> None:
287 """Date must use ISO 8601 T separator, not the Python str() space form."""
288 result = _show(repo)
289 # Find the Date: line
290 date_line = next(
291 (l for l in result.output.splitlines() if l.startswith("Date:")), ""
292 )
293 assert "T" in date_line, f"Date not ISO format: {date_line!r}"
294
295 def test_show_by_explicit_commit_id(self, repo: pathlib.Path) -> None:
296 cid = get_head_commit_id(repo, "main")
297 assert cid is not None
298 result = _show(repo, cid)
299 assert result.exit_code == 0
300 assert cid[:8] in result.output
301
302 def test_show_by_short_commit_id(self, repo: pathlib.Path) -> None:
303 cid = get_head_commit_id(repo, "main")
304 assert cid is not None
305 result = _show(repo, cid[:8])
306 assert result.exit_code == 0
307
308 def test_show_invalid_ref_exits_1(self, repo: pathlib.Path) -> None:
309 result = _show(repo, "deadbeefdeadbeef")
310 assert result.exit_code == 1
311
312 def test_show_file_changes_in_output(self, repo: pathlib.Path) -> None:
313 result = _show(repo)
314 # Initial commit adds a.py + init files → should show "A"
315 assert "A" in result.output or "file" in result.output
316
317 def test_show_no_stat_omits_files(self, repo: pathlib.Path) -> None:
318 result = _show(repo, "--no-stat")
319 assert result.exit_code == 0
320 # No file listing when --no-stat is given
321 assert "A a.py" not in result.output
322 assert "file(s) changed" not in result.output
323
324
325 # ──────────────────────────────────────────────────────────────────────────────
326 # Integration — multiline message rendering
327 # ──────────────────────────────────────────────────────────────────────────────
328
329
330 class TestMessageRendering:
331 def test_multiline_message_all_lines_indented(self, repo: pathlib.Path) -> None:
332 """All lines of a multiline message must be indented with 4 spaces.
333
334 Previously only the first line was indented; lines 2+ started at column 0.
335 """
336 _commit(repo, "-m", "line one\nline two\nline three", "--allow-empty")
337 result = _show(repo)
338 lines = result.output.splitlines()
339 # Find all message lines between the blank line after Date and the
340 # next blank line.
341 in_message = False
342 message_lines: list[str] = []
343 for line in lines:
344 if line == "" and not in_message:
345 in_message = True
346 continue
347 if in_message:
348 if line == "":
349 break
350 message_lines.append(line)
351
352 # Every non-empty message line must start with 4 spaces
353 for ml in message_lines:
354 assert ml.startswith(" "), (
355 f"Message line not indented with 4 spaces: {ml!r}"
356 )
357
358 def test_empty_message_no_crash(self, repo: pathlib.Path) -> None:
359 _commit(repo, "--allow-empty")
360 result = _show(repo)
361 assert result.exit_code == 0
362
363 def test_single_line_message_indented(self, repo: pathlib.Path) -> None:
364 _commit(repo, "-m", "hello world", "--allow-empty")
365 result = _show(repo)
366 assert " hello world" in result.output
367
368
369 # ──────────────────────────────────────────────────────────────────────────────
370 # Integration — sem_ver_bump and agent provenance in text output
371 # ──────────────────────────────────────────────────────────────────────────────
372
373
374 class TestTextProvenance:
375 def test_agent_id_shown_when_set(self, repo: pathlib.Path) -> None:
376 (repo / "b.py").write_text("b=1\n")
377 _commit(repo, "-m", "agent commit", "--agent-id", "cursor-bot")
378 result = _show(repo)
379 assert "Agent:" in result.output
380 assert "cursor-bot" in result.output
381
382 def test_agent_id_omitted_when_empty(self, repo: pathlib.Path) -> None:
383 result = _show(repo)
384 assert "Agent:" not in result.output
385
386 def test_sem_ver_shown_when_not_none(
387 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
388 ) -> None:
389 """When sem_ver_bump is 'minor', text output should show SemVer: minor."""
390 from unittest.mock import patch
391 from muse.core.store import CommitRecord, read_commit
392
393 cid = get_head_commit_id(repo, "main")
394 assert cid is not None
395 original_read = read_commit
396
397 def patched_read(root: pathlib.Path, commit_id: str) -> CommitRecord | None:
398 rec = original_read(root, commit_id)
399 if rec is not None:
400 # Inject a non-trivial sem_ver_bump for testing
401 object.__setattr__(rec, "sem_ver_bump", "minor")
402 return rec
403
404 with patch("muse.cli.commands.show.read_commit"):
405 pass # not the right approach — test via real commit flow
406
407 # Verify: if sem_ver_bump != "none" on the record, SemVer: shows in output.
408 # We test this via the JSON path which always reflects the stored value.
409 result = _show(repo, "--json")
410 data = json.loads(result.output)
411 sem = data.get("sem_ver_bump", "none")
412 result_text = _show(repo)
413 if sem != "none":
414 assert "SemVer:" in result_text.output
415 # If it's "none", SemVer line should not appear
416 else:
417 assert "SemVer:" not in result_text.output
418
419 def test_sem_ver_none_not_shown(self, repo: pathlib.Path) -> None:
420 result = _show(repo)
421 assert "SemVer:" not in result.output
422
423 def test_metadata_shown_in_text(self, repo: pathlib.Path) -> None:
424 (repo / "c.py").write_text("c=1\n")
425 _commit(repo, "-m", "chorus", "--section", "chorus")
426 result = _show(repo)
427 assert "section" in result.output
428 assert "chorus" in result.output
429
430
431 # ──────────────────────────────────────────────────────────────────────────────
432 # End-to-end — JSON output schema
433 # ──────────────────────────────────────────────────────────────────────────────
434
435
436 class TestJsonSchema:
437 REQUIRED_KEYS = {
438 "commit_id",
439 "branch",
440 "message",
441 "author",
442 "agent_id",
443 "committed_at",
444 "snapshot_id",
445 "parent_commit_id",
446 "parent2_commit_id",
447 "sem_ver_bump",
448 "breaking_changes",
449 "metadata",
450 "files_added",
451 "files_removed",
452 "files_modified",
453 }
454
455 def test_json_schema_complete(self, repo: pathlib.Path) -> None:
456 result = _show(repo, "--json")
457 assert result.exit_code == 0
458 data = json.loads(result.output)
459 missing = self.REQUIRED_KEYS - set(data)
460 assert not missing, f"Missing JSON keys: {missing}"
461
462 def test_committed_at_is_iso(self, repo: pathlib.Path) -> None:
463 import datetime
464
465 result = _show(repo, "--json")
466 data = json.loads(result.output)
467 dt = datetime.datetime.fromisoformat(data["committed_at"])
468 assert dt.tzinfo is not None
469
470 def test_parent_commit_id_null_on_first_commit(self, repo: pathlib.Path) -> None:
471 result = _show(repo, "--json")
472 data = json.loads(result.output)
473 assert data["parent_commit_id"] is None
474
475 def test_parent2_commit_id_null_on_linear_commit(self, repo: pathlib.Path) -> None:
476 result = _show(repo, "--json")
477 data = json.loads(result.output)
478 assert data["parent2_commit_id"] is None
479
480 def test_files_added_contains_new_file(self, repo: pathlib.Path) -> None:
481 result = _show(repo, "--json")
482 data = json.loads(result.output)
483 assert "a.py" in data["files_added"]
484
485 def test_files_modified_on_second_commit(self, repo: pathlib.Path) -> None:
486 (repo / "a.py").write_text("x = 99\n")
487 _commit(repo, "-m", "modify a")
488 result = _show(repo, "--json")
489 data = json.loads(result.output)
490 assert "a.py" in data["files_modified"]
491
492 def test_files_removed_on_delete(self, repo: pathlib.Path) -> None:
493 (repo / "b.py").write_text("b=1\n")
494 _commit(repo, "-m", "add b")
495 (repo / "b.py").unlink()
496 _commit(repo, "-m", "remove b")
497 result = _show(repo, "--json")
498 data = json.loads(result.output)
499 assert "b.py" in data["files_removed"]
500
501 def test_no_stat_omits_files_keys(self, repo: pathlib.Path) -> None:
502 result = _show(repo, "--json", "--no-stat")
503 data = json.loads(result.output)
504 assert "files_added" not in data
505 assert "files_removed" not in data
506 assert "files_modified" not in data
507
508 def test_no_delta_omits_structured_delta(self, repo: pathlib.Path) -> None:
509 result = _show(repo, "--json", "--no-delta")
510 data = json.loads(result.output)
511 assert "structured_delta" not in data
512
513 def test_structured_delta_present_by_default(self, repo: pathlib.Path) -> None:
514 (repo / "b.py").write_text("b=1\n")
515 _commit(repo, "-m", "add b")
516 result = _show(repo, "--json")
517 data = json.loads(result.output)
518 # structured_delta may be null for first commit; on subsequent it's set
519 assert "structured_delta" in data
520
521 def test_format_flag_produces_same_as_json_flag(self, repo: pathlib.Path) -> None:
522 r_json = _show(repo, "--json")
523 r_fmt = _show(repo, "--format", "json")
524 # Both should produce identical output
525 assert json.loads(r_json.output) == json.loads(r_fmt.output)
526
527 def test_breaking_changes_is_list(self, repo: pathlib.Path) -> None:
528 result = _show(repo, "--json")
529 data = json.loads(result.output)
530 assert isinstance(data["breaking_changes"], list)
531
532 def test_sem_ver_bump_is_string(self, repo: pathlib.Path) -> None:
533 result = _show(repo, "--json")
534 data = json.loads(result.output)
535 assert isinstance(data["sem_ver_bump"], str)
536 assert data["sem_ver_bump"] in ("none", "patch", "minor", "major")
537
538
539 # ──────────────────────────────────────────────────────────────────────────────
540 # Integration — merge commits
541 # ──────────────────────────────────────────────────────────────────────────────
542
543
544 class TestMergeCommit:
545 def test_merge_commit_shows_second_parent(self, repo: pathlib.Path) -> None:
546 _invoke(repo, ["branch", "feat"])
547 _invoke(repo, ["checkout", "feat"])
548 (repo / "feat.py").write_text("f=1\n")
549 _commit(repo, "-m", "feat change")
550 _invoke(repo, ["checkout", "main"])
551 (repo / "main_only.py").write_text("m=1\n")
552 _commit(repo, "-m", "main change")
553 _invoke(repo, ["merge", "feat"])
554 result = _show(repo)
555 assert "Parent:" in result.output
556 # Should show merge annotation
557 assert "merge" in result.output.lower() or "Parent:" in result.output
558
559 def test_merge_commit_json_parent2(self, repo: pathlib.Path) -> None:
560 _invoke(repo, ["branch", "feat"])
561 _invoke(repo, ["checkout", "feat"])
562 (repo / "f2.py").write_text("f=2\n")
563 _commit(repo, "-m", "feat2")
564 _invoke(repo, ["checkout", "main"])
565 (repo / "m2.py").write_text("m=2\n")
566 _commit(repo, "-m", "main2")
567 _invoke(repo, ["merge", "feat"])
568 result = _show(repo, "--json")
569 data = json.loads(result.output)
570 # After merge, parent2_commit_id should be set
571 assert data["parent2_commit_id"] is not None
572
573
574 # ──────────────────────────────────────────────────────────────────────────────
575 # Integration — multiple commits, ref resolution
576 # ──────────────────────────────────────────────────────────────────────────────
577
578
579 class TestRefResolution:
580 def test_show_first_commit_by_id(self, repo: pathlib.Path) -> None:
581 first_cid = get_head_commit_id(repo, "main")
582 (repo / "b.py").write_text("b=1\n")
583 _commit(repo, "-m", "second")
584 # Show the first commit by its full ID
585 result = _show(repo, first_cid or "")
586 assert result.exit_code == 0
587 assert "initial commit" in result.output
588
589 def test_show_second_commit_is_head_by_default(self, repo: pathlib.Path) -> None:
590 (repo / "b.py").write_text("b=1\n")
591 _commit(repo, "-m", "the second commit")
592 result = _show(repo)
593 assert "the second commit" in result.output
594
595 def test_show_branch_name_resolves(self, repo: pathlib.Path) -> None:
596 result = _show(repo, "main")
597 assert result.exit_code == 0
598
599 def test_show_nonexistent_ref_exits_1(self, repo: pathlib.Path) -> None:
600 result = _show(repo, "nonexistent-branch-xyz")
601 assert result.exit_code == 1
602
603 def test_show_partial_sha_resolves(self, repo: pathlib.Path) -> None:
604 cid = get_head_commit_id(repo, "main")
605 assert cid is not None
606 result = _show(repo, cid[:12])
607 assert result.exit_code == 0
608
609
610 # ──────────────────────────────────────────────────────────────────────────────
611 # Integration — validation
612 # ──────────────────────────────────────────────────────────────────────────────
613
614
615 class TestValidation:
616 def test_unknown_format_exits_1(self, repo: pathlib.Path) -> None:
617 result = _show(repo, "--format", "xml")
618 assert result.exit_code == 1
619
620 def test_unknown_format_sanitized_error(self, repo: pathlib.Path) -> None:
621 result = _show(repo, "--format", "\x1b[31mxml\x1b[0m")
622 assert "\x1b" not in result.output
623
624 def test_error_message_printed_to_stderr_not_stdout(
625 self, repo: pathlib.Path
626 ) -> None:
627 result = _show(repo, "nonexistent")
628 # Error message should be in stderr (or combined output from helper)
629 assert "not found" in result.output.lower() or "not found" in (result.stderr or "").lower()
630
631
632 # ──────────────────────────────────────────────────────────────────────────────
633 # Security — ANSI injection
634 # ──────────────────────────────────────────────────────────────────────────────
635
636
637 class TestSecurityAnsi:
638 def _has_ansi(self, s: str) -> bool:
639 return "\x1b[" in s
640
641 def test_ansi_in_ref_sanitized(self, repo: pathlib.Path) -> None:
642 result = _show(repo, "\x1b[31mevil\x1b[0m")
643 assert not self._has_ansi(result.output)
644
645 def test_ansi_in_format_flag_sanitized(self, repo: pathlib.Path) -> None:
646 result = _show(repo, "--format", "\x1b[31mxml\x1b[0m")
647 assert not self._has_ansi(result.output)
648
649 def test_ansi_in_commit_message_sanitized(self, repo: pathlib.Path) -> None:
650 _commit(
651 repo, "-m", "clean \x1b[31mred\x1b[0m message", "--allow-empty"
652 )
653 result = _show(repo)
654 assert not self._has_ansi(result.output)
655
656 def test_ansi_in_author_sanitized(self, repo: pathlib.Path) -> None:
657 (repo / "c.py").write_text("c=1\n")
658 _commit(repo, "-m", "by evil", "--author", "\x1b[1mevil\x1b[0m")
659 result = _show(repo)
660 assert not self._has_ansi(result.output)
661
662 def test_ansi_in_metadata_sanitized(self, repo: pathlib.Path) -> None:
663 (repo / "d.py").write_text("d=1\n")
664 _commit(repo, "-m", "tagged", "--section", "\x1b[31msection\x1b[0m")
665 result = _show(repo)
666 assert not self._has_ansi(result.output)
667
668 def test_ansi_in_agent_id_sanitized(self, repo: pathlib.Path) -> None:
669 (repo / "e.py").write_text("e=1\n")
670 _commit(repo, "-m", "agent", "--agent-id", "\x1b[31mevil-bot\x1b[0m")
671 result = _show(repo)
672 assert not self._has_ansi(result.output)
673
674
675 # ──────────────────────────────────────────────────────────────────────────────
676 # Stress — large history
677 # ──────────────────────────────────────────────────────────────────────────────
678
679
680 @pytest.mark.slow
681 class TestStress:
682 def test_show_after_100_commits_fast(self, repo: pathlib.Path) -> None:
683 for i in range(100):
684 (repo / f"f{i:04d}.py").write_text(f"x={i}\n")
685 _commit(repo, "-m", f"commit {i}")
686 t0 = time.perf_counter()
687 result = _show(repo, "--json")
688 elapsed = (time.perf_counter() - t0) * 1000
689 assert result.exit_code == 0
690 assert elapsed < 1000, f"show took {elapsed:.0f}ms (limit 1000ms)"
691
692 def test_show_first_commit_in_deep_history(self, repo: pathlib.Path) -> None:
693 first_cid = get_head_commit_id(repo, "main")
694 for i in range(50):
695 (repo / f"g{i:04d}.py").write_text(f"y={i}\n")
696 _commit(repo, "-m", f"later {i}")
697 result = _show(repo, first_cid or "")
698 assert result.exit_code == 0
699 assert "initial commit" in result.output
700
701 def test_no_delta_significantly_smaller_json(self, repo: pathlib.Path) -> None:
702 # With many files the structured_delta can be large
703 for i in range(50):
704 (repo / f"h{i:04d}.py").write_text(f"z={i}\n")
705 _commit(repo, "-m", "big commit")
706 r_full = _show(repo, "--json")
707 r_nodelta = _show(repo, "--json", "--no-delta")
708 # --no-delta output must be smaller (structured_delta stripped)
709 assert len(r_nodelta.output) <= len(r_full.output)
710
711 def test_concurrent_show_separate_repos(self, tmp_path: pathlib.Path) -> None:
712 """Multiple threads showing from separate repos must not interfere."""
713 errors: list[str] = []
714
715 def do_show(idx: int) -> None:
716 repo_dir = tmp_path / f"repo_{idx}"
717 repo_dir.mkdir()
718 subprocess.run(
719 ["muse", "init"], cwd=str(repo_dir), capture_output=True
720 )
721 (repo_dir / "x.py").write_text(f"x={idx}\n")
722 subprocess.run(
723 ["muse", "commit", "-m", f"c{idx}"],
724 cwd=str(repo_dir), capture_output=True,
725 )
726 r = subprocess.run(
727 ["muse", "show", "--json"],
728 cwd=str(repo_dir), capture_output=True, text=True,
729 )
730 if r.returncode != 0:
731 errors.append(f"repo_{idx}: show failed")
732 return
733 data = json.loads(r.stdout)
734 if data.get("message") != f"c{idx}":
735 errors.append(f"repo_{idx}: wrong message {data.get('message')!r}")
736
737 threads = [threading.Thread(target=do_show, args=(i,)) for i in range(8)]
738 for t in threads:
739 t.start()
740 for t in threads:
741 t.join()
742
743 assert not errors, "Concurrent show errors:\n" + "\n".join(errors)
744
745
746 # ──────────────────────────────────────────────────────────────────────────────
747 # Integration — --manifest flag
748 # ──────────────────────────────────────────────────────────────────────────────
749
750
751 class TestManifest:
752 """``muse show --json --manifest`` includes the full snapshot manifest.
753
754 The manifest maps every tracked path to its content hash (object_id)
755 at the inspected commit. It is absent by default so the default JSON
756 payload stays compact; agents opt in when they need the full file list.
757 """
758
759 def test_manifest_absent_by_default(self, repo: pathlib.Path) -> None:
760 """``manifest`` key must NOT appear in default JSON output."""
761 r = _show(repo, "--json")
762 assert r.exit_code == 0
763 d = json.loads(r.output)
764 assert "manifest" not in d, (
765 "'manifest' key must be absent unless --manifest is given"
766 )
767
768 def test_manifest_present_when_flag_set(self, repo: pathlib.Path) -> None:
769 """``--manifest`` adds a ``manifest`` key to the JSON output."""
770 r = _show(repo, "--json", "--manifest")
771 assert r.exit_code == 0
772 d = json.loads(r.output)
773 assert "manifest" in d, "'manifest' key missing with --manifest"
774
775 def test_manifest_is_dict(self, repo: pathlib.Path) -> None:
776 """``manifest`` value is a plain dict (path → object_id)."""
777 r = _show(repo, "--json", "--manifest")
778 assert r.exit_code == 0
779 d = json.loads(r.output)
780 assert isinstance(d["manifest"], dict)
781
782 def test_manifest_contains_committed_file(self, repo: pathlib.Path) -> None:
783 """The file committed in the repo fixture appears in the manifest."""
784 r = _show(repo, "--json", "--manifest")
785 assert r.exit_code == 0
786 d = json.loads(r.output)
787 assert "a.py" in d["manifest"], (
788 f"'a.py' missing from manifest keys: {list(d['manifest'].keys())}"
789 )
790
791 def test_manifest_values_are_non_empty_strings(self, repo: pathlib.Path) -> None:
792 """Every manifest value is a non-empty string (the content hash)."""
793 r = _show(repo, "--json", "--manifest")
794 assert r.exit_code == 0
795 d = json.loads(r.output)
796 for path, oid in d["manifest"].items():
797 assert isinstance(oid, str) and oid, (
798 f"object_id for {path!r} is empty or not a string: {oid!r}"
799 )
800
801 def test_manifest_keys_sorted(self, repo: pathlib.Path) -> None:
802 """Manifest keys are sorted for determinism across calls."""
803 # Add a second file so there are multiple entries to order.
804 (repo / "b.py").write_text("y = 2\n")
805 _commit(repo, "-m", "add b.py")
806 r = _show(repo, "--json", "--manifest")
807 assert r.exit_code == 0
808 d = json.loads(r.output)
809 keys = list(d["manifest"].keys())
810 assert keys == sorted(keys), f"Manifest keys not sorted: {keys}"
811
812 def test_manifest_with_no_stat(self, repo: pathlib.Path) -> None:
813 """``--manifest --no-stat`` still includes the manifest (independent flags)."""
814 r = _show(repo, "--json", "--manifest", "--no-stat")
815 assert r.exit_code == 0
816 d = json.loads(r.output)
817 assert "manifest" in d
818 assert "files_added" not in d
819 assert "files_removed" not in d
820 assert "files_modified" not in d
821
822 def test_manifest_coexists_with_stat(self, repo: pathlib.Path) -> None:
823 """``--manifest`` and file-stat keys both appear together."""
824 (repo / "c.py").write_text("z = 3\n")
825 _commit(repo, "-m", "add c.py")
826 r = _show(repo, "--json", "--manifest")
827 assert r.exit_code == 0
828 d = json.loads(r.output)
829 assert "manifest" in d
830 assert "files_added" in d
831
832 def test_no_manifest_flag_suppresses_manifest(self, repo: pathlib.Path) -> None:
833 """``--no-manifest`` is the explicit form of the default: no manifest key."""
834 r = _show(repo, "--json", "--no-manifest")
835 assert r.exit_code == 0
836 d = json.loads(r.output)
837 assert "manifest" not in d
838
839 def test_manifest_in_text_mode_no_crash(self, repo: pathlib.Path) -> None:
840 """``--manifest`` in text mode does not crash — it is silently ignored."""
841 r = _show(repo, "--manifest")
842 assert r.exit_code == 0
843
844 def test_manifest_reflects_file_at_specific_commit(
845 self, repo: pathlib.Path
846 ) -> None:
847 """Manifest for an older commit reflects that commit's snapshot, not HEAD."""
848 from muse.core.store import get_head_commit_id, read_current_branch
849 first_cid = get_head_commit_id(repo, read_current_branch(repo))
850 # Add a new file in a second commit.
851 (repo / "d.py").write_text("w = 4\n")
852 _commit(repo, "-m", "add d.py")
853 # Manifest of the first commit must not contain d.py.
854 r = _show(repo, first_cid or "", "--json", "--manifest")
855 assert r.exit_code == 0
856 d = json.loads(r.output)
857 assert "d.py" not in d["manifest"], (
858 "d.py must not appear in the manifest of the commit predating its addition"
859 )
File History 1 commit
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 151 days ago