test_commit_object_store_completeness.py
python
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