gabriel / muse public
test_cli_fetch_push.py python
695 lines 27.0 KB
dec4604a feat(mwp): replace JSON+base64 wire protocol with MWP binary msgpack Gabriel Cardona <gabriel@tellurstori.com> 14h 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.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 FilterResponse, NegotiateResponse, PresignResponse, 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": __version__, "domain": "midi"})
54 )
55 (muse_dir / "HEAD").write_text("ref: 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="s" * 64, 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="s" * 64,
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=[ObjectPayload(object_id=oid, content=content)],
131 branch_heads={"main": commit_id},
132 )
133
134
135 def _push_transport_mock(
136 push_result: PushResult | None = None,
137 missing_ids: list[str] | None = None,
138 ) -> unittest.mock.MagicMock:
139 """Return a transport mock pre-configured for MWP push tests."""
140 if push_result is None:
141 push_result = PushResult(ok=True, message="ok", branch_heads={"main": "commit1"})
142 transport = unittest.mock.MagicMock()
143 transport.push_pack.return_value = push_result
144 # filter_objects: server reports given IDs as missing (triggers upload).
145 transport.filter_objects.return_value = missing_ids if missing_ids is not None else []
146 # presign_objects: local backend — return all as inline (no presigned URLs).
147 transport.presign_objects.return_value = PresignResponse(presigned={}, inline=[])
148 # push_objects: return success counts.
149 transport.push_objects.return_value = {"stored": 1, "skipped": 0}
150 # negotiate: report ready immediately for pull tests.
151 transport.negotiate.return_value = NegotiateResponse(ack=[], common_base=None, ready=True)
152 return transport
153
154
155 # ---------------------------------------------------------------------------
156 # muse fetch
157 # ---------------------------------------------------------------------------
158
159
160 class TestFetch:
161 def test_fetch_updates_tracking_head(self, repo: pathlib.Path) -> None:
162 info = _make_remote_info({"main": "remote_commit1"})
163 bundle = _make_bundle("remote_commit1")
164 transport_mock = unittest.mock.MagicMock()
165 transport_mock.fetch_remote_info.return_value = info
166 transport_mock.fetch_pack.return_value = bundle
167 transport_mock.negotiate.return_value = NegotiateResponse(
168 ack=[], common_base=None, ready=True
169 )
170
171 with unittest.mock.patch(
172 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
173 ):
174 result = runner.invoke(cli, ["fetch", "origin"])
175
176 assert result.exit_code == 0
177 assert "Fetched" in result.output
178 tracking = get_remote_head("origin", "main", repo)
179 assert tracking == "remote_commit1"
180
181 def test_fetch_defaults_to_current_branch_not_upstream_name(
182 self, repo: pathlib.Path
183 ) -> None:
184 """Regression: fetch with no --branch must use the current branch name,
185 not the upstream *remote* name (which get_upstream() returns)."""
186 (repo / ".muse" / "config.toml").write_text(
187 '[remotes.origin]\nurl = "https://hub.example.com/repos/r1"\nbranch = "main"\n'
188 )
189 info = _make_remote_info({"main": "remote_commit1"})
190 bundle = _make_bundle("remote_commit1")
191 transport_mock = unittest.mock.MagicMock()
192 transport_mock.fetch_remote_info.return_value = info
193 transport_mock.fetch_pack.return_value = bundle
194 transport_mock.negotiate.return_value = NegotiateResponse(
195 ack=[], common_base=None, ready=True
196 )
197
198 with unittest.mock.patch(
199 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
200 ):
201 result = runner.invoke(cli, ["fetch", "origin"])
202
203 assert result.exit_code == 0, result.output
204 assert "Fetched" in result.output
205
206 def test_fetch_no_remote_configured_fails(
207 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
208 ) -> None:
209 result = runner.invoke(cli, ["fetch", "nonexistent"])
210 assert result.exit_code != 0
211 assert "not configured" in result.output
212
213 def test_fetch_branch_not_on_remote_fails(self, repo: pathlib.Path) -> None:
214 info = _make_remote_info({"main": "abc"})
215 transport_mock = unittest.mock.MagicMock()
216 transport_mock.fetch_remote_info.return_value = info
217
218 with unittest.mock.patch(
219 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
220 ):
221 result = runner.invoke(cli, ["fetch", "--branch", "nonexistent", "origin"])
222
223 assert result.exit_code != 0
224 assert "does not exist on remote" in result.output
225
226 def test_fetch_branch_not_on_remote_shows_available(self, repo: pathlib.Path) -> None:
227 """Error output should hint at which branches actually exist."""
228 info = _make_remote_info({"main": "abc", "dev": "def"})
229 transport_mock = unittest.mock.MagicMock()
230 transport_mock.fetch_remote_info.return_value = info
231
232 with unittest.mock.patch(
233 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
234 ):
235 result = runner.invoke(cli, ["fetch", "--branch", "nonexistent", "origin"])
236
237 assert result.exit_code != 0
238 assert "Available branches" in result.output
239
240 def test_fetch_transport_error_propagates(self, repo: pathlib.Path) -> None:
241 transport_mock = unittest.mock.MagicMock()
242 transport_mock.fetch_remote_info.side_effect = TransportError("timeout", 0)
243
244 with unittest.mock.patch(
245 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
246 ):
247 result = runner.invoke(cli, ["fetch", "origin"])
248
249 assert result.exit_code != 0
250 assert "Cannot reach remote" in result.output
251
252 def test_fetch_already_up_to_date(self, repo: pathlib.Path) -> None:
253 """When local tracking ref matches remote HEAD, no pack is fetched."""
254 set_remote_head("origin", "main", "remote_commit1", repo)
255 info = _make_remote_info({"main": "remote_commit1"})
256 transport_mock = unittest.mock.MagicMock()
257 transport_mock.fetch_remote_info.return_value = info
258
259 with unittest.mock.patch(
260 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
261 ):
262 result = runner.invoke(cli, ["fetch", "origin"])
263
264 assert result.exit_code == 0
265 assert "up to date" in result.output
266 transport_mock.fetch_pack.assert_not_called()
267
268 def test_fetch_prune_removes_stale_refs(self, repo: pathlib.Path) -> None:
269 """--prune deletes tracking refs for branches that no longer exist on remote."""
270 set_remote_head("origin", "old-feature", "deadbeef", repo)
271 info = _make_remote_info({"main": "remote_commit1"})
272 bundle = _make_bundle("remote_commit1")
273 transport_mock = unittest.mock.MagicMock()
274 transport_mock.fetch_remote_info.return_value = info
275 transport_mock.fetch_pack.return_value = bundle
276 transport_mock.negotiate.return_value = NegotiateResponse(
277 ack=[], common_base=None, ready=True
278 )
279
280 with unittest.mock.patch(
281 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
282 ):
283 result = runner.invoke(cli, ["fetch", "--prune", "origin"])
284
285 assert result.exit_code == 0
286 assert "deleted" in result.output
287 assert get_remote_head("origin", "old-feature", repo) is None
288
289 def test_fetch_dry_run_writes_nothing(self, repo: pathlib.Path) -> None:
290 """--dry-run must not write objects or update any tracking ref."""
291 info = _make_remote_info({"main": "remote_commit1"})
292 transport_mock = unittest.mock.MagicMock()
293 transport_mock.fetch_remote_info.return_value = info
294
295 with unittest.mock.patch(
296 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
297 ):
298 result = runner.invoke(cli, ["fetch", "--dry-run", "origin"])
299
300 assert result.exit_code == 0
301 assert "Would fetch" in result.output
302 transport_mock.fetch_pack.assert_not_called()
303 assert get_remote_head("origin", "main", repo) is None
304
305 def test_fetch_all_fetches_every_remote(self, repo: pathlib.Path) -> None:
306 """--all must contact every configured remote."""
307 config_path = repo / ".muse" / "config.toml"
308 config_path.write_text(
309 '[remotes.origin]\nurl = "https://hub.example.com/repos/r1"\n'
310 '[remotes.upstream]\nurl = "https://hub.example.com/repos/r2"\n'
311 )
312 info = _make_remote_info({"main": "remote_commit1"})
313 bundle = _make_bundle("remote_commit1")
314 transport_mock = unittest.mock.MagicMock()
315 transport_mock.fetch_remote_info.return_value = info
316 transport_mock.fetch_pack.return_value = bundle
317 transport_mock.negotiate.return_value = NegotiateResponse(
318 ack=[], common_base=None, ready=True
319 )
320
321 with unittest.mock.patch(
322 "muse.cli.commands.fetch.make_transport", return_value=transport_mock
323 ):
324 result = runner.invoke(cli, ["fetch", "--all"])
325
326 assert result.exit_code == 0
327 assert transport_mock.fetch_remote_info.call_count == 2
328
329
330 # ---------------------------------------------------------------------------
331 # muse push
332 # ---------------------------------------------------------------------------
333
334
335 class TestPush:
336 def test_push_sends_commits(self, repo: pathlib.Path) -> None:
337 transport_mock = _push_transport_mock()
338
339 with unittest.mock.patch(
340 "muse.cli.commands.push.make_transport", return_value=transport_mock
341 ):
342 result = runner.invoke(cli, ["push", "origin"])
343
344 assert result.exit_code == 0, result.output
345 assert "Pushed" in result.output
346 transport_mock.push_pack.assert_called_once()
347
348 def test_push_calls_filter_objects(self, repo: pathlib.Path) -> None:
349 """MWP Phase 1: push must call filter_objects before building the pack."""
350 transport_mock = _push_transport_mock()
351
352 with unittest.mock.patch(
353 "muse.cli.commands.push.make_transport", return_value=transport_mock
354 ):
355 result = runner.invoke(cli, ["push", "origin"])
356
357 assert result.exit_code == 0, result.output
358 transport_mock.filter_objects.assert_called_once()
359
360 def test_push_uploads_only_missing_objects(self, repo: pathlib.Path) -> None:
361 """When filter_objects returns a non-empty list, push_objects is called."""
362 content = b"hello"
363 oid = _sha(content)
364 transport_mock = _push_transport_mock(missing_ids=[oid])
365
366 with unittest.mock.patch(
367 "muse.cli.commands.push.make_transport", return_value=transport_mock
368 ):
369 result = runner.invoke(cli, ["push", "origin"])
370
371 assert result.exit_code == 0, result.output
372 transport_mock.push_objects.assert_called_once()
373
374 def test_push_skips_upload_when_all_present(self, repo: pathlib.Path) -> None:
375 """When filter_objects returns empty list, push_objects is never called."""
376 transport_mock = _push_transport_mock(missing_ids=[])
377
378 with unittest.mock.patch(
379 "muse.cli.commands.push.make_transport", return_value=transport_mock
380 ):
381 result = runner.invoke(cli, ["push", "origin"])
382
383 assert result.exit_code == 0, result.output
384 transport_mock.push_objects.assert_not_called()
385
386 def test_push_filter_objects_fallback_on_transport_error(
387 self, repo: pathlib.Path
388 ) -> None:
389 """When filter_objects raises TransportError, push falls back to full upload."""
390 transport_mock = _push_transport_mock()
391 transport_mock.filter_objects.side_effect = TransportError("not found", 404)
392
393 with unittest.mock.patch(
394 "muse.cli.commands.push.make_transport", return_value=transport_mock
395 ):
396 result = runner.invoke(cli, ["push", "origin"])
397
398 assert result.exit_code == 0, result.output
399
400 def test_push_no_remote_configured_fails(self, repo: pathlib.Path) -> None:
401 result = runner.invoke(cli, ["push", "nonexistent"])
402 assert result.exit_code != 0
403 assert "not configured" in result.output
404
405 def test_push_set_upstream_records_tracking(self, repo: pathlib.Path) -> None:
406 transport_mock = _push_transport_mock()
407
408 with unittest.mock.patch(
409 "muse.cli.commands.push.make_transport", return_value=transport_mock
410 ):
411 result = runner.invoke(cli, ["push", "-u", "origin"])
412
413 assert result.exit_code == 0, result.output
414 assert get_upstream("main", repo) == "origin"
415
416 def test_push_conflict_409_shows_helpful_message(self, repo: pathlib.Path) -> None:
417 transport_mock = _push_transport_mock()
418 transport_mock.push_pack.side_effect = TransportError("non-fast-forward", 409)
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
426 assert "diverged" in result.output
427
428 def test_push_already_up_to_date(self, repo: pathlib.Path) -> None:
429 # Remote reports the same HEAD as our local branch → nothing to push.
430 transport_mock = _push_transport_mock()
431 transport_mock.fetch_remote_info.return_value = _make_remote_info({"main": "commit1"})
432 with unittest.mock.patch(
433 "muse.cli.commands.push.make_transport", return_value=transport_mock
434 ):
435 result = runner.invoke(cli, ["push", "origin"])
436
437 assert result.exit_code == 0
438 assert "up to date" in result.output
439 transport_mock.push_pack.assert_not_called()
440
441 def test_push_force_flag_passed_to_transport(self, repo: pathlib.Path) -> None:
442 transport_mock = _push_transport_mock()
443
444 with unittest.mock.patch(
445 "muse.cli.commands.push.make_transport", return_value=transport_mock
446 ):
447 result = runner.invoke(cli, ["push", "--force", "origin"])
448
449 assert result.exit_code == 0, result.output
450 call_kwargs = transport_mock.push_pack.call_args
451 assert call_kwargs[0][4] is True # force=True positional arg
452
453
454 # ---------------------------------------------------------------------------
455 # muse ls-remote
456 # ---------------------------------------------------------------------------
457
458
459 class TestLsRemote:
460 def test_ls_remote_prints_branches(self, repo: pathlib.Path) -> None:
461 info = _make_remote_info({"main": "abc123", "dev": "def456"})
462 transport_mock = unittest.mock.MagicMock()
463 transport_mock.fetch_remote_info.return_value = info
464
465 with unittest.mock.patch(
466 "muse.cli.commands.plumbing.ls_remote.HttpTransport",
467 return_value=transport_mock,
468 ):
469 result = runner.invoke(cli, ["plumbing", "ls-remote", "origin"])
470
471 assert result.exit_code == 0
472 assert "abc123" in result.output
473 assert "main" in result.output
474
475 def test_ls_remote_json_output(self, repo: pathlib.Path) -> None:
476 info = _make_remote_info({"main": "abc123"})
477 transport_mock = unittest.mock.MagicMock()
478 transport_mock.fetch_remote_info.return_value = info
479
480 with unittest.mock.patch(
481 "muse.cli.commands.plumbing.ls_remote.HttpTransport",
482 return_value=transport_mock,
483 ):
484 result = runner.invoke(cli, ["plumbing", "ls-remote", "--format", "json", "origin"])
485
486 assert result.exit_code == 0
487 data = json.loads(result.output)
488 assert data["branches"]["main"] == "abc123"
489 assert "repo_id" in data
490
491 def test_ls_remote_unknown_name_fails(self, repo: pathlib.Path) -> None:
492 result = runner.invoke(cli, ["plumbing", "ls-remote", "ghost"])
493 assert result.exit_code != 0
494
495 def test_ls_remote_bare_url_accepted(self, repo: pathlib.Path) -> None:
496 info = _make_remote_info({"main": "abc123"})
497 transport_mock = unittest.mock.MagicMock()
498 transport_mock.fetch_remote_info.return_value = info
499
500 with unittest.mock.patch(
501 "muse.cli.commands.plumbing.ls_remote.HttpTransport",
502 return_value=transport_mock,
503 ):
504 result = runner.invoke(
505 cli, ["plumbing", "ls-remote", "https://hub.example.com/repos/r1"]
506 )
507
508 assert result.exit_code == 0
509 assert "abc123" in result.output
510
511
512 # ---------------------------------------------------------------------------
513 # MWP — LocalFileTransport: filter_objects, presign_objects, negotiate
514 # ---------------------------------------------------------------------------
515
516
517 class TestLocalTransportMwp2:
518 """End-to-end tests for MWP methods on LocalFileTransport (no network)."""
519
520 def _make_remote(self, path: pathlib.Path) -> pathlib.Path:
521 muse_dir = path / ".muse"
522 for d in ("objects", "refs/heads", "commits", "snapshots"):
523 (muse_dir / d).mkdir(parents=True, exist_ok=True)
524 (muse_dir / "repo.json").write_text(
525 json.dumps({"repo_id": "r", "schema_version": "1", "domain": "code"})
526 )
527 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
528 return path
529
530 def test_filter_objects_returns_missing_ids(self, tmp_path: pathlib.Path) -> None:
531 """filter_objects returns only IDs not present in the remote store."""
532 from muse.core.transport import LocalFileTransport
533
534 remote = self._make_remote(tmp_path / "remote")
535 present_content = b"present"
536 present_id = _sha(present_content)
537 write_object(remote, present_id, present_content)
538 missing_id = "a" * 64
539
540 transport = LocalFileTransport()
541 result = transport.filter_objects(f"file://{remote}", None, [present_id, missing_id])
542
543 assert missing_id in result
544 assert present_id not in result
545
546 def test_presign_objects_returns_all_inline(self, tmp_path: pathlib.Path) -> None:
547 """LocalFileTransport has no cloud backend — everything is inline."""
548 from muse.core.transport import LocalFileTransport
549
550 remote = self._make_remote(tmp_path / "remote")
551 transport = LocalFileTransport()
552 resp = transport.presign_objects(f"file://{remote}", None, ["id1", "id2"], "put")
553
554 assert resp["presigned"] == {}
555 assert set(resp["inline"]) == {"id1", "id2"}
556
557 def test_negotiate_returns_ready_when_no_have(self, tmp_path: pathlib.Path) -> None:
558 """When client has no local commits, negotiate should return ready=True."""
559 from muse.core.transport import LocalFileTransport
560
561 remote = self._make_remote(tmp_path / "remote")
562 transport = LocalFileTransport()
563 resp = transport.negotiate(f"file://{remote}", None, want=["abc"], have=[])
564
565 assert resp["ready"] is True
566 assert resp["ack"] == []
567
568 def test_negotiate_acks_known_commits(self, tmp_path: pathlib.Path) -> None:
569 """negotiate acks commit IDs that exist in the remote's store."""
570 from muse.core.transport import LocalFileTransport
571
572 remote = self._make_remote(tmp_path / "remote")
573 snap = SnapshotRecord(snapshot_id="s" * 64, manifest={})
574 write_snapshot(remote, snap)
575 commit = CommitRecord(
576 commit_id="known_commit",
577 repo_id="r",
578 branch="main",
579 snapshot_id="s" * 64,
580 message="seed",
581 committed_at=datetime.datetime.now(datetime.timezone.utc),
582 )
583 write_commit(remote, commit)
584
585 transport = LocalFileTransport()
586 resp = transport.negotiate(
587 f"file://{remote}", None,
588 want=["unknown_tip"],
589 have=["known_commit", "unknown_local"],
590 )
591
592 assert "known_commit" in resp["ack"]
593 assert "unknown_local" not in resp["ack"]
594
595
596 # ---------------------------------------------------------------------------
597 # MWP — ObjectPayload shape and pack helpers
598 # ---------------------------------------------------------------------------
599
600
601 class TestMwp2PackHelpers:
602 def test_object_payload_has_content_bytes(self) -> None:
603 """ObjectPayload must use 'content: bytes', not 'content_b64: str'."""
604 payload = ObjectPayload(object_id="abc", content=b"hello")
605 assert payload["content"] == b"hello"
606 assert "content_b64" not in payload
607
608 def test_build_pack_only_objects_filters_correctly(
609 self, tmp_path: pathlib.Path
610 ) -> None:
611 """build_pack only_objects param includes only requested objects."""
612 from muse.core.pack import build_pack
613
614 muse_dir = tmp_path / ".muse"
615 for d in ("objects", "refs/heads", "commits", "snapshots"):
616 (muse_dir / d).mkdir(parents=True, exist_ok=True)
617 (muse_dir / "repo.json").write_text(
618 json.dumps({"repo_id": "r", "schema_version": "1", "domain": "code"})
619 )
620 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
621
622 content_a = b"object_a"
623 oid_a = _sha(content_a)
624 content_b = b"object_b"
625 oid_b = _sha(content_b)
626 write_object(tmp_path, oid_a, content_a)
627 write_object(tmp_path, oid_b, content_b)
628
629 snap = SnapshotRecord(
630 snapshot_id="s" * 64, manifest={"a.txt": oid_a, "b.txt": oid_b}
631 )
632 write_snapshot(tmp_path, snap)
633 commit = CommitRecord(
634 commit_id="c1",
635 repo_id="r",
636 branch="main",
637 snapshot_id="s" * 64,
638 message="test",
639 committed_at=datetime.datetime.now(datetime.timezone.utc),
640 )
641 write_commit(tmp_path, commit)
642 (muse_dir / "refs" / "heads" / "main").write_text("c1")
643
644 bundle = build_pack(tmp_path, ["c1"], only_objects={oid_a})
645 object_ids = {obj["object_id"] for obj in (bundle.get("objects") or [])}
646 assert oid_a in object_ids
647 assert oid_b not in object_ids
648
649 def test_collect_object_ids_excludes_have(self, tmp_path: pathlib.Path) -> None:
650 """collect_object_ids stops at have commits, not including their objects."""
651 from muse.core.pack import collect_object_ids
652
653 muse_dir = tmp_path / ".muse"
654 for d in ("objects", "refs/heads", "commits", "snapshots"):
655 (muse_dir / d).mkdir(parents=True, exist_ok=True)
656 (muse_dir / "repo.json").write_text(
657 json.dumps({"repo_id": "r", "schema_version": "1", "domain": "code"})
658 )
659 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
660
661 content_old = b"old"
662 oid_old = _sha(content_old)
663 write_object(tmp_path, oid_old, content_old)
664 snap_old = SnapshotRecord(snapshot_id="so" * 32, manifest={"old.txt": oid_old})
665 write_snapshot(tmp_path, snap_old)
666 commit_old = CommitRecord(
667 commit_id="c_old",
668 repo_id="r",
669 branch="main",
670 snapshot_id="so" * 32,
671 message="old",
672 committed_at=datetime.datetime.now(datetime.timezone.utc),
673 )
674 write_commit(tmp_path, commit_old)
675
676 content_new = b"new"
677 oid_new = _sha(content_new)
678 write_object(tmp_path, oid_new, content_new)
679 snap_new = SnapshotRecord(
680 snapshot_id="sn" * 32, manifest={"old.txt": oid_old, "new.txt": oid_new}
681 )
682 write_snapshot(tmp_path, snap_new)
683 commit_new = CommitRecord(
684 commit_id="c_new",
685 repo_id="r",
686 branch="main",
687 snapshot_id="sn" * 32,
688 message="new",
689 committed_at=datetime.datetime.now(datetime.timezone.utc),
690 parent_commit_id="c_old",
691 )
692 write_commit(tmp_path, commit_new)
693
694 ids = collect_object_ids(tmp_path, ["c_new"], have=["c_old"])
695 assert oid_new in ids