gabriel / muse public
test_rebase_missing_snapshot_guard.py python
235 lines 9.5 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 27 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
43 from muse.core.store import (
44 CommitRecord,
45 SnapshotRecord,
46 write_branch_ref,
47 write_commit,
48 write_snapshot,
49 )
50
51 _TS = datetime.datetime(2024, 6, 15, 10, 0, 0, tzinfo=datetime.timezone.utc)
52
53
54 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
55 import json
56 import uuid
57 repo = tmp_path / "repo"
58 repo.mkdir()
59 muse = repo / ".muse"
60 (muse / "commits").mkdir(parents=True)
61 (muse / "snapshots").mkdir()
62 (muse / "objects").mkdir()
63 (muse / "refs" / "heads").mkdir(parents=True)
64 (muse / "HEAD").write_text("ref: refs/heads/main\n")
65 (muse / "refs" / "heads" / "main").write_text("")
66 (muse / "repo.json").write_text(json.dumps({
67 "repo_id": str(uuid.uuid4()),
68 "domain": "code",
69 "default_branch": "main",
70 }))
71 return repo
72
73
74 def _write_commit(
75 repo: pathlib.Path,
76 message: str,
77 manifest: Manifest,
78 parent: str | None = None,
79 *,
80 write_objects: bool = False,
81 ) -> CommitRecord:
82 if write_objects:
83 import hashlib
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 = hashlib.sha256(raw).hexdigest()
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(parent_ids, snap_id, message, _TS.isoformat())
98 c = CommitRecord(
99 commit_id=cid,
100 repo_id="test-repo",
101 branch="main",
102 snapshot_id=snap_id,
103 message=message,
104 committed_at=_TS,
105 author="tester",
106 parent_commit_id=parent,
107 parent2_commit_id=None,
108 )
109 write_commit(repo, c)
110 return c
111
112
113 def _get_plugin(repo: pathlib.Path) -> "MuseDomainPlugin":
114 from muse.plugins.registry import resolve_plugin
115 return resolve_plugin(repo)
116
117
118 # ──────────────────────────────────────────────────────────────────────────────
119 # Unit: replay_one raises when commit snapshot is missing
120 # ──────────────────────────────────────────────────────────────────────────────
121
122 class TestReplayOneMissingSnapshot:
123
124 def test_replay_one_raises_valueerror_when_snapshot_missing(self, tmp_path: pathlib.Path) -> None:
125 """Bug 14: replay_one must raise ValueError when the commit's snapshot is missing."""
126 repo = _make_repo(tmp_path)
127
128 # Base: initial commit
129 c1 = _write_commit(repo, "initial", {"a.py": "a" * 64})
130 write_branch_ref(repo, "main", c1.commit_id)
131
132 # The commit to replay
133 c2 = _write_commit(repo, "target", {"b.py": "b" * 64}, parent=c1.commit_id)
134
135 # Delete c2's snapshot to simulate corruption
136 snap_path = repo / ".muse" / "snapshots" / f"{c2.snapshot_id}.msgpack"
137 snap_path.unlink()
138
139 plugin = _get_plugin(repo)
140 domain = "code"
141 repo_id = "test-repo"
142
143 with pytest.raises(ValueError, match="missing or corrupt"):
144 replay_one(repo, c2, c1.commit_id, plugin, domain, repo_id, "main")
145
146 def test_replay_one_raises_when_snapshot_corrupt(self, tmp_path: pathlib.Path) -> None:
147 """replay_one must raise ValueError when the commit's snapshot is corrupt."""
148 repo = _make_repo(tmp_path)
149 c1 = _write_commit(repo, "initial", {"a.py": "a" * 64})
150 write_branch_ref(repo, "main", c1.commit_id)
151 c2 = _write_commit(repo, "target", {"b.py": "b" * 64}, parent=c1.commit_id)
152
153 # Corrupt c2's snapshot
154 snap_path = repo / ".muse" / "snapshots" / f"{c2.snapshot_id}.msgpack"
155 snap_path.write_bytes(b"\xff\x00garbage-bytes")
156
157 plugin = _get_plugin(repo)
158
159 with pytest.raises(ValueError, match="missing or corrupt"):
160 replay_one(repo, c2, c1.commit_id, plugin, "code", "test-repo", "main")
161
162 def test_replay_one_succeeds_when_all_snapshots_present(self, tmp_path: pathlib.Path) -> None:
163 """Regression: replay_one must work normally when all snapshots exist."""
164 repo = _make_repo(tmp_path)
165 c1 = _write_commit(repo, "initial", {"a.py": "hello"}, write_objects=True)
166 write_branch_ref(repo, "main", c1.commit_id)
167 c2 = _write_commit(repo, "target", {"b.py": "world"}, parent=c1.commit_id, write_objects=True)
168
169 plugin = _get_plugin(repo)
170
171 result = replay_one(repo, c2, c1.commit_id, plugin, "code", "test-repo", "main")
172
173 # Should return a new CommitRecord (or conflict list), NOT raise
174 assert result is not None
175 # If clean merge, result is a CommitRecord
176 from muse.core.store import CommitRecord as CR
177 if isinstance(result, CR):
178 assert result.message == c2.message
179
180 def test_before_fix_would_produce_wrong_manifest(self, tmp_path: pathlib.Path) -> None:
181 """Document the pre-fix behavior: missing snapshot → empty theirs_manifest.
182
183 With theirs_manifest={}, the three-way merge would treat ALL files
184 in the commit as deleted — producing a rebased commit with no content.
185 """
186 repo = _make_repo(tmp_path)
187 c1 = _write_commit(repo, "initial", {"a.py": "a" * 64})
188 c2 = _write_commit(repo, "target", {"b.py": "b" * 64}, parent=c1.commit_id)
189
190 # Simulate the pre-fix fallback
191 from muse.core.store import read_snapshot
192 theirs_snap = read_snapshot(repo, c2.snapshot_id)
193 assert theirs_snap is not None # snapshot exists
194
195 # Now delete it to show what would happen
196 snap_path = repo / ".muse" / "snapshots" / f"{c2.snapshot_id}.msgpack"
197 snap_path.unlink()
198
199 theirs_snap = read_snapshot(repo, c2.snapshot_id)
200 old_behavior_manifest: Manifest = theirs_snap.manifest if theirs_snap else {}
201
202 # Pre-fix: empty manifest would be used, silently deleting b.py
203 assert old_behavior_manifest == {}, (
204 "BUG 14: missing snapshot caused theirs_manifest={} in replay_one, "
205 "which would silently delete all files from the rebased commit"
206 )
207
208
209 # ──────────────────────────────────────────────────────────────────────────────
210 # Integration: snapshot missing guard in rebase path
211 # ──────────────────────────────────────────────────────────────────────────────
212
213 class TestRebaseSnapshotMissingIntegration:
214
215 def test_replay_one_raises_valueerror_not_returns_empty_commit(self, tmp_path: pathlib.Path) -> None:
216 """ValueError from replay_one must propagate — not be swallowed."""
217 repo = _make_repo(tmp_path)
218 c1 = _write_commit(repo, "initial", {"main.py": "a" * 64})
219 write_branch_ref(repo, "main", c1.commit_id)
220 c2 = _write_commit(repo, "add feature", {"feature.py": "b" * 64}, parent=c1.commit_id)
221
222 # Remove snapshot for c2
223 (repo / ".muse" / "snapshots" / f"{c2.snapshot_id}.msgpack").unlink()
224
225 plugin = _get_plugin(repo)
226
227 # Should raise, not silently return an empty commit
228 with pytest.raises(ValueError):
229 replay_one(repo, c2, c1.commit_id, plugin, "code", "test-repo", "main")
230
231 # c2's commit still exists on disk (replay_one didn't corrupt anything)
232 from muse.core.store import read_commit
233 assert read_commit(repo, c2.commit_id) is not None, (
234 "replay_one raising ValueError must not corrupt the original commit"
235 )
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 27 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 27 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 30 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 49 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 101 days ago