gabriel / muse public
test_code_migrate.py python
958 lines 34.7 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
1 """Tests for ``muse code migrate`` — commit-id-v2 DAG replay.
2
3 Coverage tiers
4 --------------
5 Unit:
6 - v1-style commit fixture helper produces a file with mismatched ID
7 - dry-run reports old→new mapping and makes zero writes
8 - bare base64 signature is normalised to ``ed25519:…`` prefix
9 - object path migration: legacy paths moved under ``sha256/`` subdir
10
11 Integration:
12 - single root commit is rewritten with v2 formula
13 - linear chain: parent IDs cascade correctly through the DAG
14 - merge commit: both parents resolved through id_map
15 - all branch heads updated to new commit IDs
16 - idempotent: running twice produces the same store state
17 - preflight aborts when MERGE_STATE is present
18 - old records carrying ``format_version`` or ``branch`` key still migrate
19 """
20
21 from __future__ import annotations
22
23 import datetime
24 import json
25 import pathlib
26
27 import msgpack
28 import pytest
29
30 from tests.cli_test_helper import CliRunner
31 from muse.core._types import MsgpackDict, blob_id, split_id
32 from muse.core.paths import merge_state_path as _merge_state_path
33 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
34 from muse.core.store import (
35 CommitRecord,
36 SnapshotRecord,
37 commit_path,
38 get_all_branch_heads,
39 read_commit,
40 write_commit,
41 write_snapshot,
42 )
43
44 cli = None
45 runner = CliRunner()
46
47 _REPO_ID = "test-repo-v2-migrate"
48 _AUTHOR = "gabriel"
49 _AT = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
50 _AT_ISO = _AT.isoformat()
51
52
53 # ---------------------------------------------------------------------------
54 # Repo bootstrap helpers
55 # ---------------------------------------------------------------------------
56
57
58 def _init_repo(tmp_path: pathlib.Path) -> pathlib.Path:
59 """Create a minimal .muse layout with no commits."""
60 muse = tmp_path / ".muse"
61 for sub in ("commits/sha256", "snapshots/sha256", "objects/sha256",
62 "refs/heads"):
63 (muse / sub).mkdir(parents=True)
64 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
65 (muse / "repo.json").write_text(
66 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
67 )
68 return tmp_path
69
70
71 type _Environ = dict[str, str]
72
73
74 def _env(repo: pathlib.Path) -> _Environ:
75 return {"MUSE_REPO_ROOT": str(repo)}
76
77
78 def _snap(repo: pathlib.Path, tag: str = "s") -> str:
79 manifest: MsgpackDict = {f"file_{tag}.py": f"sha256:{'a' * 64}"}
80 sid = compute_snapshot_id(manifest)
81 write_snapshot(repo, SnapshotRecord(
82 snapshot_id=sid, manifest=manifest,
83 created_at=_AT,
84 ))
85 return sid
86
87
88 def _v2_commit(
89 repo: pathlib.Path,
90 tag: str,
91 sid: str,
92 branch: str = "main",
93 parent: str | None = None,
94 author: str = _AUTHOR,
95 signer_public_key: str = "",
96 ) -> str:
97 """Write a proper v2 commit (using compute_commit_id with all fields)."""
98 parent_ids = [parent] if parent else []
99 cid = compute_commit_id(
100 parent_ids=parent_ids,
101 snapshot_id=sid,
102 message=tag,
103 committed_at_iso=_AT_ISO,
104 repo_id=_REPO_ID,
105 author=author,
106 signer_public_key=signer_public_key,
107 )
108 write_commit(repo, CommitRecord(
109 commit_id=cid,
110 repo_id=_REPO_ID,
111 created_on_branch=branch,
112 snapshot_id=sid,
113 message=tag,
114 committed_at=_AT,
115 author=author,
116 parent_commit_id=parent,
117 signer_public_key=signer_public_key,
118 ))
119 _set_ref(repo, branch, cid)
120 return cid
121
122
123 def _v1_commit_id(
124 parent_ids: list[str],
125 snapshot_id: str,
126 message: str,
127 committed_at_iso: str,
128 ) -> str:
129 """Compute a v1 commit ID (old formula — no repo_id/author/signer)."""
130 _SEP = "\x00"
131 parts = [
132 _SEP.join(sorted(split_id(p)[1] for p in parent_ids)),
133 split_id(snapshot_id)[1],
134 message,
135 committed_at_iso,
136 ]
137 payload = _SEP.join(parts).encode()
138 return blob_id(payload)
139
140
141 def _write_v1_commit_raw(
142 repo: pathlib.Path,
143 tag: str,
144 sid: str,
145 branch: str = "main",
146 parent: str | None = None,
147 author: str = _AUTHOR,
148 signature: str = "",
149 signer_public_key: str = "",
150 extra: MsgpackDict | None = None,
151 ) -> str:
152 """Write a v1-style commit directly to disk, bypassing write_commit.
153
154 The stored commit_id is computed with the OLD v1 formula so it will NOT
155 match the v2 formula. migrate must detect this mismatch and rewrite.
156 """
157 parent_ids = [parent] if parent else []
158 cid = _v1_commit_id(parent_ids, sid, tag, _AT_ISO)
159 record: MsgpackDict = {
160 "commit_id": cid,
161 "repo_id": _REPO_ID,
162 "created_on_branch": branch,
163 "snapshot_id": sid,
164 "message": tag,
165 "committed_at": _AT_ISO,
166 "parent_commit_id": parent,
167 "parent2_commit_id": None,
168 "author": author,
169 "metadata": {},
170 "structured_delta": None,
171 "sem_ver_bump": "none",
172 "breaking_changes": [],
173 "agent_id": "",
174 "model_id": "",
175 "toolchain_id": "",
176 "prompt_hash": "",
177 "signature": signature,
178 "signer_public_key": signer_public_key,
179 "signer_key_id": "",
180 "reviewed_by": [],
181 "test_runs": 0,
182 "labels": [],
183 "status": "",
184 "notes": [],
185 "score": None,
186 }
187 if extra:
188 record.update(extra)
189 algo, hex_id = split_id(cid)
190 dest = repo / ".muse" / "commits" / algo / f"{hex_id}.msgpack"
191 dest.parent.mkdir(parents=True, exist_ok=True)
192 dest.write_bytes(msgpack.packb(record, use_bin_type=True))
193 return cid
194
195
196 def _set_ref(repo: pathlib.Path, branch: str, commit_id: str) -> None:
197 ref = repo / ".muse" / "refs" / "heads" / branch
198 ref.parent.mkdir(parents=True, exist_ok=True)
199 ref.write_text(commit_id, encoding="utf-8")
200
201
202 def _invoke(args: list[str], repo: pathlib.Path) -> MsgpackDict:
203 if "--json" not in args:
204 args = args + ["--json"]
205 result = runner.invoke(cli, args, env=_env(repo))
206 assert result.exit_code == 0, (
207 f"muse {' '.join(args)} failed (exit {result.exit_code}):\n{result.output}"
208 )
209 return json.loads(result.output)
210
211
212 # ---------------------------------------------------------------------------
213 # Fixture validity sanity-check
214 # ---------------------------------------------------------------------------
215
216
217 class TestFixture:
218 def test_v1_commit_id_differs_from_v2(self, tmp_path: pathlib.Path) -> None:
219 """v1 formula must produce a different ID than v2 for the same inputs."""
220 repo = _init_repo(tmp_path)
221 sid = _snap(repo)
222 v1_id = _v1_commit_id([], sid, "root", _AT_ISO)
223 v2_id = compute_commit_id(
224 parent_ids=[], snapshot_id=sid, message="root",
225 committed_at_iso=_AT_ISO, repo_id=_REPO_ID,
226 author=_AUTHOR, signer_public_key="",
227 )
228 assert v1_id != v2_id, "v1 and v2 IDs must differ when author/repo_id are non-empty"
229
230 def test_v1_raw_write_is_unreadable_by_read_commit(self, tmp_path: pathlib.Path) -> None:
231 """read_commit must return None for a v1 commit (ID mismatch)."""
232 repo = _init_repo(tmp_path)
233 sid = _snap(repo)
234 old_id = _write_v1_commit_raw(repo, "root", sid)
235 _set_ref(repo, "main", old_id)
236 assert read_commit(repo, old_id) is None
237
238
239 # ---------------------------------------------------------------------------
240 # Preflight
241 # ---------------------------------------------------------------------------
242
243
244 class TestPreflight:
245 def test_aborts_when_merge_state_exists(self, tmp_path: pathlib.Path) -> None:
246 repo = _init_repo(tmp_path)
247 sid = _snap(repo)
248 _write_v1_commit_raw(repo, "root", sid)
249 _merge_state_path(repo).write_text("{}", encoding="utf-8")
250 result = runner.invoke(cli, ["code", "migrate", "--json"], env=_env(repo))
251 assert result.exit_code != 0
252 out = json.loads(result.output)
253 assert "merge" in out.get("error", "").lower() or "merge" in str(out).lower()
254
255 def test_aborts_when_rebase_in_progress(self, tmp_path: pathlib.Path) -> None:
256 repo = _init_repo(tmp_path)
257 (repo / ".muse" / "rebase-merge").mkdir(parents=True)
258 result = runner.invoke(cli, ["code", "migrate", "--json"], env=_env(repo))
259 assert result.exit_code != 0
260 out = json.loads(result.output)
261 assert "rebase" in out.get("error", "").lower() or "rebase" in str(out).lower()
262
263
264 # ---------------------------------------------------------------------------
265 # Dry-run
266 # ---------------------------------------------------------------------------
267
268
269 class TestDryRun:
270 def test_dry_run_makes_no_writes(self, tmp_path: pathlib.Path) -> None:
271 repo = _init_repo(tmp_path)
272 sid = _snap(repo)
273 old_id = _write_v1_commit_raw(repo, "root", sid)
274 _set_ref(repo, "main", old_id)
275
276 before = list((repo / ".muse" / "commits").rglob("*.msgpack"))
277 runner.invoke(cli, ["code", "migrate", "--dry-run", "--json"], env=_env(repo))
278 after = list((repo / ".muse" / "commits").rglob("*.msgpack"))
279
280 assert set(str(p) for p in before) == set(str(p) for p in after)
281 assert (repo / ".muse" / "refs" / "heads" / "main").read_text() == old_id
282
283 def test_dry_run_reports_old_to_new_mapping(self, tmp_path: pathlib.Path) -> None:
284 repo = _init_repo(tmp_path)
285 sid = _snap(repo)
286 old_id = _write_v1_commit_raw(repo, "root", sid)
287 _set_ref(repo, "main", old_id)
288
289 result = runner.invoke(
290 cli, ["code", "migrate", "--dry-run", "--json"], env=_env(repo)
291 )
292 assert result.exit_code == 0
293 out = json.loads(result.output)
294 mapping = out.get("id_map", {})
295 assert old_id in mapping
296 new_id = mapping[old_id]
297 expected = compute_commit_id(
298 parent_ids=[], snapshot_id=sid, message="root",
299 committed_at_iso=_AT_ISO, repo_id=_REPO_ID,
300 author=_AUTHOR, signer_public_key="",
301 )
302 assert new_id == expected
303
304 def test_dry_run_reports_summary_counts(self, tmp_path: pathlib.Path) -> None:
305 repo = _init_repo(tmp_path)
306 sid = _snap(repo)
307 old_id = _write_v1_commit_raw(repo, "root", sid)
308 _set_ref(repo, "main", old_id)
309
310 result = runner.invoke(
311 cli, ["code", "migrate", "--dry-run", "--json"], env=_env(repo)
312 )
313 assert result.exit_code == 0
314 out = json.loads(result.output)
315 assert out.get("commits_rewritten", -1) == 1
316 assert "blobs_migrated" in out
317 assert "dry_run" in out and out["dry_run"] is True
318
319
320 # ---------------------------------------------------------------------------
321 # Single root commit
322 # ---------------------------------------------------------------------------
323
324
325 class TestSingleRootCommit:
326 def test_rewrites_v1_commit_with_v2_id(self, tmp_path: pathlib.Path) -> None:
327 repo = _init_repo(tmp_path)
328 sid = _snap(repo)
329 old_id = _write_v1_commit_raw(repo, "root", sid)
330 _set_ref(repo, "main", old_id)
331
332 _invoke(["code", "migrate"], repo)
333
334 expected_new_id = compute_commit_id(
335 parent_ids=[], snapshot_id=sid, message="root",
336 committed_at_iso=_AT_ISO, repo_id=_REPO_ID,
337 author=_AUTHOR, signer_public_key="",
338 )
339 rec = read_commit(repo, expected_new_id)
340 assert rec is not None
341 assert rec.commit_id == expected_new_id
342
343 def test_old_commit_file_deleted(self, tmp_path: pathlib.Path) -> None:
344 repo = _init_repo(tmp_path)
345 sid = _snap(repo)
346 old_id = _write_v1_commit_raw(repo, "root", sid)
347 _set_ref(repo, "main", old_id)
348
349 _invoke(["code", "migrate"], repo)
350
351 old_path = commit_path(repo, old_id)
352 assert not old_path.exists()
353
354 def test_already_v2_commit_unchanged(self, tmp_path: pathlib.Path) -> None:
355 """A commit already using v2 ID must not be rewritten (old_id == new_id)."""
356 repo = _init_repo(tmp_path)
357 sid = _snap(repo)
358 v2_id = _v2_commit(repo, "root", sid)
359
360 _invoke(["code", "migrate"], repo)
361
362 rec = read_commit(repo, v2_id)
363 assert rec is not None
364 assert rec.commit_id == v2_id
365
366
367 # ---------------------------------------------------------------------------
368 # Linear chain — parent ID cascading
369 # ---------------------------------------------------------------------------
370
371
372 class TestLinearChain:
373 def _build_chain(self, repo: pathlib.Path, length: int = 3) -> list[str]:
374 old_ids: list[str] = []
375 sid = _snap(repo, "s0")
376 parent = None
377 for i in range(length):
378 if i > 0:
379 sid = _snap(repo, f"s{i}")
380 old_id = _write_v1_commit_raw(repo, f"c{i}", sid, parent=parent)
381 old_ids.append(old_id)
382 parent = old_id
383 _set_ref(repo, "main", old_ids[-1])
384 return old_ids
385
386 def test_chain_all_readable_after_migrate(self, tmp_path: pathlib.Path) -> None:
387 repo = _init_repo(tmp_path)
388 old_ids = self._build_chain(repo, 3)
389
390 _invoke(["code", "migrate"], repo)
391
392 new_head = (repo / ".muse" / "refs" / "heads" / "main").read_text().strip()
393 head_rec = read_commit(repo, new_head)
394 assert head_rec is not None
395 parent_rec = read_commit(repo, head_rec.parent_commit_id)
396 assert parent_rec is not None
397 root_rec = read_commit(repo, parent_rec.parent_commit_id)
398 assert root_rec is not None
399 assert root_rec.parent_commit_id is None
400
401 def test_chain_old_ids_all_deleted(self, tmp_path: pathlib.Path) -> None:
402 repo = _init_repo(tmp_path)
403 old_ids = self._build_chain(repo, 3)
404
405 _invoke(["code", "migrate"], repo)
406
407 for old_id in old_ids:
408 assert not commit_path(repo, old_id).exists()
409
410 def test_chain_parent_ids_consistent(self, tmp_path: pathlib.Path) -> None:
411 """After migrate, each commit's parent_commit_id must match the new ID
412 of the previous commit in the chain."""
413 repo = _init_repo(tmp_path)
414 self._build_chain(repo, 3)
415
416 _invoke(["code", "migrate"], repo)
417
418 new_head = (repo / ".muse" / "refs" / "heads" / "main").read_text().strip()
419 tip = read_commit(repo, new_head)
420 mid = read_commit(repo, tip.parent_commit_id)
421 root = read_commit(repo, mid.parent_commit_id)
422
423 assert root.parent_commit_id is None
424 assert mid.parent_commit_id == root.commit_id
425 assert tip.parent_commit_id == mid.commit_id
426
427
428 # ---------------------------------------------------------------------------
429 # Merge commit
430 # ---------------------------------------------------------------------------
431
432
433 class TestMergeCommit:
434 def test_merge_commit_both_parents_cascaded(self, tmp_path: pathlib.Path) -> None:
435 repo = _init_repo(tmp_path)
436 sid = _snap(repo)
437
438 root_id = _write_v1_commit_raw(repo, "root", sid)
439
440 sid_a = _snap(repo, "a")
441 a_id = _write_v1_commit_raw(repo, "feat-a", sid_a, branch="feat/a", parent=root_id)
442
443 sid_b = _snap(repo, "b")
444 b_id = _write_v1_commit_raw(repo, "feat-b", sid_b, branch="feat/b", parent=root_id)
445
446 sid_m = _snap(repo, "m")
447 new_root_id = _v1_commit_id([], sid, "root", _AT_ISO)
448 new_a_id = _v1_commit_id([root_id], sid_a, "feat-a", _AT_ISO)
449 new_b_id = _v1_commit_id([root_id], sid_b, "feat-b", _AT_ISO)
450 merge_id_raw = _v1_commit_id([a_id, b_id], sid_m, "merge", _AT_ISO)
451
452 merge_record = {
453 "commit_id": merge_id_raw,
454 "repo_id": _REPO_ID,
455 "created_on_branch": "main",
456 "snapshot_id": sid_m,
457 "message": "merge",
458 "committed_at": _AT_ISO,
459 "parent_commit_id": a_id,
460 "parent2_commit_id": b_id,
461 "author": _AUTHOR,
462 "metadata": {},
463 "structured_delta": None,
464 "sem_ver_bump": "none",
465 "breaking_changes": [],
466 "agent_id": "",
467 "model_id": "",
468 "toolchain_id": "",
469 "prompt_hash": "",
470 "signature": "",
471 "signer_public_key": "",
472 "signer_key_id": "",
473 "reviewed_by": [],
474 "test_runs": 0,
475 "labels": [],
476 "status": "",
477 "notes": [],
478 "score": None,
479 }
480 algo, hex_id = split_id(merge_id_raw)
481 dest = repo / ".muse" / "commits" / algo / f"{hex_id}.msgpack"
482 dest.write_bytes(msgpack.packb(merge_record, use_bin_type=True))
483
484 _set_ref(repo, "main", merge_id_raw)
485 _set_ref(repo, "feat/a", a_id)
486 _set_ref(repo, "feat/b", b_id)
487
488 _invoke(["code", "migrate"], repo)
489
490 new_main = (repo / ".muse" / "refs" / "heads" / "main").read_text().strip()
491 merge_rec = read_commit(repo, new_main)
492 assert merge_rec is not None
493 assert merge_rec.parent_commit_id is not None
494 assert merge_rec.parent2_commit_id is not None
495 p1 = read_commit(repo, merge_rec.parent_commit_id)
496 p2 = read_commit(repo, merge_rec.parent2_commit_id)
497 assert p1 is not None
498 assert p2 is not None
499
500 def test_merge_commit_old_file_deleted(self, tmp_path: pathlib.Path) -> None:
501 repo = _init_repo(tmp_path)
502 sid = _snap(repo)
503 root_id = _write_v1_commit_raw(repo, "root", sid)
504 sid2 = _snap(repo, "s2")
505 c2_id = _write_v1_commit_raw(repo, "c2", sid2, parent=root_id)
506 sid3 = _snap(repo, "s3")
507 c3_id = _write_v1_commit_raw(repo, "c3", sid3, parent=root_id)
508 sid_m = _snap(repo, "sm")
509 merge_id = _v1_commit_id([c2_id, c3_id], sid_m, "merge", _AT_ISO)
510 rec = {
511 "commit_id": merge_id, "repo_id": _REPO_ID,
512 "created_on_branch": "main", "snapshot_id": sid_m,
513 "message": "merge", "committed_at": _AT_ISO,
514 "parent_commit_id": c2_id, "parent2_commit_id": c3_id,
515 "author": _AUTHOR, "metadata": {}, "structured_delta": None,
516 "sem_ver_bump": "none", "breaking_changes": [], "agent_id": "",
517 "model_id": "", "toolchain_id": "", "prompt_hash": "",
518 "signature": "", "signer_public_key": "", "signer_key_id": "",
519 "reviewed_by": [], "test_runs": 0, "labels": [], "status": "",
520 "notes": [], "score": None,
521 }
522 dest = repo / ".muse" / "commits" / "sha256" / f"{split_id(merge_id)[1]}.msgpack"
523 dest.write_bytes(msgpack.packb(rec, use_bin_type=True))
524 _set_ref(repo, "main", merge_id)
525
526 _invoke(["code", "migrate"], repo)
527
528 assert not commit_path(repo, merge_id).exists()
529
530
531 # ---------------------------------------------------------------------------
532 # Branch heads
533 # ---------------------------------------------------------------------------
534
535
536 class TestBranchHeads:
537 def test_all_branch_heads_updated(self, tmp_path: pathlib.Path) -> None:
538 repo = _init_repo(tmp_path)
539 sid = _snap(repo)
540 root_id = _write_v1_commit_raw(repo, "root", sid, branch="main")
541 _set_ref(repo, "main", root_id)
542
543 sid2 = _snap(repo, "s2")
544 feat_id = _write_v1_commit_raw(repo, "feat", sid2, branch="feat/x", parent=root_id)
545 _set_ref(repo, "feat/x", feat_id)
546
547 _invoke(["code", "migrate"], repo)
548
549 heads = get_all_branch_heads(repo)
550 for branch, cid in heads.items():
551 rec = read_commit(repo, cid)
552 assert rec is not None, f"Branch {branch!r} head {cid[:12]} unreadable after migrate"
553
554 def test_branch_heads_point_to_v2_ids(self, tmp_path: pathlib.Path) -> None:
555 repo = _init_repo(tmp_path)
556 sid = _snap(repo)
557 old_id = _write_v1_commit_raw(repo, "root", sid)
558 _set_ref(repo, "main", old_id)
559
560 _invoke(["code", "migrate"], repo)
561
562 new_head = (repo / ".muse" / "refs" / "heads" / "main").read_text().strip()
563 assert new_head != old_id
564 expected = compute_commit_id(
565 parent_ids=[], snapshot_id=sid, message="root",
566 committed_at_iso=_AT_ISO, repo_id=_REPO_ID,
567 author=_AUTHOR, signer_public_key="",
568 )
569 assert new_head == expected
570
571
572 # ---------------------------------------------------------------------------
573 # Signature normalisation
574 # ---------------------------------------------------------------------------
575
576
577 class TestSignatureNormalisation:
578 def _fake_pubkey(self) -> str:
579 import base64
580 return "ed25519:" + base64.urlsafe_b64encode(b"\x01" * 32).rstrip(b"=").decode()
581
582 def _fake_sig_bytes(self) -> bytes:
583 return b"\x02" * 64
584
585 def test_bare_base64_sig_normalised(self, tmp_path: pathlib.Path) -> None:
586 import base64
587 repo = _init_repo(tmp_path)
588 sid = _snap(repo)
589 bare_sig = base64.urlsafe_b64encode(self._fake_sig_bytes()).rstrip(b"=").decode()
590 pubkey = self._fake_pubkey()
591 old_id = _write_v1_commit_raw(
592 repo, "signed", sid,
593 signature=bare_sig,
594 signer_public_key=pubkey,
595 )
596 _set_ref(repo, "main", old_id)
597
598 _invoke(["code", "migrate"], repo)
599
600 new_head = (repo / ".muse" / "refs" / "heads" / "main").read_text().strip()
601 rec = read_commit(repo, new_head)
602 assert rec is not None
603 assert rec.signature.startswith("ed25519:"), (
604 f"Expected 'ed25519:' prefix on signature, got: {rec.signature!r}"
605 )
606
607 def test_already_prefixed_sig_unchanged(self, tmp_path: pathlib.Path) -> None:
608 import base64
609 repo = _init_repo(tmp_path)
610 sid = _snap(repo)
611 prefixed_sig = "ed25519:" + base64.urlsafe_b64encode(self._fake_sig_bytes()).rstrip(b"=").decode()
612 pubkey = self._fake_pubkey()
613 old_id = _write_v1_commit_raw(
614 repo, "signed", sid,
615 signature=prefixed_sig,
616 signer_public_key=pubkey,
617 )
618 _set_ref(repo, "main", old_id)
619
620 _invoke(["code", "migrate"], repo)
621
622 new_head = (repo / ".muse" / "refs" / "heads" / "main").read_text().strip()
623 rec = read_commit(repo, new_head)
624 assert rec is not None
625 assert rec.signature == prefixed_sig
626
627 def test_empty_sig_not_modified(self, tmp_path: pathlib.Path) -> None:
628 repo = _init_repo(tmp_path)
629 sid = _snap(repo)
630 old_id = _write_v1_commit_raw(repo, "unsigned", sid, signature="")
631 _set_ref(repo, "main", old_id)
632
633 _invoke(["code", "migrate"], repo)
634
635 new_head = (repo / ".muse" / "refs" / "heads" / "main").read_text().strip()
636 rec = read_commit(repo, new_head)
637 assert rec is not None
638 assert rec.signature == ""
639
640
641 # ---------------------------------------------------------------------------
642 # Idempotency
643 # ---------------------------------------------------------------------------
644
645
646 class TestIdempotent:
647 def test_running_twice_same_state(self, tmp_path: pathlib.Path) -> None:
648 repo = _init_repo(tmp_path)
649 sid = _snap(repo)
650 old_id = _write_v1_commit_raw(repo, "root", sid)
651 _set_ref(repo, "main", old_id)
652
653 _invoke(["code", "migrate"], repo)
654 head_after_first = (repo / ".muse" / "refs" / "heads" / "main").read_text().strip()
655 files_after_first = set(
656 p.name for p in (repo / ".muse" / "commits").rglob("*.msgpack")
657 )
658
659 _invoke(["code", "migrate"], repo)
660 head_after_second = (repo / ".muse" / "refs" / "heads" / "main").read_text().strip()
661 files_after_second = set(
662 p.name for p in (repo / ".muse" / "commits").rglob("*.msgpack")
663 )
664
665 assert head_after_first == head_after_second
666 assert files_after_first == files_after_second
667
668
669 # ---------------------------------------------------------------------------
670 # Object path migration (Part B)
671 # ---------------------------------------------------------------------------
672
673
674 class TestObjectPathMigration:
675 def _write_legacy_blob(self, repo: pathlib.Path, content: bytes) -> str:
676 """Write a blob at the legacy path (no sha256/ subdir)."""
677 oid = blob_id(content)
678 hex_id = split_id(oid)[1]
679 prefix, rest = hex_id[:2], hex_id[2:]
680 legacy = repo / ".muse" / "objects" / prefix / rest
681 legacy.parent.mkdir(parents=True, exist_ok=True)
682 legacy.write_bytes(content)
683 return oid
684
685 def test_legacy_blob_moved_to_canonical_path(self, tmp_path: pathlib.Path) -> None:
686 repo = _init_repo(tmp_path)
687 sid = _snap(repo)
688 old_id = _write_v1_commit_raw(repo, "root", sid)
689 _set_ref(repo, "main", old_id)
690 content = b"hello blob"
691 oid = self._write_legacy_blob(repo, content)
692
693 _invoke(["code", "migrate"], repo)
694
695 hex_id = split_id(oid)[1]
696 prefix, rest = hex_id[:2], hex_id[2:]
697 canonical = repo / ".muse" / "objects" / "sha256" / prefix / rest
698 legacy = repo / ".muse" / "objects" / prefix / rest
699 assert canonical.exists(), "Blob must exist at canonical algo-prefixed path"
700 assert canonical.read_bytes() == content
701 assert not legacy.exists(), "Legacy blob path must be deleted after migration"
702
703 def test_legacy_dir_removed_when_empty(self, tmp_path: pathlib.Path) -> None:
704 repo = _init_repo(tmp_path)
705 sid = _snap(repo)
706 old_id = _write_v1_commit_raw(repo, "root", sid)
707 _set_ref(repo, "main", old_id)
708 content = b"only blob in shard"
709 oid = self._write_legacy_blob(repo, content)
710 hex_id = split_id(oid)[1]
711 prefix = hex_id[:2]
712 legacy_dir = repo / ".muse" / "objects" / prefix
713
714 _invoke(["code", "migrate"], repo)
715
716 assert not legacy_dir.exists(), (
717 "Empty legacy shard directory must be removed after migration"
718 )
719
720 def test_blob_already_at_canonical_not_duplicated(self, tmp_path: pathlib.Path) -> None:
721 repo = _init_repo(tmp_path)
722 sid = _snap(repo)
723 old_id = _write_v1_commit_raw(repo, "root", sid)
724 _set_ref(repo, "main", old_id)
725 content = b"already canonical"
726 oid = blob_id(content)
727 hex_id = split_id(oid)[1]
728 prefix, rest = hex_id[:2], hex_id[2:]
729 canonical = repo / ".muse" / "objects" / "sha256" / prefix / rest
730 canonical.parent.mkdir(parents=True, exist_ok=True)
731 canonical.write_bytes(content)
732
733 _invoke(["code", "migrate"], repo)
734
735 assert canonical.exists()
736 assert canonical.read_bytes() == content
737
738 def test_dry_run_reports_blobs_to_migrate(self, tmp_path: pathlib.Path) -> None:
739 repo = _init_repo(tmp_path)
740 sid = _snap(repo)
741 old_id = _write_v1_commit_raw(repo, "root", sid)
742 _set_ref(repo, "main", old_id)
743 self._write_legacy_blob(repo, b"blob1")
744 self._write_legacy_blob(repo, b"blob2")
745
746 result = runner.invoke(
747 cli, ["code", "migrate", "--dry-run", "--json"], env=_env(repo)
748 )
749 assert result.exit_code == 0
750 out = json.loads(result.output)
751 assert out.get("blobs_migrated", -1) == 2
752
753
754 # ---------------------------------------------------------------------------
755 # Legacy field handling
756 # ---------------------------------------------------------------------------
757
758
759 class TestLegacyFields:
760 def test_format_version_in_raw_dict_ignored(self, tmp_path: pathlib.Path) -> None:
761 """Old records with format_version key must still migrate successfully."""
762 repo = _init_repo(tmp_path)
763 sid = _snap(repo)
764 old_id = _write_v1_commit_raw(
765 repo, "root", sid, extra={"format_version": 7}
766 )
767 _set_ref(repo, "main", old_id)
768
769 _invoke(["code", "migrate"], repo)
770
771 new_head = (repo / ".muse" / "refs" / "heads" / "main").read_text().strip()
772 rec = read_commit(repo, new_head)
773 assert rec is not None
774 assert not hasattr(rec, "format_version")
775
776 def test_old_branch_key_in_raw_dict_handled(self, tmp_path: pathlib.Path) -> None:
777 """Records stored with ``branch`` instead of ``created_on_branch`` migrate."""
778 repo = _init_repo(tmp_path)
779 sid = _snap(repo)
780
781 old_id = _v1_commit_id([], sid, "root", _AT_ISO)
782 record = {
783 "commit_id": old_id,
784 "repo_id": _REPO_ID,
785 "branch": "main",
786 "snapshot_id": sid,
787 "message": "root",
788 "committed_at": _AT_ISO,
789 "parent_commit_id": None,
790 "parent2_commit_id": None,
791 "author": _AUTHOR,
792 "metadata": {}, "structured_delta": None,
793 "sem_ver_bump": "none", "breaking_changes": [],
794 "agent_id": "", "model_id": "", "toolchain_id": "",
795 "prompt_hash": "", "signature": "", "signer_public_key": "",
796 "signer_key_id": "", "reviewed_by": [], "test_runs": 0,
797 "labels": [], "status": "", "notes": [], "score": None,
798 }
799 dest = repo / ".muse" / "commits" / "sha256" / f"{split_id(old_id)[1]}.msgpack"
800 dest.write_bytes(msgpack.packb(record, use_bin_type=True))
801 _set_ref(repo, "main", old_id)
802
803 _invoke(["code", "migrate"], repo)
804
805 new_head = (repo / ".muse" / "refs" / "heads" / "main").read_text().strip()
806 rec = read_commit(repo, new_head)
807 assert rec is not None
808 assert rec.created_on_branch == "main"
809
810
811 # ---------------------------------------------------------------------------
812 # JSON output contract
813 # ---------------------------------------------------------------------------
814
815
816 class TestJsonOutput:
817 def test_json_output_has_required_keys(self, tmp_path: pathlib.Path) -> None:
818 repo = _init_repo(tmp_path)
819 sid = _snap(repo)
820 old_id = _write_v1_commit_raw(repo, "root", sid)
821 _set_ref(repo, "main", old_id)
822
823 out = _invoke(["code", "migrate"], repo)
824
825 for key in ("commits_rewritten", "commits_signed", "blobs_migrated", "id_map", "dry_run"):
826 assert key in out, f"Missing key {key!r} in JSON output"
827
828 def test_json_dry_run_flag_is_true(self, tmp_path: pathlib.Path) -> None:
829 repo = _init_repo(tmp_path)
830 sid = _snap(repo)
831 _set_ref(repo, "main", _write_v1_commit_raw(repo, "root", sid))
832 result = runner.invoke(
833 cli, ["code", "migrate", "--dry-run", "--json"], env=_env(repo)
834 )
835 out = json.loads(result.output)
836 assert out["dry_run"] is True
837
838 def test_json_live_run_dry_run_is_false(self, tmp_path: pathlib.Path) -> None:
839 repo = _init_repo(tmp_path)
840 sid = _snap(repo)
841 _set_ref(repo, "main", _write_v1_commit_raw(repo, "root", sid))
842 out = _invoke(["code", "migrate"], repo)
843 assert out["dry_run"] is False
844
845 def test_json_commits_signed_zero_when_unsigned(self, tmp_path: pathlib.Path) -> None:
846 repo = _init_repo(tmp_path)
847 sid = _snap(repo)
848 _set_ref(repo, "main", _write_v1_commit_raw(repo, "root", sid))
849 out = _invoke(["code", "migrate"], repo)
850 assert out["commits_signed"] == 0
851
852
853 # ---------------------------------------------------------------------------
854 # Re-signing (progressive enhancement — full provenance path)
855 # ---------------------------------------------------------------------------
856
857
858 def _generate_test_key() -> "Ed25519PrivateKey":
859 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
860 return Ed25519PrivateKey.generate()
861
862
863 class TestResigning:
864 def test_signed_migrate_produces_valid_signatures(self, tmp_path: pathlib.Path) -> None:
865 from muse.core.migrate import migrate as _migrate
866 from muse.core.provenance import verify_commit_ed25519, provenance_payload
867 from muse.core.provenance import encode_public_key
868 from muse.core._types import decode_pubkey
869
870 repo = _init_repo(tmp_path)
871 sid = _snap(repo)
872 old_id = _write_v1_commit_raw(repo, "root", sid)
873 _set_ref(repo, "main", old_id)
874
875 private_key = _generate_test_key()
876 result = _migrate(repo, dry_run=False, private_key=private_key)
877
878 new_head = (repo / ".muse" / "refs" / "heads" / "main").read_text().strip()
879 rec = read_commit(repo, new_head)
880 assert rec is not None
881 assert rec.signature.startswith("ed25519:"), "signature must have ed25519: prefix"
882
883 payload = provenance_payload(
884 rec.commit_id,
885 author=rec.author,
886 agent_id=rec.agent_id,
887 model_id=rec.model_id,
888 toolchain_id=rec.toolchain_id,
889 prompt_hash=rec.prompt_hash,
890 committed_at=rec.committed_at.isoformat(),
891 )
892 _, pub_bytes = decode_pubkey(rec.signer_public_key)
893 assert verify_commit_ed25519(payload, rec.signature, pub_bytes), (
894 "Re-signed commit must verify against stored signer_public_key"
895 )
896
897 def test_signed_migrate_all_commits_signed(self, tmp_path: pathlib.Path) -> None:
898 from muse.core.migrate import migrate as _migrate
899
900 repo = _init_repo(tmp_path)
901 sid = _snap(repo)
902 root_id = _write_v1_commit_raw(repo, "root", sid)
903 sid2 = _snap(repo, "s2")
904 child_id = _write_v1_commit_raw(repo, "child", sid2, parent=root_id)
905 _set_ref(repo, "main", child_id)
906
907 private_key = _generate_test_key()
908 result = _migrate(repo, dry_run=False, private_key=private_key)
909
910 assert result.commits_signed == 2, (
911 f"Expected 2 commits signed, got {result.commits_signed}"
912 )
913
914 def test_signed_migrate_signer_public_key_bound_in_commit_id(
915 self, tmp_path: pathlib.Path
916 ) -> None:
917 from muse.core.migrate import migrate as _migrate
918 from muse.core.provenance import encode_public_key
919
920 repo = _init_repo(tmp_path)
921 sid = _snap(repo)
922 old_id = _write_v1_commit_raw(repo, "root", sid)
923 _set_ref(repo, "main", old_id)
924
925 private_key = _generate_test_key()
926 result = _migrate(repo, dry_run=False, private_key=private_key)
927
928 new_head = (repo / ".muse" / "refs" / "heads" / "main").read_text().strip()
929 rec = read_commit(repo, new_head)
930 assert rec is not None
931
932 _, expected_pubkey = encode_public_key(private_key)
933 expected_id = compute_commit_id(
934 parent_ids=[],
935 snapshot_id=rec.snapshot_id,
936 message=rec.message,
937 committed_at_iso=rec.committed_at.isoformat(),
938 repo_id=_REPO_ID,
939 author=rec.author,
940 signer_public_key=expected_pubkey,
941 )
942 assert rec.commit_id == expected_id, (
943 "Commit ID must be computed with signer_public_key bound in"
944 )
945
946 def test_dry_run_skips_signing(self, tmp_path: pathlib.Path) -> None:
947 from muse.core.migrate import migrate as _migrate
948
949 repo = _init_repo(tmp_path)
950 sid = _snap(repo)
951 old_id = _write_v1_commit_raw(repo, "root", sid)
952 _set_ref(repo, "main", old_id)
953
954 private_key = _generate_test_key()
955 result = _migrate(repo, dry_run=True, private_key=private_key)
956
957 assert result.commits_signed == 0, "dry-run must not sign anything"
958 assert (repo / ".muse" / "refs" / "heads" / "main").read_text().strip() == old_id
File History 1 commit
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago