"""CLI integration tests for: reflog, gc, archive, bisect, blame, worktree, workspace.""" from __future__ import annotations import datetime import json import pathlib import pytest from tests.cli_test_helper import CliRunner from muse.core.ids import hash_commit, hash_snapshot from muse.core.commits import ( CommitRecord, write_commit, ) from muse.core.snapshots import ( SnapshotRecord, write_snapshot, ) from muse.core.types import NULL_COMMIT_ID, blob_id, split_id from muse.core.object_store import object_path, write_object from muse.core.paths import heads_dir, muse_dir cli = None # argparse migration — CliRunner ignores this arg runner = CliRunner() def _make_repo( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> tuple[pathlib.Path, str]: """Create a minimal repo with one commit, one file tracked. Sets cwd. Returns ``(repo_path, commit_id)`` so callers that chain further commits can pass the real content-addressed ID to ``_add_commits``. """ monkeypatch.chdir(tmp_path) muse = muse_dir(tmp_path) for d in ("objects", "commits", "snapshots", "refs/heads", "logs/refs/heads"): (muse / d).mkdir(parents=True, exist_ok=True) (muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo"})) (muse / "HEAD").write_text("ref: refs/heads/main\n") content = b"hello world\n" oid = blob_id(content) write_object(tmp_path, oid, content) snap_id = hash_snapshot({"hello.txt": oid}) write_snapshot(tmp_path, SnapshotRecord(snapshot_id=snap_id, manifest={"hello.txt": oid})) committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) commit_id = hash_commit( parent_ids=[], snapshot_id=snap_id, message="initial commit", committed_at_iso=committed_at.isoformat(), author="Test User", ) write_commit(tmp_path, CommitRecord( commit_id=commit_id, branch="main", snapshot_id=snap_id, message="initial commit", committed_at=committed_at, author="Test User", )) (muse / "refs" / "heads" / "main").write_text(commit_id) return tmp_path, commit_id def _add_commits(repo: pathlib.Path, n: int, parent: str) -> list[str]: """Append *n* commits to the main branch, return all commit IDs. Uses content-addressed IDs (``hash_commit``) so every commit passes ``_verify_commit_id`` on read-back. Timestamps are pinned to deterministic values (2026-01-02 + i days) to keep IDs stable. """ snap_id = hash_snapshot({}) write_snapshot(repo, SnapshotRecord(snapshot_id=snap_id, manifest={})) commit_ids = [parent] prev = parent for i in range(n): at = datetime.datetime(2026, 1, 2 + i, tzinfo=datetime.timezone.utc) msg = f"commit {i + 1}" cid = hash_commit( parent_ids=[prev], snapshot_id=snap_id, message=msg, committed_at_iso=at.isoformat(), author="Test", ) write_commit(repo, CommitRecord( commit_id=cid, branch="main", snapshot_id=snap_id, message=msg, committed_at=at, parent_commit_id=prev, author="Test", )) commit_ids.append(cid) prev = cid (heads_dir(repo) / "main").write_text(commit_ids[-1]) return commit_ids # --------------------------------------------------------------------------- # muse reflog # --------------------------------------------------------------------------- class TestReflogCli: def test_reflog_no_entries_exits_ok( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) result = runner.invoke(cli, ["reflog"], catch_exceptions=False) assert result.exit_code == 0 assert "No reflog entries" in result.output def test_reflog_shows_entries( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: from muse.core.reflog import append_reflog _make_repo(tmp_path, monkeypatch) append_reflog(tmp_path, "main", old_id=None, new_id="c" * 64, author="A", operation="commit: test") result = runner.invoke(cli, ["reflog"], catch_exceptions=False) assert result.exit_code == 0 assert "commit: test" in result.output def test_reflog_all_flag( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: from muse.core.reflog import append_reflog _make_repo(tmp_path, monkeypatch) append_reflog(tmp_path, "main", old_id=None, new_id="c" * 64, author="A", operation="commit: x") result = runner.invoke(cli, ["reflog", "--all"], catch_exceptions=False) assert result.exit_code == 0 assert "refs/heads/main" in result.output def test_reflog_branch_filter( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: from muse.core.reflog import append_reflog _make_repo(tmp_path, monkeypatch) append_reflog(tmp_path, "dev", old_id=None, new_id="d" * 64, author="A", operation="commit: dev") result = runner.invoke(cli, ["reflog", "--branch", "dev"], catch_exceptions=False) assert result.exit_code == 0 assert "commit: dev" in result.output def test_reflog_limit( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: from muse.core.reflog import append_reflog _make_repo(tmp_path, monkeypatch) for i in range(10): append_reflog(tmp_path, "main", old_id=None, new_id="c" * 64, author="A", operation=f"commit: {i}") result = runner.invoke(cli, ["reflog", "--limit", "3"], catch_exceptions=False) assert result.exit_code == 0 # At most 3 @{N} entries. lines = [l for l in result.output.splitlines() if l.startswith("@{")] assert len(lines) <= 3 def test_reflog_shows_at_index_format( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: from muse.core.reflog import append_reflog _make_repo(tmp_path, monkeypatch) append_reflog(tmp_path, "main", old_id=None, new_id="c" * 64, author="A", operation="commit: x") result = runner.invoke(cli, ["reflog"], catch_exceptions=False) # Format is @{N:...} so just check the @ prefix. assert "@{" in result.output # --------------------------------------------------------------------------- # muse gc # --------------------------------------------------------------------------- class TestGcCli: def test_gc_empty_reports_zero( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) result = runner.invoke(cli, ["gc"], catch_exceptions=False) assert result.exit_code == 0 assert "0 object" in result.output def test_gc_dry_run_does_not_delete( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) orphan_content = b"totally orphaned" oid = blob_id(orphan_content) write_object(tmp_path, oid, orphan_content) obj_file = object_path(tmp_path, oid) result = runner.invoke(cli, ["gc", "--dry-run"], catch_exceptions=False) assert result.exit_code == 0 assert "dry-run" in result.output assert obj_file.exists() def test_gc_removes_orphan( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) orphan_content = b"not referenced anywhere at all" oid = blob_id(orphan_content) write_object(tmp_path, oid, orphan_content) obj_file = object_path(tmp_path, oid) result = runner.invoke(cli, ["gc", "--grace-period", "0"], catch_exceptions=False) assert result.exit_code == 0 assert not obj_file.exists() def test_gc_verbose_lists_objects( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) orphan_content = b"verbose orphan" oid = blob_id(orphan_content) write_object(tmp_path, oid, orphan_content) result = runner.invoke(cli, ["gc", "--verbose", "--grace-period", "0"], catch_exceptions=False) assert result.exit_code == 0 assert split_id(oid)[1] in result.output def test_gc_preserves_reachable( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: # The hello.txt object in the initial commit must survive GC. _make_repo(tmp_path, monkeypatch) content = b"hello world\n" oid = blob_id(content) result = runner.invoke(cli, ["gc"], catch_exceptions=False) assert result.exit_code == 0 assert object_path(tmp_path, oid).exists() # --------------------------------------------------------------------------- # muse archive # --------------------------------------------------------------------------- class TestArchiveCli: def test_archive_creates_targz( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) out = str(tmp_path / "snap.tar.gz") result = runner.invoke(cli, ["archive", "--output", out], catch_exceptions=False) assert result.exit_code == 0 assert pathlib.Path(out).exists() def test_archive_creates_zip( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) out = str(tmp_path / "snap.zip") result = runner.invoke(cli, ["archive", "--format", "zip", "--output", out], catch_exceptions=False) assert result.exit_code == 0 assert pathlib.Path(out).exists() def test_archive_invalid_format( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) result = runner.invoke(cli, ["archive", "--format", "rar"]) assert result.exit_code != 0 assert "invalid choice" in result.stderr or "Unknown format" in result.stderr def test_archive_with_prefix( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) out = str(tmp_path / "out.tar.gz") result = runner.invoke( cli, ["archive", "--output", out, "--prefix", "myproject/"], catch_exceptions=False, ) assert result.exit_code == 0 import tarfile with tarfile.open(out, "r:gz") as tar: names = tar.getnames() assert any("myproject/" in n for n in names) def test_archive_output_shows_commit_info( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) out = str(tmp_path / "out.tar.gz") result = runner.invoke(cli, ["archive", "--output", out], catch_exceptions=False) assert result.exit_code == 0 assert "initial commit" in result.output def test_archive_default_name_is_sha_based( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) result = runner.invoke(cli, ["archive"], catch_exceptions=False) assert result.exit_code == 0 # Should create a .tar.gz file. tar_files = list(tmp_path.glob("*.tar.gz")) assert len(tar_files) == 1 # --------------------------------------------------------------------------- # muse bisect # --------------------------------------------------------------------------- class TestBisectCli: def _setup( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, n: int = 4 ) -> list[str]: _, initial = _make_repo(tmp_path, monkeypatch) return _add_commits(tmp_path, n, initial) def test_bisect_start_requires_good( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: commits = self._setup(tmp_path, monkeypatch) result = runner.invoke(cli, ["bisect", "start", "--bad", commits[-1]]) assert result.exit_code != 0 def test_bisect_start_success( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: commits = self._setup(tmp_path, monkeypatch, n=4) result = runner.invoke( cli, ["bisect", "start", "--bad", commits[-1], "--good", commits[0]], catch_exceptions=False, ) assert result.exit_code == 0 assert "Bisect session started" in result.output def test_bisect_reset( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: commits = self._setup(tmp_path, monkeypatch, n=4) runner.invoke(cli, ["bisect", "start", "--bad", commits[-1], "--good", commits[0]]) result = runner.invoke(cli, ["bisect", "reset"], catch_exceptions=False) assert result.exit_code == 0 assert "reset" in result.output def test_bisect_log_shows_entries( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: commits = self._setup(tmp_path, monkeypatch, n=4) runner.invoke( cli, ["bisect", "start", "--bad", commits[-1], "--good", commits[0]], catch_exceptions=False, ) result = runner.invoke(cli, ["bisect", "log"], catch_exceptions=False) assert result.exit_code == 0 assert "bad" in result.output or "good" in result.output def test_bisect_bad_without_session( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) result = runner.invoke(cli, ["bisect", "bad"]) assert result.exit_code != 0 def test_bisect_good_without_session( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) result = runner.invoke(cli, ["bisect", "good"]) assert result.exit_code != 0 def test_bisect_shows_next_to_test( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: commits = self._setup(tmp_path, monkeypatch, n=8) result = runner.invoke( cli, ["bisect", "start", "--bad", commits[-1], "--good", commits[0]], catch_exceptions=False, ) assert "Next to test:" in result.output def test_bisect_skip( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: commits = self._setup(tmp_path, monkeypatch, n=4) runner.invoke( cli, ["bisect", "start", "--bad", commits[-1], "--good", commits[0]], ) from muse.core.bisect import _load_state state = _load_state(tmp_path) assert state is not None remaining = state.get("remaining", []) if remaining: mid = remaining[len(remaining) // 2] result = runner.invoke(cli, ["bisect", "skip", mid], catch_exceptions=False) assert result.exit_code == 0 # --------------------------------------------------------------------------- # muse blame (core VCS) # --------------------------------------------------------------------------- class TestBlameCli: def test_blame_missing_file( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) result = runner.invoke(cli, ["blame", "nonexistent.txt"]) assert result.exit_code != 0 assert "not found" in result.stderr def test_blame_existing_file( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) result = runner.invoke(cli, ["blame", "hello.txt"], catch_exceptions=False) assert result.exit_code == 0 assert "hello world" in result.output def test_blame_shows_author( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) result = runner.invoke(cli, ["blame", "hello.txt"], catch_exceptions=False) assert result.exit_code == 0 assert "Test User" in result.output def test_blame_json_output( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) result = runner.invoke(cli, ["blame", "--json", "hello.txt"], catch_exceptions=False) assert result.exit_code == 0 parsed = json.loads(result.output) assert "lines" in parsed assert len(parsed["lines"]) >= 1 line = parsed["lines"][0] assert "lineno" in line assert "commit_id" in line assert "content" in line def test_blame_lineno_starts_at_1( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) result = runner.invoke(cli, ["blame", "--json", "hello.txt"], catch_exceptions=False) assert result.exit_code == 0 parsed = json.loads(result.output) assert parsed["lines"][0]["lineno"] == 1 # --------------------------------------------------------------------------- # muse worktree # --------------------------------------------------------------------------- class TestWorktreeCli: def _make_named_repo( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> pathlib.Path: """Create a repo in myproject/ subdirectory.""" repo_dir = tmp_path / "myproject" repo_dir.mkdir() muse = muse_dir(repo_dir) for d in ("objects", "commits", "snapshots", "refs/heads"): (muse / d).mkdir(parents=True, exist_ok=True) (muse / "repo.json").write_text(json.dumps({"repo_id": "test"})) (muse / "HEAD").write_text("ref: refs/heads/main\n") (muse / "refs" / "heads" / "main").write_text(NULL_COMMIT_ID) monkeypatch.chdir(repo_dir) return repo_dir def test_worktree_list_shows_main( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: self._make_named_repo(tmp_path, monkeypatch) result = runner.invoke(cli, ["worktree", "list"], catch_exceptions=False) assert result.exit_code == 0 assert "(main)" in result.output def test_worktree_add_and_list( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: repo = self._make_named_repo(tmp_path, monkeypatch) (heads_dir(repo) / "dev").write_text(NULL_COMMIT_ID) result = runner.invoke(cli, ["worktree", "add", "mydev", "dev"], catch_exceptions=False) assert result.exit_code == 0 result2 = runner.invoke(cli, ["worktree", "list"], catch_exceptions=False) assert "mydev" in result2.output def test_worktree_remove( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: repo = self._make_named_repo(tmp_path, monkeypatch) (heads_dir(repo) / "dev").write_text(NULL_COMMIT_ID) runner.invoke(cli, ["worktree", "add", "mydev", "dev"]) result = runner.invoke(cli, ["worktree", "remove", "mydev"], catch_exceptions=False) assert result.exit_code == 0 assert "mydev" in result.output def test_worktree_prune_empty( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: self._make_named_repo(tmp_path, monkeypatch) result = runner.invoke(cli, ["worktree", "prune"], catch_exceptions=False) assert result.exit_code == 0 assert "Nothing to prune" in result.output def test_worktree_remove_nonexistent( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: self._make_named_repo(tmp_path, monkeypatch) result = runner.invoke(cli, ["worktree", "remove", "nonexistent"]) assert result.exit_code != 0 # --------------------------------------------------------------------------- # muse workspace # --------------------------------------------------------------------------- class TestWorkspaceCli: def test_workspace_add_and_list( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) result = runner.invoke( cli, ["workspace", "add", "core", "https://musehub.ai/acme/core"], catch_exceptions=False, ) assert result.exit_code == 0 assert "Added workspace member" in result.output result2 = runner.invoke(cli, ["workspace", "list"], catch_exceptions=False) assert result2.exit_code == 0 assert "core" in result2.output def test_workspace_remove( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) runner.invoke(cli, ["workspace", "add", "core", "https://musehub.ai/acme/core"]) result = runner.invoke(cli, ["workspace", "remove", "core"], catch_exceptions=False) assert result.exit_code == 0 assert "Removed" in result.output def test_workspace_status_empty( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) result = runner.invoke(cli, ["workspace", "status"], catch_exceptions=False) assert result.exit_code == 0 assert "No workspace members" in result.output def test_workspace_list_empty( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) result = runner.invoke(cli, ["workspace", "list"], catch_exceptions=False) assert result.exit_code == 0 assert "No workspace members" in result.output def test_workspace_add_with_branch( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) runner.invoke( cli, ["workspace", "add", "data", "https://example.com/data", "--branch", "v2"], ) result = runner.invoke(cli, ["workspace", "list"], catch_exceptions=False) assert "v2" in result.output def test_workspace_remove_nonexistent( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) runner.invoke(cli, ["workspace", "add", "core", "https://example.com/core"]) result = runner.invoke(cli, ["workspace", "remove", "nonexistent"]) assert result.exit_code != 0 def test_workspace_sync_empty( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) result = runner.invoke(cli, ["workspace", "sync"], catch_exceptions=False) assert result.exit_code == 0 assert "No members" in result.output def test_workspace_add_duplicate( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: _make_repo(tmp_path, monkeypatch) runner.invoke(cli, ["workspace", "add", "core", "https://example.com/core"]) result = runner.invoke(cli, ["workspace", "add", "core", "https://example.com/other"]) assert result.exit_code != 0