cgcardona / muse public
test_cli_fetch_push.py python
333 lines 11.9 KB
99c4ff79 feat: add remote sync commands (remote, clone, fetch, pull, push, ls-remote) Gabriel Cardona <cgcardona@gmail.com> 1d ago
1 """Tests for muse fetch, push, pull, and ls-remote CLI commands.
2
3 All network calls are mocked — no real HTTP traffic occurs.
4 """
5
6 from __future__ import annotations
7
8 import base64
9 import datetime
10 import hashlib
11 import json
12 import pathlib
13 import unittest.mock
14
15 import pytest
16 from typer.testing import CliRunner
17
18 from muse.cli.app import cli
19 from muse.cli.config import get_remote_head, get_upstream
20 from muse.core.object_store import write_object
21 from muse.core.pack import PackBundle, RemoteInfo
22 from muse.core.store import (
23 CommitRecord,
24 SnapshotRecord,
25 get_head_commit_id,
26 read_commit,
27 write_commit,
28 write_snapshot,
29 )
30 from muse.core.transport import TransportError
31
32 runner = CliRunner()
33
34
35 # ---------------------------------------------------------------------------
36 # Fixture helpers
37 # ---------------------------------------------------------------------------
38
39
40 def _sha(content: bytes) -> str:
41 return hashlib.sha256(content).hexdigest()
42
43
44 @pytest.fixture
45 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
46 """Fully-initialised .muse/ repo with one commit on main."""
47 muse_dir = tmp_path / ".muse"
48 (muse_dir / "refs" / "heads").mkdir(parents=True)
49 (muse_dir / "objects").mkdir()
50 (muse_dir / "commits").mkdir()
51 (muse_dir / "snapshots").mkdir()
52 (muse_dir / "repo.json").write_text(
53 json.dumps({"repo_id": "test-repo", "schema_version": "2", "domain": "midi"})
54 )
55 (muse_dir / "HEAD").write_text("refs/heads/main\n")
56
57 # Write one object + snapshot + commit so there is something to push.
58 content = b"hello"
59 oid = _sha(content)
60 write_object(tmp_path, oid, content)
61 snap = SnapshotRecord(snapshot_id="snap1", manifest={"file.txt": oid})
62 write_snapshot(tmp_path, snap)
63 commit = CommitRecord(
64 commit_id="commit1",
65 repo_id="test-repo",
66 branch="main",
67 snapshot_id="snap1",
68 message="initial",
69 committed_at=datetime.datetime.now(datetime.timezone.utc),
70 )
71 write_commit(tmp_path, commit)
72 (muse_dir / "refs" / "heads" / "main").write_text("commit1")
73 (muse_dir / "config.toml").write_text(
74 '[remotes.origin]\nurl = "https://hub.example.com/repos/r1"\n'
75 )
76
77 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
78 monkeypatch.chdir(tmp_path)
79 return tmp_path
80
81
82 def _make_remote_info(
83 branch_heads: dict[str, str] | None = None,
84 ) -> RemoteInfo:
85 return RemoteInfo(
86 repo_id="remote-repo",
87 domain="midi",
88 default_branch="main",
89 branch_heads=branch_heads or {"main": "remote_commit1"},
90 )
91
92
93 def _make_bundle(commit_id: str = "remote_commit1") -> PackBundle:
94 content = b"remote content"
95 oid = _sha(content)
96 return PackBundle(
97 commits=[
98 {
99 "commit_id": commit_id,
100 "repo_id": "test-repo",
101 "branch": "main",
102 "snapshot_id": "remote_snap1",
103 "message": "remote",
104 "committed_at": "2026-01-01T00:00:00+00:00",
105 "parent_commit_id": None,
106 "parent2_commit_id": None,
107 "author": "remote",
108 "metadata": {},
109 "structured_delta": None,
110 "sem_ver_bump": "none",
111 "breaking_changes": [],
112 "agent_id": "",
113 "model_id": "",
114 "toolchain_id": "",
115 "prompt_hash": "",
116 "signature": "",
117 "signer_key_id": "",
118 "format_version": 5,
119 "reviewed_by": [],
120 "test_runs": 0,
121 }
122 ],
123 snapshots=[
124 {
125 "snapshot_id": "remote_snap1",
126 "manifest": {"remote.txt": oid},
127 "created_at": "2026-01-01T00:00:00+00:00",
128 }
129 ],
130 objects=[
131 ObjectPayload(object_id=oid, content_b64=base64.b64encode(content).decode())
132 ],
133 branch_heads={"main": commit_id},
134 )
135
136
137 # Import ObjectPayload here to avoid circular issues in the fixture above.
138 from muse.core.pack import ObjectPayload # noqa: E402
139
140
141 # ---------------------------------------------------------------------------
142 # muse fetch
143 # ---------------------------------------------------------------------------
144
145
146 class TestFetch:
147 def test_fetch_updates_tracking_head(self, repo: pathlib.Path) -> None:
148 info = _make_remote_info({"main": "remote_commit1"})
149 bundle = _make_bundle("remote_commit1")
150 transport_mock = unittest.mock.MagicMock()
151 transport_mock.fetch_remote_info.return_value = info
152 transport_mock.fetch_pack.return_value = bundle
153
154 with unittest.mock.patch(
155 "muse.cli.commands.fetch.HttpTransport", return_value=transport_mock
156 ):
157 result = runner.invoke(cli, ["fetch", "origin"])
158
159 assert result.exit_code == 0
160 assert "Fetched" in result.output
161 tracking = get_remote_head("origin", "main", repo)
162 assert tracking == "remote_commit1"
163
164 def test_fetch_no_remote_configured_fails(
165 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
166 ) -> None:
167 result = runner.invoke(cli, ["fetch", "nonexistent"])
168 assert result.exit_code != 0
169 assert "not configured" in result.output
170
171 def test_fetch_branch_not_on_remote_fails(self, repo: pathlib.Path) -> None:
172 info = _make_remote_info({"main": "abc"})
173 transport_mock = unittest.mock.MagicMock()
174 transport_mock.fetch_remote_info.return_value = info
175
176 with unittest.mock.patch(
177 "muse.cli.commands.fetch.HttpTransport", return_value=transport_mock
178 ):
179 # Options must precede positional args in add_typer groups.
180 result = runner.invoke(cli, ["fetch", "--branch", "nonexistent", "origin"])
181
182 assert result.exit_code != 0
183 assert "does not exist on remote" in result.output
184
185 def test_fetch_transport_error_propagates(self, repo: pathlib.Path) -> None:
186 transport_mock = unittest.mock.MagicMock()
187 transport_mock.fetch_remote_info.side_effect = TransportError("timeout", 0)
188
189 with unittest.mock.patch(
190 "muse.cli.commands.fetch.HttpTransport", return_value=transport_mock
191 ):
192 result = runner.invoke(cli, ["fetch", "origin"])
193
194 assert result.exit_code != 0
195 assert "Cannot reach remote" in result.output
196
197
198 # ---------------------------------------------------------------------------
199 # muse push
200 # ---------------------------------------------------------------------------
201
202
203 class TestPush:
204 def test_push_sends_commits(self, repo: pathlib.Path) -> None:
205 push_result = {"ok": True, "message": "ok", "branch_heads": {"main": "commit1"}}
206 transport_mock = unittest.mock.MagicMock()
207 transport_mock.push_pack.return_value = push_result
208
209 with unittest.mock.patch(
210 "muse.cli.commands.push.HttpTransport", return_value=transport_mock
211 ):
212 result = runner.invoke(cli, ["push", "origin"])
213
214 assert result.exit_code == 0
215 assert "Pushed" in result.output
216 transport_mock.push_pack.assert_called_once()
217
218 def test_push_no_remote_configured_fails(self, repo: pathlib.Path) -> None:
219 result = runner.invoke(cli, ["push", "nonexistent"])
220 assert result.exit_code != 0
221 assert "not configured" in result.output
222
223 def test_push_set_upstream_records_tracking(self, repo: pathlib.Path) -> None:
224 push_result = {"ok": True, "message": "", "branch_heads": {"main": "commit1"}}
225 transport_mock = unittest.mock.MagicMock()
226 transport_mock.push_pack.return_value = push_result
227
228 with unittest.mock.patch(
229 "muse.cli.commands.push.HttpTransport", return_value=transport_mock
230 ):
231 # Options must precede positional args in add_typer groups.
232 result = runner.invoke(cli, ["push", "-u", "origin"])
233
234 assert result.exit_code == 0
235 assert get_upstream("main", repo) == "origin"
236
237 def test_push_conflict_409_shows_helpful_message(self, repo: pathlib.Path) -> None:
238 transport_mock = unittest.mock.MagicMock()
239 transport_mock.push_pack.side_effect = TransportError("non-fast-forward", 409)
240
241 with unittest.mock.patch(
242 "muse.cli.commands.push.HttpTransport", return_value=transport_mock
243 ):
244 result = runner.invoke(cli, ["push", "origin"])
245
246 assert result.exit_code != 0
247 assert "diverged" in result.output
248
249 def test_push_already_up_to_date(self, repo: pathlib.Path) -> None:
250 # Set tracking head to the same commit as local HEAD.
251 (repo / ".muse" / "remotes" / "origin").mkdir(parents=True)
252 (repo / ".muse" / "remotes" / "origin" / "main").write_text("commit1")
253
254 transport_mock = unittest.mock.MagicMock()
255 with unittest.mock.patch(
256 "muse.cli.commands.push.HttpTransport", return_value=transport_mock
257 ):
258 result = runner.invoke(cli, ["push", "origin"])
259
260 assert result.exit_code == 0
261 assert "up to date" in result.output
262 transport_mock.push_pack.assert_not_called()
263
264 def test_push_force_flag_passed_to_transport(self, repo: pathlib.Path) -> None:
265 push_result = {"ok": True, "message": "", "branch_heads": {"main": "commit1"}}
266 transport_mock = unittest.mock.MagicMock()
267 transport_mock.push_pack.return_value = push_result
268
269 with unittest.mock.patch(
270 "muse.cli.commands.push.HttpTransport", return_value=transport_mock
271 ):
272 result = runner.invoke(cli, ["push", "--force", "origin"])
273
274 assert result.exit_code == 0
275 call_kwargs = transport_mock.push_pack.call_args
276 assert call_kwargs[0][4] is True # force=True positional arg
277
278
279 # ---------------------------------------------------------------------------
280 # muse ls-remote
281 # ---------------------------------------------------------------------------
282
283
284 class TestLsRemote:
285 def test_ls_remote_prints_branches(self, repo: pathlib.Path) -> None:
286 info = _make_remote_info({"main": "abc123", "dev": "def456"})
287 transport_mock = unittest.mock.MagicMock()
288 transport_mock.fetch_remote_info.return_value = info
289
290 with unittest.mock.patch(
291 "muse.cli.commands.ls_remote.HttpTransport", return_value=transport_mock
292 ):
293 result = runner.invoke(cli, ["ls-remote", "origin"])
294
295 assert result.exit_code == 0
296 assert "abc123" in result.output
297 assert "main" in result.output
298
299 def test_ls_remote_json_output(self, repo: pathlib.Path) -> None:
300 info = _make_remote_info({"main": "abc123"})
301 transport_mock = unittest.mock.MagicMock()
302 transport_mock.fetch_remote_info.return_value = info
303
304 with unittest.mock.patch(
305 "muse.cli.commands.ls_remote.HttpTransport", return_value=transport_mock
306 ):
307 # --json option must precede positional arg in add_typer groups.
308 result = runner.invoke(cli, ["ls-remote", "--json", "origin"])
309
310 assert result.exit_code == 0
311 data = json.loads(result.output)
312 assert data["branches"]["main"] == "abc123"
313 assert "repo_id" in data
314
315 def test_ls_remote_unknown_name_fails(self, repo: pathlib.Path) -> None:
316 result = runner.invoke(cli, ["ls-remote", "ghost"])
317 assert result.exit_code != 0
318 assert "not a configured remote" in result.output or "not configured" in result.output
319
320 def test_ls_remote_bare_url_accepted(self, repo: pathlib.Path) -> None:
321 info = _make_remote_info({"main": "abc123"})
322 transport_mock = unittest.mock.MagicMock()
323 transport_mock.fetch_remote_info.return_value = info
324
325 with unittest.mock.patch(
326 "muse.cli.commands.ls_remote.HttpTransport", return_value=transport_mock
327 ):
328 result = runner.invoke(
329 cli, ["ls-remote", "https://hub.example.com/repos/r1"]
330 )
331
332 assert result.exit_code == 0
333 assert "abc123" in result.output