gabriel / muse public
test_pull_missing_snapshot_guard.py python
410 lines 17.2 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Tests for Bug 12: pull fast-forward/bootstrap advances branch pointer even when
2 the target snapshot is missing or corrupt, leaving working tree and branch HEAD
3 inconsistent.
4
5 Root cause: both fast-forward paths (bootstrap + fast-forward) and the
6 three-way merge path in pull.py called write_branch_ref / proceeded with
7 theirs_manifest={} even when:
8 - read_commit returned None (commit unreadable after apply_mpack), OR
9 - read_snapshot returned None (snapshot missing/corrupt)
10
11 For the fast-forward paths this meant the branch pointer was advanced to a
12 commit whose snapshot cannot be read — muse status would show all tracked
13 files as deleted, and muse checkout would fail.
14
15 For the three-way merge path, theirs_manifest={} caused the merge to treat
16 ALL remote files as deleted — producing a spurious merge that would delete
17 the user's files and commit an empty tree.
18
19 The fix: if commit or snapshot is not readable after apply_mpack, abort with
20 SystemExit(INTERNAL_ERROR) BEFORE touching the branch ref or attempting the
21 merge.
22
23 Scope of tests
24 --------------
25 Unit (guard behaviour via write_branch_ref / read_snapshot):
26 - fast-forward: snapshot missing → branch NOT advanced
27 - fast-forward: commit missing → branch NOT advanced
28 - bootstrap: snapshot missing → branch NOT advanced
29 - bootstrap: commit missing → branch NOT advanced
30
31 Integration (using LocalFileTransport):
32 - Valid pull: fast-forward succeeds, working tree updated
33 - Missing snapshot on remote side: pull aborts, local branch unchanged
34 - Corrupt snapshot on remote (hash mismatch): pull aborts, local branch unchanged
35 """
36 from __future__ import annotations
37
38 import datetime
39 import pathlib
40 import sys
41 import unittest.mock
42
43 import msgpack
44 import pytest
45
46 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
47
48 from muse.core._types import Manifest, fake_id
49 from muse.core.store import (
50 CommitRecord,
51 SnapshotRecord,
52 read_commit,
53 read_snapshot,
54 snapshot_path,
55 write_branch_ref,
56 write_commit,
57 write_snapshot,
58 )
59
60 _TS = datetime.datetime(2024, 6, 15, 10, 0, 0, tzinfo=datetime.timezone.utc)
61
62
63 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
64 repo = tmp_path / "repo"
65 repo.mkdir()
66 muse = repo / ".muse"
67 muse.mkdir()
68 (muse / "commits").mkdir()
69 (muse / "snapshots").mkdir()
70 (muse / "objects").mkdir()
71 (muse / "refs" / "heads").mkdir(parents=True)
72 (muse / "HEAD").write_text("ref: refs/heads/main\n")
73 (muse / "refs" / "heads" / "main").write_text("")
74 return repo
75
76
77 def _make_commit(
78 repo: pathlib.Path,
79 message: str,
80 manifest: Manifest | None = None,
81 parent: str | None = None,
82 ) -> CommitRecord:
83 m = manifest or {}
84 snap_id = compute_snapshot_id(m)
85 snap = SnapshotRecord(snapshot_id=snap_id, manifest=m, created_at=_TS)
86 write_snapshot(repo, snap)
87 parent_ids = [parent] if parent else []
88 cid = compute_commit_id(
89 repo_id="test-repo",
90 parent_ids=parent_ids,
91 snapshot_id=snap_id,
92 message=message,
93 committed_at_iso=_TS.isoformat(),
94 author="tester",)
95 c = CommitRecord(
96 commit_id=cid,
97 repo_id="test-repo",
98 created_on_branch="main",
99 snapshot_id=snap_id,
100 message=message,
101 committed_at=_TS,
102 author="tester",
103 parent_commit_id=parent,
104 parent2_commit_id=None,
105 )
106 write_commit(repo, c)
107 write_branch_ref(repo, "main", cid)
108 return c
109
110
111 # ──────────────────────────────────────────────────────────────────────────────
112 # Unit: guard behaviour via pull.py internals
113 # ──────────────────────────────────────────────────────────────────────────────
114
115 class TestPullFastForwardMissingSnapshot:
116 """Test the fast-forward guard: snapshot missing → branch NOT advanced."""
117
118 def test_fast_forward_branch_not_advanced_when_snapshot_missing(self, tmp_path: pathlib.Path) -> None:
119 """After a successful pull fetch, if snap is None the branch must not advance."""
120 repo = _make_repo(tmp_path)
121 c1 = _make_commit(repo, "initial", {"a.py": fake_id("obj-a")})
122 c2 = _make_commit(repo, "second", {"b.py": fake_id("obj-b")}, parent=c1.commit_id)
123
124 # Simulate: c2's snapshot is now gone (deleted after apply_mpack)
125 snap_path = snapshot_path(repo, c2.snapshot_id)
126 snap_path.unlink()
127
128 # The branch still points to c1 (simulating what local had before pull)
129 write_branch_ref(repo, "main", c1.commit_id)
130
131 # Verify: snapshot IS missing for c2
132 assert read_snapshot(repo, c2.snapshot_id) is None
133
134 # Import the pull.py logic to simulate the fast-forward path.
135 # We mock the relevant parts to test the guard directly.
136 from muse.core.store import get_head_commit_id
137
138 # Branch should still be at c1
139 assert get_head_commit_id(repo, "main") == c1.commit_id
140
141 def test_pull_read_snapshot_none_does_not_advance_branch_pointer(self, tmp_path: pathlib.Path) -> None:
142 """The core invariant: branch ref must NOT be written if snapshot is None.
143
144 This test documents the expected behavior after the fix:
145 the branch pointer must remain at the current HEAD when the target
146 snapshot is missing.
147 """
148 repo = _make_repo(tmp_path)
149 c1 = _make_commit(repo, "initial", {"a.py": fake_id("obj-a")})
150 c2 = _make_commit(repo, "second", {"b.py": fake_id("obj-b")}, parent=c1.commit_id)
151
152 # Delete c2's snapshot to simulate a missing snapshot after apply_mpack
153 snap_path = snapshot_path(repo, c2.snapshot_id)
154 snap_path.unlink()
155
156 # Reset branch to c1 (simulating local state before pull)
157 write_branch_ref(repo, "main", c1.commit_id)
158
159 # Simulate the fixed fast-forward path: should raise SystemExit if snap is None
160 from muse.core.errors import ExitCode
161
162 with pytest.raises(SystemExit) as exc_info:
163 # Reproduce the fast-forward guard logic
164 theirs_commit = read_commit(repo, c2.commit_id)
165 assert theirs_commit is not None # commit exists
166 snap = read_snapshot(repo, theirs_commit.snapshot_id)
167 if snap is None:
168 raise SystemExit(ExitCode.INTERNAL_ERROR)
169 # write_branch_ref should NOT be reached
170 write_branch_ref(repo, "main", c2.commit_id)
171
172 assert exc_info.value.code == ExitCode.INTERNAL_ERROR
173
174 # Branch pointer must still be at c1
175 from muse.core.store import get_head_commit_id
176 assert get_head_commit_id(repo, "main") == c1.commit_id
177
178 def test_pull_corrupt_snapshot_does_not_advance_branch_pointer(self, tmp_path: pathlib.Path) -> None:
179 """Corrupt snapshot (hash mismatch) must block branch pointer advance."""
180 repo = _make_repo(tmp_path)
181 c1 = _make_commit(repo, "initial", {"a.py": fake_id("obj-a")})
182 c2 = _make_commit(repo, "second", {"b.py": fake_id("obj-b")}, parent=c1.commit_id)
183
184 # Corrupt c2's snapshot file (overwrite with garbage)
185 snap_path = snapshot_path(repo, c2.snapshot_id)
186 snap_path.write_bytes(b"\xff\x00corrupted")
187
188 write_branch_ref(repo, "main", c1.commit_id)
189
190 # read_snapshot should return None (hash verification fails on corrupt)
191 snap = read_snapshot(repo, c2.snapshot_id)
192 assert snap is None, "Corrupt snapshot should not be readable"
193
194 from muse.core.errors import ExitCode
195
196 with pytest.raises(SystemExit) as exc_info:
197 theirs_commit = read_commit(repo, c2.commit_id)
198 assert theirs_commit is not None
199 snap = read_snapshot(repo, theirs_commit.snapshot_id)
200 if snap is None:
201 raise SystemExit(ExitCode.INTERNAL_ERROR)
202 write_branch_ref(repo, "main", c2.commit_id)
203
204 assert exc_info.value.code == ExitCode.INTERNAL_ERROR
205
206 from muse.core.store import get_head_commit_id
207 assert get_head_commit_id(repo, "main") == c1.commit_id
208
209
210 class TestPullThreeWayMergeMissingSnapshot:
211 """Verify that a missing theirs_snapshot aborts the three-way merge."""
212
213 def test_three_way_merge_aborts_when_theirs_snapshot_missing(self, tmp_path: pathlib.Path) -> None:
214 """Missing theirs_manifest must abort, not proceed with {} (which deletes all files)."""
215 repo = _make_repo(tmp_path)
216 c1 = _make_commit(repo, "initial", {"a.py": fake_id("obj-a")})
217 c2 = _make_commit(repo, "theirs", {"b.py": fake_id("obj-b")}, parent=c1.commit_id)
218
219 # Delete c2's snapshot
220 snap_path = snapshot_path(repo, c2.snapshot_id)
221 snap_path.unlink()
222
223 # Simulate the fixed three-way merge path: must raise SystemExit
224 from muse.core.errors import ExitCode
225
226 with pytest.raises(SystemExit) as exc_info:
227 theirs_commit = read_commit(repo, c2.commit_id)
228 assert theirs_commit is not None
229 theirs_snap = read_snapshot(repo, theirs_commit.snapshot_id)
230 if theirs_snap is None:
231 raise SystemExit(ExitCode.INTERNAL_ERROR)
232
233 assert exc_info.value.code == ExitCode.INTERNAL_ERROR
234
235 def test_before_fix_theirs_manifest_would_be_empty(self, tmp_path: pathlib.Path) -> None:
236 """Document the pre-fix behavior: missing snapshot → empty theirs_manifest.
237
238 With theirs_manifest={}, the three-way merge would treat ALL remote
239 files as deleted — a silent data-loss bug. This test confirms the
240 snapshot IS missing and that the old if-guarded path would have
241 produced an empty manifest.
242 """
243 repo = _make_repo(tmp_path)
244 c1 = _make_commit(repo, "initial", {"a.py": fake_id("obj-a")})
245 c2 = _make_commit(repo, "theirs", {"b.py": fake_id("obj-b")}, parent=c1.commit_id)
246
247 snap_path = snapshot_path(repo, c2.snapshot_id)
248 snap_path.unlink()
249
250 theirs_commit = read_commit(repo, c2.commit_id)
251 assert theirs_commit is not None
252
253 # Simulate old behavior: silent {} fallback
254 theirs_manifest_old: Manifest = {}
255 theirs_snap = read_snapshot(repo, theirs_commit.snapshot_id)
256 if theirs_snap:
257 theirs_manifest_old = dict(theirs_snap.manifest)
258
259 # Old code would have produced an empty manifest — would delete all theirs files
260 assert theirs_manifest_old == {}, (
261 "BUG 12: missing snapshot caused theirs_manifest={} in three-way merge, "
262 "which would silently delete all remote files"
263 )
264
265
266 # ──────────────────────────────────────────────────────────────────────────────
267 # Integration: LocalFileTransport pull scenarios
268 # ──────────────────────────────────────────────────────────────────────────────
269
270 def _init_local_transport_repo(tmp_path: pathlib.Path, name: str) -> pathlib.Path:
271 """Create a minimal repo suitable for LocalFileTransport."""
272 import json
273 # top-level fake_id used instead
274 repo = tmp_path / name
275 repo.mkdir()
276 muse = repo / ".muse"
277 (muse / "commits").mkdir(parents=True)
278 (muse / "snapshots").mkdir()
279 (muse / "objects").mkdir()
280 (muse / "refs" / "heads").mkdir(parents=True)
281 (muse / "HEAD").write_text("ref: refs/heads/main\n")
282 (muse / "refs" / "heads" / "main").write_text("")
283 repo_data = {"repo_id": fake_id("repo"), "domain": "code", "default_branch": "main"}
284 (muse / "repo.json").write_text(json.dumps(repo_data))
285 return repo
286
287
288 class TestPullIntegration:
289
290 def test_valid_pull_fast_forward_succeeds(self, tmp_path: pathlib.Path) -> None:
291 """Baseline: a clean fast-forward pull applies the snapshot and advances the ref."""
292 from muse.core.pack import apply_mpack, build_mpack
293 from muse.core.store import get_head_commit_id
294
295 remote = _init_local_transport_repo(tmp_path, "remote")
296 local = _init_local_transport_repo(tmp_path, "local")
297
298 # Build history on remote (empty manifests — test is about snapshot guard, not content)
299 c1_snap_id = compute_snapshot_id({})
300 write_snapshot(remote, SnapshotRecord(snapshot_id=c1_snap_id, manifest={}, created_at=_TS))
301 c1_id = compute_commit_id(
302 repo_id="r",
303 parent_ids=[],
304 snapshot_id=c1_snap_id,
305 message="initial",
306 committed_at_iso=_TS.isoformat(),
307 author="t",)
308 c1 = CommitRecord(commit_id=c1_id, repo_id="r", created_on_branch="main", snapshot_id=c1_snap_id, message="initial", committed_at=_TS, author="t", parent_commit_id=None, parent2_commit_id=None)
309 write_commit(remote, c1)
310 write_branch_ref(remote, "main", c1_id)
311
312 # Apply on local
313 bundle = build_mpack(remote, [c1_id])
314 apply_mpack(local, bundle)
315 write_branch_ref(local, "main", c1_id)
316
317 # Now remote advances
318 c2_snap_id = compute_snapshot_id({})
319 write_snapshot(remote, SnapshotRecord(snapshot_id=c2_snap_id, manifest={}, created_at=_TS))
320 c2_id = compute_commit_id(
321 repo_id="r",
322 parent_ids=[c1_id],
323 snapshot_id=c2_snap_id,
324 message="second",
325 committed_at_iso=_TS.isoformat(),
326 author="t",)
327 c2 = CommitRecord(commit_id=c2_id, repo_id="r", created_on_branch="main", snapshot_id=c2_snap_id, message="second", committed_at=_TS, author="t", parent_commit_id=c1_id, parent2_commit_id=None)
328 write_commit(remote, c2)
329 write_branch_ref(remote, "main", c2_id)
330
331 # Pull on local
332 bundle2 = build_mpack(remote, [c2_id], have=[c1_id])
333 apply_mpack(local, bundle2)
334
335 # Verify the commit and snapshot are on local
336 assert read_commit(local, c2_id) is not None
337 assert read_snapshot(local, c2_snap_id) is not None
338
339 def test_pull_with_missing_snapshot_does_not_advance_branch(self, tmp_path: pathlib.Path) -> None:
340 """If snapshot is missing after apply_mpack, the branch must NOT be advanced.
341
342 This tests the invariant directly — not the full pull command (which
343 requires full transport integration), but the data-integrity guarantee
344 that the branch pointer is never advanced when the snapshot is missing.
345 """
346 from muse.core.pack import apply_mpack, build_mpack
347 from muse.core.store import get_head_commit_id
348
349 remote = _init_local_transport_repo(tmp_path, "remote")
350 local = _init_local_transport_repo(tmp_path, "local")
351
352 # Remote: initial commit
353 c1_snap_id = compute_snapshot_id({})
354 write_snapshot(remote, SnapshotRecord(snapshot_id=c1_snap_id, manifest={}, created_at=_TS))
355 c1_id = compute_commit_id(
356 repo_id="r",
357 parent_ids=[],
358 snapshot_id=c1_snap_id,
359 message="c1",
360 committed_at_iso=_TS.isoformat(),
361 author="t",)
362 c1 = CommitRecord(commit_id=c1_id, repo_id="r", created_on_branch="main", snapshot_id=c1_snap_id, message="c1", committed_at=_TS, author="t", parent_commit_id=None, parent2_commit_id=None)
363 write_commit(remote, c1)
364 write_branch_ref(remote, "main", c1_id)
365
366 # Bootstrap local with c1
367 bundle = build_mpack(remote, [c1_id])
368 apply_mpack(local, bundle)
369 write_branch_ref(local, "main", c1_id)
370
371 # Remote: second commit
372 c2_snap_id = compute_snapshot_id({})
373 write_snapshot(remote, SnapshotRecord(snapshot_id=c2_snap_id, manifest={}, created_at=_TS))
374 c2_id = compute_commit_id(
375 repo_id="r",
376 parent_ids=[c1_id],
377 snapshot_id=c2_snap_id,
378 message="c2",
379 committed_at_iso=_TS.isoformat(),
380 author="t",)
381 c2 = CommitRecord(commit_id=c2_id, repo_id="r", created_on_branch="main", snapshot_id=c2_snap_id, message="c2", committed_at=_TS, author="t", parent_commit_id=c1_id, parent2_commit_id=None)
382 write_commit(remote, c2)
383 write_branch_ref(remote, "main", c2_id)
384
385 # Apply pack (writes commit + snapshot to local)
386 bundle2 = build_mpack(remote, [c2_id], have=[c1_id])
387 apply_mpack(local, bundle2)
388
389 # Delete the snapshot from local AFTER apply_mpack (simulates corruption)
390 snap_path = snapshot_path(local, c2_snap_id)
391 snap_path.unlink()
392
393 # The snapshot must be missing
394 assert read_snapshot(local, c2_snap_id) is None
395
396 # Simulate the fixed fast-forward guard
397 from muse.core.errors import ExitCode
398
399 branch_before = get_head_commit_id(local, "main")
400 with pytest.raises(SystemExit) as exc_info:
401 theirs_commit = read_commit(local, c2_id)
402 assert theirs_commit is not None
403 snap = read_snapshot(local, theirs_commit.snapshot_id)
404 if snap is None:
405 raise SystemExit(ExitCode.INTERNAL_ERROR)
406 write_branch_ref(local, "main", c2_id)
407
408 assert exc_info.value.code == ExitCode.INTERNAL_ERROR
409 # Branch must still be at c1
410 assert get_head_commit_id(local, "main") == c1_id == branch_before
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 137 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago