"""Tests for the invariant: after muse commit, every object in the snapshot manifest is present in the local object store. Bug description --------------- ``muse commit`` skips writing objects for files that are unchanged from the parent commit, on the assumption that "their objects are already in the store." The assumption is wrong when parent objects have been removed (e.g. after a fresh clone without fetching blobs, after ``muse gc``, or when the very first commit on the repo happened without the object store being populated). The consequence is that ``apply_manifest`` raises ``RuntimeError`` at the end of ``commit``, and subsequent ``checkout`` commands fail with "missing objects" errors even though the commit record exists in ``.muse/commits/``. The invariant these tests enforce ---------------------------------- ∀ (path, oid) ∈ snapshot.manifest → has_object(repo, oid) is True immediately after a successful ``muse commit`` returns exit code 0. """ from __future__ import annotations from collections.abc import Mapping import os import pathlib import pytest from tests.cli_test_helper import CliRunner, InvokeResult from muse.core.types import long_id from muse.core.object_store import has_object, iter_stored_objects, object_path from muse.core.refs import ( get_head_commit_id, read_current_branch, ) from muse.core.commits import read_commit from muse.core.snapshots import read_snapshot runner = CliRunner() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult: saved = os.getcwd() try: os.chdir(repo) return runner.invoke(None, args) finally: os.chdir(saved) def _commit(repo: pathlib.Path, *extra: str) -> InvokeResult: return _invoke(repo, ["commit", *extra]) def _add(repo: pathlib.Path, *paths: str) -> None: _invoke(repo, ["code", "add", *paths]) def _init_repo(repo: pathlib.Path) -> None: repo.mkdir(parents=True, exist_ok=True) result = _invoke(repo, ["init"]) assert result.exit_code == 0, f"muse init failed: {result.output}" def _head_snapshot_manifest(repo: pathlib.Path) -> Mapping[str, str]: """Return the manifest dict for the current HEAD commit.""" branch = read_current_branch(repo) cid = get_head_commit_id(repo, branch) assert cid is not None rec = read_commit(repo, cid) assert rec is not None snap = read_snapshot(repo, rec.snapshot_id) assert snap is not None return snap.manifest def _delete_all_objects(repo: pathlib.Path) -> list[str]: """Remove all blob objects from the local store; return deleted OIDs.""" deleted: list[str] = [] for oid, obj_file in iter_stored_objects(repo): obj_file.unlink() deleted.append(oid) return deleted # --------------------------------------------------------------------------- # Fixture # --------------------------------------------------------------------------- @pytest.fixture() def repo(tmp_path: pathlib.Path) -> pathlib.Path: """Initialised code repo with one file ready to commit.""" _init_repo(tmp_path) (tmp_path / "main.py").write_text("x = 1\n") return tmp_path # --------------------------------------------------------------------------- # TestAllObjectsInStoreAfterCommit # # Core invariant: every object referenced by the committed snapshot is present # in the local object store immediately after a successful commit. # --------------------------------------------------------------------------- class TestAllObjectsInStoreAfterCommit: def test_first_commit_stores_all_objects(self, repo: pathlib.Path) -> None: """Every object in the first commit's manifest is in the store.""" result = _commit(repo, "-m", "first") assert result.exit_code == 0, result.output manifest = _head_snapshot_manifest(repo) assert manifest, "Manifest must not be empty" for path, oid in manifest.items(): assert has_object(repo, oid), ( f"Object for '{path}' ({oid[:20]}…) missing after first commit" ) def test_second_commit_stores_all_objects(self, repo: pathlib.Path) -> None: """Every object in the second commit's manifest is in the store.""" _commit(repo, "-m", "first") (repo / "util.py").write_text("y = 2\n") _add(repo, "util.py") result = _commit(repo, "-m", "second") assert result.exit_code == 0, result.output manifest = _head_snapshot_manifest(repo) for path, oid in manifest.items(): assert has_object(repo, oid), ( f"Object for '{path}' ({oid[:20]}…) missing after second commit" ) def test_unchanged_file_object_present_after_second_commit( self, repo: pathlib.Path ) -> None: """An unchanged file's object from a prior commit is still accessible.""" _commit(repo, "-m", "first") manifest_1 = _head_snapshot_manifest(repo) # Add a new file; main.py is UNCHANGED. (repo / "extra.py").write_text("z = 3\n") _add(repo, "extra.py") result = _commit(repo, "-m", "second") assert result.exit_code == 0, result.output manifest_2 = _head_snapshot_manifest(repo) # The unchanged file's object ID is the same in both manifests. for path, oid in manifest_2.items(): if manifest_1.get(path) == oid: # This is an UNCHANGED file — its object must still be in the store. assert has_object(repo, oid), ( f"Object for unchanged '{path}' ({oid[:20]}…) missing after second commit" ) def test_parent_objects_missing_rewritten_on_next_commit( self, repo: pathlib.Path ) -> None: """THE BUG: if parent objects are deleted, the next commit must restore them. Scenario: 1. First commit stores objects for main.py. 2. All objects are deleted from the store (simulating a clone without blobs). 3. A new file is added; main.py is unchanged. 4. Second commit runs. BEFORE THE FIX: main.py's object is skipped ("unchanged from parent") → has_object(repo, oid_main) is False after the commit. AFTER THE FIX: the commit notices the object is missing and writes it. → has_object(repo, oid_main) is True. """ _commit(repo, "-m", "first") manifest_1 = _head_snapshot_manifest(repo) # Simulate objects disappearing (clone without objects, gc, corruption). deleted = _delete_all_objects(repo) assert deleted, "Expected at least one object to have been written by first commit" # Verify the objects are actually gone. for path, oid in manifest_1.items(): assert not has_object(repo, oid), ( f"Expected object for '{path}' to be absent before second commit" ) # Add a new file so the snapshot changes (otherwise "nothing to commit"). (repo / "new.py").write_text("new = True\n") result = _commit(repo, "-m", "second") assert result.exit_code == 0, ( f"Commit failed with missing parent objects: {result.output}" ) # INVARIANT: every object in the new manifest must be in the store. manifest_2 = _head_snapshot_manifest(repo) missing = [ (path, oid) for path, oid in manifest_2.items() if not has_object(repo, oid) ] assert not missing, ( "Objects missing from store after commit:\n" + "\n".join(f" {p}: {o[:20]}…" for p, o in missing) ) def test_commit_does_not_leave_partial_state_on_apply_manifest_failure( self, repo: pathlib.Path ) -> None: """If apply_manifest would fail, commit must not succeed. After the fix, apply_manifest never fails because all objects are written before it is called. This test confirms that a commit with missing parent objects completes without raising RuntimeError. """ _commit(repo, "-m", "first") _delete_all_objects(repo) (repo / "extra.py").write_text("extra = 1\n") # Must not raise RuntimeError("apply_manifest: N object(s) missing …") result = _commit(repo, "-m", "after deletion") assert result.exit_code == 0, ( f"Commit raised an exception or exited non-zero: {result.output}" ) assert "missing" not in result.output.lower(), ( f"Unexpected 'missing' in commit output: {result.output}" ) # --------------------------------------------------------------------------- # TestCheckoutAfterCommit # # Regression: checkout must succeed after a commit that had missing parent # objects. Before the fix, checkout would fail with "N object(s) not in # local store." # --------------------------------------------------------------------------- class TestCheckoutAfterCommit: def test_checkout_after_commit_with_missing_parent_objects( self, tmp_path: pathlib.Path ) -> None: """Checkout must not fail due to missing objects after a commit. Regression test for: muse checkout failing with '11 object(s) not in local store' immediately after muse commit. """ repo = tmp_path / "repo" _init_repo(repo) (repo / "main.py").write_text("x = 1\n") _commit(repo, "-m", "first") # Create a second branch so we have something to checkout to. result = _invoke(repo, ["checkout", "-b", "feature"]) assert result.exit_code == 0, f"checkout -b feature failed: {result.output}" # Switch back to main. result = _invoke(repo, ["checkout", "main"]) assert result.exit_code == 0, f"checkout main failed: {result.output}" # Delete objects and make a new commit on main. _delete_all_objects(repo) (repo / "extra.py").write_text("extra = 1\n") result = _commit(repo, "-m", "second") assert result.exit_code == 0, f"commit failed: {result.output}" # REGRESSION: checkout must succeed after the fixed commit. result = _invoke(repo, ["checkout", "feature"]) assert result.exit_code == 0, ( f"checkout failed after commit with missing parent objects:\n{result.output}" ) def test_checkout_back_and_forth_after_multi_commit_session( self, tmp_path: pathlib.Path ) -> None: """Multiple commits with object deletions between them; checkout works.""" repo = tmp_path / "repo" _init_repo(repo) (repo / "a.py").write_text("a = 1\n") _commit(repo, "-m", "c1") _invoke(repo, ["checkout", "-b", "dev"]) (repo / "b.py").write_text("b = 2\n") _commit(repo, "-m", "c2") _delete_all_objects(repo) (repo / "c.py").write_text("c = 3\n") _commit(repo, "-m", "c3") # Checkout main — should restore working tree to c1 state. result = _invoke(repo, ["checkout", "main"]) assert result.exit_code == 0, ( f"checkout main failed: {result.output}" ) # Checkout dev again. result = _invoke(repo, ["checkout", "dev"]) assert result.exit_code == 0, ( f"checkout dev failed: {result.output}" ) # --------------------------------------------------------------------------- # TestApplyManifestAfterCommit # # Direct verification that apply_manifest does not raise RuntimeError # when called with the manifest from the latest commit. # --------------------------------------------------------------------------- class TestApplyManifestAfterCommit: def test_apply_manifest_does_not_raise_after_commit( self, repo: pathlib.Path ) -> None: """apply_manifest must not raise after a successful commit.""" from muse.core.workdir import apply_manifest _commit(repo, "-m", "first") manifest = _head_snapshot_manifest(repo) # This must not raise RuntimeError("apply_manifest: N object(s) missing …") apply_manifest(repo, {}, manifest) def test_apply_manifest_does_not_raise_when_parent_objects_were_missing( self, repo: pathlib.Path ) -> None: """apply_manifest works even if parent objects were absent before commit.""" from muse.core.workdir import apply_manifest _commit(repo, "-m", "first") _delete_all_objects(repo) (repo / "new.py").write_text("n = 0\n") result = _commit(repo, "-m", "second") assert result.exit_code == 0, result.output manifest = _head_snapshot_manifest(repo) apply_manifest(repo, {}, manifest) # must not raise