cgcardona / muse public
test_push.py python
680 lines 23.7 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Tests for ``muse push``.
2
3 Covers acceptance criteria:
4 - ``muse push`` with no remote configured exits 1 with instructive message.
5 - ``muse push`` calls ``POST <remote>/push`` with correct payload structure.
6 - ``muse push`` updates ``.muse/remotes/origin/<branch>`` after a successful push.
7 - ``muse push`` when branch has no commits exits 1.
8 - Network errors surface as exit code 3.
9 - ``muse push`` with all commits already on remote prints up-to-date message.
10
11 Covers acceptance criteria (new remote sync flags):
12 - ``muse push --force`` sends ``force=True`` in the payload.
13 - ``muse push --force-with-lease`` sends ``force_with_lease=True`` and
14 ``expected_remote_head`` in the payload; a 409 response exits 1.
15 - ``muse push --tags`` includes tag refs from ``.muse/refs/tags/``.
16 - ``muse push --set-upstream`` writes upstream tracking to config after push.
17
18 All HTTP calls are mocked with unittest.mock — no live network required.
19 """
20 from __future__ import annotations
21
22 import asyncio
23 import datetime
24 import json
25 import pathlib
26 from unittest.mock import AsyncMock, MagicMock, patch
27
28 import pytest
29
30 from maestro.muse_cli.commands.push import (
31 _build_push_request,
32 _collect_tag_refs,
33 _compute_push_delta,
34 _push_async,
35 )
36 from maestro.muse_cli.config import get_remote_head, get_upstream, set_remote
37 from maestro.muse_cli.errors import ExitCode
38 from maestro.muse_cli.models import MuseCliCommit
39
40
41 # ---------------------------------------------------------------------------
42 # Helpers
43 # ---------------------------------------------------------------------------
44
45
46 def _init_repo(tmp_path: pathlib.Path, branch: str = "main") -> pathlib.Path:
47 """Create a minimal .muse/ structure with one commit."""
48 import json as _json
49 muse_dir = tmp_path / ".muse"
50 muse_dir.mkdir()
51 (muse_dir / "repo.json").write_text(
52 _json.dumps({"repo_id": "test-repo-id"}), encoding="utf-8"
53 )
54 (muse_dir / "HEAD").write_text(f"refs/heads/{branch}", encoding="utf-8")
55 return tmp_path
56
57
58 def _make_commit(
59 commit_id: str,
60 parent_id: str | None = None,
61 branch: str = "main",
62 repo_id: str = "test-repo-id",
63 ) -> MuseCliCommit:
64 """Build a MuseCliCommit ORM object for testing (not persisted)."""
65 return MuseCliCommit(
66 commit_id=commit_id,
67 repo_id=repo_id,
68 branch=branch,
69 parent_commit_id=parent_id,
70 snapshot_id="snap-" + commit_id[:8],
71 message="Test commit",
72 author="test-author",
73 committed_at=datetime.datetime.now(datetime.timezone.utc),
74 )
75
76
77 def _write_branch_ref(root: pathlib.Path, branch: str, commit_id: str) -> None:
78 """Write .muse/refs/heads/<branch> with the given commit ID."""
79 ref_path = root / ".muse" / "refs" / "heads" / branch
80 ref_path.parent.mkdir(parents=True, exist_ok=True)
81 ref_path.write_text(commit_id, encoding="utf-8")
82
83
84 # ---------------------------------------------------------------------------
85 # test_push_no_remote_exits_1
86 # ---------------------------------------------------------------------------
87
88
89 def test_push_no_remote_exits_1(tmp_path: pathlib.Path) -> None:
90 """muse push exits 1 with instructive message when no remote is configured."""
91 import typer
92
93 root = _init_repo(tmp_path)
94 _write_branch_ref(root, "main", "abc12345" * 8)
95
96 with pytest.raises(typer.Exit) as exc_info:
97 asyncio.run(
98 _push_async(root=root, remote_name="origin", branch=None)
99 )
100
101 assert exc_info.value.exit_code == int(ExitCode.USER_ERROR)
102
103
104 def test_push_no_remote_message_is_instructive(
105 tmp_path: pathlib.Path,
106 capsys: pytest.CaptureFixture[str],
107 ) -> None:
108 """Push with no remote prints a message telling user to run muse remote add."""
109 import typer
110
111 root = _init_repo(tmp_path)
112 _write_branch_ref(root, "main", "abc12345" * 8)
113
114 with pytest.raises(typer.Exit):
115 asyncio.run(_push_async(root=root, remote_name="origin", branch=None))
116
117 captured = capsys.readouterr()
118 assert "muse remote add" in captured.out
119
120
121 # ---------------------------------------------------------------------------
122 # test_push_no_commits_exits_1
123 # ---------------------------------------------------------------------------
124
125
126 def test_push_branch_no_commits_exits_1(tmp_path: pathlib.Path) -> None:
127 """muse push exits 1 when the current branch has no commits (no ref file)."""
128 import typer
129
130 root = _init_repo(tmp_path)
131 set_remote("origin", "https://hub.example.com/musehub/repos/r", root)
132 # No .muse/refs/heads/main file
133
134 with pytest.raises(typer.Exit) as exc_info:
135 asyncio.run(_push_async(root=root, remote_name="origin", branch=None))
136
137 assert exc_info.value.exit_code == int(ExitCode.USER_ERROR)
138
139
140 # ---------------------------------------------------------------------------
141 # test_push_calls_hub_endpoint
142 # ---------------------------------------------------------------------------
143
144
145 @pytest.mark.anyio
146 async def test_push_calls_hub_endpoint(tmp_path: pathlib.Path) -> None:
147 """muse push POSTs to /push with branch, head_commit_id, commits, objects."""
148 import typer
149
150 head_id = "aabbccdd" * 8
151 root = _init_repo(tmp_path)
152 _write_branch_ref(root, "main", head_id)
153 set_remote("origin", "https://hub.example.com/musehub/repos/r", root)
154
155 # Write auth token so MuseHubClient doesn't exit early
156 muse_dir = root / ".muse"
157 (muse_dir / "config.toml").write_text(
158 '[auth]\ntoken = "test-token"\n\n[remotes.origin]\nurl = "https://hub.example.com/musehub/repos/r"\n',
159 encoding="utf-8",
160 )
161
162 commit = _make_commit(head_id)
163 captured_payloads: list[dict[str, object]] = []
164
165 mock_response = MagicMock()
166 mock_response.status_code = 200
167
168 mock_hub = MagicMock()
169 mock_hub.__aenter__ = AsyncMock(return_value=mock_hub)
170 mock_hub.__aexit__ = AsyncMock(return_value=None)
171
172 async def _fake_post(path: str, **kwargs: object) -> MagicMock:
173 payload = kwargs.get("json", {})
174 if isinstance(payload, dict):
175 captured_payloads.append(payload)
176 return mock_response
177
178 mock_hub.post = _fake_post
179
180 with (
181 patch(
182 "maestro.muse_cli.commands.push.get_commits_for_branch",
183 new=AsyncMock(return_value=[commit]),
184 ),
185 patch(
186 "maestro.muse_cli.commands.push.get_all_object_ids",
187 new=AsyncMock(return_value=["obj-001"]),
188 ),
189 patch("maestro.muse_cli.commands.push.open_session") as mock_open_session,
190 patch("maestro.muse_cli.commands.push.MuseHubClient", return_value=mock_hub),
191 ):
192 # open_session returns an async context manager
193 mock_session_ctx = MagicMock()
194 mock_session_ctx.__aenter__ = AsyncMock(return_value=MagicMock())
195 mock_session_ctx.__aexit__ = AsyncMock(return_value=None)
196 mock_open_session.return_value = mock_session_ctx
197
198 await _push_async(root=root, remote_name="origin", branch=None)
199
200 assert len(captured_payloads) == 1
201 payload = captured_payloads[0]
202 assert payload["branch"] == "main"
203 assert payload["head_commit_id"] == head_id
204 assert isinstance(payload["commits"], list)
205 assert isinstance(payload["objects"], list)
206
207
208 # ---------------------------------------------------------------------------
209 # test_push_updates_remote_head_file
210 # ---------------------------------------------------------------------------
211
212
213 @pytest.mark.anyio
214 async def test_push_updates_remote_head_file(tmp_path: pathlib.Path) -> None:
215 """After a successful push, .muse/remotes/origin/<branch> is updated."""
216 head_id = "deadbeef" * 8
217 root = _init_repo(tmp_path)
218 _write_branch_ref(root, "main", head_id)
219
220 muse_dir = root / ".muse"
221 (muse_dir / "config.toml").write_text(
222 '[auth]\ntoken = "tok"\n\n[remotes.origin]\nurl = "https://hub.example.com/r"\n',
223 encoding="utf-8",
224 )
225
226 commit = _make_commit(head_id)
227
228 mock_response = MagicMock()
229 mock_response.status_code = 200
230
231 mock_hub = MagicMock()
232 mock_hub.__aenter__ = AsyncMock(return_value=mock_hub)
233 mock_hub.__aexit__ = AsyncMock(return_value=None)
234 mock_hub.post = AsyncMock(return_value=mock_response)
235
236 with (
237 patch(
238 "maestro.muse_cli.commands.push.get_commits_for_branch",
239 new=AsyncMock(return_value=[commit]),
240 ),
241 patch(
242 "maestro.muse_cli.commands.push.get_all_object_ids",
243 new=AsyncMock(return_value=[]),
244 ),
245 patch("maestro.muse_cli.commands.push.open_session") as mock_open_session,
246 patch("maestro.muse_cli.commands.push.MuseHubClient", return_value=mock_hub),
247 ):
248 mock_session_ctx = MagicMock()
249 mock_session_ctx.__aenter__ = AsyncMock(return_value=MagicMock())
250 mock_session_ctx.__aexit__ = AsyncMock(return_value=None)
251 mock_open_session.return_value = mock_session_ctx
252
253 await _push_async(root=root, remote_name="origin", branch=None)
254
255 remote_head = get_remote_head("origin", "main", root)
256 assert remote_head == head_id
257
258
259 # ---------------------------------------------------------------------------
260 # _compute_push_delta unit tests
261 # ---------------------------------------------------------------------------
262
263
264 def test_compute_push_delta_first_push_returns_all_chronological() -> None:
265 """First push (no remote head) returns all commits oldest-first."""
266 c1 = _make_commit("commit-aaa")
267 c2 = _make_commit("commit-bbb", parent_id="commit-aaa")
268 # DB returns newest first
269 commits = [c2, c1]
270 delta = _compute_push_delta(commits, remote_head=None)
271 assert [c.commit_id for c in delta] == ["commit-aaa", "commit-bbb"]
272
273
274 def test_compute_push_delta_returns_only_new_commits() -> None:
275 """Delta excludes commits already on the remote."""
276 c1 = _make_commit("commit-001")
277 c2 = _make_commit("commit-002", parent_id="commit-001")
278 c3 = _make_commit("commit-003", parent_id="commit-002")
279 commits = [c3, c2, c1] # newest first
280
281 delta = _compute_push_delta(commits, remote_head="commit-001")
282 assert [c.commit_id for c in delta] == ["commit-002", "commit-003"]
283
284
285 def test_compute_push_delta_already_synced_returns_empty() -> None:
286 """When local HEAD == remote head, delta is empty."""
287 c1 = _make_commit("commit-001")
288 commits = [c1]
289 delta = _compute_push_delta(commits, remote_head="commit-001")
290 assert delta == []
291
292
293 def test_compute_push_delta_empty_commits() -> None:
294 """Empty commit list always returns empty delta."""
295 assert _compute_push_delta([], remote_head=None) == []
296 assert _compute_push_delta([], remote_head="some-id") == []
297
298
299 # ---------------------------------------------------------------------------
300 # _build_push_request unit tests
301 # ---------------------------------------------------------------------------
302
303
304 def test_build_push_request_structure() -> None:
305 """_build_push_request produces correct PushRequest dict shape."""
306 c1 = _make_commit("commit-aaa")
307 request = _build_push_request(
308 branch="main",
309 head_commit_id="commit-aaa",
310 delta=[c1],
311 all_object_ids=["obj-001", "obj-002"],
312 )
313 assert request["branch"] == "main"
314 assert request["head_commit_id"] == "commit-aaa"
315 assert len(request["commits"]) == 1
316 assert request["commits"][0]["commit_id"] == "commit-aaa"
317 assert len(request["objects"]) == 2
318 assert request["objects"][0]["object_id"] == "obj-001"
319
320
321 # ---------------------------------------------------------------------------
322 # Issue #77 — new remote sync flags
323 # ---------------------------------------------------------------------------
324
325
326 def test_build_push_request_force_flag() -> None:
327 """_build_push_request includes force=True when requested."""
328 c1 = _make_commit("commit-aaa")
329 request = _build_push_request(
330 branch="main",
331 head_commit_id="commit-aaa",
332 delta=[c1],
333 all_object_ids=[],
334 force=True,
335 )
336 assert request.get("force") is True
337 assert "force_with_lease" not in request
338
339
340 def test_build_push_request_force_with_lease() -> None:
341 """_build_push_request includes force_with_lease and expected_remote_head."""
342 c1 = _make_commit("commit-aaa")
343 expected_head = "old-remote-sha" * 4
344 request = _build_push_request(
345 branch="main",
346 head_commit_id="commit-aaa",
347 delta=[c1],
348 all_object_ids=[],
349 force_with_lease=True,
350 expected_remote_head=expected_head,
351 )
352 assert request.get("force_with_lease") is True
353 assert request.get("expected_remote_head") == expected_head
354 assert "force" not in request
355
356
357 def test_build_push_request_no_force_flags_by_default() -> None:
358 """_build_push_request does not include force flags unless explicitly set."""
359 c1 = _make_commit("commit-bbb")
360 request = _build_push_request(
361 branch="main",
362 head_commit_id="commit-bbb",
363 delta=[c1],
364 all_object_ids=[],
365 )
366 assert "force" not in request
367 assert "force_with_lease" not in request
368 assert "tags" not in request
369
370
371 def test_build_push_request_tags() -> None:
372 """_build_push_request includes tags when tag_payloads is provided."""
373 from maestro.muse_cli.hub_client import PushTagPayload
374
375 c1 = _make_commit("commit-ccc")
376 tags = [PushTagPayload(tag_name="v1.0", commit_id="commit-ccc")]
377 request = _build_push_request(
378 branch="main",
379 head_commit_id="commit-ccc",
380 delta=[c1],
381 all_object_ids=[],
382 tag_payloads=tags,
383 )
384 assert "tags" in request
385 assert len(request["tags"]) == 1
386 assert request["tags"][0]["tag_name"] == "v1.0"
387 assert request["tags"][0]["commit_id"] == "commit-ccc"
388
389
390 # ---------------------------------------------------------------------------
391 # _collect_tag_refs unit tests
392 # ---------------------------------------------------------------------------
393
394
395 def test_collect_tag_refs_empty_when_no_tags_dir(tmp_path: pathlib.Path) -> None:
396 """_collect_tag_refs returns empty list when .muse/refs/tags/ does not exist."""
397 result = _collect_tag_refs(tmp_path)
398 assert result == []
399
400
401 def test_collect_tag_refs_returns_tags_from_dir(tmp_path: pathlib.Path) -> None:
402 """_collect_tag_refs reads each tag file and returns name+commit pairs."""
403 tags_dir = tmp_path / ".muse" / "refs" / "tags"
404 tags_dir.mkdir(parents=True)
405 commit_v1 = "aaaa" * 16
406 commit_v2 = "bbbb" * 16
407 (tags_dir / "v1.0").write_text(commit_v1, encoding="utf-8")
408 (tags_dir / "v2.0").write_text(commit_v2, encoding="utf-8")
409
410 result = _collect_tag_refs(tmp_path)
411 assert len(result) == 2
412 tag_map = {t["tag_name"]: t["commit_id"] for t in result}
413 assert tag_map["v1.0"] == commit_v1
414 assert tag_map["v2.0"] == commit_v2
415
416
417 def test_collect_tag_refs_ignores_empty_files(tmp_path: pathlib.Path) -> None:
418 """_collect_tag_refs skips tag files with empty content."""
419 tags_dir = tmp_path / ".muse" / "refs" / "tags"
420 tags_dir.mkdir(parents=True)
421 (tags_dir / "empty-tag").write_text("", encoding="utf-8")
422 (tags_dir / "v1.0").write_text("aabbccdd" * 8, encoding="utf-8")
423
424 result = _collect_tag_refs(tmp_path)
425 assert len(result) == 1
426 assert result[0]["tag_name"] == "v1.0"
427
428
429 # ---------------------------------------------------------------------------
430 # test_push_force_with_lease_rejects_mismatch (regression)
431 # ---------------------------------------------------------------------------
432
433
434 @pytest.mark.anyio
435 async def test_push_force_with_lease_rejects_mismatch(
436 tmp_path: pathlib.Path,
437 capsys: pytest.CaptureFixture[str],
438 ) -> None:
439 """When Hub returns 409, --force-with-lease exits 1 with instructive message.
440
441 Regression: the hub rejects the push because the remote HEAD
442 has advanced beyond our last-known tracking pointer.
443 """
444 import typer
445
446 head_id = "localtip1234" * 5
447 root = _init_repo(tmp_path)
448 _write_branch_ref(root, "main", head_id)
449
450 muse_dir = root / ".muse"
451 (muse_dir / "config.toml").write_text(
452 '[auth]\ntoken = "tok"\n\n[remotes.origin]\nurl = "https://hub.example.com/r"\n',
453 encoding="utf-8",
454 )
455 # Record a known remote head (the "lease" value)
456 from maestro.muse_cli.config import set_remote_head as _set_rh
457 old_remote_head = "oldremotehead" * 5
458 _set_rh("origin", "main", old_remote_head, root)
459
460 commit = _make_commit(head_id)
461
462 # Hub responds with 409 — remote has advanced
463 mock_response = MagicMock()
464 mock_response.status_code = 409
465 mock_response.text = "remote has advanced"
466
467 mock_hub = MagicMock()
468 mock_hub.__aenter__ = AsyncMock(return_value=mock_hub)
469 mock_hub.__aexit__ = AsyncMock(return_value=None)
470 mock_hub.post = AsyncMock(return_value=mock_response)
471
472 with (
473 patch(
474 "maestro.muse_cli.commands.push.get_commits_for_branch",
475 new=AsyncMock(return_value=[commit]),
476 ),
477 patch(
478 "maestro.muse_cli.commands.push.get_all_object_ids",
479 new=AsyncMock(return_value=[]),
480 ),
481 patch("maestro.muse_cli.commands.push.open_session") as mock_open_session,
482 patch("maestro.muse_cli.commands.push.MuseHubClient", return_value=mock_hub),
483 ):
484 mock_session_ctx = MagicMock()
485 mock_session_ctx.__aenter__ = AsyncMock(return_value=MagicMock())
486 mock_session_ctx.__aexit__ = AsyncMock(return_value=None)
487 mock_open_session.return_value = mock_session_ctx
488
489 with pytest.raises(typer.Exit) as exc_info:
490 await _push_async(
491 root=root,
492 remote_name="origin",
493 branch=None,
494 force_with_lease=True,
495 )
496
497 assert exc_info.value.exit_code == int(ExitCode.USER_ERROR)
498 captured = capsys.readouterr()
499 assert "fetch" in captured.out.lower() or "advanced" in captured.out.lower()
500
501
502 # ---------------------------------------------------------------------------
503 # test_push_tags_includes_tags (regression)
504 # ---------------------------------------------------------------------------
505
506
507 @pytest.mark.anyio
508 async def test_push_tags_includes_tags(tmp_path: pathlib.Path) -> None:
509 """--tags flag includes VCS tag refs in the push payload."""
510 head_id = "tagpushhead1" * 5
511 root = _init_repo(tmp_path)
512 _write_branch_ref(root, "main", head_id)
513
514 muse_dir = root / ".muse"
515 (muse_dir / "config.toml").write_text(
516 '[auth]\ntoken = "tok"\n\n[remotes.origin]\nurl = "https://hub.example.com/r"\n',
517 encoding="utf-8",
518 )
519
520 # Write a tag ref
521 tags_dir = muse_dir / "refs" / "tags"
522 tags_dir.mkdir(parents=True)
523 (tags_dir / "v1.0").write_text(head_id, encoding="utf-8")
524
525 commit = _make_commit(head_id)
526 captured_payloads: list[dict[str, object]] = []
527
528 mock_response = MagicMock()
529 mock_response.status_code = 200
530
531 mock_hub = MagicMock()
532 mock_hub.__aenter__ = AsyncMock(return_value=mock_hub)
533 mock_hub.__aexit__ = AsyncMock(return_value=None)
534
535 async def _fake_post(path: str, **kwargs: object) -> MagicMock:
536 payload = kwargs.get("json", {})
537 if isinstance(payload, dict):
538 captured_payloads.append(payload)
539 return mock_response
540
541 mock_hub.post = _fake_post
542
543 with (
544 patch(
545 "maestro.muse_cli.commands.push.get_commits_for_branch",
546 new=AsyncMock(return_value=[commit]),
547 ),
548 patch(
549 "maestro.muse_cli.commands.push.get_all_object_ids",
550 new=AsyncMock(return_value=[]),
551 ),
552 patch("maestro.muse_cli.commands.push.open_session") as mock_open_session,
553 patch("maestro.muse_cli.commands.push.MuseHubClient", return_value=mock_hub),
554 ):
555 mock_session_ctx = MagicMock()
556 mock_session_ctx.__aenter__ = AsyncMock(return_value=MagicMock())
557 mock_session_ctx.__aexit__ = AsyncMock(return_value=None)
558 mock_open_session.return_value = mock_session_ctx
559
560 await _push_async(root=root, remote_name="origin", branch=None, include_tags=True)
561
562 assert len(captured_payloads) == 1
563 payload = captured_payloads[0]
564 assert "tags" in payload
565 tags = payload["tags"]
566 assert isinstance(tags, list)
567 assert len(tags) == 1
568 assert tags[0]["tag_name"] == "v1.0"
569 assert tags[0]["commit_id"] == head_id
570
571
572 # ---------------------------------------------------------------------------
573 # test_push_set_upstream_writes_config (regression)
574 # ---------------------------------------------------------------------------
575
576
577 @pytest.mark.anyio
578 async def test_push_set_upstream_writes_config(tmp_path: pathlib.Path) -> None:
579 """--set-upstream writes upstream tracking to .muse/config.toml after push."""
580 head_id = "upstreamtest" * 5
581 root = _init_repo(tmp_path)
582 _write_branch_ref(root, "main", head_id)
583
584 muse_dir = root / ".muse"
585 (muse_dir / "config.toml").write_text(
586 '[auth]\ntoken = "tok"\n\n[remotes.origin]\nurl = "https://hub.example.com/r"\n',
587 encoding="utf-8",
588 )
589
590 commit = _make_commit(head_id)
591
592 mock_response = MagicMock()
593 mock_response.status_code = 200
594
595 mock_hub = MagicMock()
596 mock_hub.__aenter__ = AsyncMock(return_value=mock_hub)
597 mock_hub.__aexit__ = AsyncMock(return_value=None)
598 mock_hub.post = AsyncMock(return_value=mock_response)
599
600 with (
601 patch(
602 "maestro.muse_cli.commands.push.get_commits_for_branch",
603 new=AsyncMock(return_value=[commit]),
604 ),
605 patch(
606 "maestro.muse_cli.commands.push.get_all_object_ids",
607 new=AsyncMock(return_value=[]),
608 ),
609 patch("maestro.muse_cli.commands.push.open_session") as mock_open_session,
610 patch("maestro.muse_cli.commands.push.MuseHubClient", return_value=mock_hub),
611 ):
612 mock_session_ctx = MagicMock()
613 mock_session_ctx.__aenter__ = AsyncMock(return_value=MagicMock())
614 mock_session_ctx.__aexit__ = AsyncMock(return_value=None)
615 mock_open_session.return_value = mock_session_ctx
616
617 await _push_async(
618 root=root,
619 remote_name="origin",
620 branch=None,
621 set_upstream_flag=True,
622 )
623
624 # After push with --set-upstream, config should record tracking branch
625 upstream = get_upstream("main", root)
626 assert upstream == "origin"
627
628
629 @pytest.mark.anyio
630 async def test_push_force_flag_sends_force_in_payload(tmp_path: pathlib.Path) -> None:
631 """--force flag sends force=True in the push payload."""
632 head_id = "forcetest1234" * 4
633 root = _init_repo(tmp_path)
634 _write_branch_ref(root, "main", head_id)
635
636 muse_dir = root / ".muse"
637 (muse_dir / "config.toml").write_text(
638 '[auth]\ntoken = "tok"\n\n[remotes.origin]\nurl = "https://hub.example.com/r"\n',
639 encoding="utf-8",
640 )
641
642 commit = _make_commit(head_id)
643 captured_payloads: list[dict[str, object]] = []
644
645 mock_response = MagicMock()
646 mock_response.status_code = 200
647
648 mock_hub = MagicMock()
649 mock_hub.__aenter__ = AsyncMock(return_value=mock_hub)
650 mock_hub.__aexit__ = AsyncMock(return_value=None)
651
652 async def _fake_post(path: str, **kwargs: object) -> MagicMock:
653 payload = kwargs.get("json", {})
654 if isinstance(payload, dict):
655 captured_payloads.append(payload)
656 return mock_response
657
658 mock_hub.post = _fake_post
659
660 with (
661 patch(
662 "maestro.muse_cli.commands.push.get_commits_for_branch",
663 new=AsyncMock(return_value=[commit]),
664 ),
665 patch(
666 "maestro.muse_cli.commands.push.get_all_object_ids",
667 new=AsyncMock(return_value=[]),
668 ),
669 patch("maestro.muse_cli.commands.push.open_session") as mock_open_session,
670 patch("maestro.muse_cli.commands.push.MuseHubClient", return_value=mock_hub),
671 ):
672 mock_session_ctx = MagicMock()
673 mock_session_ctx.__aenter__ = AsyncMock(return_value=MagicMock())
674 mock_session_ctx.__aexit__ = AsyncMock(return_value=None)
675 mock_open_session.return_value = mock_session_ctx
676
677 await _push_async(root=root, remote_name="origin", branch=None, force=True)
678
679 assert len(captured_payloads) == 1
680 assert captured_payloads[0].get("force") is True