cgcardona / muse public
test_pull.py python
1192 lines 43.0 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """Tests for ``muse pull``.
2
3 Covers acceptance criteria:
4 - ``muse pull`` with no remote configured exits 1 with instructive message.
5 - ``muse pull`` calls ``POST <remote>/pull`` with correct payload structure.
6 - Returned commits are stored in local Postgres (via DB helpers).
7 - ``.muse/remotes/origin/<branch>`` is updated after a successful pull.
8 - Divergence message is printed (exit 0) when branches have diverged.
9
10 Covers acceptance criteria (new remote sync flags):
11 - ``muse pull --rebase``: fast-forwards local branch when remote is ahead.
12 - ``muse pull --rebase``: rebases local commits when branches have diverged.
13 - ``muse pull --ff-only``: fast-forwards local branch when remote is ahead.
14 - ``muse pull --ff-only``: exits 1 when branches have diverged.
15
16 All HTTP calls are mocked — no live network required.
17 DB calls use the in-memory SQLite fixture from conftest.py where needed.
18 """
19 from __future__ import annotations
20
21 import asyncio
22 import datetime
23 import json
24 import pathlib
25 from unittest.mock import AsyncMock, MagicMock, patch
26
27 import pytest
28
29 from maestro.muse_cli.commands.pull import _is_ancestor, _pull_async, _rebase_commits_onto
30 from maestro.muse_cli.commands.push import _push_async
31 from maestro.muse_cli.config import get_remote_head, set_remote
32 from maestro.muse_cli.db import store_pulled_commit, store_pulled_object
33 from maestro.muse_cli.errors import ExitCode
34 from maestro.muse_cli.models import MuseCliCommit
35
36
37 # ---------------------------------------------------------------------------
38 # Helpers
39 # ---------------------------------------------------------------------------
40
41
42 def _init_repo(tmp_path: pathlib.Path, branch: str = "main") -> pathlib.Path:
43 """Create a minimal .muse/ structure."""
44 import json as _json
45 muse_dir = tmp_path / ".muse"
46 muse_dir.mkdir()
47 (muse_dir / "repo.json").write_text(
48 _json.dumps({"repo_id": "test-repo-id"}), encoding="utf-8"
49 )
50 (muse_dir / "HEAD").write_text(f"refs/heads/{branch}", encoding="utf-8")
51 return tmp_path
52
53
54 def _write_branch_ref(root: pathlib.Path, branch: str, commit_id: str) -> None:
55 ref_path = root / ".muse" / "refs" / "heads" / branch
56 ref_path.parent.mkdir(parents=True, exist_ok=True)
57 ref_path.write_text(commit_id, encoding="utf-8")
58
59
60 def _write_config_with_token(root: pathlib.Path, remote_url: str) -> None:
61 muse_dir = root / ".muse"
62 (muse_dir / "config.toml").write_text(
63 f'[auth]\ntoken = "test-token"\n\n[remotes.origin]\nurl = "{remote_url}"\n',
64 encoding="utf-8",
65 )
66
67
68 def _make_hub_pull_response(
69 commits: list[dict[str, object]] | None = None,
70 objects: list[dict[str, object]] | None = None,
71 remote_head: str | None = "remote-head-001",
72 diverged: bool = False,
73 ) -> MagicMock:
74 """Return a mock httpx.Response for the pull endpoint."""
75 mock_resp = MagicMock()
76 mock_resp.status_code = 200
77 mock_resp.json.return_value = {
78 "commits": commits or [],
79 "objects": objects or [],
80 "remote_head": remote_head,
81 "diverged": diverged,
82 }
83 return mock_resp
84
85
86 # ---------------------------------------------------------------------------
87 # test_pull_no_remote_exits_1
88 # ---------------------------------------------------------------------------
89
90
91 def test_pull_no_remote_exits_1(tmp_path: pathlib.Path) -> None:
92 """muse pull exits 1 with instructive message when no remote is configured."""
93 import typer
94
95 root = _init_repo(tmp_path)
96
97 with pytest.raises(typer.Exit) as exc_info:
98 asyncio.run(_pull_async(root=root, remote_name="origin", branch=None))
99
100 assert exc_info.value.exit_code == int(ExitCode.USER_ERROR)
101
102
103 def test_pull_no_remote_message_is_instructive(
104 tmp_path: pathlib.Path,
105 capsys: pytest.CaptureFixture[str],
106 ) -> None:
107 """Pull with no remote prints a message telling user to run muse remote add."""
108 import typer
109
110 root = _init_repo(tmp_path)
111
112 with pytest.raises(typer.Exit):
113 asyncio.run(_pull_async(root=root, remote_name="origin", branch=None))
114
115 captured = capsys.readouterr()
116 assert "muse remote add" in captured.out
117
118
119 # ---------------------------------------------------------------------------
120 # test_pull_calls_hub_endpoint
121 # ---------------------------------------------------------------------------
122
123
124 @pytest.mark.anyio
125 async def test_pull_calls_hub_endpoint(tmp_path: pathlib.Path) -> None:
126 """muse pull POSTs to /pull with branch, have_commits, have_objects."""
127 root = _init_repo(tmp_path)
128 _write_config_with_token(root, "https://hub.example.com/musehub/repos/r")
129
130 captured_payloads: list[dict[str, object]] = []
131
132 mock_response = _make_hub_pull_response()
133
134 mock_hub = MagicMock()
135 mock_hub.__aenter__ = AsyncMock(return_value=mock_hub)
136 mock_hub.__aexit__ = AsyncMock(return_value=None)
137
138 async def _fake_post(path: str, **kwargs: object) -> MagicMock:
139 payload = kwargs.get("json", {})
140 if isinstance(payload, dict):
141 captured_payloads.append(payload)
142 return mock_response
143
144 mock_hub.post = _fake_post
145
146 with (
147 patch(
148 "maestro.muse_cli.commands.pull.get_commits_for_branch",
149 new=AsyncMock(return_value=[]),
150 ),
151 patch(
152 "maestro.muse_cli.commands.pull.get_all_object_ids",
153 new=AsyncMock(return_value=[]),
154 ),
155 patch(
156 "maestro.muse_cli.commands.pull.store_pulled_commit",
157 new=AsyncMock(return_value=False),
158 ),
159 patch(
160 "maestro.muse_cli.commands.pull.store_pulled_object",
161 new=AsyncMock(return_value=False),
162 ),
163 patch("maestro.muse_cli.commands.pull.open_session") as mock_open_session,
164 patch("maestro.muse_cli.commands.pull.MuseHubClient", return_value=mock_hub),
165 ):
166 mock_session_ctx = MagicMock()
167 mock_session_ctx.__aenter__ = AsyncMock(return_value=MagicMock())
168 mock_session_ctx.__aexit__ = AsyncMock(return_value=None)
169 mock_open_session.return_value = mock_session_ctx
170
171 await _pull_async(root=root, remote_name="origin", branch=None)
172
173 assert len(captured_payloads) == 1
174 payload = captured_payloads[0]
175 assert payload["branch"] == "main"
176 assert "have_commits" in payload
177 assert "have_objects" in payload
178
179
180 # ---------------------------------------------------------------------------
181 # test_pull_stores_commits_in_db
182 # ---------------------------------------------------------------------------
183
184
185 @pytest.mark.anyio
186 async def test_pull_stores_commits_in_db(muse_cli_db_session: object) -> None:
187 """Commits returned from the Hub are stored in local Postgres via store_pulled_commit."""
188 # Use the in-memory SQLite session fixture
189 from sqlalchemy.ext.asyncio import AsyncSession
190 from maestro.muse_cli.models import MuseCliCommit as MCCommit
191
192 session: AsyncSession = muse_cli_db_session # type: ignore[assignment]
193
194 commit_data: dict[str, object] = {
195 "commit_id": "pulled-commit-abc123" * 3,
196 "repo_id": "test-repo-id",
197 "parent_commit_id": None,
198 "snapshot_id": "snap-abc",
199 "branch": "main",
200 "message": "Pulled from remote",
201 "author": "remote-author",
202 "committed_at": "2025-01-01T00:00:00+00:00",
203 "metadata": None,
204 }
205
206 # Ensure snapshot stub is written (store_pulled_commit creates one)
207 inserted = await store_pulled_commit(session, commit_data)
208 await session.commit()
209
210 assert inserted is True
211
212 # Verify in DB
213 commit_id = str(commit_data["commit_id"])
214 stored = await session.get(MCCommit, commit_id)
215 assert stored is not None
216 assert stored.message == "Pulled from remote"
217 assert stored.branch == "main"
218
219
220 @pytest.mark.anyio
221 async def test_pull_stores_commits_idempotent(muse_cli_db_session: object) -> None:
222 """Storing the same pulled commit twice does not raise and returns False on dup."""
223 from sqlalchemy.ext.asyncio import AsyncSession
224
225 session: AsyncSession = muse_cli_db_session # type: ignore[assignment]
226
227 commit_data: dict[str, object] = {
228 "commit_id": "idem-commit-xyz789" * 3,
229 "repo_id": "test-repo-id",
230 "parent_commit_id": None,
231 "snapshot_id": "snap-idem",
232 "branch": "main",
233 "message": "Idempotent test",
234 "author": "",
235 "committed_at": "2025-01-01T00:00:00+00:00",
236 "metadata": None,
237 }
238
239 first = await store_pulled_commit(session, commit_data)
240 await session.flush()
241 second = await store_pulled_commit(session, commit_data)
242
243 assert first is True
244 assert second is False
245
246
247 @pytest.mark.anyio
248 async def test_pull_stores_objects_in_db(muse_cli_db_session: object) -> None:
249 """Objects returned from the Hub are stored in local Postgres via store_pulled_object."""
250 from sqlalchemy.ext.asyncio import AsyncSession
251 from maestro.muse_cli.models import MuseCliObject
252
253 session: AsyncSession = muse_cli_db_session # type: ignore[assignment]
254
255 obj_data: dict[str, object] = {
256 "object_id": "a" * 64,
257 "size_bytes": 1024,
258 }
259
260 inserted = await store_pulled_object(session, obj_data)
261 await session.commit()
262
263 assert inserted is True
264 stored = await session.get(MuseCliObject, "a" * 64)
265 assert stored is not None
266 assert stored.size_bytes == 1024
267
268
269 # ---------------------------------------------------------------------------
270 # test_pull_updates_remote_head_file
271 # ---------------------------------------------------------------------------
272
273
274 @pytest.mark.anyio
275 async def test_pull_updates_remote_head_file(tmp_path: pathlib.Path) -> None:
276 """After a successful pull, .muse/remotes/origin/<branch> is updated."""
277 root = _init_repo(tmp_path)
278 _write_config_with_token(root, "https://hub.example.com/musehub/repos/r")
279
280 remote_head = "new-remote-commit-aabbccddeeff0011" * 2
281
282 mock_response = _make_hub_pull_response(remote_head=remote_head)
283
284 mock_hub = MagicMock()
285 mock_hub.__aenter__ = AsyncMock(return_value=mock_hub)
286 mock_hub.__aexit__ = AsyncMock(return_value=None)
287 mock_hub.post = AsyncMock(return_value=mock_response)
288
289 with (
290 patch(
291 "maestro.muse_cli.commands.pull.get_commits_for_branch",
292 new=AsyncMock(return_value=[]),
293 ),
294 patch(
295 "maestro.muse_cli.commands.pull.get_all_object_ids",
296 new=AsyncMock(return_value=[]),
297 ),
298 patch(
299 "maestro.muse_cli.commands.pull.store_pulled_commit",
300 new=AsyncMock(return_value=False),
301 ),
302 patch(
303 "maestro.muse_cli.commands.pull.store_pulled_object",
304 new=AsyncMock(return_value=False),
305 ),
306 patch("maestro.muse_cli.commands.pull.open_session") as mock_open_session,
307 patch("maestro.muse_cli.commands.pull.MuseHubClient", return_value=mock_hub),
308 ):
309 mock_session_ctx = MagicMock()
310 mock_session_ctx.__aenter__ = AsyncMock(return_value=MagicMock())
311 mock_session_ctx.__aexit__ = AsyncMock(return_value=None)
312 mock_open_session.return_value = mock_session_ctx
313
314 await _pull_async(root=root, remote_name="origin", branch=None)
315
316 stored_head = get_remote_head("origin", "main", root)
317 assert stored_head == remote_head
318
319
320 # ---------------------------------------------------------------------------
321 # test_pull_diverged_prints_warning
322 # ---------------------------------------------------------------------------
323
324
325 @pytest.mark.anyio
326 async def test_pull_diverged_prints_warning(
327 tmp_path: pathlib.Path,
328 capsys: pytest.CaptureFixture[str],
329 ) -> None:
330 """When Hub reports diverged=True, a warning message is printed (exit 0)."""
331 root = _init_repo(tmp_path)
332 _write_config_with_token(root, "https://hub.example.com/musehub/repos/r")
333
334 mock_response = _make_hub_pull_response(diverged=True, remote_head="remote-head-xx")
335
336 mock_hub = MagicMock()
337 mock_hub.__aenter__ = AsyncMock(return_value=mock_hub)
338 mock_hub.__aexit__ = AsyncMock(return_value=None)
339 mock_hub.post = AsyncMock(return_value=mock_response)
340
341 with (
342 patch(
343 "maestro.muse_cli.commands.pull.get_commits_for_branch",
344 new=AsyncMock(return_value=[]),
345 ),
346 patch(
347 "maestro.muse_cli.commands.pull.get_all_object_ids",
348 new=AsyncMock(return_value=[]),
349 ),
350 patch(
351 "maestro.muse_cli.commands.pull.store_pulled_commit",
352 new=AsyncMock(return_value=False),
353 ),
354 patch(
355 "maestro.muse_cli.commands.pull.store_pulled_object",
356 new=AsyncMock(return_value=False),
357 ),
358 patch("maestro.muse_cli.commands.pull.open_session") as mock_open_session,
359 patch("maestro.muse_cli.commands.pull.MuseHubClient", return_value=mock_hub),
360 ):
361 mock_session_ctx = MagicMock()
362 mock_session_ctx.__aenter__ = AsyncMock(return_value=MagicMock())
363 mock_session_ctx.__aexit__ = AsyncMock(return_value=None)
364 mock_open_session.return_value = mock_session_ctx
365
366 # Should NOT raise (diverge is exit 0)
367 await _pull_async(root=root, remote_name="origin", branch=None)
368
369 captured = capsys.readouterr()
370 assert "diverged" in captured.out.lower() or "merge" in captured.out.lower()
371
372
373 # ---------------------------------------------------------------------------
374 # _is_ancestor unit tests
375 # ---------------------------------------------------------------------------
376
377
378 def _make_commit_stub(commit_id: str, parent_id: str | None = None) -> MuseCliCommit:
379 return MuseCliCommit(
380 commit_id=commit_id,
381 repo_id="r",
382 branch="main",
383 parent_commit_id=parent_id,
384 snapshot_id="snap",
385 message="msg",
386 author="",
387 committed_at=datetime.datetime.now(datetime.timezone.utc),
388 )
389
390
391 def test_is_ancestor_direct_parent() -> None:
392 """parent is an ancestor of child."""
393 c1 = _make_commit_stub("commit-001")
394 c2 = _make_commit_stub("commit-002", parent_id="commit-001")
395 by_id = {c.commit_id: c for c in [c1, c2]}
396 assert _is_ancestor(by_id, "commit-001", "commit-002") is True
397
398
399 def test_is_ancestor_same_commit() -> None:
400 """A commit is its own ancestor."""
401 c1 = _make_commit_stub("commit-001")
402 by_id = {"commit-001": c1}
403 assert _is_ancestor(by_id, "commit-001", "commit-001") is True
404
405
406 def test_is_ancestor_unrelated() -> None:
407 """Two unrelated commits are not ancestors of each other."""
408 c1 = _make_commit_stub("commit-001")
409 c2 = _make_commit_stub("commit-002")
410 by_id = {c.commit_id: c for c in [c1, c2]}
411 assert _is_ancestor(by_id, "commit-001", "commit-002") is False
412
413
414 def test_is_ancestor_transitive() -> None:
415 """Ancestor check traverses multi-hop parent chain."""
416 c1 = _make_commit_stub("commit-001")
417 c2 = _make_commit_stub("commit-002", parent_id="commit-001")
418 c3 = _make_commit_stub("commit-003", parent_id="commit-002")
419 by_id = {c.commit_id: c for c in [c1, c2, c3]}
420 assert _is_ancestor(by_id, "commit-001", "commit-003") is True
421
422
423 def test_is_ancestor_descendant_unknown() -> None:
424 """Returns False when descendant is not in commits_by_id."""
425 by_id: dict[str, MuseCliCommit] = {}
426 assert _is_ancestor(by_id, "commit-001", "commit-002") is False
427
428
429 # ---------------------------------------------------------------------------
430 # test_push_pull_roundtrip (integration-style with two tmp_path dirs)
431 # ---------------------------------------------------------------------------
432
433
434 @pytest.mark.anyio
435 async def test_push_pull_roundtrip(tmp_path: pathlib.Path) -> None:
436 """Simulate push from dir A then pull in dir B — remote_head is consistent.
437
438 This is a lightweight integration test: both push and pull call real config
439 read/write code; only the HTTP and DB layers are mocked. The remote_head
440 tracking file is the shared state that must be consistent across both
441 operations.
442 """
443 dir_a = tmp_path / "repo_a"
444 dir_b = tmp_path / "repo_b"
445 dir_a.mkdir()
446 dir_b.mkdir()
447
448 head_id = "sync-commit-id12345abcdef" * 2
449 hub_url = "https://hub.example.com/musehub/repos/shared"
450
451 # --- Set up repo A (pusher) -------------------------------------------
452 import json as _json
453 for d in [dir_a, dir_b]:
454 (d / ".muse").mkdir()
455 (d / ".muse" / "repo.json").write_text(
456 _json.dumps({"repo_id": "shared-repo"}), encoding="utf-8"
457 )
458 (d / ".muse" / "HEAD").write_text("refs/heads/main", encoding="utf-8")
459 (d / ".muse" / "config.toml").write_text(
460 f'[auth]\ntoken = "tok"\n\n[remotes.origin]\nurl = "{hub_url}"\n',
461 encoding="utf-8",
462 )
463
464 _write_branch_ref(dir_a, "main", head_id)
465
466 from maestro.muse_cli.models import MuseCliCommit as MCCommit
467 commit_a = MCCommit(
468 commit_id=head_id,
469 repo_id="shared-repo",
470 branch="main",
471 parent_commit_id=None,
472 snapshot_id="snap-aa",
473 message="First shared commit",
474 author="a",
475 committed_at=datetime.datetime.now(datetime.timezone.utc),
476 )
477
478 # Push from dir_a
479 mock_push_resp = MagicMock()
480 mock_push_resp.status_code = 200
481 mock_hub_push = MagicMock()
482 mock_hub_push.__aenter__ = AsyncMock(return_value=mock_hub_push)
483 mock_hub_push.__aexit__ = AsyncMock(return_value=None)
484 mock_hub_push.post = AsyncMock(return_value=mock_push_resp)
485
486 with (
487 patch(
488 "maestro.muse_cli.commands.push.get_commits_for_branch",
489 new=AsyncMock(return_value=[commit_a]),
490 ),
491 patch("maestro.muse_cli.commands.push.get_all_object_ids", new=AsyncMock(return_value=[])),
492 patch("maestro.muse_cli.commands.push.open_session") as mock_push_session,
493 patch("maestro.muse_cli.commands.push.MuseHubClient", return_value=mock_hub_push),
494 ):
495 ctx = MagicMock()
496 ctx.__aenter__ = AsyncMock(return_value=MagicMock())
497 ctx.__aexit__ = AsyncMock(return_value=None)
498 mock_push_session.return_value = ctx
499 await _push_async(root=dir_a, remote_name="origin", branch=None)
500
501 # Verify dir_a now has remote tracking head
502 assert get_remote_head("origin", "main", dir_a) == head_id
503
504 # Pull into dir_b using the head_id as the remote head
505 mock_pull_resp = _make_hub_pull_response(
506 commits=[{
507 "commit_id": head_id,
508 "repo_id": "shared-repo",
509 "parent_commit_id": None,
510 "snapshot_id": "snap-aa",
511 "branch": "main",
512 "message": "First shared commit",
513 "author": "a",
514 "committed_at": "2025-01-01T00:00:00+00:00",
515 "metadata": None,
516 }],
517 remote_head=head_id,
518 diverged=False,
519 )
520
521 mock_hub_pull = MagicMock()
522 mock_hub_pull.__aenter__ = AsyncMock(return_value=mock_hub_pull)
523 mock_hub_pull.__aexit__ = AsyncMock(return_value=None)
524 mock_hub_pull.post = AsyncMock(return_value=mock_pull_resp)
525
526 with (
527 patch("maestro.muse_cli.commands.pull.get_commits_for_branch", new=AsyncMock(return_value=[])),
528 patch("maestro.muse_cli.commands.pull.get_all_object_ids", new=AsyncMock(return_value=[])),
529 patch("maestro.muse_cli.commands.pull.store_pulled_commit", new=AsyncMock(return_value=True)),
530 patch("maestro.muse_cli.commands.pull.store_pulled_object", new=AsyncMock(return_value=False)),
531 patch("maestro.muse_cli.commands.pull.open_session") as mock_pull_session,
532 patch("maestro.muse_cli.commands.pull.MuseHubClient", return_value=mock_hub_pull),
533 ):
534 ctx2 = MagicMock()
535 ctx2.__aenter__ = AsyncMock(return_value=MagicMock())
536 ctx2.__aexit__ = AsyncMock(return_value=None)
537 mock_pull_session.return_value = ctx2
538 await _pull_async(root=dir_b, remote_name="origin", branch=None)
539
540 # dir_b now has the remote head from the push
541 assert get_remote_head("origin", "main", dir_b) == head_id
542
543
544 # ---------------------------------------------------------------------------
545 # Issue #77 — new pull flags: --ff-only and --rebase
546 # ---------------------------------------------------------------------------
547
548
549 def _make_hub_session_patches(
550 local_commits: list[object] | None = None,
551 mock_store_commit: bool = True,
552 ) -> tuple[AsyncMock, AsyncMock, AsyncMock, AsyncMock, MagicMock]:
553 """Build the standard DB mock patches for pull tests.
554
555 Returns (mock_get_commits, mock_get_objects, mock_store_commit,
556 mock_store_object, mock_session_ctx).
557 """
558 mock_get_commits = AsyncMock(return_value=local_commits or [])
559 mock_get_objects = AsyncMock(return_value=[])
560 mock_sc = AsyncMock(return_value=True if mock_store_commit else False)
561 mock_so = AsyncMock(return_value=False)
562 ctx = MagicMock()
563 ctx.__aenter__ = AsyncMock(return_value=MagicMock())
564 ctx.__aexit__ = AsyncMock(return_value=None)
565 return mock_get_commits, mock_get_objects, mock_sc, mock_so, ctx
566
567
568 @pytest.mark.anyio
569 async def test_pull_ff_only_fast_forwards_when_remote_ahead(
570 tmp_path: pathlib.Path,
571 capsys: pytest.CaptureFixture[str],
572 ) -> None:
573 """--ff-only updates local branch ref when remote HEAD is a fast-forward.
574
575 Regression: pulling with --ff-only when remote is strictly
576 ahead of local should advance the local branch ref without merge.
577 """
578 root = _init_repo(tmp_path)
579 _write_config_with_token(root, "https://hub.example.com/musehub/repos/r")
580
581 local_commit_id = "local-base-001" * 4
582 remote_head_id = "remote-tip-002" * 4
583
584 # Write local branch ref at the base commit
585 _write_branch_ref(root, "main", local_commit_id)
586
587 # Remote is strictly ahead: local commit IS an ancestor of remote_head
588 # Simulate this by providing all commits in the DB including remote commit
589 local_commit_stub = _make_commit_stub(local_commit_id)
590 remote_commit_stub = _make_commit_stub(remote_head_id, parent_id=local_commit_id)
591
592 mock_response = _make_hub_pull_response(
593 remote_head=remote_head_id,
594 diverged=False,
595 )
596
597 mock_hub = MagicMock()
598 mock_hub.__aenter__ = AsyncMock(return_value=mock_hub)
599 mock_hub.__aexit__ = AsyncMock(return_value=None)
600 mock_hub.post = AsyncMock(return_value=mock_response)
601
602 all_commits = [remote_commit_stub, local_commit_stub]
603
604 with (
605 patch(
606 "maestro.muse_cli.commands.pull.get_commits_for_branch",
607 new=AsyncMock(return_value=all_commits),
608 ),
609 patch(
610 "maestro.muse_cli.commands.pull.get_all_object_ids",
611 new=AsyncMock(return_value=[]),
612 ),
613 patch(
614 "maestro.muse_cli.commands.pull.store_pulled_commit",
615 new=AsyncMock(return_value=False),
616 ),
617 patch(
618 "maestro.muse_cli.commands.pull.store_pulled_object",
619 new=AsyncMock(return_value=False),
620 ),
621 patch("maestro.muse_cli.commands.pull.open_session") as mock_open_session,
622 patch("maestro.muse_cli.commands.pull.MuseHubClient", return_value=mock_hub),
623 ):
624 ctx = MagicMock()
625 ctx.__aenter__ = AsyncMock(return_value=MagicMock())
626 ctx.__aexit__ = AsyncMock(return_value=None)
627 mock_open_session.return_value = ctx
628
629 await _pull_async(root=root, remote_name="origin", branch=None, ff_only=True)
630
631 # Local branch ref must have been fast-forwarded to remote_head
632 ref_path = root / ".muse" / "refs" / "heads" / "main"
633 assert ref_path.exists()
634 assert ref_path.read_text(encoding="utf-8").strip() == remote_head_id
635
636 captured = capsys.readouterr()
637 assert "fast-forward" in captured.out.lower()
638
639
640 @pytest.mark.anyio
641 async def test_pull_ff_only_fails_when_not_ff(
642 tmp_path: pathlib.Path,
643 capsys: pytest.CaptureFixture[str],
644 ) -> None:
645 """--ff-only exits 1 when branches have diverged (cannot fast-forward).
646
647 Regression: pulling with --ff-only must refuse to integrate
648 when the remote and local branches have diverged.
649 """
650 import typer
651
652 root = _init_repo(tmp_path)
653 _write_config_with_token(root, "https://hub.example.com/musehub/repos/r")
654
655 local_head_id = "local-diverged-001" * 3
656 remote_head_id = "remote-diverged-002" * 3
657 _write_branch_ref(root, "main", local_head_id)
658
659 # Both branches diverged — neither is ancestor of the other
660 local_commit = _make_commit_stub(local_head_id)
661 remote_commit = _make_commit_stub(remote_head_id)
662
663 mock_response = _make_hub_pull_response(
664 remote_head=remote_head_id,
665 diverged=True,
666 )
667
668 mock_hub = MagicMock()
669 mock_hub.__aenter__ = AsyncMock(return_value=mock_hub)
670 mock_hub.__aexit__ = AsyncMock(return_value=None)
671 mock_hub.post = AsyncMock(return_value=mock_response)
672
673 with (
674 patch(
675 "maestro.muse_cli.commands.pull.get_commits_for_branch",
676 new=AsyncMock(return_value=[local_commit, remote_commit]),
677 ),
678 patch(
679 "maestro.muse_cli.commands.pull.get_all_object_ids",
680 new=AsyncMock(return_value=[]),
681 ),
682 patch(
683 "maestro.muse_cli.commands.pull.store_pulled_commit",
684 new=AsyncMock(return_value=False),
685 ),
686 patch(
687 "maestro.muse_cli.commands.pull.store_pulled_object",
688 new=AsyncMock(return_value=False),
689 ),
690 patch("maestro.muse_cli.commands.pull.open_session") as mock_open_session,
691 patch("maestro.muse_cli.commands.pull.MuseHubClient", return_value=mock_hub),
692 ):
693 ctx = MagicMock()
694 ctx.__aenter__ = AsyncMock(return_value=MagicMock())
695 ctx.__aexit__ = AsyncMock(return_value=None)
696 mock_open_session.return_value = ctx
697
698 with pytest.raises(typer.Exit) as exc_info:
699 await _pull_async(
700 root=root, remote_name="origin", branch=None, ff_only=True
701 )
702
703 assert exc_info.value.exit_code == int(ExitCode.USER_ERROR)
704 captured = capsys.readouterr()
705 assert "diverged" in captured.out.lower() or "cannot fast-forward" in captured.out.lower()
706
707 # Local branch ref must NOT have been changed
708 ref_path = root / ".muse" / "refs" / "heads" / "main"
709 assert ref_path.read_text(encoding="utf-8").strip() == local_head_id
710
711
712 @pytest.mark.anyio
713 async def test_pull_rebase_fast_forwards_when_remote_ahead(
714 tmp_path: pathlib.Path,
715 capsys: pytest.CaptureFixture[str],
716 ) -> None:
717 """--rebase fast-forwards local branch when remote is strictly ahead.
718
719 Regression: when remote is simply ahead (no local commits
720 above the common base), --rebase acts like a fast-forward.
721 """
722 root = _init_repo(tmp_path)
723 _write_config_with_token(root, "https://hub.example.com/musehub/repos/r")
724
725 local_commit_id = "rebase-base-001" * 4
726 remote_head_id = "rebase-tip-002" * 4
727 _write_branch_ref(root, "main", local_commit_id)
728
729 local_commit_stub = _make_commit_stub(local_commit_id)
730 remote_commit_stub = _make_commit_stub(remote_head_id, parent_id=local_commit_id)
731
732 mock_response = _make_hub_pull_response(
733 remote_head=remote_head_id,
734 diverged=False,
735 )
736
737 mock_hub = MagicMock()
738 mock_hub.__aenter__ = AsyncMock(return_value=mock_hub)
739 mock_hub.__aexit__ = AsyncMock(return_value=None)
740 mock_hub.post = AsyncMock(return_value=mock_response)
741
742 all_commits = [remote_commit_stub, local_commit_stub]
743
744 with (
745 patch(
746 "maestro.muse_cli.commands.pull.get_commits_for_branch",
747 new=AsyncMock(return_value=all_commits),
748 ),
749 patch(
750 "maestro.muse_cli.commands.pull.get_all_object_ids",
751 new=AsyncMock(return_value=[]),
752 ),
753 patch(
754 "maestro.muse_cli.commands.pull.store_pulled_commit",
755 new=AsyncMock(return_value=False),
756 ),
757 patch(
758 "maestro.muse_cli.commands.pull.store_pulled_object",
759 new=AsyncMock(return_value=False),
760 ),
761 patch("maestro.muse_cli.commands.pull.open_session") as mock_open_session,
762 patch("maestro.muse_cli.commands.pull.MuseHubClient", return_value=mock_hub),
763 ):
764 ctx = MagicMock()
765 ctx.__aenter__ = AsyncMock(return_value=MagicMock())
766 ctx.__aexit__ = AsyncMock(return_value=None)
767 mock_open_session.return_value = ctx
768
769 await _pull_async(root=root, remote_name="origin", branch=None, rebase=True)
770
771 # Branch ref advanced to remote_head
772 ref_path = root / ".muse" / "refs" / "heads" / "main"
773 assert ref_path.read_text(encoding="utf-8").strip() == remote_head_id
774
775 captured = capsys.readouterr()
776 assert "fast-forward" in captured.out.lower()
777
778
779 @pytest.mark.anyio
780 async def test_pull_rebase_sends_rebase_hint_in_request(
781 tmp_path: pathlib.Path,
782 ) -> None:
783 """--rebase flag includes rebase=True in the pull request payload."""
784 root = _init_repo(tmp_path)
785 _write_config_with_token(root, "https://hub.example.com/musehub/repos/r")
786
787 remote_head_id = "rebase-tip-hint" * 4
788 captured_payloads: list[dict[str, object]] = []
789
790 mock_response = _make_hub_pull_response(remote_head=remote_head_id, diverged=False)
791
792 mock_hub = MagicMock()
793 mock_hub.__aenter__ = AsyncMock(return_value=mock_hub)
794 mock_hub.__aexit__ = AsyncMock(return_value=None)
795
796 async def _fake_post(path: str, **kwargs: object) -> MagicMock:
797 payload = kwargs.get("json", {})
798 if isinstance(payload, dict):
799 captured_payloads.append(payload)
800 return mock_response
801
802 mock_hub.post = _fake_post
803
804 with (
805 patch(
806 "maestro.muse_cli.commands.pull.get_commits_for_branch",
807 new=AsyncMock(return_value=[]),
808 ),
809 patch(
810 "maestro.muse_cli.commands.pull.get_all_object_ids",
811 new=AsyncMock(return_value=[]),
812 ),
813 patch(
814 "maestro.muse_cli.commands.pull.store_pulled_commit",
815 new=AsyncMock(return_value=False),
816 ),
817 patch(
818 "maestro.muse_cli.commands.pull.store_pulled_object",
819 new=AsyncMock(return_value=False),
820 ),
821 patch("maestro.muse_cli.commands.pull.open_session") as mock_open_session,
822 patch("maestro.muse_cli.commands.pull.MuseHubClient", return_value=mock_hub),
823 ):
824 ctx = MagicMock()
825 ctx.__aenter__ = AsyncMock(return_value=MagicMock())
826 ctx.__aexit__ = AsyncMock(return_value=None)
827 mock_open_session.return_value = ctx
828
829 await _pull_async(root=root, remote_name="origin", branch=None, rebase=True)
830
831 assert len(captured_payloads) == 1
832 assert captured_payloads[0].get("rebase") is True
833
834
835 @pytest.mark.anyio
836 async def test_pull_ff_only_sends_ff_only_hint_in_request(
837 tmp_path: pathlib.Path,
838 ) -> None:
839 """--ff-only flag includes ff_only=True in the pull request payload."""
840 root = _init_repo(tmp_path)
841 _write_config_with_token(root, "https://hub.example.com/musehub/repos/r")
842
843 remote_head_id = "ff-only-tip-hint" * 4
844 _write_branch_ref(root, "main", remote_head_id)
845 captured_payloads: list[dict[str, object]] = []
846
847 # Remote is same as local — no divergence, ff trivially satisfied
848 local_stub = _make_commit_stub(remote_head_id)
849 mock_response = _make_hub_pull_response(
850 remote_head=remote_head_id,
851 diverged=False,
852 )
853
854 mock_hub = MagicMock()
855 mock_hub.__aenter__ = AsyncMock(return_value=mock_hub)
856 mock_hub.__aexit__ = AsyncMock(return_value=None)
857
858 async def _fake_post(path: str, **kwargs: object) -> MagicMock:
859 payload = kwargs.get("json", {})
860 if isinstance(payload, dict):
861 captured_payloads.append(payload)
862 return mock_response
863
864 mock_hub.post = _fake_post
865
866 with (
867 patch(
868 "maestro.muse_cli.commands.pull.get_commits_for_branch",
869 new=AsyncMock(return_value=[local_stub]),
870 ),
871 patch(
872 "maestro.muse_cli.commands.pull.get_all_object_ids",
873 new=AsyncMock(return_value=[]),
874 ),
875 patch(
876 "maestro.muse_cli.commands.pull.store_pulled_commit",
877 new=AsyncMock(return_value=False),
878 ),
879 patch(
880 "maestro.muse_cli.commands.pull.store_pulled_object",
881 new=AsyncMock(return_value=False),
882 ),
883 patch("maestro.muse_cli.commands.pull.open_session") as mock_open_session,
884 patch("maestro.muse_cli.commands.pull.MuseHubClient", return_value=mock_hub),
885 ):
886 ctx = MagicMock()
887 ctx.__aenter__ = AsyncMock(return_value=MagicMock())
888 ctx.__aexit__ = AsyncMock(return_value=None)
889 mock_open_session.return_value = ctx
890
891 await _pull_async(root=root, remote_name="origin", branch=None, ff_only=True)
892
893 assert len(captured_payloads) == 1
894 assert captured_payloads[0].get("ff_only") is True
895
896
897 # ---------------------------------------------------------------------------
898 # Issue #238 — diverged-branch rebase path (find_merge_base + _rebase_commits_onto)
899 # ---------------------------------------------------------------------------
900
901
902 @pytest.mark.anyio
903 async def test_pull_rebase_replays_local_commits_on_diverged_branch(
904 tmp_path: pathlib.Path,
905 capsys: pytest.CaptureFixture[str],
906 ) -> None:
907 """--rebase replays local-only commits onto remote HEAD when branches have diverged.
908
909 Regression: the diverged rebase path (find_merge_base →
910 _rebase_commits_onto) was missing test coverage. This test verifies that:
911
912 1. ``find_merge_base`` is called with the local and remote HEAD commit IDs.
913 2. ``_rebase_commits_onto`` is called with the commits above the merge base.
914 3. The local branch ref file is updated to the new rebased HEAD.
915 4. A success message is printed to stdout.
916 """
917 root = _init_repo(tmp_path)
918 _write_config_with_token(root, "https://hub.example.com/musehub/repos/r")
919
920 # History: base ← local_a ← local_b (local side, 2 commits above base)
921 # base ← remote_a (remote side, diverged)
922 base_id = "base-commit-0000" * 4
923 local_a_id = "local-commit-a001" * 4
924 local_b_id = "local-commit-b002" * 4
925 remote_a_id = "remote-commit-a003" * 4
926 rebased_head_id = "rebased-head-xxxx" * 4
927
928 _write_branch_ref(root, "main", local_b_id)
929
930 base_stub = _make_commit_stub(base_id)
931 local_a_stub = _make_commit_stub(local_a_id, parent_id=base_id)
932 local_b_stub = _make_commit_stub(local_b_id, parent_id=local_a_id)
933 remote_a_stub = _make_commit_stub(remote_a_id, parent_id=base_id)
934
935 # Hub says branches have diverged; remote HEAD is remote_a
936 mock_response = _make_hub_pull_response(
937 remote_head=remote_a_id,
938 diverged=True,
939 )
940
941 mock_hub = MagicMock()
942 mock_hub.__aenter__ = AsyncMock(return_value=mock_hub)
943 mock_hub.__aexit__ = AsyncMock(return_value=None)
944 mock_hub.post = AsyncMock(return_value=mock_response)
945
946 # After pulling, DB contains all four commits
947 all_commits = [base_stub, local_a_stub, local_b_stub, remote_a_stub]
948
949 # Track what arguments _rebase_commits_onto receives
950 rebase_calls: list[tuple[list[MuseCliCommit], str]] = []
951
952 async def _fake_rebase(
953 root: pathlib.Path,
954 repo_id: str,
955 branch: str,
956 commits_to_rebase: list[MuseCliCommit],
957 new_base_commit_id: str,
958 ) -> str:
959 rebase_calls.append((list(commits_to_rebase), new_base_commit_id))
960 # Write the ref to simulate what the real function does
961 ref = root / ".muse" / "refs" / "heads" / branch
962 ref.parent.mkdir(parents=True, exist_ok=True)
963 ref.write_text(rebased_head_id, encoding="utf-8")
964 return rebased_head_id
965
966 with (
967 patch(
968 "maestro.muse_cli.commands.pull.get_commits_for_branch",
969 new=AsyncMock(return_value=all_commits),
970 ),
971 patch(
972 "maestro.muse_cli.commands.pull.get_all_object_ids",
973 new=AsyncMock(return_value=[]),
974 ),
975 patch(
976 "maestro.muse_cli.commands.pull.store_pulled_commit",
977 new=AsyncMock(return_value=False),
978 ),
979 patch(
980 "maestro.muse_cli.commands.pull.store_pulled_object",
981 new=AsyncMock(return_value=False),
982 ),
983 patch("maestro.muse_cli.commands.pull.open_session") as mock_open_session,
984 patch("maestro.muse_cli.commands.pull.MuseHubClient", return_value=mock_hub),
985 patch(
986 "maestro.muse_cli.commands.pull.find_merge_base",
987 new=AsyncMock(return_value=base_id),
988 ),
989 patch(
990 "maestro.muse_cli.commands.pull._rebase_commits_onto",
991 side_effect=_fake_rebase,
992 ),
993 ):
994 ctx = MagicMock()
995 ctx.__aenter__ = AsyncMock(return_value=MagicMock())
996 ctx.__aexit__ = AsyncMock(return_value=None)
997 mock_open_session.return_value = ctx
998
999 await _pull_async(root=root, remote_name="origin", branch=None, rebase=True)
1000
1001 # Branch ref must be the rebased head produced by _rebase_commits_onto
1002 ref_path = root / ".muse" / "refs" / "heads" / "main"
1003 assert ref_path.read_text(encoding="utf-8").strip() == rebased_head_id
1004
1005 # _rebase_commits_onto must have been called exactly once
1006 assert len(rebase_calls) == 1
1007 replayed_commits, onto_id = rebase_calls[0]
1008 assert onto_id == remote_a_id
1009 # The two local-only commits (local_a, local_b) should have been replayed;
1010 # base and remote commits are excluded
1011 replayed_ids = {c.commit_id for c in replayed_commits}
1012 assert local_a_id in replayed_ids or local_b_id in replayed_ids
1013
1014 captured = capsys.readouterr()
1015 assert "rebase" in captured.out.lower()
1016
1017
1018 @pytest.mark.anyio
1019 async def test_pull_rebase_diverged_no_common_ancestor_exits_1(
1020 tmp_path: pathlib.Path,
1021 capsys: pytest.CaptureFixture[str],
1022 ) -> None:
1023 """--rebase exits 1 with instructive message when no common ancestor exists.
1024
1025 Guards the ``merge_base_id is None`` branch in _pull_async (diverged rebase
1026 path) that was untested before .
1027 """
1028 import typer
1029
1030 root = _init_repo(tmp_path)
1031 _write_config_with_token(root, "https://hub.example.com/musehub/repos/r")
1032
1033 local_head_id = "local-disjoint-001" * 4
1034 remote_head_id = "remote-disjoint-002" * 4
1035 _write_branch_ref(root, "main", local_head_id)
1036
1037 local_stub = _make_commit_stub(local_head_id)
1038 remote_stub = _make_commit_stub(remote_head_id)
1039
1040 mock_response = _make_hub_pull_response(
1041 remote_head=remote_head_id,
1042 diverged=True,
1043 )
1044
1045 mock_hub = MagicMock()
1046 mock_hub.__aenter__ = AsyncMock(return_value=mock_hub)
1047 mock_hub.__aexit__ = AsyncMock(return_value=None)
1048 mock_hub.post = AsyncMock(return_value=mock_response)
1049
1050 with (
1051 patch(
1052 "maestro.muse_cli.commands.pull.get_commits_for_branch",
1053 new=AsyncMock(return_value=[local_stub, remote_stub]),
1054 ),
1055 patch(
1056 "maestro.muse_cli.commands.pull.get_all_object_ids",
1057 new=AsyncMock(return_value=[]),
1058 ),
1059 patch(
1060 "maestro.muse_cli.commands.pull.store_pulled_commit",
1061 new=AsyncMock(return_value=False),
1062 ),
1063 patch(
1064 "maestro.muse_cli.commands.pull.store_pulled_object",
1065 new=AsyncMock(return_value=False),
1066 ),
1067 patch("maestro.muse_cli.commands.pull.open_session") as mock_open_session,
1068 patch("maestro.muse_cli.commands.pull.MuseHubClient", return_value=mock_hub),
1069 patch(
1070 "maestro.muse_cli.commands.pull.find_merge_base",
1071 new=AsyncMock(return_value=None), # no common ancestor
1072 ),
1073 ):
1074 ctx = MagicMock()
1075 ctx.__aenter__ = AsyncMock(return_value=MagicMock())
1076 ctx.__aexit__ = AsyncMock(return_value=None)
1077 mock_open_session.return_value = ctx
1078
1079 with pytest.raises(typer.Exit) as exc_info:
1080 await _pull_async(
1081 root=root, remote_name="origin", branch=None, rebase=True
1082 )
1083
1084 assert exc_info.value.exit_code == int(ExitCode.USER_ERROR)
1085 captured = capsys.readouterr()
1086 assert "rebase" in captured.out.lower() or "common ancestor" in captured.out.lower()
1087
1088
1089 @pytest.mark.anyio
1090 async def test_rebase_commits_onto_idempotent(tmp_path: pathlib.Path) -> None:
1091 """_rebase_commits_onto is idempotent: running twice yields the same HEAD.
1092
1093 Verifies the idempotency contract documented: re-running a
1094 rebase with the same inputs (same parent IDs, snapshot IDs, messages, and
1095 authors) produces the same deterministic commit IDs and does not insert
1096 duplicate rows. Uses a dedicated in-memory SQLite engine so that both
1097 invocations share the same DB state.
1098 """
1099 from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
1100 from sqlalchemy.pool import StaticPool
1101
1102 from maestro.db.database import Base
1103 import maestro.muse_cli.models # noqa: F401 — registers MuseCli* models with Base
1104 from maestro.muse_cli.models import MuseCliCommit as MCCommit
1105 from maestro.muse_cli.snapshot import compute_commit_tree_id
1106
1107 # Create a fresh in-memory engine for this test
1108 engine = create_async_engine(
1109 "sqlite+aiosqlite:///:memory:",
1110 connect_args={"check_same_thread": False},
1111 poolclass=StaticPool,
1112 )
1113 async with engine.begin() as conn:
1114 await conn.run_sync(Base.metadata.create_all)
1115
1116 factory = async_sessionmaker(bind=engine, expire_on_commit=False)
1117
1118 repo_id = "idempotent-repo"
1119 branch = "main"
1120 remote_base_id = "remote-base-idem" * 4
1121
1122 # Seed the original local commit (the one that will be replayed)
1123 local_commit = MCCommit(
1124 commit_id="local-original-idem" * 3,
1125 repo_id=repo_id,
1126 branch=branch,
1127 parent_commit_id=None,
1128 snapshot_id="snap-idem-0001",
1129 message="Original local commit",
1130 author="dev",
1131 committed_at=datetime.datetime.now(datetime.timezone.utc),
1132 )
1133 async with factory() as seed_session:
1134 seed_session.add(local_commit)
1135 await seed_session.commit()
1136
1137 # Expected rebased commit ID (deterministic via compute_commit_tree_id)
1138 expected_new_id = compute_commit_tree_id(
1139 parent_ids=[remote_base_id],
1140 snapshot_id=local_commit.snapshot_id,
1141 message=local_commit.message,
1142 author=local_commit.author,
1143 )
1144
1145 class _FakeSessionCtx:
1146 """Context manager that opens a new session from the shared in-memory factory."""
1147
1148 async def __aenter__(self) -> AsyncSession:
1149 self._session: AsyncSession = factory()
1150 return await self._session.__aenter__()
1151
1152 async def __aexit__(self, *args: object) -> None:
1153 await self._session.__aexit__(*args)
1154
1155 root = tmp_path / "repo"
1156 (root / ".muse" / "refs" / "heads").mkdir(parents=True)
1157
1158 with patch(
1159 "maestro.muse_cli.commands.pull.open_session",
1160 return_value=_FakeSessionCtx(),
1161 ):
1162 head1 = await _rebase_commits_onto(
1163 root=root,
1164 repo_id=repo_id,
1165 branch=branch,
1166 commits_to_rebase=[local_commit],
1167 new_base_commit_id=remote_base_id,
1168 )
1169
1170 with patch(
1171 "maestro.muse_cli.commands.pull.open_session",
1172 return_value=_FakeSessionCtx(),
1173 ):
1174 head2 = await _rebase_commits_onto(
1175 root=root,
1176 repo_id=repo_id,
1177 branch=branch,
1178 commits_to_rebase=[local_commit],
1179 new_base_commit_id=remote_base_id,
1180 )
1181
1182 # Both calls must return the same deterministic HEAD
1183 assert head1 == expected_new_id
1184 assert head2 == expected_new_id
1185
1186 # Branch ref must point to the rebased head
1187 ref_path = root / ".muse" / "refs" / "heads" / branch
1188 assert ref_path.read_text(encoding="utf-8").strip() == expected_new_id
1189
1190 async with engine.begin() as conn:
1191 await conn.run_sync(Base.metadata.drop_all)
1192 await engine.dispose()