gabriel / muse public
test_cli_fetch_push.py python
448 lines 17.2 KB
7af7a886 feat(push): implement two-phase chunked push protocol Gabriel Cardona <gabriel@tellurstori.com> 16h 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 tests.cli_test_helper import CliRunner
17
18 from muse._version import __version__
19 cli = None # argparse migration — CliRunner ignores this arg
20 from muse.cli.config import get_remote_head, get_upstream, set_remote_head
21 from muse.core.object_store import write_object
22 from muse.core.pack import PackBundle, RemoteInfo
23 from muse.core.store import (
24 CommitRecord,
25 SnapshotRecord,
26 get_head_commit_id,
27 read_commit,
28 write_commit,
29 write_snapshot,
30 )
31 from muse.core.transport import TransportError
32
33 runner = CliRunner()
34
35
36 # ---------------------------------------------------------------------------
37 # Fixture helpers
38 # ---------------------------------------------------------------------------
39
40
41 def _sha(content: bytes) -> str:
42 return hashlib.sha256(content).hexdigest()
43
44
45 @pytest.fixture
46 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
47 """Fully-initialised .muse/ repo with one commit on main."""
48 muse_dir = tmp_path / ".muse"
49 (muse_dir / "refs" / "heads").mkdir(parents=True)
50 (muse_dir / "objects").mkdir()
51 (muse_dir / "commits").mkdir()
52 (muse_dir / "snapshots").mkdir()
53 (muse_dir / "repo.json").write_text(
54 json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "midi"})
55 )
56 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
57
58 # Write one object + snapshot + commit so there is something to push.
59 content = b"hello"
60 oid = _sha(content)
61 write_object(tmp_path, oid, content)
62 snap = SnapshotRecord(snapshot_id="s" * 64, manifest={"file.txt": oid})
63 write_snapshot(tmp_path, snap)
64 commit = CommitRecord(
65 commit_id="commit1",
66 repo_id="test-repo",
67 branch="main",
68 snapshot_id="s" * 64,
69 message="initial",
70 committed_at=datetime.datetime.now(datetime.timezone.utc),
71 )
72 write_commit(tmp_path, commit)
73 (muse_dir / "refs" / "heads" / "main").write_text("commit1")
74 (muse_dir / "config.toml").write_text(
75 '[remotes.origin]\nurl = "https://hub.example.com/repos/r1"\n'
76 )
77
78 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
79 monkeypatch.chdir(tmp_path)
80 return tmp_path
81
82
83 def _make_remote_info(
84 branch_heads: dict[str, str] | None = None,
85 ) -> RemoteInfo:
86 return RemoteInfo(
87 repo_id="remote-repo",
88 domain="midi",
89 default_branch="main",
90 branch_heads=branch_heads or {"main": "remote_commit1"},
91 )
92
93
94 def _make_bundle(commit_id: str = "remote_commit1") -> PackBundle:
95 content = b"remote content"
96 oid = _sha(content)
97 return PackBundle(
98 commits=[
99 {
100 "commit_id": commit_id,
101 "repo_id": "test-repo",
102 "branch": "main",
103 "snapshot_id": "remote_snap1",
104 "message": "remote",
105 "committed_at": "2026-01-01T00:00:00+00:00",
106 "parent_commit_id": None,
107 "parent2_commit_id": None,
108 "author": "remote",
109 "metadata": {},
110 "structured_delta": None,
111 "sem_ver_bump": "none",
112 "breaking_changes": [],
113 "agent_id": "",
114 "model_id": "",
115 "toolchain_id": "",
116 "prompt_hash": "",
117 "signature": "",
118 "signer_key_id": "",
119 "format_version": 5,
120 "reviewed_by": [],
121 "test_runs": 0,
122 }
123 ],
124 snapshots=[
125 {
126 "snapshot_id": "remote_snap1",
127 "manifest": {"remote.txt": oid},
128 "created_at": "2026-01-01T00:00:00+00:00",
129 }
130 ],
131 objects=[
132 ObjectPayload(object_id=oid, content_b64=base64.b64encode(content).decode())
133 ],
134 branch_heads={"main": commit_id},
135 )
136
137
138 # Import ObjectPayload here to avoid circular issues in the fixture above.
139 from muse.core.pack import ObjectPayload # noqa: E402
140
141
142 # ---------------------------------------------------------------------------
143 # muse fetch
144 # ---------------------------------------------------------------------------
145
146
147 class TestFetch:
148 def test_fetch_updates_tracking_head(self, repo: pathlib.Path) -> None:
149 info = _make_remote_info({"main": "remote_commit1"})
150 bundle = _make_bundle("remote_commit1")
151 transport_mock = unittest.mock.MagicMock()
152 transport_mock.fetch_remote_info.return_value = info
153 transport_mock.fetch_pack.return_value = bundle
154
155 with unittest.mock.patch(
156 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
157 ):
158 result = runner.invoke(cli, ["fetch", "origin"])
159
160 assert result.exit_code == 0
161 assert "Fetched" in result.output
162 tracking = get_remote_head("origin", "main", repo)
163 assert tracking == "remote_commit1"
164
165 def test_fetch_defaults_to_current_branch_not_upstream_name(
166 self, repo: pathlib.Path
167 ) -> None:
168 """Regression: fetch with no --branch must use the current branch name,
169 not the upstream *remote* name (which get_upstream() returns)."""
170 # Set upstream so get_upstream("main", root) would return "origin".
171 (repo / ".muse" / "config.toml").write_text(
172 '[remotes.origin]\nurl = "https://hub.example.com/repos/r1"\nbranch = "main"\n'
173 )
174 # Remote has "main" → fetch must succeed, not look for branch "origin".
175 info = _make_remote_info({"main": "remote_commit1"})
176 bundle = _make_bundle("remote_commit1")
177 transport_mock = unittest.mock.MagicMock()
178 transport_mock.fetch_remote_info.return_value = info
179 transport_mock.fetch_pack.return_value = bundle
180
181 with unittest.mock.patch(
182 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
183 ):
184 result = runner.invoke(cli, ["fetch", "origin"])
185
186 assert result.exit_code == 0, result.output
187 assert "Fetched" in result.output
188
189 def test_fetch_no_remote_configured_fails(
190 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
191 ) -> None:
192 result = runner.invoke(cli, ["fetch", "nonexistent"])
193 assert result.exit_code != 0
194 assert "not configured" in result.output
195
196 def test_fetch_branch_not_on_remote_fails(self, repo: pathlib.Path) -> None:
197 info = _make_remote_info({"main": "abc"})
198 transport_mock = unittest.mock.MagicMock()
199 transport_mock.fetch_remote_info.return_value = info
200
201 with unittest.mock.patch(
202 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
203 ):
204 result = runner.invoke(cli, ["fetch", "--branch", "nonexistent", "origin"])
205
206 assert result.exit_code != 0
207 assert "does not exist on remote" in result.output
208
209 def test_fetch_branch_not_on_remote_shows_available(self, repo: pathlib.Path) -> None:
210 """Error output should hint at which branches actually exist."""
211 info = _make_remote_info({"main": "abc", "dev": "def"})
212 transport_mock = unittest.mock.MagicMock()
213 transport_mock.fetch_remote_info.return_value = info
214
215 with unittest.mock.patch(
216 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
217 ):
218 result = runner.invoke(cli, ["fetch", "--branch", "nonexistent", "origin"])
219
220 assert result.exit_code != 0
221 assert "Available branches" in result.output
222
223 def test_fetch_transport_error_propagates(self, repo: pathlib.Path) -> None:
224 transport_mock = unittest.mock.MagicMock()
225 transport_mock.fetch_remote_info.side_effect = TransportError("timeout", 0)
226
227 with unittest.mock.patch(
228 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
229 ):
230 result = runner.invoke(cli, ["fetch", "origin"])
231
232 assert result.exit_code != 0
233 assert "Cannot reach remote" in result.output
234
235 def test_fetch_already_up_to_date(self, repo: pathlib.Path) -> None:
236 """When local tracking ref matches remote HEAD, no pack is fetched."""
237 # Pre-seed the tracking ref to match the remote.
238 set_remote_head("origin", "main", "remote_commit1", repo)
239 info = _make_remote_info({"main": "remote_commit1"})
240 transport_mock = unittest.mock.MagicMock()
241 transport_mock.fetch_remote_info.return_value = info
242
243 with unittest.mock.patch(
244 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
245 ):
246 result = runner.invoke(cli, ["fetch", "origin"])
247
248 assert result.exit_code == 0
249 assert "up to date" in result.output
250 transport_mock.fetch_pack.assert_not_called()
251
252 def test_fetch_prune_removes_stale_refs(self, repo: pathlib.Path) -> None:
253 """--prune deletes tracking refs for branches that no longer exist on remote."""
254 # Simulate a stale tracking ref for "old-feature".
255 set_remote_head("origin", "old-feature", "deadbeef", repo)
256 # Remote only has "main".
257 info = _make_remote_info({"main": "remote_commit1"})
258 bundle = _make_bundle("remote_commit1")
259 transport_mock = unittest.mock.MagicMock()
260 transport_mock.fetch_remote_info.return_value = info
261 transport_mock.fetch_pack.return_value = bundle
262
263 with unittest.mock.patch(
264 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
265 ):
266 result = runner.invoke(cli, ["fetch", "--prune", "origin"])
267
268 assert result.exit_code == 0
269 assert "deleted" in result.output
270 # Stale tracking ref must be gone.
271 assert get_remote_head("origin", "old-feature", repo) is None
272
273 def test_fetch_dry_run_writes_nothing(self, repo: pathlib.Path) -> None:
274 """--dry-run must not write objects or update any tracking ref."""
275 info = _make_remote_info({"main": "remote_commit1"})
276 transport_mock = unittest.mock.MagicMock()
277 transport_mock.fetch_remote_info.return_value = info
278
279 with unittest.mock.patch(
280 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
281 ):
282 result = runner.invoke(cli, ["fetch", "--dry-run", "origin"])
283
284 assert result.exit_code == 0
285 assert "Would fetch" in result.output
286 # No pack was requested and no tracking ref written.
287 transport_mock.fetch_pack.assert_not_called()
288 assert get_remote_head("origin", "main", repo) is None
289
290 def test_fetch_all_fetches_every_remote(self, repo: pathlib.Path) -> None:
291 """--all must contact every configured remote."""
292 # Add a second remote.
293 config_path = repo / ".muse" / "config.toml"
294 config_path.write_text(
295 '[remotes.origin]\nurl = "https://hub.example.com/repos/r1"\n'
296 '[remotes.upstream]\nurl = "https://hub.example.com/repos/r2"\n'
297 )
298 info = _make_remote_info({"main": "remote_commit1"})
299 bundle = _make_bundle("remote_commit1")
300 transport_mock = unittest.mock.MagicMock()
301 transport_mock.fetch_remote_info.return_value = info
302 transport_mock.fetch_pack.return_value = bundle
303
304 with unittest.mock.patch(
305 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
306 ):
307 result = runner.invoke(cli, ["fetch", "--all"])
308
309 assert result.exit_code == 0
310 # fetch_remote_info called once per remote.
311 assert transport_mock.fetch_remote_info.call_count == 2
312
313
314 # ---------------------------------------------------------------------------
315 # muse push
316 # ---------------------------------------------------------------------------
317
318
319 class TestPush:
320 def test_push_sends_commits(self, repo: pathlib.Path) -> None:
321 push_result = {"ok": True, "message": "ok", "branch_heads": {"main": "commit1"}}
322 transport_mock = unittest.mock.MagicMock()
323 transport_mock.push_pack.return_value = push_result
324
325 with unittest.mock.patch(
326 "muse.cli.commands.push.make_transport", return_value=transport_mock
327 ):
328 result = runner.invoke(cli, ["push", "origin"])
329
330 assert result.exit_code == 0
331 assert "Pushed" in result.output
332 transport_mock.push_pack.assert_called_once()
333
334 def test_push_no_remote_configured_fails(self, repo: pathlib.Path) -> None:
335 result = runner.invoke(cli, ["push", "nonexistent"])
336 assert result.exit_code != 0
337 assert "not configured" in result.output
338
339 def test_push_set_upstream_records_tracking(self, repo: pathlib.Path) -> None:
340 push_result = {"ok": True, "message": "", "branch_heads": {"main": "commit1"}}
341 transport_mock = unittest.mock.MagicMock()
342 transport_mock.push_pack.return_value = push_result
343
344 with unittest.mock.patch(
345 "muse.cli.commands.push.make_transport", return_value=transport_mock
346 ):
347 # Options must precede positional args in add_typer groups.
348 result = runner.invoke(cli, ["push", "-u", "origin"])
349
350 assert result.exit_code == 0
351 assert get_upstream("main", repo) == "origin"
352
353 def test_push_conflict_409_shows_helpful_message(self, repo: pathlib.Path) -> None:
354 transport_mock = unittest.mock.MagicMock()
355 transport_mock.push_pack.side_effect = TransportError("non-fast-forward", 409)
356
357 with unittest.mock.patch(
358 "muse.cli.commands.push.make_transport", return_value=transport_mock
359 ):
360 result = runner.invoke(cli, ["push", "origin"])
361
362 assert result.exit_code != 0
363 assert "diverged" in result.output
364
365 def test_push_already_up_to_date(self, repo: pathlib.Path) -> None:
366 # Remote reports the same HEAD as our local branch → nothing to push.
367 transport_mock = unittest.mock.MagicMock()
368 transport_mock.fetch_remote_info.return_value = _make_remote_info({"main": "commit1"})
369 with unittest.mock.patch(
370 "muse.cli.commands.push.make_transport", return_value=transport_mock
371 ):
372 result = runner.invoke(cli, ["push", "origin"])
373
374 assert result.exit_code == 0
375 assert "up to date" in result.output
376 transport_mock.push_pack.assert_not_called()
377
378 def test_push_force_flag_passed_to_transport(self, repo: pathlib.Path) -> None:
379 push_result = {"ok": True, "message": "", "branch_heads": {"main": "commit1"}}
380 transport_mock = unittest.mock.MagicMock()
381 transport_mock.push_pack.return_value = push_result
382
383 with unittest.mock.patch(
384 "muse.cli.commands.push.make_transport", return_value=transport_mock
385 ):
386 result = runner.invoke(cli, ["push", "--force", "origin"])
387
388 assert result.exit_code == 0
389 call_kwargs = transport_mock.push_pack.call_args
390 assert call_kwargs[0][4] is True # force=True positional arg
391
392
393 # ---------------------------------------------------------------------------
394 # muse ls-remote
395 # ---------------------------------------------------------------------------
396
397
398 class TestLsRemote:
399 def test_ls_remote_prints_branches(self, repo: pathlib.Path) -> None:
400 info = _make_remote_info({"main": "abc123", "dev": "def456"})
401 transport_mock = unittest.mock.MagicMock()
402 transport_mock.fetch_remote_info.return_value = info
403
404 with unittest.mock.patch(
405 "muse.cli.commands.plumbing.ls_remote.HttpTransport",
406 return_value=transport_mock,
407 ):
408 result = runner.invoke(cli, ["plumbing", "ls-remote", "origin"])
409
410 assert result.exit_code == 0
411 assert "abc123" in result.output
412 assert "main" in result.output
413
414 def test_ls_remote_json_output(self, repo: pathlib.Path) -> None:
415 info = _make_remote_info({"main": "abc123"})
416 transport_mock = unittest.mock.MagicMock()
417 transport_mock.fetch_remote_info.return_value = info
418
419 with unittest.mock.patch(
420 "muse.cli.commands.plumbing.ls_remote.HttpTransport",
421 return_value=transport_mock,
422 ):
423 result = runner.invoke(cli, ["plumbing", "ls-remote", "--format", "json", "origin"])
424
425 assert result.exit_code == 0
426 data = json.loads(result.output)
427 assert data["branches"]["main"] == "abc123"
428 assert "repo_id" in data
429
430 def test_ls_remote_unknown_name_fails(self, repo: pathlib.Path) -> None:
431 result = runner.invoke(cli, ["plumbing", "ls-remote", "ghost"])
432 assert result.exit_code != 0
433
434 def test_ls_remote_bare_url_accepted(self, repo: pathlib.Path) -> None:
435 info = _make_remote_info({"main": "abc123"})
436 transport_mock = unittest.mock.MagicMock()
437 transport_mock.fetch_remote_info.return_value = info
438
439 with unittest.mock.patch(
440 "muse.cli.commands.plumbing.ls_remote.HttpTransport",
441 return_value=transport_mock,
442 ):
443 result = runner.invoke(
444 cli, ["plumbing", "ls-remote", "https://hub.example.com/repos/r1"]
445 )
446
447 assert result.exit_code == 0
448 assert "abc123" in result.output