gabriel / muse public
test_rebase_missing_snapshot_guard.py python
239 lines 9.5 KB
Raw
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 29 days ago
1 """Tests for Bug 14: rebase/squash proceeds with theirs_manifest={} when the
2 commit's snapshot is missing or corrupt — silently deleting all files from
3 that commit in the rebased history.
4
5 Root cause: both muse/core/rebase.py::replay_one and the squash path in
6 muse/cli/commands/rebase.py had:
7 theirs_manifest = theirs_snap.manifest if theirs_snap else {}
8
9 If theirs_snap is None (snapshot missing or corrupt), theirs_manifest={}
10 causes the three-way merge engine to treat all files from that commit as
11 "deleted" — producing a rebased history that is missing the commit's content.
12 This is silent data loss.
13
14 The fix: if theirs_snap is None, raise ValueError (in replay_one) or abort
15 with SystemExit (in the squash path) rather than proceeding with empty manifest.
16
17 Scope of tests
18 --------------
19 Unit (replay_one missing snapshot):
20 - replay_one raises ValueError when commit snapshot is missing
21 - replay_one raises ValueError when commit snapshot is corrupt (unreadable)
22 - replay_one succeeds when all snapshots are present
23
24 Integration (the pre-fix empty-manifest behavior):
25 - Documents that theirs_snap=None → theirs_manifest={} would delete all files
26 - Validates that the fix prevents wrong merge from occurring
27 """
28 from __future__ import annotations
29
30 import datetime
31 import pathlib
32 from typing import TYPE_CHECKING
33
34 import pytest
35
36 if TYPE_CHECKING:
37 from muse.plugins.registry import MuseDomainPlugin
38
39 from muse.core.rebase import replay_one
40 from muse.core.ids import hash_commit as compute_commit_id, hash_snapshot as compute_snapshot_id
41
42 from muse.core.types import Manifest, blob_id, fake_id
43 from muse.core.paths import muse_dir
44 from muse.core.object_store import object_path as _obj_path
45 from muse.core.store import (
46 CommitRecord,
47 SnapshotRecord,
48 write_branch_ref,
49 write_commit,
50 write_snapshot,
51 )
52
53 _TS = datetime.datetime(2024, 6, 15, 10, 0, 0, tzinfo=datetime.timezone.utc)
54
55
56 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
57 import json
58 repo = tmp_path / "repo"
59 repo.mkdir()
60 dot_muse = muse_dir(repo)
61 (dot_muse / "commits").mkdir(parents=True)
62 (dot_muse / "snapshots").mkdir()
63 (dot_muse / "objects").mkdir()
64 (dot_muse / "refs" / "heads").mkdir(parents=True)
65 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
66 (dot_muse / "refs" / "heads" / "main").write_text("")
67 (dot_muse / "repo.json").write_text(json.dumps({
68 "repo_id": fake_id("repo"),
69 "domain": "code",
70 "default_branch": "main",
71 }))
72 return repo
73
74
75 def _write_commit(
76 repo: pathlib.Path,
77 message: str,
78 manifest: Manifest,
79 parent: str | None = None,
80 *,
81 write_objects: bool = False,
82 ) -> CommitRecord:
83 if write_objects:
84 from muse.core.object_store import write_object
85 real_manifest: Manifest = {}
86 for path, content in manifest.items():
87 raw = content.encode()
88 oid = blob_id(raw)
89 write_object(repo, oid, raw)
90 real_manifest[path] = oid
91 manifest = real_manifest
92
93 snap_id = compute_snapshot_id(manifest)
94 snap = SnapshotRecord(snapshot_id=snap_id, manifest=manifest, created_at=_TS)
95 write_snapshot(repo, snap)
96 parent_ids = [parent] if parent else []
97 cid = compute_commit_id(
98 parent_ids=parent_ids,
99 snapshot_id=snap_id,
100 message=message,
101 committed_at_iso=_TS.isoformat(),
102 author="tester",
103 )
104 c = CommitRecord(
105 commit_id=cid,
106 branch="main",
107 snapshot_id=snap_id,
108 message=message,
109 committed_at=_TS,
110 author="tester",
111 parent_commit_id=parent,
112 parent2_commit_id=None,
113 )
114 write_commit(repo, c)
115 return c
116
117
118 def _get_plugin(repo: pathlib.Path) -> "MuseDomainPlugin":
119 from muse.plugins.registry import resolve_plugin
120 return resolve_plugin(repo)
121
122
123 # ──────────────────────────────────────────────────────────────────────────────
124 # Unit: replay_one raises when commit snapshot is missing
125 # ──────────────────────────────────────────────────────────────────────────────
126
127 class TestReplayOneMissingSnapshot:
128
129 def test_replay_one_raises_valueerror_when_snapshot_missing(self, tmp_path: pathlib.Path) -> None:
130 """Bug 14: replay_one must raise ValueError when the commit's snapshot is missing."""
131 repo = _make_repo(tmp_path)
132
133 # Base: initial commit
134 c1 = _write_commit(repo, "initial", {"a.py": "a" * 64})
135 write_branch_ref(repo, "main", c1.commit_id)
136
137 # The commit to replay
138 c2 = _write_commit(repo, "target", {"b.py": "b" * 64}, parent=c1.commit_id)
139
140 # Delete c2's snapshot to simulate corruption
141 snap_path = _obj_path(repo, c2.snapshot_id)
142 snap_path.unlink()
143
144 plugin = _get_plugin(repo)
145 domain = "code"
146
147 with pytest.raises(ValueError, match="missing or corrupt"):
148 replay_one(repo, c2, c1.commit_id, plugin, domain, "main")
149
150 def test_replay_one_raises_when_snapshot_corrupt(self, tmp_path: pathlib.Path) -> None:
151 """replay_one must raise ValueError when the commit's snapshot is corrupt."""
152 repo = _make_repo(tmp_path)
153 c1 = _write_commit(repo, "initial", {"a.py": "a" * 64})
154 write_branch_ref(repo, "main", c1.commit_id)
155 c2 = _write_commit(repo, "target", {"b.py": "b" * 64}, parent=c1.commit_id)
156
157 # Corrupt c2's snapshot
158 snap_path = _obj_path(repo, c2.snapshot_id)
159 snap_path.write_bytes(b"\xff\x00garbage-bytes")
160
161 plugin = _get_plugin(repo)
162
163 with pytest.raises(ValueError, match="missing or corrupt"):
164 replay_one(repo, c2, c1.commit_id, plugin, "code", "main")
165
166 def test_replay_one_succeeds_when_all_snapshots_present(self, tmp_path: pathlib.Path) -> None:
167 """Regression: replay_one must work normally when all snapshots exist."""
168 repo = _make_repo(tmp_path)
169 c1 = _write_commit(repo, "initial", {"a.py": "hello"}, write_objects=True)
170 write_branch_ref(repo, "main", c1.commit_id)
171 c2 = _write_commit(repo, "target", {"b.py": "world"}, parent=c1.commit_id, write_objects=True)
172
173 plugin = _get_plugin(repo)
174
175 result = replay_one(repo, c2, c1.commit_id, plugin, "code", "main")
176
177 # Should return a new CommitRecord (or conflict list), NOT raise
178 assert result is not None
179 # If clean merge, result is a CommitRecord
180 from muse.core.store import CommitRecord as CR
181 if isinstance(result, CR):
182 assert result.message == c2.message
183
184 def test_before_fix_would_produce_wrong_manifest(self, tmp_path: pathlib.Path) -> None:
185 """Document the pre-fix behavior: missing snapshot → empty theirs_manifest.
186
187 With theirs_manifest={}, the three-way merge would treat ALL files
188 in the commit as deleted — producing a rebased commit with no content.
189 """
190 repo = _make_repo(tmp_path)
191 c1 = _write_commit(repo, "initial", {"a.py": "a" * 64})
192 c2 = _write_commit(repo, "target", {"b.py": "b" * 64}, parent=c1.commit_id)
193
194 # Simulate the pre-fix fallback
195 from muse.core.store import read_snapshot
196 theirs_snap = read_snapshot(repo, c2.snapshot_id)
197 assert theirs_snap is not None # snapshot exists
198
199 # Now delete it to show what would happen
200 snap_path = _obj_path(repo, c2.snapshot_id)
201 snap_path.unlink()
202
203 theirs_snap = read_snapshot(repo, c2.snapshot_id)
204 old_behavior_manifest: Manifest = theirs_snap.manifest if theirs_snap else {}
205
206 # Pre-fix: empty manifest would be used, silently deleting b.py
207 assert old_behavior_manifest == {}, (
208 "BUG 14: missing snapshot caused theirs_manifest={} in replay_one, "
209 "which would silently delete all files from the rebased commit"
210 )
211
212
213 # ──────────────────────────────────────────────────────────────────────────────
214 # Integration: snapshot missing guard in rebase path
215 # ──────────────────────────────────────────────────────────────────────────────
216
217 class TestRebaseSnapshotMissingIntegration:
218
219 def test_replay_one_raises_valueerror_not_returns_empty_commit(self, tmp_path: pathlib.Path) -> None:
220 """ValueError from replay_one must propagate — not be swallowed."""
221 repo = _make_repo(tmp_path)
222 c1 = _write_commit(repo, "initial", {"main.py": "a" * 64})
223 write_branch_ref(repo, "main", c1.commit_id)
224 c2 = _write_commit(repo, "add feature", {"feature.py": "b" * 64}, parent=c1.commit_id)
225
226 # Remove snapshot for c2
227 (_obj_path(repo, c2.snapshot_id)).unlink()
228
229 plugin = _get_plugin(repo)
230
231 # Should raise, not silently return an empty commit
232 with pytest.raises(ValueError):
233 replay_one(repo, c2, c1.commit_id, plugin, "code", "main")
234
235 # c2's commit still exists on disk (replay_one didn't corrupt anything)
236 from muse.core.store import read_commit
237 assert read_commit(repo, c2.commit_id) is not None, (
238 "replay_one raising ValueError must not corrupt the original commit"
239 )
File History 2 commits
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 29 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 29 days ago