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