gabriel / muse public
test_cmd_bundle_hardening.py python
2,063 lines 83.0 KB
Raw
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 49 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
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 = _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 len(cid) == 64
495
496
497 # ---------------------------------------------------------------------------
498 # Flags: --json rejects old --format arg
499 # ---------------------------------------------------------------------------
500
501
502 def test_verify_rejects_format_flag(tmp_path: pathlib.Path) -> None:
503 """The old ``--format json`` pattern must not be accepted."""
504 _init_repo(tmp_path)
505 _make_commit(tmp_path, content=b"old-fmt")
506 out = tmp_path / "old.bundle"
507 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
508 result = _invoke(
509 ["bundle", "verify", str(out), "--format", "json"], env=_env(tmp_path)
510 )
511 # --format is no longer a registered flag, so argparse returns exit 2.
512 assert result.exit_code == 2
513
514
515 def test_list_heads_rejects_format_flag(tmp_path: pathlib.Path) -> None:
516 _init_repo(tmp_path)
517 _make_commit(tmp_path, content=b"lh-old")
518 out = tmp_path / "lh-old.bundle"
519 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
520 result = _invoke(
521 ["bundle", "list-heads", str(out), "--format", "json"], env=_env(tmp_path)
522 )
523 assert result.exit_code == 2
524
525
526 # ---------------------------------------------------------------------------
527 # Integration: --have pruning
528 # ---------------------------------------------------------------------------
529
530
531 def test_create_have_prunes_bundle(tmp_path: pathlib.Path) -> None:
532 """Passing --have should produce a smaller bundle than the full chain."""
533 _init_repo(tmp_path)
534 c1 = _make_commit(tmp_path, content=b"have-base")
535 _make_commit(tmp_path, parent_id=c1, content=b"have-tip")
536
537 out_full = tmp_path / "full.bundle"
538 out_pruned = tmp_path / "pruned.bundle"
539
540 _invoke(["bundle", "create", str(out_full)], env=_env(tmp_path))
541 _invoke(
542 ["bundle", "create", str(out_pruned), "--have", c1],
543 env=_env(tmp_path),
544 )
545
546 # Pruned bundle must be smaller (fewer commits packed).
547 assert out_pruned.stat().st_size < out_full.stat().st_size
548
549
550 def test_create_have_json_smaller_commits(tmp_path: pathlib.Path) -> None:
551 _init_repo(tmp_path)
552 c1 = _make_commit(tmp_path, content=b"hjp-base")
553 _make_commit(tmp_path, parent_id=c1, content=b"hjp-tip")
554
555 out_full = tmp_path / "hjp-full.bundle"
556 out_pruned = tmp_path / "hjp-pruned.bundle"
557
558 r_full = _invoke(
559 ["bundle", "create", str(out_full), "--json"], env=_env(tmp_path)
560 )
561 r_pruned = _invoke(
562 ["bundle", "create", str(out_pruned), "--have", c1, "--json"],
563 env=_env(tmp_path),
564 )
565
566 full_data = _parse_create(r_full)
567 pruned_data = _parse_create(r_pruned)
568 assert pruned_data["commits"] < full_data["commits"]
569
570
571 # ---------------------------------------------------------------------------
572 # Integration: multi-branch bundle
573 # ---------------------------------------------------------------------------
574
575
576 def test_create_multiple_branches(tmp_path: pathlib.Path) -> None:
577 _init_repo(tmp_path)
578 _make_commit(tmp_path, content=b"mb-main", branch="main")
579 _make_commit(tmp_path, content=b"mb-feat", branch="feat/x")
580
581 out = tmp_path / "mb.bundle"
582 result = _invoke(
583 ["bundle", "create", str(out), "--json"], env=_env(tmp_path)
584 )
585 assert result.exit_code == 0
586 data = _parse_create(result)
587 assert "main" in data["branches"] or "feat/x" in data["branches"]
588
589
590 def test_round_trip_with_json_summary(tmp_path: pathlib.Path) -> None:
591 """Full create → verify → unbundle pipeline with JSON at each step."""
592 src = tmp_path / "src"
593 dst = tmp_path / "dst"
594 src.mkdir()
595 dst.mkdir()
596 _init_repo(src)
597 _init_repo(dst, repo_id="dst-rt-json")
598
599 prev: str | None = None
600 for i in range(5):
601 prev = _make_commit(src, parent_id=prev, content=f"rt-{i}".encode())
602
603 out = tmp_path / "rt-json.bundle"
604
605 create_result = _invoke(
606 ["bundle", "create", str(out), "--json"], env=_env(src)
607 )
608 assert create_result.exit_code == 0
609 create_data = _parse_create(create_result)
610 assert create_data["commits"] == 5
611
612 verify_result = _invoke(
613 ["bundle", "verify", str(out), "--json"], env=_env(src)
614 )
615 assert verify_result.exit_code == 0
616 verify_data = _parse_verify(verify_result)
617 assert verify_data["all_ok"] is True
618
619 unbundle_result = _invoke(
620 ["bundle", "unbundle", str(out), "--json"], env=_env(dst)
621 )
622 assert unbundle_result.exit_code == 0
623 unbundle_data = _parse_unbundle(unbundle_result)
624 assert unbundle_data["commits_written"] == 5
625
626
627 # ---------------------------------------------------------------------------
628 # Integration: verify detects missing snapshot objects
629 # ---------------------------------------------------------------------------
630
631
632 def test_verify_missing_snapshot_object(tmp_path: pathlib.Path) -> None:
633 """Removing an object from the bundle should cause snapshot verification to fail."""
634 _init_repo(tmp_path)
635 _make_commit(tmp_path, content=b"snap-miss")
636 out = tmp_path / "snap-miss.bundle"
637 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
638
639 raw = msgpack.unpackb(out.read_bytes(), raw=False)
640 # Remove all objects so snapshots cannot find theirs.
641 raw["objects"] = []
642 out.write_bytes(msgpack.packb(raw, use_bin_type=True))
643
644 result = _invoke(["bundle", "verify", str(out), "--json"], env=_env(tmp_path))
645 data = _parse_verify(result)
646 assert data["all_ok"] is False
647 # Some failure should mention missing objects.
648 assert any("missing" in f for f in data["failures"])
649
650
651 # ---------------------------------------------------------------------------
652 # E2E: help output for all subcommands
653 # ---------------------------------------------------------------------------
654
655
656 def test_create_help_mentions_json() -> None:
657 result = _invoke(["bundle", "create", "--help"])
658 assert result.exit_code == 0
659 assert "--json" in result.output
660
661
662 def test_unbundle_help_mentions_json() -> None:
663 result = _invoke(["bundle", "unbundle", "--help"])
664 assert result.exit_code == 0
665 assert "--json" in result.output
666
667
668 def test_verify_help_mentions_json() -> None:
669 result = _invoke(["bundle", "verify", "--help"])
670 assert result.exit_code == 0
671 assert "--json" in result.output
672 assert "--format" not in result.output
673
674
675 def test_list_heads_help_mentions_json() -> None:
676 result = _invoke(["bundle", "list-heads", "--help"])
677 assert result.exit_code == 0
678 assert "--json" in result.output
679 assert "--format" not in result.output
680
681
682 def test_bundle_help_top_level() -> None:
683 result = _invoke(["bundle", "--help"])
684 assert result.exit_code == 0
685 assert "create" in result.output
686 assert "unbundle" in result.output
687 assert "verify" in result.output
688 assert "list-heads" in result.output
689
690
691 # ---------------------------------------------------------------------------
692 # Stress: 200-commit bundle
693 # ---------------------------------------------------------------------------
694
695
696 def test_stress_200_commit_chain(tmp_path: pathlib.Path) -> None:
697 _init_repo(tmp_path)
698 prev: str | None = None
699 for i in range(200):
700 prev = _make_commit(tmp_path, parent_id=prev, content=f"stress-{i}".encode())
701
702 out = tmp_path / "stress200.bundle"
703 create_result = _invoke(
704 ["bundle", "create", str(out), "--json"], env=_env(tmp_path)
705 )
706 assert create_result.exit_code == 0
707 data = _parse_create(create_result)
708 assert data["commits"] == 200
709
710 verify_result = _invoke(["bundle", "verify", str(out), "-q"], env=_env(tmp_path))
711 assert verify_result.exit_code == 0
712
713
714 # ---------------------------------------------------------------------------
715 # Stress: concurrent list-heads reads
716 # ---------------------------------------------------------------------------
717
718
719 def test_stress_concurrent_list_heads(tmp_path: pathlib.Path) -> None:
720 _init_repo(tmp_path)
721 _make_commit(tmp_path, content=b"concurrent-bundle")
722 out = tmp_path / "concurrent.bundle"
723 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
724
725 errors: list[str] = []
726
727 def _read() -> None:
728 r = _invoke(["bundle", "list-heads", str(out), "--json"], env=_env(tmp_path))
729 if r.exit_code != 0:
730 errors.append(f"exit {r.exit_code}: {r.output}")
731 else:
732 try:
733 data = json.loads(r.output)
734 if not isinstance(data, dict):
735 errors.append("not a dict")
736 except json.JSONDecodeError as exc:
737 errors.append(str(exc))
738
739 threads = [threading.Thread(target=_read) for _ in range(8)]
740 for t in threads:
741 t.start()
742 for t in threads:
743 t.join()
744
745 assert not errors, f"Concurrent list-heads failures: {errors}"
746
747
748 # ===========================================================================
749 # TestBundleCreateExtended — 18 tests
750 # ===========================================================================
751
752
753 class TestBundleCreateExtended:
754 def test_exits_0_basic(self, tmp_path: pathlib.Path) -> None:
755 """Single commit → create exits 0."""
756 _init_repo(tmp_path)
757 _make_commit(tmp_path, content=b"ext-basic")
758 out = tmp_path / "basic.bundle"
759 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
760 assert result.exit_code == 0
761
762 def test_creates_file_on_disk(self, tmp_path: pathlib.Path) -> None:
763 """Output file must exist after a successful create."""
764 _init_repo(tmp_path)
765 _make_commit(tmp_path, content=b"ext-file")
766 out = tmp_path / "check.bundle"
767 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
768 assert out.exists()
769
770 def test_text_output_mentions_commits(self, tmp_path: pathlib.Path) -> None:
771 _init_repo(tmp_path)
772 _make_commit(tmp_path, content=b"ext-txt-c")
773 out = tmp_path / "tc.bundle"
774 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
775 assert "commits" in result.output
776
777 def test_text_output_mentions_kib(self, tmp_path: pathlib.Path) -> None:
778 _init_repo(tmp_path)
779 _make_commit(tmp_path, content=b"ext-txt-kib")
780 out = tmp_path / "kib.bundle"
781 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
782 assert "KiB" in result.output
783
784 def test_text_output_contains_bundle_path(self, tmp_path: pathlib.Path) -> None:
785 _init_repo(tmp_path)
786 _make_commit(tmp_path, content=b"ext-txt-path")
787 out = tmp_path / "pathcheck.bundle"
788 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
789 assert "pathcheck.bundle" in result.output
790
791 def test_empty_repo_exits_1(self, tmp_path: pathlib.Path) -> None:
792 """Repo with no commits → exit 1 (no commits to bundle)."""
793 _init_repo(tmp_path)
794 out = tmp_path / "empty.bundle"
795 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
796 assert result.exit_code == 1
797
798 def test_bad_ref_exits_1(self, tmp_path: pathlib.Path) -> None:
799 """Unknown ref → exit 1."""
800 _init_repo(tmp_path)
801 _make_commit(tmp_path, content=b"ext-bad-ref")
802 out = tmp_path / "bad-ref.bundle"
803 result = _invoke(
804 ["bundle", "create", str(out), "nonexistent-branch"],
805 env=_env(tmp_path),
806 )
807 assert result.exit_code == 1
808
809 def test_json_branches_sorted(self, tmp_path: pathlib.Path) -> None:
810 """Branches list in JSON output must be sorted."""
811 _init_repo(tmp_path)
812 # Create a commit on main, then make z-branch and a-branch point to
813 # the same commit so they are all reachable when bundling HEAD.
814 c1 = _make_commit(tmp_path, content=b"ext-br-base", branch="main")
815 for br in ("z-branch", "a-branch"):
816 ref_file = tmp_path / ".muse" / "refs" / "heads" / br
817 ref_file.write_text(c1, encoding="utf-8")
818 out = tmp_path / "sorted.bundle"
819 result = _invoke(
820 ["bundle", "create", str(out), "--json"], env=_env(tmp_path)
821 )
822 assert result.exit_code == 0
823 data = _parse_create(result)
824 assert data["branches"] == sorted(data["branches"])
825
826 def test_json_size_matches_file(self, tmp_path: pathlib.Path) -> None:
827 """size_bytes in JSON must equal the actual file size on disk."""
828 _init_repo(tmp_path)
829 _make_commit(tmp_path, content=b"ext-size")
830 out = tmp_path / "size.bundle"
831 result = _invoke(
832 ["bundle", "create", str(out), "--json"], env=_env(tmp_path)
833 )
834 assert result.exit_code == 0
835 data = _parse_create(result)
836 assert data["size_bytes"] == out.stat().st_size
837
838 def test_j_alias(self, tmp_path: pathlib.Path) -> None:
839 """-j must produce identical JSON to --json."""
840 _init_repo(tmp_path)
841 _make_commit(tmp_path, content=b"ext-j-alias")
842 out1 = tmp_path / "j1.bundle"
843 out2 = tmp_path / "j2.bundle"
844 r1 = _invoke(["bundle", "create", str(out1), "--json"], env=_env(tmp_path))
845 r2 = _invoke(["bundle", "create", str(out2), "-j"], env=_env(tmp_path))
846 assert r1.exit_code == 0
847 assert r2.exit_code == 0
848 d1 = json.loads(r1.output)
849 d2 = json.loads(r2.output)
850 # Both should have the same structural keys and counts.
851 assert d1["commits"] == d2["commits"]
852 assert d1["objects"] == d2["objects"]
853 assert d1["branches"] == d2["branches"]
854
855 def test_default_ref_is_head(self, tmp_path: pathlib.Path) -> None:
856 """When no refs are given, HEAD is used — bundle contains the HEAD commit."""
857 _init_repo(tmp_path)
858 _make_commit(tmp_path, content=b"ext-head")
859 out = tmp_path / "head.bundle"
860 result = _invoke(
861 ["bundle", "create", str(out), "--json"], env=_env(tmp_path)
862 )
863 assert result.exit_code == 0
864 data = _parse_create(result)
865 assert data["commits"] >= 1
866
867 def test_explicit_head_ref(self, tmp_path: pathlib.Path) -> None:
868 """Passing 'HEAD' explicitly is equivalent to the default."""
869 _init_repo(tmp_path)
870 _make_commit(tmp_path, content=b"ext-head-explicit")
871 out_default = tmp_path / "head-default.bundle"
872 out_explicit = tmp_path / "head-explicit.bundle"
873 _invoke(["bundle", "create", str(out_default)], env=_env(tmp_path))
874 result = _invoke(
875 ["bundle", "create", str(out_explicit), "HEAD", "--json"],
876 env=_env(tmp_path),
877 )
878 assert result.exit_code == 0
879 data = _parse_create(result)
880 assert data["commits"] >= 1
881 # Both bundles should contain the same number of commits.
882 raw_default = __import__("msgpack").unpackb(
883 out_default.read_bytes(), raw=False
884 )
885 assert len(raw_default.get("commits", [])) == data["commits"]
886
887 def test_explicit_commit_id(self, tmp_path: pathlib.Path) -> None:
888 """A raw commit ID passed as ref is resolved correctly."""
889 _init_repo(tmp_path)
890 cid = _make_commit(tmp_path, content=b"ext-cid")
891 out = tmp_path / "cid.bundle"
892 result = _invoke(
893 ["bundle", "create", str(out), cid, "--json"],
894 env=_env(tmp_path),
895 )
896 assert result.exit_code == 0
897 data = _parse_create(result)
898 assert data["commits"] >= 1
899
900 def test_output_is_valid_msgpack(self, tmp_path: pathlib.Path) -> None:
901 """The output file must be valid msgpack."""
902 import msgpack as _mp
903
904 _init_repo(tmp_path)
905 _make_commit(tmp_path, content=b"ext-msgpack")
906 out = tmp_path / "mp.bundle"
907 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
908 raw = _mp.unpackb(out.read_bytes(), raw=False)
909 assert isinstance(raw, dict)
910 assert "commits" in raw
911
912 def test_help_mentions_agent_quickstart(self) -> None:
913 result = _invoke(["bundle", "create", "--help"])
914 assert result.exit_code == 0
915 assert "Agent quickstart" in result.output
916
917 def test_help_mentions_exit_codes(self) -> None:
918 result = _invoke(["bundle", "create", "--help"])
919 assert result.exit_code == 0
920 assert "Exit codes" in result.output
921
922 def test_help_mentions_json_schema(self) -> None:
923 result = _invoke(["bundle", "create", "--help"])
924 assert result.exit_code == 0
925 assert "JSON output schema" in result.output
926
927 def test_multiple_have_ids_reduce_bundle(self, tmp_path: pathlib.Path) -> None:
928 """Multiple --have IDs each reduce what is bundled."""
929 _init_repo(tmp_path)
930 c1 = _make_commit(tmp_path, content=b"ext-have-1")
931 c2 = _make_commit(tmp_path, parent_id=c1, content=b"ext-have-2")
932 _make_commit(tmp_path, parent_id=c2, content=b"ext-have-3")
933
934 out_full = tmp_path / "have-full.bundle"
935 out_pruned = tmp_path / "have-pruned.bundle"
936 r_full = _invoke(
937 ["bundle", "create", str(out_full), "--json"], env=_env(tmp_path)
938 )
939 r_pruned = _invoke(
940 ["bundle", "create", str(out_pruned), "--have", c1, c2, "--json"],
941 env=_env(tmp_path),
942 )
943 assert r_full.exit_code == 0
944 assert r_pruned.exit_code == 0
945 full_data = _parse_create(r_full)
946 pruned_data = _parse_create(r_pruned)
947 assert pruned_data["commits"] < full_data["commits"]
948
949
950 # ===========================================================================
951 # TestBundleCreateSecurity — 6 tests
952 # ===========================================================================
953
954
955 class TestBundleCreateSecurity:
956 def test_ansi_in_file_path_stripped_text_output(
957 self, tmp_path: pathlib.Path
958 ) -> None:
959 """ANSI escape in the output file path must be stripped in text output."""
960 _init_repo(tmp_path)
961 _make_commit(tmp_path, content=b"sec-ansi-path")
962 # Build an output path whose filename component contains an ANSI escape.
963 out = tmp_path / "\x1b[31mevil\x1b[0m.bundle"
964 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
965 assert result.exit_code == 0
966 assert "\x1b" not in result.output
967
968 def test_control_char_in_file_path_stripped(
969 self, tmp_path: pathlib.Path
970 ) -> None:
971 """Control characters in the output file path must not reach stdout."""
972 _init_repo(tmp_path)
973 _make_commit(tmp_path, content=b"sec-ctrl-path")
974 out = tmp_path / "foo\x07bar.bundle"
975 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
976 assert result.exit_code == 0
977 assert "\x07" not in result.output
978
979 def test_outside_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
980 """Without a .muse directory, create must exit 2 (REPO_NOT_FOUND)."""
981 out = tmp_path / "norepo.bundle"
982 result = _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
983 assert result.exit_code == 2
984
985 def test_bad_ref_ansi_stripped_from_error(
986 self, tmp_path: pathlib.Path
987 ) -> None:
988 """ANSI in an unknown ref name must not appear in the error output."""
989 _init_repo(tmp_path)
990 _make_commit(tmp_path, content=b"sec-ref-ansi")
991 out = tmp_path / "ref-ansi.bundle"
992 evil_ref = "\x1b[31mbadref\x1b[0m"
993 result = _invoke(
994 ["bundle", "create", str(out), evil_ref], env=_env(tmp_path)
995 )
996 assert result.exit_code == 1
997 assert "\x1b" not in result.output
998
999 def test_ansi_in_have_no_injection(self, tmp_path: pathlib.Path) -> None:
1000 """ANSI characters in a --have value must not appear in any output."""
1001 _init_repo(tmp_path)
1002 _make_commit(tmp_path, content=b"sec-have-ansi")
1003 out = tmp_path / "have-ansi.bundle"
1004 evil_have = "\x1b[31m" + "a" * 64 + "\x1b[0m"
1005 result = _invoke(
1006 ["bundle", "create", str(out), "--have", evil_have],
1007 env=_env(tmp_path),
1008 )
1009 # The have ID won't match anything — bundle succeeds with full history.
1010 assert "\x1b" not in result.output
1011
1012 def test_no_json_on_error(self, tmp_path: pathlib.Path) -> None:
1013 """On error (no commits), stdout must not contain JSON."""
1014 _init_repo(tmp_path)
1015 out = tmp_path / "err-json.bundle"
1016 result = _invoke(
1017 ["bundle", "create", str(out), "--json"], env=_env(tmp_path)
1018 )
1019 assert result.exit_code != 0
1020 assert not result.output.strip().startswith("{")
1021
1022
1023 # ===========================================================================
1024 # TestBundleCreateStress — 3 tests
1025 # ===========================================================================
1026
1027
1028 class TestBundleCreateStress:
1029 def test_50_commit_chain(self, tmp_path: pathlib.Path) -> None:
1030 """50-commit linear chain is bundled correctly."""
1031 _init_repo(tmp_path)
1032 prev: str | None = None
1033 for i in range(50):
1034 prev = _make_commit(
1035 tmp_path, parent_id=prev, content=f"stress50-{i}".encode()
1036 )
1037 out = tmp_path / "stress50.bundle"
1038 result = _invoke(
1039 ["bundle", "create", str(out), "--json"], env=_env(tmp_path)
1040 )
1041 assert result.exit_code == 0
1042 data = _parse_create(result)
1043 assert data["commits"] == 50
1044 assert data["size_bytes"] > 0
1045
1046 def test_create_with_large_have_list(self, tmp_path: pathlib.Path) -> None:
1047 """Passing 15 --have IDs on a 20-commit chain produces a smaller bundle."""
1048 _init_repo(tmp_path)
1049 ids: list[str] = []
1050 prev: str | None = None
1051 for i in range(20):
1052 prev = _make_commit(
1053 tmp_path, parent_id=prev, content=f"have-list-{i}".encode()
1054 )
1055 ids.append(prev)
1056
1057 out_full = tmp_path / "have-full-20.bundle"
1058 out_pruned = tmp_path / "have-pruned-20.bundle"
1059 r_full = _invoke(
1060 ["bundle", "create", str(out_full), "--json"], env=_env(tmp_path)
1061 )
1062 # Pass the first 15 as --have to exclude them.
1063 have_args = ["--have"] + ids[:15]
1064 r_pruned = _invoke(
1065 ["bundle", "create", str(out_pruned)] + have_args + ["--json"],
1066 env=_env(tmp_path),
1067 )
1068 assert r_full.exit_code == 0
1069 assert r_pruned.exit_code == 0
1070 full_data = _parse_create(r_full)
1071 pruned_data = _parse_create(r_pruned)
1072 assert pruned_data["commits"] < full_data["commits"]
1073
1074 def test_many_branches(self, tmp_path: pathlib.Path) -> None:
1075 """10 branches pointing to reachable commits all appear in the bundle."""
1076 _init_repo(tmp_path)
1077 # Build a 10-commit chain on main, then create a feature branch ref
1078 # pointing to each commit — all are reachable from HEAD.
1079 prev: str | None = None
1080 commit_ids: list[str] = []
1081 for i in range(10):
1082 prev = _make_commit(
1083 tmp_path, parent_id=prev, content=f"stress-br-{i}".encode()
1084 )
1085 commit_ids.append(prev)
1086 branch_names = [f"feat/stress-br-{i}" for i in range(10)]
1087 for br, cid in zip(branch_names, commit_ids):
1088 ref_file = tmp_path / ".muse" / "refs" / "heads" / br
1089 ref_file.parent.mkdir(parents=True, exist_ok=True)
1090 ref_file.write_text(cid, encoding="utf-8")
1091 out = tmp_path / "many-branches.bundle"
1092 result = _invoke(
1093 ["bundle", "create", str(out), "--json"], env=_env(tmp_path)
1094 )
1095 assert result.exit_code == 0
1096 data = _parse_create(result)
1097 for br in branch_names:
1098 assert br in data["branches"]
1099
1100
1101 # ===========================================================================
1102 # TestBundleUnbundleExtended — 18 tests
1103 # ===========================================================================
1104
1105
1106 def _make_bundle(src: pathlib.Path, dst_file: pathlib.Path) -> None:
1107 """Helper: create a bundle from src repo into dst_file."""
1108 _invoke(["bundle", "create", str(dst_file)], env=_env(src))
1109
1110
1111 class TestBundleUnbundleExtended:
1112 def _src_dst(self, tmp_path: pathlib.Path) -> tuple[pathlib.Path, pathlib.Path]:
1113 src = tmp_path / "src"
1114 dst = tmp_path / "dst"
1115 src.mkdir()
1116 dst.mkdir()
1117 _init_repo(src)
1118 _init_repo(dst, repo_id="ub-dst")
1119 return src, dst
1120
1121 def test_exits_0_basic(self, tmp_path: pathlib.Path) -> None:
1122 src, dst = self._src_dst(tmp_path)
1123 _make_commit(src, content=b"ub-basic")
1124 bundle = tmp_path / "basic.bundle"
1125 _make_bundle(src, bundle)
1126 result = _invoke(["bundle", "unbundle", str(bundle)], env=_env(dst))
1127 assert result.exit_code == 0
1128
1129 def test_commits_written_count(self, tmp_path: pathlib.Path) -> None:
1130 src, dst = self._src_dst(tmp_path)
1131 prev: str | None = None
1132 for i in range(3):
1133 prev = _make_commit(src, parent_id=prev, content=f"ub-cnt-{i}".encode())
1134 bundle = tmp_path / "cnt.bundle"
1135 _make_bundle(src, bundle)
1136 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1137 assert result.exit_code == 0
1138 data = _parse_unbundle(result)
1139 assert data["commits_written"] == 3
1140
1141 def test_snapshots_written_count(self, tmp_path: pathlib.Path) -> None:
1142 src, dst = self._src_dst(tmp_path)
1143 _make_commit(src, content=b"ub-snap")
1144 bundle = tmp_path / "snap.bundle"
1145 _make_bundle(src, bundle)
1146 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1147 assert result.exit_code == 0
1148 data = _parse_unbundle(result)
1149 assert data["snapshots_written"] >= 1
1150
1151 def test_objects_written_count(self, tmp_path: pathlib.Path) -> None:
1152 src, dst = self._src_dst(tmp_path)
1153 _make_commit(src, content=b"ub-obj")
1154 bundle = tmp_path / "obj.bundle"
1155 _make_bundle(src, bundle)
1156 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1157 assert result.exit_code == 0
1158 data = _parse_unbundle(result)
1159 assert data["objects_written"] >= 1
1160
1161 def test_objects_skipped_idempotent(self, tmp_path: pathlib.Path) -> None:
1162 """Unbundling twice: second pass skips all already-present objects."""
1163 src, dst = self._src_dst(tmp_path)
1164 _make_commit(src, content=b"ub-idem")
1165 bundle = tmp_path / "idem.bundle"
1166 _make_bundle(src, bundle)
1167 _invoke(["bundle", "unbundle", str(bundle)], env=_env(dst))
1168 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1169 assert result.exit_code == 0
1170 data = _parse_unbundle(result)
1171 assert data["commits_written"] == 0
1172 assert data["objects_written"] == 0
1173 assert data["objects_skipped"] >= 1
1174
1175 def test_text_output_mentions_commits(self, tmp_path: pathlib.Path) -> None:
1176 src, dst = self._src_dst(tmp_path)
1177 _make_commit(src, content=b"ub-txt-c")
1178 bundle = tmp_path / "txt-c.bundle"
1179 _make_bundle(src, bundle)
1180 result = _invoke(["bundle", "unbundle", str(bundle)], env=_env(dst))
1181 assert result.exit_code == 0
1182 assert "commit(s)" in result.output
1183
1184 def test_text_output_mentions_applied(self, tmp_path: pathlib.Path) -> None:
1185 src, dst = self._src_dst(tmp_path)
1186 _make_commit(src, content=b"ub-txt-a")
1187 bundle = tmp_path / "txt-a.bundle"
1188 _make_bundle(src, bundle)
1189 result = _invoke(["bundle", "unbundle", str(bundle)], env=_env(dst))
1190 assert result.exit_code == 0
1191 assert "Bundle applied" in result.output
1192
1193 def test_refs_updated_by_default(self, tmp_path: pathlib.Path) -> None:
1194 """By default, branch refs in the destination are updated."""
1195 src, dst = self._src_dst(tmp_path)
1196 _make_commit(src, content=b"ub-ref-up")
1197 bundle = tmp_path / "ref-up.bundle"
1198 _make_bundle(src, bundle)
1199 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1200 assert result.exit_code == 0
1201 data = _parse_unbundle(result)
1202 assert "main" in data["refs_updated"]
1203
1204 def test_no_update_refs_skips_refs(self, tmp_path: pathlib.Path) -> None:
1205 src, dst = self._src_dst(tmp_path)
1206 _make_commit(src, content=b"ub-no-ref")
1207 bundle = tmp_path / "no-ref.bundle"
1208 _make_bundle(src, bundle)
1209 result = _invoke(
1210 ["bundle", "unbundle", str(bundle), "--no-update-refs", "--json"],
1211 env=_env(dst),
1212 )
1213 assert result.exit_code == 0
1214 data = _parse_unbundle(result)
1215 assert data["refs_updated"] == []
1216
1217 def test_refs_updated_branch_file_exists(self, tmp_path: pathlib.Path) -> None:
1218 """After unbundle, the branch ref file must exist in the destination."""
1219 src, dst = self._src_dst(tmp_path)
1220 _make_commit(src, content=b"ub-ref-file")
1221 bundle = tmp_path / "ref-file.bundle"
1222 _make_bundle(src, bundle)
1223 _invoke(["bundle", "unbundle", str(bundle)], env=_env(dst))
1224 ref_file = dst / ".muse" / "refs" / "heads" / "main"
1225 assert ref_file.exists()
1226 cid = ref_file.read_text(encoding="utf-8").strip()
1227 assert len(cid) == 64
1228
1229 def test_j_alias(self, tmp_path: pathlib.Path) -> None:
1230 """-j must produce identical JSON to --json."""
1231 src1, dst1 = self._src_dst(tmp_path)
1232 src2 = tmp_path / "src2"
1233 dst2 = tmp_path / "dst2"
1234 src2.mkdir()
1235 dst2.mkdir()
1236 _init_repo(src2)
1237 _init_repo(dst2, repo_id="ub-j2")
1238
1239 _make_commit(src1, content=b"ub-j-a1")
1240 _make_commit(src2, content=b"ub-j-a2")
1241 b1 = tmp_path / "j1.bundle"
1242 b2 = tmp_path / "j2.bundle"
1243 _make_bundle(src1, b1)
1244 _make_bundle(src2, b2)
1245
1246 r1 = _invoke(["bundle", "unbundle", str(b1), "--json"], env=_env(dst1))
1247 r2 = _invoke(["bundle", "unbundle", str(b2), "-j"], env=_env(dst2))
1248 assert r1.exit_code == 0
1249 assert r2.exit_code == 0
1250 d1 = _parse_unbundle(r1)
1251 d2 = _parse_unbundle(r2)
1252 assert set(d1.keys()) == set(d2.keys())
1253 assert d1["commits_written"] == d2["commits_written"]
1254
1255 def test_json_refs_updated_sorted(self, tmp_path: pathlib.Path) -> None:
1256 """refs_updated in JSON output must be sorted."""
1257 src, dst = self._src_dst(tmp_path)
1258 c1 = _make_commit(src, content=b"ub-sort-base")
1259 # Add extra branch refs pointing at c1 so the bundle has multiple heads.
1260 for br in ("z-br", "a-br"):
1261 ref = src / ".muse" / "refs" / "heads" / br
1262 ref.write_text(c1, encoding="utf-8")
1263 bundle = tmp_path / "sort.bundle"
1264 _make_bundle(src, bundle)
1265 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1266 assert result.exit_code == 0
1267 data = _parse_unbundle(result)
1268 assert data["refs_updated"] == sorted(data["refs_updated"])
1269
1270 def test_empty_bundle_no_crash(self, tmp_path: pathlib.Path) -> None:
1271 """An empty dict bundle (no commits/objects) must exit 0 cleanly."""
1272 _init_repo(tmp_path)
1273 empty_bundle = tmp_path / "empty.bundle"
1274 empty_bundle.write_bytes(msgpack.packb({}, use_bin_type=True))
1275 result = _invoke(["bundle", "unbundle", str(empty_bundle)], env=_env(tmp_path))
1276 assert result.exit_code == 0
1277
1278 def test_bundle_without_branch_heads_no_refs(self, tmp_path: pathlib.Path) -> None:
1279 """A bundle missing the branch_heads key → refs_updated must be empty."""
1280 src, dst = self._src_dst(tmp_path)
1281 _make_commit(src, content=b"ub-no-heads")
1282 bundle = tmp_path / "no-heads.bundle"
1283 _make_bundle(src, bundle)
1284 # Strip branch_heads from the bundle.
1285 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1286 raw.pop("branch_heads", None)
1287 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1288 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1289 assert result.exit_code == 0
1290 data = _parse_unbundle(result)
1291 assert data["refs_updated"] == []
1292
1293 def test_help_mentions_agent_quickstart(self) -> None:
1294 result = _invoke(["bundle", "unbundle", "--help"])
1295 assert result.exit_code == 0
1296 assert "Agent quickstart" in result.output
1297
1298 def test_help_mentions_exit_codes(self) -> None:
1299 result = _invoke(["bundle", "unbundle", "--help"])
1300 assert result.exit_code == 0
1301 assert "Exit codes" in result.output
1302
1303 def test_help_mentions_json_schema(self) -> None:
1304 result = _invoke(["bundle", "unbundle", "--help"])
1305 assert result.exit_code == 0
1306 assert "JSON output schema" in result.output
1307
1308 def test_no_update_refs_flag_in_help(self) -> None:
1309 result = _invoke(["bundle", "unbundle", "--help"])
1310 assert result.exit_code == 0
1311 assert "--no-update-refs" in result.output
1312
1313
1314 # ===========================================================================
1315 # TestBundleUnbundleSecurity — 6 tests
1316 # ===========================================================================
1317
1318
1319 class TestBundleUnbundleSecurity:
1320 def test_outside_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
1321 """Without a .muse directory, unbundle must exit 2 (REPO_NOT_FOUND)."""
1322 bundle = tmp_path / "norepo.bundle"
1323 bundle.write_bytes(msgpack.packb({}, use_bin_type=True))
1324 result = _invoke(["bundle", "unbundle", str(bundle)], env=_env(tmp_path))
1325 assert result.exit_code == 2
1326
1327 def test_missing_bundle_file_exits_1(self, tmp_path: pathlib.Path) -> None:
1328 _init_repo(tmp_path)
1329 result = _invoke(
1330 ["bundle", "unbundle", str(tmp_path / "missing.bundle")],
1331 env=_env(tmp_path),
1332 )
1333 assert result.exit_code == 1
1334
1335 def test_invalid_msgpack_exits_1(self, tmp_path: pathlib.Path) -> None:
1336 _init_repo(tmp_path)
1337 corrupt = tmp_path / "corrupt.bundle"
1338 corrupt.write_bytes(b"\xff\xfe not msgpack at all")
1339 result = _invoke(["bundle", "unbundle", str(corrupt)], env=_env(tmp_path))
1340 assert result.exit_code == 1
1341
1342 def test_ansi_branch_name_skipped_no_injection(
1343 self, tmp_path: pathlib.Path
1344 ) -> None:
1345 """ANSI escape in a bundle branch name is skipped; no escape in output."""
1346 src = tmp_path / "src"
1347 dst = tmp_path / "dst"
1348 src.mkdir()
1349 dst.mkdir()
1350 _init_repo(src)
1351 _init_repo(dst, repo_id="sec-ansi-br")
1352 _make_commit(src, content=b"sec-ansi-br")
1353 bundle = tmp_path / "ansi-br.bundle"
1354 _make_bundle(src, bundle)
1355 # Inject an ANSI-poisoned branch name into branch_heads.
1356 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1357 raw["branch_heads"] = {"\x1b[31mevil\x1b[0m": "a" * 64}
1358 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1359 result = _invoke(["bundle", "unbundle", str(bundle)], env=_env(dst))
1360 assert result.exit_code == 0
1361 assert "\x1b" not in result.output
1362
1363 def test_invalid_commit_id_branch_ref_skipped(
1364 self, tmp_path: pathlib.Path
1365 ) -> None:
1366 """A commit ID shorter than 64 chars in branch_heads must be skipped."""
1367 src = tmp_path / "src"
1368 dst = tmp_path / "dst"
1369 src.mkdir()
1370 dst.mkdir()
1371 _init_repo(src)
1372 _init_repo(dst, repo_id="sec-short-cid")
1373 _make_commit(src, content=b"sec-short-cid")
1374 bundle = tmp_path / "short-cid.bundle"
1375 _make_bundle(src, bundle)
1376 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1377 # Replace the commit IDs with a too-short value.
1378 raw["branch_heads"] = {"main": "tooshort"}
1379 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1380 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1381 assert result.exit_code == 0
1382 data = _parse_unbundle(result)
1383 assert "main" not in data["refs_updated"]
1384
1385 def test_no_json_on_missing_file(self, tmp_path: pathlib.Path) -> None:
1386 """Error path (file not found) must not emit JSON to stdout."""
1387 _init_repo(tmp_path)
1388 result = _invoke(
1389 ["bundle", "unbundle", str(tmp_path / "ghost.bundle"), "--json"],
1390 env=_env(tmp_path),
1391 )
1392 assert result.exit_code != 0
1393 assert not result.output.strip().startswith("{")
1394
1395
1396 # ===========================================================================
1397 # TestBundleUnbundleStress — 3 tests
1398 # ===========================================================================
1399
1400
1401 class TestBundleUnbundleStress:
1402 def test_50_commit_chain(self, tmp_path: pathlib.Path) -> None:
1403 """50-commit chain is fully unpacked into the destination."""
1404 src = tmp_path / "src"
1405 dst = tmp_path / "dst"
1406 src.mkdir()
1407 dst.mkdir()
1408 _init_repo(src)
1409 _init_repo(dst, repo_id="stress-ub-dst")
1410 prev: str | None = None
1411 for i in range(50):
1412 prev = _make_commit(src, parent_id=prev, content=f"ub50-{i}".encode())
1413 bundle = tmp_path / "ub50.bundle"
1414 _make_bundle(src, bundle)
1415 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1416 assert result.exit_code == 0
1417 data = _parse_unbundle(result)
1418 assert data["commits_written"] == 50
1419 assert data["objects_written"] >= 50
1420
1421 def test_idempotent_multiple_applications(self, tmp_path: pathlib.Path) -> None:
1422 """Applying the same bundle 5 times: only the first writes anything."""
1423 src = tmp_path / "src"
1424 dst = tmp_path / "dst"
1425 src.mkdir()
1426 dst.mkdir()
1427 _init_repo(src)
1428 _init_repo(dst, repo_id="stress-idem-dst")
1429 prev: str | None = None
1430 for i in range(5):
1431 prev = _make_commit(src, parent_id=prev, content=f"idem-{i}".encode())
1432 bundle = tmp_path / "idem5.bundle"
1433 _make_bundle(src, bundle)
1434 first = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1435 assert first.exit_code == 0
1436 first_data = _parse_unbundle(first)
1437 assert first_data["commits_written"] == 5
1438 for _ in range(4):
1439 repeat = _invoke(
1440 ["bundle", "unbundle", str(bundle), "--json"], env=_env(dst)
1441 )
1442 assert repeat.exit_code == 0
1443 repeat_data = _parse_unbundle(repeat)
1444 assert repeat_data["commits_written"] == 0
1445 assert repeat_data["objects_written"] == 0
1446
1447 def test_many_branch_refs_updated(self, tmp_path: pathlib.Path) -> None:
1448 """10 branch heads in the bundle → all 10 appear in refs_updated."""
1449 src = tmp_path / "src"
1450 dst = tmp_path / "dst"
1451 src.mkdir()
1452 dst.mkdir()
1453 _init_repo(src)
1454 _init_repo(dst, repo_id="stress-refs-dst")
1455 # Build a 10-commit chain on main.
1456 prev: str | None = None
1457 cids: list[str] = []
1458 for i in range(10):
1459 prev = _make_commit(src, parent_id=prev, content=f"br-ref-{i}".encode())
1460 cids.append(prev)
1461 # Create 10 feature branch refs pointing to reachable commits.
1462 br_names = [f"feat/br-{i}" for i in range(10)]
1463 for br, cid in zip(br_names, cids):
1464 ref = src / ".muse" / "refs" / "heads" / br
1465 ref.parent.mkdir(parents=True, exist_ok=True)
1466 ref.write_text(cid, encoding="utf-8")
1467 bundle = tmp_path / "many-refs.bundle"
1468 _make_bundle(src, bundle)
1469 result = _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst))
1470 assert result.exit_code == 0
1471 data = _parse_unbundle(result)
1472 for br in br_names:
1473 assert br in data["refs_updated"]
1474
1475
1476 # ===========================================================================
1477 # TestBundleVerifyExtended — 18 tests
1478 # ===========================================================================
1479
1480
1481 class TestBundleVerifyExtended:
1482 def _clean_bundle(self, tmp_path: pathlib.Path) -> pathlib.Path:
1483 """Create a repo with one commit and return a clean bundle path."""
1484 _init_repo(tmp_path)
1485 _make_commit(tmp_path, content=b"vext-clean")
1486 out = tmp_path / "clean.bundle"
1487 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
1488 return out
1489
1490 def _corrupt_bundle(self, tmp_path: pathlib.Path) -> pathlib.Path:
1491 """Create a bundle then tamper one object's content."""
1492 _init_repo(tmp_path)
1493 _make_commit(tmp_path, content=b"vext-corrupt")
1494 out = tmp_path / "corrupt.bundle"
1495 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
1496 raw = msgpack.unpackb(out.read_bytes(), raw=False)
1497 if raw.get("objects"):
1498 raw["objects"][0]["content"] = b"TAMPERED"
1499 out.write_bytes(msgpack.packb(raw, use_bin_type=True))
1500 return out
1501
1502 def test_exits_0_on_clean_bundle(self, tmp_path: pathlib.Path) -> None:
1503 bundle = self._clean_bundle(tmp_path)
1504 result = _invoke(["bundle", "verify", str(bundle)], env=_env(tmp_path))
1505 assert result.exit_code == 0
1506
1507 def test_exits_1_on_corrupt_object(self, tmp_path: pathlib.Path) -> None:
1508 bundle = self._corrupt_bundle(tmp_path)
1509 result = _invoke(["bundle", "verify", str(bundle)], env=_env(tmp_path))
1510 assert result.exit_code == 1
1511
1512 def test_all_ok_true_on_clean(self, tmp_path: pathlib.Path) -> None:
1513 bundle = self._clean_bundle(tmp_path)
1514 result = _invoke(["bundle", "verify", str(bundle), "--json"], env=_env(tmp_path))
1515 assert result.exit_code == 0
1516 data = _parse_verify(result)
1517 assert data["all_ok"] is True
1518
1519 def test_all_ok_false_on_corrupt(self, tmp_path: pathlib.Path) -> None:
1520 bundle = self._corrupt_bundle(tmp_path)
1521 result = _invoke(["bundle", "verify", str(bundle), "--json"], env=_env(tmp_path))
1522 assert result.exit_code == 1
1523 data = _parse_verify(result)
1524 assert data["all_ok"] is False
1525
1526 def test_objects_checked_count(self, tmp_path: pathlib.Path) -> None:
1527 """objects_checked must equal the number of objects in the bundle."""
1528 _init_repo(tmp_path)
1529 _make_commit(tmp_path, content=b"vext-cnt")
1530 out = tmp_path / "cnt.bundle"
1531 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
1532 raw = msgpack.unpackb(out.read_bytes(), raw=False)
1533 n_objects = len(raw.get("objects", []))
1534 result = _invoke(["bundle", "verify", str(out), "--json"], env=_env(tmp_path))
1535 assert result.exit_code == 0
1536 data = _parse_verify(result)
1537 assert data["objects_checked"] == n_objects
1538
1539 def test_snapshots_checked_count(self, tmp_path: pathlib.Path) -> None:
1540 bundle = self._clean_bundle(tmp_path)
1541 result = _invoke(["bundle", "verify", str(bundle), "--json"], env=_env(tmp_path))
1542 assert result.exit_code == 0
1543 data = _parse_verify(result)
1544 assert data["snapshots_checked"] >= 1
1545
1546 def test_failures_empty_on_clean(self, tmp_path: pathlib.Path) -> None:
1547 bundle = self._clean_bundle(tmp_path)
1548 result = _invoke(["bundle", "verify", str(bundle), "--json"], env=_env(tmp_path))
1549 data = _parse_verify(result)
1550 assert data["failures"] == []
1551
1552 def test_failures_nonempty_on_corrupt(self, tmp_path: pathlib.Path) -> None:
1553 bundle = self._corrupt_bundle(tmp_path)
1554 result = _invoke(["bundle", "verify", str(bundle), "--json"], env=_env(tmp_path))
1555 data = _parse_verify(result)
1556 assert len(data["failures"]) >= 1
1557
1558 def test_quiet_clean_exits_0_no_output(self, tmp_path: pathlib.Path) -> None:
1559 bundle = self._clean_bundle(tmp_path)
1560 result = _invoke(["bundle", "verify", str(bundle), "--quiet"], env=_env(tmp_path))
1561 assert result.exit_code == 0
1562 assert result.output.strip() == ""
1563
1564 def test_quiet_corrupt_exits_1_no_output(self, tmp_path: pathlib.Path) -> None:
1565 bundle = self._corrupt_bundle(tmp_path)
1566 result = _invoke(["bundle", "verify", str(bundle), "-q"], env=_env(tmp_path))
1567 assert result.exit_code == 1
1568 assert result.output.strip() == ""
1569
1570 def test_json_output_is_single_line(self, tmp_path: pathlib.Path) -> None:
1571 """JSON output must be compact (no indent=2), matching all other commands."""
1572 bundle = self._clean_bundle(tmp_path)
1573 result = _invoke(["bundle", "verify", str(bundle), "--json"], env=_env(tmp_path))
1574 assert result.exit_code == 0
1575 # Compact JSON has no interior newlines.
1576 assert "\n" not in result.output.strip()
1577
1578 def test_j_alias(self, tmp_path: pathlib.Path) -> None:
1579 bundle = self._clean_bundle(tmp_path)
1580 r1 = _invoke(["bundle", "verify", str(bundle), "--json"], env=_env(tmp_path))
1581 r2 = _invoke(["bundle", "verify", str(bundle), "-j"], env=_env(tmp_path))
1582 assert r1.exit_code == 0
1583 assert r2.exit_code == 0
1584 assert json.loads(r1.output) == json.loads(r2.output)
1585
1586 def test_text_output_mentions_objects_checked(self, tmp_path: pathlib.Path) -> None:
1587 bundle = self._clean_bundle(tmp_path)
1588 result = _invoke(["bundle", "verify", str(bundle)], env=_env(tmp_path))
1589 assert "Objects checked" in result.output
1590
1591 def test_text_output_mentions_snapshots_checked(self, tmp_path: pathlib.Path) -> None:
1592 bundle = self._clean_bundle(tmp_path)
1593 result = _invoke(["bundle", "verify", str(bundle)], env=_env(tmp_path))
1594 assert "Snapshots checked" in result.output
1595
1596 def test_text_output_clean_checkmark(self, tmp_path: pathlib.Path) -> None:
1597 bundle = self._clean_bundle(tmp_path)
1598 result = _invoke(["bundle", "verify", str(bundle)], env=_env(tmp_path))
1599 assert "Bundle is clean" in result.output
1600
1601 def test_text_output_failures_listed(self, tmp_path: pathlib.Path) -> None:
1602 bundle = self._corrupt_bundle(tmp_path)
1603 result = _invoke(["bundle", "verify", str(bundle)], env=_env(tmp_path))
1604 assert result.exit_code == 1
1605 assert "hash mismatch" in result.output
1606
1607 def test_help_mentions_agent_quickstart(self) -> None:
1608 result = _invoke(["bundle", "verify", "--help"])
1609 assert result.exit_code == 0
1610 assert "Agent quickstart" in result.output
1611
1612 def test_help_mentions_exit_codes(self) -> None:
1613 result = _invoke(["bundle", "verify", "--help"])
1614 assert result.exit_code == 0
1615 assert "Exit codes" in result.output
1616
1617
1618 # ===========================================================================
1619 # TestBundleVerifySecurity — 6 tests
1620 # ===========================================================================
1621
1622
1623 class TestBundleVerifySecurity:
1624 def _bundle_with_ansi_object_id(self, tmp_path: pathlib.Path) -> pathlib.Path:
1625 """Bundle where an object_id contains an ANSI escape sequence."""
1626 _init_repo(tmp_path)
1627 _make_commit(tmp_path, content=b"sec-ansi-oid")
1628 out = tmp_path / "ansi-oid.bundle"
1629 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
1630 raw = msgpack.unpackb(out.read_bytes(), raw=False)
1631 if raw.get("objects"):
1632 # Inject ANSI into the object_id — will trigger hash mismatch failure.
1633 raw["objects"][0]["object_id"] = "\x1b[31mevil_oid_xxx\x1b[0m"
1634 out.write_bytes(msgpack.packb(raw, use_bin_type=True))
1635 return out
1636
1637 def _bundle_with_ansi_rel_path(self, tmp_path: pathlib.Path) -> pathlib.Path:
1638 """Bundle where a snapshot manifest key contains an ANSI escape."""
1639 _init_repo(tmp_path)
1640 _make_commit(tmp_path, content=b"sec-ansi-path")
1641 out = tmp_path / "ansi-path.bundle"
1642 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
1643 raw = msgpack.unpackb(out.read_bytes(), raw=False)
1644 if raw.get("snapshots"):
1645 snap = raw["snapshots"][0]
1646 # Replace manifest keys with ANSI-poisoned path.
1647 old_manifest = snap.get("manifest", {})
1648 snap["manifest"] = {
1649 "\x1b[31mevil/path\x1b[0m": v for v in old_manifest.values()
1650 }
1651 out.write_bytes(msgpack.packb(raw, use_bin_type=True))
1652 return out
1653
1654 def test_ansi_in_object_id_failure_stripped_text(
1655 self, tmp_path: pathlib.Path
1656 ) -> None:
1657 """ANSI in object_id within a failure message must be stripped in text output."""
1658 bundle = self._bundle_with_ansi_object_id(tmp_path)
1659 result = _invoke(["bundle", "verify", str(bundle)], env=_env(tmp_path))
1660 assert "\x1b" not in result.output
1661
1662 def test_ansi_in_rel_path_failure_stripped_text(
1663 self, tmp_path: pathlib.Path
1664 ) -> None:
1665 """ANSI in a manifest rel_path within a failure must be stripped in text output."""
1666 bundle = self._bundle_with_ansi_rel_path(tmp_path)
1667 result = _invoke(["bundle", "verify", str(bundle)], env=_env(tmp_path))
1668 assert "\x1b" not in result.output
1669
1670 def test_ansi_in_failures_sanitized_json(self, tmp_path: pathlib.Path) -> None:
1671 """failures list in JSON output must not contain raw ANSI escapes."""
1672 bundle = self._bundle_with_ansi_object_id(tmp_path)
1673 result = _invoke(["bundle", "verify", str(bundle), "--json"], env=_env(tmp_path))
1674 assert "\x1b" not in result.output
1675
1676 def test_no_repo_required(self, tmp_path: pathlib.Path) -> None:
1677 """verify must work outside any .muse repository (no require_repo call)."""
1678 work = tmp_path / "no_repo"
1679 work.mkdir()
1680 _init_repo(tmp_path)
1681 _make_commit(tmp_path, content=b"sec-no-repo")
1682 bundle = tmp_path / "no-repo.bundle"
1683 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1684 # Run verify from a directory with no .muse — must NOT exit 2.
1685 result = _invoke(["bundle", "verify", str(bundle)], env={"MUSE_REPO_ROOT": str(work)})
1686 assert result.exit_code != 2
1687
1688 def test_missing_file_exits_1(self, tmp_path: pathlib.Path) -> None:
1689 _init_repo(tmp_path)
1690 result = _invoke(
1691 ["bundle", "verify", str(tmp_path / "ghost.bundle")],
1692 env=_env(tmp_path),
1693 )
1694 assert result.exit_code == 1
1695
1696 def test_invalid_msgpack_exits_1(self, tmp_path: pathlib.Path) -> None:
1697 _init_repo(tmp_path)
1698 corrupt = tmp_path / "bad.bundle"
1699 corrupt.write_bytes(b"\xff\xfe not msgpack")
1700 result = _invoke(["bundle", "verify", str(corrupt)], env=_env(tmp_path))
1701 assert result.exit_code == 1
1702
1703
1704 # ===========================================================================
1705 # TestBundleVerifyStress — 3 tests
1706 # ===========================================================================
1707
1708
1709 class TestBundleVerifyStress:
1710 def test_200_commit_bundle_verify_clean(self, tmp_path: pathlib.Path) -> None:
1711 """200-commit bundle verifies clean with correct counts."""
1712 _init_repo(tmp_path)
1713 prev: str | None = None
1714 for i in range(200):
1715 prev = _make_commit(tmp_path, parent_id=prev, content=f"vstress-{i}".encode())
1716 out = tmp_path / "vstress200.bundle"
1717 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
1718 result = _invoke(["bundle", "verify", str(out), "--json"], env=_env(tmp_path))
1719 assert result.exit_code == 0
1720 data = _parse_verify(result)
1721 assert data["all_ok"] is True
1722 assert data["objects_checked"] >= 200
1723 assert data["snapshots_checked"] >= 200
1724
1725 def test_multiple_corrupt_objects_all_detected(
1726 self, tmp_path: pathlib.Path
1727 ) -> None:
1728 """Multiple corrupted objects must each produce a failure entry."""
1729 _init_repo(tmp_path)
1730 prev: str | None = None
1731 for i in range(5):
1732 prev = _make_commit(tmp_path, parent_id=prev, content=f"multi-corrupt-{i}".encode())
1733 out = tmp_path / "multi-corrupt.bundle"
1734 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
1735 raw = msgpack.unpackb(out.read_bytes(), raw=False)
1736 # Corrupt every object.
1737 for obj in raw.get("objects", []):
1738 obj["content"] = b"TAMPERED"
1739 out.write_bytes(msgpack.packb(raw, use_bin_type=True))
1740 result = _invoke(["bundle", "verify", str(out), "--json"], env=_env(tmp_path))
1741 assert result.exit_code == 1
1742 data = _parse_verify(result)
1743 assert data["all_ok"] is False
1744 assert len(data["failures"]) >= 5
1745
1746 def test_empty_bundle_verifies_clean(self, tmp_path: pathlib.Path) -> None:
1747 """An empty dict bundle has nothing to check and must exit 0."""
1748 _init_repo(tmp_path)
1749 empty = tmp_path / "empty.bundle"
1750 empty.write_bytes(msgpack.packb({}, use_bin_type=True))
1751 result = _invoke(["bundle", "verify", str(empty), "--json"], env=_env(tmp_path))
1752 assert result.exit_code == 0
1753 data = _parse_verify(result)
1754 assert data["all_ok"] is True
1755 assert data["objects_checked"] == 0
1756 assert data["failures"] == []
1757
1758
1759 # ===========================================================================
1760 # TestBundleListHeadsExtended — 18 tests
1761 # ===========================================================================
1762
1763
1764 class TestBundleListHeadsExtended:
1765 def _bundle_with_head(self, tmp_path: pathlib.Path, branch: str = "main") -> pathlib.Path:
1766 _init_repo(tmp_path)
1767 _make_commit(tmp_path, content=b"lhe-base", branch=branch)
1768 out = tmp_path / "lhe.bundle"
1769 _invoke(["bundle", "create", str(out)], env=_env(tmp_path))
1770 return out
1771
1772 def test_exits_0_with_heads(self, tmp_path: pathlib.Path) -> None:
1773 bundle = self._bundle_with_head(tmp_path)
1774 result = _invoke(["bundle", "list-heads", str(bundle)], env=_env(tmp_path))
1775 assert result.exit_code == 0
1776
1777 def test_exits_0_no_heads(self, tmp_path: pathlib.Path) -> None:
1778 """A bundle with no branch_heads key must still exit 0."""
1779 _init_repo(tmp_path)
1780 _make_commit(tmp_path, content=b"lhe-noheads")
1781 bundle = tmp_path / "noheads.bundle"
1782 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1783 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1784 raw.pop("branch_heads", None)
1785 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1786 result = _invoke(["bundle", "list-heads", str(bundle)], env=_env(tmp_path))
1787 assert result.exit_code == 0
1788
1789 def test_text_shows_branch_and_cid(self, tmp_path: pathlib.Path) -> None:
1790 bundle = self._bundle_with_head(tmp_path)
1791 result = _invoke(["bundle", "list-heads", str(bundle)], env=_env(tmp_path))
1792 assert result.exit_code == 0
1793 assert "main" in result.output
1794
1795 def test_text_no_heads_message(self, tmp_path: pathlib.Path) -> None:
1796 _init_repo(tmp_path)
1797 _make_commit(tmp_path, content=b"lhe-nomsg")
1798 bundle = tmp_path / "nomsg.bundle"
1799 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1800 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1801 raw.pop("branch_heads", None)
1802 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1803 result = _invoke(["bundle", "list-heads", str(bundle)], env=_env(tmp_path))
1804 assert "No branch heads" in result.output
1805
1806 def test_json_returns_dict(self, tmp_path: pathlib.Path) -> None:
1807 bundle = self._bundle_with_head(tmp_path)
1808 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1809 assert result.exit_code == 0
1810 data = json.loads(result.output)
1811 assert isinstance(data, dict)
1812
1813 def test_json_contains_main(self, tmp_path: pathlib.Path) -> None:
1814 bundle = self._bundle_with_head(tmp_path)
1815 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1816 data = json.loads(result.output)
1817 assert "main" in data
1818
1819 def test_json_commit_id_is_64_hex(self, tmp_path: pathlib.Path) -> None:
1820 bundle = self._bundle_with_head(tmp_path)
1821 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1822 data = json.loads(result.output)
1823 for cid in data.values():
1824 assert len(cid) == 64
1825 assert all(c in "0123456789abcdef" for c in cid)
1826
1827 def test_json_is_single_line(self, tmp_path: pathlib.Path) -> None:
1828 """JSON output must be compact (no indent=2)."""
1829 bundle = self._bundle_with_head(tmp_path)
1830 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1831 assert "\n" not in result.output.strip()
1832
1833 def test_j_alias(self, tmp_path: pathlib.Path) -> None:
1834 bundle = self._bundle_with_head(tmp_path)
1835 r1 = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1836 r2 = _invoke(["bundle", "list-heads", str(bundle), "-j"], env=_env(tmp_path))
1837 assert r1.exit_code == 0 and r2.exit_code == 0
1838 assert json.loads(r1.output) == json.loads(r2.output)
1839
1840 def test_json_empty_on_no_heads(self, tmp_path: pathlib.Path) -> None:
1841 _init_repo(tmp_path)
1842 _make_commit(tmp_path, content=b"lhe-empty-json")
1843 bundle = tmp_path / "ej.bundle"
1844 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1845 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1846 raw.pop("branch_heads", None)
1847 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1848 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1849 assert result.exit_code == 0
1850 assert json.loads(result.output) == {}
1851
1852 def test_multiple_branches_all_listed_text(self, tmp_path: pathlib.Path) -> None:
1853 _init_repo(tmp_path)
1854 c1 = _make_commit(tmp_path, content=b"lhe-multi-base")
1855 for br in ("feat/a", "feat/b", "feat/c"):
1856 ref = tmp_path / ".muse" / "refs" / "heads" / br
1857 ref.parent.mkdir(parents=True, exist_ok=True)
1858 ref.write_text(c1, encoding="utf-8")
1859 bundle = tmp_path / "multi.bundle"
1860 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1861 result = _invoke(["bundle", "list-heads", str(bundle)], env=_env(tmp_path))
1862 assert result.exit_code == 0
1863 for br in ("feat/a", "feat/b", "feat/c"):
1864 assert br in result.output
1865
1866 def test_multiple_branches_all_in_json(self, tmp_path: pathlib.Path) -> None:
1867 _init_repo(tmp_path)
1868 c1 = _make_commit(tmp_path, content=b"lhe-multi-json")
1869 for br in ("feat/x", "feat/y"):
1870 ref = tmp_path / ".muse" / "refs" / "heads" / br
1871 ref.parent.mkdir(parents=True, exist_ok=True)
1872 ref.write_text(c1, encoding="utf-8")
1873 bundle = tmp_path / "multij.bundle"
1874 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1875 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1876 data = json.loads(result.output)
1877 assert "feat/x" in data
1878 assert "feat/y" in data
1879
1880 def test_text_cid_is_12_chars(self, tmp_path: pathlib.Path) -> None:
1881 """Text output shows 12-char abbreviated commit ID."""
1882 bundle = self._bundle_with_head(tmp_path)
1883 result = _invoke(["bundle", "list-heads", str(bundle)], env=_env(tmp_path))
1884 # Each non-empty line should start with exactly 12 hex chars.
1885 for line in result.output.strip().splitlines():
1886 parts = line.split()
1887 assert len(parts[0]) == 12
1888 assert all(c in "0123456789abcdef" for c in parts[0])
1889
1890 def test_no_repo_required(self, tmp_path: pathlib.Path) -> None:
1891 """list-heads must work outside any .muse repository."""
1892 work = tmp_path / "no_repo_dir"
1893 work.mkdir()
1894 _init_repo(tmp_path)
1895 _make_commit(tmp_path, content=b"lhe-no-repo")
1896 bundle = tmp_path / "norepo.bundle"
1897 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1898 result = _invoke(["bundle", "list-heads", str(bundle)], env={"MUSE_REPO_ROOT": str(work)})
1899 assert result.exit_code != 2
1900
1901 def test_help_mentions_agent_quickstart(self) -> None:
1902 result = _invoke(["bundle", "list-heads", "--help"])
1903 assert result.exit_code == 0
1904 assert "Agent quickstart" in result.output
1905
1906 def test_help_mentions_exit_codes(self) -> None:
1907 result = _invoke(["bundle", "list-heads", "--help"])
1908 assert result.exit_code == 0
1909 assert "Exit codes" in result.output
1910
1911 def test_help_mentions_json_schema(self) -> None:
1912 result = _invoke(["bundle", "list-heads", "--help"])
1913 assert result.exit_code == 0
1914 assert "JSON output schema" in result.output
1915
1916 def test_missing_file_exits_1(self, tmp_path: pathlib.Path) -> None:
1917 _init_repo(tmp_path)
1918 result = _invoke(
1919 ["bundle", "list-heads", str(tmp_path / "ghost.bundle")],
1920 env=_env(tmp_path),
1921 )
1922 assert result.exit_code == 1
1923
1924
1925 # ===========================================================================
1926 # TestBundleListHeadsSecurity — 6 tests
1927 # ===========================================================================
1928
1929
1930 class TestBundleListHeadsSecurity:
1931 def test_ansi_branch_name_stripped_text(self, tmp_path: pathlib.Path) -> None:
1932 """ANSI escape in branch name must not appear in text output."""
1933 _init_repo(tmp_path)
1934 _make_commit(tmp_path, content=b"sec-lh-ansi")
1935 bundle = tmp_path / "ansi-br.bundle"
1936 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1937 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1938 raw["branch_heads"] = {"\x1b[31mevil\x1b[0m": "a" * 64}
1939 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1940 result = _invoke(["bundle", "list-heads", str(bundle)], env=_env(tmp_path))
1941 assert result.exit_code == 0
1942 assert "\x1b" not in result.output
1943
1944 def test_ansi_commit_id_stripped_text(self, tmp_path: pathlib.Path) -> None:
1945 """ANSI escape in a commit ID must not appear in text output (cid[:12])."""
1946 _init_repo(tmp_path)
1947 _make_commit(tmp_path, content=b"sec-lh-cid")
1948 bundle = tmp_path / "ansi-cid.bundle"
1949 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1950 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1951 raw["branch_heads"] = {"main": "\x1b[31m" + "a" * 64 + "\x1b[0m"}
1952 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1953 result = _invoke(["bundle", "list-heads", str(bundle)], env=_env(tmp_path))
1954 assert result.exit_code == 0
1955 assert "\x1b" not in result.output
1956
1957 def test_ansi_branch_name_stripped_json(self, tmp_path: pathlib.Path) -> None:
1958 """ANSI escape in branch name must not appear in JSON output."""
1959 _init_repo(tmp_path)
1960 _make_commit(tmp_path, content=b"sec-lh-ansi-json")
1961 bundle = tmp_path / "ansi-br-json.bundle"
1962 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1963 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1964 raw["branch_heads"] = {"\x1b[31mevil\x1b[0m": "b" * 64}
1965 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1966 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1967 assert result.exit_code == 0
1968 assert "\x1b" not in result.output
1969
1970 def test_ansi_commit_id_stripped_json(self, tmp_path: pathlib.Path) -> None:
1971 """ANSI escape in commit ID value must not appear in JSON output."""
1972 _init_repo(tmp_path)
1973 _make_commit(tmp_path, content=b"sec-lh-cid-json")
1974 bundle = tmp_path / "ansi-cid-json.bundle"
1975 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
1976 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
1977 raw["branch_heads"] = {"main": "\x1b[32m" + "c" * 64 + "\x1b[0m"}
1978 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
1979 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
1980 assert result.exit_code == 0
1981 assert "\x1b" not in result.output
1982
1983 def test_invalid_msgpack_exits_1(self, tmp_path: pathlib.Path) -> None:
1984 _init_repo(tmp_path)
1985 bad = tmp_path / "bad.bundle"
1986 bad.write_bytes(b"\xff\xfe garbage")
1987 result = _invoke(["bundle", "list-heads", str(bad)], env=_env(tmp_path))
1988 assert result.exit_code == 1
1989
1990 def test_no_json_on_missing_file(self, tmp_path: pathlib.Path) -> None:
1991 """Missing file error must not emit JSON to stdout."""
1992 _init_repo(tmp_path)
1993 result = _invoke(
1994 ["bundle", "list-heads", str(tmp_path / "missing.bundle"), "--json"],
1995 env=_env(tmp_path),
1996 )
1997 assert result.exit_code != 0
1998 assert not result.output.strip().startswith("{")
1999
2000
2001 # ===========================================================================
2002 # TestBundleListHeadsStress — 3 tests
2003 # ===========================================================================
2004
2005
2006 class TestBundleListHeadsStress:
2007 def test_50_branches_all_listed(self, tmp_path: pathlib.Path) -> None:
2008 """50 branch heads are all present in the JSON output."""
2009 _init_repo(tmp_path)
2010 c1 = _make_commit(tmp_path, content=b"lhstress-base")
2011 branch_names = [f"feat/stress-{i}" for i in range(50)]
2012 for br in branch_names:
2013 ref = tmp_path / ".muse" / "refs" / "heads" / br
2014 ref.parent.mkdir(parents=True, exist_ok=True)
2015 ref.write_text(c1, encoding="utf-8")
2016 bundle = tmp_path / "stress50.bundle"
2017 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
2018 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
2019 assert result.exit_code == 0
2020 data = json.loads(result.output)
2021 for br in branch_names:
2022 assert br in data
2023
2024 def test_concurrent_reads_consistent(self, tmp_path: pathlib.Path) -> None:
2025 """Concurrent list-heads reads on the same bundle must all succeed."""
2026 _init_repo(tmp_path)
2027 _make_commit(tmp_path, content=b"lhstress-concurrent")
2028 bundle = tmp_path / "concurrent.bundle"
2029 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
2030 errors: list[str] = []
2031
2032 def _read() -> None:
2033 r = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
2034 if r.exit_code != 0:
2035 errors.append(f"exit {r.exit_code}")
2036 else:
2037 try:
2038 if not isinstance(json.loads(r.output), dict):
2039 errors.append("not a dict")
2040 except json.JSONDecodeError as exc:
2041 errors.append(str(exc))
2042
2043 threads = [threading.Thread(target=_read) for _ in range(10)]
2044 for t in threads:
2045 t.start()
2046 for t in threads:
2047 t.join()
2048 assert not errors, f"Concurrent failures: {errors}"
2049
2050 def test_large_bundle_list_heads_fast(self, tmp_path: pathlib.Path) -> None:
2051 """list-heads on a 200-commit bundle returns quickly (I/O, not compute)."""
2052 import time
2053 _init_repo(tmp_path)
2054 prev: str | None = None
2055 for i in range(200):
2056 prev = _make_commit(tmp_path, parent_id=prev, content=f"lhfast-{i}".encode())
2057 bundle = tmp_path / "fast200.bundle"
2058 _invoke(["bundle", "create", str(bundle)], env=_env(tmp_path))
2059 t0 = time.monotonic()
2060 result = _invoke(["bundle", "list-heads", str(bundle), "--json"], env=_env(tmp_path))
2061 elapsed = time.monotonic() - t0
2062 assert result.exit_code == 0
2063 assert elapsed < 5.0, f"list-heads took {elapsed:.2f}s on 200-commit bundle"
File History 1 commit
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 49 days ago