gabriel / muse public

test_cli_fetch_push.py file-level

at sha256:d · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:0 test: validate staging push and MP workflow for aaronrene · · Jul 7, 2026
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 datetime
9 import hashlib
10 import json
11 import pathlib
12 import unittest.mock
13
14 import pytest
15 from tests.cli_test_helper import CliRunner
16
17 from muse._version import __version__
18 cli = None # argparse migration — CliRunner ignores this arg
19 from muse.cli.config import get_remote_head, get_upstream, set_remote_head
20 from muse.core.object_store import write_object
21 from muse.core.pack import ObjectPayload, PackBundle, PushResult, RemoteInfo
22 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
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 FilterObjectsResult, NegotiateResponse, PresignResponse, TransportError
32 from muse.core._types import Manifest
33
34 runner = CliRunner()
35
36
37 # ---------------------------------------------------------------------------
38 # Fixture helpers
39 # ---------------------------------------------------------------------------
40
41
42 def _sha(content: bytes) -> str:
43 return hashlib.sha256(content).hexdigest()
44
45
46 @pytest.fixture
47 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
48 """Fully-initialised .muse/ repo with one commit on main."""
49 muse_dir = tmp_path / ".muse"
50 (muse_dir / "refs" / "heads").mkdir(parents=True)
51 (muse_dir / "objects").mkdir()
52 (muse_dir / "commits").mkdir()
53 (muse_dir / "snapshots").mkdir()
54 (muse_dir / "repo.json").write_text(
55 json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"})
56 )
57 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
58
59 # Write one object + snapshot + commit so there is something to push.
60 content = b"hello"
61 oid = _sha(content)
62 write_object(tmp_path, oid, content)
63 snap_id = compute_snapshot_id({"file.txt": oid})
64 snap = SnapshotRecord(snapshot_id=snap_id, manifest={"file.txt": oid})
65 write_snapshot(tmp_path, snap)
66 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
67 cid = compute_commit_id([], snap_id, "initial", committed_at.isoformat())
68 commit = CommitRecord(
69 commit_id=cid,
70 repo_id="test-repo",
71 branch="main",
72 snapshot_id=snap_id,
73 message="initial",
74 committed_at=committed_at,
75 )
76 write_commit(tmp_path, commit)
77 (muse_dir / "refs" / "heads" / "main").write_text(cid)
78 (muse_dir / "config.toml").write_text(
79 '[remotes.origin]\nurl = "https://hub.example.com/repos/r1"\n'
80 )
81
82 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
83 monkeypatch.chdir(tmp_path)
84 return tmp_path
85
86
87 def _make_remote_info(
88 branch_heads: Manifest | None = None,
89 ) -> RemoteInfo:
90 return RemoteInfo(
91 repo_id="remote-repo",
92 domain="midi",
93 default_branch="main",
94 branch_heads=branch_heads or {"main": "remote_commit1"},
95 )
96
97
98 def _make_bundle(commit_id: str = "remote_commit1") -> PackBundle:
99 content = b"remote content"
100 oid = _sha(content)
101 return PackBundle(
102 commits=[
103 {
104 "commit_id": commit_id,
105 "repo_id": "test-repo",
106 "branch": "main",
107 "snapshot_id": "remote_snap1",
108 "message": "remote",
109 "committed_at": "2026-01-01T00:00:00+00:00",
110 "parent_commit_id": None,
111 "parent2_commit_id": None,
112 "author": "remote",
113 "metadata": {},
114 "structured_delta": None,
115 "sem_ver_bump": "none",
116 "breaking_changes": [],
117 "agent_id": "",
118 "model_id": "",
119 "toolchain_id": "",
120 "prompt_hash": "",
121 "signature": "",
122 "signer_key_id": "",
123 "format_version": 5,
124 "reviewed_by": [],
125 "test_runs": 0,
126 }
127 ],
128 snapshots=[
129 {
130 "snapshot_id": "remote_snap1",
131 "manifest": {"remote.txt": oid},
132 "created_at": "2026-01-01T00:00:00+00:00",
133 }
134 ],
135 objects=[ObjectPayload(object_id=oid, content=content)],
136 branch_heads={"main": commit_id},
137 )
138
139
140 def _push_transport_mock(
141 push_result: PushResult | None = None,
142 missing_ids: list[str] | None = None,
143 ) -> unittest.mock.MagicMock:
144 """Return a transport mock pre-configured for MWP push tests."""
145 if push_result is None:
146 push_result = PushResult(ok=True, message="ok", branch_heads={"main": "commit1"})
147 transport = unittest.mock.MagicMock()
148 transport.push_pack.return_value = push_result
149 # fetch_remote_info: return a minimal RemoteInfo with no pack_origin so
150 # pack uploads go direct to the server, not through a Worker.
151 transport.fetch_remote_info.return_value = RemoteInfo(branch_heads={})
152 # filter_objects: server reports given IDs as missing (triggers upload).
153 ids = missing_ids if missing_ids is not None else []
154 transport.filter_objects.return_value = FilterObjectsResult(missing=ids, bases={})
155 # presign_objects: local backend — return all as inline (no presigned URLs).
156 transport.presign_objects.return_value = PresignResponse(presigned={}, inline=[])
157 # push_objects: return success counts.
158 transport.push_objects.return_value = {"stored": 1, "skipped": 0}
159 # negotiate: report ready immediately for pull tests.
160 transport.negotiate.return_value = NegotiateResponse(ack=[], common_base=None, ready=True)
161 return transport
162
163
164 # ---------------------------------------------------------------------------
165 # muse fetch
166 # ---------------------------------------------------------------------------
167
168
169 class TestFetch:
170 def test_fetch_updates_tracking_head(self, repo: pathlib.Path) -> None:
171 info = _make_remote_info({"main": "remote_commit1"})
172 bundle = _make_bundle("remote_commit1")
173 transport_mock = unittest.mock.MagicMock()
174 transport_mock.fetch_remote_info.return_value = info
175 transport_mock.fetch_pack.return_value = bundle
176 transport_mock.negotiate.return_value = NegotiateResponse(
177 ack=[], common_base=None, ready=True
178 )
179
180 with unittest.mock.patch(
181 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
182 ):
183 result = runner.invoke(cli, ["fetch", "origin"])
184
185 assert result.exit_code == 0
186 assert "Fetched" in result.output
187 tracking = get_remote_head("origin", "main", repo)
188 assert tracking == "remote_commit1"
189
190 def test_fetch_defaults_to_current_branch_not_upstream_name(
191 self, repo: pathlib.Path
192 ) -> None:
193 """Regression: fetch with no --branch must use the current branch name,
194 not the upstream *remote* name (which get_upstream() returns)."""
195 (repo / ".muse" / "config.toml").write_text(
196 '[remotes.origin]\nurl = "https://hub.example.com/repos/r1"\nbranch = "main"\n'
197 )
198 info = _make_remote_info({"main": "remote_commit1"})
199 bundle = _make_bundle("remote_commit1")
200 transport_mock = unittest.mock.MagicMock()
201 transport_mock.fetch_remote_info.return_value = info
202 transport_mock.fetch_pack.return_value = bundle
203 transport_mock.negotiate.return_value = NegotiateResponse(
204 ack=[], common_base=None, ready=True
205 )
206
207 with unittest.mock.patch(
208 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
209 ):
210 result = runner.invoke(cli, ["fetch", "origin"])
211
212 assert result.exit_code == 0, result.output
213 assert "Fetched" in result.output
214
215 def test_fetch_no_remote_configured_fails(
216 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
217 ) -> None:
218 result = runner.invoke(cli, ["fetch", "nonexistent"])
219 assert result.exit_code != 0
220 assert "not configured" in result.output
221
222 def test_fetch_branch_not_on_remote_fails(self, repo: pathlib.Path) -> None:
223 info = _make_remote_info({"main": "abc"})
224 transport_mock = unittest.mock.MagicMock()
225 transport_mock.fetch_remote_info.return_value = info
226
227 with unittest.mock.patch(
228 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
229 ):
230 result = runner.invoke(cli, ["fetch", "--branch", "nonexistent", "origin"])
231
232 assert result.exit_code != 0
233 assert "does not exist on remote" in result.output
234
235 def test_fetch_branch_not_on_remote_shows_available(self, repo: pathlib.Path) -> None:
236 """Error output should hint at which branches actually exist."""
237 info = _make_remote_info({"main": "abc", "dev": "def"})
238 transport_mock = unittest.mock.MagicMock()
239 transport_mock.fetch_remote_info.return_value = info
240
241 with unittest.mock.patch(
242 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
243 ):
244 result = runner.invoke(cli, ["fetch", "--branch", "nonexistent", "origin"])
245
246 assert result.exit_code != 0
247 assert "Available branches" in result.output
248
249 def test_fetch_transport_error_propagates(self, repo: pathlib.Path) -> None:
250 transport_mock = unittest.mock.MagicMock()
251 transport_mock.fetch_remote_info.side_effect = TransportError("timeout", 0)
252
253 with unittest.mock.patch(
254 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
255 ):
256 result = runner.invoke(cli, ["fetch", "origin"])
257
258 assert result.exit_code != 0
259 assert "Cannot reach remote" in result.output
260
261 def test_fetch_already_up_to_date(self, repo: pathlib.Path) -> None:
262 """When local tracking ref matches remote HEAD, no pack is fetched."""
263 set_remote_head("origin", "main", "remote_commit1", repo)
264 info = _make_remote_info({"main": "remote_commit1"})
265 transport_mock = unittest.mock.MagicMock()
266 transport_mock.fetch_remote_info.return_value = info
267
268 with unittest.mock.patch(
269 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
270 ):
271 result = runner.invoke(cli, ["fetch", "origin"])
272
273 assert result.exit_code == 0
274 assert "up to date" in result.output
275 transport_mock.fetch_pack.assert_not_called()
276
277 def test_fetch_prune_removes_stale_refs(self, repo: pathlib.Path) -> None:
278 """--prune deletes tracking refs for branches that no longer exist on remote."""
279 set_remote_head("origin", "old-feature", "deadbeef", repo)
280 info = _make_remote_info({"main": "remote_commit1"})
281 bundle = _make_bundle("remote_commit1")
282 transport_mock = unittest.mock.MagicMock()
283 transport_mock.fetch_remote_info.return_value = info
284 transport_mock.fetch_pack.return_value = bundle
285 transport_mock.negotiate.return_value = NegotiateResponse(
286 ack=[], common_base=None, ready=True
287 )
288
289 with unittest.mock.patch(
290 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
291 ):
292 result = runner.invoke(cli, ["fetch", "--prune", "origin"])
293
294 assert result.exit_code == 0
295 assert "deleted" in result.output
296 assert get_remote_head("origin", "old-feature", repo) is None
297
298 def test_fetch_prune_current_branch_deleted_on_remote(
299 self, repo: pathlib.Path
300 ) -> None:
301 """--prune succeeds and prints [deleted] when the current branch itself
302 no longer exists on the remote (e.g. after a proposal merge with branch delete).
303
304 Regression test: previously the CLI printed ❌ and exited with USER_ERROR
305 instead of pruning the stale tracking ref and returning 0.
306 """
307 # The repo fixture starts on 'main'. Simulate being on a feature branch
308 # that has already been merged and deleted on the remote.
309 (repo / ".muse" / "HEAD").write_text("ref: refs/heads/feat/merged-pr\n")
310 set_remote_head("origin", "feat/merged-pr", "deadbeef", repo)
311
312 # Remote only knows about 'main' — the feature branch is gone.
313 info = _make_remote_info({"main": "abc123"})
314 transport_mock = unittest.mock.MagicMock()
315 transport_mock.fetch_remote_info.return_value = info
316
317 with unittest.mock.patch(
318 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
319 ):
320 result = runner.invoke(cli, ["fetch", "--prune", "origin"])
321
322 # Must exit cleanly (not with USER_ERROR).
323 assert result.exit_code == 0, result.output
324 # Must show the deleted branch, not an error.
325 assert "[deleted]" in result.output
326 assert "does not exist on remote" not in result.output
327 # Tracking ref must be gone.
328 assert get_remote_head("origin", "feat/merged-pr", repo) is None
329
330 def test_fetch_dry_run_writes_nothing(self, repo: pathlib.Path) -> None:
331 """--dry-run must not write objects or update any tracking ref."""
332 info = _make_remote_info({"main": "remote_commit1"})
333 transport_mock = unittest.mock.MagicMock()
334 transport_mock.fetch_remote_info.return_value = info
335
336 with unittest.mock.patch(
337 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
338 ):
339 result = runner.invoke(cli, ["fetch", "--dry-run", "origin"])
340
341 assert result.exit_code == 0
342 assert "Would fetch" in result.output
343 transport_mock.fetch_pack.assert_not_called()
344 assert get_remote_head("origin", "main", repo) is None
345
346 def test_fetch_all_fetches_every_remote(self, repo: pathlib.Path) -> None:
347 """--all must contact every configured remote."""
348 config_path = repo / ".muse" / "config.toml"
349 config_path.write_text(
350 '[remotes.origin]\nurl = "https://hub.example.com/repos/r1"\n'
351 '[remotes.upstream]\nurl = "https://hub.example.com/repos/r2"\n'
352 )
353 info = _make_remote_info({"main": "remote_commit1"})
354 bundle = _make_bundle("remote_commit1")
355 transport_mock = unittest.mock.MagicMock()
356 transport_mock.fetch_remote_info.return_value = info
357 transport_mock.fetch_pack.return_value = bundle
358 transport_mock.negotiate.return_value = NegotiateResponse(
359 ack=[], common_base=None, ready=True
360 )
361
362 with unittest.mock.patch(
363 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
364 ):
365 result = runner.invoke(cli, ["fetch", "--all"])
366
367 assert result.exit_code == 0
368 assert transport_mock.fetch_remote_info.call_count == 2
369
370
371 # ---------------------------------------------------------------------------
372 # muse pull — performance fast path
373 # ---------------------------------------------------------------------------
374
375
376 class TestPullFastPath:
377 """Tests for the pull command's skip-fetch optimisation.
378
379 When the local tracking ref already points at the remote commit, pull must
380 not call negotiate or fetch_pack — it already has everything it needs.
381 """
382
383 def test_pull_skips_negotiate_and_fetch_when_already_known(
384 self, repo: pathlib.Path
385 ) -> None:
386 """If local tracking ref == remote HEAD, no network pack fetch occurs."""
387 from muse.cli.config import set_remote_head
388 from muse.core.store import get_head_commit_id
389
390 # Use the actual local HEAD so Phase 2 sees it as already up to date.
391 commit_id = get_head_commit_id(repo, "main")
392 set_remote_head("origin", "main", commit_id, repo)
393
394 info = _make_remote_info({"main": commit_id})
395 transport_mock = unittest.mock.MagicMock()
396 transport_mock.fetch_remote_info.return_value = info
397
398 with unittest.mock.patch(
399 "muse.cli.commands.pull.make_transport", return_value=transport_mock
400 ):
401 result = runner.invoke(cli, ["pull", "origin", "main"])
402
403 # Must succeed without touching the network for pack data.
404 assert result.exit_code == 0, result.output
405 transport_mock.negotiate.assert_not_called()
406 transport_mock.fetch_pack.assert_not_called()
407 # Fast-path message should appear.
408 assert "0 commit(s), 0 new object(s)" in result.output
409
410
411 # ---------------------------------------------------------------------------
412 # muse push
413 # ---------------------------------------------------------------------------
414
415
416 class TestPush:
417 def test_push_sends_commits(self, repo: pathlib.Path) -> None:
418 transport_mock = _push_transport_mock()
419
420 with unittest.mock.patch(
421 "muse.cli.commands.push.make_transport", return_value=transport_mock
422 ):
423 result = runner.invoke(cli, ["push", "origin"])
424
425 assert result.exit_code == 0, result.output
426 assert "Pushed" in result.output
427 transport_mock.push_pack.assert_called_once()
428
429 def test_push_calls_filter_objects(self, repo: pathlib.Path) -> None:
430 """MWP Phase 1: push must call filter_objects before building the pack."""
431 transport_mock = _push_transport_mock()
432
433 with unittest.mock.patch(
434 "muse.cli.commands.push.make_transport", return_value=transport_mock
435 ):
436 result = runner.invoke(cli, ["push", "origin"])
437
438 assert result.exit_code == 0, result.output
439 transport_mock.filter_objects.assert_called_once()
440
441 def test_push_uploads_only_missing_objects(self, repo: pathlib.Path) -> None:
442 """When filter_objects returns a non-empty list, pack upload is triggered."""
443 content = b"hello"
444 oid = _sha(content)
445 transport_mock = _push_transport_mock(missing_ids=[oid])
446
447 with unittest.mock.patch(
448 "muse.cli.commands.push.make_transport", return_value=transport_mock
449 ):
450 result = runner.invoke(cli, ["push", "origin"])
451
452 assert result.exit_code == 0, result.output
453 transport_mock.push_pack.assert_called()
454
455 def test_push_skips_upload_when_all_present(self, repo: pathlib.Path) -> None:
456 """When filter_objects returns empty list, no object upload occurs."""
457 transport_mock = _push_transport_mock(missing_ids=[])
458
459 with unittest.mock.patch(
460 "muse.cli.commands.push.make_transport", return_value=transport_mock
461 ):
462 result = runner.invoke(cli, ["push", "origin"])
463
464 assert result.exit_code == 0, result.output
465 transport_mock.push_objects.assert_not_called()
466
467 def test_push_filter_objects_fallback_on_transport_error(
468 self, repo: pathlib.Path
469 ) -> None:
470 """When filter_objects raises TransportError, push falls back to full upload."""
471 transport_mock = _push_transport_mock()
472 transport_mock.filter_objects.side_effect = TransportError("not found", 404)
473
474 with unittest.mock.patch(
475 "muse.cli.commands.push.make_transport", return_value=transport_mock
476 ):
477 result = runner.invoke(cli, ["push", "origin"])
478
479 assert result.exit_code == 0, result.output
480
481 def test_push_no_remote_configured_fails(self, repo: pathlib.Path) -> None:
482 result = runner.invoke(cli, ["push", "nonexistent"])
483 assert result.exit_code != 0
484 assert "not configured" in result.output
485
486 def test_push_set_upstream_records_tracking(self, repo: pathlib.Path) -> None:
487 transport_mock = _push_transport_mock()
488
489 with unittest.mock.patch(
490 "muse.cli.commands.push.make_transport", return_value=transport_mock
491 ):
492 result = runner.invoke(cli, ["push", "-u", "origin"])
493
494 assert result.exit_code == 0, result.output
495 assert get_upstream("main", repo) == "origin"
496
497 def test_push_conflict_409_shows_helpful_message(self, repo: pathlib.Path) -> None:
498 transport_mock = _push_transport_mock()
499 transport_mock.push_pack.side_effect = TransportError("non-fast-forward", 409)
500
501 with unittest.mock.patch(
502 "muse.cli.commands.push.make_transport", return_value=transport_mock
503 ):
504 result = runner.invoke(cli, ["push", "origin"])
505
506 assert result.exit_code != 0
507 assert "diverged" in result.output
508
509 def test_push_already_up_to_date(self, repo: pathlib.Path) -> None:
510 # Remote reports the same HEAD as our local branch → nothing to push.
511 local_head = (repo / ".muse" / "refs" / "heads" / "main").read_text().strip()
512 transport_mock = _push_transport_mock()
513 transport_mock.fetch_remote_info.return_value = _make_remote_info({"main": local_head})
514 with unittest.mock.patch(
515 "muse.cli.commands.push.make_transport", return_value=transport_mock
516 ):
517 result = runner.invoke(cli, ["push", "origin"])
518
519 assert result.exit_code == 0
520 assert "up to date" in result.output
521 transport_mock.push_pack.assert_not_called()
522
523 def test_push_force_flag_passed_to_transport(self, repo: pathlib.Path) -> None:
524 transport_mock = _push_transport_mock()
525
526 with unittest.mock.patch(
527 "muse.cli.commands.push.make_transport", return_value=transport_mock
528 ):
529 result = runner.invoke(cli, ["push", "--force", "origin"])
530
531 assert result.exit_code == 0, result.output
532 call_kwargs = transport_mock.push_pack.call_args
533 assert call_kwargs[0][4] is True # force=True positional arg
534
535
536 # ---------------------------------------------------------------------------
537 # muse ls-remote
538 # ---------------------------------------------------------------------------
539
540
541 class TestLsRemote:
542 def test_ls_remote_prints_branches(self, repo: pathlib.Path) -> None:
543 info = _make_remote_info({"main": "abc123", "dev": "def456"})
544 transport_mock = unittest.mock.MagicMock()
545 transport_mock.fetch_remote_info.return_value = info
546
547 with unittest.mock.patch(
548 "muse.cli.commands.plumbing.ls_remote.HttpTransport",
549 return_value=transport_mock,
550 ):
551 result = runner.invoke(cli, ["ls-remote", "origin"])
552
553 assert result.exit_code == 0
554 assert "abc123" in result.output
555 assert "main" in result.output
556
557 def test_ls_remote_json_output(self, repo: pathlib.Path) -> None:
558 info = _make_remote_info({"main": "abc123"})
559 transport_mock = unittest.mock.MagicMock()
560 transport_mock.fetch_remote_info.return_value = info
561
562 with unittest.mock.patch(
563 "muse.cli.commands.plumbing.ls_remote.HttpTransport",
564 return_value=transport_mock,
565 ):
566 result = runner.invoke(cli, ["ls-remote", "--format", "json", "origin"])
567
568 assert result.exit_code == 0
569 data = json.loads(result.output)
570 assert data["branches"]["main"] == "abc123"
571 assert "repo_id" in data
572
573 def test_ls_remote_unknown_name_fails(self, repo: pathlib.Path) -> None:
574 result = runner.invoke(cli, ["ls-remote", "ghost"])
575 assert result.exit_code != 0
576
577 def test_ls_remote_bare_url_accepted(self, repo: pathlib.Path) -> None:
578 info = _make_remote_info({"main": "abc123"})
579 transport_mock = unittest.mock.MagicMock()
580 transport_mock.fetch_remote_info.return_value = info
581
582 with unittest.mock.patch(
583 "muse.cli.commands.plumbing.ls_remote.HttpTransport",
584 return_value=transport_mock,
585 ):
586 result = runner.invoke(
587 cli, ["ls-remote", "https://hub.example.com/repos/r1"]
588 )
589
590 assert result.exit_code == 0
591 assert "abc123" in result.output
592
593
594 # ---------------------------------------------------------------------------
595 # MWP — LocalFileTransport: filter_objects, presign_objects, negotiate
596 # ---------------------------------------------------------------------------
597
598
599 class TestLocalTransportMwp2:
600 """End-to-end tests for MWP methods on LocalFileTransport (no network)."""
601
602 def _make_remote(self, path: pathlib.Path) -> pathlib.Path:
603 muse_dir = path / ".muse"
604 for d in ("objects", "refs/heads", "commits", "snapshots"):
605 (muse_dir / d).mkdir(parents=True, exist_ok=True)
606 (muse_dir / "repo.json").write_text(
607 json.dumps({"repo_id": "r", "schema_version": "1", "domain": "code"})
608 )
609 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
610 return path
611
612 def test_filter_objects_returns_missing_ids(self, tmp_path: pathlib.Path) -> None:
613 """filter_objects returns only IDs not present in the remote store."""
614 from muse.core.transport import LocalFileTransport
615
616 remote = self._make_remote(tmp_path / "remote")
617 present_content = b"present"
618 present_id = _sha(present_content)
619 write_object(remote, present_id, present_content)
620 missing_id = "a" * 64
621
622 transport = LocalFileTransport()
623 result = transport.filter_objects(f"file://{remote}", None, [present_id, missing_id])
624
625 assert missing_id in result["missing"]
626 assert present_id not in result["missing"]
627
628 def test_presign_objects_returns_all_inline(self, tmp_path: pathlib.Path) -> None:
629 """LocalFileTransport has no cloud backend — everything is inline."""
630 from muse.core.transport import LocalFileTransport
631
632 remote = self._make_remote(tmp_path / "remote")
633 transport = LocalFileTransport()
634 resp = transport.presign_objects(f"file://{remote}", None, ["id1", "id2"], "put")
635
636 assert resp["presigned"] == {}
637 assert set(resp["inline"]) == {"id1", "id2"}
638
639 def test_negotiate_returns_ready_when_no_have(self, tmp_path: pathlib.Path) -> None:
640 """When client has no local commits, negotiate should return ready=True."""
641 from muse.core.transport import LocalFileTransport
642
643 remote = self._make_remote(tmp_path / "remote")
644 transport = LocalFileTransport()
645 resp = transport.negotiate(f"file://{remote}", None, want=["abc"], have=[])
646
647 assert resp["ready"] is True
648 assert resp["ack"] == []
649
650 def test_negotiate_acks_known_commits(self, tmp_path: pathlib.Path) -> None:
651 """negotiate acks commit IDs that exist in the remote's store."""
652 from muse.core.transport import LocalFileTransport
653
654 remote = self._make_remote(tmp_path / "remote")
655 snap_id = compute_snapshot_id({})
656 snap = SnapshotRecord(snapshot_id=snap_id, manifest={})
657 write_snapshot(remote, snap)
658 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
659 known_cid = compute_commit_id([], snap_id, "seed", committed_at.isoformat())
660 commit = CommitRecord(
661 commit_id=known_cid,
662 repo_id="r",
663 branch="main",
664 snapshot_id=snap_id,
665 message="seed",
666 committed_at=committed_at,
667 )
668 write_commit(remote, commit)
669
670 transport = LocalFileTransport()
671 resp = transport.negotiate(
672 f"file://{remote}", None,
673 want=["unknown_tip"],
674 have=[known_cid, "unknown_local"],
675 )
676
677 assert known_cid in resp["ack"]
678 assert "unknown_local" not in resp["ack"]
679
680
681 # ---------------------------------------------------------------------------
682 # MWP — ObjectPayload shape and pack helpers
683 # ---------------------------------------------------------------------------
684
685
686 class TestMwp2PackHelpers:
687 def test_object_payload_has_content_bytes(self) -> None:
688 """ObjectPayload must use 'content: bytes', not 'content_b64: str'."""
689 payload = ObjectPayload(object_id="abc", content=b"hello")
690 assert payload["content"] == b"hello"
691 assert "content_b64" not in payload
692
693 def test_build_pack_only_objects_filters_correctly(
694 self, tmp_path: pathlib.Path
695 ) -> None:
696 """build_pack only_objects param includes only requested objects."""
697 from muse.core.pack import build_pack
698
699 muse_dir = tmp_path / ".muse"
700 for d in ("objects", "refs/heads", "commits", "snapshots"):
701 (muse_dir / d).mkdir(parents=True, exist_ok=True)
702 (muse_dir / "repo.json").write_text(
703 json.dumps({"repo_id": "r", "schema_version": "1", "domain": "code"})
704 )
705 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
706
707 content_a = b"object_a"
708 oid_a = _sha(content_a)
709 content_b = b"object_b"
710 oid_b = _sha(content_b)
711 write_object(tmp_path, oid_a, content_a)
712 write_object(tmp_path, oid_b, content_b)
713
714 snap_id = compute_snapshot_id({"a.txt": oid_a, "b.txt": oid_b})
715 snap = SnapshotRecord(snapshot_id=snap_id, manifest={"a.txt": oid_a, "b.txt": oid_b})
716 write_snapshot(tmp_path, snap)
717 committed_at = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
718 cid = compute_commit_id([], snap_id, "test", committed_at.isoformat())
719 commit = CommitRecord(
720 commit_id=cid,
721 repo_id="r",
722 branch="main",
723 snapshot_id=snap_id,
724 message="test",
725 committed_at=committed_at,
726 )
727 write_commit(tmp_path, commit)
728 (muse_dir / "refs" / "heads" / "main").write_text(cid)
729
730 bundle = build_pack(tmp_path, [cid], only_objects={oid_a})
731 object_ids = {obj["object_id"] for obj in (bundle.get("objects") or [])}
732 assert oid_a in object_ids
733 assert oid_b not in object_ids
734
735 def test_collect_object_ids_excludes_have(self, tmp_path: pathlib.Path) -> None:
736 """collect_object_ids stops at have commits, not including their objects."""
737 from muse.core.pack import collect_object_ids
738
739 muse_dir = tmp_path / ".muse"
740 for d in ("objects", "refs/heads", "commits", "snapshots"):
741 (muse_dir / d).mkdir(parents=True, exist_ok=True)
742 (muse_dir / "repo.json").write_text(
743 json.dumps({"repo_id": "r", "schema_version": "1", "domain": "code"})
744 )
745 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
746
747 content_old = b"old"
748 oid_old = _sha(content_old)
749 write_object(tmp_path, oid_old, content_old)
750 snap_old_id = compute_snapshot_id({"old.txt": oid_old})
751 snap_old = SnapshotRecord(snapshot_id=snap_old_id, manifest={"old.txt": oid_old})
752 write_snapshot(tmp_path, snap_old)
753 at_old = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
754 c_old_id = compute_commit_id([], snap_old_id, "old", at_old.isoformat())
755 commit_old = CommitRecord(
756 commit_id=c_old_id,
757 repo_id="r",
758 branch="main",
759 snapshot_id=snap_old_id,
760 message="old",
761 committed_at=at_old,
762 )
763 write_commit(tmp_path, commit_old)
764
765 content_new = b"new"
766 oid_new = _sha(content_new)
767 write_object(tmp_path, oid_new, content_new)
768 snap_new_id = compute_snapshot_id({"old.txt": oid_old, "new.txt": oid_new})
769 snap_new = SnapshotRecord(
770 snapshot_id=snap_new_id, manifest={"old.txt": oid_old, "new.txt": oid_new}
771 )
772 write_snapshot(tmp_path, snap_new)
773 at_new = datetime.datetime(2026, 1, 2, tzinfo=datetime.timezone.utc)
774 c_new_id = compute_commit_id([c_old_id], snap_new_id, "new", at_new.isoformat())
775 commit_new = CommitRecord(
776 commit_id=c_new_id,
777 repo_id="r",
778 branch="main",
779 snapshot_id=snap_new_id,
780 message="new",
781 committed_at=at_new,
782 parent_commit_id=c_old_id,
783 )
784 write_commit(tmp_path, commit_new)
785
786 ids = collect_object_ids(tmp_path, [c_new_id], have=[c_old_id])
787 assert oid_new in ids