gabriel / muse public
test_cmd_show.py python
847 lines 35.2 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 120 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_genesis_commit_shows_sem_ver(self, repo: pathlib.Path) -> None:
417 # Genesis commits now produce a real structured_delta (all inserts),
418 # so they get a semver bump and the SemVer line appears in output.
419 result = _show(repo)
420 assert "SemVer:" in result.output
421
422 def test_metadata_shown_in_text(self, repo: pathlib.Path) -> None:
423 (repo / "c.py").write_text("c=1\n")
424 _commit(repo, "-m", "chorus", "--section", "chorus")
425 result = _show(repo)
426 assert "section" in result.output
427 assert "chorus" in result.output
428
429
430 # ──────────────────────────────────────────────────────────────────────────────
431 # End-to-end — JSON output schema
432 # ──────────────────────────────────────────────────────────────────────────────
433
434
435 class TestJsonSchema:
436 REQUIRED_KEYS = {
437 "commit_id",
438 "branch",
439 "message",
440 "author",
441 "agent_id",
442 "committed_at",
443 "snapshot_id",
444 "parent_commit_id",
445 "parent2_commit_id",
446 "sem_ver_bump",
447 "breaking_changes",
448 "metadata",
449 "files_added",
450 "files_removed",
451 "files_modified",
452 }
453
454 def test_json_schema_complete(self, repo: pathlib.Path) -> None:
455 result = _show(repo, "--json")
456 assert result.exit_code == 0
457 data = json.loads(result.output)
458 missing = self.REQUIRED_KEYS - set(data)
459 assert not missing, f"Missing JSON keys: {missing}"
460
461 def test_committed_at_is_iso(self, repo: pathlib.Path) -> None:
462 import datetime
463
464 result = _show(repo, "--json")
465 data = json.loads(result.output)
466 dt = datetime.datetime.fromisoformat(data["committed_at"])
467 assert dt.tzinfo is not None
468
469 def test_parent_commit_id_null_on_first_commit(self, repo: pathlib.Path) -> None:
470 result = _show(repo, "--json")
471 data = json.loads(result.output)
472 assert data["parent_commit_id"] is None
473
474 def test_parent2_commit_id_null_on_linear_commit(self, repo: pathlib.Path) -> None:
475 result = _show(repo, "--json")
476 data = json.loads(result.output)
477 assert data["parent2_commit_id"] is None
478
479 def test_files_added_contains_new_file(self, repo: pathlib.Path) -> None:
480 result = _show(repo, "--json")
481 data = json.loads(result.output)
482 assert "a.py" in data["files_added"]
483
484 def test_files_modified_on_second_commit(self, repo: pathlib.Path) -> None:
485 (repo / "a.py").write_text("x = 99\n")
486 _commit(repo, "-m", "modify a")
487 result = _show(repo, "--json")
488 data = json.loads(result.output)
489 assert "a.py" in data["files_modified"]
490
491 def test_files_removed_on_delete(self, repo: pathlib.Path) -> None:
492 (repo / "b.py").write_text("b=1\n")
493 _commit(repo, "-m", "add b")
494 (repo / "b.py").unlink()
495 _commit(repo, "-m", "remove b")
496 result = _show(repo, "--json")
497 data = json.loads(result.output)
498 assert "b.py" in data["files_removed"]
499
500 def test_no_stat_omits_files_keys(self, repo: pathlib.Path) -> None:
501 result = _show(repo, "--json", "--no-stat")
502 data = json.loads(result.output)
503 assert "files_added" not in data
504 assert "files_removed" not in data
505 assert "files_modified" not in data
506
507 def test_no_delta_omits_structured_delta(self, repo: pathlib.Path) -> None:
508 result = _show(repo, "--json", "--no-delta")
509 data = json.loads(result.output)
510 assert "structured_delta" not in data
511
512 def test_structured_delta_present_by_default(self, repo: pathlib.Path) -> None:
513 (repo / "b.py").write_text("b=1\n")
514 _commit(repo, "-m", "add b")
515 result = _show(repo, "--json")
516 data = json.loads(result.output)
517 assert "structured_delta" in data
518
519 def test_breaking_changes_is_list(self, repo: pathlib.Path) -> None:
520 result = _show(repo, "--json")
521 data = json.loads(result.output)
522 assert isinstance(data["breaking_changes"], list)
523
524 def test_sem_ver_bump_is_string(self, repo: pathlib.Path) -> None:
525 result = _show(repo, "--json")
526 data = json.loads(result.output)
527 assert isinstance(data["sem_ver_bump"], str)
528 assert data["sem_ver_bump"] in ("none", "patch", "minor", "major")
529
530
531 # ──────────────────────────────────────────────────────────────────────────────
532 # Integration — merge commits
533 # ──────────────────────────────────────────────────────────────────────────────
534
535
536 class TestMergeCommit:
537 def test_merge_commit_shows_second_parent(self, repo: pathlib.Path) -> None:
538 _invoke(repo, ["branch", "feat"])
539 _invoke(repo, ["checkout", "feat"])
540 (repo / "feat.py").write_text("f=1\n")
541 _commit(repo, "-m", "feat change")
542 _invoke(repo, ["checkout", "main"])
543 (repo / "main_only.py").write_text("m=1\n")
544 _commit(repo, "-m", "main change")
545 _invoke(repo, ["merge", "feat"])
546 result = _show(repo)
547 assert "Parent:" in result.output
548 # Should show merge annotation
549 assert "merge" in result.output.lower() or "Parent:" in result.output
550
551 def test_merge_commit_json_parent2(self, repo: pathlib.Path) -> None:
552 _invoke(repo, ["branch", "feat"])
553 _invoke(repo, ["checkout", "feat"])
554 (repo / "f2.py").write_text("f=2\n")
555 _commit(repo, "-m", "feat2")
556 _invoke(repo, ["checkout", "main"])
557 (repo / "m2.py").write_text("m=2\n")
558 _commit(repo, "-m", "main2")
559 _invoke(repo, ["merge", "feat"])
560 result = _show(repo, "--json")
561 data = json.loads(result.output)
562 # After merge, parent2_commit_id should be set
563 assert data["parent2_commit_id"] is not None
564
565
566 # ──────────────────────────────────────────────────────────────────────────────
567 # Integration — multiple commits, ref resolution
568 # ──────────────────────────────────────────────────────────────────────────────
569
570
571 class TestRefResolution:
572 def test_show_first_commit_by_id(self, repo: pathlib.Path) -> None:
573 first_cid = get_head_commit_id(repo, "main")
574 (repo / "b.py").write_text("b=1\n")
575 _commit(repo, "-m", "second")
576 # Show the first commit by its full ID
577 result = _show(repo, first_cid or "")
578 assert result.exit_code == 0
579 assert "initial commit" in result.output
580
581 def test_show_second_commit_is_head_by_default(self, repo: pathlib.Path) -> None:
582 (repo / "b.py").write_text("b=1\n")
583 _commit(repo, "-m", "the second commit")
584 result = _show(repo)
585 assert "the second commit" in result.output
586
587 def test_show_branch_name_resolves(self, repo: pathlib.Path) -> None:
588 result = _show(repo, "main")
589 assert result.exit_code == 0
590
591 def test_show_nonexistent_ref_exits_1(self, repo: pathlib.Path) -> None:
592 result = _show(repo, "nonexistent-branch-xyz")
593 assert result.exit_code == 1
594
595 def test_show_partial_sha_resolves(self, repo: pathlib.Path) -> None:
596 cid = get_head_commit_id(repo, "main")
597 assert cid is not None
598 result = _show(repo, short_id(cid))
599 assert result.exit_code == 0
600
601
602 # ──────────────────────────────────────────────────────────────────────────────
603 # Integration — validation
604 # ──────────────────────────────────────────────────────────────────────────────
605
606
607 class TestValidation:
608 def test_unknown_flag_exits_nonzero(self, repo: pathlib.Path) -> None:
609 result = _show(repo, "--format", "xml")
610 assert result.exit_code != 0
611
612 def test_error_message_printed_to_stderr_not_stdout(
613 self, repo: pathlib.Path
614 ) -> None:
615 result = _show(repo, "nonexistent")
616 # Error message should be in stderr (or combined output from helper)
617 assert "not found" in result.output.lower() or "not found" in (result.stderr or "").lower()
618
619
620 # ──────────────────────────────────────────────────────────────────────────────
621 # Security — ANSI injection
622 # ──────────────────────────────────────────────────────────────────────────────
623
624
625 class TestSecurityAnsi:
626 def _has_ansi(self, s: str) -> bool:
627 return "\x1b[" in s
628
629 def test_ansi_in_ref_sanitized(self, repo: pathlib.Path) -> None:
630 result = _show(repo, "\x1b[31mmalicious\x1b[0m")
631 assert not self._has_ansi(result.output)
632
633 def test_ansi_in_format_flag_sanitized(self, repo: pathlib.Path) -> None:
634 result = _show(repo, "--format", "\x1b[31mxml\x1b[0m")
635 assert not self._has_ansi(result.output)
636
637 def test_ansi_in_commit_message_sanitized(self, repo: pathlib.Path) -> None:
638 _commit(
639 repo, "-m", "clean \x1b[31mred\x1b[0m message", "--allow-empty"
640 )
641 result = _show(repo)
642 assert not self._has_ansi(result.output)
643
644 def test_ansi_in_author_sanitized(self, repo: pathlib.Path) -> None:
645 (repo / "c.py").write_text("c=1\n")
646 _commit(repo, "-m", "by malicious", "--author", "\x1b[1mmalicious\x1b[0m")
647 result = _show(repo)
648 assert not self._has_ansi(result.output)
649
650 def test_ansi_in_metadata_sanitized(self, repo: pathlib.Path) -> None:
651 (repo / "d.py").write_text("d=1\n")
652 _commit(repo, "-m", "tagged", "--section", "\x1b[31msection\x1b[0m")
653 result = _show(repo)
654 assert not self._has_ansi(result.output)
655
656 def test_ansi_in_agent_id_sanitized(self, repo: pathlib.Path) -> None:
657 (repo / "e.py").write_text("e=1\n")
658 _commit(repo, "-m", "agent", "--agent-id", "\x1b[31mmalicious-bot\x1b[0m")
659 result = _show(repo)
660 assert not self._has_ansi(result.output)
661
662
663 # ──────────────────────────────────────────────────────────────────────────────
664 # Stress — large history
665 # ──────────────────────────────────────────────────────────────────────────────
666
667
668 @pytest.mark.slow
669 class TestStress:
670 def test_show_after_100_commits_fast(self, repo: pathlib.Path) -> None:
671 for i in range(100):
672 (repo / f"f{i:04d}.py").write_text(f"x={i}\n")
673 _commit(repo, "-m", f"commit {i}")
674 t0 = time.perf_counter()
675 result = _show(repo, "--json")
676 elapsed = (time.perf_counter() - t0) * 1000
677 assert result.exit_code == 0
678 assert elapsed < 1000, f"show took {elapsed:.0f}ms (limit 1000ms)"
679
680 def test_show_first_commit_in_deep_history(self, repo: pathlib.Path) -> None:
681 first_cid = get_head_commit_id(repo, "main")
682 for i in range(50):
683 (repo / f"g{i:04d}.py").write_text(f"y={i}\n")
684 _commit(repo, "-m", f"later {i}")
685 result = _show(repo, first_cid or "")
686 assert result.exit_code == 0
687 assert "initial commit" in result.output
688
689 def test_no_delta_significantly_smaller_json(self, repo: pathlib.Path) -> None:
690 # With many files the structured_delta can be large
691 for i in range(50):
692 (repo / f"h{i:04d}.py").write_text(f"z={i}\n")
693 _commit(repo, "-m", "big commit")
694 r_full = _show(repo, "--json")
695 r_nodelta = _show(repo, "--json", "--no-delta")
696 # --no-delta output must be smaller (structured_delta stripped)
697 assert len(r_nodelta.output) <= len(r_full.output)
698
699 def test_concurrent_show_separate_repos(self, tmp_path: pathlib.Path) -> None:
700 """Multiple threads showing from separate repos must not interfere."""
701 errors: list[str] = []
702
703 def do_show(idx: int) -> None:
704 repo_dir = tmp_path / f"repo_{idx}"
705 repo_dir.mkdir()
706 subprocess.run(
707 ["muse", "init"], cwd=str(repo_dir), capture_output=True
708 )
709 (repo_dir / "x.py").write_text(f"x={idx}\n")
710 subprocess.run(
711 ["muse", "commit", "-m", f"c{idx}"],
712 cwd=str(repo_dir), capture_output=True,
713 )
714 r = subprocess.run(
715 ["muse", "read", "--json"],
716 cwd=str(repo_dir), capture_output=True, text=True,
717 )
718 if r.returncode != 0:
719 errors.append(f"repo_{idx}: show failed")
720 return
721 data = json.loads(r.stdout)
722 if data.get("message") != f"c{idx}":
723 errors.append(f"repo_{idx}: wrong message {data.get('message')!r}")
724
725 threads = [threading.Thread(target=do_show, args=(i,)) for i in range(8)]
726 for t in threads:
727 t.start()
728 for t in threads:
729 t.join()
730
731 assert not errors, f"Concurrent show errors:\n{'\n'.join(errors)}"
732
733
734 # ──────────────────────────────────────────────────────────────────────────────
735 # Integration — --manifest flag
736 # ──────────────────────────────────────────────────────────────────────────────
737
738
739 class TestManifest:
740 """``muse read --json --manifest`` includes the full snapshot manifest.
741
742 The manifest maps every tracked path to its content hash (object_id)
743 at the inspected commit. It is absent by default so the default JSON
744 payload stays compact; agents opt in when they need the full file list.
745 """
746
747 def test_manifest_absent_by_default(self, repo: pathlib.Path) -> None:
748 """``manifest`` key must NOT appear in default JSON output."""
749 r = _show(repo, "--json")
750 assert r.exit_code == 0
751 d = json.loads(r.output)
752 assert "manifest" not in d, (
753 "'manifest' key must be absent unless --manifest is given"
754 )
755
756 def test_manifest_present_when_flag_set(self, repo: pathlib.Path) -> None:
757 """``--manifest`` adds a ``manifest`` key to the JSON output."""
758 r = _show(repo, "--json", "--manifest")
759 assert r.exit_code == 0
760 d = json.loads(r.output)
761 assert "manifest" in d, "'manifest' key missing with --manifest"
762
763 def test_manifest_is_dict(self, repo: pathlib.Path) -> None:
764 """``manifest`` value is a plain dict (path → object_id)."""
765 r = _show(repo, "--json", "--manifest")
766 assert r.exit_code == 0
767 d = json.loads(r.output)
768 assert isinstance(d["manifest"], dict)
769
770 def test_manifest_contains_committed_file(self, repo: pathlib.Path) -> None:
771 """The file committed in the repo fixture appears in the manifest."""
772 r = _show(repo, "--json", "--manifest")
773 assert r.exit_code == 0
774 d = json.loads(r.output)
775 assert "a.py" in d["manifest"], (
776 f"'a.py' missing from manifest keys: {list(d['manifest'].keys())}"
777 )
778
779 def test_manifest_values_are_non_empty_strings(self, repo: pathlib.Path) -> None:
780 """Every manifest value is a non-empty string (the content hash)."""
781 r = _show(repo, "--json", "--manifest")
782 assert r.exit_code == 0
783 d = json.loads(r.output)
784 for path, oid in d["manifest"].items():
785 assert isinstance(oid, str) and oid, (
786 f"object_id for {path!r} is empty or not a string: {oid!r}"
787 )
788
789 def test_manifest_keys_sorted(self, repo: pathlib.Path) -> None:
790 """Manifest keys are sorted for determinism across calls."""
791 # Add a second file so there are multiple entries to order.
792 (repo / "b.py").write_text("y = 2\n")
793 _commit(repo, "-m", "add b.py")
794 r = _show(repo, "--json", "--manifest")
795 assert r.exit_code == 0
796 d = json.loads(r.output)
797 keys = list(d["manifest"].keys())
798 assert keys == sorted(keys), f"Manifest keys not sorted: {keys}"
799
800 def test_manifest_with_no_stat(self, repo: pathlib.Path) -> None:
801 """``--manifest --no-stat`` still includes the manifest (independent flags)."""
802 r = _show(repo, "--json", "--manifest", "--no-stat")
803 assert r.exit_code == 0
804 d = json.loads(r.output)
805 assert "manifest" in d
806 assert "files_added" not in d
807 assert "files_removed" not in d
808 assert "files_modified" not in d
809
810 def test_manifest_coexists_with_stat(self, repo: pathlib.Path) -> None:
811 """``--manifest`` and file-stat keys both appear together."""
812 (repo / "c.py").write_text("z = 3\n")
813 _commit(repo, "-m", "add c.py")
814 r = _show(repo, "--json", "--manifest")
815 assert r.exit_code == 0
816 d = json.loads(r.output)
817 assert "manifest" in d
818 assert "files_added" in d
819
820 def test_no_manifest_flag_suppresses_manifest(self, repo: pathlib.Path) -> None:
821 """``--no-manifest`` is the explicit form of the default: no manifest key."""
822 r = _show(repo, "--json", "--no-manifest")
823 assert r.exit_code == 0
824 d = json.loads(r.output)
825 assert "manifest" not in d
826
827 def test_manifest_in_text_mode_no_crash(self, repo: pathlib.Path) -> None:
828 """``--manifest`` in text mode does not crash — it is silently ignored."""
829 r = _show(repo, "--manifest")
830 assert r.exit_code == 0
831
832 def test_manifest_reflects_file_at_specific_commit(
833 self, repo: pathlib.Path
834 ) -> None:
835 """Manifest for an older commit reflects that commit's snapshot, not HEAD."""
836 from muse.core.store import get_head_commit_id, read_current_branch
837 first_cid = get_head_commit_id(repo, read_current_branch(repo))
838 # Add a new file in a second commit.
839 (repo / "d.py").write_text("w = 4\n")
840 _commit(repo, "-m", "add d.py")
841 # Manifest of the first commit must not contain d.py.
842 r = _show(repo, first_cid or "", "--json", "--manifest")
843 assert r.exit_code == 0
844 d = json.loads(r.output)
845 assert "d.py" not in d["manifest"], (
846 "d.py must not appear in the manifest of the commit predating its addition"
847 )
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 120 days ago