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