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