test_snapshot_content_migrate.py
python
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be
Merge branch 'fix/hub-user-read-update-path' into dev
Human
10 days ago
| 1 | """TDD -- muse#83: unified-store snapshot-content-ID migration. |
| 2 | |
| 3 | Repairs a class of corruption distinct from muse.core.migrate's existing |
| 4 | v1->v2 commit-ID-formula and legacy-directory-layout passes: a commit whose |
| 5 | declared snapshot_id never matched its own manifest, since creation. See |
| 6 | docs/issues/snapshot-content-id-migration-plan.md for the full background |
| 7 | (musehub#134's investigation). |
| 8 | |
| 9 | Phase 1 tests: migrate_snapshot_content_ids() -- detection + correction at |
| 10 | the snapshot layer, sourced from the unified .muse/objects/ store only. |
| 11 | """ |
| 12 | from __future__ import annotations |
| 13 | |
| 14 | import json |
| 15 | import pathlib |
| 16 | |
| 17 | import pytest |
| 18 | |
| 19 | from muse.core.ids import hash_snapshot |
| 20 | from muse.core.object_store import object_path |
| 21 | from muse.core.paths import muse_dir, objects_dir, snapshots_dir |
| 22 | from muse.core.snapshot_content_migrate import migrate_snapshot_content_ids |
| 23 | |
| 24 | |
| 25 | # --------------------------------------------------------------------------- |
| 26 | # Fixtures / helpers |
| 27 | # --------------------------------------------------------------------------- |
| 28 | |
| 29 | |
| 30 | @pytest.fixture |
| 31 | def repo(tmp_path: pathlib.Path) -> pathlib.Path: |
| 32 | objects_dir(tmp_path).mkdir(parents=True, exist_ok=True) |
| 33 | return tmp_path |
| 34 | |
| 35 | |
| 36 | def _write_unified_snapshot( |
| 37 | repo: pathlib.Path, |
| 38 | declared_id: str, |
| 39 | manifest: dict[str, str], |
| 40 | directories: list[str] | None = None, |
| 41 | ) -> pathlib.Path: |
| 42 | """Write a snapshot object directly to the unified store at the path |
| 43 | implied by *declared_id* -- which may or may not actually reproduce |
| 44 | the manifest (this is exactly how the real corrupted staging data |
| 45 | looks: a file whose own embedded snapshot_id disagrees with a fresh |
| 46 | hash_snapshot recomputation of its own manifest).""" |
| 47 | payload = json.dumps({ |
| 48 | "snapshot_id": declared_id, |
| 49 | "manifest": manifest, |
| 50 | "directories": directories or [], |
| 51 | "created_at": "2026-05-27T00:00:00+00:00", |
| 52 | }, separators=(",", ":")).encode() |
| 53 | header = f"snapshot {len(payload)}\0".encode() |
| 54 | path = object_path(repo, declared_id) |
| 55 | path.parent.mkdir(parents=True, exist_ok=True) |
| 56 | path.write_bytes(header + payload) |
| 57 | return path |
| 58 | |
| 59 | |
| 60 | def _read_unified_snapshot(repo: pathlib.Path, object_id: str) -> dict: |
| 61 | path = object_path(repo, object_id) |
| 62 | data = path.read_bytes() |
| 63 | null_idx = data.index(b"\x00") |
| 64 | return json.loads(data[null_idx + 1:]) |
| 65 | |
| 66 | |
| 67 | # --------------------------------------------------------------------------- |
| 68 | # SC_01 -- a correct snapshot is left completely untouched |
| 69 | # --------------------------------------------------------------------------- |
| 70 | |
| 71 | |
| 72 | def test_sc_01_correct_snapshot_produces_no_id_map_entry_and_no_write( |
| 73 | repo: pathlib.Path, |
| 74 | ) -> None: |
| 75 | manifest = {"a.txt": "sha256:" + "1" * 64} |
| 76 | correct_id = hash_snapshot(manifest, None) |
| 77 | path = _write_unified_snapshot(repo, correct_id, manifest) |
| 78 | before_mtime = path.stat().st_mtime_ns |
| 79 | before_bytes = path.read_bytes() |
| 80 | |
| 81 | result = migrate_snapshot_content_ids(repo, dry_run=False) |
| 82 | |
| 83 | assert correct_id not in result.id_map |
| 84 | assert result.snapshots_written == 0 |
| 85 | assert path.stat().st_mtime_ns == before_mtime |
| 86 | assert path.read_bytes() == before_bytes |
| 87 | |
| 88 | |
| 89 | # --------------------------------------------------------------------------- |
| 90 | # SC_02 -- a snapshot whose declared ID doesn't match its manifest is |
| 91 | # detected and corrected |
| 92 | # --------------------------------------------------------------------------- |
| 93 | |
| 94 | |
| 95 | def test_sc_02_mismatched_snapshot_detected_and_corrected(repo: pathlib.Path) -> None: |
| 96 | manifest = {"b.txt": "sha256:" + "2" * 64} |
| 97 | wrong_id = "sha256:" + "0" * 64 # deliberately wrong -- like the real staging bug |
| 98 | _write_unified_snapshot(repo, wrong_id, manifest) |
| 99 | |
| 100 | result = migrate_snapshot_content_ids(repo, dry_run=False) |
| 101 | |
| 102 | correct_id = hash_snapshot(manifest, None) |
| 103 | assert result.id_map == {wrong_id: correct_id} |
| 104 | assert result.snapshots_written == 1 |
| 105 | |
| 106 | new_path = object_path(repo, correct_id) |
| 107 | assert new_path.exists() |
| 108 | corrected = _read_unified_snapshot(repo, correct_id) |
| 109 | assert corrected["snapshot_id"] == correct_id |
| 110 | assert corrected["manifest"] == manifest |
| 111 | |
| 112 | # Non-destructive: the old (wrong) object is never deleted. |
| 113 | old_path = object_path(repo, wrong_id) |
| 114 | assert old_path.exists() |
| 115 | |
| 116 | |
| 117 | # --------------------------------------------------------------------------- |
| 118 | # SC_03 -- dry_run computes the id_map but writes nothing |
| 119 | # --------------------------------------------------------------------------- |
| 120 | |
| 121 | |
| 122 | def test_sc_03_dry_run_writes_nothing(repo: pathlib.Path) -> None: |
| 123 | manifest = {"c.txt": "sha256:" + "3" * 64} |
| 124 | wrong_id = "sha256:" + "1" * 64 |
| 125 | _write_unified_snapshot(repo, wrong_id, manifest) |
| 126 | |
| 127 | result = migrate_snapshot_content_ids(repo, dry_run=True) |
| 128 | |
| 129 | correct_id = hash_snapshot(manifest, None) |
| 130 | assert result.id_map == {wrong_id: correct_id} |
| 131 | assert result.snapshots_written == 1 # counted... |
| 132 | assert not object_path(repo, correct_id).exists() # ...but never written |
| 133 | |
| 134 | |
| 135 | # --------------------------------------------------------------------------- |
| 136 | # SC_04 -- the legacy .muse/snapshots/ layout is never read by this function |
| 137 | # --------------------------------------------------------------------------- |
| 138 | |
| 139 | |
| 140 | def test_sc_04_legacy_layout_snapshots_are_ignored(repo: pathlib.Path) -> None: |
| 141 | """A mismatch that only exists in the legacy layout must produce zero |
| 142 | changes -- that's muse.core.migrate.migrate_snapshot_ids's job, and this |
| 143 | function must be strictly additive to it, never a replacement.""" |
| 144 | import msgpack |
| 145 | |
| 146 | manifest = {"legacy.txt": "sha256:" + "4" * 64} |
| 147 | wrong_id = "sha256:" + "5" * 64 |
| 148 | hex_id = wrong_id.split(":", 1)[1] |
| 149 | legacy_path = snapshots_dir(repo) / "sha256" / f"{hex_id}.msgpack" |
| 150 | legacy_path.parent.mkdir(parents=True, exist_ok=True) |
| 151 | legacy_path.write_bytes(msgpack.packb( |
| 152 | {"snapshot_id": wrong_id, "manifest": manifest, "created_at": "2026-01-01T00:00:00+00:00"}, |
| 153 | use_bin_type=True, |
| 154 | )) |
| 155 | |
| 156 | result = migrate_snapshot_content_ids(repo, dry_run=False) |
| 157 | |
| 158 | assert result.id_map == {} |
| 159 | assert result.snapshots_written == 0 |
| 160 | |
| 161 | |
| 162 | # --------------------------------------------------------------------------- |
| 163 | # SC_05 -- correctly derives directories into the hash, not just the manifest |
| 164 | # --------------------------------------------------------------------------- |
| 165 | |
| 166 | |
| 167 | def test_sc_05_directories_are_included_in_recomputation(repo: pathlib.Path) -> None: |
| 168 | manifest = {"src/a.txt": "sha256:" + "6" * 64} |
| 169 | directories = ["src"] |
| 170 | wrong_id = hash_snapshot(manifest, None) # correct WITHOUT dirs, wrong WITH dirs |
| 171 | _write_unified_snapshot(repo, wrong_id, manifest, directories=directories) |
| 172 | |
| 173 | result = migrate_snapshot_content_ids(repo, dry_run=False) |
| 174 | |
| 175 | correct_id = hash_snapshot(manifest, directories) |
| 176 | assert wrong_id in result.id_map |
| 177 | assert result.id_map[wrong_id] == correct_id |
| 178 | |
| 179 | |
| 180 | # --------------------------------------------------------------------------- |
| 181 | # SC_06 -- no .muse/objects/ directory at all is a clean no-op |
| 182 | # --------------------------------------------------------------------------- |
| 183 | |
| 184 | |
| 185 | def test_sc_06_missing_objects_dir_is_a_noop(tmp_path: pathlib.Path) -> None: |
| 186 | result = migrate_snapshot_content_ids(tmp_path, dry_run=False) |
| 187 | assert result.id_map == {} |
| 188 | assert result.snapshots_written == 0 |
File History
1 commit
sha256:37af4ab271a64f11d7bf14b4871ebaaf5dd68fb0247521007c6b034d35aa01be
Merge branch 'fix/hub-user-read-update-path' into dev
Human
10 days ago