gabriel / muse public
test_cmd_show.py python
861 lines 35.9 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
1 """Tests for ``muse read``.
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, ["read", *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.read import register
78
79 p = argparse.ArgumentParser()
80 sub = p.add_subparsers()
81 register(sub)
82 return p.parse_args(["read", *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.read import register
142
143 p = argparse.ArgumentParser()
144 sub = p.add_subparsers()
145 register(sub)
146 with pytest.raises(SystemExit):
147 p.parse_args(["read", "--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.read 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.read 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.read 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.read 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.read 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.read 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.read 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.read.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, excluding timing-dependent fields.
525 def _strip_timing(d: dict) -> dict:
526 return {k: v for k, v in d.items() if k not in ("duration_ms",)}
527 assert _strip_timing(json.loads(r_json.output)) == _strip_timing(json.loads(r_fmt.output))
528
529 def test_breaking_changes_is_list(self, repo: pathlib.Path) -> None:
530 result = _show(repo, "--json")
531 data = json.loads(result.output)
532 assert isinstance(data["breaking_changes"], list)
533
534 def test_sem_ver_bump_is_string(self, repo: pathlib.Path) -> None:
535 result = _show(repo, "--json")
536 data = json.loads(result.output)
537 assert isinstance(data["sem_ver_bump"], str)
538 assert data["sem_ver_bump"] in ("none", "patch", "minor", "major")
539
540
541 # ──────────────────────────────────────────────────────────────────────────────
542 # Integration — merge commits
543 # ──────────────────────────────────────────────────────────────────────────────
544
545
546 class TestMergeCommit:
547 def test_merge_commit_shows_second_parent(self, repo: pathlib.Path) -> None:
548 _invoke(repo, ["branch", "feat"])
549 _invoke(repo, ["checkout", "feat"])
550 (repo / "feat.py").write_text("f=1\n")
551 _commit(repo, "-m", "feat change")
552 _invoke(repo, ["checkout", "main"])
553 (repo / "main_only.py").write_text("m=1\n")
554 _commit(repo, "-m", "main change")
555 _invoke(repo, ["merge", "feat"])
556 result = _show(repo)
557 assert "Parent:" in result.output
558 # Should show merge annotation
559 assert "merge" in result.output.lower() or "Parent:" in result.output
560
561 def test_merge_commit_json_parent2(self, repo: pathlib.Path) -> None:
562 _invoke(repo, ["branch", "feat"])
563 _invoke(repo, ["checkout", "feat"])
564 (repo / "f2.py").write_text("f=2\n")
565 _commit(repo, "-m", "feat2")
566 _invoke(repo, ["checkout", "main"])
567 (repo / "m2.py").write_text("m=2\n")
568 _commit(repo, "-m", "main2")
569 _invoke(repo, ["merge", "feat"])
570 result = _show(repo, "--json")
571 data = json.loads(result.output)
572 # After merge, parent2_commit_id should be set
573 assert data["parent2_commit_id"] is not None
574
575
576 # ──────────────────────────────────────────────────────────────────────────────
577 # Integration — multiple commits, ref resolution
578 # ──────────────────────────────────────────────────────────────────────────────
579
580
581 class TestRefResolution:
582 def test_show_first_commit_by_id(self, repo: pathlib.Path) -> None:
583 first_cid = get_head_commit_id(repo, "main")
584 (repo / "b.py").write_text("b=1\n")
585 _commit(repo, "-m", "second")
586 # Show the first commit by its full ID
587 result = _show(repo, first_cid or "")
588 assert result.exit_code == 0
589 assert "initial commit" in result.output
590
591 def test_show_second_commit_is_head_by_default(self, repo: pathlib.Path) -> None:
592 (repo / "b.py").write_text("b=1\n")
593 _commit(repo, "-m", "the second commit")
594 result = _show(repo)
595 assert "the second commit" in result.output
596
597 def test_show_branch_name_resolves(self, repo: pathlib.Path) -> None:
598 result = _show(repo, "main")
599 assert result.exit_code == 0
600
601 def test_show_nonexistent_ref_exits_1(self, repo: pathlib.Path) -> None:
602 result = _show(repo, "nonexistent-branch-xyz")
603 assert result.exit_code == 1
604
605 def test_show_partial_sha_resolves(self, repo: pathlib.Path) -> None:
606 cid = get_head_commit_id(repo, "main")
607 assert cid is not None
608 result = _show(repo, cid[:12])
609 assert result.exit_code == 0
610
611
612 # ──────────────────────────────────────────────────────────────────────────────
613 # Integration — validation
614 # ──────────────────────────────────────────────────────────────────────────────
615
616
617 class TestValidation:
618 def test_unknown_format_exits_1(self, repo: pathlib.Path) -> None:
619 result = _show(repo, "--format", "xml")
620 assert result.exit_code == 1
621
622 def test_unknown_format_sanitized_error(self, repo: pathlib.Path) -> None:
623 result = _show(repo, "--format", "\x1b[31mxml\x1b[0m")
624 assert "\x1b" not in result.output
625
626 def test_error_message_printed_to_stderr_not_stdout(
627 self, repo: pathlib.Path
628 ) -> None:
629 result = _show(repo, "nonexistent")
630 # Error message should be in stderr (or combined output from helper)
631 assert "not found" in result.output.lower() or "not found" in (result.stderr or "").lower()
632
633
634 # ──────────────────────────────────────────────────────────────────────────────
635 # Security — ANSI injection
636 # ──────────────────────────────────────────────────────────────────────────────
637
638
639 class TestSecurityAnsi:
640 def _has_ansi(self, s: str) -> bool:
641 return "\x1b[" in s
642
643 def test_ansi_in_ref_sanitized(self, repo: pathlib.Path) -> None:
644 result = _show(repo, "\x1b[31mevil\x1b[0m")
645 assert not self._has_ansi(result.output)
646
647 def test_ansi_in_format_flag_sanitized(self, repo: pathlib.Path) -> None:
648 result = _show(repo, "--format", "\x1b[31mxml\x1b[0m")
649 assert not self._has_ansi(result.output)
650
651 def test_ansi_in_commit_message_sanitized(self, repo: pathlib.Path) -> None:
652 _commit(
653 repo, "-m", "clean \x1b[31mred\x1b[0m message", "--allow-empty"
654 )
655 result = _show(repo)
656 assert not self._has_ansi(result.output)
657
658 def test_ansi_in_author_sanitized(self, repo: pathlib.Path) -> None:
659 (repo / "c.py").write_text("c=1\n")
660 _commit(repo, "-m", "by evil", "--author", "\x1b[1mevil\x1b[0m")
661 result = _show(repo)
662 assert not self._has_ansi(result.output)
663
664 def test_ansi_in_metadata_sanitized(self, repo: pathlib.Path) -> None:
665 (repo / "d.py").write_text("d=1\n")
666 _commit(repo, "-m", "tagged", "--section", "\x1b[31msection\x1b[0m")
667 result = _show(repo)
668 assert not self._has_ansi(result.output)
669
670 def test_ansi_in_agent_id_sanitized(self, repo: pathlib.Path) -> None:
671 (repo / "e.py").write_text("e=1\n")
672 _commit(repo, "-m", "agent", "--agent-id", "\x1b[31mevil-bot\x1b[0m")
673 result = _show(repo)
674 assert not self._has_ansi(result.output)
675
676
677 # ──────────────────────────────────────────────────────────────────────────────
678 # Stress — large history
679 # ──────────────────────────────────────────────────────────────────────────────
680
681
682 @pytest.mark.slow
683 class TestStress:
684 def test_show_after_100_commits_fast(self, repo: pathlib.Path) -> None:
685 for i in range(100):
686 (repo / f"f{i:04d}.py").write_text(f"x={i}\n")
687 _commit(repo, "-m", f"commit {i}")
688 t0 = time.perf_counter()
689 result = _show(repo, "--json")
690 elapsed = (time.perf_counter() - t0) * 1000
691 assert result.exit_code == 0
692 assert elapsed < 1000, f"show took {elapsed:.0f}ms (limit 1000ms)"
693
694 def test_show_first_commit_in_deep_history(self, repo: pathlib.Path) -> None:
695 first_cid = get_head_commit_id(repo, "main")
696 for i in range(50):
697 (repo / f"g{i:04d}.py").write_text(f"y={i}\n")
698 _commit(repo, "-m", f"later {i}")
699 result = _show(repo, first_cid or "")
700 assert result.exit_code == 0
701 assert "initial commit" in result.output
702
703 def test_no_delta_significantly_smaller_json(self, repo: pathlib.Path) -> None:
704 # With many files the structured_delta can be large
705 for i in range(50):
706 (repo / f"h{i:04d}.py").write_text(f"z={i}\n")
707 _commit(repo, "-m", "big commit")
708 r_full = _show(repo, "--json")
709 r_nodelta = _show(repo, "--json", "--no-delta")
710 # --no-delta output must be smaller (structured_delta stripped)
711 assert len(r_nodelta.output) <= len(r_full.output)
712
713 def test_concurrent_show_separate_repos(self, tmp_path: pathlib.Path) -> None:
714 """Multiple threads showing from separate repos must not interfere."""
715 errors: list[str] = []
716
717 def do_show(idx: int) -> None:
718 repo_dir = tmp_path / f"repo_{idx}"
719 repo_dir.mkdir()
720 subprocess.run(
721 ["muse", "init"], cwd=str(repo_dir), capture_output=True
722 )
723 (repo_dir / "x.py").write_text(f"x={idx}\n")
724 subprocess.run(
725 ["muse", "commit", "-m", f"c{idx}"],
726 cwd=str(repo_dir), capture_output=True,
727 )
728 r = subprocess.run(
729 ["muse", "read", "--json"],
730 cwd=str(repo_dir), capture_output=True, text=True,
731 )
732 if r.returncode != 0:
733 errors.append(f"repo_{idx}: show failed")
734 return
735 data = json.loads(r.stdout)
736 if data.get("message") != f"c{idx}":
737 errors.append(f"repo_{idx}: wrong message {data.get('message')!r}")
738
739 threads = [threading.Thread(target=do_show, args=(i,)) for i in range(8)]
740 for t in threads:
741 t.start()
742 for t in threads:
743 t.join()
744
745 assert not errors, "Concurrent show errors:\n" + "\n".join(errors)
746
747
748 # ──────────────────────────────────────────────────────────────────────────────
749 # Integration — --manifest flag
750 # ──────────────────────────────────────────────────────────────────────────────
751
752
753 class TestManifest:
754 """``muse read --json --manifest`` includes the full snapshot manifest.
755
756 The manifest maps every tracked path to its content hash (object_id)
757 at the inspected commit. It is absent by default so the default JSON
758 payload stays compact; agents opt in when they need the full file list.
759 """
760
761 def test_manifest_absent_by_default(self, repo: pathlib.Path) -> None:
762 """``manifest`` key must NOT appear in default JSON output."""
763 r = _show(repo, "--json")
764 assert r.exit_code == 0
765 d = json.loads(r.output)
766 assert "manifest" not in d, (
767 "'manifest' key must be absent unless --manifest is given"
768 )
769
770 def test_manifest_present_when_flag_set(self, repo: pathlib.Path) -> None:
771 """``--manifest`` adds a ``manifest`` key to the JSON output."""
772 r = _show(repo, "--json", "--manifest")
773 assert r.exit_code == 0
774 d = json.loads(r.output)
775 assert "manifest" in d, "'manifest' key missing with --manifest"
776
777 def test_manifest_is_dict(self, repo: pathlib.Path) -> None:
778 """``manifest`` value is a plain dict (path → object_id)."""
779 r = _show(repo, "--json", "--manifest")
780 assert r.exit_code == 0
781 d = json.loads(r.output)
782 assert isinstance(d["manifest"], dict)
783
784 def test_manifest_contains_committed_file(self, repo: pathlib.Path) -> None:
785 """The file committed in the repo fixture appears in the manifest."""
786 r = _show(repo, "--json", "--manifest")
787 assert r.exit_code == 0
788 d = json.loads(r.output)
789 assert "a.py" in d["manifest"], (
790 f"'a.py' missing from manifest keys: {list(d['manifest'].keys())}"
791 )
792
793 def test_manifest_values_are_non_empty_strings(self, repo: pathlib.Path) -> None:
794 """Every manifest value is a non-empty string (the content hash)."""
795 r = _show(repo, "--json", "--manifest")
796 assert r.exit_code == 0
797 d = json.loads(r.output)
798 for path, oid in d["manifest"].items():
799 assert isinstance(oid, str) and oid, (
800 f"object_id for {path!r} is empty or not a string: {oid!r}"
801 )
802
803 def test_manifest_keys_sorted(self, repo: pathlib.Path) -> None:
804 """Manifest keys are sorted for determinism across calls."""
805 # Add a second file so there are multiple entries to order.
806 (repo / "b.py").write_text("y = 2\n")
807 _commit(repo, "-m", "add b.py")
808 r = _show(repo, "--json", "--manifest")
809 assert r.exit_code == 0
810 d = json.loads(r.output)
811 keys = list(d["manifest"].keys())
812 assert keys == sorted(keys), f"Manifest keys not sorted: {keys}"
813
814 def test_manifest_with_no_stat(self, repo: pathlib.Path) -> None:
815 """``--manifest --no-stat`` still includes the manifest (independent flags)."""
816 r = _show(repo, "--json", "--manifest", "--no-stat")
817 assert r.exit_code == 0
818 d = json.loads(r.output)
819 assert "manifest" in d
820 assert "files_added" not in d
821 assert "files_removed" not in d
822 assert "files_modified" not in d
823
824 def test_manifest_coexists_with_stat(self, repo: pathlib.Path) -> None:
825 """``--manifest`` and file-stat keys both appear together."""
826 (repo / "c.py").write_text("z = 3\n")
827 _commit(repo, "-m", "add c.py")
828 r = _show(repo, "--json", "--manifest")
829 assert r.exit_code == 0
830 d = json.loads(r.output)
831 assert "manifest" in d
832 assert "files_added" in d
833
834 def test_no_manifest_flag_suppresses_manifest(self, repo: pathlib.Path) -> None:
835 """``--no-manifest`` is the explicit form of the default: no manifest key."""
836 r = _show(repo, "--json", "--no-manifest")
837 assert r.exit_code == 0
838 d = json.loads(r.output)
839 assert "manifest" not in d
840
841 def test_manifest_in_text_mode_no_crash(self, repo: pathlib.Path) -> None:
842 """``--manifest`` in text mode does not crash — it is silently ignored."""
843 r = _show(repo, "--manifest")
844 assert r.exit_code == 0
845
846 def test_manifest_reflects_file_at_specific_commit(
847 self, repo: pathlib.Path
848 ) -> None:
849 """Manifest for an older commit reflects that commit's snapshot, not HEAD."""
850 from muse.core.store import get_head_commit_id, read_current_branch
851 first_cid = get_head_commit_id(repo, read_current_branch(repo))
852 # Add a new file in a second commit.
853 (repo / "d.py").write_text("w = 4\n")
854 _commit(repo, "-m", "add d.py")
855 # Manifest of the first commit must not contain d.py.
856 r = _show(repo, first_cid or "", "--json", "--manifest")
857 assert r.exit_code == 0
858 d = json.loads(r.output)
859 assert "d.py" not in d["manifest"], (
860 "d.py must not appear in the manifest of the commit predating its addition"
861 )
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 142 days ago