gabriel / muse public
test_commit_object_store_completeness.py python
335 lines 12.4 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
1 """Tests for the invariant: after muse commit, every object in the snapshot
2 manifest is present in the local object store.
3
4 Bug description
5 ---------------
6 ``muse commit`` skips writing objects for files that are unchanged from the
7 parent commit, on the assumption that "their objects are already in the store."
8 The assumption is wrong when parent objects have been removed (e.g. after a
9 fresh clone without fetching blobs, after ``muse gc``, or when the very first
10 commit on the repo happened without the object store being populated).
11
12 The consequence is that ``apply_manifest`` raises ``RuntimeError`` at the end
13 of ``commit``, and subsequent ``checkout`` commands fail with "missing objects"
14 errors even though the commit record exists in ``.muse/commits/``.
15
16 The invariant these tests enforce
17 ----------------------------------
18 ∀ (path, oid) ∈ snapshot.manifest → has_object(repo, oid) is True
19
20 immediately after a successful ``muse commit`` returns exit code 0.
21 """
22
23 from __future__ import annotations
24
25 import os
26 import pathlib
27
28 import pytest
29
30 from tests.cli_test_helper import CliRunner
31 from muse.core._types import long_id
32 from muse.core.object_store import has_object, iter_stored_objects, object_path
33 from muse.core.store import (
34 get_head_commit_id,
35 read_commit,
36 read_current_branch,
37 read_snapshot,
38 )
39
40 runner = CliRunner()
41
42
43 # ---------------------------------------------------------------------------
44 # Helpers
45 # ---------------------------------------------------------------------------
46
47
48 def _invoke(repo: pathlib.Path, args: list[str]):
49 saved = os.getcwd()
50 try:
51 os.chdir(repo)
52 return runner.invoke(None, args)
53 finally:
54 os.chdir(saved)
55
56
57 def _commit(repo: pathlib.Path, *extra: str):
58 return _invoke(repo, ["commit", *extra])
59
60
61 def _init_repo(repo: pathlib.Path) -> None:
62 repo.mkdir(parents=True, exist_ok=True)
63 result = _invoke(repo, ["init"])
64 assert result.exit_code == 0, f"muse init failed: {result.output}"
65
66
67 def _head_snapshot_manifest(repo: pathlib.Path) -> dict[str, str]:
68 """Return the manifest dict for the current HEAD commit."""
69 branch = read_current_branch(repo)
70 cid = get_head_commit_id(repo, branch)
71 assert cid is not None
72 rec = read_commit(repo, cid)
73 assert rec is not None
74 snap = read_snapshot(repo, rec.snapshot_id)
75 assert snap is not None
76 return snap.manifest
77
78
79 def _delete_all_objects(repo: pathlib.Path) -> list[str]:
80 """Remove all blob objects from the local store; return deleted OIDs."""
81 deleted: list[str] = []
82 for oid, obj_file in iter_stored_objects(repo):
83 obj_file.unlink()
84 deleted.append(oid)
85 return deleted
86
87
88 # ---------------------------------------------------------------------------
89 # Fixture
90 # ---------------------------------------------------------------------------
91
92
93 @pytest.fixture()
94 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
95 """Initialised code repo with one file ready to commit."""
96 _init_repo(tmp_path)
97 (tmp_path / "main.py").write_text("x = 1\n")
98 return tmp_path
99
100
101 # ---------------------------------------------------------------------------
102 # TestAllObjectsInStoreAfterCommit
103 #
104 # Core invariant: every object referenced by the committed snapshot is present
105 # in the local object store immediately after a successful commit.
106 # ---------------------------------------------------------------------------
107
108
109 class TestAllObjectsInStoreAfterCommit:
110 def test_first_commit_stores_all_objects(self, repo: pathlib.Path) -> None:
111 """Every object in the first commit's manifest is in the store."""
112 result = _commit(repo, "-m", "first")
113 assert result.exit_code == 0, result.output
114
115 manifest = _head_snapshot_manifest(repo)
116 assert manifest, "Manifest must not be empty"
117 for path, oid in manifest.items():
118 assert has_object(repo, oid), (
119 f"Object for '{path}' ({oid[:20]}…) missing after first commit"
120 )
121
122 def test_second_commit_stores_all_objects(self, repo: pathlib.Path) -> None:
123 """Every object in the second commit's manifest is in the store."""
124 _commit(repo, "-m", "first")
125 (repo / "util.py").write_text("y = 2\n")
126 result = _commit(repo, "-m", "second")
127 assert result.exit_code == 0, result.output
128
129 manifest = _head_snapshot_manifest(repo)
130 for path, oid in manifest.items():
131 assert has_object(repo, oid), (
132 f"Object for '{path}' ({oid[:20]}…) missing after second commit"
133 )
134
135 def test_unchanged_file_object_present_after_second_commit(
136 self, repo: pathlib.Path
137 ) -> None:
138 """An unchanged file's object from a prior commit is still accessible."""
139 _commit(repo, "-m", "first")
140 manifest_1 = _head_snapshot_manifest(repo)
141
142 # Add a new file; main.py is UNCHANGED.
143 (repo / "extra.py").write_text("z = 3\n")
144 result = _commit(repo, "-m", "second")
145 assert result.exit_code == 0, result.output
146
147 manifest_2 = _head_snapshot_manifest(repo)
148 # The unchanged file's object ID is the same in both manifests.
149 for path, oid in manifest_2.items():
150 if manifest_1.get(path) == oid:
151 # This is an UNCHANGED file — its object must still be in the store.
152 assert has_object(repo, oid), (
153 f"Object for unchanged '{path}' ({oid[:20]}…) missing after second commit"
154 )
155
156 def test_parent_objects_missing_rewritten_on_next_commit(
157 self, repo: pathlib.Path
158 ) -> None:
159 """THE BUG: if parent objects are deleted, the next commit must restore them.
160
161 Scenario:
162 1. First commit stores objects for main.py.
163 2. All objects are deleted from the store (simulating a clone without blobs).
164 3. A new file is added; main.py is unchanged.
165 4. Second commit runs.
166
167 BEFORE THE FIX: main.py's object is skipped ("unchanged from parent")
168 → has_object(repo, oid_main) is False after the commit.
169
170 AFTER THE FIX: the commit notices the object is missing and writes it.
171 → has_object(repo, oid_main) is True.
172 """
173 _commit(repo, "-m", "first")
174 manifest_1 = _head_snapshot_manifest(repo)
175
176 # Simulate objects disappearing (clone without objects, gc, corruption).
177 deleted = _delete_all_objects(repo)
178 assert deleted, "Expected at least one object to have been written by first commit"
179
180 # Verify the objects are actually gone.
181 for path, oid in manifest_1.items():
182 assert not has_object(repo, oid), (
183 f"Expected object for '{path}' to be absent before second commit"
184 )
185
186 # Add a new file so the snapshot changes (otherwise "nothing to commit").
187 (repo / "new.py").write_text("new = True\n")
188 result = _commit(repo, "-m", "second")
189 assert result.exit_code == 0, (
190 f"Commit failed with missing parent objects: {result.output}"
191 )
192
193 # INVARIANT: every object in the new manifest must be in the store.
194 manifest_2 = _head_snapshot_manifest(repo)
195 missing = [
196 (path, oid)
197 for path, oid in manifest_2.items()
198 if not has_object(repo, oid)
199 ]
200 assert not missing, (
201 "Objects missing from store after commit:\n"
202 + "\n".join(f" {p}: {o[:20]}…" for p, o in missing)
203 )
204
205 def test_commit_does_not_leave_partial_state_on_apply_manifest_failure(
206 self, repo: pathlib.Path
207 ) -> None:
208 """If apply_manifest would fail, commit must not succeed.
209
210 After the fix, apply_manifest never fails because all objects are
211 written before it is called. This test confirms that a commit with
212 missing parent objects completes without raising RuntimeError.
213 """
214 _commit(repo, "-m", "first")
215 _delete_all_objects(repo)
216 (repo / "extra.py").write_text("extra = 1\n")
217
218 # Must not raise RuntimeError("apply_manifest: N object(s) missing …")
219 result = _commit(repo, "-m", "after deletion")
220 assert result.exit_code == 0, (
221 f"Commit raised an exception or exited non-zero: {result.output}"
222 )
223 assert "missing" not in result.output.lower(), (
224 f"Unexpected 'missing' in commit output: {result.output}"
225 )
226
227
228 # ---------------------------------------------------------------------------
229 # TestCheckoutAfterCommit
230 #
231 # Regression: checkout must succeed after a commit that had missing parent
232 # objects. Before the fix, checkout would fail with "N object(s) not in
233 # local store."
234 # ---------------------------------------------------------------------------
235
236
237 class TestCheckoutAfterCommit:
238 def test_checkout_after_commit_with_missing_parent_objects(
239 self, tmp_path: pathlib.Path
240 ) -> None:
241 """Checkout must not fail due to missing objects after a commit.
242
243 Regression test for: muse checkout <branch> failing with
244 '11 object(s) not in local store' immediately after muse commit.
245 """
246 repo = tmp_path / "repo"
247 _init_repo(repo)
248 (repo / "main.py").write_text("x = 1\n")
249 _commit(repo, "-m", "first")
250
251 # Create a second branch so we have something to checkout to.
252 result = _invoke(repo, ["checkout", "-b", "feature"])
253 assert result.exit_code == 0, f"checkout -b feature failed: {result.output}"
254
255 # Switch back to main.
256 result = _invoke(repo, ["checkout", "main"])
257 assert result.exit_code == 0, f"checkout main failed: {result.output}"
258
259 # Delete objects and make a new commit on main.
260 _delete_all_objects(repo)
261 (repo / "extra.py").write_text("extra = 1\n")
262 result = _commit(repo, "-m", "second")
263 assert result.exit_code == 0, f"commit failed: {result.output}"
264
265 # REGRESSION: checkout must succeed after the fixed commit.
266 result = _invoke(repo, ["checkout", "feature"])
267 assert result.exit_code == 0, (
268 f"checkout failed after commit with missing parent objects:\n{result.output}"
269 )
270
271 def test_checkout_back_and_forth_after_multi_commit_session(
272 self, tmp_path: pathlib.Path
273 ) -> None:
274 """Multiple commits with object deletions between them; checkout works."""
275 repo = tmp_path / "repo"
276 _init_repo(repo)
277 (repo / "a.py").write_text("a = 1\n")
278 _commit(repo, "-m", "c1")
279
280 _invoke(repo, ["checkout", "-b", "dev"])
281 (repo / "b.py").write_text("b = 2\n")
282 _commit(repo, "-m", "c2")
283
284 _delete_all_objects(repo)
285 (repo / "c.py").write_text("c = 3\n")
286 _commit(repo, "-m", "c3")
287
288 # Checkout main — should restore working tree to c1 state.
289 result = _invoke(repo, ["checkout", "main"])
290 assert result.exit_code == 0, (
291 f"checkout main failed: {result.output}"
292 )
293
294 # Checkout dev again.
295 result = _invoke(repo, ["checkout", "dev"])
296 assert result.exit_code == 0, (
297 f"checkout dev failed: {result.output}"
298 )
299
300
301 # ---------------------------------------------------------------------------
302 # TestApplyManifestAfterCommit
303 #
304 # Direct verification that apply_manifest does not raise RuntimeError
305 # when called with the manifest from the latest commit.
306 # ---------------------------------------------------------------------------
307
308
309 class TestApplyManifestAfterCommit:
310 def test_apply_manifest_does_not_raise_after_commit(
311 self, repo: pathlib.Path
312 ) -> None:
313 """apply_manifest must not raise after a successful commit."""
314 from muse.core.workdir import apply_manifest
315
316 _commit(repo, "-m", "first")
317 manifest = _head_snapshot_manifest(repo)
318
319 # This must not raise RuntimeError("apply_manifest: N object(s) missing …")
320 apply_manifest(repo, manifest)
321
322 def test_apply_manifest_does_not_raise_when_parent_objects_were_missing(
323 self, repo: pathlib.Path
324 ) -> None:
325 """apply_manifest works even if parent objects were absent before commit."""
326 from muse.core.workdir import apply_manifest
327
328 _commit(repo, "-m", "first")
329 _delete_all_objects(repo)
330 (repo / "new.py").write_text("n = 0\n")
331 result = _commit(repo, "-m", "second")
332 assert result.exit_code == 0, result.output
333
334 manifest = _head_snapshot_manifest(repo)
335 apply_manifest(repo, manifest) # must not raise
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago