gabriel / muse public
test_bundle_supercharge.py python
1,053 lines 42.1 KB
Raw
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 20 days ago
1 """Supercharged tests for ``muse bundle`` — three new agent-first features.
2
3 Feature 1 — ``muse bundle inspect <file> [--json]``
4 -----------------------------------------------------
5 Read and display the commit log and branch state from a bundle file without
6 unbundling. No repository required. Agents use this to decide whether to
7 apply a bundle before committing to the operation.
8
9 JSON schema::
10
11 {
12 "total_commits": int,
13 "total_objects": int,
14 "branches": {"<name>": "<commit_id>"},
15 "commits": [
16 {
17 "commit_id": str,
18 "message": str,
19 "committed_at": str, # ISO-8601
20 "agent_id": str, # "" when not an agent commit
21 "branches": [str] # branch names whose head == this commit
22 },
23 ... # newest first (by committed_at)
24 ]
25 }
26
27 Feature 2 — ``--verify`` flag on ``muse bundle unbundle``
28 ----------------------------------------------------------
29 Verify bundle integrity atomically before applying. Exits 1 (with no
30 writes) if the bundle is corrupt. JSON output gains a ``"verified"`` bool.
31
32 Feature 3 — ``muse bundle diff <file> [--json]``
33 -------------------------------------------------
34 Show which commits in the bundle are not already present in the local
35 repository. Agents use this to answer "what would this bundle add?" before
36 deciding to apply.
37
38 JSON schema::
39
40 {
41 "new_commits": int,
42 "known_commits": int,
43 "refs_to_advance": [str], # branch names that would move
44 "commits": [
45 {"commit_id": str, "message": str, "committed_at": str}
46 ]
47 }
48
49 Test categories
50 ---------------
51 - unit : internal helpers and schema shapes
52 - integration : CLI flag parsing and output contracts
53 - e2e : full round-trips via CliRunner
54 - security : ANSI/control injection in bundle content
55 - data_integrity: inspect/diff remain consistent across create-verify-unbundle
56 - performance : inspect and diff on 100-commit bundles under 1 s
57 - stress : inspect and diff on 200-commit bundles
58 """
59
60 from __future__ import annotations
61 from collections.abc import Mapping
62
63 import datetime
64
65 import json
66 import os
67 import pathlib
68 import time
69 import threading
70
71 import msgpack
72 import pytest
73
74 from tests.cli_test_helper import CliRunner, InvokeResult
75 from muse.core.object_store import write_object
76 from muse.core.ids import hash_commit, hash_snapshot
77 from muse.core.commits import (
78 CommitRecord,
79 write_commit,
80 )
81 from muse.core.snapshots import (
82 SnapshotRecord,
83 write_snapshot,
84 )
85 from muse.core.types import Manifest, blob_id, long_id
86 from muse.core.paths import heads_dir, muse_dir, objects_dir, ref_path
87
88 runner = CliRunner()
89 _REPO_ID = "bundle-supercharged-test"
90
91
92 # ---------------------------------------------------------------------------
93 # Helpers
94 # ---------------------------------------------------------------------------
95
96
97 def _init_repo(path: pathlib.Path, repo_id: str = _REPO_ID) -> pathlib.Path:
98 muse = muse_dir(path)
99 for d in ("commits", "snapshots", "objects", "refs/heads"):
100 (muse / d).mkdir(parents=True, exist_ok=True)
101 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
102 (muse / "repo.json").write_text(
103 json.dumps({"repo_id": repo_id, "domain": "midi"}), encoding="utf-8"
104 )
105 return path
106
107
108 def _env(repo: pathlib.Path) -> Manifest:
109 return {"MUSE_REPO_ROOT": str(repo)}
110
111
112 _counter = 0
113
114
115 def _make_commit(
116 root: pathlib.Path,
117 parent_id: str | None = None,
118 content: bytes = b"data",
119 branch: str = "main",
120 message: str | None = None,
121 agent_id: str = "",
122 ) -> str:
123 global _counter
124 _counter += 1
125 c = content + str(_counter).encode()
126 obj_id = blob_id(c)
127 write_object(root, obj_id, c)
128 manifest = {f"f_{_counter}.txt": obj_id}
129 snap_id = hash_snapshot(manifest)
130 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
131 committed_at = datetime.datetime.now(datetime.timezone.utc)
132 parent_ids = [parent_id] if parent_id else []
133 msg = message or f"commit {_counter}"
134 commit_id = hash_commit(
135 parent_ids=parent_ids,
136 snapshot_id=snap_id,
137 message=msg,
138 committed_at_iso=committed_at.isoformat(),
139 )
140 write_commit(root, CommitRecord(
141 commit_id=commit_id,
142 branch=branch,
143 snapshot_id=snap_id,
144 message=msg,
145 committed_at=committed_at,
146 parent_commit_id=parent_id,
147 agent_id=agent_id,
148 ))
149 ref_dir = heads_dir(root)
150 if "/" in branch:
151 (ref_dir / branch).parent.mkdir(parents=True, exist_ok=True)
152 (ref_dir / branch).write_text(commit_id, encoding="utf-8")
153 return commit_id
154
155
156 def _invoke(args: list[str], env: Manifest | None = None) -> InvokeResult:
157 return runner.invoke(None, args, env=env)
158
159
160 def _create_bundle(
161 repo: pathlib.Path, out: pathlib.Path, *extra_args: str
162 ) -> InvokeResult:
163 return _invoke(["bundle", "create", str(out), *extra_args], env=_env(repo))
164
165
166 def _parse_inspect(result: InvokeResult) -> Mapping[str, object]:
167 return json.loads(result.output)
168
169
170 def _parse_diff(result: InvokeResult) -> Mapping[str, object]:
171 return json.loads(result.output)
172
173
174 # ===========================================================================
175 # Feature 1: muse bundle inspect
176 # ===========================================================================
177
178
179 class TestBundleInspectUnit:
180 """Unit-level schema and output contracts for bundle inspect."""
181
182 def test_inspect_help_exits_0(self) -> None:
183 result = _invoke(["bundle", "inspect", "--help"])
184 assert result.exit_code == 0
185
186 def test_inspect_help_mentions_agent(self) -> None:
187 result = _invoke(["bundle", "inspect", "--help"])
188 assert "agent" in result.output.lower() or "Agent" in result.output
189
190 def test_inspect_help_mentions_json_schema(self) -> None:
191 result = _invoke(["bundle", "inspect", "--help"])
192 assert "JSON" in result.output
193
194 def test_inspect_json_schema_keys(self, tmp_path: pathlib.Path) -> None:
195 _init_repo(tmp_path)
196 _make_commit(tmp_path, content=b"inspect-schema")
197 bundle = tmp_path / "schema.bundle"
198 _create_bundle(tmp_path, bundle)
199 result = _invoke(["bundle", "inspect", str(bundle), "--json"])
200 assert result.exit_code == 0
201 data = _parse_inspect(result)
202 for key in ("total_commits", "total_objects", "branches", "commits"):
203 assert key in data, f"missing key: {key}"
204
205 def test_inspect_commit_entry_schema(self, tmp_path: pathlib.Path) -> None:
206 _init_repo(tmp_path)
207 _make_commit(tmp_path, content=b"inspect-entry")
208 bundle = tmp_path / "entry.bundle"
209 _create_bundle(tmp_path, bundle)
210 result = _invoke(["bundle", "inspect", str(bundle), "--json"])
211 assert result.exit_code == 0
212 data = _parse_inspect(result)
213 assert len(data["commits"]) >= 1
214 entry = data["commits"][0]
215 for key in ("commit_id", "message", "committed_at", "agent_id", "branches"):
216 assert key in entry, f"commit entry missing key: {key}"
217
218 def test_inspect_total_commits_count(self, tmp_path: pathlib.Path) -> None:
219 _init_repo(tmp_path)
220 prev = None
221 for i in range(5):
222 prev = _make_commit(tmp_path, parent_id=prev, content=f"cnt-{i}".encode())
223 bundle = tmp_path / "cnt.bundle"
224 _create_bundle(tmp_path, bundle)
225 result = _invoke(["bundle", "inspect", str(bundle), "--json"])
226 data = _parse_inspect(result)
227 assert data["total_commits"] == 5
228
229 def test_inspect_total_objects_positive(self, tmp_path: pathlib.Path) -> None:
230 _init_repo(tmp_path)
231 _make_commit(tmp_path, content=b"obj-count")
232 bundle = tmp_path / "objcnt.bundle"
233 _create_bundle(tmp_path, bundle)
234 result = _invoke(["bundle", "inspect", str(bundle), "--json"])
235 data = _parse_inspect(result)
236 assert data["total_objects"] > 0
237
238 def test_inspect_branches_map(self, tmp_path: pathlib.Path) -> None:
239 _init_repo(tmp_path)
240 cid = _make_commit(tmp_path, content=b"branches-map")
241 bundle = tmp_path / "bmap.bundle"
242 _create_bundle(tmp_path, bundle)
243 result = _invoke(["bundle", "inspect", str(bundle), "--json"])
244 data = _parse_inspect(result)
245 assert "main" in data["branches"]
246
247 def test_inspect_commit_message_present(self, tmp_path: pathlib.Path) -> None:
248 _init_repo(tmp_path)
249 _make_commit(tmp_path, content=b"msg-check", message="feat: add audio engine")
250 bundle = tmp_path / "msg.bundle"
251 _create_bundle(tmp_path, bundle)
252 result = _invoke(["bundle", "inspect", str(bundle), "--json"])
253 data = _parse_inspect(result)
254 messages = [c["message"] for c in data["commits"]]
255 assert any("feat: add audio engine" in m for m in messages)
256
257 def test_inspect_agent_id_from_agent_commit(self, tmp_path: pathlib.Path) -> None:
258 _init_repo(tmp_path)
259 _make_commit(tmp_path, content=b"agent-commit", agent_id="claude-code")
260 bundle = tmp_path / "agent.bundle"
261 _create_bundle(tmp_path, bundle)
262 result = _invoke(["bundle", "inspect", str(bundle), "--json"])
263 data = _parse_inspect(result)
264 agent_ids = [c["agent_id"] for c in data["commits"]]
265 assert "claude-code" in agent_ids
266
267 def test_inspect_agent_id_empty_for_human_commit(self, tmp_path: pathlib.Path) -> None:
268 _init_repo(tmp_path)
269 _make_commit(tmp_path, content=b"human-commit", agent_id="")
270 bundle = tmp_path / "human.bundle"
271 _create_bundle(tmp_path, bundle)
272 result = _invoke(["bundle", "inspect", str(bundle), "--json"])
273 data = _parse_inspect(result)
274 # Human commits have empty or None agent_id
275 assert data["commits"][0]["agent_id"] in ("", None)
276
277 def test_inspect_commits_newest_first(self, tmp_path: pathlib.Path) -> None:
278 """Commits must be ordered newest first (by committed_at)."""
279 _init_repo(tmp_path)
280 prev = None
281 for i in range(3):
282 prev = _make_commit(tmp_path, parent_id=prev, content=f"ord-{i}".encode())
283 bundle = tmp_path / "ord.bundle"
284 _create_bundle(tmp_path, bundle)
285 result = _invoke(["bundle", "inspect", str(bundle), "--json"])
286 data = _parse_inspect(result)
287 dates = [c["committed_at"] for c in data["commits"]]
288 assert dates == sorted(dates, reverse=True)
289
290 def test_inspect_branch_annotated_on_tip_commit(self, tmp_path: pathlib.Path) -> None:
291 """The commit that is a branch head should have that branch in its branches list."""
292 _init_repo(tmp_path)
293 cid = _make_commit(tmp_path, content=b"tip-commit")
294 bundle = tmp_path / "tip.bundle"
295 _create_bundle(tmp_path, bundle)
296 result = _invoke(["bundle", "inspect", str(bundle), "--json"])
297 data = _parse_inspect(result)
298 tip_entry = next(c for c in data["commits"] if c["commit_id"] == cid)
299 assert "main" in tip_entry["branches"]
300
301 def test_inspect_non_tip_commit_has_no_branch(self, tmp_path: pathlib.Path) -> None:
302 """Commits that are not at the tip of any branch have empty branches list."""
303 _init_repo(tmp_path)
304 c1 = _make_commit(tmp_path, content=b"non-tip-parent")
305 _make_commit(tmp_path, parent_id=c1, content=b"non-tip-child")
306 bundle = tmp_path / "nontip.bundle"
307 _create_bundle(tmp_path, bundle)
308 result = _invoke(["bundle", "inspect", str(bundle), "--json"])
309 data = _parse_inspect(result)
310 parent_entry = next(c for c in data["commits"] if c["commit_id"] == c1)
311 assert parent_entry["branches"] == []
312
313 def test_inspect_does_not_require_repo(self, tmp_path: pathlib.Path) -> None:
314 """inspect must work without MUSE_REPO_ROOT (no repo needed)."""
315 src = tmp_path / "src"
316 src.mkdir()
317 _init_repo(src)
318 _make_commit(src, content=b"no-repo-needed")
319 bundle = tmp_path / "norepo.bundle"
320 _create_bundle(src, bundle)
321 # Invoke with no env — no repo context at all
322 result = _invoke(["bundle", "inspect", str(bundle), "--json"])
323 assert result.exit_code == 0
324
325 def test_inspect_file_not_found(self, tmp_path: pathlib.Path) -> None:
326 result = _invoke(["bundle", "inspect", str(tmp_path / "missing.bundle"), "--json"])
327 assert result.exit_code != 0
328
329 def test_inspect_invalid_msgpack(self, tmp_path: pathlib.Path) -> None:
330 bad = tmp_path / "bad.bundle"
331 bad.write_bytes(b"not msgpack")
332 result = _invoke(["bundle", "inspect", str(bad), "--json"])
333 assert result.exit_code != 0
334
335 def test_inspect_empty_bundle(self, tmp_path: pathlib.Path) -> None:
336 empty = tmp_path / "empty.bundle"
337 empty.write_bytes(msgpack.packb({}, use_bin_type=True))
338 result = _invoke(["bundle", "inspect", str(empty), "--json"])
339 assert result.exit_code == 0
340 data = _parse_inspect(result)
341 assert data["total_commits"] == 0
342 assert data["commits"] == []
343 assert data["branches"] == {}
344
345 def test_inspect_j_alias(self, tmp_path: pathlib.Path) -> None:
346 _init_repo(tmp_path)
347 _make_commit(tmp_path, content=b"j-alias")
348 bundle = tmp_path / "jalias.bundle"
349 _create_bundle(tmp_path, bundle)
350 r1 = _invoke(["bundle", "inspect", str(bundle), "--json"])
351 r2 = _invoke(["bundle", "inspect", str(bundle), "-j"])
352 assert r1.exit_code == 0
353 assert r2.exit_code == 0
354 assert json.loads(r1.output)["total_commits"] == json.loads(r2.output)["total_commits"]
355
356
357 class TestBundleInspectText:
358 """Text output (no --json) contracts for bundle inspect."""
359
360 def test_text_output_mentions_commits(self, tmp_path: pathlib.Path) -> None:
361 _init_repo(tmp_path)
362 _make_commit(tmp_path, content=b"txt-commits")
363 bundle = tmp_path / "txt.bundle"
364 _create_bundle(tmp_path, bundle)
365 result = _invoke(["bundle", "inspect", str(bundle)])
366 assert result.exit_code == 0
367 assert "commit" in result.output.lower()
368
369 def test_text_output_mentions_branch(self, tmp_path: pathlib.Path) -> None:
370 _init_repo(tmp_path)
371 _make_commit(tmp_path, content=b"txt-branch")
372 bundle = tmp_path / "txt-br.bundle"
373 _create_bundle(tmp_path, bundle)
374 result = _invoke(["bundle", "inspect", str(bundle)])
375 assert result.exit_code == 0
376 assert "main" in result.output
377
378 def test_text_output_includes_commit_message(self, tmp_path: pathlib.Path) -> None:
379 _init_repo(tmp_path)
380 _make_commit(tmp_path, content=b"txt-msg", message="feat: melody engine")
381 bundle = tmp_path / "txt-msg.bundle"
382 _create_bundle(tmp_path, bundle)
383 result = _invoke(["bundle", "inspect", str(bundle)])
384 assert "feat: melody engine" in result.output
385
386
387 class TestBundleInspectSecurity:
388 """Security: ANSI and control injection from crafted bundle content."""
389
390 def _has_ansi(self, s: str) -> bool:
391 return "\x1b[" in s
392
393 def test_ansi_in_commit_message_stripped(self, tmp_path: pathlib.Path) -> None:
394 _init_repo(tmp_path)
395 _make_commit(tmp_path, content=b"ansi-msg", message="\x1b[31mmalicious\x1b[0m")
396 bundle = tmp_path / "ansi-msg.bundle"
397 _create_bundle(tmp_path, bundle)
398 result = _invoke(["bundle", "inspect", str(bundle), "--json"])
399 assert result.exit_code == 0
400 data = _parse_inspect(result)
401 for c in data["commits"]:
402 assert not self._has_ansi(c["message"]), "ANSI in message not stripped"
403
404 def test_ansi_in_branch_name_stripped(self, tmp_path: pathlib.Path) -> None:
405 """A crafted bundle with ANSI in a branch_heads key must not reach stdout."""
406 _init_repo(tmp_path)
407 cid = _make_commit(tmp_path, content=b"ansi-branch")
408 bundle = tmp_path / "ansi-br.bundle"
409 _create_bundle(tmp_path, bundle)
410 # Inject ANSI into branch_heads in the msgpack
411 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
412 raw["branch_heads"] = {"\x1b[31mmalicious\x1b[0m": cid}
413 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
414 result = _invoke(["bundle", "inspect", str(bundle), "--json"])
415 assert result.exit_code == 0
416 assert not self._has_ansi(result.output)
417
418 def test_ansi_in_agent_id_stripped(self, tmp_path: pathlib.Path) -> None:
419 _init_repo(tmp_path)
420 _make_commit(tmp_path, content=b"ansi-agent", agent_id="\x1b[31mhacked\x1b[0m")
421 bundle = tmp_path / "ansi-agent.bundle"
422 _create_bundle(tmp_path, bundle)
423 result = _invoke(["bundle", "inspect", str(bundle), "--json"])
424 assert result.exit_code == 0
425 assert not self._has_ansi(result.output)
426
427 def test_oversized_bundle_rejected(self, tmp_path: pathlib.Path) -> None:
428 """Bundle larger than the safety cap must be rejected."""
429 from muse.core.io import MAX_PACK_MSGPACK_BYTES
430 oversized = tmp_path / "oversized.bundle"
431 oversized.write_bytes(b"\x00" * (MAX_PACK_MSGPACK_BYTES + 1))
432 result = _invoke(["bundle", "inspect", str(oversized), "--json"])
433 assert result.exit_code != 0
434
435
436 class TestBundleInspectDataIntegrity:
437 """Data integrity: inspect output is consistent with create and unbundle."""
438
439 def test_inspect_commit_ids_match_create_json(self, tmp_path: pathlib.Path) -> None:
440 """Commits listed by inspect must equal those packed by create."""
441 _init_repo(tmp_path)
442 prev = None
443 cids = []
444 for i in range(4):
445 prev = _make_commit(tmp_path, parent_id=prev, content=f"di-{i}".encode())
446 cids.append(prev)
447 bundle = tmp_path / "di.bundle"
448 _create_bundle(tmp_path, bundle)
449 result = _invoke(["bundle", "inspect", str(bundle), "--json"])
450 data = _parse_inspect(result)
451 inspect_ids = {c["commit_id"] for c in data["commits"]}
452 for cid in cids:
453 assert cid in inspect_ids
454
455 def test_inspect_consistent_with_verify(self, tmp_path: pathlib.Path) -> None:
456 """A bundle that verify says is clean must also inspect cleanly."""
457 _init_repo(tmp_path)
458 prev = None
459 for i in range(3):
460 prev = _make_commit(tmp_path, parent_id=prev, content=f"vdi-{i}".encode())
461 bundle = tmp_path / "vdi.bundle"
462 _create_bundle(tmp_path, bundle)
463 v = _invoke(["bundle", "verify", str(bundle), "--json"])
464 assert json.loads(v.output)["all_ok"] is True
465 result = _invoke(["bundle", "inspect", str(bundle), "--json"])
466 assert result.exit_code == 0
467 data = _parse_inspect(result)
468 assert data["total_commits"] == 3
469
470 def test_inspect_branch_commit_id_matches_list_heads(self, tmp_path: pathlib.Path) -> None:
471 """branches map in inspect must match list-heads output."""
472 _init_repo(tmp_path)
473 _make_commit(tmp_path, content=b"lh-match")
474 bundle = tmp_path / "lh.bundle"
475 _create_bundle(tmp_path, bundle)
476 lh_raw = json.loads(
477 _invoke(["bundle", "list-heads", str(bundle), "--json"]).output
478 )
479 lh = lh_raw["heads"] if "heads" in lh_raw else lh_raw
480 ins = _parse_inspect(
481 _invoke(["bundle", "inspect", str(bundle), "--json"])
482 )
483 assert ins["branches"] == lh
484
485
486 class TestBundleInspectPerformance:
487 def test_inspect_100_commit_bundle_under_1s(self, tmp_path: pathlib.Path) -> None:
488 _init_repo(tmp_path)
489 prev = None
490 for i in range(100):
491 prev = _make_commit(tmp_path, parent_id=prev, content=f"perf-{i}".encode())
492 bundle = tmp_path / "perf100.bundle"
493 _create_bundle(tmp_path, bundle)
494 start = time.monotonic()
495 result = _invoke(["bundle", "inspect", str(bundle), "--json"])
496 elapsed = time.monotonic() - start
497 assert result.exit_code == 0
498 data = _parse_inspect(result)
499 assert data["total_commits"] == 100
500 assert elapsed < 1.0, f"inspect 100-commit bundle took {elapsed:.2f}s"
501
502
503 class TestBundleInspectStress:
504 def test_inspect_200_commit_bundle(self, tmp_path: pathlib.Path) -> None:
505 _init_repo(tmp_path)
506 prev = None
507 for i in range(200):
508 prev = _make_commit(tmp_path, parent_id=prev, content=f"s200-{i}".encode())
509 bundle = tmp_path / "s200.bundle"
510 _create_bundle(tmp_path, bundle)
511 result = _invoke(["bundle", "inspect", str(bundle), "--json"])
512 assert result.exit_code == 0
513 data = _parse_inspect(result)
514 assert data["total_commits"] == 200
515
516 def test_inspect_multi_branch_bundle(self, tmp_path: pathlib.Path) -> None:
517 _init_repo(tmp_path)
518 base = _make_commit(tmp_path, content=b"multi-base")
519 for i in range(10):
520 br = f"feat/branch-{i}"
521 ref = ref_path(tmp_path, br)
522 ref.parent.mkdir(parents=True, exist_ok=True)
523 ref.write_text(base, encoding="utf-8")
524 bundle = tmp_path / "multibr.bundle"
525 _create_bundle(tmp_path, bundle)
526 result = _invoke(["bundle", "inspect", str(bundle), "--json"])
527 data = _parse_inspect(result)
528 assert len(data["branches"]) == 11 # main + 10 feature branches
529 tip = next(c for c in data["commits"] if c["commit_id"] == base)
530 assert len(tip["branches"]) == 11
531
532 def test_concurrent_inspect_consistent(self, tmp_path: pathlib.Path) -> None:
533 _init_repo(tmp_path)
534 prev = None
535 for i in range(20):
536 prev = _make_commit(tmp_path, parent_id=prev, content=f"conc-{i}".encode())
537 bundle = tmp_path / "concurrent.bundle"
538 _create_bundle(tmp_path, bundle)
539 errors: list[str] = []
540
541 def _read() -> None:
542 r = _invoke(["bundle", "inspect", str(bundle), "--json"])
543 if r.exit_code != 0:
544 errors.append(f"exit {r.exit_code}")
545 else:
546 try:
547 d = json.loads(r.output)
548 if d["total_commits"] != 20:
549 errors.append(f"count {d['total_commits']}")
550 except Exception as exc:
551 errors.append(str(exc))
552
553 threads = [threading.Thread(target=_read) for _ in range(8)]
554 for t in threads:
555 t.start()
556 for t in threads:
557 t.join()
558 assert not errors, f"Concurrent inspect failures: {errors}"
559
560
561 # ===========================================================================
562 # Feature 2: --verify flag on muse bundle unbundle
563 # ===========================================================================
564
565
566 class TestBundleUnbundleVerifyFlag:
567 """--verify flag: verify integrity before applying."""
568
569 def _src_dst(
570 self, tmp_path: pathlib.Path, dst_id: str = "verify-dst"
571 ) -> tuple[pathlib.Path, pathlib.Path]:
572 src = tmp_path / "src"
573 dst = tmp_path / "dst"
574 src.mkdir()
575 dst.mkdir()
576 _init_repo(src)
577 _init_repo(dst, repo_id=dst_id)
578 return src, dst
579
580 def test_verify_flag_help_mentioned(self) -> None:
581 result = _invoke(["bundle", "unbundle", "--help"])
582 assert result.exit_code == 0
583 assert "--verify" in result.output
584
585 def test_verify_flag_clean_bundle_exits_0(self, tmp_path: pathlib.Path) -> None:
586 src, dst = self._src_dst(tmp_path)
587 _make_commit(src, content=b"vf-clean")
588 bundle = tmp_path / "clean.bundle"
589 _create_bundle(src, bundle)
590 result = _invoke(["bundle", "unbundle", str(bundle), "--verify"], env=_env(dst))
591 assert result.exit_code == 0
592
593 def test_verify_flag_applies_objects(self, tmp_path: pathlib.Path) -> None:
594 src, dst = self._src_dst(tmp_path)
595 _make_commit(src, content=b"vf-apply")
596 bundle = tmp_path / "apply.bundle"
597 _create_bundle(src, bundle)
598 result = _invoke(["bundle", "unbundle", str(bundle), "--verify"], env=_env(dst))
599 assert result.exit_code == 0
600 assert "unpacked" in result.output.lower() or "commit" in result.output.lower()
601
602 def test_verify_flag_corrupt_bundle_exits_1(self, tmp_path: pathlib.Path) -> None:
603 src, dst = self._src_dst(tmp_path)
604 _make_commit(src, content=b"vf-corrupt")
605 bundle = tmp_path / "corrupt.bundle"
606 _create_bundle(src, bundle)
607 # Corrupt an object
608 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
609 if raw.get("blobs"):
610 raw["blobs"][0]["content"] = b"TAMPERED"
611 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
612 result = _invoke(["bundle", "unbundle", str(bundle), "--verify"], env=_env(dst))
613 assert result.exit_code != 0
614
615 def test_verify_flag_corrupt_does_not_write(self, tmp_path: pathlib.Path) -> None:
616 """When --verify fails, no objects must be written to the destination."""
617 src, dst = self._src_dst(tmp_path)
618 _make_commit(src, content=b"vf-no-write")
619 bundle = tmp_path / "nowrite.bundle"
620 _create_bundle(src, bundle)
621 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
622 obj_ids_before = set(raw.get("branch_heads", {}).values())
623 if raw.get("blobs"):
624 raw["blobs"][0]["content"] = b"CORRUPTED"
625 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
626 _invoke(["bundle", "unbundle", str(bundle), "--verify"], env=_env(dst))
627 # Destination object store must be empty
628 obj_dir = objects_dir(dst)
629 written = list(obj_dir.rglob("*")) if obj_dir.exists() else []
630 written_files = [p for p in written if p.is_file()]
631 assert len(written_files) == 0, "Objects were written despite corrupt bundle"
632
633 def test_verify_flag_json_output_has_verified_field(
634 self, tmp_path: pathlib.Path
635 ) -> None:
636 src, dst = self._src_dst(tmp_path)
637 _make_commit(src, content=b"vf-json")
638 bundle = tmp_path / "json.bundle"
639 _create_bundle(src, bundle)
640 result = _invoke(
641 ["bundle", "unbundle", str(bundle), "--verify", "--json"], env=_env(dst)
642 )
643 assert result.exit_code == 0
644 data = json.loads(result.output)
645 assert "verified" in data
646 assert data["verified"] is True
647
648 def test_verify_flag_json_corrupt_verified_false(
649 self, tmp_path: pathlib.Path
650 ) -> None:
651 src, dst = self._src_dst(tmp_path)
652 _make_commit(src, content=b"vf-json-corrupt")
653 bundle = tmp_path / "json-corrupt.bundle"
654 _create_bundle(src, bundle)
655 raw = msgpack.unpackb(bundle.read_bytes(), raw=False)
656 if raw.get("blobs"):
657 raw["blobs"][0]["content"] = b"CORRUPT"
658 bundle.write_bytes(msgpack.packb(raw, use_bin_type=True))
659 result = _invoke(
660 ["bundle", "unbundle", str(bundle), "--verify", "--json"], env=_env(dst)
661 )
662 assert result.exit_code != 0
663
664 def test_no_verify_flag_still_works(self, tmp_path: pathlib.Path) -> None:
665 """Without --verify the old behavior is unchanged."""
666 src, dst = self._src_dst(tmp_path)
667 _make_commit(src, content=b"vf-no-flag")
668 bundle = tmp_path / "noflag.bundle"
669 _create_bundle(src, bundle)
670 result = _invoke(["bundle", "unbundle", str(bundle)], env=_env(dst))
671 assert result.exit_code == 0
672
673 def test_verify_and_no_update_refs_combined(self, tmp_path: pathlib.Path) -> None:
674 """--verify and --no-update-refs must be combinable."""
675 src, dst = self._src_dst(tmp_path)
676 _make_commit(src, content=b"vf-no-refs")
677 bundle = tmp_path / "norefs.bundle"
678 _create_bundle(src, bundle)
679 result = _invoke(
680 ["bundle", "unbundle", str(bundle), "--verify", "--no-update-refs", "--json"],
681 env=_env(dst),
682 )
683 assert result.exit_code == 0
684 data = json.loads(result.output)
685 assert data["verified"] is True
686 assert data["refs_updated"] == []
687
688 def test_verify_flag_security_corrupt_before_parse(
689 self, tmp_path: pathlib.Path
690 ) -> None:
691 """Bytes-level corruption (not msgpack) is caught before any write."""
692 src, dst = self._src_dst(tmp_path)
693 _make_commit(src, content=b"vf-bytes-corrupt")
694 bundle = tmp_path / "bytes-corrupt.bundle"
695 _create_bundle(src, bundle)
696 raw = bundle.read_bytes()
697 # Flip bytes in the middle to corrupt msgpack framing
698 mid = len(raw) // 2
699 corrupted = raw[:mid] + bytes(b ^ 0xFF for b in raw[mid:mid + 20]) + raw[mid + 20:]
700 bundle.write_bytes(corrupted)
701 result = _invoke(["bundle", "unbundle", str(bundle), "--verify"], env=_env(dst))
702 assert result.exit_code != 0
703
704
705 class TestBundleUnbundleVerifyStress:
706 def test_verify_flag_100_commit_bundle(self, tmp_path: pathlib.Path) -> None:
707 src = tmp_path / "src"
708 dst = tmp_path / "dst"
709 src.mkdir()
710 dst.mkdir()
711 _init_repo(src)
712 _init_repo(dst, repo_id="stress-verify-dst")
713 prev = None
714 for i in range(100):
715 prev = _make_commit(src, parent_id=prev, content=f"sv-{i}".encode())
716 bundle = tmp_path / "sv100.bundle"
717 _create_bundle(src, bundle)
718 start = time.monotonic()
719 result = _invoke(
720 ["bundle", "unbundle", str(bundle), "--verify", "--json"], env=_env(dst)
721 )
722 elapsed = time.monotonic() - start
723 assert result.exit_code == 0
724 data = json.loads(result.output)
725 assert data["verified"] is True
726 assert data["commits_written"] == 100
727 assert elapsed < 5.0, f"verify+unbundle 100 commits took {elapsed:.2f}s"
728
729
730 # ===========================================================================
731 # Feature 3: muse bundle diff
732 # ===========================================================================
733
734
735 class TestBundleDiffUnit:
736 """Unit-level schema and output contracts for bundle diff."""
737
738 def test_diff_help_exits_0(self) -> None:
739 result = _invoke(["bundle", "diff", "--help"])
740 assert result.exit_code == 0
741
742 def test_diff_help_mentions_agent(self) -> None:
743 result = _invoke(["bundle", "diff", "--help"])
744 assert "agent" in result.output.lower() or "Agent" in result.output
745
746 def test_diff_json_schema_keys(self, tmp_path: pathlib.Path) -> None:
747 _init_repo(tmp_path)
748 _make_commit(tmp_path, content=b"diff-schema")
749 bundle = tmp_path / "dschema.bundle"
750 _create_bundle(tmp_path, bundle)
751 result = _invoke(["bundle", "diff", str(bundle), "--json"], env=_env(tmp_path))
752 assert result.exit_code == 0
753 data = _parse_diff(result)
754 for key in ("new_commits", "known_commits", "refs_to_advance", "commits"):
755 assert key in data, f"diff JSON missing key: {key}"
756
757 def test_diff_known_commits_when_already_applied(
758 self, tmp_path: pathlib.Path
759 ) -> None:
760 """If the repo already has all bundle commits, new_commits == 0."""
761 _init_repo(tmp_path)
762 _make_commit(tmp_path, content=b"diff-known")
763 bundle = tmp_path / "known.bundle"
764 _create_bundle(tmp_path, bundle)
765 result = _invoke(["bundle", "diff", str(bundle), "--json"], env=_env(tmp_path))
766 data = _parse_diff(result)
767 assert data["new_commits"] == 0
768 assert data["known_commits"] >= 1
769
770 def test_diff_new_commits_in_fresh_repo(self, tmp_path: pathlib.Path) -> None:
771 """Diff against a fresh repo with no commits: all bundle commits are new."""
772 src = tmp_path / "src"
773 dst = tmp_path / "dst"
774 src.mkdir()
775 dst.mkdir()
776 _init_repo(src)
777 _init_repo(dst, repo_id="diff-fresh-dst")
778 prev = None
779 for i in range(3):
780 prev = _make_commit(src, parent_id=prev, content=f"df-{i}".encode())
781 bundle = tmp_path / "fresh.bundle"
782 _create_bundle(src, bundle)
783 result = _invoke(["bundle", "diff", str(bundle), "--json"], env=_env(dst))
784 data = _parse_diff(result)
785 assert data["new_commits"] == 3
786 assert data["known_commits"] == 0
787
788 def test_diff_refs_to_advance_populated(self, tmp_path: pathlib.Path) -> None:
789 """refs_to_advance must contain branch names that would move."""
790 src = tmp_path / "src"
791 dst = tmp_path / "dst"
792 src.mkdir()
793 dst.mkdir()
794 _init_repo(src)
795 _init_repo(dst, repo_id="diff-refs-dst")
796 _make_commit(src, content=b"diff-refs")
797 bundle = tmp_path / "refs.bundle"
798 _create_bundle(src, bundle)
799 result = _invoke(["bundle", "diff", str(bundle), "--json"], env=_env(dst))
800 data = _parse_diff(result)
801 assert "main" in data["refs_to_advance"]
802
803 def test_diff_refs_to_advance_empty_when_known(
804 self, tmp_path: pathlib.Path
805 ) -> None:
806 """When the repo is already up-to-date, refs_to_advance is empty."""
807 _init_repo(tmp_path)
808 _make_commit(tmp_path, content=b"diff-upto-date")
809 bundle = tmp_path / "upto.bundle"
810 _create_bundle(tmp_path, bundle)
811 result = _invoke(["bundle", "diff", str(bundle), "--json"], env=_env(tmp_path))
812 data = _parse_diff(result)
813 assert data["refs_to_advance"] == []
814
815 def test_diff_commits_list_contains_new_entries(
816 self, tmp_path: pathlib.Path
817 ) -> None:
818 src = tmp_path / "src"
819 dst = tmp_path / "dst"
820 src.mkdir()
821 dst.mkdir()
822 _init_repo(src)
823 _init_repo(dst, repo_id="diff-commits-dst")
824 prev = None
825 cids = []
826 for i in range(3):
827 prev = _make_commit(src, parent_id=prev, content=f"dc-{i}".encode())
828 cids.append(prev)
829 bundle = tmp_path / "commits.bundle"
830 _create_bundle(src, bundle)
831 result = _invoke(["bundle", "diff", str(bundle), "--json"], env=_env(dst))
832 data = _parse_diff(result)
833 listed_ids = {c["commit_id"] for c in data["commits"]}
834 for cid in cids:
835 assert cid in listed_ids
836
837 def test_diff_commit_entry_schema(self, tmp_path: pathlib.Path) -> None:
838 src = tmp_path / "src"
839 dst = tmp_path / "dst"
840 src.mkdir()
841 dst.mkdir()
842 _init_repo(src)
843 _init_repo(dst, repo_id="diff-entry-dst")
844 _make_commit(src, content=b"diff-entry")
845 bundle = tmp_path / "entry.bundle"
846 _create_bundle(src, bundle)
847 result = _invoke(["bundle", "diff", str(bundle), "--json"], env=_env(dst))
848 data = _parse_diff(result)
849 if data["commits"]:
850 entry = data["commits"][0]
851 for key in ("commit_id", "message", "committed_at"):
852 assert key in entry
853
854 def test_diff_partial_known_commits(self, tmp_path: pathlib.Path) -> None:
855 """When the dst repo has some but not all commits, count matches."""
856 src = tmp_path / "src"
857 dst = tmp_path / "dst"
858 src.mkdir()
859 dst.mkdir()
860 _init_repo(src)
861 _init_repo(dst, repo_id="diff-partial-dst")
862 # Build 5-commit chain; write first 2 to dst manually
863 prev = None
864 all_ids: list[str] = []
865 for i in range(5):
866 prev = _make_commit(src, parent_id=prev, content=f"partial-{i}".encode())
867 all_ids.append(prev)
868 # Copy first 2 commits into dst so they are "known"
869 from muse.core.commits import read_commit
870 for cid in all_ids[:2]:
871 rec = read_commit(src, cid)
872 if rec:
873 write_commit(dst, rec)
874 bundle = tmp_path / "partial.bundle"
875 _create_bundle(src, bundle)
876 result = _invoke(["bundle", "diff", str(bundle), "--json"], env=_env(dst))
877 data = _parse_diff(result)
878 assert data["new_commits"] == 3
879 assert data["known_commits"] == 2
880
881 def test_diff_requires_repo(self, tmp_path: pathlib.Path) -> None:
882 """diff requires a repository (unlike inspect/verify/list-heads)."""
883 src = tmp_path / "src"
884 src.mkdir()
885 _init_repo(src)
886 _make_commit(src, content=b"diff-needs-repo")
887 bundle = tmp_path / "needsrepo.bundle"
888 _create_bundle(src, bundle)
889 # Point MUSE_REPO_ROOT at a directory with no .muse → require_repo() fails.
890 no_repo = tmp_path / "no_repo"
891 no_repo.mkdir()
892 result = _invoke(["bundle", "diff", str(bundle), "--json"], env=_env(no_repo))
893 assert result.exit_code != 0
894
895 def test_diff_file_not_found(self, tmp_path: pathlib.Path) -> None:
896 _init_repo(tmp_path)
897 result = _invoke(
898 ["bundle", "diff", str(tmp_path / "missing.bundle"), "--json"],
899 env=_env(tmp_path),
900 )
901 assert result.exit_code != 0
902
903 def test_diff_j_alias(self, tmp_path: pathlib.Path) -> None:
904 src = tmp_path / "src"
905 dst = tmp_path / "dst"
906 src.mkdir()
907 dst.mkdir()
908 _init_repo(src)
909 _init_repo(dst, repo_id="diff-j-alias-dst")
910 _make_commit(src, content=b"diff-jalias")
911 bundle = tmp_path / "jalias.bundle"
912 _create_bundle(src, bundle)
913 r1 = _invoke(["bundle", "diff", str(bundle), "--json"], env=_env(dst))
914 r2 = _invoke(["bundle", "diff", str(bundle), "-j"], env=_env(dst))
915 assert r1.exit_code == 0
916 assert r2.exit_code == 0
917 d1 = json.loads(r1.output)
918 d2 = json.loads(r2.output)
919 assert d1["new_commits"] == d2["new_commits"]
920
921
922 class TestBundleDiffText:
923 def test_text_output_mentions_new_commits(self, tmp_path: pathlib.Path) -> None:
924 src = tmp_path / "src"
925 dst = tmp_path / "dst"
926 src.mkdir()
927 dst.mkdir()
928 _init_repo(src)
929 _init_repo(dst, repo_id="diff-txt-dst")
930 _make_commit(src, content=b"diff-txt")
931 bundle = tmp_path / "txt.bundle"
932 _create_bundle(src, bundle)
933 result = _invoke(["bundle", "diff", str(bundle)], env=_env(dst))
934 assert result.exit_code == 0
935 assert "new" in result.output.lower() or "commit" in result.output.lower()
936
937 def test_text_output_up_to_date_message(self, tmp_path: pathlib.Path) -> None:
938 """When nothing is new, output should say so."""
939 _init_repo(tmp_path)
940 _make_commit(tmp_path, content=b"diff-uptodate-txt")
941 bundle = tmp_path / "uptodate.bundle"
942 _create_bundle(tmp_path, bundle)
943 result = _invoke(["bundle", "diff", str(bundle)], env=_env(tmp_path))
944 assert result.exit_code == 0
945 # Should mention up-to-date or 0 new commits
946 assert "0" in result.output or "up-to-date" in result.output.lower()
947
948
949 class TestBundleDiffSecurity:
950 def _has_ansi(self, s: str) -> bool:
951 return "\x1b[" in s
952
953 def test_ansi_in_bundle_message_stripped(self, tmp_path: pathlib.Path) -> None:
954 src = tmp_path / "src"
955 dst = tmp_path / "dst"
956 src.mkdir()
957 dst.mkdir()
958 _init_repo(src)
959 _init_repo(dst, repo_id="diff-sec-ansi-dst")
960 _make_commit(src, content=b"diff-sec-ansi", message="\x1b[31mmalicious\x1b[0m")
961 bundle = tmp_path / "ansi.bundle"
962 _create_bundle(src, bundle)
963 result = _invoke(["bundle", "diff", str(bundle), "--json"], env=_env(dst))
964 assert result.exit_code == 0
965 assert not self._has_ansi(result.output)
966
967
968 class TestBundleDiffDataIntegrity:
969 def test_diff_then_unbundle_gives_zero_new(self, tmp_path: pathlib.Path) -> None:
970 """After unbundling, a second diff should show 0 new commits."""
971 src = tmp_path / "src"
972 dst = tmp_path / "dst"
973 src.mkdir()
974 dst.mkdir()
975 _init_repo(src)
976 _init_repo(dst, repo_id="diff-di-dst")
977 prev = None
978 for i in range(3):
979 prev = _make_commit(src, parent_id=prev, content=f"di-dt-{i}".encode())
980 bundle = tmp_path / "di.bundle"
981 _create_bundle(src, bundle)
982 # Before unbundle: 3 new
983 r1 = _invoke(["bundle", "diff", str(bundle), "--json"], env=_env(dst))
984 assert json.loads(r1.output)["new_commits"] == 3
985 # Unbundle
986 _invoke(["bundle", "unbundle", str(bundle)], env=_env(dst))
987 # After unbundle: 0 new
988 r2 = _invoke(["bundle", "diff", str(bundle), "--json"], env=_env(dst))
989 assert json.loads(r2.output)["new_commits"] == 0
990
991 def test_diff_new_count_matches_actual_writes(
992 self, tmp_path: pathlib.Path
993 ) -> None:
994 """new_commits from diff must equal commits_written from unbundle --json."""
995 src = tmp_path / "src"
996 dst = tmp_path / "dst"
997 src.mkdir()
998 dst.mkdir()
999 _init_repo(src)
1000 _init_repo(dst, repo_id="diff-di2-dst")
1001 prev = None
1002 for i in range(5):
1003 prev = _make_commit(src, parent_id=prev, content=f"match-{i}".encode())
1004 bundle = tmp_path / "match.bundle"
1005 _create_bundle(src, bundle)
1006 diff_data = json.loads(
1007 _invoke(["bundle", "diff", str(bundle), "--json"], env=_env(dst)).output
1008 )
1009 unbundle_data = json.loads(
1010 _invoke(["bundle", "unbundle", str(bundle), "--json"], env=_env(dst)).output
1011 )
1012 assert diff_data["new_commits"] == unbundle_data["commits_written"]
1013
1014
1015 class TestBundleDiffPerformance:
1016 def test_diff_100_commit_bundle_under_1s(self, tmp_path: pathlib.Path) -> None:
1017 src = tmp_path / "src"
1018 dst = tmp_path / "dst"
1019 src.mkdir()
1020 dst.mkdir()
1021 _init_repo(src)
1022 _init_repo(dst, repo_id="diff-perf-dst")
1023 prev = None
1024 for i in range(100):
1025 prev = _make_commit(src, parent_id=prev, content=f"dp-{i}".encode())
1026 bundle = tmp_path / "dp100.bundle"
1027 _create_bundle(src, bundle)
1028 start = time.monotonic()
1029 result = _invoke(["bundle", "diff", str(bundle), "--json"], env=_env(dst))
1030 elapsed = time.monotonic() - start
1031 assert result.exit_code == 0
1032 data = _parse_diff(result)
1033 assert data["new_commits"] == 100
1034 assert elapsed < 1.0, f"diff 100-commit bundle took {elapsed:.2f}s"
1035
1036
1037 class TestBundleDiffStress:
1038 def test_diff_200_commit_bundle(self, tmp_path: pathlib.Path) -> None:
1039 src = tmp_path / "src"
1040 dst = tmp_path / "dst"
1041 src.mkdir()
1042 dst.mkdir()
1043 _init_repo(src)
1044 _init_repo(dst, repo_id="diff-stress-dst")
1045 prev = None
1046 for i in range(200):
1047 prev = _make_commit(src, parent_id=prev, content=f"ds-{i}".encode())
1048 bundle = tmp_path / "ds200.bundle"
1049 _create_bundle(src, bundle)
1050 result = _invoke(["bundle", "diff", str(bundle), "--json"], env=_env(dst))
1051 assert result.exit_code == 0
1052 data = _parse_diff(result)
1053 assert data["new_commits"] == 200
File History 1 commit
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 20 days ago