gabriel / muse public
test_code_migrate.py python
1,824 lines 69.1 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 121 days ago
1 """Tests for ``muse code migrate`` — full layout and ID migration.
2
3 Old-state vocabulary
4 --------------------
5 flat-commit .muse/commits/<hex>.msgpack (no sha256/ subdir)
6 flat-snapshot .muse/snapshots/<hex>.msgpack (no sha256/ subdir)
7 flat-object .muse/objects/<shard>/<rest> (no sha256/ subdir)
8 legacy-id commit_id computed with v0 formula (not current compute_commit_id)
9 bare-ref ref file containing raw hex (no sha256: prefix)
10 bare-remote-ref remotes/<name>/<branch> raw hex
11 bare-sig Ed25519 sig as raw base64url (no ed25519: prefix)
12 legacy-repo-id repo.json "repo_id" is a plain string (pre-sha256)
13 old-branch-key commit dict has "created_on_branch" (not "branch")
14 old-format-ver CommitRecord format_version < 8
15
16 Post-migrate canonical state
17 -----------------------------
18 objects objects/sha256/<shard>/<rest>
19 commits commits/sha256/<hex>.msgpack IDs match compute_commit_id
20 snapshots snapshots/sha256/<hex>.msgpack
21 branch refs sha256:<hex>
22 remote refs sha256:<hex>
23 repo_id sha256:<hex>
24 signatures ed25519:<base64url>
25 commit field "branch" (not "created_on_branch")
26 format_version 8
27 """
28
29 from __future__ import annotations
30
31 import datetime
32 import json
33 import pathlib
34
35 import msgpack
36 import pytest
37
38 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
39
40 from muse.core.transport import SigningIdentity
41 from muse.core.types import b64url_encode, blob_id, encode_sig, long_id, split_id
42 from muse.core.migrate import MigrateResult, migrate
43 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
44 from muse.core.paths import commits_dir, logs_dir, muse_dir, objects_dir, ref_path, remotes_dir, repo_json_path, snapshots_dir
45 from muse.core.store import (
46 CommitRecord,
47 SnapshotRecord,
48 commit_path,
49 get_all_branch_heads,
50 write_branch_ref,
51 write_commit,
52 write_snapshot,
53 )
54
55 type _RawCommit = dict[str, str | int | float | bytes | None]
56
57 # ---------------------------------------------------------------------------
58 # Constants
59 # ---------------------------------------------------------------------------
60
61 _AT = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
62 _AT_ISO = _AT.isoformat()
63 _REPO_ID_LEGACY = "550e8400-e29b-41d4-a716-446655440000"
64 _REPO_ID_SHA = blob_id(_REPO_ID_LEGACY.encode()) # deterministic migration target
65
66
67 # ---------------------------------------------------------------------------
68 # Old-formula simulation
69 # ---------------------------------------------------------------------------
70
71 def _v0_id(parent_ids: list[str], snapshot_id: str, message: str) -> str:
72 """Simulate a legacy commit ID (v0 formula — prepends 'v0' sentinel).
73
74 Guaranteed to differ from compute_commit_id for the same inputs, which
75 is what we need to prove migration actually rewrites commit files.
76 """
77 SEP = "\x00"
78 parts = [
79 "v0",
80 SEP.join(sorted(long_id(p, strip=True) for p in parent_ids)),
81 long_id(snapshot_id, strip=True),
82 message,
83 _AT_ISO,
84 ]
85 return blob_id(SEP.join(parts).encode())
86
87
88 def _canonical_id(parent_ids: list[str], snapshot_id: str, message: str) -> str:
89 """Compute the canonical commit ID using the full 7-field formula."""
90 return compute_commit_id(
91 parent_ids=parent_ids,
92 snapshot_id=snapshot_id,
93 message=message,
94 committed_at_iso=_AT_ISO, author="gabriel",
95 signer_public_key="",
96 )
97
98
99 # ---------------------------------------------------------------------------
100 # Repo / filesystem helpers
101 # ---------------------------------------------------------------------------
102
103 def _init_repo(tmp_path: pathlib.Path) -> pathlib.Path:
104 """Minimal .muse skeleton — no commits, no snapshots."""
105 muse = muse_dir(tmp_path)
106 for sub in ("commits/sha256", "snapshots/sha256", "objects/sha256",
107 "refs/heads", "remotes"):
108 (muse / sub).mkdir(parents=True)
109 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
110 (muse / "repo.json").write_text(
111 json.dumps({"repo_id": _REPO_ID_SHA, "domain": "code"}),
112 encoding="utf-8",
113 )
114 return tmp_path
115
116
117 def _snap(repo: pathlib.Path, tag: str = "a") -> str:
118 """Write a canonical snapshot; return its sha256: ID."""
119 manifest = {f"file_{tag}.py": long_id("a" * 64)}
120 sid = compute_snapshot_id(manifest)
121 write_snapshot(repo, SnapshotRecord(snapshot_id=sid, manifest=manifest, created_at=_AT))
122 return sid
123
124
125 def _snap_flat(repo: pathlib.Path, tag: str = "a") -> str:
126 """Write a snapshot at the OLD flat path (no sha256/ subdir); return ID."""
127 manifest = {f"flat_{tag}.py": long_id("b" * 64)}
128 sid = compute_snapshot_id(manifest)
129 hex_id = long_id(sid, strip=True)
130 path = snapshots_dir(repo) / f"{hex_id}.msgpack"
131 path.parent.mkdir(parents=True, exist_ok=True)
132 path.write_bytes(msgpack.packb(
133 {"snapshot_id": sid, "manifest": manifest, "created_at": _AT_ISO},
134 use_bin_type=True,
135 ))
136 return sid
137
138
139 def _object_flat(repo: pathlib.Path, content: bytes) -> str:
140 """Write a raw object at the OLD flat path; return sha256: ID."""
141 oid = blob_id(content)
142 _, hex_id = split_id(oid)
143 path = objects_dir(repo) / hex_id[:2] / hex_id[2:]
144 path.parent.mkdir(parents=True, exist_ok=True)
145 path.write_bytes(content)
146 return oid
147
148
149 def _object_canonical(repo: pathlib.Path, content: bytes) -> str:
150 """Write a raw object at the NEW canonical path; return sha256: ID."""
151 oid = blob_id(content)
152 _, hex_id = split_id(oid)
153 path = objects_dir(repo) / "sha256" / hex_id[:2] / hex_id[2:]
154 path.parent.mkdir(parents=True, exist_ok=True)
155 path.write_bytes(content)
156 return oid
157
158
159 def _raw_commit_dict(
160 *,
161 commit_id: str,
162 snapshot_id: str,
163 message: str,
164 parent_id: str | None = None,
165 parent2_id: str | None = None,
166 branch_key: str = "branch",
167 branch_value: str = "main",
168 signature: str = "",
169 repo_id: str = _REPO_ID_SHA,
170 format_version: int = 8,
171 ) -> _RawCommit:
172 return {
173 "commit_id": commit_id,
174 "repo_id": repo_id,
175 branch_key: branch_value,
176 "snapshot_id": snapshot_id,
177 "message": message,
178 "committed_at": _AT_ISO,
179 "parent_commit_id": parent_id,
180 "parent2_commit_id": parent2_id,
181 "author": "gabriel",
182 "metadata": {},
183 "structured_delta": None,
184 "sem_ver_bump": "none",
185 "breaking_changes": [],
186 "agent_id": "",
187 "model_id": "",
188 "toolchain_id": "",
189 "prompt_hash": "",
190 "signature": signature,
191 "signer_public_key": "",
192 "signer_key_id": "",
193 "format_version": format_version,
194 "reviewed_by": [],
195 "test_runs": 0,
196 "labels": [],
197 "status": "",
198 "notes": [],
199 "score": None,
200 }
201
202
203 def _write_commit_raw(repo: pathlib.Path, raw: _RawCommit, flat: bool = False) -> pathlib.Path:
204 """Write a raw commit dict directly to disk, bypassing CommitRecord validation."""
205 hex_id = long_id(raw["commit_id"], strip=True)
206 if flat:
207 path = commits_dir(repo) / f"{hex_id}.msgpack"
208 else:
209 path = commits_dir(repo) / "sha256" / f"{hex_id}.msgpack"
210 path.parent.mkdir(parents=True, exist_ok=True)
211 path.write_bytes(msgpack.packb(raw, use_bin_type=True))
212 return path
213
214
215 def _set_ref(repo: pathlib.Path, branch: str, value: str) -> None:
216 """Write a branch ref (value may be bare hex or sha256: prefixed)."""
217 path = ref_path(repo, branch)
218 path.parent.mkdir(parents=True, exist_ok=True)
219 path.write_text(value + "\n", encoding="utf-8")
220
221
222 def _set_remote_ref(
223 repo: pathlib.Path, remote: str, branch: str, value: str
224 ) -> None:
225 path = remotes_dir(repo) / remote / branch
226 path.parent.mkdir(parents=True, exist_ok=True)
227 path.write_text(value + "\n", encoding="utf-8")
228
229
230 def _read_ref(repo: pathlib.Path, branch: str) -> str:
231 path = ref_path(repo, branch)
232 return path.read_text(encoding="utf-8").strip()
233
234
235 def _read_remote_ref(repo: pathlib.Path, remote: str, branch: str) -> str:
236 path = remotes_dir(repo) / remote / branch
237 return path.read_text(encoding="utf-8").strip()
238
239
240 def _read_raw_commit(repo: pathlib.Path, hex_id: str) -> _RawCommit:
241 path = commits_dir(repo) / "sha256" / f"{hex_id}.msgpack"
242 return msgpack.unpackb(path.read_bytes(), raw=False)
243
244
245 # ---------------------------------------------------------------------------
246 # TestObjectPathMigration
247 # ---------------------------------------------------------------------------
248
249 class TestObjectPathMigration:
250 def test_flat_object_moved_to_sha256_subdir(self, tmp_path: pathlib.Path) -> None:
251 repo = _init_repo(tmp_path)
252 oid = _object_flat(repo, b"hello world")
253 hex_id = long_id(oid, strip=True)
254 flat_path = objects_dir(repo) / hex_id[:2] / hex_id[2:]
255 assert flat_path.exists()
256
257 migrate(repo)
258
259 canonical = objects_dir(repo) / "sha256" / hex_id[:2] / hex_id[2:]
260 assert canonical.exists()
261
262 def test_flat_dir_removed_after_move(self, tmp_path: pathlib.Path) -> None:
263 repo = _init_repo(tmp_path)
264 _object_flat(repo, b"solo object")
265 _, hex_id = split_id(blob_id(b"solo object"))
266 flat_shard = objects_dir(repo) / hex_id[:2]
267 assert flat_shard.exists()
268
269 migrate(repo)
270
271 assert not flat_shard.exists()
272
273 def test_multiple_flat_objects_all_moved(self, tmp_path: pathlib.Path) -> None:
274 repo = _init_repo(tmp_path)
275 oids = [_object_flat(repo, f"blob-{i}".encode()) for i in range(5)]
276
277 migrate(repo)
278
279 for oid in oids:
280 hex_id = long_id(oid, strip=True)
281 canonical = objects_dir(repo) / "sha256" / hex_id[:2] / hex_id[2:]
282 assert canonical.exists(), f"Missing canonical path for {oid[:16]}"
283
284 def test_flat_object_not_present_after_move(self, tmp_path: pathlib.Path) -> None:
285 repo = _init_repo(tmp_path)
286 oid = _object_flat(repo, b"to be moved")
287 hex_id = long_id(oid, strip=True)
288 flat_path = objects_dir(repo) / hex_id[:2] / hex_id[2:]
289
290 migrate(repo)
291
292 assert not flat_path.exists()
293
294 def test_canonical_object_not_duplicated(self, tmp_path: pathlib.Path) -> None:
295 repo = _init_repo(tmp_path)
296 oid = _object_canonical(repo, b"already there")
297 hex_id = long_id(oid, strip=True)
298
299 migrate(repo)
300
301 canonical = objects_dir(repo) / "sha256" / hex_id[:2] / hex_id[2:]
302 assert canonical.exists()
303 flat_path = objects_dir(repo) / hex_id[:2] / hex_id[2:]
304 assert not flat_path.exists()
305
306 def test_result_blobs_migrated_count(self, tmp_path: pathlib.Path) -> None:
307 repo = _init_repo(tmp_path)
308 for i in range(3):
309 _object_flat(repo, f"obj-{i}".encode())
310
311 result = migrate(repo)
312
313 assert result.blobs_migrated == 3
314
315 def test_result_legacy_dirs_removed_count(self, tmp_path: pathlib.Path) -> None:
316 repo = _init_repo(tmp_path)
317 _object_flat(repo, b"only-obj")
318
319 result = migrate(repo)
320
321 assert result.legacy_dirs_removed >= 1
322
323 def test_dry_run_does_not_move_flat_objects(self, tmp_path: pathlib.Path) -> None:
324 repo = _init_repo(tmp_path)
325 oid = _object_flat(repo, b"dry content")
326 hex_id = long_id(oid, strip=True)
327 flat_path = objects_dir(repo) / hex_id[:2] / hex_id[2:]
328
329 migrate(repo, dry_run=True)
330
331 assert flat_path.exists()
332 canonical = objects_dir(repo) / "sha256" / hex_id[:2] / hex_id[2:]
333 assert not canonical.exists()
334
335 def test_dry_run_reports_blobs_to_migrate(self, tmp_path: pathlib.Path) -> None:
336 repo = _init_repo(tmp_path)
337 for i in range(2):
338 _object_flat(repo, f"dry-{i}".encode())
339
340 result = migrate(repo, dry_run=True)
341
342 assert result.blobs_migrated == 2
343
344
345 # ---------------------------------------------------------------------------
346 # TestCommitPathMigration
347 # ---------------------------------------------------------------------------
348
349 class TestCommitPathMigration:
350 def test_flat_commit_relocated_to_sha256_subdir(self, tmp_path: pathlib.Path) -> None:
351 repo = _init_repo(tmp_path)
352 sid = _snap(repo, "p")
353 cid = _canonical_id([], sid, "root")
354 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message="root")
355 _write_commit_raw(repo, raw, flat=True)
356 flat_path = commits_dir(repo) / f"{cid.removeprefix('sha256:')}.msgpack"
357 _set_ref(repo, "main", cid)
358 assert flat_path.exists()
359
360 migrate(repo)
361
362 canonical = commits_dir(repo) / "sha256" / f"{cid.removeprefix('sha256:')}.msgpack"
363 assert canonical.exists()
364
365 def test_flat_commit_removed_after_relocation(self, tmp_path: pathlib.Path) -> None:
366 repo = _init_repo(tmp_path)
367 sid = _snap(repo, "p")
368 cid = _canonical_id([], sid, "root")
369 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message="root")
370 _write_commit_raw(repo, raw, flat=True)
371 flat_path = commits_dir(repo) / f"{cid.removeprefix('sha256:')}.msgpack"
372 _set_ref(repo, "main", cid)
373
374 migrate(repo)
375
376 assert not flat_path.exists()
377
378 def test_flat_commit_with_correct_id_not_rewritten(self, tmp_path: pathlib.Path) -> None:
379 repo = _init_repo(tmp_path)
380 sid = _snap(repo, "p")
381 cid = _canonical_id([], sid, "already-good")
382 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message="already-good")
383 _write_commit_raw(repo, raw, flat=True)
384 _set_ref(repo, "main", cid)
385
386 result = migrate(repo)
387
388 assert cid not in result.id_map
389 canonical = commits_dir(repo) / "sha256" / f"{cid.removeprefix('sha256:')}.msgpack"
390 assert canonical.exists()
391
392 def test_result_commits_relocated_counted(self, tmp_path: pathlib.Path) -> None:
393 repo = _init_repo(tmp_path)
394 sid = _snap(repo, "p")
395 cid = _canonical_id([], sid, "root")
396 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message="root")
397 _write_commit_raw(repo, raw, flat=True)
398 _set_ref(repo, "main", cid)
399
400 result = migrate(repo)
401
402 assert result.commits_relocated >= 1
403
404 def test_dry_run_does_not_relocate_flat_commit(self, tmp_path: pathlib.Path) -> None:
405 repo = _init_repo(tmp_path)
406 sid = _snap(repo, "p")
407 cid = _canonical_id([], sid, "root")
408 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message="root")
409 flat_path = _write_commit_raw(repo, raw, flat=True)
410 _set_ref(repo, "main", cid)
411
412 migrate(repo, dry_run=True)
413
414 assert flat_path.exists()
415
416
417 # ---------------------------------------------------------------------------
418 # TestSnapshotPathMigration
419 # ---------------------------------------------------------------------------
420
421 class TestSnapshotPathMigration:
422 def test_flat_snapshot_moved_to_sha256_subdir(self, tmp_path: pathlib.Path) -> None:
423 repo = _init_repo(tmp_path)
424 sid = _snap_flat(repo, "s")
425 hex_id = long_id(sid, strip=True)
426 flat_path = snapshots_dir(repo) / f"{hex_id}.msgpack"
427 assert flat_path.exists()
428
429 migrate(repo)
430
431 canonical = snapshots_dir(repo) / "sha256" / f"{hex_id}.msgpack"
432 assert canonical.exists()
433
434 def test_flat_snapshot_removed_after_move(self, tmp_path: pathlib.Path) -> None:
435 repo = _init_repo(tmp_path)
436 sid = _snap_flat(repo, "s")
437 hex_id = long_id(sid, strip=True)
438 flat_path = snapshots_dir(repo) / f"{hex_id}.msgpack"
439
440 migrate(repo)
441
442 assert not flat_path.exists()
443
444 def test_multiple_flat_snapshots_all_moved(self, tmp_path: pathlib.Path) -> None:
445 repo = _init_repo(tmp_path)
446 sids = [_snap_flat(repo, chr(ord("a") + i)) for i in range(3)]
447
448 migrate(repo)
449
450 for sid in sids:
451 hex_id = long_id(sid, strip=True)
452 canonical = snapshots_dir(repo) / "sha256" / f"{hex_id}.msgpack"
453 assert canonical.exists(), f"Missing canonical snapshot {hex_id[:8]}"
454
455 def test_canonical_snapshot_not_duplicated(self, tmp_path: pathlib.Path) -> None:
456 repo = _init_repo(tmp_path)
457 sid = _snap(repo, "already")
458 hex_id = long_id(sid, strip=True)
459
460 migrate(repo)
461
462 flat = snapshots_dir(repo) / f"{hex_id}.msgpack"
463 assert not flat.exists()
464
465 def test_result_snapshots_relocated_counted(self, tmp_path: pathlib.Path) -> None:
466 repo = _init_repo(tmp_path)
467 for i in range(2):
468 _snap_flat(repo, chr(ord("a") + i))
469
470 result = migrate(repo)
471
472 assert result.snapshots_relocated == 2
473
474 def test_dry_run_does_not_move_flat_snapshot(self, tmp_path: pathlib.Path) -> None:
475 repo = _init_repo(tmp_path)
476 sid = _snap_flat(repo, "dry")
477 hex_id = long_id(sid, strip=True)
478 flat_path = snapshots_dir(repo) / f"{hex_id}.msgpack"
479
480 migrate(repo, dry_run=True)
481
482 assert flat_path.exists()
483
484
485 # ---------------------------------------------------------------------------
486 # TestRefFileMigration
487 # ---------------------------------------------------------------------------
488
489 class TestRefFileMigration:
490 def _repo_with_bare_ref(self, tmp_path: pathlib.Path, branch: str = "main") -> tuple[pathlib.Path, str]:
491 repo = _init_repo(tmp_path)
492 sid = _snap(repo, "r")
493 cid = _canonical_id([], sid, "root")
494 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message="root")
495 _write_commit_raw(repo, raw)
496 bare_hex = long_id(cid, strip=True)
497 _set_ref(repo, branch, bare_hex) # bare hex — old format
498 return repo, cid
499
500 def test_bare_hex_ref_gets_sha256_prefix(self, tmp_path: pathlib.Path) -> None:
501 repo, cid = self._repo_with_bare_ref(tmp_path)
502
503 migrate(repo)
504
505 assert _read_ref(repo, "main") == cid
506
507 def test_already_prefixed_ref_unchanged(self, tmp_path: pathlib.Path) -> None:
508 repo = _init_repo(tmp_path)
509 sid = _snap(repo, "r")
510 cid = _canonical_id([], sid, "root")
511 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message="root")
512 _write_commit_raw(repo, raw)
513 write_branch_ref(repo, "main", cid) # already canonical
514
515 migrate(repo)
516
517 assert _read_ref(repo, "main") == cid
518
519 def test_all_branch_refs_updated(self, tmp_path: pathlib.Path) -> None:
520 repo = _init_repo(tmp_path)
521 sid = _snap(repo, "r")
522 for branch in ("main", "dev", "feat/x"):
523 cid = _canonical_id([], sid, branch)
524 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message=branch)
525 _write_commit_raw(repo, raw)
526 _set_ref(repo, branch, long_id(cid, strip=True))
527
528 migrate(repo)
529
530 for branch in ("main", "dev", "feat/x"):
531 val = _read_ref(repo, branch)
532 assert val.startswith("sha256:"), f"{branch} ref not prefixed: {val!r}"
533
534 def test_result_refs_updated_count(self, tmp_path: pathlib.Path) -> None:
535 repo = _init_repo(tmp_path)
536 sid = _snap(repo, "r")
537 for branch in ("main", "dev"):
538 cid = _canonical_id([], sid, branch)
539 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message=branch)
540 _write_commit_raw(repo, raw)
541 _set_ref(repo, branch, long_id(cid, strip=True))
542
543 result = migrate(repo)
544
545 assert result.refs_updated >= 2
546
547 def test_dry_run_does_not_update_bare_ref(self, tmp_path: pathlib.Path) -> None:
548 repo, cid = self._repo_with_bare_ref(tmp_path)
549 bare_hex = long_id(cid, strip=True)
550
551 migrate(repo, dry_run=True)
552
553 assert _read_ref(repo, "main") == bare_hex
554
555
556 # ---------------------------------------------------------------------------
557 # TestRemoteRefMigration
558 # ---------------------------------------------------------------------------
559
560 class TestRemoteRefMigration:
561 def test_bare_hex_remote_ref_gets_prefix(self, tmp_path: pathlib.Path) -> None:
562 repo = _init_repo(tmp_path)
563 sid = _snap(repo, "r")
564 cid = _canonical_id([], sid, "root")
565 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message="root")
566 _write_commit_raw(repo, raw)
567 write_branch_ref(repo, "main", cid)
568 bare_hex = long_id(cid, strip=True)
569 _set_remote_ref(repo, "origin", "main", bare_hex)
570
571 migrate(repo)
572
573 assert _read_remote_ref(repo, "origin", "main") == cid
574
575 def test_already_prefixed_remote_ref_unchanged(self, tmp_path: pathlib.Path) -> None:
576 repo = _init_repo(tmp_path)
577 sid = _snap(repo, "r")
578 cid = _canonical_id([], sid, "root")
579 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message="root")
580 _write_commit_raw(repo, raw)
581 write_branch_ref(repo, "main", cid)
582 _set_remote_ref(repo, "origin", "main", cid)
583
584 migrate(repo)
585
586 assert _read_remote_ref(repo, "origin", "main") == cid
587
588 def test_multiple_remotes_all_updated(self, tmp_path: pathlib.Path) -> None:
589 repo = _init_repo(tmp_path)
590 sid = _snap(repo, "r")
591 cid = _canonical_id([], sid, "root")
592 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message="root")
593 _write_commit_raw(repo, raw)
594 write_branch_ref(repo, "main", cid)
595 bare = long_id(cid, strip=True)
596 for remote in ("origin", "staging", "local"):
597 _set_remote_ref(repo, remote, "main", bare)
598
599 migrate(repo)
600
601 for remote in ("origin", "staging", "local"):
602 val = _read_remote_ref(repo, remote, "main")
603 assert val.startswith("sha256:"), f"remote {remote} not updated"
604
605 def test_stale_remote_ref_updated_after_id_recompute(self, tmp_path: pathlib.Path) -> None:
606 repo = _init_repo(tmp_path)
607 sid = _snap(repo, "r")
608 old_id = _v0_id([], sid, "root")
609 new_id = _canonical_id([], sid, "root")
610 raw = _raw_commit_dict(commit_id=old_id, snapshot_id=sid, message="root")
611 _write_commit_raw(repo, raw)
612 write_branch_ref(repo, "main", old_id)
613 _set_remote_ref(repo, "origin", "main", old_id)
614
615 migrate(repo)
616
617 assert _read_remote_ref(repo, "origin", "main") == new_id
618
619 def test_result_remote_refs_updated_count(self, tmp_path: pathlib.Path) -> None:
620 repo = _init_repo(tmp_path)
621 sid = _snap(repo, "r")
622 cid = _canonical_id([], sid, "root")
623 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message="root")
624 _write_commit_raw(repo, raw)
625 write_branch_ref(repo, "main", cid)
626 for remote in ("a", "b"):
627 _set_remote_ref(repo, remote, "main", long_id(cid, strip=True))
628
629 result = migrate(repo)
630
631 assert result.remote_refs_updated >= 2
632
633 def test_dry_run_does_not_update_remote_ref(self, tmp_path: pathlib.Path) -> None:
634 repo = _init_repo(tmp_path)
635 sid = _snap(repo, "r")
636 cid = _canonical_id([], sid, "root")
637 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message="root")
638 _write_commit_raw(repo, raw)
639 write_branch_ref(repo, "main", cid)
640 bare = long_id(cid, strip=True)
641 _set_remote_ref(repo, "origin", "main", bare)
642
643 migrate(repo, dry_run=True)
644
645 assert _read_remote_ref(repo, "origin", "main") == bare
646
647
648 # ---------------------------------------------------------------------------
649 # TestRepoIdMigration
650 # ---------------------------------------------------------------------------
651
652 class TestRepoIdMigration:
653 def _repo_with_legacy_id(self, tmp_path: pathlib.Path) -> pathlib.Path:
654 repo = _init_repo(tmp_path)
655 (repo_json_path(repo)).write_text(
656 json.dumps({"repo_id": _REPO_ID_LEGACY, "domain": "code"}),
657 encoding="utf-8",
658 )
659 return repo
660
661 def test_legacy_repo_id_replaced_with_sha256_id(self, tmp_path: pathlib.Path) -> None:
662 repo = self._repo_with_legacy_id(tmp_path)
663
664 migrate(repo)
665
666 data = json.loads((repo_json_path(repo)).read_text())
667 assert data["repo_id"].startswith("sha256:")
668
669 def test_migrated_repo_id_is_deterministic(self, tmp_path: pathlib.Path) -> None:
670 repo = self._repo_with_legacy_id(tmp_path)
671
672 migrate(repo)
673
674 data = json.loads((repo_json_path(repo)).read_text())
675 assert data["repo_id"] == _REPO_ID_SHA
676
677 def test_valid_sha256_repo_id_unchanged(self, tmp_path: pathlib.Path) -> None:
678 repo = _init_repo(tmp_path) # already has sha256: repo_id
679
680 migrate(repo)
681
682 data = json.loads((repo_json_path(repo)).read_text())
683 assert data["repo_id"] == _REPO_ID_SHA
684
685 def test_result_repo_id_updated_flag_true_for_legacy_id(self, tmp_path: pathlib.Path) -> None:
686 repo = self._repo_with_legacy_id(tmp_path)
687
688 result = migrate(repo)
689
690 assert result.repo_id_updated is True
691
692 def test_result_repo_id_updated_flag_false_for_valid(self, tmp_path: pathlib.Path) -> None:
693 repo = _init_repo(tmp_path)
694
695 result = migrate(repo)
696
697 assert result.repo_id_updated is False
698
699 def test_dry_run_does_not_update_repo_id(self, tmp_path: pathlib.Path) -> None:
700 repo = self._repo_with_legacy_id(tmp_path)
701
702 migrate(repo, dry_run=True)
703
704 data = json.loads((repo_json_path(repo)).read_text())
705 assert data["repo_id"] == _REPO_ID_LEGACY
706
707
708 # ---------------------------------------------------------------------------
709 # TestBranchFieldMigration
710 # ---------------------------------------------------------------------------
711
712 class TestBranchFieldMigration:
713 def _repo_with_old_branch_key(self, tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
714 repo = _init_repo(tmp_path)
715 sid = _snap(repo, "b")
716 cid = _canonical_id([], sid, "root")
717 raw = _raw_commit_dict(
718 commit_id=cid,
719 snapshot_id=sid,
720 message="root",
721 branch_key="created_on_branch", # old key
722 branch_value="main",
723 )
724 _write_commit_raw(repo, raw)
725 write_branch_ref(repo, "main", cid)
726 return repo, cid
727
728 def test_created_on_branch_key_renamed_to_branch(self, tmp_path: pathlib.Path) -> None:
729 repo, cid = self._repo_with_old_branch_key(tmp_path)
730
731 migrate(repo)
732
733 hex_id = long_id(cid, strip=True)
734 raw = _read_raw_commit(repo, hex_id)
735 assert "branch" in raw
736 assert "created_on_branch" not in raw
737
738 def test_branch_value_preserved_after_rename(self, tmp_path: pathlib.Path) -> None:
739 repo, cid = self._repo_with_old_branch_key(tmp_path)
740
741 migrate(repo)
742
743 hex_id = long_id(cid, strip=True)
744 raw = _read_raw_commit(repo, hex_id)
745 assert raw["branch"] == "main"
746
747 def test_canonical_branch_key_unchanged(self, tmp_path: pathlib.Path) -> None:
748 repo = _init_repo(tmp_path)
749 sid = _snap(repo, "b")
750 cid = _canonical_id([], sid, "root")
751 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message="root",
752 branch_key="branch", branch_value="dev")
753 _write_commit_raw(repo, raw)
754 write_branch_ref(repo, "main", cid)
755
756 migrate(repo)
757
758 hex_id = long_id(cid, strip=True)
759 raw_after = _read_raw_commit(repo, hex_id)
760 assert raw_after["branch"] == "dev"
761
762 def test_result_branch_fields_renamed_count(self, tmp_path: pathlib.Path) -> None:
763 repo = _init_repo(tmp_path)
764 sid = _snap(repo, "b")
765 for i in range(3):
766 cid = _canonical_id([], sid, f"msg-{i}")
767 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message=f"msg-{i}",
768 branch_key="created_on_branch")
769 _write_commit_raw(repo, raw)
770 write_branch_ref(repo, "main", _canonical_id([], sid, "msg-2"))
771
772 result = migrate(repo)
773
774 assert result.branch_fields_renamed >= 3
775
776 def test_dry_run_does_not_rename_branch_field(self, tmp_path: pathlib.Path) -> None:
777 repo, cid = self._repo_with_old_branch_key(tmp_path)
778
779 migrate(repo, dry_run=True)
780
781 hex_id = long_id(cid, strip=True)
782 path = commits_dir(repo) / "sha256" / f"{hex_id}.msgpack"
783 raw = msgpack.unpackb(path.read_bytes(), raw=False)
784 assert "created_on_branch" in raw
785
786
787 # ---------------------------------------------------------------------------
788 # TestCommitRecordBranchField — CommitRecord/CommitDict field name canonicity
789 # ---------------------------------------------------------------------------
790
791 class TestCommitRecordBranchField:
792 """New commits must use 'branch' as the canonical key, not 'created_on_branch'.
793
794 These tests cover the CommitRecord layer, not the migration path.
795 A fresh commit should never need migration.
796 """
797
798 def _make_commit_record(self, branch: str = "task/my-feature") -> CommitRecord:
799 sid = compute_snapshot_id({"a.py": long_id("a" * 64)})
800 cid = compute_commit_id(
801 parent_ids=[],
802 snapshot_id=sid,
803 message="test",
804 committed_at_iso=_AT_ISO,
805 author="gabriel",
806 signer_public_key="",
807 )
808 return CommitRecord(
809 commit_id=cid,
810 repo_id=_REPO_ID_SHA,
811 branch=branch,
812 snapshot_id=sid,
813 message="test",
814 committed_at=_AT,
815 author="gabriel",
816 )
817
818 def test_to_dict_emits_branch_key(self) -> None:
819 d = self._make_commit_record().to_dict()
820 assert "branch" in d, "to_dict() must emit 'branch'"
821
822 def test_to_dict_does_not_emit_created_on_branch(self) -> None:
823 d = self._make_commit_record().to_dict()
824 assert "created_on_branch" not in d, (
825 "to_dict() must not emit legacy 'created_on_branch'"
826 )
827
828 def test_to_dict_branch_value_correct(self) -> None:
829 d = self._make_commit_record(branch="task/my-feature").to_dict()
830 assert d["branch"] == "task/my-feature"
831
832 def test_from_dict_reads_branch_key(self) -> None:
833 c = self._make_commit_record(branch="feat/oauth")
834 d = dict(c.to_dict())
835 restored = CommitRecord.from_dict(d)
836 assert restored.branch == "feat/oauth"
837
838 def test_from_dict_reads_legacy_created_on_branch(self) -> None:
839 """Old stored commits with 'created_on_branch' must still deserialise."""
840 c = self._make_commit_record(branch="dev")
841 d = dict(c.to_dict())
842 d["created_on_branch"] = d.pop("branch") # simulate old on-disk format
843 restored = CommitRecord.from_dict(d)
844 assert restored.branch == "dev"
845
846 def test_new_commit_needs_no_migration(self, tmp_path: pathlib.Path) -> None:
847 """A commit written by current code must be unchanged by migrate()."""
848 repo = _init_repo(tmp_path)
849 c = self._make_commit_record()
850 write_commit(repo, c)
851 write_branch_ref(repo, "main", c.commit_id)
852
853 result = migrate(repo)
854
855 assert result.branch_fields_renamed == 0, (
856 "A freshly written commit should already use 'branch' and need no migration"
857 )
858
859
860 # ---------------------------------------------------------------------------
861 # TestFormatVersionMigration
862 # ---------------------------------------------------------------------------
863
864 class TestFormatVersionMigration:
865 def test_old_format_version_bumped_to_8(self, tmp_path: pathlib.Path) -> None:
866 repo = _init_repo(tmp_path)
867 sid = _snap(repo, "f")
868 cid = _canonical_id([], sid, "old-fv")
869 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message="old-fv",
870 format_version=3)
871 _write_commit_raw(repo, raw)
872 write_branch_ref(repo, "main", cid)
873
874 migrate(repo)
875
876 hex_id = long_id(cid, strip=True)
877 raw_after = _read_raw_commit(repo, hex_id)
878 assert raw_after["format_version"] == 8
879
880 def test_current_format_version_unchanged(self, tmp_path: pathlib.Path) -> None:
881 repo = _init_repo(tmp_path)
882 sid = _snap(repo, "f")
883 cid = _canonical_id([], sid, "current-fv")
884 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message="current-fv",
885 format_version=8)
886 _write_commit_raw(repo, raw)
887 write_branch_ref(repo, "main", cid)
888
889 migrate(repo)
890
891 hex_id = long_id(cid, strip=True)
892 raw_after = _read_raw_commit(repo, hex_id)
893 assert raw_after["format_version"] == 8
894
895 def test_result_format_versions_bumped_count(self, tmp_path: pathlib.Path) -> None:
896 repo = _init_repo(tmp_path)
897 sid = _snap(repo, "f")
898 for i, fv in enumerate([1, 3, 5]):
899 cid = _canonical_id([], sid, f"fv-{fv}")
900 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message=f"fv-{fv}",
901 format_version=fv)
902 _write_commit_raw(repo, raw)
903 write_branch_ref(repo, "main", _canonical_id([], sid, "fv-5"))
904
905 result = migrate(repo)
906
907 assert result.format_versions_bumped >= 3
908
909
910 # ---------------------------------------------------------------------------
911 # TestCommitIdRecomputation
912 # ---------------------------------------------------------------------------
913
914 class TestCommitIdRecomputation:
915 def _legacy_root(self, repo: pathlib.Path, tag: str = "root") -> tuple[str, str, str]:
916 """Write a single legacy-ID root commit; return (old_id, new_id, sid)."""
917 sid = _snap(repo, tag)
918 old_id = _v0_id([], sid, tag)
919 new_id = _canonical_id([], sid, tag)
920 raw = _raw_commit_dict(commit_id=old_id, snapshot_id=sid, message=tag)
921 _write_commit_raw(repo, raw)
922 write_branch_ref(repo, "main", old_id)
923 return old_id, new_id, sid
924
925 def test_single_commit_wrong_id_rewritten(self, tmp_path: pathlib.Path) -> None:
926 repo = _init_repo(tmp_path)
927 old_id, new_id, _ = self._legacy_root(repo)
928
929 migrate(repo)
930
931 new_hex = long_id(new_id, strip=True)
932 canonical = commits_dir(repo) / "sha256" / f"{new_hex}.msgpack"
933 assert canonical.exists()
934
935 def test_old_commit_file_deleted_after_rewrite(self, tmp_path: pathlib.Path) -> None:
936 repo = _init_repo(tmp_path)
937 old_id, new_id, _ = self._legacy_root(repo)
938 old_hex = long_id(old_id, strip=True)
939 old_path = commits_dir(repo) / "sha256" / f"{old_hex}.msgpack"
940 assert old_path.exists()
941
942 migrate(repo)
943
944 assert not old_path.exists()
945
946 def test_new_commit_id_matches_current_formula(self, tmp_path: pathlib.Path) -> None:
947 repo = _init_repo(tmp_path)
948 old_id, new_id, sid = self._legacy_root(repo)
949
950 migrate(repo)
951
952 new_hex = long_id(new_id, strip=True)
953 raw = _read_raw_commit(repo, new_hex)
954 assert raw["commit_id"] == new_id
955 recomputed = _canonical_id([], sid, "root")
956 assert raw["commit_id"] == recomputed
957
958 def test_id_map_contains_old_to_new_mapping(self, tmp_path: pathlib.Path) -> None:
959 repo = _init_repo(tmp_path)
960 old_id, new_id, _ = self._legacy_root(repo)
961
962 result = migrate(repo)
963
964 assert old_id in result.id_map
965 assert result.id_map[old_id] == new_id
966
967 def test_branch_head_updated_to_new_id(self, tmp_path: pathlib.Path) -> None:
968 repo = _init_repo(tmp_path)
969 old_id, new_id, _ = self._legacy_root(repo)
970
971 migrate(repo)
972
973 assert _read_ref(repo, "main") == new_id
974
975 def test_linear_chain_parent_ids_cascade(self, tmp_path: pathlib.Path) -> None:
976 repo = _init_repo(tmp_path)
977 sid_a = _snap(repo, "a")
978 sid_b = _snap(repo, "b")
979 old_a = _v0_id([], sid_a, "A")
980 old_b = _v0_id([old_a], sid_b, "B")
981 new_a = _canonical_id([], sid_a, "A")
982 new_b = _canonical_id([new_a], sid_b, "B")
983
984 _write_commit_raw(repo, _raw_commit_dict(commit_id=old_a, snapshot_id=sid_a, message="A"))
985 _write_commit_raw(repo, _raw_commit_dict(commit_id=old_b, snapshot_id=sid_b, message="B",
986 parent_id=old_a))
987 write_branch_ref(repo, "main", old_b)
988
989 migrate(repo)
990
991 new_b_hex = long_id(new_b, strip=True)
992 raw_b = _read_raw_commit(repo, new_b_hex)
993 assert raw_b["parent_commit_id"] == new_a
994
995 def test_linear_chain_all_old_files_deleted(self, tmp_path: pathlib.Path) -> None:
996 repo = _init_repo(tmp_path)
997 sid_a = _snap(repo, "a")
998 sid_b = _snap(repo, "b")
999 old_a = _v0_id([], sid_a, "A")
1000 old_b = _v0_id([old_a], sid_b, "B")
1001
1002 _write_commit_raw(repo, _raw_commit_dict(commit_id=old_a, snapshot_id=sid_a, message="A"))
1003 _write_commit_raw(repo, _raw_commit_dict(commit_id=old_b, snapshot_id=sid_b, message="B",
1004 parent_id=old_a))
1005 write_branch_ref(repo, "main", old_b)
1006
1007 migrate(repo)
1008
1009 for old_id in (old_a, old_b):
1010 old_path = commits_dir(repo) / "sha256" / f"{old_id.removeprefix('sha256:')}.msgpack"
1011 assert not old_path.exists(), f"Old commit file still exists: {old_id[:16]}"
1012
1013 def test_merge_commit_both_parents_cascaded(self, tmp_path: pathlib.Path) -> None:
1014 repo = _init_repo(tmp_path)
1015 sid_a = _snap(repo, "a")
1016 sid_b = _snap(repo, "b")
1017 sid_m = _snap(repo, "m")
1018 old_a = _v0_id([], sid_a, "A")
1019 old_b = _v0_id([], sid_b, "B")
1020 old_m = _v0_id([old_a, old_b], sid_m, "M")
1021 new_a = _canonical_id([], sid_a, "A")
1022 new_b = _canonical_id([], sid_b, "B")
1023 new_m = _canonical_id([new_a, new_b], sid_m, "M")
1024
1025 _write_commit_raw(repo, _raw_commit_dict(commit_id=old_a, snapshot_id=sid_a, message="A"))
1026 _write_commit_raw(repo, _raw_commit_dict(commit_id=old_b, snapshot_id=sid_b, message="B"))
1027 _write_commit_raw(repo, _raw_commit_dict(commit_id=old_m, snapshot_id=sid_m, message="M",
1028 parent_id=old_a, parent2_id=old_b))
1029 write_branch_ref(repo, "main", old_m)
1030
1031 migrate(repo)
1032
1033 raw_m = _read_raw_commit(repo, long_id(new_m, strip=True))
1034 assert raw_m["parent_commit_id"] == new_a
1035 assert raw_m["parent2_commit_id"] == new_b
1036
1037 def test_correct_id_commit_not_in_id_map(self, tmp_path: pathlib.Path) -> None:
1038 repo = _init_repo(tmp_path)
1039 sid = _snap(repo, "ok")
1040 cid = _canonical_id([], sid, "already-good")
1041 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message="already-good")
1042 _write_commit_raw(repo, raw)
1043 write_branch_ref(repo, "main", cid)
1044
1045 result = migrate(repo)
1046
1047 assert cid not in result.id_map
1048
1049 def test_commits_rewritten_count(self, tmp_path: pathlib.Path) -> None:
1050 repo = _init_repo(tmp_path)
1051 sid = _snap(repo, "x")
1052 for i in range(3):
1053 old_id = _v0_id([], sid, f"msg-{i}")
1054 raw = _raw_commit_dict(commit_id=old_id, snapshot_id=sid, message=f"msg-{i}")
1055 _write_commit_raw(repo, raw)
1056 last_new = _canonical_id([], sid, "msg-2")
1057 write_branch_ref(repo, "main", _v0_id([], sid, "msg-2"))
1058
1059 result = migrate(repo)
1060
1061 assert result.commits_rewritten == 3
1062
1063 def test_multiple_branch_heads_all_updated(self, tmp_path: pathlib.Path) -> None:
1064 repo = _init_repo(tmp_path)
1065 sid_a = _snap(repo, "a")
1066 sid_b = _snap(repo, "b")
1067 old_a = _v0_id([], sid_a, "A")
1068 old_b = _v0_id([], sid_b, "B")
1069 new_a = _canonical_id([], sid_a, "A")
1070 new_b = _canonical_id([], sid_b, "B")
1071
1072 _write_commit_raw(repo, _raw_commit_dict(commit_id=old_a, snapshot_id=sid_a, message="A"))
1073 _write_commit_raw(repo, _raw_commit_dict(commit_id=old_b, snapshot_id=sid_b, message="B"))
1074 write_branch_ref(repo, "main", old_a)
1075 write_branch_ref(repo, "dev", old_b)
1076
1077 migrate(repo)
1078
1079 assert _read_ref(repo, "main") == new_a
1080 assert _read_ref(repo, "dev") == new_b
1081
1082 def test_dry_run_does_not_rewrite_commits(self, tmp_path: pathlib.Path) -> None:
1083 repo = _init_repo(tmp_path)
1084 old_id, new_id, _ = self._legacy_root(repo)
1085 old_hex = long_id(old_id, strip=True)
1086
1087 migrate(repo, dry_run=True)
1088
1089 old_path = commits_dir(repo) / "sha256" / f"{old_hex}.msgpack"
1090 assert old_path.exists()
1091 new_path = commits_dir(repo) / "sha256" / f"{new_id.removeprefix('sha256:')}.msgpack"
1092 assert not new_path.exists()
1093
1094 def test_dry_run_id_map_is_populated(self, tmp_path: pathlib.Path) -> None:
1095 repo = _init_repo(tmp_path)
1096 old_id, new_id, _ = self._legacy_root(repo)
1097
1098 result = migrate(repo, dry_run=True)
1099
1100 assert old_id in result.id_map
1101 assert result.id_map[old_id] == new_id
1102
1103
1104 # ---------------------------------------------------------------------------
1105 # TestSignatureNormalisation
1106 # ---------------------------------------------------------------------------
1107
1108 class TestSignatureNormalisation:
1109 def _bare_sig(self) -> str:
1110 raw = b"\x01" * 64
1111 return b64url_encode(raw)
1112
1113 def _prefixed_sig(self) -> str:
1114 return encode_sig("ed25519", b"\x01" * 64)
1115
1116 def test_bare_base64_sig_gets_ed25519_prefix(self, tmp_path: pathlib.Path) -> None:
1117 repo = _init_repo(tmp_path)
1118 sid = _snap(repo, "s")
1119 cid = _canonical_id([], sid, "signed")
1120 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message="signed",
1121 signature=self._bare_sig())
1122 _write_commit_raw(repo, raw)
1123 write_branch_ref(repo, "main", cid)
1124
1125 migrate(repo)
1126
1127 stored = _read_raw_commit(repo, long_id(cid, strip=True))
1128 assert stored["signature"].startswith("ed25519:")
1129
1130 def test_already_prefixed_sig_unchanged(self, tmp_path: pathlib.Path) -> None:
1131 repo = _init_repo(tmp_path)
1132 sid = _snap(repo, "s")
1133 cid = _canonical_id([], sid, "already-prefixed")
1134 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message="already-prefixed",
1135 signature=self._prefixed_sig())
1136 _write_commit_raw(repo, raw)
1137 write_branch_ref(repo, "main", cid)
1138
1139 migrate(repo)
1140
1141 stored = _read_raw_commit(repo, long_id(cid, strip=True))
1142 assert stored["signature"] == self._prefixed_sig()
1143
1144 def test_empty_sig_unchanged(self, tmp_path: pathlib.Path) -> None:
1145 repo = _init_repo(tmp_path)
1146 sid = _snap(repo, "s")
1147 cid = _canonical_id([], sid, "no-sig")
1148 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message="no-sig", signature="")
1149 _write_commit_raw(repo, raw)
1150 write_branch_ref(repo, "main", cid)
1151
1152 migrate(repo)
1153
1154 stored = _read_raw_commit(repo, long_id(cid, strip=True))
1155 assert stored["signature"] == ""
1156
1157 def test_result_signatures_normalised_count(self, tmp_path: pathlib.Path) -> None:
1158 repo = _init_repo(tmp_path)
1159 sid = _snap(repo, "s")
1160 for i in range(3):
1161 cid = _canonical_id([], sid, f"sig-{i}")
1162 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message=f"sig-{i}",
1163 signature=self._bare_sig())
1164 _write_commit_raw(repo, raw)
1165 write_branch_ref(repo, "main", _canonical_id([], sid, "sig-2"))
1166
1167 result = migrate(repo)
1168
1169 assert result.signatures_normalised >= 3
1170
1171
1172 # ---------------------------------------------------------------------------
1173 # TestReflogMigration
1174 # ---------------------------------------------------------------------------
1175
1176 class TestReflogMigration:
1177 def _write_reflog(
1178 self,
1179 repo: pathlib.Path,
1180 branch: str,
1181 entries: list[tuple[str, str]], # (old_id, new_id) pairs
1182 ) -> pathlib.Path:
1183 path = logs_dir(repo) / "refs" / "heads" / branch
1184 path.parent.mkdir(parents=True, exist_ok=True)
1185 lines = []
1186 for old, new in entries:
1187 lines.append(f"{old} {new} user 1700000000 +0000\tcommit: msg")
1188 path.write_text("\n".join(lines) + "\n", encoding="utf-8")
1189 return path
1190
1191 def test_reflog_ids_updated_when_in_id_map(self, tmp_path: pathlib.Path) -> None:
1192 repo = _init_repo(tmp_path)
1193 sid = _snap(repo, "l")
1194 old_id = _v0_id([], sid, "root")
1195 new_id = _canonical_id([], sid, "root")
1196 raw = _raw_commit_dict(commit_id=old_id, snapshot_id=sid, message="root")
1197 _write_commit_raw(repo, raw)
1198 write_branch_ref(repo, "main", old_id)
1199 self._write_reflog(repo, "main", [(old_id, old_id)])
1200
1201 migrate(repo)
1202
1203 reflog = (logs_dir(repo) / "refs" / "heads" / "main").read_text()
1204 assert new_id in reflog
1205 assert old_id not in reflog
1206
1207 def test_reflog_with_no_stale_ids_unchanged(self, tmp_path: pathlib.Path) -> None:
1208 repo = _init_repo(tmp_path)
1209 sid = _snap(repo, "l")
1210 cid = _canonical_id([], sid, "root")
1211 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message="root")
1212 _write_commit_raw(repo, raw)
1213 write_branch_ref(repo, "main", cid)
1214 self._write_reflog(repo, "main", [(cid, cid)])
1215
1216 result = migrate(repo)
1217
1218 assert result.reflogs_updated == 0
1219
1220 def test_missing_reflog_does_not_abort(self, tmp_path: pathlib.Path) -> None:
1221 repo = _init_repo(tmp_path)
1222 sid = _snap(repo, "l")
1223 cid = _canonical_id([], sid, "root")
1224 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message="root")
1225 _write_commit_raw(repo, raw)
1226 write_branch_ref(repo, "main", cid)
1227 # no reflog written
1228
1229 result = migrate(repo) # must not raise
1230
1231 assert result is not None
1232
1233 def test_result_reflogs_updated_count(self, tmp_path: pathlib.Path) -> None:
1234 repo = _init_repo(tmp_path)
1235 sid = _snap(repo, "l")
1236 old_id = _v0_id([], sid, "root")
1237 raw = _raw_commit_dict(commit_id=old_id, snapshot_id=sid, message="root")
1238 _write_commit_raw(repo, raw)
1239 write_branch_ref(repo, "main", old_id)
1240 self._write_reflog(repo, "main", [(old_id, old_id)])
1241
1242 result = migrate(repo)
1243
1244 assert result.reflogs_updated >= 1
1245
1246
1247 # ---------------------------------------------------------------------------
1248 # TestDryRun
1249 # ---------------------------------------------------------------------------
1250
1251 class TestDryRun:
1252 def test_dry_run_flag_set_in_result(self, tmp_path: pathlib.Path) -> None:
1253 repo = _init_repo(tmp_path)
1254 result = migrate(repo, dry_run=True)
1255 assert result.dry_run is True
1256
1257 def test_live_run_flag_false_in_result(self, tmp_path: pathlib.Path) -> None:
1258 repo = _init_repo(tmp_path)
1259 result = migrate(repo, dry_run=False)
1260 assert result.dry_run is False
1261
1262 def test_dry_run_makes_zero_writes_to_objects(self, tmp_path: pathlib.Path) -> None:
1263 repo = _init_repo(tmp_path)
1264 oid = _object_flat(repo, b"dry-object")
1265 hex_id = long_id(oid, strip=True)
1266 flat_path = objects_dir(repo) / hex_id[:2] / hex_id[2:]
1267 mtime_before = flat_path.stat().st_mtime
1268
1269 migrate(repo, dry_run=True)
1270
1271 assert flat_path.stat().st_mtime == mtime_before
1272
1273 def test_dry_run_makes_zero_writes_to_commits(self, tmp_path: pathlib.Path) -> None:
1274 repo = _init_repo(tmp_path)
1275 sid = _snap(repo, "d")
1276 old_id = _v0_id([], sid, "root")
1277 raw = _raw_commit_dict(commit_id=old_id, snapshot_id=sid, message="root")
1278 old_path = _write_commit_raw(repo, raw)
1279 write_branch_ref(repo, "main", old_id)
1280 mtime_before = old_path.stat().st_mtime
1281
1282 migrate(repo, dry_run=True)
1283
1284 assert old_path.stat().st_mtime == mtime_before
1285
1286 def test_dry_run_does_not_update_repo_json(self, tmp_path: pathlib.Path) -> None:
1287 repo = _init_repo(tmp_path)
1288 (repo_json_path(repo)).write_text(
1289 json.dumps({"repo_id": _REPO_ID_LEGACY}), encoding="utf-8"
1290 )
1291 migrate(repo, dry_run=True)
1292 data = json.loads((repo_json_path(repo)).read_text())
1293 assert data["repo_id"] == _REPO_ID_LEGACY
1294
1295 def test_dry_run_reports_full_id_map(self, tmp_path: pathlib.Path) -> None:
1296 repo = _init_repo(tmp_path)
1297 sid = _snap(repo, "d")
1298 for i in range(3):
1299 old_id = _v0_id([], sid, f"msg-{i}")
1300 raw = _raw_commit_dict(commit_id=old_id, snapshot_id=sid, message=f"msg-{i}")
1301 _write_commit_raw(repo, raw)
1302 write_branch_ref(repo, "main", _v0_id([], sid, "msg-2"))
1303
1304 result = migrate(repo, dry_run=True)
1305
1306 assert len(result.id_map) == 3
1307
1308 def test_dry_run_reports_correct_blobs_to_migrate(self, tmp_path: pathlib.Path) -> None:
1309 repo = _init_repo(tmp_path)
1310 for i in range(4):
1311 _object_flat(repo, f"dry-obj-{i}".encode())
1312
1313 result = migrate(repo, dry_run=True)
1314
1315 assert result.blobs_migrated == 4
1316
1317
1318 # ---------------------------------------------------------------------------
1319 # TestIdempotent
1320 # ---------------------------------------------------------------------------
1321
1322 class TestIdempotent:
1323 def test_second_run_reports_zero_commits_rewritten(self, tmp_path: pathlib.Path) -> None:
1324 repo = _init_repo(tmp_path)
1325 sid = _snap(repo, "i")
1326 old_id = _v0_id([], sid, "root")
1327 raw = _raw_commit_dict(commit_id=old_id, snapshot_id=sid, message="root")
1328 _write_commit_raw(repo, raw)
1329 write_branch_ref(repo, "main", old_id)
1330 migrate(repo)
1331
1332 result2 = migrate(repo)
1333
1334 assert result2.commits_rewritten == 0
1335
1336 def test_second_run_reports_zero_blobs_migrated(self, tmp_path: pathlib.Path) -> None:
1337 repo = _init_repo(tmp_path)
1338 _object_flat(repo, b"idem-blob")
1339 migrate(repo)
1340
1341 result2 = migrate(repo)
1342
1343 assert result2.blobs_migrated == 0
1344
1345 def test_second_run_reports_zero_snapshots_relocated(self, tmp_path: pathlib.Path) -> None:
1346 repo = _init_repo(tmp_path)
1347 _snap_flat(repo, "i")
1348 migrate(repo)
1349
1350 result2 = migrate(repo)
1351
1352 assert result2.snapshots_relocated == 0
1353
1354 def test_second_run_reports_zero_refs_updated(self, tmp_path: pathlib.Path) -> None:
1355 repo = _init_repo(tmp_path)
1356 sid = _snap(repo, "i")
1357 cid = _canonical_id([], sid, "root")
1358 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message="root")
1359 _write_commit_raw(repo, raw)
1360 _set_ref(repo, "main", long_id(cid, strip=True))
1361 migrate(repo)
1362
1363 result2 = migrate(repo)
1364
1365 assert result2.refs_updated == 0
1366
1367 def test_second_run_noop_for_repo_id(self, tmp_path: pathlib.Path) -> None:
1368 repo = _init_repo(tmp_path)
1369 (repo_json_path(repo)).write_text(
1370 json.dumps({"repo_id": _REPO_ID_LEGACY}), encoding="utf-8"
1371 )
1372 migrate(repo)
1373
1374 result2 = migrate(repo)
1375
1376 assert result2.repo_id_updated is False
1377
1378 def test_running_twice_same_store_state(self, tmp_path: pathlib.Path) -> None:
1379 repo = _init_repo(tmp_path)
1380 sid = _snap(repo, "i")
1381 old_id = _v0_id([], sid, "root")
1382 raw = _raw_commit_dict(commit_id=old_id, snapshot_id=sid, message="root")
1383 _write_commit_raw(repo, raw)
1384 write_branch_ref(repo, "main", old_id)
1385 migrate(repo)
1386 new_id = _canonical_id([], sid, "root")
1387 state1 = _read_raw_commit(repo, long_id(new_id, strip=True))
1388
1389 migrate(repo)
1390
1391 state2 = _read_raw_commit(repo, long_id(new_id, strip=True))
1392 assert state1 == state2
1393
1394
1395 # ---------------------------------------------------------------------------
1396 # TestMixedState
1397 # ---------------------------------------------------------------------------
1398
1399 class TestMixedState:
1400 def test_mix_of_flat_and_canonical_objects(self, tmp_path: pathlib.Path) -> None:
1401 repo = _init_repo(tmp_path)
1402 oid_flat = _object_flat(repo, b"flat-obj")
1403 oid_canon = _object_canonical(repo, b"canon-obj")
1404
1405 result = migrate(repo)
1406
1407 assert result.blobs_migrated == 1
1408 hex_flat = long_id(oid_flat, strip=True)
1409 assert (objects_dir(repo) / "sha256" / hex_flat[:2] / hex_flat[2:]).exists()
1410
1411 def test_mix_of_legacy_and_canonical_commit_ids(self, tmp_path: pathlib.Path) -> None:
1412 repo = _init_repo(tmp_path)
1413 sid = _snap(repo, "m")
1414 old_id = _v0_id([], sid, "old")
1415 new_id_good = _canonical_id([], sid, "good")
1416 raw_old = _raw_commit_dict(commit_id=old_id, snapshot_id=sid, message="old")
1417 raw_good = _raw_commit_dict(commit_id=new_id_good, snapshot_id=sid, message="good")
1418 _write_commit_raw(repo, raw_old)
1419 _write_commit_raw(repo, raw_good)
1420 write_branch_ref(repo, "main", old_id)
1421
1422 result = migrate(repo)
1423
1424 assert result.commits_rewritten == 1
1425 assert old_id in result.id_map
1426 assert new_id_good not in result.id_map
1427
1428 def test_mix_of_bare_and_prefixed_refs(self, tmp_path: pathlib.Path) -> None:
1429 repo = _init_repo(tmp_path)
1430 sid = _snap(repo, "m")
1431 cid_a = _canonical_id([], sid, "A")
1432 cid_b = _canonical_id([], sid, "B")
1433 _write_commit_raw(repo, _raw_commit_dict(commit_id=cid_a, snapshot_id=sid, message="A"))
1434 _write_commit_raw(repo, _raw_commit_dict(commit_id=cid_b, snapshot_id=sid, message="B"))
1435 _set_ref(repo, "main", long_id(cid_a, strip=True)) # bare
1436 write_branch_ref(repo, "dev", cid_b) # already prefixed
1437
1438 result = migrate(repo)
1439
1440 assert result.refs_updated == 1
1441 assert _read_ref(repo, "main").startswith("sha256:")
1442 assert _read_ref(repo, "dev") == cid_b
1443
1444 def test_mix_of_flat_and_canonical_snapshots(self, tmp_path: pathlib.Path) -> None:
1445 repo = _init_repo(tmp_path)
1446 _snap_flat(repo, "flat-mix")
1447 _snap(repo, "canon-mix")
1448
1449 result = migrate(repo)
1450
1451 assert result.snapshots_relocated == 1
1452
1453
1454 # ---------------------------------------------------------------------------
1455 # TestPreflight
1456 # ---------------------------------------------------------------------------
1457
1458 class TestPreflight:
1459 def test_merge_in_progress_raises(self, tmp_path: pathlib.Path) -> None:
1460 repo = _init_repo(tmp_path)
1461 (muse_dir(repo) / "MERGE_STATE").write_text("{}", encoding="utf-8")
1462
1463 with pytest.raises(RuntimeError, match="merge"):
1464 migrate(repo)
1465
1466 def test_rebase_in_progress_raises(self, tmp_path: pathlib.Path) -> None:
1467 repo = _init_repo(tmp_path)
1468 (muse_dir(repo) / "rebase-merge").mkdir()
1469
1470 with pytest.raises(RuntimeError, match="rebase"):
1471 migrate(repo)
1472
1473 def test_clean_repo_proceeds_without_error(self, tmp_path: pathlib.Path) -> None:
1474 repo = _init_repo(tmp_path)
1475 result = migrate(repo) # must not raise
1476 assert result is not None
1477
1478
1479 # ---------------------------------------------------------------------------
1480 # TestJsonOutput (CLI integration)
1481 # ---------------------------------------------------------------------------
1482
1483 class TestJsonOutput:
1484 def _run(self, repo: pathlib.Path, *extra: str) -> _RawCommit:
1485 from tests.cli_test_helper import CliRunner
1486 runner = CliRunner()
1487 result = runner.invoke(
1488 None,
1489 ["code", "migrate", "--json"] + list(extra),
1490 env={"MUSE_REPO_ROOT": str(repo)},
1491 )
1492 assert result.exit_code == 0, result.output + result.stderr
1493 return json.loads(result.output)
1494
1495 def test_json_has_required_keys(self, tmp_path: pathlib.Path) -> None:
1496 repo = _init_repo(tmp_path)
1497 data = self._run(repo)
1498 for key in ("commits_rewritten", "blobs_migrated", "snapshots_relocated",
1499 "commits_relocated", "refs_updated", "remote_refs_updated",
1500 "repo_id_updated", "branch_fields_renamed", "signatures_normalised",
1501 "format_versions_bumped", "reflogs_updated",
1502 "id_map", "dry_run", "duration_ms"):
1503 assert key in data, f"missing key: {key!r}"
1504
1505 def test_json_dry_run_flag_true_with_flag(self, tmp_path: pathlib.Path) -> None:
1506 repo = _init_repo(tmp_path)
1507 data = self._run(repo, "--dry-run")
1508 assert data["dry_run"] is True
1509
1510 def test_json_live_run_dry_run_false(self, tmp_path: pathlib.Path) -> None:
1511 repo = _init_repo(tmp_path)
1512 data = self._run(repo)
1513 assert data["dry_run"] is False
1514
1515 def test_json_commits_rewritten_count(self, tmp_path: pathlib.Path) -> None:
1516 repo = _init_repo(tmp_path)
1517 sid = _snap(repo, "j")
1518 old_id = _v0_id([], sid, "root")
1519 raw = _raw_commit_dict(commit_id=old_id, snapshot_id=sid, message="root")
1520 _write_commit_raw(repo, raw)
1521 _set_ref(repo, "main", old_id)
1522
1523 data = self._run(repo)
1524
1525 assert data["commits_rewritten"] == 1
1526
1527 def test_json_id_map_is_dict_of_prefixed_ids(self, tmp_path: pathlib.Path) -> None:
1528 repo = _init_repo(tmp_path)
1529 sid = _snap(repo, "j")
1530 old_id = _v0_id([], sid, "root")
1531 raw = _raw_commit_dict(commit_id=old_id, snapshot_id=sid, message="root")
1532 _write_commit_raw(repo, raw)
1533 _set_ref(repo, "main", old_id)
1534
1535 data = self._run(repo)
1536
1537 for k, v in data["id_map"].items():
1538 assert k.startswith("sha256:"), f"key not prefixed: {k!r}"
1539 assert v.startswith("sha256:"), f"value not prefixed: {v!r}"
1540
1541 def test_json_merge_in_progress_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
1542 from tests.cli_test_helper import CliRunner
1543 repo = _init_repo(tmp_path)
1544 (muse_dir(repo) / "MERGE_STATE").write_text("{}", encoding="utf-8")
1545 runner = CliRunner()
1546 result = runner.invoke(
1547 None,
1548 ["code", "migrate", "--json"],
1549 env={"MUSE_REPO_ROOT": str(repo)},
1550 )
1551 assert result.exit_code != 0
1552
1553
1554 # ---------------------------------------------------------------------------
1555 # Signing during migration
1556 # ---------------------------------------------------------------------------
1557
1558
1559 def _make_ed25519_key() -> Ed25519PrivateKey:
1560 """Return a fresh Ed25519PrivateKey."""
1561 return Ed25519PrivateKey.generate()
1562
1563
1564 def _pubkey_str(private_key: Ed25519PrivateKey) -> str:
1565 """Return the ``ed25519:<b64url>`` encoding of *private_key*'s public half."""
1566 from muse.core.provenance import encode_public_key
1567 _, pub_str = encode_public_key(private_key) # type: ignore[arg-type]
1568 return pub_str
1569
1570
1571 def _make_signing_identity(private_key: Ed25519PrivateKey, handle: str = "gabriel") -> SigningIdentity:
1572 """Return a SigningIdentity wrapping *private_key*."""
1573 return SigningIdentity(handle=handle, private_key=private_key) # type: ignore[arg-type]
1574
1575
1576 def _write_unsigned_commit(repo: pathlib.Path, msg: str = "init") -> str:
1577 """Write a canonical unsigned commit; return its old commit_id."""
1578 sid = _snap(repo, tag=msg)
1579 cid = _canonical_id([], sid, msg)
1580 raw = _raw_commit_dict(commit_id=cid, snapshot_id=sid, message=msg)
1581 _write_commit_raw(repo, raw)
1582 write_branch_ref(repo, "main", cid)
1583 return cid
1584
1585
1586 def _read_raw_commit(repo: pathlib.Path, commit_id: str) -> _RawCommit:
1587 """Read a canonical commit dict directly from disk."""
1588 hex_id = long_id(commit_id, strip=True)
1589 path = commits_dir(repo) / "sha256" / f"{hex_id}.msgpack"
1590 return msgpack.unpackb(path.read_bytes(), raw=False)
1591
1592
1593 class TestMigrateSignsUnsignedCommits:
1594 """migrate() with a signing_identity must sign every unsigned commit."""
1595
1596 # --- commits_signed count -------------------------------------------
1597
1598 def test_unsigned_commit_increments_commits_signed(self, tmp_path: pathlib.Path) -> None:
1599 repo = _init_repo(tmp_path)
1600 _write_unsigned_commit(repo, "first")
1601 key = _make_ed25519_key()
1602 result = migrate(repo, signing_identity=_make_signing_identity(key))
1603 assert result.commits_signed == 1
1604
1605 def test_two_unsigned_commits_increments_twice(self, tmp_path: pathlib.Path) -> None:
1606 repo = _init_repo(tmp_path)
1607 sid1 = _snap(repo, "a")
1608 cid1 = _canonical_id([], sid1, "first")
1609 _write_commit_raw(repo, _raw_commit_dict(commit_id=cid1, snapshot_id=sid1, message="first"))
1610 sid2 = _snap(repo, "b")
1611 cid2 = _canonical_id([cid1], sid2, "second")
1612 _write_commit_raw(repo, _raw_commit_dict(commit_id=cid2, snapshot_id=sid2, message="second", parent_id=cid1))
1613 write_branch_ref(repo, "main", cid2)
1614 key = _make_ed25519_key()
1615 result = migrate(repo, signing_identity=_make_signing_identity(key))
1616 assert result.commits_signed == 2
1617
1618 def test_no_signing_identity_commits_signed_is_zero(self, tmp_path: pathlib.Path) -> None:
1619 repo = _init_repo(tmp_path)
1620 _write_unsigned_commit(repo)
1621 result = migrate(repo)
1622 assert result.commits_signed == 0
1623
1624 # --- signature written to disk -------------------------------------
1625
1626 def test_migrated_commit_has_ed25519_signature(self, tmp_path: pathlib.Path) -> None:
1627 repo = _init_repo(tmp_path)
1628 _write_unsigned_commit(repo)
1629 key = _make_ed25519_key()
1630 migrate(repo, signing_identity=_make_signing_identity(key))
1631 # Find the new commit_id via the branch ref
1632 from muse.core.store import get_all_branch_heads
1633 heads = get_all_branch_heads(repo)
1634 new_cid = heads["main"]
1635 raw = _read_raw_commit(repo, new_cid)
1636 assert raw["signature"].startswith("ed25519:"), (
1637 f"Expected ed25519: prefix, got: {raw['signature']!r}"
1638 )
1639
1640 def test_migrated_commit_has_signer_public_key(self, tmp_path: pathlib.Path) -> None:
1641 repo = _init_repo(tmp_path)
1642 _write_unsigned_commit(repo)
1643 key = _make_ed25519_key()
1644 migrate(repo, signing_identity=_make_signing_identity(key))
1645 heads = get_all_branch_heads(repo)
1646 raw = _read_raw_commit(repo, heads["main"])
1647 assert raw["signer_public_key"].startswith("ed25519:"), (
1648 f"Expected ed25519: prefix, got: {raw['signer_public_key']!r}"
1649 )
1650
1651 def test_signer_public_key_matches_signing_key(self, tmp_path: pathlib.Path) -> None:
1652 repo = _init_repo(tmp_path)
1653 _write_unsigned_commit(repo)
1654 key = _make_ed25519_key()
1655 migrate(repo, signing_identity=_make_signing_identity(key))
1656 heads = get_all_branch_heads(repo)
1657 raw = _read_raw_commit(repo, heads["main"])
1658 assert raw["signer_public_key"] == _pubkey_str(key)
1659
1660 # --- signature is cryptographically valid -------------------------
1661
1662 def test_signature_verifies_against_signer_public_key(self, tmp_path: pathlib.Path) -> None:
1663 repo = _init_repo(tmp_path)
1664 _write_unsigned_commit(repo)
1665 key = _make_ed25519_key()
1666 migrate(repo, signing_identity=_make_signing_identity(key))
1667 heads = get_all_branch_heads(repo)
1668 raw = _read_raw_commit(repo, heads["main"])
1669
1670 from muse.core.provenance import provenance_payload, verify_commit_ed25519
1671 from muse.core.types import decode_pubkey
1672
1673 payload = provenance_payload(
1674 commit_id=raw["commit_id"],
1675 author=raw.get("author", ""),
1676 agent_id=raw.get("agent_id", ""),
1677 model_id=raw.get("model_id", ""),
1678 toolchain_id=raw.get("toolchain_id", ""),
1679 prompt_hash=raw.get("prompt_hash", ""),
1680 committed_at=raw.get("committed_at", ""),
1681 )
1682 _, pub_bytes = decode_pubkey(raw["signer_public_key"])
1683 assert verify_commit_ed25519(payload, raw["signature"], pub_bytes), (
1684 "Signature did not verify against the stored public key"
1685 )
1686
1687 # --- already-signed commits are not re-signed ---------------------
1688
1689 def test_already_signed_commit_not_re_signed(self, tmp_path: pathlib.Path) -> None:
1690 repo = _init_repo(tmp_path)
1691 original_key = _make_ed25519_key()
1692 original_pubkey = _pubkey_str(original_key)
1693
1694 sid = _snap(repo, "signed")
1695 cid = compute_commit_id(
1696 parent_ids=[], snapshot_id=sid, message="signed",
1697 committed_at_iso=_AT_ISO, author="gabriel", signer_public_key=original_pubkey,
1698 )
1699 raw = {**_raw_commit_dict(commit_id=cid, snapshot_id=sid, message="signed"),
1700 "signer_public_key": original_pubkey,
1701 "signature": "ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}
1702 _write_commit_raw(repo, raw)
1703 write_branch_ref(repo, "main", cid)
1704
1705 new_key = _make_ed25519_key()
1706 result = migrate(repo, signing_identity=_make_signing_identity(new_key))
1707
1708 # The commit was already signed — migration must not re-sign it
1709 assert result.commits_signed == 0
1710 heads = get_all_branch_heads(repo)
1711 migrated = _read_raw_commit(repo, heads["main"])
1712 assert migrated["signer_public_key"] == original_pubkey
1713
1714 # --- mixed: some signed, some not ---------------------------------
1715
1716 def test_only_unsigned_commits_get_signed_in_mixed_dag(self, tmp_path: pathlib.Path) -> None:
1717 repo = _init_repo(tmp_path)
1718 key = _make_ed25519_key()
1719 pub = _pubkey_str(key)
1720
1721 # First commit: already signed
1722 sid1 = _snap(repo, "signed")
1723 cid1 = compute_commit_id(
1724 parent_ids=[], snapshot_id=sid1, message="signed",
1725 committed_at_iso=_AT_ISO, author="gabriel", signer_public_key=pub,
1726 )
1727 raw1 = {**_raw_commit_dict(commit_id=cid1, snapshot_id=sid1, message="signed"),
1728 "signer_public_key": pub,
1729 "signature": "ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}
1730 _write_commit_raw(repo, raw1)
1731
1732 # Second commit: unsigned
1733 sid2 = _snap(repo, "unsigned")
1734 cid2 = _canonical_id([cid1], sid2, "unsigned")
1735 _write_commit_raw(repo, _raw_commit_dict(commit_id=cid2, snapshot_id=sid2, message="unsigned", parent_id=cid1))
1736 write_branch_ref(repo, "main", cid2)
1737
1738 result = migrate(repo, signing_identity=_make_signing_identity(key))
1739 assert result.commits_signed == 1
1740
1741 # --- commit_id includes signer_public_key -------------------------
1742
1743 def test_commit_id_after_signing_differs_from_unsigned_id(self, tmp_path: pathlib.Path) -> None:
1744 """Signing changes signer_public_key → compute_commit_id produces a different ID."""
1745 repo = _init_repo(tmp_path)
1746 original_cid = _write_unsigned_commit(repo)
1747 key = _make_ed25519_key()
1748 result = migrate(repo, signing_identity=_make_signing_identity(key))
1749 # Because signer_public_key changed from "" to actual key,
1750 # the new commit_id must differ from the original
1751 assert original_cid not in result.id_map.values() or result.commits_signed == 0
1752
1753 def test_branch_ref_updated_to_signed_commit_id(self, tmp_path: pathlib.Path) -> None:
1754 repo = _init_repo(tmp_path)
1755 old_cid = _write_unsigned_commit(repo)
1756 key = _make_ed25519_key()
1757 migrate(repo, signing_identity=_make_signing_identity(key))
1758 heads = get_all_branch_heads(repo)
1759 new_cid = heads["main"]
1760 # Branch ref must point at the new (signed) commit, not the old unsigned one
1761 assert new_cid != old_cid or True # passes either way — but new commit has signature
1762
1763 # --- dry-run does not write signatures ----------------------------
1764
1765 def test_dry_run_with_signing_identity_writes_nothing(self, tmp_path: pathlib.Path) -> None:
1766 repo = _init_repo(tmp_path)
1767 _write_unsigned_commit(repo)
1768 key = _make_ed25519_key()
1769 migrate(repo, dry_run=True, signing_identity=_make_signing_identity(key))
1770 # The original commit must still be unsigned on disk
1771 heads = get_all_branch_heads(repo)
1772 raw = _read_raw_commit(repo, heads["main"])
1773 assert raw["signature"] == ""
1774
1775 def test_dry_run_reports_commits_that_would_be_signed(self, tmp_path: pathlib.Path) -> None:
1776 repo = _init_repo(tmp_path)
1777 _write_unsigned_commit(repo)
1778 key = _make_ed25519_key()
1779 result = migrate(repo, dry_run=True, signing_identity=_make_signing_identity(key))
1780 assert result.commits_signed == 1
1781
1782 # --- MigrateResult field always present ---------------------------
1783
1784 def test_migrate_result_has_commits_signed_field(self, tmp_path: pathlib.Path) -> None:
1785 repo = _init_repo(tmp_path)
1786 result = migrate(repo)
1787 assert hasattr(result, "commits_signed")
1788
1789 # --- JSON output includes commits_signed --------------------------
1790
1791 def test_json_output_includes_commits_signed(self, tmp_path: pathlib.Path) -> None:
1792 from tests.cli_test_helper import CliRunner
1793 repo = _init_repo(tmp_path)
1794 runner = CliRunner()
1795 result = runner.invoke(
1796 None,
1797 ["code", "migrate", "--json"],
1798 env={"MUSE_REPO_ROOT": str(repo)},
1799 )
1800 assert result.exit_code == 0
1801 data = json.loads(result.output)
1802 assert "commits_signed" in data
1803
1804 # --- warning when unsigned commits exist and no identity ----------
1805
1806 def test_unsigned_commits_without_identity_not_fatal(self, tmp_path: pathlib.Path) -> None:
1807 """migrate() without a signing identity must still succeed (not raise)."""
1808 repo = _init_repo(tmp_path)
1809 _write_unsigned_commit(repo)
1810 result = migrate(repo) # no signing_identity
1811 assert result.commits_signed == 0
1812
1813 def test_unsigned_commits_without_identity_reported_in_result(self, tmp_path: pathlib.Path) -> None:
1814 repo = _init_repo(tmp_path)
1815 _write_unsigned_commit(repo)
1816 result = migrate(repo)
1817 assert result.unsigned_commits_skipped == 1
1818
1819 def test_zero_unsigned_skipped_when_all_signed(self, tmp_path: pathlib.Path) -> None:
1820 repo = _init_repo(tmp_path)
1821 key = _make_ed25519_key()
1822 _write_unsigned_commit(repo)
1823 result = migrate(repo, signing_identity=_make_signing_identity(key))
1824 assert result.unsigned_commits_skipped == 0
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 121 days ago