gabriel / muse public
test_cmd_bundle_hardening.py python
2,066 lines 83.2 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 144 days ago
1 """Hardening tests for ``muse bundle``.
2
3 Covers:
4 Unit — _iter_branches (symlink guard, size cap), _reachable_from,
5 _load_bundle (narrow except), _resolve_refs, TypeGuards
6 Security — symlink traversal in _iter_branches, ANSI injection in
7 branch names and failure messages, oversized bundle rejection
8 Perf — reachable set is pre-computed once (not per branch)
9 JSON — _BundleCreateJson, _BundleUnbundleJson, _BundleVerifyJson,
10 list-heads dict schema
11 Flags — --json for create / unbundle / verify / list-heads
12 Integration — create → unbundle round-trip with branch ref updates,
13 --have pruning reduces bundle size,
14 verify catches corruption and missing snapshot objects
15 E2E — --help output for all subcommands
16 Stress — 200-commit chain, concurrent unbundle reads
17 """
18
19 from __future__ import annotations
20
21 import datetime
22 import hashlib
23 import json
24 import pathlib
25 import threading
26 from typing import TypedDict
27
28 import msgpack
29 import pytest
30 from tests.cli_test_helper import CliRunner, InvokeResult
31
32 from muse.core.object_store import write_object
33 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
34 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
35 from muse.core._types import Manifest, long_id
36
37 cli = None
38 runner = CliRunner()
39 _invoke_lock = threading.Lock()
40
41 _REPO_ID = "bundle-hardening-test"
42
43
44 # ---------------------------------------------------------------------------
45 # Helpers
46 # ---------------------------------------------------------------------------
47
48
49 class _CreateOut(TypedDict):
50 file: str
51 commits: int
52 objects: int
53 size_bytes: int
54 branches: list[str]
55
56
57 class _UnbundleOut(TypedDict):
58 commits_written: int
59 snapshots_written: int
60 objects_written: int
61 objects_skipped: int
62 refs_updated: list[str]
63
64
65 class _VerifyOut(TypedDict):
66 objects_checked: int
67 snapshots_checked: int
68 all_ok: bool
69 failures: list[str]
70
71
72 def _sha(data: bytes) -> str:
73 return hashlib.sha256(data).hexdigest()
74
75
76 def _init_repo(path: pathlib.Path, repo_id: str = _REPO_ID) -> pathlib.Path:
77 muse = path / ".muse"
78 for d in ("commits", "snapshots", "objects", "refs/heads"):
79 (muse / d).mkdir(parents=True, exist_ok=True)
80 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
81 (muse / "repo.json").write_text(
82 json.dumps({"repo_id": repo_id, "domain": "midi"}), encoding="utf-8"
83 )
84 return path
85
86
87 def _env(repo: pathlib.Path) -> Manifest:
88 return {"MUSE_REPO_ROOT": str(repo)}
89
90
91 _counter = 0
92 _branch_heads_map: dict[tuple[str, str], str] = {}
93
94
95 def _make_commit(
96 root: pathlib.Path,
97 parent_id: str | None = None,
98 content: bytes = b"data",
99 branch: str = "main",
100 ) -> str:
101 global _counter
102 _counter += 1
103 c = content + str(_counter).encode()
104 obj_id = long_id(_sha(c))
105 write_object(root, obj_id, c)
106 manifest = {f"f_{_counter}.txt": obj_id}
107 snap_id = compute_snapshot_id(manifest)
108 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
109 committed_at = datetime.datetime.now(datetime.timezone.utc)
110
111 resolved_parent = parent_id
112 if resolved_parent is None:
113 key = (str(root), branch)
114 resolved_parent = _branch_heads_map.get(key)
115
116 parent_ids = [resolved_parent] if resolved_parent else []
117 commit_id = compute_commit_id(
118 parent_ids, snap_id, f"commit {_counter}", committed_at.isoformat()
119 )
120 write_commit(
121 root,
122 CommitRecord(
123 commit_id=commit_id,
124 repo_id=_REPO_ID,
125 branch=branch,
126 snapshot_id=snap_id,
127 message=f"commit {_counter}",
128 committed_at=committed_at,
129 parent_commit_id=resolved_parent,
130 ),
131 )
132 ref_path = root / ".muse" / "refs" / "heads" / branch
133 ref_path.parent.mkdir(parents=True, exist_ok=True)
134 ref_path.write_text(commit_id, encoding="utf-8")
135 _branch_heads_map[(str(root), branch)] = commit_id
136 return commit_id
137
138
139 def _invoke(args: list[str], env: Manifest | None = None) -> InvokeResult:
140 with _invoke_lock:
141 return runner.invoke(cli, args, env=env)
142
143
144 def _parse_create(result: InvokeResult) -> _CreateOut:
145 raw: _CreateOut = json.loads(result.output)
146 return raw
147
148
149 def _parse_unbundle(result: InvokeResult) -> _UnbundleOut:
150 raw: _UnbundleOut = json.loads(result.output)
151 return raw
152
153
154 def _parse_verify(result: InvokeResult) -> _VerifyOut:
155 raw: _VerifyOut = json.loads(result.output)
156 return raw
157
158
159 # ---------------------------------------------------------------------------
160 # Unit: _iter_branches — symlink guard
161 # ---------------------------------------------------------------------------
162
163
164 def test_iter_branches_skips_symlinks(tmp_path: pathlib.Path) -> None:
165 """A symlink inside refs/heads must be silently skipped."""
166 from muse.cli.commands.bundle import _iter_branches
167
168 _init_repo(tmp_path)
169 target = tmp_path / "outside.txt"
170 target.write_text("evil-sha" * 8, encoding="utf-8") # 64 chars
171
172 heads_dir = tmp_path / ".muse" / "refs" / "heads"
173 real_ref = heads_dir / "main"
174 real_ref.write_text("a" * 64, encoding="utf-8")
175
176 link = heads_dir / "evil"
177 link.symlink_to(target)
178
179 result = _iter_branches(tmp_path)
180 branch_names = [name for name, _ in result]
181 assert "evil" not in branch_names
182 assert "main" in branch_names
183
184
185 def test_iter_branches_size_cap(tmp_path: pathlib.Path) -> None:
186 """Ref files larger than 65 bytes are read but will be invalid after strip."""
187 from muse.cli.commands.bundle import _iter_branches, _MAX_REF_BYTES
188
189 _init_repo(tmp_path)
190 heads_dir = tmp_path / ".muse" / "refs" / "heads"
191 oversized = heads_dir / "main"
192 oversized.write_bytes(b"x" * (_MAX_REF_BYTES + 100))
193
194 result = _iter_branches(tmp_path)
195 # Should still return one entry; the content is capped — the commit ID
196 # won't be valid hex but _iter_branches returns it; validation is downstream.
197 assert len(result) == 1
198 _, cid = result[0]
199 assert len(cid) <= _MAX_REF_BYTES # capped at read time
200
201
202 def test_iter_branches_empty_dir(tmp_path: pathlib.Path) -> None:
203 from muse.cli.commands.bundle import _iter_branches
204
205 _init_repo(tmp_path)
206 result = _iter_branches(tmp_path)
207 assert result == []
208
209
210 def test_iter_branches_multiple(tmp_path: pathlib.Path) -> None:
211 from muse.cli.commands.bundle import _iter_branches
212
213 _init_repo(tmp_path)
214 heads_dir = tmp_path / ".muse" / "refs" / "heads"
215 for name in ("main", "dev", "feat/foo"):
216 p = heads_dir / name
217 p.parent.mkdir(parents=True, exist_ok=True)
218 p.write_text("a" * 64, encoding="utf-8")
219
220 result = _iter_branches(tmp_path)
221 names = [n for n, _ in result]
222 assert "main" in names
223 assert "dev" in names
224 assert "feat/foo" in names
225
226
227 # ---------------------------------------------------------------------------
228 # Unit: _reachable_from — correctness
229 # ---------------------------------------------------------------------------
230
231
232 def test_reachable_from_single(tmp_path: pathlib.Path) -> None:
233 from muse.cli.commands.bundle import _reachable_from
234
235 _init_repo(tmp_path)
236 c1 = _make_commit(tmp_path, content=b"r1")
237 result = _reachable_from(tmp_path, [c1])
238 assert c1 in result
239
240
241 def test_reachable_from_chain(tmp_path: pathlib.Path) -> None:
242 from muse.cli.commands.bundle import _reachable_from
243
244 _init_repo(tmp_path)
245 c1 = _make_commit(tmp_path, content=b"rc1")
246 c2 = _make_commit(tmp_path, parent_id=c1, content=b"rc2")
247 c3 = _make_commit(tmp_path, parent_id=c2, content=b"rc3")
248 result = _reachable_from(tmp_path, [c3])
249 assert c1 in result
250 assert c2 in result
251 assert c3 in result
252
253
254 def test_reachable_from_empty_tips(tmp_path: pathlib.Path) -> None:
255 from muse.cli.commands.bundle import _reachable_from
256
257 _init_repo(tmp_path)
258 assert _reachable_from(tmp_path, []) == set()
259
260
261 # ---------------------------------------------------------------------------
262 # Unit: _load_bundle — narrow except
263 # ---------------------------------------------------------------------------
264
265
266 def test_load_bundle_not_found(tmp_path: pathlib.Path) -> None:
267 result = _invoke(
268 ["bundle", "verify", str(tmp_path / "missing.bundle")],
269 env=_env(tmp_path),
270 )
271 assert result.exit_code != 0
272
273
274 def test_load_bundle_invalid_msgpack(tmp_path: pathlib.Path) -> None:
275 _init_repo(tmp_path)
276 bad = tmp_path / "bad.bundle"
277 bad.write_bytes(b"\xff\xfe this is not msgpack")
278 result = _invoke(["bundle", "verify", str(bad)], env=_env(tmp_path))
279 assert result.exit_code != 0
280
281
282 def test_load_bundle_not_dict(tmp_path: pathlib.Path) -> None:
283 """A valid msgpack list instead of dict must be rejected cleanly."""
284 _init_repo(tmp_path)
285 bad = tmp_path / "list.bundle"
286 bad.write_bytes(msgpack.packb([1, 2, 3], use_bin_type=True))
287 result = _invoke(["bundle", "verify", str(bad)], env=_env(tmp_path))
288 assert result.exit_code != 0
289
290
291 # ---------------------------------------------------------------------------
292 # Security: ANSI injection in branch names
293 # ---------------------------------------------------------------------------
294
295
296 def test_list_heads_ansi_injection(tmp_path: pathlib.Path) -> None:
297 """Branch names with ANSI escapes must be stripped in text output."""
298 _init_repo(tmp_path)
299 _make_commit(tmp_path, content=b"ansi-branch")
300 out = tmp_path / "ansi.bundle"
301 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
302
303 # Inject a crafted branch_heads entry with ANSI escape in branch name.
304 raw = msgpack.unpackb(out.read_bytes(), raw=False)
305 raw["branch_heads"] = {"\x1b[31mevil\x1b[0m": "a" * 64}
306 out.write_bytes(msgpack.packb(raw, use_bin_type=True))
307
308 result = _invoke(["bundle", "list-heads", str(out)], env=_env(tmp_path))
309 assert result.exit_code == 0
310 assert "\x1b" not in result.output
311
312
313 def test_verify_failure_ansi_injection(tmp_path: pathlib.Path) -> None:
314 """Failure messages must not allow ANSI injection through object_id fields."""
315 _init_repo(tmp_path)
316 _make_commit(tmp_path, content=b"ansi-verify")
317 out = tmp_path / "ansi-v.bundle"
318 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
319
320 # Tamper content to trigger a hash mismatch failure.
321 raw = msgpack.unpackb(out.read_bytes(), raw=False)
322 if raw.get("objects"):
323 raw["objects"][0]["content"] = b"\x1b[31mTAMPERED\x1b[0m"
324 out.write_bytes(msgpack.packb(raw, use_bin_type=True))
325
326 result = _invoke(["bundle", "verify", str(out)], env=_env(tmp_path))
327 # Text output must strip ANSI from the failure description.
328 assert "\x1b" not in result.output
329 assert result.exit_code != 0
330
331
332 # ---------------------------------------------------------------------------
333 # JSON schema: bundle create --json
334 # ---------------------------------------------------------------------------
335
336
337 def test_create_json_schema(tmp_path: pathlib.Path) -> None:
338 _init_repo(tmp_path)
339 _make_commit(tmp_path, content=b"cj1")
340 out = tmp_path / "cj.bundle"
341 result = _invoke(["bundle", "create", str(out), "--json"], env=_env(tmp_path))
342 assert result.exit_code == 0
343 data = _parse_create(result)
344 assert data["file"] == str(out)
345 assert data["commits"] >= 1
346 assert data["objects"] >= 1
347 assert data["size_bytes"] > 0
348 assert isinstance(data["branches"], list)
349 assert "main" in data["branches"]
350
351
352 def test_create_json_no_output_on_success_without_flag(tmp_path: pathlib.Path) -> None:
353 _init_repo(tmp_path)
354 _make_commit(tmp_path, content=b"cj-no-flag")
355 out = tmp_path / "cnf.bundle"
356 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
357 assert result.exit_code == 0
358 # Text output, not JSON.
359 assert "Bundle" in result.output or "✅" in result.output
360
361
362 # ---------------------------------------------------------------------------
363 # JSON schema: bundle unbundle --json
364 # ---------------------------------------------------------------------------
365
366
367 def test_unbundle_json_schema(tmp_path: pathlib.Path) -> None:
368 src = tmp_path / "src"
369 dst = tmp_path / "dst"
370 src.mkdir()
371 dst.mkdir()
372 _init_repo(src)
373 _init_repo(dst, repo_id="dst-json")
374
375 _make_commit(src, content=b"uj1")
376 out = tmp_path / "uj.bundle"
377 _invoke(["bundle", "create", str(out)], env=_env(src))
378
379 result = _invoke(["bundle", "unbundle", str(out), "--json"], env=_env(dst))
380 assert result.exit_code == 0
381 data = _parse_unbundle(result)
382 assert data["commits_written"] >= 1
383 assert isinstance(data["snapshots_written"], int)
384 assert isinstance(data["objects_written"], int)
385 assert isinstance(data["objects_skipped"], int)
386 assert isinstance(data["refs_updated"], list)
387
388
389 def test_unbundle_json_refs_updated(tmp_path: pathlib.Path) -> None:
390 src = tmp_path / "src"
391 dst = tmp_path / "dst"
392 src.mkdir()
393 dst.mkdir()
394 _init_repo(src)
395 _init_repo(dst, repo_id="dst-ru")
396
397 _make_commit(src, content=b"ru1")
398 out = tmp_path / "ru.bundle"
399 _invoke(["bundle", "create", str(out)], env=_env(src))
400
401 result = _invoke(["bundle", "unbundle", str(out), "--json"], env=_env(dst))
402 assert result.exit_code == 0
403 data = _parse_unbundle(result)
404 assert "main" in data["refs_updated"]
405
406
407 def test_unbundle_json_no_update_refs(tmp_path: pathlib.Path) -> None:
408 src = tmp_path / "src"
409 dst = tmp_path / "dst"
410 src.mkdir()
411 dst.mkdir()
412 _init_repo(src)
413 _init_repo(dst, repo_id="dst-nur")
414
415 _make_commit(src, content=b"nur1")
416 out = tmp_path / "nur.bundle"
417 _invoke(["bundle", "create", str(out)], env=_env(src))
418
419 result = _invoke(
420 ["bundle", "unbundle", str(out), "--no-update-refs", "--json"],
421 env=_env(dst),
422 )
423 assert result.exit_code == 0
424 data = _parse_unbundle(result)
425 assert data["refs_updated"] == []
426
427
428 # ---------------------------------------------------------------------------
429 # JSON schema: bundle verify --json
430 # ---------------------------------------------------------------------------
431
432
433 def test_verify_json_schema_clean(tmp_path: pathlib.Path) -> None:
434 _init_repo(tmp_path)
435 _make_commit(tmp_path, content=b"vjs1")
436 out = tmp_path / "vjs.bundle"
437 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
438 result = _invoke(["bundle", "verify", str(out), "--json"], env=_env(tmp_path))
439 assert result.exit_code == 0
440 data = _parse_verify(result)
441 assert data["all_ok"] is True
442 assert data["objects_checked"] >= 1
443 assert "snapshots_checked" in data
444 assert data["failures"] == []
445
446
447 def test_verify_json_schema_corrupt(tmp_path: pathlib.Path) -> None:
448 _init_repo(tmp_path)
449 _make_commit(tmp_path, content=b"corrupt-j")
450 out = tmp_path / "cj2.bundle"
451 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
452
453 raw = msgpack.unpackb(out.read_bytes(), raw=False)
454 if raw.get("objects"):
455 raw["objects"][0]["content"] = b"tampered!"
456 out.write_bytes(msgpack.packb(raw, use_bin_type=True))
457
458 result = _invoke(["bundle", "verify", str(out), "--json"], env=_env(tmp_path))
459 assert result.exit_code != 0
460 data = _parse_verify(result)
461 assert data["all_ok"] is False
462 assert len(data["failures"]) > 0
463
464
465 def test_verify_json_snapshots_checked(tmp_path: pathlib.Path) -> None:
466 """``snapshots_checked`` must count non-zero when snapshots are present."""
467 _init_repo(tmp_path)
468 _make_commit(tmp_path, content=b"snap-counted")
469 out = tmp_path / "sc.bundle"
470 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
471 result = _invoke(["bundle", "verify", str(out), "--json"], env=_env(tmp_path))
472 assert result.exit_code == 0
473 data = _parse_verify(result)
474 # At least one snapshot should have been included in the bundle.
475 assert data["snapshots_checked"] >= 1
476
477
478 # ---------------------------------------------------------------------------
479 # JSON schema: bundle list-heads --json
480 # ---------------------------------------------------------------------------
481
482
483 def test_list_heads_json_schema(tmp_path: pathlib.Path) -> None:
484 _init_repo(tmp_path)
485 _make_commit(tmp_path, content=b"lhjs1")
486 out = tmp_path / "lhjs.bundle"
487 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
488 result = _invoke(["bundle", "list-heads", str(out), "--json"], env=_env(tmp_path))
489 assert result.exit_code == 0
490 data: Manifest = json.loads(result.output)
491 assert isinstance(data, dict)
492 assert "main" in data
493 for _branch, cid in data.items():
494 assert isinstance(cid, str) and cid.startswith("sha256:")
495 assert len(cid) == len("sha256:") + 64
496
497
498 # ---------------------------------------------------------------------------
499 # Flags: --json rejects old --format arg
500 # ---------------------------------------------------------------------------
501
502
503 def test_verify_rejects_format_flag(tmp_path: pathlib.Path) -> None:
504 """The old ``--format json`` pattern must not be accepted."""
505 _init_repo(tmp_path)
506 _make_commit(tmp_path, content=b"old-fmt")
507 out = tmp_path / "old.bundle"
508 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
509 result = _invoke(
510 ["bundle", "verify", str(out), "--format", "json"], env=_env(tmp_path)
511 )
512 # --format is no longer a registered flag, so argparse returns exit 2.
513 assert result.exit_code == 2
514
515
516 def test_list_heads_rejects_format_flag(tmp_path: pathlib.Path) -> None:
517 _init_repo(tmp_path)
518 _make_commit(tmp_path, content=b"lh-old")
519 out = tmp_path / "lh-old.bundle"
520 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
521 result = _invoke(
522 ["bundle", "list-heads", str(out), "--format", "json"], env=_env(tmp_path)
523 )
524 assert result.exit_code == 2
525
526
527 # ---------------------------------------------------------------------------
528 # Integration: --have pruning
529 # ---------------------------------------------------------------------------
530
531
532 def test_create_have_prunes_bundle(tmp_path: pathlib.Path) -> None:
533 """Passing --have should produce a smaller bundle than the full chain."""
534 _init_repo(tmp_path)
535 c1 = _make_commit(tmp_path, content=b"have-base")
536 _make_commit(tmp_path, parent_id=c1, content=b"have-tip")
537
538 out_full = tmp_path / "full.bundle"
539 out_pruned = tmp_path / "pruned.bundle"
540
541 _invoke(["bundle", "create", str(out_full)], env=_env(tmp_path))
542 _invoke(
543 ["bundle", "create", str(out_pruned), "--have", c1],
544 env=_env(tmp_path),
545 )
546
547 # Pruned bundle must be smaller (fewer commits packed).
548 assert out_pruned.stat().st_size < out_full.stat().st_size
549
550
551 def test_create_have_json_smaller_commits(tmp_path: pathlib.Path) -> None:
552 _init_repo(tmp_path)
553 c1 = _make_commit(tmp_path, content=b"hjp-base")
554 _make_commit(tmp_path, parent_id=c1, content=b"hjp-tip")
555
556 out_full = tmp_path / "hjp-full.bundle"
557 out_pruned = tmp_path / "hjp-pruned.bundle"
558
559 r_full = _invoke(
560 ["bundle", "create", str(out_full), "--json"], env=_env(tmp_path)
561 )
562 r_pruned = _invoke(
563 ["bundle", "create", str(out_pruned), "--have", c1, "--json"],
564 env=_env(tmp_path),
565 )
566
567 full_data = _parse_create(r_full)
568 pruned_data = _parse_create(r_pruned)
569 assert pruned_data["commits"] < full_data["commits"]
570
571
572 # ---------------------------------------------------------------------------
573 # Integration: multi-branch bundle
574 # ---------------------------------------------------------------------------
575
576
577 def test_create_multiple_branches(tmp_path: pathlib.Path) -> None:
578 _init_repo(tmp_path)
579 _make_commit(tmp_path, content=b"mb-main", branch="main")
580 _make_commit(tmp_path, content=b"mb-feat", branch="feat/x")
581
582 out = tmp_path / "mb.bundle"
583 result = _invoke(
584 ["bundle", "create", str(out), "--json"], env=_env(tmp_path)
585 )
586 assert result.exit_code == 0
587 data = _parse_create(result)
588 assert "main" in data["branches"] or "feat/x" in data["branches"]
589
590
591 def test_round_trip_with_json_summary(tmp_path: pathlib.Path) -> None:
592 """Full create → verify → unbundle pipeline with JSON at each step."""
593 src = tmp_path / "src"
594 dst = tmp_path / "dst"
595 src.mkdir()
596 dst.mkdir()
597 _init_repo(src)
598 _init_repo(dst, repo_id="dst-rt-json")
599
600 prev: str | None = None
601 for i in range(5):
602 prev = _make_commit(src, parent_id=prev, content=f"rt-{i}".encode())
603
604 out = tmp_path / "rt-json.bundle"
605
606 create_result = _invoke(
607 ["bundle", "create", str(out), "--json"], env=_env(src)
608 )
609 assert create_result.exit_code == 0
610 create_data = _parse_create(create_result)
611 assert create_data["commits"] == 5
612
613 verify_result = _invoke(
614 ["bundle", "verify", str(out), "--json"], env=_env(src)
615 )
616 assert verify_result.exit_code == 0
617 verify_data = _parse_verify(verify_result)
618 assert verify_data["all_ok"] is True
619
620 unbundle_result = _invoke(
621 ["bundle", "unbundle", str(out), "--json"], env=_env(dst)
622 )
623 assert unbundle_result.exit_code == 0
624 unbundle_data = _parse_unbundle(unbundle_result)
625 assert unbundle_data["commits_written"] == 5
626
627
628 # ---------------------------------------------------------------------------
629 # Integration: verify detects missing snapshot objects
630 # ---------------------------------------------------------------------------
631
632
633 def test_verify_missing_snapshot_object(tmp_path: pathlib.Path) -> None:
634 """Removing an object from the bundle should cause snapshot verification to fail."""
635 _init_repo(tmp_path)
636 _make_commit(tmp_path, content=b"snap-miss")
637 out = tmp_path / "snap-miss.bundle"
638 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
639
640 raw = msgpack.unpackb(out.read_bytes(), raw=False)
641 # Remove all objects so snapshots cannot find theirs.
642 raw["objects"] = []
643 out.write_bytes(msgpack.packb(raw, use_bin_type=True))
644
645 result = _invoke(["bundle", "verify", str(out), "--json"], env=_env(tmp_path))
646 data = _parse_verify(result)
647 assert data["all_ok"] is False
648 # Some failure should mention missing objects.
649 assert any("missing" in f for f in data["failures"])
650
651
652 # ---------------------------------------------------------------------------
653 # E2E: help output for all subcommands
654 # ---------------------------------------------------------------------------
655
656
657 def test_create_help_mentions_json() -> None:
658 result = _invoke(["bundle", "create", "--help"])
659 assert result.exit_code == 0
660 assert "--json" in result.output
661
662
663 def test_unbundle_help_mentions_json() -> None:
664 result = _invoke(["bundle", "unbundle", "--help"])
665 assert result.exit_code == 0
666 assert "--json" in result.output
667
668
669 def test_verify_help_mentions_json() -> None:
670 result = _invoke(["bundle", "verify", "--help"])
671 assert result.exit_code == 0
672 assert "--json" in result.output
673 assert "--format" not in result.output
674
675
676 def test_list_heads_help_mentions_json() -> None:
677 result = _invoke(["bundle", "list-heads", "--help"])
678 assert result.exit_code == 0
679 assert "--json" in result.output
680 assert "--format" not in result.output
681
682
683 def test_bundle_help_top_level() -> None:
684 result = _invoke(["bundle", "--help"])
685 assert result.exit_code == 0
686 assert "create" in result.output
687 assert "unbundle" in result.output
688 assert "verify" in result.output
689 assert "list-heads" in result.output
690
691
692 # ---------------------------------------------------------------------------
693 # Stress: 200-commit bundle
694 # ---------------------------------------------------------------------------
695
696
697 def test_stress_200_commit_chain(tmp_path: pathlib.Path) -> None:
698 _init_repo(tmp_path)
699 prev: str | None = None
700 for i in range(200):
701 prev = _make_commit(tmp_path, parent_id=prev, content=f"stress-{i}".encode())
702
703 out = tmp_path / "stress200.bundle"
704 create_result = _invoke(
705 ["bundle", "create", str(out), "--json"], env=_env(tmp_path)
706 )
707 assert create_result.exit_code == 0
708 data = _parse_create(create_result)
709 assert data["commits"] == 200
710
711 verify_result = _invoke(["bundle", "verify", str(out), "-q"], env=_env(tmp_path))
712 assert verify_result.exit_code == 0
713
714
715 # ---------------------------------------------------------------------------
716 # Stress: concurrent list-heads reads
717 # ---------------------------------------------------------------------------
718
719
720 def test_stress_concurrent_list_heads(tmp_path: pathlib.Path) -> None:
721 _init_repo(tmp_path)
722 _make_commit(tmp_path, content=b"concurrent-bundle")
723 out = tmp_path / "concurrent.bundle"
724 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
725
726 errors: list[str] = []
727
728 def _read() -> None:
729 r = _invoke(["bundle", "list-heads", str(out), "--json"], env=_env(tmp_path))
730 if r.exit_code != 0:
731 errors.append(f"exit {r.exit_code}: {r.output}")
732 else:
733 try:
734 data = json.loads(r.output)
735 if not isinstance(data, dict):
736 errors.append("not a dict")
737 except json.JSONDecodeError as exc:
738 errors.append(str(exc))
739
740 threads = [threading.Thread(target=_read) for _ in range(8)]
741 for t in threads:
742 t.start()
743 for t in threads:
744 t.join()
745
746 assert not errors, f"Concurrent list-heads failures: {errors}"
747
748
749 # ===========================================================================
750 # TestBundleCreateExtended — 18 tests
751 # ===========================================================================
752
753
754 class TestBundleCreateExtended:
755 def test_exits_0_basic(self, tmp_path: pathlib.Path) -> None:
756 """Single commit → create exits 0."""
757 _init_repo(tmp_path)
758 _make_commit(tmp_path, content=b"ext-basic")
759 out = tmp_path / "basic.bundle"
760 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
761 assert result.exit_code == 0
762
763 def test_creates_file_on_disk(self, tmp_path: pathlib.Path) -> None:
764 """Output file must exist after a successful create."""
765 _init_repo(tmp_path)
766 _make_commit(tmp_path, content=b"ext-file")
767 out = tmp_path / "check.bundle"
768 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
769 assert out.exists()
770
771 def test_text_output_mentions_commits(self, tmp_path: pathlib.Path) -> None:
772 _init_repo(tmp_path)
773 _make_commit(tmp_path, content=b"ext-txt-c")
774 out = tmp_path / "tc.bundle"
775 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
776 assert "commits" in result.output
777
778 def test_text_output_mentions_kib(self, tmp_path: pathlib.Path) -> None:
779 _init_repo(tmp_path)
780 _make_commit(tmp_path, content=b"ext-txt-kib")
781 out = tmp_path / "kib.bundle"
782 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
783 assert "KiB" in result.output
784
785 def test_text_output_contains_bundle_path(self, tmp_path: pathlib.Path) -> None:
786 _init_repo(tmp_path)
787 _make_commit(tmp_path, content=b"ext-txt-path")
788 out = tmp_path / "pathcheck.bundle"
789 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
790 assert "pathcheck.bundle" in result.output
791
792 def test_empty_repo_exits_1(self, tmp_path: pathlib.Path) -> None:
793 """Repo with no commits → exit 1 (no commits to bundle)."""
794 _init_repo(tmp_path)
795 out = tmp_path / "empty.bundle"
796 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
797 assert result.exit_code == 1
798
799 def test_bad_ref_exits_1(self, tmp_path: pathlib.Path) -> None:
800 """Unknown ref → exit 1."""
801 _init_repo(tmp_path)
802 _make_commit(tmp_path, content=b"ext-bad-ref")
803 out = tmp_path / "bad-ref.bundle"
804 result = _invoke(
805 ["bundle", "create", str(out), "nonexistent-branch"],
806 env=_env(tmp_path),
807 )
808 assert result.exit_code == 1
809
810 def test_json_branches_sorted(self, tmp_path: pathlib.Path) -> None:
811 """Branches list in JSON output must be sorted."""
812 _init_repo(tmp_path)
813 # Create a commit on main, then make z-branch and a-branch point to
814 # the same commit so they are all reachable when bundling HEAD.
815 c1 = _make_commit(tmp_path, content=b"ext-br-base", branch="main")
816 for br in ("z-branch", "a-branch"):
817 ref_file = tmp_path / ".muse" / "refs" / "heads" / br
818 ref_file.write_text(c1, encoding="utf-8")
819 out = tmp_path / "sorted.bundle"
820 result = _invoke(
821 ["bundle", "create", str(out), "--json"], env=_env(tmp_path)
822 )
823 assert result.exit_code == 0
824 data = _parse_create(result)
825 assert data["branches"] == sorted(data["branches"])
826
827 def test_json_size_matches_file(self, tmp_path: pathlib.Path) -> None:
828 """size_bytes in JSON must equal the actual file size on disk."""
829 _init_repo(tmp_path)
830 _make_commit(tmp_path, content=b"ext-size")
831 out = tmp_path / "size.bundle"
832 result = _invoke(
833 ["bundle", "create", str(out), "--json"], env=_env(tmp_path)
834 )
835 assert result.exit_code == 0
836 data = _parse_create(result)
837 assert data["size_bytes"] == out.stat().st_size
838
839 def test_j_alias(self, tmp_path: pathlib.Path) -> None:
840 """-j must produce identical JSON to --json."""
841 _init_repo(tmp_path)
842 _make_commit(tmp_path, content=b"ext-j-alias")
843 out1 = tmp_path / "j1.bundle"
844 out2 = tmp_path / "j2.bundle"
845 r1 = _invoke(["bundle", "create", str(out1), "--json"], env=_env(tmp_path))
846 r2 = _invoke(["bundle", "create", str(out2), "-j"], env=_env(tmp_path))
847 assert r1.exit_code == 0
848 assert r2.exit_code == 0
849 d1 = json.loads(r1.output)
850 d2 = json.loads(r2.output)
851 # Both should have the same structural keys and counts.
852 assert d1["commits"] == d2["commits"]
853 assert d1["objects"] == d2["objects"]
854 assert d1["branches"] == d2["branches"]
855
856 def test_default_ref_is_head(self, tmp_path: pathlib.Path) -> None:
857 """When no refs are given, HEAD is used — bundle contains the HEAD commit."""
858 _init_repo(tmp_path)
859 _make_commit(tmp_path, content=b"ext-head")
860 out = tmp_path / "head.bundle"
861 result = _invoke(
862 ["bundle", "create", str(out), "--json"], env=_env(tmp_path)
863 )
864 assert result.exit_code == 0
865 data = _parse_create(result)
866 assert data["commits"] >= 1
867
868 def test_explicit_head_ref(self, tmp_path: pathlib.Path) -> None:
869 """Passing 'HEAD' explicitly is equivalent to the default."""
870 _init_repo(tmp_path)
871 _make_commit(tmp_path, content=b"ext-head-explicit")
872 out_default = tmp_path / "head-default.bundle"
873 out_explicit = tmp_path / "head-explicit.bundle"
874 _invoke(["bundle", "create", str(out_default)], env=_env(tmp_path))
875 result = _invoke(
876 ["bundle", "create", str(out_explicit), "HEAD", "--json"],
877 env=_env(tmp_path),
878 )
879 assert result.exit_code == 0
880 data = _parse_create(result)
881 assert data["commits"] >= 1
882 # Both bundles should contain the same number of commits.
883 raw_default = __import__("msgpack").unpackb(
884 out_default.read_bytes(), raw=False
885 )
886 assert len(raw_default.get("commits", [])) == data["commits"]
887
888 def test_explicit_commit_id(self, tmp_path: pathlib.Path) -> None:
889 """A raw commit ID passed as ref is resolved correctly."""
890 _init_repo(tmp_path)
891 cid = _make_commit(tmp_path, content=b"ext-cid")
892 out = tmp_path / "cid.bundle"
893 result = _invoke(
894 ["bundle", "create", str(out), cid, "--json"],
895 env=_env(tmp_path),
896 )
897 assert result.exit_code == 0
898 data = _parse_create(result)
899 assert data["commits"] >= 1
900
901 def test_output_is_valid_msgpack(self, tmp_path: pathlib.Path) -> None:
902 """The output file must be valid msgpack."""
903 import msgpack as _mp
904
905 _init_repo(tmp_path)
906 _make_commit(tmp_path, content=b"ext-msgpack")
907 out = tmp_path / "mp.bundle"
908 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
909 raw = _mp.unpackb(out.read_bytes(), raw=False)
910 assert isinstance(raw, dict)
911 assert "commits" in raw
912
913 def test_help_mentions_agent_quickstart(self) -> None:
914 result = _invoke(["bundle", "create", "--help"])
915 assert result.exit_code == 0
916 assert "Agent quickstart" in result.output
917
918 def test_help_mentions_exit_codes(self) -> None:
919 result = _invoke(["bundle", "create", "--help"])
920 assert result.exit_code == 0
921 assert "Exit codes" in result.output
922
923 def test_help_mentions_json_schema(self) -> None:
924 result = _invoke(["bundle", "create", "--help"])
925 assert result.exit_code == 0
926 assert "JSON output schema" in result.output
927
928 def test_multiple_have_ids_reduce_bundle(self, tmp_path: pathlib.Path) -> None:
929 """Multiple --have IDs each reduce what is bundled."""
930 _init_repo(tmp_path)
931 c1 = _make_commit(tmp_path, content=b"ext-have-1")
932 c2 = _make_commit(tmp_path, parent_id=c1, content=b"ext-have-2")
933 _make_commit(tmp_path, parent_id=c2, content=b"ext-have-3")
934
935 out_full = tmp_path / "have-full.bundle"
936 out_pruned = tmp_path / "have-pruned.bundle"
937 r_full = _invoke(
938 ["bundle", "create", str(out_full), "--json"], env=_env(tmp_path)
939 )
940 r_pruned = _invoke(
941 ["bundle", "create", str(out_pruned), "--have", c1, c2, "--json"],
942 env=_env(tmp_path),
943 )
944 assert r_full.exit_code == 0
945 assert r_pruned.exit_code == 0
946 full_data = _parse_create(r_full)
947 pruned_data = _parse_create(r_pruned)
948 assert pruned_data["commits"] < full_data["commits"]
949
950
951 # ===========================================================================
952 # TestBundleCreateSecurity — 6 tests
953 # ===========================================================================
954
955
956 class TestBundleCreateSecurity:
957 def test_ansi_in_file_path_stripped_text_output(
958 self, tmp_path: pathlib.Path
959 ) -> None:
960 """ANSI escape in the output file path must be stripped in text output."""
961 _init_repo(tmp_path)
962 _make_commit(tmp_path, content=b"sec-ansi-path")
963 # Build an output path whose filename component contains an ANSI escape.
964 out = tmp_path / "\x1b[31mevil\x1b[0m.bundle"
965 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
966 assert result.exit_code == 0
967 assert "\x1b" not in result.output
968
969 def test_control_char_in_file_path_stripped(
970 self, tmp_path: pathlib.Path
971 ) -> None:
972 """Control characters in the output file path must not reach stdout."""
973 _init_repo(tmp_path)
974 _make_commit(tmp_path, content=b"sec-ctrl-path")
975 out = tmp_path / "foo\x07bar.bundle"
976 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
977 assert result.exit_code == 0
978 assert "\x07" not in result.output
979
980 def test_outside_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
981 """Without a .muse directory, create must exit 2 (REPO_NOT_FOUND)."""
982 out = tmp_path / "norepo.bundle"
983 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
984 assert result.exit_code == 2
985
986 def test_bad_ref_ansi_stripped_from_error(
987 self, tmp_path: pathlib.Path
988 ) -> None:
989 """ANSI in an unknown ref name must not appear in the error output."""
990 _init_repo(tmp_path)
991 _make_commit(tmp_path, content=b"sec-ref-ansi")
992 out = tmp_path / "ref-ansi.bundle"
993 evil_ref = "\x1b[31mbadref\x1b[0m"
994 result = _invoke(
995 ["bundle", "create", str(out), evil_ref], env=_env(tmp_path)
996 )
997 assert result.exit_code == 1
998 assert "\x1b" not in result.output
999
1000 def test_ansi_in_have_no_injection(self, tmp_path: pathlib.Path) -> None:
1001 """ANSI characters in a --have value must not appear in any output."""
1002 _init_repo(tmp_path)
1003 _make_commit(tmp_path, content=b"sec-have-ansi")
1004 out = tmp_path / "have-ansi.bundle"
1005 evil_have = "\x1b[31m" + "a" * 64 + "\x1b[0m"
1006 result = _invoke(
1007 ["bundle", "create", str(out), "--have", evil_have],
1008 env=_env(tmp_path),
1009 )
1010 # The have ID won't match anything — bundle succeeds with full history.
1011 assert "\x1b" not in result.output
1012
1013 def test_no_json_on_error(self, tmp_path: pathlib.Path) -> None:
1014 """On error (no commits), stdout must not contain JSON."""
1015 _init_repo(tmp_path)
1016 out = tmp_path / "err-json.bundle"
1017 result = _invoke(
1018 ["bundle", "create", str(out), "--json"], env=_env(tmp_path)
1019 )
1020 assert result.exit_code != 0
1021 assert not result.output.strip().startswith("{")
1022
1023
1024 # ===========================================================================
1025 # TestBundleCreateStress — 3 tests
1026 # ===========================================================================
1027
1028
1029 class TestBundleCreateStress:
1030 def test_50_commit_chain(self, tmp_path: pathlib.Path) -> None:
1031 """50-commit linear chain is bundled correctly."""
1032 _init_repo(tmp_path)
1033 prev: str | None = None
1034 for i in range(50):
1035 prev = _make_commit(
1036 tmp_path, parent_id=prev, content=f"stress50-{i}".encode()
1037 )
1038 out = tmp_path / "stress50.bundle"
1039 result = _invoke(
1040 ["bundle", "create", str(out), "--json"], env=_env(tmp_path)
1041 )
1042 assert result.exit_code == 0
1043 data = _parse_create(result)
1044 assert data["commits"] == 50
1045 assert data["size_bytes"] > 0
1046
1047 def test_create_with_large_have_list(self, tmp_path: pathlib.Path) -> None:
1048 """Passing 15 --have IDs on a 20-commit chain produces a smaller bundle."""
1049 _init_repo(tmp_path)
1050 ids: list[str] = []
1051 prev: str | None = None
1052 for i in range(20):
1053 prev = _make_commit(
1054 tmp_path, parent_id=prev, content=f"have-list-{i}".encode()
1055 )
1056 ids.append(prev)
1057
1058 out_full = tmp_path / "have-full-20.bundle"
1059 out_pruned = tmp_path / "have-pruned-20.bundle"
1060 r_full = _invoke(
1061 ["bundle", "create", str(out_full), "--json"], env=_env(tmp_path)
1062 )
1063 # Pass the first 15 as --have to exclude them.
1064 have_args = ["--have"] + ids[:15]
1065 r_pruned = _invoke(
1066 ["bundle", "create", str(out_pruned)] + have_args + ["--json"],
1067 env=_env(tmp_path),
1068 )
1069 assert r_full.exit_code == 0
1070 assert r_pruned.exit_code == 0
1071 full_data = _parse_create(r_full)
1072 pruned_data = _parse_create(r_pruned)
1073 assert pruned_data["commits"] < full_data["commits"]
1074
1075 def test_many_branches(self, tmp_path: pathlib.Path) -> None:
1076 """10 branches pointing to reachable commits all appear in the bundle."""
1077 _init_repo(tmp_path)
1078 # Build a 10-commit chain on main, then create a feature branch ref
1079 # pointing to each commit — all are reachable from HEAD.
1080 prev: str | None = None
1081 commit_ids: list[str] = []
1082 for i in range(10):
1083 prev = _make_commit(
1084 tmp_path, parent_id=prev, content=f"stress-br-{i}".encode()
1085 )
1086 commit_ids.append(prev)
1087 branch_names = [f"feat/stress-br-{i}" for i in range(10)]
1088 for br, cid in zip(branch_names, commit_ids):
1089 ref_file = tmp_path / ".muse" / "refs" / "heads" / br
1090 ref_file.parent.mkdir(parents=True, exist_ok=True)
1091 ref_file.write_text(cid, encoding="utf-8")
1092 out = tmp_path / "many-branches.bundle"
1093 result = _invoke(
1094 ["bundle", "create", str(out), "--json"], env=_env(tmp_path)
1095 )
1096 assert result.exit_code == 0
1097 data = _parse_create(result)
1098 for br in branch_names:
1099 assert br in data["branches"]
1100
1101
1102 # ===========================================================================
1103 # TestBundleUnbundleExtended — 18 tests
1104 # ===========================================================================
1105
1106
1107 def _make_bundle(src: pathlib.Path, dst_file: pathlib.Path) -> None:
1108 """Helper: create a bundle from src repo into dst_file."""
1109 _invoke(["bundle", "create", str(dst_file)], env=_env(src))
1110
1111
1112 class TestBundleUnbundleExtended:
1113 def _src_dst(self, tmp_path: pathlib.Path) -> tuple[pathlib.Path, pathlib.Path]:
1114 src = tmp_path / "src"
1115 dst = tmp_path / "dst"
1116 src.mkdir()
1117 dst.mkdir()
1118 _init_repo(src)
1119 _init_repo(dst, repo_id="ub-dst")
1120 return src, dst
1121
1122 def test_exits_0_basic(self, tmp_path: pathlib.Path) -> None:
1123 src, dst = self._src_dst(tmp_path)
1124 _make_commit(src, content=b"ub-basic")
1125 bundle = tmp_path / "basic.bundle"
1126 _make_bundle(src, bundle)
1127 result = _invoke(["bundle", "unbundle", str(bundle)], env=_env(dst))
1128 assert result.exit_code == 0
1129
1130 def test_commits_written_count(self, tmp_path: pathlib.Path) -> None:
1131 src, dst = self._src_dst(tmp_path)
1132 prev: str | None = None
1133 for i in range(3):
1134 prev = _make_commit(src, parent_id=prev, content=f"ub-cnt-{i}".encode())
1135 bundle = tmp_path / "cnt.bundle"
1136 _make_bundle(src, bundle)
1137 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1138 assert result.exit_code == 0
1139 data = _parse_unbundle(result)
1140 assert data["commits_written"] == 3
1141
1142 def test_snapshots_written_count(self, tmp_path: pathlib.Path) -> None:
1143 src, dst = self._src_dst(tmp_path)
1144 _make_commit(src, content=b"ub-snap")
1145 bundle = tmp_path / "snap.bundle"
1146 _make_bundle(src, bundle)
1147 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1148 assert result.exit_code == 0
1149 data = _parse_unbundle(result)
1150 assert data["snapshots_written"] >= 1
1151
1152 def test_objects_written_count(self, tmp_path: pathlib.Path) -> None:
1153 src, dst = self._src_dst(tmp_path)
1154 _make_commit(src, content=b"ub-obj")
1155 bundle = tmp_path / "obj.bundle"
1156 _make_bundle(src, bundle)
1157 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1158 assert result.exit_code == 0
1159 data = _parse_unbundle(result)
1160 assert data["objects_written"] >= 1
1161
1162 def test_objects_skipped_idempotent(self, tmp_path: pathlib.Path) -> None:
1163 """Unbundling twice: second pass skips all already-present objects."""
1164 src, dst = self._src_dst(tmp_path)
1165 _make_commit(src, content=b"ub-idem")
1166 bundle = tmp_path / "idem.bundle"
1167 _make_bundle(src, bundle)
1168 _invoke(["bundle", "unbundle", str(bundle)], env=_env(dst))
1169 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1170 assert result.exit_code == 0
1171 data = _parse_unbundle(result)
1172 assert data["commits_written"] == 0
1173 assert data["objects_written"] == 0
1174 assert data["objects_skipped"] >= 1
1175
1176 def test_text_output_mentions_commits(self, tmp_path: pathlib.Path) -> None:
1177 src, dst = self._src_dst(tmp_path)
1178 _make_commit(src, content=b"ub-txt-c")
1179 bundle = tmp_path / "txt-c.bundle"
1180 _make_bundle(src, bundle)
1181 result = _invoke(["bundle", "unbundle", str(bundle)], env=_env(dst))
1182 assert result.exit_code == 0
1183 assert "commit(s)" in result.output
1184
1185 def test_text_output_mentions_applied(self, tmp_path: pathlib.Path) -> None:
1186 src, dst = self._src_dst(tmp_path)
1187 _make_commit(src, content=b"ub-txt-a")
1188 bundle = tmp_path / "txt-a.bundle"
1189 _make_bundle(src, bundle)
1190 result = _invoke(["bundle", "unbundle", str(bundle)], env=_env(dst))
1191 assert result.exit_code == 0
1192 assert "Bundle applied" in result.output
1193
1194 def test_refs_updated_by_default(self, tmp_path: pathlib.Path) -> None:
1195 """By default, branch refs in the destination are updated."""
1196 src, dst = self._src_dst(tmp_path)
1197 _make_commit(src, content=b"ub-ref-up")
1198 bundle = tmp_path / "ref-up.bundle"
1199 _make_bundle(src, bundle)
1200 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1201 assert result.exit_code == 0
1202 data = _parse_unbundle(result)
1203 assert "main" in data["refs_updated"]
1204
1205 def test_no_update_refs_skips_refs(self, tmp_path: pathlib.Path) -> None:
1206 src, dst = self._src_dst(tmp_path)
1207 _make_commit(src, content=b"ub-no-ref")
1208 bundle = tmp_path / "no-ref.bundle"
1209 _make_bundle(src, bundle)
1210 result = _invoke(
1211 ["bundle", "unbundle", str(bundle), "--no-update-refs", "--json"],
1212 env=_env(dst),
1213 )
1214 assert result.exit_code == 0
1215 data = _parse_unbundle(result)
1216 assert data["refs_updated"] == []
1217
1218 def test_refs_updated_branch_file_exists(self, tmp_path: pathlib.Path) -> None:
1219 """After unbundle, the branch ref file must exist in the destination."""
1220 src, dst = self._src_dst(tmp_path)
1221 _make_commit(src, content=b"ub-ref-file")
1222 bundle = tmp_path / "ref-file.bundle"
1223 _make_bundle(src, bundle)
1224 _invoke(["bundle", "unbundle", str(bundle)], env=_env(dst))
1225 ref_file = dst / ".muse" / "refs" / "heads" / "main"
1226 assert ref_file.exists()
1227 cid = ref_file.read_text(encoding="utf-8").strip()
1228 # Ref files store canonical "sha256:<64hex>" format (71 chars).
1229 assert cid.startswith("sha256:")
1230 assert len(cid) == 71
1231
1232 def test_j_alias(self, tmp_path: pathlib.Path) -> None:
1233 """-j must produce identical JSON to --json."""
1234 src1, dst1 = self._src_dst(tmp_path)
1235 src2 = tmp_path / "src2"
1236 dst2 = tmp_path / "dst2"
1237 src2.mkdir()
1238 dst2.mkdir()
1239 _init_repo(src2)
1240 _init_repo(dst2, repo_id="ub-j2")
1241
1242 _make_commit(src1, content=b"ub-j-a1")
1243 _make_commit(src2, content=b"ub-j-a2")
1244 b1 = tmp_path / "j1.bundle"
1245 b2 = tmp_path / "j2.bundle"
1246 _make_bundle(src1, b1)
1247 _make_bundle(src2, b2)
1248
1249 r1 = _invoke(["bundle", "unbundle", str(b1), "--json"], env=_env(dst1))
1250 r2 = _invoke(["bundle", "unbundle", str(b2), "-j"], env=_env(dst2))
1251 assert r1.exit_code == 0
1252 assert r2.exit_code == 0
1253 d1 = _parse_unbundle(r1)
1254 d2 = _parse_unbundle(r2)
1255 assert set(d1.keys()) == set(d2.keys())
1256 assert d1["commits_written"] == d2["commits_written"]
1257
1258 def test_json_refs_updated_sorted(self, tmp_path: pathlib.Path) -> None:
1259 """refs_updated in JSON output must be sorted."""
1260 src, dst = self._src_dst(tmp_path)
1261 c1 = _make_commit(src, content=b"ub-sort-base")
1262 # Add extra branch refs pointing at c1 so the bundle has multiple heads.
1263 for br in ("z-br", "a-br"):
1264 ref = src / ".muse" / "refs" / "heads" / br
1265 ref.write_text(c1, encoding="utf-8")
1266 bundle = tmp_path / "sort.bundle"
1267 _make_bundle(src, bundle)
1268 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1269 assert result.exit_code == 0
1270 data = _parse_unbundle(result)
1271 assert data["refs_updated"] == sorted(data["refs_updated"])
1272
1273 def test_empty_bundle_no_crash(self, tmp_path: pathlib.Path) -> None:
1274 """An empty dict bundle (no commits/objects) must exit 0 cleanly."""
1275 _init_repo(tmp_path)
1276 empty_bundle = tmp_path / "empty.bundle"
1277 empty_bundle.write_bytes(msgpack.packb({}, use_bin_type=True))
1278 result = _invoke(["bundle", "unbundle", str(empty_bundle)], env=_env(tmp_path))
1279 assert result.exit_code == 0
1280
1281 def test_bundle_without_branch_heads_no_refs(self, tmp_path: pathlib.Path) -> None:
1282 """A bundle missing the branch_heads key → refs_updated must be empty."""
1283 src, dst = self._src_dst(tmp_path)
1284 _make_commit(src, content=b"ub-no-heads")
1285 bundle = tmp_path / "no-heads.bundle"
1286 _make_bundle(src, bundle)
1287 # Strip branch_heads from the bundle.
1288 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1289 raw.pop("branch_heads", None)
1290 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1291 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1292 assert result.exit_code == 0
1293 data = _parse_unbundle(result)
1294 assert data["refs_updated"] == []
1295
1296 def test_help_mentions_agent_quickstart(self) -> None:
1297 result = _invoke(["bundle", "unbundle", "--help"])
1298 assert result.exit_code == 0
1299 assert "Agent quickstart" in result.output
1300
1301 def test_help_mentions_exit_codes(self) -> None:
1302 result = _invoke(["bundle", "unbundle", "--help"])
1303 assert result.exit_code == 0
1304 assert "Exit codes" in result.output
1305
1306 def test_help_mentions_json_schema(self) -> None:
1307 result = _invoke(["bundle", "unbundle", "--help"])
1308 assert result.exit_code == 0
1309 assert "JSON output schema" in result.output
1310
1311 def test_no_update_refs_flag_in_help(self) -> None:
1312 result = _invoke(["bundle", "unbundle", "--help"])
1313 assert result.exit_code == 0
1314 assert "--no-update-refs" in result.output
1315
1316
1317 # ===========================================================================
1318 # TestBundleUnbundleSecurity — 6 tests
1319 # ===========================================================================
1320
1321
1322 class TestBundleUnbundleSecurity:
1323 def test_outside_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
1324 """Without a .muse directory, unbundle must exit 2 (REPO_NOT_FOUND)."""
1325 bundle = tmp_path / "norepo.bundle"
1326 bundle.write_bytes(msgpack.packb({}, use_bin_type=True))
1327 result = _invoke(["bundle", "unbundle", str(bundle)], env=_env(tmp_path))
1328 assert result.exit_code == 2
1329
1330 def test_missing_bundle_file_exits_1(self, tmp_path: pathlib.Path) -> None:
1331 _init_repo(tmp_path)
1332 result = _invoke(
1333 ["bundle", "unbundle", str(tmp_path / "missing.bundle")],
1334 env=_env(tmp_path),
1335 )
1336 assert result.exit_code == 1
1337
1338 def test_invalid_msgpack_exits_1(self, tmp_path: pathlib.Path) -> None:
1339 _init_repo(tmp_path)
1340 corrupt = tmp_path / "corrupt.bundle"
1341 corrupt.write_bytes(b"\xff\xfe not msgpack at all")
1342 result = _invoke(["bundle", "unbundle", str(corrupt)], env=_env(tmp_path))
1343 assert result.exit_code == 1
1344
1345 def test_ansi_branch_name_skipped_no_injection(
1346 self, tmp_path: pathlib.Path
1347 ) -> None:
1348 """ANSI escape in a bundle branch name is skipped; no escape in output."""
1349 src = tmp_path / "src"
1350 dst = tmp_path / "dst"
1351 src.mkdir()
1352 dst.mkdir()
1353 _init_repo(src)
1354 _init_repo(dst, repo_id="sec-ansi-br")
1355 _make_commit(src, content=b"sec-ansi-br")
1356 bundle = tmp_path / "ansi-br.bundle"
1357 _make_bundle(src, bundle)
1358 # Inject an ANSI-poisoned branch name into branch_heads.
1359 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1360 raw["branch_heads"] = {"\x1b[31mevil\x1b[0m": "a" * 64}
1361 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1362 result = _invoke(["bundle", "unbundle", str(bundle)], env=_env(dst))
1363 assert result.exit_code == 0
1364 assert "\x1b" not in result.output
1365
1366 def test_invalid_commit_id_branch_ref_skipped(
1367 self, tmp_path: pathlib.Path
1368 ) -> None:
1369 """A commit ID shorter than 64 chars in branch_heads must be skipped."""
1370 src = tmp_path / "src"
1371 dst = tmp_path / "dst"
1372 src.mkdir()
1373 dst.mkdir()
1374 _init_repo(src)
1375 _init_repo(dst, repo_id="sec-short-cid")
1376 _make_commit(src, content=b"sec-short-cid")
1377 bundle = tmp_path / "short-cid.bundle"
1378 _make_bundle(src, bundle)
1379 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1380 # Replace the commit IDs with a too-short value.
1381 raw["branch_heads"] = {"main": "tooshort"}
1382 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1383 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1384 assert result.exit_code == 0
1385 data = _parse_unbundle(result)
1386 assert "main" not in data["refs_updated"]
1387
1388 def test_no_json_on_missing_file(self, tmp_path: pathlib.Path) -> None:
1389 """Error path (file not found) must not emit JSON to stdout."""
1390 _init_repo(tmp_path)
1391 result = _invoke(
1392 ["bundle", "unbundle", str(tmp_path / "ghost.bundle"), "--json"],
1393 env=_env(tmp_path),
1394 )
1395 assert result.exit_code != 0
1396 assert not result.output.strip().startswith("{")
1397
1398
1399 # ===========================================================================
1400 # TestBundleUnbundleStress — 3 tests
1401 # ===========================================================================
1402
1403
1404 class TestBundleUnbundleStress:
1405 def test_50_commit_chain(self, tmp_path: pathlib.Path) -> None:
1406 """50-commit chain is fully unpacked into the destination."""
1407 src = tmp_path / "src"
1408 dst = tmp_path / "dst"
1409 src.mkdir()
1410 dst.mkdir()
1411 _init_repo(src)
1412 _init_repo(dst, repo_id="stress-ub-dst")
1413 prev: str | None = None
1414 for i in range(50):
1415 prev = _make_commit(src, parent_id=prev, content=f"ub50-{i}".encode())
1416 bundle = tmp_path / "ub50.bundle"
1417 _make_bundle(src, bundle)
1418 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1419 assert result.exit_code == 0
1420 data = _parse_unbundle(result)
1421 assert data["commits_written"] == 50
1422 assert data["objects_written"] >= 50
1423
1424 def test_idempotent_multiple_applications(self, tmp_path: pathlib.Path) -> None:
1425 """Applying the same bundle 5 times: only the first writes anything."""
1426 src = tmp_path / "src"
1427 dst = tmp_path / "dst"
1428 src.mkdir()
1429 dst.mkdir()
1430 _init_repo(src)
1431 _init_repo(dst, repo_id="stress-idem-dst")
1432 prev: str | None = None
1433 for i in range(5):
1434 prev = _make_commit(src, parent_id=prev, content=f"idem-{i}".encode())
1435 bundle = tmp_path / "idem5.bundle"
1436 _make_bundle(src, bundle)
1437 first = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1438 assert first.exit_code == 0
1439 first_data = _parse_unbundle(first)
1440 assert first_data["commits_written"] == 5
1441 for _ in range(4):
1442 repeat = _invoke(
1443 ["bundle", "unbundle", str(bundle), "--json"], env=_env(dst)
1444 )
1445 assert repeat.exit_code == 0
1446 repeat_data = _parse_unbundle(repeat)
1447 assert repeat_data["commits_written"] == 0
1448 assert repeat_data["objects_written"] == 0
1449
1450 def test_many_branch_refs_updated(self, tmp_path: pathlib.Path) -> None:
1451 """10 branch heads in the bundle → all 10 appear in refs_updated."""
1452 src = tmp_path / "src"
1453 dst = tmp_path / "dst"
1454 src.mkdir()
1455 dst.mkdir()
1456 _init_repo(src)
1457 _init_repo(dst, repo_id="stress-refs-dst")
1458 # Build a 10-commit chain on main.
1459 prev: str | None = None
1460 cids: list[str] = []
1461 for i in range(10):
1462 prev = _make_commit(src, parent_id=prev, content=f"br-ref-{i}".encode())
1463 cids.append(prev)
1464 # Create 10 feature branch refs pointing to reachable commits.
1465 br_names = [f"feat/br-{i}" for i in range(10)]
1466 for br, cid in zip(br_names, cids):
1467 ref = src / ".muse" / "refs" / "heads" / br
1468 ref.parent.mkdir(parents=True, exist_ok=True)
1469 ref.write_text(cid, encoding="utf-8")
1470 bundle = tmp_path / "many-refs.bundle"
1471 _make_bundle(src, bundle)
1472 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1473 assert result.exit_code == 0
1474 data = _parse_unbundle(result)
1475 for br in br_names:
1476 assert br in data["refs_updated"]
1477
1478
1479 # ===========================================================================
1480 # TestBundleVerifyExtended — 18 tests
1481 # ===========================================================================
1482
1483
1484 class TestBundleVerifyExtended:
1485 def _clean_bundle(self, tmp_path: pathlib.Path) -> pathlib.Path:
1486 """Create a repo with one commit and return a clean bundle path."""
1487 _init_repo(tmp_path)
1488 _make_commit(tmp_path, content=b"vext-clean")
1489 out = tmp_path / "clean.bundle"
1490 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
1491 return out
1492
1493 def _corrupt_bundle(self, tmp_path: pathlib.Path) -> pathlib.Path:
1494 """Create a bundle then tamper one object's content."""
1495 _init_repo(tmp_path)
1496 _make_commit(tmp_path, content=b"vext-corrupt")
1497 out = tmp_path / "corrupt.bundle"
1498 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
1499 raw = msgpack.unpackb(out.read_bytes(), raw=False)
1500 if raw.get("objects"):
1501 raw["objects"][0]["content"] = b"TAMPERED"
1502 out.write_bytes(msgpack.packb(raw, use_bin_type=True))
1503 return out
1504
1505 def test_exits_0_on_clean_bundle(self, tmp_path: pathlib.Path) -> None:
1506 bundle = self._clean_bundle(tmp_path)
1507 result = _invoke(["bundle", "verify", str(bundle)], env=_env(tmp_path))
1508 assert result.exit_code == 0
1509
1510 def test_exits_1_on_corrupt_object(self, tmp_path: pathlib.Path) -> None:
1511 bundle = self._corrupt_bundle(tmp_path)
1512 result = _invoke(["bundle", "verify", str(bundle)], env=_env(tmp_path))
1513 assert result.exit_code == 1
1514
1515 def test_all_ok_true_on_clean(self, tmp_path: pathlib.Path) -> None:
1516 bundle = self._clean_bundle(tmp_path)
1517 result = _invoke(["bundle", "verify", str(bundle), "--json"], env=_env(tmp_path))
1518 assert result.exit_code == 0
1519 data = _parse_verify(result)
1520 assert data["all_ok"] is True
1521
1522 def test_all_ok_false_on_corrupt(self, tmp_path: pathlib.Path) -> None:
1523 bundle = self._corrupt_bundle(tmp_path)
1524 result = _invoke(["bundle", "verify", str(bundle), "--json"], env=_env(tmp_path))
1525 assert result.exit_code == 1
1526 data = _parse_verify(result)
1527 assert data["all_ok"] is False
1528
1529 def test_objects_checked_count(self, tmp_path: pathlib.Path) -> None:
1530 """objects_checked must equal the number of objects in the bundle."""
1531 _init_repo(tmp_path)
1532 _make_commit(tmp_path, content=b"vext-cnt")
1533 out = tmp_path / "cnt.bundle"
1534 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
1535 raw = msgpack.unpackb(out.read_bytes(), raw=False)
1536 n_objects = len(raw.get("objects", []))
1537 result = _invoke(["bundle", "verify", str(out), "--json"], env=_env(tmp_path))
1538 assert result.exit_code == 0
1539 data = _parse_verify(result)
1540 assert data["objects_checked"] == n_objects
1541
1542 def test_snapshots_checked_count(self, tmp_path: pathlib.Path) -> None:
1543 bundle = self._clean_bundle(tmp_path)
1544 result = _invoke(["bundle", "verify", str(bundle), "--json"], env=_env(tmp_path))
1545 assert result.exit_code == 0
1546 data = _parse_verify(result)
1547 assert data["snapshots_checked"] >= 1
1548
1549 def test_failures_empty_on_clean(self, tmp_path: pathlib.Path) -> None:
1550 bundle = self._clean_bundle(tmp_path)
1551 result = _invoke(["bundle", "verify", str(bundle), "--json"], env=_env(tmp_path))
1552 data = _parse_verify(result)
1553 assert data["failures"] == []
1554
1555 def test_failures_nonempty_on_corrupt(self, tmp_path: pathlib.Path) -> None:
1556 bundle = self._corrupt_bundle(tmp_path)
1557 result = _invoke(["bundle", "verify", str(bundle), "--json"], env=_env(tmp_path))
1558 data = _parse_verify(result)
1559 assert len(data["failures"]) >= 1
1560
1561 def test_quiet_clean_exits_0_no_output(self, tmp_path: pathlib.Path) -> None:
1562 bundle = self._clean_bundle(tmp_path)
1563 result = _invoke(["bundle", "verify", str(bundle), "--quiet"], env=_env(tmp_path))
1564 assert result.exit_code == 0
1565 assert result.output.strip() == ""
1566
1567 def test_quiet_corrupt_exits_1_no_output(self, tmp_path: pathlib.Path) -> None:
1568 bundle = self._corrupt_bundle(tmp_path)
1569 result = _invoke(["bundle", "verify", str(bundle), "-q"], env=_env(tmp_path))
1570 assert result.exit_code == 1
1571 assert result.output.strip() == ""
1572
1573 def test_json_output_is_single_line(self, tmp_path: pathlib.Path) -> None:
1574 """JSON output must be compact (no indent=2), matching all other commands."""
1575 bundle = self._clean_bundle(tmp_path)
1576 result = _invoke(["bundle", "verify", str(bundle), "--json"], env=_env(tmp_path))
1577 assert result.exit_code == 0
1578 # Compact JSON has no interior newlines.
1579 assert "\n" not in result.output.strip()
1580
1581 def test_j_alias(self, tmp_path: pathlib.Path) -> None:
1582 bundle = self._clean_bundle(tmp_path)
1583 r1 = _invoke(["bundle", "verify", str(bundle), "--json"], env=_env(tmp_path))
1584 r2 = _invoke(["bundle", "verify", str(bundle), "-j"], env=_env(tmp_path))
1585 assert r1.exit_code == 0
1586 assert r2.exit_code == 0
1587 assert json.loads(r1.output) == json.loads(r2.output)
1588
1589 def test_text_output_mentions_objects_checked(self, tmp_path: pathlib.Path) -> None:
1590 bundle = self._clean_bundle(tmp_path)
1591 result = _invoke(["bundle", "verify", str(bundle)], env=_env(tmp_path))
1592 assert "Objects checked" in result.output
1593
1594 def test_text_output_mentions_snapshots_checked(self, tmp_path: pathlib.Path) -> None:
1595 bundle = self._clean_bundle(tmp_path)
1596 result = _invoke(["bundle", "verify", str(bundle)], env=_env(tmp_path))
1597 assert "Snapshots checked" in result.output
1598
1599 def test_text_output_clean_checkmark(self, tmp_path: pathlib.Path) -> None:
1600 bundle = self._clean_bundle(tmp_path)
1601 result = _invoke(["bundle", "verify", str(bundle)], env=_env(tmp_path))
1602 assert "Bundle is clean" in result.output
1603
1604 def test_text_output_failures_listed(self, tmp_path: pathlib.Path) -> None:
1605 bundle = self._corrupt_bundle(tmp_path)
1606 result = _invoke(["bundle", "verify", str(bundle)], env=_env(tmp_path))
1607 assert result.exit_code == 1
1608 assert "hash mismatch" in result.output
1609
1610 def test_help_mentions_agent_quickstart(self) -> None:
1611 result = _invoke(["bundle", "verify", "--help"])
1612 assert result.exit_code == 0
1613 assert "Agent quickstart" in result.output
1614
1615 def test_help_mentions_exit_codes(self) -> None:
1616 result = _invoke(["bundle", "verify", "--help"])
1617 assert result.exit_code == 0
1618 assert "Exit codes" in result.output
1619
1620
1621 # ===========================================================================
1622 # TestBundleVerifySecurity — 6 tests
1623 # ===========================================================================
1624
1625
1626 class TestBundleVerifySecurity:
1627 def _bundle_with_ansi_object_id(self, tmp_path: pathlib.Path) -> pathlib.Path:
1628 """Bundle where an object_id contains an ANSI escape sequence."""
1629 _init_repo(tmp_path)
1630 _make_commit(tmp_path, content=b"sec-ansi-oid")
1631 out = tmp_path / "ansi-oid.bundle"
1632 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
1633 raw = msgpack.unpackb(out.read_bytes(), raw=False)
1634 if raw.get("objects"):
1635 # Inject ANSI into the object_id — will trigger hash mismatch failure.
1636 raw["objects"][0]["object_id"] = "\x1b[31mevil_oid_xxx\x1b[0m"
1637 out.write_bytes(msgpack.packb(raw, use_bin_type=True))
1638 return out
1639
1640 def _bundle_with_ansi_rel_path(self, tmp_path: pathlib.Path) -> pathlib.Path:
1641 """Bundle where a snapshot manifest key contains an ANSI escape."""
1642 _init_repo(tmp_path)
1643 _make_commit(tmp_path, content=b"sec-ansi-path")
1644 out = tmp_path / "ansi-path.bundle"
1645 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
1646 raw = msgpack.unpackb(out.read_bytes(), raw=False)
1647 if raw.get("snapshots"):
1648 snap = raw["snapshots"][0]
1649 # Replace manifest keys with ANSI-poisoned path.
1650 old_manifest = snap.get("manifest", {})
1651 snap["manifest"] = {
1652 "\x1b[31mevil/path\x1b[0m": v for v in old_manifest.values()
1653 }
1654 out.write_bytes(msgpack.packb(raw, use_bin_type=True))
1655 return out
1656
1657 def test_ansi_in_object_id_failure_stripped_text(
1658 self, tmp_path: pathlib.Path
1659 ) -> None:
1660 """ANSI in object_id within a failure message must be stripped in text output."""
1661 bundle = self._bundle_with_ansi_object_id(tmp_path)
1662 result = _invoke(["bundle", "verify", str(bundle)], env=_env(tmp_path))
1663 assert "\x1b" not in result.output
1664
1665 def test_ansi_in_rel_path_failure_stripped_text(
1666 self, tmp_path: pathlib.Path
1667 ) -> None:
1668 """ANSI in a manifest rel_path within a failure must be stripped in text output."""
1669 bundle = self._bundle_with_ansi_rel_path(tmp_path)
1670 result = _invoke(["bundle", "verify", str(bundle)], env=_env(tmp_path))
1671 assert "\x1b" not in result.output
1672
1673 def test_ansi_in_failures_sanitized_json(self, tmp_path: pathlib.Path) -> None:
1674 """failures list in JSON output must not contain raw ANSI escapes."""
1675 bundle = self._bundle_with_ansi_object_id(tmp_path)
1676 result = _invoke(["bundle", "verify", str(bundle), "--json"], env=_env(tmp_path))
1677 assert "\x1b" not in result.output
1678
1679 def test_no_repo_required(self, tmp_path: pathlib.Path) -> None:
1680 """verify must work outside any .muse repository (no require_repo call)."""
1681 work = tmp_path / "no_repo"
1682 work.mkdir()
1683 _init_repo(tmp_path)
1684 _make_commit(tmp_path, content=b"sec-no-repo")
1685 bundle = tmp_path / "no-repo.bundle"
1686 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1687 # Run verify from a directory with no .muse — must NOT exit 2.
1688 result = _invoke(["bundle", "verify", str(bundle)], env={"MUSE_REPO_ROOT": str(work)})
1689 assert result.exit_code != 2
1690
1691 def test_missing_file_exits_1(self, tmp_path: pathlib.Path) -> None:
1692 _init_repo(tmp_path)
1693 result = _invoke(
1694 ["bundle", "verify", str(tmp_path / "ghost.bundle")],
1695 env=_env(tmp_path),
1696 )
1697 assert result.exit_code == 1
1698
1699 def test_invalid_msgpack_exits_1(self, tmp_path: pathlib.Path) -> None:
1700 _init_repo(tmp_path)
1701 corrupt = tmp_path / "bad.bundle"
1702 corrupt.write_bytes(b"\xff\xfe not msgpack")
1703 result = _invoke(["bundle", "verify", str(corrupt)], env=_env(tmp_path))
1704 assert result.exit_code == 1
1705
1706
1707 # ===========================================================================
1708 # TestBundleVerifyStress — 3 tests
1709 # ===========================================================================
1710
1711
1712 class TestBundleVerifyStress:
1713 def test_200_commit_bundle_verify_clean(self, tmp_path: pathlib.Path) -> None:
1714 """200-commit bundle verifies clean with correct counts."""
1715 _init_repo(tmp_path)
1716 prev: str | None = None
1717 for i in range(200):
1718 prev = _make_commit(tmp_path, parent_id=prev, content=f"vstress-{i}".encode())
1719 out = tmp_path / "vstress200.bundle"
1720 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
1721 result = _invoke(["bundle", "verify", str(out), "--json"], env=_env(tmp_path))
1722 assert result.exit_code == 0
1723 data = _parse_verify(result)
1724 assert data["all_ok"] is True
1725 assert data["objects_checked"] >= 200
1726 assert data["snapshots_checked"] >= 200
1727
1728 def test_multiple_corrupt_objects_all_detected(
1729 self, tmp_path: pathlib.Path
1730 ) -> None:
1731 """Multiple corrupted objects must each produce a failure entry."""
1732 _init_repo(tmp_path)
1733 prev: str | None = None
1734 for i in range(5):
1735 prev = _make_commit(tmp_path, parent_id=prev, content=f"multi-corrupt-{i}".encode())
1736 out = tmp_path / "multi-corrupt.bundle"
1737 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
1738 raw = msgpack.unpackb(out.read_bytes(), raw=False)
1739 # Corrupt every object.
1740 for obj in raw.get("objects", []):
1741 obj["content"] = b"TAMPERED"
1742 out.write_bytes(msgpack.packb(raw, use_bin_type=True))
1743 result = _invoke(["bundle", "verify", str(out), "--json"], env=_env(tmp_path))
1744 assert result.exit_code == 1
1745 data = _parse_verify(result)
1746 assert data["all_ok"] is False
1747 assert len(data["failures"]) >= 5
1748
1749 def test_empty_bundle_verifies_clean(self, tmp_path: pathlib.Path) -> None:
1750 """An empty dict bundle has nothing to check and must exit 0."""
1751 _init_repo(tmp_path)
1752 empty = tmp_path / "empty.bundle"
1753 empty.write_bytes(msgpack.packb({}, use_bin_type=True))
1754 result = _invoke(["bundle", "verify", str(empty), "--json"], env=_env(tmp_path))
1755 assert result.exit_code == 0
1756 data = _parse_verify(result)
1757 assert data["all_ok"] is True
1758 assert data["objects_checked"] == 0
1759 assert data["failures"] == []
1760
1761
1762 # ===========================================================================
1763 # TestBundleListHeadsExtended — 18 tests
1764 # ===========================================================================
1765
1766
1767 class TestBundleListHeadsExtended:
1768 def _bundle_with_head(self, tmp_path: pathlib.Path, branch: str = "main") -> pathlib.Path:
1769 _init_repo(tmp_path)
1770 _make_commit(tmp_path, content=b"lhe-base", branch=branch)
1771 out = tmp_path / "lhe.bundle"
1772 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
1773 return out
1774
1775 def test_exits_0_with_heads(self, tmp_path: pathlib.Path) -> None:
1776 bundle = self._bundle_with_head(tmp_path)
1777 result = _invoke(["bundle", "list-heads", str(bundle)], env=_env(tmp_path))
1778 assert result.exit_code == 0
1779
1780 def test_exits_0_no_heads(self, tmp_path: pathlib.Path) -> None:
1781 """A bundle with no branch_heads key must still exit 0."""
1782 _init_repo(tmp_path)
1783 _make_commit(tmp_path, content=b"lhe-noheads")
1784 bundle = tmp_path / "noheads.bundle"
1785 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1786 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1787 raw.pop("branch_heads", None)
1788 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1789 result = _invoke(["bundle", "list-heads", str(bundle)], env=_env(tmp_path))
1790 assert result.exit_code == 0
1791
1792 def test_text_shows_branch_and_cid(self, tmp_path: pathlib.Path) -> None:
1793 bundle = self._bundle_with_head(tmp_path)
1794 result = _invoke(["bundle", "list-heads", str(bundle)], env=_env(tmp_path))
1795 assert result.exit_code == 0
1796 assert "main" in result.output
1797
1798 def test_text_no_heads_message(self, tmp_path: pathlib.Path) -> None:
1799 _init_repo(tmp_path)
1800 _make_commit(tmp_path, content=b"lhe-nomsg")
1801 bundle = tmp_path / "nomsg.bundle"
1802 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1803 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1804 raw.pop("branch_heads", None)
1805 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1806 result = _invoke(["bundle", "list-heads", str(bundle)], env=_env(tmp_path))
1807 assert "No branch heads" in result.output
1808
1809 def test_json_returns_dict(self, tmp_path: pathlib.Path) -> None:
1810 bundle = self._bundle_with_head(tmp_path)
1811 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1812 assert result.exit_code == 0
1813 data = json.loads(result.output)
1814 assert isinstance(data, dict)
1815
1816 def test_json_contains_main(self, tmp_path: pathlib.Path) -> None:
1817 bundle = self._bundle_with_head(tmp_path)
1818 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1819 data = json.loads(result.output)
1820 assert "main" in data
1821
1822 def test_json_commit_id_has_sha256_prefix(self, tmp_path: pathlib.Path) -> None:
1823 bundle = self._bundle_with_head(tmp_path)
1824 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1825 data = json.loads(result.output)
1826 for cid in data.values():
1827 assert cid.startswith("sha256:")
1828 assert len(cid) == len("sha256:") + 64
1829
1830 def test_json_is_single_line(self, tmp_path: pathlib.Path) -> None:
1831 """JSON output must be compact (no indent=2)."""
1832 bundle = self._bundle_with_head(tmp_path)
1833 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1834 assert "\n" not in result.output.strip()
1835
1836 def test_j_alias(self, tmp_path: pathlib.Path) -> None:
1837 bundle = self._bundle_with_head(tmp_path)
1838 r1 = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1839 r2 = _invoke(["bundle", "list-heads", str(bundle), "-j"], env=_env(tmp_path))
1840 assert r1.exit_code == 0 and r2.exit_code == 0
1841 assert json.loads(r1.output) == json.loads(r2.output)
1842
1843 def test_json_empty_on_no_heads(self, tmp_path: pathlib.Path) -> None:
1844 _init_repo(tmp_path)
1845 _make_commit(tmp_path, content=b"lhe-empty-json")
1846 bundle = tmp_path / "ej.bundle"
1847 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1848 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1849 raw.pop("branch_heads", None)
1850 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1851 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1852 assert result.exit_code == 0
1853 assert json.loads(result.output) == {}
1854
1855 def test_multiple_branches_all_listed_text(self, tmp_path: pathlib.Path) -> None:
1856 _init_repo(tmp_path)
1857 c1 = _make_commit(tmp_path, content=b"lhe-multi-base")
1858 for br in ("feat/a", "feat/b", "feat/c"):
1859 ref = tmp_path / ".muse" / "refs" / "heads" / br
1860 ref.parent.mkdir(parents=True, exist_ok=True)
1861 ref.write_text(c1, encoding="utf-8")
1862 bundle = tmp_path / "multi.bundle"
1863 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1864 result = _invoke(["bundle", "list-heads", str(bundle)], env=_env(tmp_path))
1865 assert result.exit_code == 0
1866 for br in ("feat/a", "feat/b", "feat/c"):
1867 assert br in result.output
1868
1869 def test_multiple_branches_all_in_json(self, tmp_path: pathlib.Path) -> None:
1870 _init_repo(tmp_path)
1871 c1 = _make_commit(tmp_path, content=b"lhe-multi-json")
1872 for br in ("feat/x", "feat/y"):
1873 ref = tmp_path / ".muse" / "refs" / "heads" / br
1874 ref.parent.mkdir(parents=True, exist_ok=True)
1875 ref.write_text(c1, encoding="utf-8")
1876 bundle = tmp_path / "multij.bundle"
1877 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1878 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1879 data = json.loads(result.output)
1880 assert "feat/x" in data
1881 assert "feat/y" in data
1882
1883 def test_text_cid_shows_sha256_prefix_plus_12(self, tmp_path: pathlib.Path) -> None:
1884 """Text output shows sha256: prefix + 12 hex chars abbreviated commit ID."""
1885 bundle = self._bundle_with_head(tmp_path)
1886 result = _invoke(["bundle", "list-heads", str(bundle)], env=_env(tmp_path))
1887 # Each non-empty line should start with sha256:<12hex>.
1888 for line in result.output.strip().splitlines():
1889 parts = line.split()
1890 assert parts[0].startswith("sha256:")
1891 assert len(parts[0]) == len("sha256:") + 12
1892
1893 def test_no_repo_required(self, tmp_path: pathlib.Path) -> None:
1894 """list-heads must work outside any .muse repository."""
1895 work = tmp_path / "no_repo_dir"
1896 work.mkdir()
1897 _init_repo(tmp_path)
1898 _make_commit(tmp_path, content=b"lhe-no-repo")
1899 bundle = tmp_path / "norepo.bundle"
1900 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1901 result = _invoke(["bundle", "list-heads", str(bundle)], env={"MUSE_REPO_ROOT": str(work)})
1902 assert result.exit_code != 2
1903
1904 def test_help_mentions_agent_quickstart(self) -> None:
1905 result = _invoke(["bundle", "list-heads", "--help"])
1906 assert result.exit_code == 0
1907 assert "Agent quickstart" in result.output
1908
1909 def test_help_mentions_exit_codes(self) -> None:
1910 result = _invoke(["bundle", "list-heads", "--help"])
1911 assert result.exit_code == 0
1912 assert "Exit codes" in result.output
1913
1914 def test_help_mentions_json_schema(self) -> None:
1915 result = _invoke(["bundle", "list-heads", "--help"])
1916 assert result.exit_code == 0
1917 assert "JSON output schema" in result.output
1918
1919 def test_missing_file_exits_1(self, tmp_path: pathlib.Path) -> None:
1920 _init_repo(tmp_path)
1921 result = _invoke(
1922 ["bundle", "list-heads", str(tmp_path / "ghost.bundle")],
1923 env=_env(tmp_path),
1924 )
1925 assert result.exit_code == 1
1926
1927
1928 # ===========================================================================
1929 # TestBundleListHeadsSecurity — 6 tests
1930 # ===========================================================================
1931
1932
1933 class TestBundleListHeadsSecurity:
1934 def test_ansi_branch_name_stripped_text(self, tmp_path: pathlib.Path) -> None:
1935 """ANSI escape in branch name must not appear in text output."""
1936 _init_repo(tmp_path)
1937 _make_commit(tmp_path, content=b"sec-lh-ansi")
1938 bundle = tmp_path / "ansi-br.bundle"
1939 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1940 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1941 raw["branch_heads"] = {"\x1b[31mevil\x1b[0m": "a" * 64}
1942 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1943 result = _invoke(["bundle", "list-heads", str(bundle)], env=_env(tmp_path))
1944 assert result.exit_code == 0
1945 assert "\x1b" not in result.output
1946
1947 def test_ansi_commit_id_stripped_text(self, tmp_path: pathlib.Path) -> None:
1948 """ANSI escape in a commit ID must not appear in text output (cid[:12])."""
1949 _init_repo(tmp_path)
1950 _make_commit(tmp_path, content=b"sec-lh-cid")
1951 bundle = tmp_path / "ansi-cid.bundle"
1952 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1953 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1954 raw["branch_heads"] = {"main": "\x1b[31m" + "a" * 64 + "\x1b[0m"}
1955 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1956 result = _invoke(["bundle", "list-heads", str(bundle)], env=_env(tmp_path))
1957 assert result.exit_code == 0
1958 assert "\x1b" not in result.output
1959
1960 def test_ansi_branch_name_stripped_json(self, tmp_path: pathlib.Path) -> None:
1961 """ANSI escape in branch name must not appear in JSON output."""
1962 _init_repo(tmp_path)
1963 _make_commit(tmp_path, content=b"sec-lh-ansi-json")
1964 bundle = tmp_path / "ansi-br-json.bundle"
1965 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1966 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1967 raw["branch_heads"] = {"\x1b[31mevil\x1b[0m": "b" * 64}
1968 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1969 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1970 assert result.exit_code == 0
1971 assert "\x1b" not in result.output
1972
1973 def test_ansi_commit_id_stripped_json(self, tmp_path: pathlib.Path) -> None:
1974 """ANSI escape in commit ID value must not appear in JSON output."""
1975 _init_repo(tmp_path)
1976 _make_commit(tmp_path, content=b"sec-lh-cid-json")
1977 bundle = tmp_path / "ansi-cid-json.bundle"
1978 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1979 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1980 raw["branch_heads"] = {"main": "\x1b[32m" + "c" * 64 + "\x1b[0m"}
1981 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1982 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1983 assert result.exit_code == 0
1984 assert "\x1b" not in result.output
1985
1986 def test_invalid_msgpack_exits_1(self, tmp_path: pathlib.Path) -> None:
1987 _init_repo(tmp_path)
1988 bad = tmp_path / "bad.bundle"
1989 bad.write_bytes(b"\xff\xfe garbage")
1990 result = _invoke(["bundle", "list-heads", str(bad)], env=_env(tmp_path))
1991 assert result.exit_code == 1
1992
1993 def test_no_json_on_missing_file(self, tmp_path: pathlib.Path) -> None:
1994 """Missing file error must not emit JSON to stdout."""
1995 _init_repo(tmp_path)
1996 result = _invoke(
1997 ["bundle", "list-heads", str(tmp_path / "missing.bundle"), "--json"],
1998 env=_env(tmp_path),
1999 )
2000 assert result.exit_code != 0
2001 assert not result.output.strip().startswith("{")
2002
2003
2004 # ===========================================================================
2005 # TestBundleListHeadsStress — 3 tests
2006 # ===========================================================================
2007
2008
2009 class TestBundleListHeadsStress:
2010 def test_50_branches_all_listed(self, tmp_path: pathlib.Path) -> None:
2011 """50 branch heads are all present in the JSON output."""
2012 _init_repo(tmp_path)
2013 c1 = _make_commit(tmp_path, content=b"lhstress-base")
2014 branch_names = [f"feat/stress-{i}" for i in range(50)]
2015 for br in branch_names:
2016 ref = tmp_path / ".muse" / "refs" / "heads" / br
2017 ref.parent.mkdir(parents=True, exist_ok=True)
2018 ref.write_text(c1, encoding="utf-8")
2019 bundle = tmp_path / "stress50.bundle"
2020 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
2021 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
2022 assert result.exit_code == 0
2023 data = json.loads(result.output)
2024 for br in branch_names:
2025 assert br in data
2026
2027 def test_concurrent_reads_consistent(self, tmp_path: pathlib.Path) -> None:
2028 """Concurrent list-heads reads on the same bundle must all succeed."""
2029 _init_repo(tmp_path)
2030 _make_commit(tmp_path, content=b"lhstress-concurrent")
2031 bundle = tmp_path / "concurrent.bundle"
2032 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
2033 errors: list[str] = []
2034
2035 def _read() -> None:
2036 r = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
2037 if r.exit_code != 0:
2038 errors.append(f"exit {r.exit_code}")
2039 else:
2040 try:
2041 if not isinstance(json.loads(r.output), dict):
2042 errors.append("not a dict")
2043 except json.JSONDecodeError as exc:
2044 errors.append(str(exc))
2045
2046 threads = [threading.Thread(target=_read) for _ in range(10)]
2047 for t in threads:
2048 t.start()
2049 for t in threads:
2050 t.join()
2051 assert not errors, f"Concurrent failures: {errors}"
2052
2053 def test_large_bundle_list_heads_fast(self, tmp_path: pathlib.Path) -> None:
2054 """list-heads on a 200-commit bundle returns quickly (I/O, not compute)."""
2055 import time
2056 _init_repo(tmp_path)
2057 prev: str | None = None
2058 for i in range(200):
2059 prev = _make_commit(tmp_path, parent_id=prev, content=f"lhfast-{i}".encode())
2060 bundle = tmp_path / "fast200.bundle"
2061 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
2062 t0 = time.monotonic()
2063 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
2064 elapsed = time.monotonic() - t0
2065 assert result.exit_code == 0
2066 assert elapsed < 5.0, f"list-heads took {elapsed:.2f}s on 200-commit bundle"
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 144 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 147 days ago