"""Phase 3 of #185 (musehub staging): guard rail against mutating an editable build's own canonical repo. Unit tests exercise the classifier functions directly. The integration test runs the real `muse-dev` binary end-to-end but points `MUSE_DEV_PROTECTED_ROOTS` at a disposable temp repo, never the real ~/ecosystem/muse — this guard rail must never be integration-tested against the actual canonical repo it exists to protect. """ import argparse import os import subprocess import sys from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "muse" / "cli")) from dev_guard import ( # noqa: E402 GuardBlocked, check_guard, classify_is_mutating, is_protected_root, ) def _ns(**kwargs) -> argparse.Namespace: return argparse.Namespace(**kwargs) class TestIsProtectedRoot: def test_exact_match_is_protected(self, tmp_path: Path) -> None: assert is_protected_root(str(tmp_path), protected_roots=[str(tmp_path)]) def test_subdirectory_of_protected_root_is_protected(self, tmp_path: Path) -> None: sub = tmp_path / "nested" / "deep" sub.mkdir(parents=True) assert is_protected_root(str(sub), protected_roots=[str(tmp_path)]) def test_sibling_path_is_not_protected(self, tmp_path: Path) -> None: sibling = tmp_path.parent / "not-protected" assert not is_protected_root(str(sibling), protected_roots=[str(tmp_path)]) def test_unrelated_third_party_repo_is_not_protected(self, tmp_path: Path) -> None: assert not is_protected_root("/some/other/repo", protected_roots=[str(tmp_path)]) class TestClassifyIsMutating: @pytest.mark.parametrize("command", ["commit", "merge", "push", "pull", "reset", "rm", "revert", "rebase", "gc", "prune", "clean"]) def test_known_mutating_top_level_commands(self, command: str) -> None: assert classify_is_mutating(_ns(command=command)) @pytest.mark.parametrize("command", ["status", "log", "diff", "branch", "rev-parse"]) def test_known_readonly_top_level_commands(self, command: str) -> None: assert not classify_is_mutating(_ns(command=command)) def test_code_grep_is_readonly(self) -> None: assert not classify_is_mutating(_ns(command="code", code_command="grep")) def test_code_add_is_mutating(self) -> None: assert classify_is_mutating(_ns(command="code", code_command="add")) def test_code_impact_is_readonly(self) -> None: assert not classify_is_mutating(_ns(command="code", code_command="impact")) def test_code_patch_is_mutating(self) -> None: assert classify_is_mutating(_ns(command="code", code_command="patch")) class TestCheckGuard: def test_mutating_command_against_protected_root_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("MUSE_DEV_ALLOW_CANONICAL", raising=False) with pytest.raises(GuardBlocked): check_guard(_ns(command="commit"), cwd=str(tmp_path), protected_roots=[str(tmp_path)], is_editable=True) def test_mutating_command_with_override_env_passes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("MUSE_DEV_ALLOW_CANONICAL", "1") check_guard(_ns(command="commit"), cwd=str(tmp_path), protected_roots=[str(tmp_path)], is_editable=True) # must not raise def test_readonly_command_against_protected_root_passes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("MUSE_DEV_ALLOW_CANONICAL", raising=False) check_guard(_ns(command="status"), cwd=str(tmp_path), protected_roots=[str(tmp_path)], is_editable=True) # must not raise def test_mutating_command_against_unprotected_root_passes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("MUSE_DEV_ALLOW_CANONICAL", raising=False) other = tmp_path / "sandbox" other.mkdir() check_guard(_ns(command="commit"), cwd=str(other), protected_roots=[str(tmp_path / "protected")], is_editable=True) # must not raise def test_stable_non_editable_build_is_never_blocked(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("MUSE_DEV_ALLOW_CANONICAL", raising=False) # A stable (non-editable) `muse` build has no live-source hazard — # the guard only ever applies to editable/dev builds. check_guard(_ns(command="commit"), cwd=str(tmp_path), protected_roots=[str(tmp_path)], is_editable=False) # must not raise class TestRealMuseDevBinaryIntegration: """Exercises the real `muse-dev` binary — but only against a disposable temp repo pointed to via MUSE_DEV_PROTECTED_ROOTS, never the real checkout.""" def _muse_dev_path(self) -> str | None: proc = subprocess.run(["zsh", "-lc", "command -v muse-dev"], capture_output=True, text=True) return proc.stdout.strip() or None def test_commit_blocked_against_fake_protected_root(self, tmp_path: Path) -> None: muse_dev = self._muse_dev_path() if muse_dev is None: pytest.skip("muse-dev not installed on this machine yet (Phase 2 not applied)") repo = tmp_path / "fake-canonical-repo" repo.mkdir() subprocess.run([muse_dev, "init"], cwd=repo, capture_output=True, text=True, check=True) (repo / "file.txt").write_text("hello\n") subprocess.run([muse_dev, "code", "add", "."], cwd=repo, capture_output=True, text=True, check=True) env = dict(os.environ, MUSE_DEV_PROTECTED_ROOTS=str(repo)) env.pop("MUSE_DEV_ALLOW_CANONICAL", None) proc = subprocess.run( [muse_dev, "commit", "-m", "should be blocked"], cwd=repo, capture_output=True, text=True, env=env, ) assert proc.returncode != 0 assert "MUSE_DEV_ALLOW_CANONICAL" in (proc.stdout + proc.stderr) def test_commit_allowed_with_override(self, tmp_path: Path) -> None: muse_dev = self._muse_dev_path() if muse_dev is None: pytest.skip("muse-dev not installed on this machine yet (Phase 2 not applied)") repo = tmp_path / "fake-canonical-repo-override" repo.mkdir() subprocess.run([muse_dev, "init"], cwd=repo, capture_output=True, text=True, check=True) (repo / "file.txt").write_text("hello\n") subprocess.run([muse_dev, "code", "add", "."], cwd=repo, capture_output=True, text=True, check=True) env = dict(os.environ, MUSE_DEV_PROTECTED_ROOTS=str(repo), MUSE_DEV_ALLOW_CANONICAL="1") proc = subprocess.run( [muse_dev, "commit", "-m", "should be allowed"], cwd=repo, capture_output=True, text=True, env=env, ) assert proc.returncode == 0, proc.stderr def test_status_never_blocked_against_fake_protected_root(self, tmp_path: Path) -> None: muse_dev = self._muse_dev_path() if muse_dev is None: pytest.skip("muse-dev not installed on this machine yet (Phase 2 not applied)") repo = tmp_path / "fake-canonical-repo-readonly" repo.mkdir() subprocess.run([muse_dev, "init"], cwd=repo, capture_output=True, text=True, check=True) env = dict(os.environ, MUSE_DEV_PROTECTED_ROOTS=str(repo)) env.pop("MUSE_DEV_ALLOW_CANONICAL", None) proc = subprocess.run([muse_dev, "status"], cwd=repo, capture_output=True, text=True, env=env) assert proc.returncode == 0, proc.stderr