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