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