test_cli_clone.py
python
| 1 | """Tests for muse clone CLI command.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import base64 |
| 6 | import hashlib |
| 7 | import json |
| 8 | import pathlib |
| 9 | import unittest.mock |
| 10 | |
| 11 | import pytest |
| 12 | from typer.testing import CliRunner |
| 13 | |
| 14 | from muse.cli.app import cli |
| 15 | from muse.cli.config import get_remote, get_upstream |
| 16 | from muse.core.pack import ObjectPayload, PackBundle, RemoteInfo |
| 17 | from muse.core.store import read_commit, read_snapshot |
| 18 | from muse.core.transport import TransportError |
| 19 | |
| 20 | runner = CliRunner() |
| 21 | |
| 22 | |
| 23 | # --------------------------------------------------------------------------- |
| 24 | # Helpers |
| 25 | # --------------------------------------------------------------------------- |
| 26 | |
| 27 | |
| 28 | def _sha(content: bytes) -> str: |
| 29 | return hashlib.sha256(content).hexdigest() |
| 30 | |
| 31 | |
| 32 | def _make_remote_info( |
| 33 | domain: str = "midi", |
| 34 | default_branch: str = "main", |
| 35 | branch_heads: dict[str, str] | None = None, |
| 36 | repo_id: str = "remote-repo-id", |
| 37 | ) -> RemoteInfo: |
| 38 | return RemoteInfo( |
| 39 | repo_id=repo_id, |
| 40 | domain=domain, |
| 41 | default_branch=default_branch, |
| 42 | branch_heads={"main": "c" * 64} if branch_heads is None else branch_heads, |
| 43 | ) |
| 44 | |
| 45 | |
| 46 | def _make_bundle(commit_id: str = "c" * 64, branch: str = "main") -> PackBundle: |
| 47 | content = b"cloned content" |
| 48 | oid = _sha(content) |
| 49 | return PackBundle( |
| 50 | commits=[ |
| 51 | { |
| 52 | "commit_id": commit_id, |
| 53 | "repo_id": "remote-repo-id", |
| 54 | "branch": branch, |
| 55 | "snapshot_id": "1" * 64, |
| 56 | "message": "initial", |
| 57 | "committed_at": "2026-01-01T00:00:00+00:00", |
| 58 | "parent_commit_id": None, |
| 59 | "parent2_commit_id": None, |
| 60 | "author": "alice", |
| 61 | "metadata": {}, |
| 62 | "structured_delta": None, |
| 63 | "sem_ver_bump": "none", |
| 64 | "breaking_changes": [], |
| 65 | "agent_id": "", |
| 66 | "model_id": "", |
| 67 | "toolchain_id": "", |
| 68 | "prompt_hash": "", |
| 69 | "signature": "", |
| 70 | "signer_key_id": "", |
| 71 | "format_version": 5, |
| 72 | "reviewed_by": [], |
| 73 | "test_runs": 0, |
| 74 | } |
| 75 | ], |
| 76 | snapshots=[ |
| 77 | { |
| 78 | "snapshot_id": "1" * 64, |
| 79 | "manifest": {"hello.txt": oid}, |
| 80 | "created_at": "2026-01-01T00:00:00+00:00", |
| 81 | } |
| 82 | ], |
| 83 | objects=[ |
| 84 | ObjectPayload(object_id=oid, content_b64=base64.b64encode(content).decode()) |
| 85 | ], |
| 86 | branch_heads={"main": commit_id}, |
| 87 | ) |
| 88 | |
| 89 | |
| 90 | def _mock_transport(info: RemoteInfo, bundle: PackBundle) -> unittest.mock.MagicMock: |
| 91 | mock = unittest.mock.MagicMock() |
| 92 | mock.fetch_remote_info.return_value = info |
| 93 | mock.fetch_pack.return_value = bundle |
| 94 | return mock |
| 95 | |
| 96 | |
| 97 | # --------------------------------------------------------------------------- |
| 98 | # Tests |
| 99 | # --------------------------------------------------------------------------- |
| 100 | |
| 101 | |
| 102 | class TestClone: |
| 103 | def test_clone_creates_muse_dir( |
| 104 | self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 105 | ) -> None: |
| 106 | monkeypatch.chdir(tmp_path) |
| 107 | info = _make_remote_info() |
| 108 | bundle = _make_bundle() |
| 109 | mock = _mock_transport(info, bundle) |
| 110 | |
| 111 | with unittest.mock.patch("muse.cli.commands.clone.HttpTransport", return_value=mock): |
| 112 | result = runner.invoke( |
| 113 | cli, ["clone", "https://hub.example.com/repos/r1", "my-repo"] |
| 114 | ) |
| 115 | |
| 116 | assert result.exit_code == 0, result.output |
| 117 | assert (tmp_path / "my-repo" / ".muse").is_dir() |
| 118 | |
| 119 | def test_clone_sets_origin_remote( |
| 120 | self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 121 | ) -> None: |
| 122 | monkeypatch.chdir(tmp_path) |
| 123 | info = _make_remote_info() |
| 124 | bundle = _make_bundle() |
| 125 | mock = _mock_transport(info, bundle) |
| 126 | |
| 127 | with unittest.mock.patch("muse.cli.commands.clone.HttpTransport", return_value=mock): |
| 128 | runner.invoke( |
| 129 | cli, ["clone", "https://hub.example.com/repos/r1", "my-repo"] |
| 130 | ) |
| 131 | |
| 132 | origin = get_remote("origin", tmp_path / "my-repo") |
| 133 | assert origin == "https://hub.example.com/repos/r1" |
| 134 | |
| 135 | def test_clone_sets_upstream_tracking( |
| 136 | self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 137 | ) -> None: |
| 138 | monkeypatch.chdir(tmp_path) |
| 139 | info = _make_remote_info() |
| 140 | bundle = _make_bundle() |
| 141 | mock = _mock_transport(info, bundle) |
| 142 | |
| 143 | with unittest.mock.patch("muse.cli.commands.clone.HttpTransport", return_value=mock): |
| 144 | runner.invoke( |
| 145 | cli, ["clone", "https://hub.example.com/repos/r1", "my-repo"] |
| 146 | ) |
| 147 | |
| 148 | upstream = get_upstream("main", tmp_path / "my-repo") |
| 149 | assert upstream == "origin" |
| 150 | |
| 151 | def test_clone_writes_commits_and_snapshots( |
| 152 | self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 153 | ) -> None: |
| 154 | monkeypatch.chdir(tmp_path) |
| 155 | info = _make_remote_info() |
| 156 | bundle = _make_bundle() |
| 157 | mock = _mock_transport(info, bundle) |
| 158 | |
| 159 | with unittest.mock.patch("muse.cli.commands.clone.HttpTransport", return_value=mock): |
| 160 | runner.invoke( |
| 161 | cli, ["clone", "https://hub.example.com/repos/r1", "dest"] |
| 162 | ) |
| 163 | |
| 164 | dest = tmp_path / "dest" |
| 165 | assert read_commit(dest, "c" * 64) is not None |
| 166 | assert read_snapshot(dest, "1" * 64) is not None |
| 167 | |
| 168 | def test_clone_propagates_domain( |
| 169 | self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 170 | ) -> None: |
| 171 | monkeypatch.chdir(tmp_path) |
| 172 | info = _make_remote_info(domain="code") |
| 173 | bundle = _make_bundle() |
| 174 | mock = _mock_transport(info, bundle) |
| 175 | |
| 176 | with unittest.mock.patch("muse.cli.commands.clone.HttpTransport", return_value=mock): |
| 177 | runner.invoke( |
| 178 | cli, ["clone", "https://hub.example.com/repos/r1", "dest"] |
| 179 | ) |
| 180 | |
| 181 | dest = tmp_path / "dest" |
| 182 | repo_meta = json.loads((dest / ".muse" / "repo.json").read_text()) |
| 183 | assert repo_meta["domain"] == "code" |
| 184 | |
| 185 | def test_clone_uses_remote_repo_id( |
| 186 | self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 187 | ) -> None: |
| 188 | monkeypatch.chdir(tmp_path) |
| 189 | info = _make_remote_info(repo_id="the-real-repo-id") |
| 190 | bundle = _make_bundle() |
| 191 | mock = _mock_transport(info, bundle) |
| 192 | |
| 193 | with unittest.mock.patch("muse.cli.commands.clone.HttpTransport", return_value=mock): |
| 194 | runner.invoke( |
| 195 | cli, ["clone", "https://hub.example.com/repos/r1", "dest"] |
| 196 | ) |
| 197 | |
| 198 | dest = tmp_path / "dest" |
| 199 | repo_meta = json.loads((dest / ".muse" / "repo.json").read_text()) |
| 200 | assert repo_meta["repo_id"] == "the-real-repo-id" |
| 201 | |
| 202 | def test_clone_infers_directory_name_from_url( |
| 203 | self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 204 | ) -> None: |
| 205 | monkeypatch.chdir(tmp_path) |
| 206 | info = _make_remote_info() |
| 207 | bundle = _make_bundle() |
| 208 | mock = _mock_transport(info, bundle) |
| 209 | |
| 210 | with unittest.mock.patch("muse.cli.commands.clone.HttpTransport", return_value=mock): |
| 211 | runner.invoke(cli, ["clone", "https://hub.example.com/repos/my-project"]) |
| 212 | |
| 213 | assert (tmp_path / "my-project" / ".muse").is_dir() |
| 214 | |
| 215 | def test_clone_transport_error_fails_cleanly( |
| 216 | self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 217 | ) -> None: |
| 218 | monkeypatch.chdir(tmp_path) |
| 219 | mock = unittest.mock.MagicMock() |
| 220 | mock.fetch_remote_info.side_effect = TransportError("connection refused", 0) |
| 221 | |
| 222 | with unittest.mock.patch("muse.cli.commands.clone.HttpTransport", return_value=mock): |
| 223 | result = runner.invoke( |
| 224 | cli, ["clone", "https://hub.example.com/repos/r1", "dest"] |
| 225 | ) |
| 226 | |
| 227 | assert result.exit_code != 0 |
| 228 | assert "Cannot reach remote" in result.output |
| 229 | |
| 230 | def test_clone_existing_repo_fails( |
| 231 | self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 232 | ) -> None: |
| 233 | monkeypatch.chdir(tmp_path) |
| 234 | # Pre-create a .muse directory. |
| 235 | (tmp_path / "dest" / ".muse").mkdir(parents=True) |
| 236 | |
| 237 | mock = unittest.mock.MagicMock() |
| 238 | with unittest.mock.patch("muse.cli.commands.clone.HttpTransport", return_value=mock): |
| 239 | result = runner.invoke( |
| 240 | cli, ["clone", "https://hub.example.com/repos/r1", "dest"] |
| 241 | ) |
| 242 | |
| 243 | assert result.exit_code != 0 |
| 244 | assert "already a Muse repository" in result.output |
| 245 | |
| 246 | def test_clone_empty_repo_fails( |
| 247 | self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 248 | ) -> None: |
| 249 | monkeypatch.chdir(tmp_path) |
| 250 | info = _make_remote_info(branch_heads={}) # no branches |
| 251 | mock = unittest.mock.MagicMock() |
| 252 | mock.fetch_remote_info.return_value = info |
| 253 | |
| 254 | with unittest.mock.patch("muse.cli.commands.clone.HttpTransport", return_value=mock): |
| 255 | result = runner.invoke( |
| 256 | cli, ["clone", "https://hub.example.com/repos/r1", "dest"] |
| 257 | ) |
| 258 | |
| 259 | assert result.exit_code != 0 |
| 260 | assert "empty" in result.output.lower() or "no branches" in result.output.lower() |
| 261 | |
| 262 | def test_clone_branch_flag_selects_branch( |
| 263 | self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch |
| 264 | ) -> None: |
| 265 | monkeypatch.chdir(tmp_path) |
| 266 | info = _make_remote_info( |
| 267 | default_branch="main", |
| 268 | branch_heads={"main": "c" * 64, "dev": "d" * 64}, |
| 269 | ) |
| 270 | bundle = _make_bundle(commit_id="d" * 64, branch="dev") |
| 271 | mock = _mock_transport(info, bundle) |
| 272 | |
| 273 | with unittest.mock.patch("muse.cli.commands.clone.HttpTransport", return_value=mock): |
| 274 | # Options must precede positional args in add_typer groups. |
| 275 | result = runner.invoke( |
| 276 | cli, |
| 277 | ["clone", "--branch", "dev", "https://hub.example.com/repos/r1", "dest"], |
| 278 | ) |
| 279 | |
| 280 | assert result.exit_code == 0, result.output |
| 281 | dest = tmp_path / "dest" |
| 282 | head_ref = (dest / ".muse" / "HEAD").read_text().strip() |
| 283 | assert "dev" in head_ref |