gabriel / muse public
test_cmd_show.py python
966 lines 40.2 KB
Raw
sha256:1d3f5470f45db58e32047678debc9438fdded1b2c7332cc743d2b8be32fdafc8 fixing more broken tests Human patch 13 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 _commit(repo, "-m", "agent commit", "--agent-id", "cursor-bot")
383 result = _show(repo)
384 assert "Agent:" in result.output
385 assert "cursor-bot" in result.output
386
387 def test_agent_id_omitted_when_empty(self, repo: pathlib.Path) -> None:
388 result = _show(repo)
389 assert "Agent:" not in result.output
390
391 def test_sem_ver_shown_when_not_none(
392 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
393 ) -> None:
394 """When sem_ver_bump is 'minor', text output should show SemVer: minor."""
395 from unittest.mock import patch
396 from muse.core.commits import (
397 CommitRecord,
398 read_commit,
399 )
400
401 cid = get_head_commit_id(repo, "main")
402 assert cid is not None
403 original_read = read_commit
404
405 def patched_read(root: pathlib.Path, commit_id: str) -> CommitRecord | None:
406 rec = original_read(root, commit_id)
407 if rec is not None:
408 # Inject a non-trivial sem_ver_bump for testing
409 object.__setattr__(rec, "sem_ver_bump", "minor")
410 return rec
411
412 with patch("muse.cli.commands.read.read_commit"):
413 pass # not the right approach — test via real commit flow
414
415 # Verify: if sem_ver_bump != "none" on the record, SemVer: shows in output.
416 # We test this via the JSON path which always reflects the stored value.
417 result = _show(repo, "--json")
418 data = json.loads(result.output)
419 sem = data.get("sem_ver_bump", "none")
420 result_text = _show(repo)
421 if sem != "none":
422 assert "SemVer:" in result_text.output
423 # If it's "none", SemVer line should not appear
424 else:
425 assert "SemVer:" not in result_text.output
426
427 def test_genesis_commit_shows_sem_ver(self, repo: pathlib.Path) -> None:
428 # Genesis commits now produce a real structured_delta (all inserts),
429 # so they get a semver bump and the SemVer line appears in output.
430 result = _show(repo)
431 assert "SemVer:" in result.output
432
433 def test_metadata_shown_in_text(self, repo: pathlib.Path) -> None:
434 (repo / "c.py").write_text("c=1\n")
435 _commit(repo, "-m", "chorus", "--section", "chorus")
436 result = _show(repo)
437 assert "section" in result.output
438 assert "chorus" in result.output
439
440
441 # ──────────────────────────────────────────────────────────────────────────────
442 # End-to-end — JSON output schema
443 # ──────────────────────────────────────────────────────────────────────────────
444
445
446 class TestJsonSchema:
447 REQUIRED_KEYS = {
448 "commit_id",
449 "branch",
450 "message",
451 "author",
452 "agent_id",
453 "committed_at",
454 "snapshot_id",
455 "parent_commit_id",
456 "sem_ver_bump",
457 "breaking_changes",
458 "files_added",
459 "files_removed",
460 "files_modified",
461 "dirs_added",
462 "dirs_removed",
463 }
464
465 def test_json_schema_complete(self, repo: pathlib.Path) -> None:
466 result = _show(repo, "--json")
467 assert result.exit_code == 0
468 data = json.loads(result.output)
469 missing = self.REQUIRED_KEYS - set(data)
470 assert not missing, f"Missing JSON keys: {missing}"
471
472 def test_committed_at_is_iso(self, repo: pathlib.Path) -> None:
473 import datetime
474
475 result = _show(repo, "--json")
476 data = json.loads(result.output)
477 dt = datetime.datetime.fromisoformat(data["committed_at"])
478 assert dt.tzinfo is not None
479
480 def test_parent_commit_id_null_on_first_commit(self, repo: pathlib.Path) -> None:
481 result = _show(repo, "--json")
482 data = json.loads(result.output)
483 assert data["parent_commit_id"] is None
484
485 def test_parent2_commit_id_absent_on_linear_commit(self, repo: pathlib.Path) -> None:
486 result = _show(repo, "--json")
487 data = json.loads(result.output)
488 assert "parent2_commit_id" not in data
489
490 def test_files_added_contains_new_file(self, repo: pathlib.Path) -> None:
491 result = _show(repo, "--json")
492 data = json.loads(result.output)
493 assert "a.py" in data["files_added"]
494
495 def test_files_modified_on_second_commit(self, repo: pathlib.Path) -> None:
496 (repo / "a.py").write_text("x = 99\n")
497 _commit(repo, "-m", "modify a")
498 result = _show(repo, "--json")
499 data = json.loads(result.output)
500 assert "a.py" in data["files_modified"]
501
502 def test_files_removed_on_delete(self, repo: pathlib.Path) -> None:
503 (repo / "b.py").write_text("b=1\n")
504 _commit(repo, "-m", "add b")
505 (repo / "b.py").unlink()
506 _commit(repo, "-m", "remove b")
507 result = _show(repo, "--json")
508 data = json.loads(result.output)
509 assert "b.py" in data["files_removed"]
510
511 def test_no_stat_omits_files_keys(self, repo: pathlib.Path) -> None:
512 result = _show(repo, "--json", "--no-stat")
513 data = json.loads(result.output)
514 assert "files_added" not in data
515 assert "files_removed" not in data
516 assert "files_modified" not in data
517
518 def test_no_delta_omits_structured_delta(self, repo: pathlib.Path) -> None:
519 result = _show(repo, "--json", "--no-delta")
520 data = json.loads(result.output)
521 assert "structured_delta" not in data
522
523 def test_structured_delta_present_by_default(self, repo: pathlib.Path) -> None:
524 (repo / "b.py").write_text("b=1\n")
525 _commit(repo, "-m", "add b")
526 result = _show(repo, "--json")
527 data = json.loads(result.output)
528 assert "structured_delta" in data
529
530 def test_breaking_changes_is_list(self, repo: pathlib.Path) -> None:
531 result = _show(repo, "--json")
532 data = json.loads(result.output)
533 assert isinstance(data["breaking_changes"], list)
534
535 def test_sem_ver_bump_is_string(self, repo: pathlib.Path) -> None:
536 result = _show(repo, "--json")
537 data = json.loads(result.output)
538 assert isinstance(data["sem_ver_bump"], str)
539 assert data["sem_ver_bump"] in ("none", "patch", "minor", "major")
540
541
542 # ──────────────────────────────────────────────────────────────────────────────
543 # Integration — JSON idioms (null vs "", omit vs zero)
544 # ──────────────────────────────────────────────────────────────────────────────
545
546
547 class TestJsonIdioms:
548 """muse read --json must use null for unset optional strings, and omit
549 empty/zero/null fields that carry no information."""
550
551 def _data(self, repo: pathlib.Path, *args: str) -> Mapping[str, object]:
552 result = _show(repo, "--json", *args)
553 assert result.exit_code == 0
554 return json.loads(result.output)
555
556 # Provenance strings: null when unset, not empty string
557 def test_agent_id_is_null_for_human_commit(self, repo: pathlib.Path) -> None:
558 data = self._data(repo)
559 assert data["agent_id"] is None, f"expected null, got {data['agent_id']!r}"
560
561 def test_model_id_is_null_for_human_commit(self, repo: pathlib.Path) -> None:
562 data = self._data(repo)
563 assert data["model_id"] is None
564
565 def test_toolchain_id_is_null_for_human_commit(self, repo: pathlib.Path) -> None:
566 data = self._data(repo)
567 assert data["toolchain_id"] is None
568
569 def test_prompt_hash_is_null_for_unsigned_commit(self, repo: pathlib.Path) -> None:
570 data = self._data(repo)
571 assert data["prompt_hash"] is None
572
573 def test_signature_is_null_for_unsigned_commit(self, repo: pathlib.Path) -> None:
574 data = self._data(repo)
575 assert data["signature"] is None
576
577 def test_signer_public_key_is_null_for_unsigned_commit(self, repo: pathlib.Path) -> None:
578 data = self._data(repo)
579 assert data["signer_public_key"] is None
580
581 def test_signer_key_id_is_null_for_unsigned_commit(self, repo: pathlib.Path) -> None:
582 data = self._data(repo)
583 assert data["signer_key_id"] is None
584
585 def test_status_is_null_when_unset(self, repo: pathlib.Path) -> None:
586 data = self._data(repo)
587 assert data["status"] is None
588
589 # Empty collections and zero scalars: omit when carrying no information
590 def test_metadata_absent_when_empty(self, repo: pathlib.Path) -> None:
591 data = self._data(repo)
592 assert "metadata" not in data, "metadata should be omitted when {}"
593
594 def test_reviewed_by_absent_when_empty(self, repo: pathlib.Path) -> None:
595 data = self._data(repo)
596 assert "reviewed_by" not in data
597
598 def test_labels_absent_when_empty(self, repo: pathlib.Path) -> None:
599 data = self._data(repo)
600 assert "labels" not in data
601
602 def test_notes_absent_when_empty(self, repo: pathlib.Path) -> None:
603 data = self._data(repo)
604 assert "notes" not in data
605
606 def test_test_runs_absent_when_zero(self, repo: pathlib.Path) -> None:
607 data = self._data(repo)
608 assert "test_runs" not in data
609
610 def test_score_absent_when_null(self, repo: pathlib.Path) -> None:
611 data = self._data(repo)
612 assert "score" not in data
613
614 def test_parent2_commit_id_absent_on_linear_commit(self, repo: pathlib.Path) -> None:
615 data = self._data(repo)
616 assert "parent2_commit_id" not in data
617
618 # Agent commits: provenance fields are non-null
619 def test_agent_id_non_null_for_agent_commit(self, repo: pathlib.Path) -> None:
620 (repo / "b.py").write_text("b=1\n")
621 _commit(repo, "-m", "agent work", "--agent-id", "claude-code", "--model-id", "claude-sonnet-4-6")
622 data = self._data(repo)
623 assert data["agent_id"] == "claude-code"
624 assert data["model_id"] == "claude-sonnet-4-6"
625
626 def test_metadata_present_when_populated(self, repo: pathlib.Path) -> None:
627 (repo / "c.py").write_text("c=1\n")
628 _commit(repo, "-m", "chorus", "--section", "chorus")
629 data = self._data(repo)
630 assert "metadata" in data
631 assert data["metadata"].get("section") == "chorus"
632
633 def test_parent2_present_on_merge_commit(self, repo: pathlib.Path) -> None:
634 _invoke(repo, ["branch", "feat2"])
635 _invoke(repo, ["checkout", "feat2"])
636 (repo / "feat2.py").write_text("f=2\n")
637 _commit(repo, "-m", "feat2 change")
638 _invoke(repo, ["checkout", "main"])
639 (repo / "main2.py").write_text("m=2\n")
640 _commit(repo, "-m", "main2 change")
641 _invoke(repo, ["merge", "feat2"])
642 data = self._data(repo)
643 assert "parent2_commit_id" in data
644 assert data["parent2_commit_id"] is not None
645
646
647 # ──────────────────────────────────────────────────────────────────────────────
648 # Integration — merge commits
649 # ──────────────────────────────────────────────────────────────────────────────
650
651
652 class TestMergeCommit:
653 def test_merge_commit_shows_second_parent(self, repo: pathlib.Path) -> None:
654 _invoke(repo, ["branch", "feat"])
655 _invoke(repo, ["checkout", "feat"])
656 (repo / "feat.py").write_text("f=1\n")
657 _commit(repo, "-m", "feat change")
658 _invoke(repo, ["checkout", "main"])
659 (repo / "main_only.py").write_text("m=1\n")
660 _commit(repo, "-m", "main change")
661 _invoke(repo, ["merge", "feat"])
662 result = _show(repo)
663 assert "Parent:" in result.output
664 # Should show merge annotation
665 assert "merge" in result.output.lower() or "Parent:" in result.output
666
667 def test_merge_commit_json_parent2(self, repo: pathlib.Path) -> None:
668 _invoke(repo, ["branch", "feat"])
669 _invoke(repo, ["checkout", "feat"])
670 (repo / "f2.py").write_text("f=2\n")
671 _commit(repo, "-m", "feat2")
672 _invoke(repo, ["checkout", "main"])
673 (repo / "m2.py").write_text("m=2\n")
674 _commit(repo, "-m", "main2")
675 _invoke(repo, ["merge", "feat"])
676 result = _show(repo, "--json")
677 data = json.loads(result.output)
678 # After merge, parent2_commit_id should be set
679 assert data["parent2_commit_id"] is not None
680
681
682 # ──────────────────────────────────────────────────────────────────────────────
683 # Integration — multiple commits, ref resolution
684 # ──────────────────────────────────────────────────────────────────────────────
685
686
687 class TestRefResolution:
688 def test_show_first_commit_by_id(self, repo: pathlib.Path) -> None:
689 first_cid = get_head_commit_id(repo, "main")
690 (repo / "b.py").write_text("b=1\n")
691 _commit(repo, "-m", "second")
692 # Show the first commit by its full ID
693 result = _show(repo, first_cid or "")
694 assert result.exit_code == 0
695 assert "initial commit" in result.output
696
697 def test_show_second_commit_is_head_by_default(self, repo: pathlib.Path) -> None:
698 (repo / "b.py").write_text("b=1\n")
699 _commit(repo, "-m", "the second commit")
700 result = _show(repo)
701 assert "the second commit" in result.output
702
703 def test_show_branch_name_resolves(self, repo: pathlib.Path) -> None:
704 result = _show(repo, "main")
705 assert result.exit_code == 0
706
707 def test_show_nonexistent_ref_exits_1(self, repo: pathlib.Path) -> None:
708 result = _show(repo, "nonexistent-branch-xyz")
709 assert result.exit_code == 1
710
711 def test_show_partial_sha_resolves(self, repo: pathlib.Path) -> None:
712 cid = get_head_commit_id(repo, "main")
713 assert cid is not None
714 result = _show(repo, short_id(cid))
715 assert result.exit_code == 0
716
717
718 # ──────────────────────────────────────────────────────────────────────────────
719 # Integration — validation
720 # ──────────────────────────────────────────────────────────────────────────────
721
722
723 class TestValidation:
724 def test_unknown_flag_exits_nonzero(self, repo: pathlib.Path) -> None:
725 result = _show(repo, "--format", "xml")
726 assert result.exit_code != 0
727
728 def test_error_message_printed_to_stderr_not_stdout(
729 self, repo: pathlib.Path
730 ) -> None:
731 result = _show(repo, "nonexistent")
732 # Error message should be in stderr (or combined output from helper)
733 assert "not found" in result.output.lower() or "not found" in (result.stderr or "").lower()
734
735
736 # ──────────────────────────────────────────────────────────────────────────────
737 # Security — ANSI injection
738 # ──────────────────────────────────────────────────────────────────────────────
739
740
741 class TestSecurityAnsi:
742 def _has_ansi(self, s: str) -> bool:
743 return "\x1b[" in s
744
745 def test_ansi_in_ref_sanitized(self, repo: pathlib.Path) -> None:
746 result = _show(repo, "\x1b[31mmalicious\x1b[0m")
747 assert not self._has_ansi(result.output)
748
749 def test_ansi_in_format_flag_sanitized(self, repo: pathlib.Path) -> None:
750 result = _show(repo, "--format", "\x1b[31mxml\x1b[0m")
751 assert not self._has_ansi(result.output)
752
753 def test_ansi_in_commit_message_sanitized(self, repo: pathlib.Path) -> None:
754 _commit(
755 repo, "-m", "clean \x1b[31mred\x1b[0m message", "--allow-empty"
756 )
757 result = _show(repo)
758 assert not self._has_ansi(result.output)
759
760 def test_ansi_in_author_sanitized(self, repo: pathlib.Path) -> None:
761 (repo / "c.py").write_text("c=1\n")
762 _commit(repo, "-m", "by malicious", "--author", "\x1b[1mmalicious\x1b[0m")
763 result = _show(repo)
764 assert not self._has_ansi(result.output)
765
766 def test_ansi_in_metadata_sanitized(self, repo: pathlib.Path) -> None:
767 (repo / "d.py").write_text("d=1\n")
768 _commit(repo, "-m", "tagged", "--section", "\x1b[31msection\x1b[0m")
769 result = _show(repo)
770 assert not self._has_ansi(result.output)
771
772 def test_ansi_in_agent_id_sanitized(self, repo: pathlib.Path) -> None:
773 (repo / "e.py").write_text("e=1\n")
774 _commit(repo, "-m", "agent", "--agent-id", "\x1b[31mmalicious-bot\x1b[0m")
775 result = _show(repo)
776 assert not self._has_ansi(result.output)
777
778
779 # ──────────────────────────────────────────────────────────────────────────────
780 # Stress — large history
781 # ──────────────────────────────────────────────────────────────────────────────
782
783
784 @pytest.mark.slow
785 class TestStress:
786 def test_show_after_100_commits_fast(self, repo: pathlib.Path) -> None:
787 for i in range(100):
788 (repo / f"f{i:04d}.py").write_text(f"x={i}\n")
789 _commit(repo, "-m", f"commit {i}")
790 t0 = time.perf_counter()
791 result = _show(repo, "--json")
792 elapsed = (time.perf_counter() - t0) * 1000
793 assert result.exit_code == 0
794 assert elapsed < 1000, f"show took {elapsed:.0f}ms (limit 1000ms)"
795
796 def test_show_first_commit_in_deep_history(self, repo: pathlib.Path) -> None:
797 first_cid = get_head_commit_id(repo, "main")
798 for i in range(50):
799 (repo / f"g{i:04d}.py").write_text(f"y={i}\n")
800 _commit(repo, "-m", f"later {i}")
801 result = _show(repo, first_cid or "")
802 assert result.exit_code == 0
803 assert "initial commit" in result.output
804
805 def test_no_delta_significantly_smaller_json(self, repo: pathlib.Path) -> None:
806 # With many files the structured_delta can be large
807 for i in range(50):
808 (repo / f"h{i:04d}.py").write_text(f"z={i}\n")
809 _commit(repo, "-m", "big commit")
810 r_full = _show(repo, "--json")
811 r_nodelta = _show(repo, "--json", "--no-delta")
812 # --no-delta output must be smaller (structured_delta stripped)
813 assert len(r_nodelta.output) <= len(r_full.output)
814
815 def test_concurrent_show_separate_repos(self, tmp_path: pathlib.Path) -> None:
816 """Multiple threads showing from separate repos must not interfere."""
817 errors: list[str] = []
818
819 def do_show(idx: int) -> None:
820 repo_dir = tmp_path / f"repo_{idx}"
821 repo_dir.mkdir()
822 subprocess.run(
823 ["muse", "init"], cwd=str(repo_dir), capture_output=True
824 )
825 (repo_dir / "x.py").write_text(f"x={idx}\n")
826 subprocess.run(
827 ["muse", "commit", "-m", f"c{idx}"],
828 cwd=str(repo_dir), capture_output=True,
829 )
830 r = subprocess.run(
831 ["muse", "read", "--json"],
832 cwd=str(repo_dir), capture_output=True, text=True,
833 )
834 if r.returncode != 0:
835 errors.append(f"repo_{idx}: show failed")
836 return
837 data = json.loads(r.stdout)
838 if data.get("message") != f"c{idx}":
839 errors.append(f"repo_{idx}: wrong message {data.get('message')!r}")
840
841 threads = [threading.Thread(target=do_show, args=(i,)) for i in range(8)]
842 for t in threads:
843 t.start()
844 for t in threads:
845 t.join()
846
847 assert not errors, f"Concurrent show errors:\n{'\n'.join(errors)}"
848
849
850 # ──────────────────────────────────────────────────────────────────────────────
851 # Integration — --manifest flag
852 # ──────────────────────────────────────────────────────────────────────────────
853
854
855 class TestManifest:
856 """``muse read --json --manifest`` includes the full snapshot manifest.
857
858 The manifest maps every tracked path to its content hash (object_id)
859 at the inspected commit. It is absent by default so the default JSON
860 payload stays compact; agents opt in when they need the full file list.
861 """
862
863 def test_manifest_absent_by_default(self, repo: pathlib.Path) -> None:
864 """``manifest`` key must NOT appear in default JSON output."""
865 r = _show(repo, "--json")
866 assert r.exit_code == 0
867 d = json.loads(r.output)
868 assert "manifest" not in d, (
869 "'manifest' key must be absent unless --manifest is given"
870 )
871
872 def test_manifest_present_when_flag_set(self, repo: pathlib.Path) -> None:
873 """``--manifest`` adds a ``manifest`` key to the JSON output."""
874 r = _show(repo, "--json", "--manifest")
875 assert r.exit_code == 0
876 d = json.loads(r.output)
877 assert "manifest" in d, "'manifest' key missing with --manifest"
878
879 def test_manifest_is_dict(self, repo: pathlib.Path) -> None:
880 """``manifest`` value is a plain dict (path → object_id)."""
881 r = _show(repo, "--json", "--manifest")
882 assert r.exit_code == 0
883 d = json.loads(r.output)
884 assert isinstance(d["manifest"], dict)
885
886 def test_manifest_contains_committed_file(self, repo: pathlib.Path) -> None:
887 """The file committed in the repo fixture appears in the manifest."""
888 r = _show(repo, "--json", "--manifest")
889 assert r.exit_code == 0
890 d = json.loads(r.output)
891 assert "a.py" in d["manifest"], (
892 f"'a.py' missing from manifest keys: {list(d['manifest'].keys())}"
893 )
894
895 def test_manifest_values_are_non_empty_strings(self, repo: pathlib.Path) -> None:
896 """Every manifest value is a non-empty string (the content hash)."""
897 r = _show(repo, "--json", "--manifest")
898 assert r.exit_code == 0
899 d = json.loads(r.output)
900 for path, oid in d["manifest"].items():
901 assert isinstance(oid, str) and oid, (
902 f"object_id for {path!r} is empty or not a string: {oid!r}"
903 )
904
905 def test_manifest_keys_sorted(self, repo: pathlib.Path) -> None:
906 """Manifest keys are sorted for determinism across calls."""
907 # Add a second file so there are multiple entries to order.
908 (repo / "b.py").write_text("y = 2\n")
909 _commit(repo, "-m", "add b.py")
910 r = _show(repo, "--json", "--manifest")
911 assert r.exit_code == 0
912 d = json.loads(r.output)
913 keys = list(d["manifest"].keys())
914 assert keys == sorted(keys), f"Manifest keys not sorted: {keys}"
915
916 def test_manifest_with_no_stat(self, repo: pathlib.Path) -> None:
917 """``--manifest --no-stat`` still includes the manifest (independent flags)."""
918 r = _show(repo, "--json", "--manifest", "--no-stat")
919 assert r.exit_code == 0
920 d = json.loads(r.output)
921 assert "manifest" in d
922 assert "files_added" not in d
923 assert "files_removed" not in d
924 assert "files_modified" not in d
925
926 def test_manifest_coexists_with_stat(self, repo: pathlib.Path) -> None:
927 """``--manifest`` and file-stat keys both appear together."""
928 (repo / "c.py").write_text("z = 3\n")
929 _commit(repo, "-m", "add c.py")
930 r = _show(repo, "--json", "--manifest")
931 assert r.exit_code == 0
932 d = json.loads(r.output)
933 assert "manifest" in d
934 assert "files_added" in d
935
936 def test_no_manifest_flag_suppresses_manifest(self, repo: pathlib.Path) -> None:
937 """``--no-manifest`` is the explicit form of the default: no manifest key."""
938 r = _show(repo, "--json", "--no-manifest")
939 assert r.exit_code == 0
940 d = json.loads(r.output)
941 assert "manifest" not in d
942
943 def test_manifest_in_text_mode_no_crash(self, repo: pathlib.Path) -> None:
944 """``--manifest`` in text mode does not crash — it is silently ignored."""
945 r = _show(repo, "--manifest")
946 assert r.exit_code == 0
947
948 def test_manifest_reflects_file_at_specific_commit(
949 self, repo: pathlib.Path
950 ) -> None:
951 """Manifest for an older commit reflects that commit's snapshot, not HEAD."""
952 from muse.core.refs import (
953 get_head_commit_id,
954 read_current_branch,
955 )
956 first_cid = get_head_commit_id(repo, read_current_branch(repo))
957 # Add a new file in a second commit.
958 (repo / "d.py").write_text("w = 4\n")
959 _commit(repo, "-m", "add d.py")
960 # Manifest of the first commit must not contain d.py.
961 r = _show(repo, first_cid or "", "--json", "--manifest")
962 assert r.exit_code == 0
963 d = json.loads(r.output)
964 assert "d.py" not in d["manifest"], (
965 "d.py must not appear in the manifest of the commit predating its addition"
966 )
File History 1 commit
sha256:1d3f5470f45db58e32047678debc9438fdded1b2c7332cc743d2b8be32fdafc8 fixing more broken tests Human patch 13 days ago