"""Tests for ``muse code migrate``. Coverage tiers -------------- - Unit: _should_exclude, _manifest_directories (pure Python, no git) - Integration: full migrate against a real git repo — single branch, multi-commit, rename/delete, exclusion patterns, --dry-run, --all branches, --json NDJSON - Regression: bare-assert fix (_CatFile raises RuntimeError, not AssertionError) Git fixture ----------- Tests that exercise the git integration create a minimal git repo in ``tmp_path`` via subprocess. This is the single approved git usage in the Muse test suite — the migrate command's sole purpose is to bridge FROM git INTO Muse. """ from __future__ import annotations import hashlib import json import pathlib import subprocess import pytest from muse.core.store import read_snapshot from tests.cli_test_helper import CliRunner runner = CliRunner() # --------------------------------------------------------------------------- # Git fixture helpers # --------------------------------------------------------------------------- def _git(path: pathlib.Path, *args: str) -> str: result = subprocess.run( ["git", *args], cwd=path, capture_output=True, text=True, check=True, ) return result.stdout.strip() def _make_git_repo(path: pathlib.Path) -> pathlib.Path: """Initialise a minimal git repo with stable author/committer identity.""" _git(path, "init", "-b", "main") _git(path, "config", "user.email", "test@muse.test") _git(path, "config", "user.name", "Muse Test") _git(path, "config", "commit.gpgsign", "false") return path def _commit(path: pathlib.Path, message: str, files: dict[str, str]) -> str: """Write *files* and create a git commit; return the SHA.""" for name, content in files.items(): fp = path / name fp.parent.mkdir(parents=True, exist_ok=True) fp.write_text(content, encoding="utf-8") _git(path, "add", name) _git(path, "commit", "-m", message) return _git(path, "rev-parse", "HEAD") def _migrate( git_repo: pathlib.Path, target: pathlib.Path, extra: list[str] | None = None, ) -> "CliRunner.__class__.__call__": # type: ignore[name-defined] args = ["code", "migrate", str(git_repo), "--target", str(target)] if extra: args += extra return runner.invoke(None, args) def _latest_manifest(muse_root: pathlib.Path) -> dict[str, str]: """Return the manifest of the most recently written snapshot.""" snaps_dir = muse_root / ".muse" / "snapshots" snap_files = sorted(snaps_dir.iterdir(), key=lambda p: p.stat().st_mtime) assert snap_files, "No snapshots found in muse repo" # Snapshots are stored as msgpack — use the public store API to read them. for snap_path in reversed(snap_files): snap_id = snap_path.stem # filename without extension snap = read_snapshot(muse_root, snap_id) if snap is not None: return snap.manifest raise AssertionError("Could not read any snapshot") # --------------------------------------------------------------------------- # Unit — _should_exclude # --------------------------------------------------------------------------- class TestShouldExclude: from muse.cli.commands.migrate import _should_exclude def test_default_git_dir_excluded(self) -> None: from muse.cli.commands.migrate import _should_exclude assert _should_exclude(".git/config", (), ()) def test_default_muse_dir_excluded(self) -> None: from muse.cli.commands.migrate import _should_exclude assert _should_exclude(".muse/HEAD", (), ()) def test_default_pycache_excluded(self) -> None: from muse.cli.commands.migrate import _should_exclude assert _should_exclude("__pycache__/foo.pyc", (), ()) def test_default_pyc_suffix_excluded(self) -> None: from muse.cli.commands.migrate import _should_exclude assert _should_exclude("muse/core/store.pyc", (), ()) def test_default_ds_store_excluded(self) -> None: from muse.cli.commands.migrate import _should_exclude assert _should_exclude(".DS_Store", (), ()) def test_normal_py_file_not_excluded(self) -> None: from muse.cli.commands.migrate import _should_exclude assert not _should_exclude("muse/core/store.py", (), ()) def test_extra_prefix_excluded(self) -> None: from muse.cli.commands.migrate import _should_exclude assert _should_exclude("dist/wheel.whl", ("dist/",), ()) def test_extra_suffix_excluded(self) -> None: from muse.cli.commands.migrate import _should_exclude assert _should_exclude("package.lock", (), (".lock",)) def test_node_modules_excluded(self) -> None: from muse.cli.commands.migrate import _should_exclude assert _should_exclude("node_modules/lodash/index.js", (), ()) def test_venv_excluded(self) -> None: from muse.cli.commands.migrate import _should_exclude assert _should_exclude(".venv/bin/python", (), ()) def test_root_level_normal_file_not_excluded(self) -> None: from muse.cli.commands.migrate import _should_exclude assert not _should_exclude("README.md", (), ()) def test_prefix_dir_itself_excluded(self) -> None: from muse.cli.commands.migrate import _should_exclude # Path equal to the stripped prefix should still match. assert _should_exclude(".git", (), ()) # --------------------------------------------------------------------------- # Unit — _manifest_directories # --------------------------------------------------------------------------- class TestManifestDirectories: def test_empty_manifest_gives_empty_list(self) -> None: from muse.cli.commands.migrate import _manifest_directories assert _manifest_directories({}) == [] def test_root_level_files_give_no_dirs(self) -> None: from muse.cli.commands.migrate import _manifest_directories result = _manifest_directories({"README.md": "abc", "main.py": "def"}) assert result == [] def test_nested_file_gives_parent_dirs(self) -> None: from muse.cli.commands.migrate import _manifest_directories result = _manifest_directories({"a/b/c.py": "oid"}) assert "a" in result assert "a/b" in result def test_dirs_are_sorted(self) -> None: from muse.cli.commands.migrate import _manifest_directories result = _manifest_directories({ "z/file.py": "o1", "a/file.py": "o2", "m/sub/file.py": "o3", }) assert result == sorted(result) def test_shared_parents_deduplicated(self) -> None: from muse.cli.commands.migrate import _manifest_directories result = _manifest_directories({ "src/foo.py": "o1", "src/bar.py": "o2", }) assert result.count("src") == 1 def test_deep_nesting(self) -> None: from muse.cli.commands.migrate import _manifest_directories result = _manifest_directories({"a/b/c/d/e.py": "o"}) assert set(result) == {"a", "a/b", "a/b/c", "a/b/c/d"} # --------------------------------------------------------------------------- # Integration — migrate a real git repo # --------------------------------------------------------------------------- @pytest.fixture() def git_repo(tmp_path: pathlib.Path) -> pathlib.Path: """A fresh git repo at tmp_path/git.""" repo = tmp_path / "git" repo.mkdir() return _make_git_repo(repo) @pytest.fixture() def muse_target(tmp_path: pathlib.Path) -> pathlib.Path: """Empty dir at tmp_path/muse for the migrate target.""" t = tmp_path / "muse" t.mkdir() return t class TestMigrateSingleBranch: def test_single_commit_writes_muse_repo( self, git_repo: pathlib.Path, muse_target: pathlib.Path ) -> None: _commit(git_repo, "init", {"hello.py": "print('hi')"}) result = _migrate(git_repo, muse_target) assert result.exit_code == 0, result.output assert (muse_target / ".muse" / "commits").exists() assert (muse_target / ".muse" / "snapshots").exists() assert (muse_target / ".muse" / "objects").exists() def test_commit_count_matches( self, git_repo: pathlib.Path, muse_target: pathlib.Path ) -> None: _commit(git_repo, "c1", {"a.py": "a"}) _commit(git_repo, "c2", {"b.py": "b"}) _commit(git_repo, "c3", {"c.py": "c"}) result = _migrate(git_repo, muse_target) assert result.exit_code == 0, result.output commits = list((muse_target / ".muse" / "commits").iterdir()) assert len(commits) == 3 def test_object_content_round_trips( self, git_repo: pathlib.Path, muse_target: pathlib.Path ) -> None: content = "Hello, Muse!\n" _commit(git_repo, "add file", {"hello.txt": content}) result = _migrate(git_repo, muse_target) assert result.exit_code == 0, result.output # Find the object by SHA-256 of the content. oid = hashlib.sha256(content.encode()).hexdigest() obj_path = muse_target / ".muse" / "objects" / oid[:2] / oid[2:] assert obj_path.exists() assert obj_path.read_bytes() == content.encode() def test_file_modification_tracked( self, git_repo: pathlib.Path, muse_target: pathlib.Path ) -> None: _commit(git_repo, "add", {"file.py": "v1"}) _commit(git_repo, "modify", {"file.py": "v2"}) result = _migrate(git_repo, muse_target) assert result.exit_code == 0, result.output # Both versions should be in the object store. oid_v1 = hashlib.sha256(b"v1").hexdigest() oid_v2 = hashlib.sha256(b"v2").hexdigest() assert (muse_target / ".muse" / "objects" / oid_v1[:2] / oid_v1[2:]).exists() assert (muse_target / ".muse" / "objects" / oid_v2[:2] / oid_v2[2:]).exists() def test_deleted_file_not_in_final_snapshot( self, git_repo: pathlib.Path, muse_target: pathlib.Path ) -> None: _commit(git_repo, "add", {"gone.py": "content", "stay.py": "keep"}) _git(git_repo, "rm", "gone.py") _git(git_repo, "commit", "-m", "delete gone.py") result = _migrate(git_repo, muse_target) assert result.exit_code == 0, result.output manifest = _latest_manifest(muse_target) assert "gone.py" not in manifest assert "stay.py" in manifest def test_renamed_file_in_final_snapshot( self, git_repo: pathlib.Path, muse_target: pathlib.Path ) -> None: _commit(git_repo, "add", {"old.py": "content"}) _git(git_repo, "mv", "old.py", "new.py") _git(git_repo, "commit", "-m", "rename") result = _migrate(git_repo, muse_target) assert result.exit_code == 0, result.output manifest = _latest_manifest(muse_target) assert "new.py" in manifest assert "old.py" not in manifest def test_excluded_files_not_in_object_store( self, git_repo: pathlib.Path, muse_target: pathlib.Path ) -> None: # .tmp is in _DEFAULT_EXCLUDE_SUFFIXES; .py is not excluded. _commit(git_repo, "add", {"keep.py": "keep", "skip.tmp": "skip"}) result = _migrate(git_repo, muse_target) assert result.exit_code == 0, result.output manifest = _latest_manifest(muse_target) assert "keep.py" in manifest assert "skip.tmp" not in manifest def test_extra_exclude_pattern( self, git_repo: pathlib.Path, muse_target: pathlib.Path ) -> None: _commit(git_repo, "add", {"keep.py": "k", "dist/wheel.whl": "w"}) result = _migrate(git_repo, muse_target, extra=["--exclude", "dist/"]) assert result.exit_code == 0, result.output manifest = _latest_manifest(muse_target) assert "dist/wheel.whl" not in manifest assert "keep.py" in manifest class TestMigrateDryRun: def test_dry_run_writes_nothing( self, git_repo: pathlib.Path, muse_target: pathlib.Path ) -> None: _commit(git_repo, "c1", {"a.py": "a"}) result = _migrate(git_repo, muse_target, extra=["--dry-run"]) assert result.exit_code == 0, result.output # No commits written in dry-run. commits_dir = muse_target / ".muse" / "commits" if commits_dir.exists(): assert list(commits_dir.iterdir()) == [] def test_dry_run_json_marks_dry_run_true( self, git_repo: pathlib.Path, muse_target: pathlib.Path ) -> None: # Use --json so the done event is parseable regardless of logging config. _commit(git_repo, "c1", {"a.py": "a"}) result = _migrate(git_repo, muse_target, extra=["--dry-run", "--json"]) assert result.exit_code == 0, result.output lines = [ln.strip() for ln in result.output.splitlines() if ln.strip()] events = [json.loads(ln) for ln in lines] done = next(e for e in events if e["event"] == "done") assert done["dry_run"] is True class TestMigrateJson: def test_json_emits_done_event( self, git_repo: pathlib.Path, muse_target: pathlib.Path ) -> None: _commit(git_repo, "c1", {"a.py": "a"}) result = _migrate(git_repo, muse_target, extra=["--json"]) assert result.exit_code == 0, result.output lines = [ln for ln in result.output.strip().splitlines() if ln.strip()] events = [json.loads(ln) for ln in lines] event_types = [e["event"] for e in events] assert "done" in event_types done = next(e for e in events if e["event"] == "done") assert done["total_commits_written"] == 1 assert "elapsed_seconds" in done assert done["source"] == str(git_repo) assert done["target"] == str(muse_target) def test_json_emits_branch_start_and_done( self, git_repo: pathlib.Path, muse_target: pathlib.Path ) -> None: _commit(git_repo, "c1", {"a.py": "a"}) result = _migrate(git_repo, muse_target, extra=["--json"]) assert result.exit_code == 0, result.output lines = [ln for ln in result.output.strip().splitlines() if ln.strip()] events = [json.loads(ln) for ln in lines] event_types = [e["event"] for e in events] assert "branch_start" in event_types assert "branch_done" in event_types def test_json_emits_progress_events( self, git_repo: pathlib.Path, muse_target: pathlib.Path ) -> None: for i in range(5): _commit(git_repo, f"c{i}", {f"f{i}.py": str(i)}) result = _migrate(git_repo, muse_target, extra=["--json"]) assert result.exit_code == 0, result.output lines = [ln for ln in result.output.strip().splitlines() if ln.strip()] events = [json.loads(ln) for ln in lines] progress = [e for e in events if e["event"] == "progress"] assert len(progress) >= 1 for p in progress: assert "committed" in p assert "total" in p assert "branch" in p def test_json_all_lines_are_valid_json( self, git_repo: pathlib.Path, muse_target: pathlib.Path ) -> None: _commit(git_repo, "c1", {"a.py": "a"}) _commit(git_repo, "c2", {"b.py": "b"}) result = _migrate(git_repo, muse_target, extra=["--json"]) assert result.exit_code == 0, result.output for line in result.output.strip().splitlines(): line = line.strip() if line: json.loads(line) # must not raise def test_json_dry_run_done_event_has_dry_run_true( self, git_repo: pathlib.Path, muse_target: pathlib.Path ) -> None: _commit(git_repo, "c1", {"a.py": "a"}) result = _migrate(git_repo, muse_target, extra=["--json", "--dry-run"]) assert result.exit_code == 0, result.output lines = [ln.strip() for ln in result.output.strip().splitlines() if ln.strip()] events = [json.loads(ln) for ln in lines] done = next(e for e in events if e["event"] == "done") assert done["dry_run"] is True class TestMigrateMultiBranch: def test_all_flag_migrates_multiple_branches( self, git_repo: pathlib.Path, muse_target: pathlib.Path ) -> None: _commit(git_repo, "root", {"base.py": "base"}) _git(git_repo, "checkout", "-b", "feature") _commit(git_repo, "feat", {"feat.py": "feat"}) _git(git_repo, "checkout", "main") result = _migrate(git_repo, muse_target, extra=["--all"]) assert result.exit_code == 0, result.output # Both branches should exist in the muse repo. assert (muse_target / ".muse" / "refs" / "heads" / "main").exists() assert (muse_target / ".muse" / "refs" / "heads" / "feature").exists() def test_branch_flag_selects_specific_branch( self, git_repo: pathlib.Path, muse_target: pathlib.Path ) -> None: _commit(git_repo, "root", {"base.py": "base"}) _git(git_repo, "checkout", "-b", "feature") _commit(git_repo, "feat", {"feat.py": "feat"}) _git(git_repo, "checkout", "main") result = _migrate(git_repo, muse_target, extra=["--branch", "main"]) assert result.exit_code == 0, result.output assert (muse_target / ".muse" / "refs" / "heads" / "main").exists() # feature branch was not requested. assert not (muse_target / ".muse" / "refs" / "heads" / "feature").exists() class TestMigrateErrors: def test_not_a_git_repo_exits_1(self, tmp_path: pathlib.Path) -> None: not_git = tmp_path / "not_git" not_git.mkdir() target = tmp_path / "muse" target.mkdir() result = _migrate(not_git, target) assert result.exit_code == 1 def test_no_init_flag_skips_muse_init( self, git_repo: pathlib.Path, muse_target: pathlib.Path ) -> None: # Without a pre-existing .muse/repo.json and with --no-init, migrate # should fail cleanly (cannot load repo_id). _commit(git_repo, "c1", {"a.py": "a"}) result = _migrate(git_repo, muse_target, extra=["--no-init"]) # Will either fail or succeed depending on implementation, but must not crash. assert result.exit_code in (0, 1) # --------------------------------------------------------------------------- # Regression — _CatFile raises RuntimeError, not AssertionError # --------------------------------------------------------------------------- class TestCatFileRegression: def test_catfile_raises_on_pipe_failure(self) -> None: """_CatFile must use RuntimeError, not bare assert, for pipe checks.""" import subprocess as sp from muse.cli.commands.migrate import _CatFile # Patch Popen to return a proc with stdin=None to simulate pipe failure. original_popen = sp.Popen class _FakeProc: stdin = None stdout = None def kill(self) -> None: pass import unittest.mock as mock with mock.patch("muse.cli.commands.migrate.subprocess.Popen", return_value=_FakeProc()): with pytest.raises(RuntimeError): _CatFile(pathlib.Path("/tmp"))