"""TDD -- muse#83: unified-store snapshot-content-ID migration. Repairs a class of corruption distinct from muse.core.migrate's existing v1->v2 commit-ID-formula and legacy-directory-layout passes: a commit whose declared snapshot_id never matched its own manifest, since creation. See docs/issues/snapshot-content-id-migration-plan.md for the full background (musehub#134's investigation). Phase 1 tests: migrate_snapshot_content_ids() -- detection + correction at the snapshot layer, sourced from the unified .muse/objects/ store only. """ from __future__ import annotations import json import pathlib import pytest from muse.core.ids import hash_snapshot from muse.core.object_store import object_path from muse.core.paths import muse_dir, objects_dir, snapshots_dir from muse.core.snapshot_content_migrate import migrate_snapshot_content_ids # --------------------------------------------------------------------------- # Fixtures / helpers # --------------------------------------------------------------------------- @pytest.fixture def repo(tmp_path: pathlib.Path) -> pathlib.Path: objects_dir(tmp_path).mkdir(parents=True, exist_ok=True) return tmp_path def _write_unified_snapshot( repo: pathlib.Path, declared_id: str, manifest: dict[str, str], directories: list[str] | None = None, ) -> pathlib.Path: """Write a snapshot object directly to the unified store at the path implied by *declared_id* -- which may or may not actually reproduce the manifest (this is exactly how the real corrupted staging data looks: a file whose own embedded snapshot_id disagrees with a fresh hash_snapshot recomputation of its own manifest).""" payload = json.dumps({ "snapshot_id": declared_id, "manifest": manifest, "directories": directories or [], "created_at": "2026-05-27T00:00:00+00:00", }, separators=(",", ":")).encode() header = f"snapshot {len(payload)}\0".encode() path = object_path(repo, declared_id) path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(header + payload) return path def _read_unified_snapshot(repo: pathlib.Path, object_id: str) -> dict: path = object_path(repo, object_id) data = path.read_bytes() null_idx = data.index(b"\x00") return json.loads(data[null_idx + 1:]) # --------------------------------------------------------------------------- # SC_01 -- a correct snapshot is left completely untouched # --------------------------------------------------------------------------- def test_sc_01_correct_snapshot_produces_no_id_map_entry_and_no_write( repo: pathlib.Path, ) -> None: manifest = {"a.txt": "sha256:" + "1" * 64} correct_id = hash_snapshot(manifest, None) path = _write_unified_snapshot(repo, correct_id, manifest) before_mtime = path.stat().st_mtime_ns before_bytes = path.read_bytes() result = migrate_snapshot_content_ids(repo, dry_run=False) assert correct_id not in result.id_map assert result.snapshots_written == 0 assert path.stat().st_mtime_ns == before_mtime assert path.read_bytes() == before_bytes # --------------------------------------------------------------------------- # SC_02 -- a snapshot whose declared ID doesn't match its manifest is # detected and corrected # --------------------------------------------------------------------------- def test_sc_02_mismatched_snapshot_detected_and_corrected(repo: pathlib.Path) -> None: manifest = {"b.txt": "sha256:" + "2" * 64} wrong_id = "sha256:" + "0" * 64 # deliberately wrong -- like the real staging bug _write_unified_snapshot(repo, wrong_id, manifest) result = migrate_snapshot_content_ids(repo, dry_run=False) correct_id = hash_snapshot(manifest, None) assert result.id_map == {wrong_id: correct_id} assert result.snapshots_written == 1 new_path = object_path(repo, correct_id) assert new_path.exists() corrected = _read_unified_snapshot(repo, correct_id) assert corrected["snapshot_id"] == correct_id assert corrected["manifest"] == manifest # Non-destructive: the old (wrong) object is never deleted. old_path = object_path(repo, wrong_id) assert old_path.exists() # --------------------------------------------------------------------------- # SC_03 -- dry_run computes the id_map but writes nothing # --------------------------------------------------------------------------- def test_sc_03_dry_run_writes_nothing(repo: pathlib.Path) -> None: manifest = {"c.txt": "sha256:" + "3" * 64} wrong_id = "sha256:" + "1" * 64 _write_unified_snapshot(repo, wrong_id, manifest) result = migrate_snapshot_content_ids(repo, dry_run=True) correct_id = hash_snapshot(manifest, None) assert result.id_map == {wrong_id: correct_id} assert result.snapshots_written == 1 # counted... assert not object_path(repo, correct_id).exists() # ...but never written # --------------------------------------------------------------------------- # SC_04 -- the legacy .muse/snapshots/ layout is never read by this function # --------------------------------------------------------------------------- def test_sc_04_legacy_layout_snapshots_are_ignored(repo: pathlib.Path) -> None: """A mismatch that only exists in the legacy layout must produce zero changes -- that's muse.core.migrate.migrate_snapshot_ids's job, and this function must be strictly additive to it, never a replacement.""" import msgpack manifest = {"legacy.txt": "sha256:" + "4" * 64} wrong_id = "sha256:" + "5" * 64 hex_id = wrong_id.split(":", 1)[1] legacy_path = snapshots_dir(repo) / "sha256" / f"{hex_id}.msgpack" legacy_path.parent.mkdir(parents=True, exist_ok=True) legacy_path.write_bytes(msgpack.packb( {"snapshot_id": wrong_id, "manifest": manifest, "created_at": "2026-01-01T00:00:00+00:00"}, use_bin_type=True, )) result = migrate_snapshot_content_ids(repo, dry_run=False) assert result.id_map == {} assert result.snapshots_written == 0 # --------------------------------------------------------------------------- # SC_05 -- correctly derives directories into the hash, not just the manifest # --------------------------------------------------------------------------- def test_sc_05_directories_are_included_in_recomputation(repo: pathlib.Path) -> None: manifest = {"src/a.txt": "sha256:" + "6" * 64} directories = ["src"] wrong_id = hash_snapshot(manifest, None) # correct WITHOUT dirs, wrong WITH dirs _write_unified_snapshot(repo, wrong_id, manifest, directories=directories) result = migrate_snapshot_content_ids(repo, dry_run=False) correct_id = hash_snapshot(manifest, directories) assert wrong_id in result.id_map assert result.id_map[wrong_id] == correct_id # --------------------------------------------------------------------------- # SC_06 -- no .muse/objects/ directory at all is a clean no-op # --------------------------------------------------------------------------- def test_sc_06_missing_objects_dir_is_a_noop(tmp_path: pathlib.Path) -> None: result = migrate_snapshot_content_ids(tmp_path, dry_run=False) assert result.id_map == {} assert result.snapshots_written == 0