"""Phase 4 of #185 (musehub staging): disposable sandbox tooling. sandbox_refresh() clones a canonical repo into a timestamped, disposable copy via APFS copy-on-write (`cp -c -R`), so real dev/testing of muse's own engine code never touches the canonical object store. These tests always point `canonical_root`/`sandbox_base` at tmp_path fixtures — never the real ~/ecosystem/muse — same discipline as test_dev_guard.py. """ import os import subprocess import sys from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts" / "dev")) from sandbox import refresh_sandbox, resolve_current_sandbox, SandboxNotFoundError # noqa: E402 def _make_fake_repo(root: Path, branches: dict[str, str]) -> None: """A minimal fake muse repo: a .muse/refs dir with branch -> commit_id files.""" refs = root / ".muse" / "refs" / "heads" refs.mkdir(parents=True) for branch, commit_id in branches.items(): (refs / branch).write_text(commit_id) (root / "working-file.txt").write_text("hello\n") def _read_branch_heads(root: Path) -> dict[str, str]: refs = root / ".muse" / "refs" / "heads" return {p.name: p.read_text() for p in refs.iterdir()} class TestRefreshSandbox: def test_sandbox_branch_heads_match_canonical_exactly(self, tmp_path: Path) -> None: canonical = tmp_path / "ecosystem" / "muse" _make_fake_repo(canonical, {"dev": "sha256:aaa", "main": "sha256:bbb"}) sandbox_base = tmp_path / "sandboxes" sandbox_path = refresh_sandbox("muse", canonical_root=canonical, sandbox_base=sandbox_base, backup_base=tmp_path / "backups") assert _read_branch_heads(sandbox_path) == _read_branch_heads(canonical) assert (sandbox_path / "working-file.txt").read_text() == "hello\n" def test_current_symlink_points_at_latest_refresh(self, tmp_path: Path) -> None: canonical = tmp_path / "ecosystem" / "muse" _make_fake_repo(canonical, {"dev": "sha256:aaa"}) sandbox_base = tmp_path / "sandboxes" first = refresh_sandbox("muse", canonical_root=canonical, sandbox_base=sandbox_base, backup_base=tmp_path / "backups") second = refresh_sandbox("muse", canonical_root=canonical, sandbox_base=sandbox_base, backup_base=tmp_path / "backups") current = resolve_current_sandbox("muse", sandbox_base=sandbox_base) assert current.resolve() == second.resolve() assert first != second def test_rotation_keeps_only_newest_n(self, tmp_path: Path) -> None: canonical = tmp_path / "ecosystem" / "muse" _make_fake_repo(canonical, {"dev": "sha256:aaa"}) sandbox_base = tmp_path / "sandboxes" paths = [ refresh_sandbox("muse", canonical_root=canonical, sandbox_base=sandbox_base, keep=3, backup_base=tmp_path / "backups") for _ in range(6) ] remaining = { p for p in (sandbox_base / "muse-sandbox").iterdir() if p.is_dir() and not p.is_symlink() } assert len(remaining) == 3 assert remaining == set(paths[-3:]) def test_corruption_in_sandbox_never_touches_canonical(self, tmp_path: Path) -> None: canonical = tmp_path / "ecosystem" / "muse" _make_fake_repo(canonical, {"dev": "sha256:aaa"}) sandbox_base = tmp_path / "sandboxes" sandbox_path = refresh_sandbox("muse", canonical_root=canonical, sandbox_base=sandbox_base, backup_base=tmp_path / "backups") # Corrupt the sandbox's copy. (sandbox_path / ".muse" / "refs" / "heads" / "dev").write_text("CORRUPTED") assert _read_branch_heads(sandbox_path)["dev"] == "CORRUPTED" assert _read_branch_heads(canonical)["dev"] == "sha256:aaa" def test_editing_canonical_after_refresh_does_not_affect_existing_sandbox(self, tmp_path: Path) -> None: # Proves cp -c produced an independent copy, not a shared/linked view — # APFS clonefile is copy-on-write, so writes to either side must diverge. canonical = tmp_path / "ecosystem" / "muse" _make_fake_repo(canonical, {"dev": "sha256:aaa"}) sandbox_base = tmp_path / "sandboxes" sandbox_path = refresh_sandbox("muse", canonical_root=canonical, sandbox_base=sandbox_base, backup_base=tmp_path / "backups") (canonical / ".muse" / "refs" / "heads" / "dev").write_text("sha256:changed-after-refresh") assert _read_branch_heads(sandbox_path)["dev"] == "sha256:aaa" def test_unknown_repo_name_raises_clear_error(self, tmp_path: Path) -> None: with pytest.raises(ValueError, match="Unknown repo"): refresh_sandbox("not-a-real-repo", sandbox_base=tmp_path / "sandboxes") class TestSandboxRefreshTakesSnapshotFirst: """Phase 6 of #185: sandbox-refresh always snapshots canonical first — cheap insurance even though the sandbox itself never touches canonical.""" def test_refresh_creates_a_snapshot_backup_before_cloning(self, tmp_path: Path) -> None: canonical = tmp_path / "ecosystem" / "muse" _make_fake_repo(canonical, {"main": "sha256:aaa"}) sandbox_base = tmp_path / "sandboxes" backup_base = tmp_path / "backups" refresh_sandbox( "muse", canonical_root=canonical, sandbox_base=sandbox_base, backup_base=backup_base, ) from backup import list_snapshots snaps = list_snapshots("muse", backup_base=backup_base) assert len(snaps) == 1 assert (snaps[0] / ".muse" / "refs" / "heads" / "main").read_text() == "sha256:aaa" def test_refresh_still_works_when_backup_base_is_not_given(self, tmp_path: Path) -> None: # backup_base defaults to the real ~/dev/backups when unset — callers # that don't care about the snapshot side-effect (e.g. most existing # tests in this file) must not be forced to pass it. canonical = tmp_path / "ecosystem" / "muse" _make_fake_repo(canonical, {"main": "sha256:aaa"}) sandbox_base = tmp_path / "sandboxes" import backup as backup_mod real_default = backup_mod.DEFAULT_BACKUP_BASE backup_mod.DEFAULT_BACKUP_BASE = tmp_path / "backups-default" try: path = refresh_sandbox("muse", canonical_root=canonical, sandbox_base=sandbox_base) finally: backup_mod.DEFAULT_BACKUP_BASE = real_default assert path.exists() assert (tmp_path / "backups-default" / "muse-snapshots").exists() class TestResolveCurrentSandbox: def test_raises_clear_error_when_never_refreshed(self, tmp_path: Path) -> None: with pytest.raises(SandboxNotFoundError, match="sandbox-refresh"): resolve_current_sandbox("muse", sandbox_base=tmp_path / "sandboxes") class TestSandboxRunScript: 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_sandbox_run_invokes_muse_dev_against_current_sandbox(self, tmp_path: Path) -> None: if self._muse_dev_path() is None: pytest.skip("muse-dev not installed on this machine yet (Phase 2 not applied)") canonical = tmp_path / "ecosystem" / "muse" canonical.mkdir(parents=True) # tests/conftest.py's autouse _isolate_muse_home fixture patches # Path.home() to a fake tmp dir for every test (so nothing here can # touch the real ~/.muse/) — the real HOME env var is untouched. real_muse_repo = Path(os.environ["HOME"]) / "ecosystem" / "muse" # A real (not fake) .muse/ is needed here since we're about to run a # genuine `muse-dev status` against it — clone the real canonical # repo's .muse/ read-only via the same cp -c mechanism refresh_sandbox # itself uses, rather than hand-rolling a fake object store. subprocess.run(["cp", "-c", "-R", str(real_muse_repo / ".muse"), str(canonical / ".muse")], check=True, capture_output=True) (canonical / "working-file.txt").write_text("hello\n") sandbox_base = tmp_path / "sandboxes" refresh_sandbox("muse", canonical_root=canonical, sandbox_base=sandbox_base, backup_base=tmp_path / "backups") script = str(Path(__file__).resolve().parents[1] / "scripts" / "dev" / "sandbox-run.sh") proc = subprocess.run( [script, "muse", "--sandbox-base", str(sandbox_base), "--", "status", "--json"], capture_output=True, text=True, ) assert proc.returncode == 0, proc.stderr assert '"branch"' in proc.stdout