gabriel / muse public
test_rebase_missing_snapshot_guard.py python
243 lines 9.7 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 122 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.snapshot import compute_commit_id, 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.store import (
45 CommitRecord,
46 SnapshotRecord,
47 snapshot_path,
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 from muse.core.repo import read_repo_id
98 _repo_id = read_repo_id(repo)
99 cid = compute_commit_id(
100 parent_ids=parent_ids,
101 snapshot_id=snap_id,
102 message=message,
103 committed_at_iso=_TS.isoformat(),
104 author="tester",
105 )
106 c = CommitRecord(
107 repo_id=_repo_id,
108 commit_id=cid,
109 branch="main",
110 snapshot_id=snap_id,
111 message=message,
112 committed_at=_TS,
113 author="tester",
114 parent_commit_id=parent,
115 parent2_commit_id=None,
116 )
117 write_commit(repo, c)
118 return c
119
120
121 def _get_plugin(repo: pathlib.Path) -> "MuseDomainPlugin":
122 from muse.plugins.registry import resolve_plugin
123 return resolve_plugin(repo)
124
125
126 # ──────────────────────────────────────────────────────────────────────────────
127 # Unit: replay_one raises when commit snapshot is missing
128 # ──────────────────────────────────────────────────────────────────────────────
129
130 class TestReplayOneMissingSnapshot:
131
132 def test_replay_one_raises_valueerror_when_snapshot_missing(self, tmp_path: pathlib.Path) -> None:
133 """Bug 14: replay_one must raise ValueError when the commit's snapshot is missing."""
134 repo = _make_repo(tmp_path)
135
136 # Base: initial commit
137 c1 = _write_commit(repo, "initial", {"a.py": "a" * 64})
138 write_branch_ref(repo, "main", c1.commit_id)
139
140 # The commit to replay
141 c2 = _write_commit(repo, "target", {"b.py": "b" * 64}, parent=c1.commit_id)
142
143 # Delete c2's snapshot to simulate corruption
144 snap_path = snapshot_path(repo, c2.snapshot_id)
145 snap_path.unlink()
146
147 plugin = _get_plugin(repo)
148 domain = "code"
149 repo_id = "test-repo"
150
151 with pytest.raises(ValueError, match="missing or corrupt"):
152 replay_one(repo, c2, c1.commit_id, plugin, domain, repo_id, "main")
153
154 def test_replay_one_raises_when_snapshot_corrupt(self, tmp_path: pathlib.Path) -> None:
155 """replay_one must raise ValueError when the commit's snapshot is corrupt."""
156 repo = _make_repo(tmp_path)
157 c1 = _write_commit(repo, "initial", {"a.py": "a" * 64})
158 write_branch_ref(repo, "main", c1.commit_id)
159 c2 = _write_commit(repo, "target", {"b.py": "b" * 64}, parent=c1.commit_id)
160
161 # Corrupt c2's snapshot
162 snap_path = snapshot_path(repo, c2.snapshot_id)
163 snap_path.write_bytes(b"\xff\x00garbage-bytes")
164
165 plugin = _get_plugin(repo)
166
167 with pytest.raises(ValueError, match="missing or corrupt"):
168 replay_one(repo, c2, c1.commit_id, plugin, "code", "test-repo", "main")
169
170 def test_replay_one_succeeds_when_all_snapshots_present(self, tmp_path: pathlib.Path) -> None:
171 """Regression: replay_one must work normally when all snapshots exist."""
172 repo = _make_repo(tmp_path)
173 c1 = _write_commit(repo, "initial", {"a.py": "hello"}, write_objects=True)
174 write_branch_ref(repo, "main", c1.commit_id)
175 c2 = _write_commit(repo, "target", {"b.py": "world"}, parent=c1.commit_id, write_objects=True)
176
177 plugin = _get_plugin(repo)
178
179 result = replay_one(repo, c2, c1.commit_id, plugin, "code", "test-repo", "main")
180
181 # Should return a new CommitRecord (or conflict list), NOT raise
182 assert result is not None
183 # If clean merge, result is a CommitRecord
184 from muse.core.store import CommitRecord as CR
185 if isinstance(result, CR):
186 assert result.message == c2.message
187
188 def test_before_fix_would_produce_wrong_manifest(self, tmp_path: pathlib.Path) -> None:
189 """Document the pre-fix behavior: missing snapshot → empty theirs_manifest.
190
191 With theirs_manifest={}, the three-way merge would treat ALL files
192 in the commit as deleted — producing a rebased commit with no content.
193 """
194 repo = _make_repo(tmp_path)
195 c1 = _write_commit(repo, "initial", {"a.py": "a" * 64})
196 c2 = _write_commit(repo, "target", {"b.py": "b" * 64}, parent=c1.commit_id)
197
198 # Simulate the pre-fix fallback
199 from muse.core.store import read_snapshot
200 theirs_snap = read_snapshot(repo, c2.snapshot_id)
201 assert theirs_snap is not None # snapshot exists
202
203 # Now delete it to show what would happen
204 snap_path = snapshot_path(repo, c2.snapshot_id)
205 snap_path.unlink()
206
207 theirs_snap = read_snapshot(repo, c2.snapshot_id)
208 old_behavior_manifest: Manifest = theirs_snap.manifest if theirs_snap else {}
209
210 # Pre-fix: empty manifest would be used, silently deleting b.py
211 assert old_behavior_manifest == {}, (
212 "BUG 14: missing snapshot caused theirs_manifest={} in replay_one, "
213 "which would silently delete all files from the rebased commit"
214 )
215
216
217 # ──────────────────────────────────────────────────────────────────────────────
218 # Integration: snapshot missing guard in rebase path
219 # ──────────────────────────────────────────────────────────────────────────────
220
221 class TestRebaseSnapshotMissingIntegration:
222
223 def test_replay_one_raises_valueerror_not_returns_empty_commit(self, tmp_path: pathlib.Path) -> None:
224 """ValueError from replay_one must propagate — not be swallowed."""
225 repo = _make_repo(tmp_path)
226 c1 = _write_commit(repo, "initial", {"main.py": "a" * 64})
227 write_branch_ref(repo, "main", c1.commit_id)
228 c2 = _write_commit(repo, "add feature", {"feature.py": "b" * 64}, parent=c1.commit_id)
229
230 # Remove snapshot for c2
231 (snapshot_path(repo, c2.snapshot_id)).unlink()
232
233 plugin = _get_plugin(repo)
234
235 # Should raise, not silently return an empty commit
236 with pytest.raises(ValueError):
237 replay_one(repo, c2, c1.commit_id, plugin, "code", "test-repo", "main")
238
239 # c2's commit still exists on disk (replay_one didn't corrupt anything)
240 from muse.core.store import read_commit
241 assert read_commit(repo, c2.commit_id) is not None, (
242 "replay_one raising ValueError must not corrupt the original commit"
243 )
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 122 days ago