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