gabriel / muse public
test_rebase_missing_snapshot_guard.py python
240 lines 9.6 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 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.store import (
44 CommitRecord,
45 SnapshotRecord,
46 snapshot_path,
47 write_branch_ref,
48 write_commit,
49 write_snapshot,
50 )
51
52 _TS = datetime.datetime(2024, 6, 15, 10, 0, 0, tzinfo=datetime.timezone.utc)
53
54
55 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
56 import json
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": fake_id("repo"),
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 from muse.core.object_store import write_object
84 real_manifest: Manifest = {}
85 for path, content in manifest.items():
86 raw = content.encode()
87 oid = blob_id(raw)
88 write_object(repo, oid, raw)
89 real_manifest[path] = oid
90 manifest = real_manifest
91
92 snap_id = compute_snapshot_id(manifest)
93 snap = SnapshotRecord(snapshot_id=snap_id, manifest=manifest, created_at=_TS)
94 write_snapshot(repo, snap)
95 parent_ids = [parent] if parent else []
96 cid = compute_commit_id(
97 repo_id="test-repo",
98 parent_ids=parent_ids,
99 snapshot_id=snap_id,
100 message=message,
101 committed_at_iso=_TS.isoformat(),
102 author="tester",)
103 c = CommitRecord(
104 commit_id=cid,
105 repo_id="test-repo",
106 created_on_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 = snapshot_path(repo, c2.snapshot_id)
142 snap_path.unlink()
143
144 plugin = _get_plugin(repo)
145 domain = "code"
146 repo_id = "test-repo"
147
148 with pytest.raises(ValueError, match="missing or corrupt"):
149 replay_one(repo, c2, c1.commit_id, plugin, domain, repo_id, "main")
150
151 def test_replay_one_raises_when_snapshot_corrupt(self, tmp_path: pathlib.Path) -> None:
152 """replay_one must raise ValueError when the commit's snapshot is corrupt."""
153 repo = _make_repo(tmp_path)
154 c1 = _write_commit(repo, "initial", {"a.py": "a" * 64})
155 write_branch_ref(repo, "main", c1.commit_id)
156 c2 = _write_commit(repo, "target", {"b.py": "b" * 64}, parent=c1.commit_id)
157
158 # Corrupt c2's snapshot
159 snap_path = snapshot_path(repo, c2.snapshot_id)
160 snap_path.write_bytes(b"\xff\x00garbage-bytes")
161
162 plugin = _get_plugin(repo)
163
164 with pytest.raises(ValueError, match="missing or corrupt"):
165 replay_one(repo, c2, c1.commit_id, plugin, "code", "test-repo", "main")
166
167 def test_replay_one_succeeds_when_all_snapshots_present(self, tmp_path: pathlib.Path) -> None:
168 """Regression: replay_one must work normally when all snapshots exist."""
169 repo = _make_repo(tmp_path)
170 c1 = _write_commit(repo, "initial", {"a.py": "hello"}, write_objects=True)
171 write_branch_ref(repo, "main", c1.commit_id)
172 c2 = _write_commit(repo, "target", {"b.py": "world"}, parent=c1.commit_id, write_objects=True)
173
174 plugin = _get_plugin(repo)
175
176 result = replay_one(repo, c2, c1.commit_id, plugin, "code", "test-repo", "main")
177
178 # Should return a new CommitRecord (or conflict list), NOT raise
179 assert result is not None
180 # If clean merge, result is a CommitRecord
181 from muse.core.store import CommitRecord as CR
182 if isinstance(result, CR):
183 assert result.message == c2.message
184
185 def test_before_fix_would_produce_wrong_manifest(self, tmp_path: pathlib.Path) -> None:
186 """Document the pre-fix behavior: missing snapshot → empty theirs_manifest.
187
188 With theirs_manifest={}, the three-way merge would treat ALL files
189 in the commit as deleted — producing a rebased commit with no content.
190 """
191 repo = _make_repo(tmp_path)
192 c1 = _write_commit(repo, "initial", {"a.py": "a" * 64})
193 c2 = _write_commit(repo, "target", {"b.py": "b" * 64}, parent=c1.commit_id)
194
195 # Simulate the pre-fix fallback
196 from muse.core.store import read_snapshot
197 theirs_snap = read_snapshot(repo, c2.snapshot_id)
198 assert theirs_snap is not None # snapshot exists
199
200 # Now delete it to show what would happen
201 snap_path = snapshot_path(repo, c2.snapshot_id)
202 snap_path.unlink()
203
204 theirs_snap = read_snapshot(repo, c2.snapshot_id)
205 old_behavior_manifest: Manifest = theirs_snap.manifest if theirs_snap else {}
206
207 # Pre-fix: empty manifest would be used, silently deleting b.py
208 assert old_behavior_manifest == {}, (
209 "BUG 14: missing snapshot caused theirs_manifest={} in replay_one, "
210 "which would silently delete all files from the rebased commit"
211 )
212
213
214 # ──────────────────────────────────────────────────────────────────────────────
215 # Integration: snapshot missing guard in rebase path
216 # ──────────────────────────────────────────────────────────────────────────────
217
218 class TestRebaseSnapshotMissingIntegration:
219
220 def test_replay_one_raises_valueerror_not_returns_empty_commit(self, tmp_path: pathlib.Path) -> None:
221 """ValueError from replay_one must propagate — not be swallowed."""
222 repo = _make_repo(tmp_path)
223 c1 = _write_commit(repo, "initial", {"main.py": "a" * 64})
224 write_branch_ref(repo, "main", c1.commit_id)
225 c2 = _write_commit(repo, "add feature", {"feature.py": "b" * 64}, parent=c1.commit_id)
226
227 # Remove snapshot for c2
228 (snapshot_path(repo, c2.snapshot_id)).unlink()
229
230 plugin = _get_plugin(repo)
231
232 # Should raise, not silently return an empty commit
233 with pytest.raises(ValueError):
234 replay_one(repo, c2, c1.commit_id, plugin, "code", "test-repo", "main")
235
236 # c2's commit still exists on disk (replay_one didn't corrupt anything)
237 from muse.core.store import read_commit
238 assert read_commit(repo, c2.commit_id) is not None, (
239 "replay_one raising ValueError must not corrupt the original commit"
240 )
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 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago