"""Tests for muse.core.store — file-based commit and snapshot storage.""" import datetime import json import pathlib import pytest from muse.core.snapshot import compute_commit_id, compute_snapshot_id from muse.core._types import Manifest from muse.core.store import ( CommitDict, CommitRecord, SnapshotRecord, TagRecord, _resolve_branch_commit_id, find_commits_by_prefix, get_all_commits, get_all_tags, get_commits_for_branch, get_head_commit_id, get_head_snapshot_id, get_head_snapshot_manifest, get_tags_for_commit, read_commit, read_snapshot, update_commit_metadata, write_commit, write_snapshot, write_tag, ) @pytest.fixture def repo(tmp_path: pathlib.Path) -> pathlib.Path: """Create a minimal .muse/ directory structure.""" muse_dir = tmp_path / ".muse" (muse_dir / "commits").mkdir(parents=True) (muse_dir / "snapshots").mkdir(parents=True) (muse_dir / "refs" / "heads").mkdir(parents=True) (muse_dir / "repo.json").write_text(json.dumps({"repo_id": "test-repo"})) (muse_dir / "HEAD").write_text("ref: refs/heads/main\n") (muse_dir / "refs" / "heads" / "main").write_text("") return tmp_path def _make_commit( root: pathlib.Path, commit_id: str, snapshot_id: str, message: str, parent: str | None = None, ) -> CommitRecord: """Write a commit whose ID is computed from its content fields. The ``commit_id`` parameter is kept for call-site readability but is ignored — the real ID is derived by :func:`~muse.core.snapshot.compute_commit_id` so every stored commit satisfies the I-10 content-hash verification. """ now = datetime.datetime.now(datetime.timezone.utc) parents: list[str] = [parent] if parent else [] real_id = compute_commit_id(parents, snapshot_id, message, now.isoformat()) c = CommitRecord( commit_id=real_id, repo_id="test-repo", branch="main", snapshot_id=snapshot_id, message=message, committed_at=now, parent_commit_id=parent, ) write_commit(root, c) return c def _make_snapshot(root: pathlib.Path, snapshot_id: str, manifest: Manifest) -> SnapshotRecord: """Write a snapshot whose ID is computed from its manifest. The ``snapshot_id`` parameter is kept for call-site readability but is ignored — the real ID is derived by :func:`~muse.core.snapshot.compute_snapshot_id` so every stored snapshot satisfies the I-10 content-hash verification. """ real_id = compute_snapshot_id(manifest) s = SnapshotRecord(snapshot_id=real_id, manifest=manifest) write_snapshot(root, s) return s class TestFormatVersion: """CommitRecord.format_version tracks schema evolution.""" def test_new_commit_has_format_version_7(self, repo: pathlib.Path) -> None: """format_version 7 is the current default — Ed25519 provenance signing.""" c = _make_commit(repo, "ignored", "s" * 64, "msg") assert c.format_version == 7 def test_format_version_round_trips_through_store_v7(self, repo: pathlib.Path) -> None: """A v7 record serialises and deserialises with format_version preserved.""" c = _make_commit(repo, "ignored", "s" * 64, "msg") loaded = read_commit(repo, c.commit_id) assert loaded is not None assert loaded.format_version == 7 def test_format_version_in_serialised_dict(self) -> None: c = CommitRecord( commit_id="x", repo_id="r", branch="main", snapshot_id="s", message="m", committed_at=datetime.datetime.now(datetime.timezone.utc), ) d = c.to_dict() assert "format_version" in d assert d["format_version"] == 7 def test_missing_format_version_defaults_to_1(self) -> None: """Existing JSON without format_version field deserialises as version 1.""" raw = CommitDict( commit_id="abc", repo_id="r", branch="main", snapshot_id="s", message="old record", committed_at="2025-01-01T00:00:00+00:00", ) c = CommitRecord.from_dict(raw) assert c.format_version == 1 def test_explicit_format_version_preserved(self) -> None: raw = CommitDict( commit_id="abc", repo_id="r", branch="main", snapshot_id="s", message="versioned record", committed_at="2025-01-01T00:00:00+00:00", format_version=2, ) c = CommitRecord.from_dict(raw) assert c.format_version == 2 def test_format_version_field_is_integer(self, repo: pathlib.Path) -> None: c = _make_commit(repo, "ignored", "s" * 64, "msg") loaded = read_commit(repo, c.commit_id) assert loaded is not None assert isinstance(loaded.format_version, int) class TestWriteReadCommit: def test_roundtrip(self, repo: pathlib.Path) -> None: c = _make_commit(repo, "ignored", "s" * 64, "Initial commit") loaded = read_commit(repo, c.commit_id) assert loaded is not None assert loaded.commit_id == c.commit_id assert loaded.message == "Initial commit" assert loaded.repo_id == "test-repo" def test_read_missing_returns_none(self, repo: pathlib.Path) -> None: assert read_commit(repo, "nonexistent") is None def test_idempotent_write(self, repo: pathlib.Path) -> None: c = _make_commit(repo, "ignored", "s" * 64, "First") _make_commit(repo, "ignored", "s" * 64, "Second") # different timestamp → different ID; should write loaded = read_commit(repo, c.commit_id) assert loaded is not None assert loaded.message == "First" def test_metadata_preserved(self, repo: pathlib.Path) -> None: now = datetime.datetime.now(datetime.timezone.utc) snap_id = "s" * 64 cid = compute_commit_id([], snap_id, "With metadata", now.isoformat()) c = CommitRecord( commit_id=cid, repo_id="test-repo", branch="main", snapshot_id=snap_id, message="With metadata", committed_at=now, metadata={"section": "chorus", "emotion": "joyful"}, ) write_commit(repo, c) loaded = read_commit(repo, cid) assert loaded is not None assert loaded.metadata["section"] == "chorus" assert loaded.metadata["emotion"] == "joyful" class TestUpdateCommitMetadata: def test_set_key(self, repo: pathlib.Path) -> None: c = _make_commit(repo, "ignored", "s" * 64, "msg") result = update_commit_metadata(repo, c.commit_id, "tempo_bpm", "120.0") assert result is True loaded = read_commit(repo, c.commit_id) assert loaded is not None assert loaded.metadata["tempo_bpm"] == "120.0" def test_missing_commit_returns_false(self, repo: pathlib.Path) -> None: assert update_commit_metadata(repo, "missing", "k", "v") is False class TestWriteReadSnapshot: def test_roundtrip(self, repo: pathlib.Path) -> None: s = _make_snapshot(repo, "ignored", {"tracks/drums.mid": "deadbeef"}) loaded = read_snapshot(repo, s.snapshot_id) assert loaded is not None assert loaded.manifest == {"tracks/drums.mid": "deadbeef"} def test_read_missing_returns_none(self, repo: pathlib.Path) -> None: assert read_snapshot(repo, "nonexistent") is None class TestHeadQueries: def test_get_head_commit_id_empty_branch(self, repo: pathlib.Path) -> None: assert get_head_commit_id(repo, "main") is None def test_get_head_commit_id(self, repo: pathlib.Path) -> None: c = _make_commit(repo, "ignored", "s" * 64, "msg") (repo / ".muse" / "refs" / "heads" / "main").write_text(c.commit_id) assert get_head_commit_id(repo, "main") == c.commit_id def test_get_head_snapshot_id(self, repo: pathlib.Path) -> None: snap = _make_snapshot(repo, "ignored", {"f.mid": "hash1"}) c = _make_commit(repo, "ignored", snap.snapshot_id, "msg") (repo / ".muse" / "refs" / "heads" / "main").write_text(c.commit_id) assert get_head_snapshot_id(repo, "test-repo", "main") == snap.snapshot_id def test_get_head_snapshot_manifest(self, repo: pathlib.Path) -> None: snap = _make_snapshot(repo, "ignored", {"f.mid": "hash1"}) c = _make_commit(repo, "ignored", snap.snapshot_id, "msg") (repo / ".muse" / "refs" / "heads" / "main").write_text(c.commit_id) manifest = get_head_snapshot_manifest(repo, "test-repo", "main") assert manifest == {"f.mid": "hash1"} class TestResolveRemoteBranchCommitId: """_resolve_branch_commit_id handles local branches and remote tracking refs.""" def test_local_branch_resolved(self, repo: pathlib.Path) -> None: c = _make_commit(repo, "ignored", "s" * 64, "msg") (repo / ".muse" / "refs" / "heads" / "main").write_text(c.commit_id) assert _resolve_branch_commit_id(repo, "main") == c.commit_id def test_empty_local_branch_returns_none(self, repo: pathlib.Path) -> None: assert _resolve_branch_commit_id(repo, "main") is None def test_remote_tracking_ref_resolved(self, repo: pathlib.Path) -> None: remote_dir = repo / ".muse" / "remotes" / "origin" remote_dir.mkdir(parents=True) (remote_dir / "dev").write_text("a" * 64) assert _resolve_branch_commit_id(repo, "origin/dev") == "a" * 64 def test_remote_tracking_ref_missing_returns_none(self, repo: pathlib.Path) -> None: (repo / ".muse" / "remotes" / "origin").mkdir(parents=True) assert _resolve_branch_commit_id(repo, "origin/nonexistent") is None def test_remote_tracking_ref_no_remotes_dir_returns_none(self, repo: pathlib.Path) -> None: assert _resolve_branch_commit_id(repo, "origin/dev") is None def test_ref_without_slash_only_checks_local(self, repo: pathlib.Path) -> None: # "dev" has no slash — never checks .muse/remotes/ assert _resolve_branch_commit_id(repo, "dev") is None def test_local_branch_takes_priority_over_remote(self, repo: pathlib.Path) -> None: local_id = "b" * 64 remote_id = "c" * 64 # Both a local branch named "origin/dev" (pathological) and a remote ref exist. feat_dir = repo / ".muse" / "refs" / "heads" / "origin" feat_dir.mkdir(parents=True) (feat_dir / "dev").write_text(local_id) remote_dir = repo / ".muse" / "remotes" / "origin" remote_dir.mkdir(parents=True) (remote_dir / "dev").write_text(remote_id) # Local branch wins. assert _resolve_branch_commit_id(repo, "origin/dev") == local_id class TestGetCommitsForBranch: def test_chain(self, repo: pathlib.Path) -> None: root = _make_commit(repo, "ignored", "s" * 64, "Root") child = _make_commit(repo, "ignored", "t" * 64, "Child", parent=root.commit_id) grandchild = _make_commit(repo, "ignored", "u" * 64, "Grandchild", parent=child.commit_id) (repo / ".muse" / "refs" / "heads" / "main").write_text(grandchild.commit_id) commits = get_commits_for_branch(repo, "test-repo", "main") assert [c.commit_id for c in commits] == [ grandchild.commit_id, child.commit_id, root.commit_id ] def test_empty_branch(self, repo: pathlib.Path) -> None: assert get_commits_for_branch(repo, "test-repo", "main") == [] def test_remote_tracking_ref(self, repo: pathlib.Path) -> None: """get_commits_for_branch resolves 'origin/dev' via .muse/remotes/.""" c = _make_commit(repo, "ignored", "s" * 64, "Remote commit") remote_dir = repo / ".muse" / "remotes" / "origin" remote_dir.mkdir(parents=True) (remote_dir / "dev").write_text(c.commit_id) commits = get_commits_for_branch(repo, "test-repo", "origin/dev") assert len(commits) == 1 assert commits[0].commit_id == c.commit_id def test_remote_tracking_ref_chain(self, repo: pathlib.Path) -> None: root = _make_commit(repo, "ignored", "s" * 64, "Root") tip = _make_commit(repo, "ignored", "t" * 64, "Tip", parent=root.commit_id) remote_dir = repo / ".muse" / "remotes" / "upstream" remote_dir.mkdir(parents=True) (remote_dir / "main").write_text(tip.commit_id) commits = get_commits_for_branch(repo, "test-repo", "upstream/main") assert [c.commit_id for c in commits] == [tip.commit_id, root.commit_id] def test_remote_tracking_ref_missing_returns_empty(self, repo: pathlib.Path) -> None: assert get_commits_for_branch(repo, "test-repo", "origin/dev") == [] def test_max_count_with_remote_ref(self, repo: pathlib.Path) -> None: commits_written = [_make_commit(repo, "ignored", f"{'s' * 63}{i}", f"Commit {i}") for i in range(5)] for i in range(1, 5): commits_written[i] = _make_commit( repo, "ignored", f"{'t' * 63}{i}", f"C{i}", parent=commits_written[i - 1].commit_id ) tip = _make_commit(repo, "ignored", "u" * 64, "Tip", parent=commits_written[-1].commit_id) remote_dir = repo / ".muse" / "remotes" / "origin" remote_dir.mkdir(parents=True) (remote_dir / "dev").write_text(tip.commit_id) commits = get_commits_for_branch(repo, "test-repo", "origin/dev", max_count=2) assert len(commits) == 2 assert commits[0].commit_id == tip.commit_id class TestFindByPrefix: def test_finds_match(self, repo: pathlib.Path) -> None: c = _make_commit(repo, "ignored", "s" * 64, "msg") results = find_commits_by_prefix(repo, c.commit_id[:6]) assert len(results) == 1 assert results[0].commit_id == c.commit_id def test_no_match(self, repo: pathlib.Path) -> None: assert find_commits_by_prefix(repo, "zzz") == [] class TestTags: def test_write_and_read(self, repo: pathlib.Path) -> None: c = _make_commit(repo, "ignored", "s" * 64, "msg") write_tag(repo, TagRecord( tag_id="tag1", repo_id="test-repo", commit_id=c.commit_id, tag="emotion:joyful", )) tags = get_tags_for_commit(repo, "test-repo", c.commit_id) assert len(tags) == 1 assert tags[0].tag == "emotion:joyful" def test_get_all_tags(self, repo: pathlib.Path) -> None: c = _make_commit(repo, "ignored", "s" * 64, "msg") write_tag(repo, TagRecord(tag_id="t1", repo_id="test-repo", commit_id=c.commit_id, tag="stage:rough-mix")) write_tag(repo, TagRecord(tag_id="t2", repo_id="test-repo", commit_id=c.commit_id, tag="key:Am")) all_tags = get_all_tags(repo, "test-repo") assert len(all_tags) == 2