gabriel / muse public
test_rebase_missing_snapshot_guard.py python
236 lines 9.5 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 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, long_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 import uuid
58 repo = tmp_path / "repo"
59 repo.mkdir()
60 muse = repo / ".muse"
61 (muse / "commits").mkdir(parents=True)
62 (muse / "snapshots").mkdir()
63 (muse / "objects").mkdir()
64 (muse / "refs" / "heads").mkdir(parents=True)
65 (muse / "HEAD").write_text("ref: refs/heads/main\n")
66 (muse / "refs" / "heads" / "main").write_text("")
67 (muse / "repo.json").write_text(json.dumps({
68 "repo_id": str(uuid.uuid4()),
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 import hashlib
85 from muse.core.object_store import write_object
86 real_manifest: Manifest = {}
87 for path, content in manifest.items():
88 raw = content.encode()
89 oid = long_id(hashlib.sha256(raw).hexdigest())
90 write_object(repo, oid, raw)
91 real_manifest[path] = oid
92 manifest = real_manifest
93
94 snap_id = compute_snapshot_id(manifest)
95 snap = SnapshotRecord(snapshot_id=snap_id, manifest=manifest, created_at=_TS)
96 write_snapshot(repo, snap)
97 parent_ids = [parent] if parent else []
98 cid = compute_commit_id(parent_ids, snap_id, message, _TS.isoformat())
99 c = CommitRecord(
100 commit_id=cid,
101 repo_id="test-repo",
102 branch="main",
103 snapshot_id=snap_id,
104 message=message,
105 committed_at=_TS,
106 author="tester",
107 parent_commit_id=parent,
108 parent2_commit_id=None,
109 )
110 write_commit(repo, c)
111 return c
112
113
114 def _get_plugin(repo: pathlib.Path) -> "MuseDomainPlugin":
115 from muse.plugins.registry import resolve_plugin
116 return resolve_plugin(repo)
117
118
119 # ──────────────────────────────────────────────────────────────────────────────
120 # Unit: replay_one raises when commit snapshot is missing
121 # ──────────────────────────────────────────────────────────────────────────────
122
123 class TestReplayOneMissingSnapshot:
124
125 def test_replay_one_raises_valueerror_when_snapshot_missing(self, tmp_path: pathlib.Path) -> None:
126 """Bug 14: replay_one must raise ValueError when the commit's snapshot is missing."""
127 repo = _make_repo(tmp_path)
128
129 # Base: initial commit
130 c1 = _write_commit(repo, "initial", {"a.py": "a" * 64})
131 write_branch_ref(repo, "main", c1.commit_id)
132
133 # The commit to replay
134 c2 = _write_commit(repo, "target", {"b.py": "b" * 64}, parent=c1.commit_id)
135
136 # Delete c2's snapshot to simulate corruption
137 snap_path = _snapshot_path(repo, c2.snapshot_id)
138 snap_path.unlink()
139
140 plugin = _get_plugin(repo)
141 domain = "code"
142 repo_id = "test-repo"
143
144 with pytest.raises(ValueError, match="missing or corrupt"):
145 replay_one(repo, c2, c1.commit_id, plugin, domain, repo_id, "main")
146
147 def test_replay_one_raises_when_snapshot_corrupt(self, tmp_path: pathlib.Path) -> None:
148 """replay_one must raise ValueError when the commit's snapshot is corrupt."""
149 repo = _make_repo(tmp_path)
150 c1 = _write_commit(repo, "initial", {"a.py": "a" * 64})
151 write_branch_ref(repo, "main", c1.commit_id)
152 c2 = _write_commit(repo, "target", {"b.py": "b" * 64}, parent=c1.commit_id)
153
154 # Corrupt c2's snapshot
155 snap_path = _snapshot_path(repo, c2.snapshot_id)
156 snap_path.write_bytes(b"\xff\x00garbage-bytes")
157
158 plugin = _get_plugin(repo)
159
160 with pytest.raises(ValueError, match="missing or corrupt"):
161 replay_one(repo, c2, c1.commit_id, plugin, "code", "test-repo", "main")
162
163 def test_replay_one_succeeds_when_all_snapshots_present(self, tmp_path: pathlib.Path) -> None:
164 """Regression: replay_one must work normally when all snapshots exist."""
165 repo = _make_repo(tmp_path)
166 c1 = _write_commit(repo, "initial", {"a.py": "hello"}, write_objects=True)
167 write_branch_ref(repo, "main", c1.commit_id)
168 c2 = _write_commit(repo, "target", {"b.py": "world"}, parent=c1.commit_id, write_objects=True)
169
170 plugin = _get_plugin(repo)
171
172 result = replay_one(repo, c2, c1.commit_id, plugin, "code", "test-repo", "main")
173
174 # Should return a new CommitRecord (or conflict list), NOT raise
175 assert result is not None
176 # If clean merge, result is a CommitRecord
177 from muse.core.store import CommitRecord as CR
178 if isinstance(result, CR):
179 assert result.message == c2.message
180
181 def test_before_fix_would_produce_wrong_manifest(self, tmp_path: pathlib.Path) -> None:
182 """Document the pre-fix behavior: missing snapshot → empty theirs_manifest.
183
184 With theirs_manifest={}, the three-way merge would treat ALL files
185 in the commit as deleted — producing a rebased commit with no content.
186 """
187 repo = _make_repo(tmp_path)
188 c1 = _write_commit(repo, "initial", {"a.py": "a" * 64})
189 c2 = _write_commit(repo, "target", {"b.py": "b" * 64}, parent=c1.commit_id)
190
191 # Simulate the pre-fix fallback
192 from muse.core.store import read_snapshot
193 theirs_snap = read_snapshot(repo, c2.snapshot_id)
194 assert theirs_snap is not None # snapshot exists
195
196 # Now delete it to show what would happen
197 snap_path = _snapshot_path(repo, c2.snapshot_id)
198 snap_path.unlink()
199
200 theirs_snap = read_snapshot(repo, c2.snapshot_id)
201 old_behavior_manifest: Manifest = theirs_snap.manifest if theirs_snap else {}
202
203 # Pre-fix: empty manifest would be used, silently deleting b.py
204 assert old_behavior_manifest == {}, (
205 "BUG 14: missing snapshot caused theirs_manifest={} in replay_one, "
206 "which would silently delete all files from the rebased commit"
207 )
208
209
210 # ──────────────────────────────────────────────────────────────────────────────
211 # Integration: snapshot missing guard in rebase path
212 # ──────────────────────────────────────────────────────────────────────────────
213
214 class TestRebaseSnapshotMissingIntegration:
215
216 def test_replay_one_raises_valueerror_not_returns_empty_commit(self, tmp_path: pathlib.Path) -> None:
217 """ValueError from replay_one must propagate — not be swallowed."""
218 repo = _make_repo(tmp_path)
219 c1 = _write_commit(repo, "initial", {"main.py": "a" * 64})
220 write_branch_ref(repo, "main", c1.commit_id)
221 c2 = _write_commit(repo, "add feature", {"feature.py": "b" * 64}, parent=c1.commit_id)
222
223 # Remove snapshot for c2
224 (_snapshot_path(repo, c2.snapshot_id)).unlink()
225
226 plugin = _get_plugin(repo)
227
228 # Should raise, not silently return an empty commit
229 with pytest.raises(ValueError):
230 replay_one(repo, c2, c1.commit_id, plugin, "code", "test-repo", "main")
231
232 # c2's commit still exists on disk (replay_one didn't corrupt anything)
233 from muse.core.store import read_commit
234 assert read_commit(repo, c2.commit_id) is not None, (
235 "replay_one raising ValueError must not corrupt the original commit"
236 )
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago