gabriel / muse public
test_cmd_pull_hardening.py python
936 lines 43.3 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Comprehensive hardening tests for ``muse pull``.
2
3 Covers all changes introduced in the pull command review:
4
5 Unit
6 ----
7 - Parser flags: --ff-only, --dry-run, --json/-j
8 - Dead-code removal: _current_branch and _restore_from_manifest absent
9 - _PullJson TypedDict keys complete
10 - _negotiate_have: exhausted-without-base falls back to full list
11 - _negotiate_have: ready on first round returns ack
12
13 Integration (mocked transport)
14 -------------------------------
15 - All error messages routed to stderr
16 - remote not configured → stderr + exit 1
17 - branch not on remote → stderr + exit 1
18 - fetch TransportError → stderr + exit 1 (INTERNAL_ERROR)
19 - unknown flag → non-zero exit
20 - up_to_date JSON schema complete
21 - fast_forward JSON schema complete
22 - merged JSON schema complete
23 - conflict JSON schema complete (exit 2)
24 - fetched JSON schema complete (--no-merge)
25 - dry_run JSON schema complete
26 - --ff-only: diverged branches refuse pull, exit 1
27 - --ff-only: fast-forward still succeeds
28 - "Already up to date" goes to stderr, not stdout
29 - apply_manifest called BEFORE write_branch_ref in fast-forward
30 - commits_received uses commits_written from apply_mpack result
31
32 End-to-end (file:// transport)
33 --------------------------------
34 - Fresh pull into empty local
35 - Fast-forward pull after remote advances
36 - --no-merge stops at fetch
37 - --dry-run produces no side effects
38 - --json produces valid parseable output
39
40 Security
41 --------
42 - remote name ANSI-sanitized in all errors
43 - branch name ANSI-sanitized in all errors
44 - conflict path ANSI-sanitized in text output
45 - invalid --format exits to stderr
46 - progress to stderr, stdout clean on --json
47
48 Stress
49 ------
50 - _negotiate_have with 10 000 synthetic commits: terminates
51 - concurrent independent _negotiate_have calls
52 """
53
54 from __future__ import annotations
55
56 type _IntMap = dict[str, int]
57
58 import argparse
59 import datetime
60 import json
61 import pathlib
62 import threading
63 from typing import TYPE_CHECKING
64 from unittest.mock import MagicMock, call, patch
65
66 import pytest
67
68 from tests.cli_test_helper import CliRunner, InvokeResult
69 from muse.core._types import blob_id
70
71 if TYPE_CHECKING:
72 from muse.cli.commands.pull import _PullJson
73 from muse.core.pack import MPackBundle, RemoteInfo
74 from muse.core.transport import NegotiateResponse
75
76 cli = None
77 runner = CliRunner()
78
79
80 # ---------------------------------------------------------------------------
81 # Helpers
82 # ---------------------------------------------------------------------------
83
84 def _env(root: pathlib.Path) -> Manifest:
85 return {"MUSE_REPO_ROOT": str(root)}
86
87
88 def _sha(content: bytes) -> str:
89 return blob_id(content)
90
91
92 def _json_line(r: InvokeResult) -> _PullJson:
93 """Extract the single JSON object line from combined output."""
94 for line in r.output.splitlines():
95 stripped = line.strip()
96 if stripped.startswith("{"):
97 parsed: _PullJson = json.loads(stripped)
98 return parsed
99 raise ValueError(f"No JSON line found in output:\n{r.output!r}")
100
101
102 def _make_remote_info(branch_heads: Manifest) -> "RemoteInfo":
103 return {
104 "repo_id": "test-repo",
105 "domain": "code",
106 "default_branch": "main",
107 "branch_heads": branch_heads,
108 }
109
110
111 def _make_bundle(
112 commit_id: str = "a" * 64,
113 snapshot_id: str = "b" * 64,
114 ) -> "MPackBundle":
115 from muse.core.pack import MPackBundle
116 return MPackBundle(commits=[], snapshots=[], objects=[])
117
118
119 # ---------------------------------------------------------------------------
120 # Fixtures
121 # ---------------------------------------------------------------------------
122
123 @pytest.fixture()
124 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
125 """Minimal .muse/ repo with one commit on main."""
126 from muse._version import __version__
127 from muse.core.object_store import write_object
128 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
129 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
130
131 muse = tmp_path / ".muse"
132 for sub in ("refs/heads", "objects", "commits", "snapshots"):
133 (muse / sub).mkdir(parents=True)
134 (muse / "repo.json").write_text(
135 json.dumps({"repo_id": "test-repo", "schema_version": __version__, "domain": "code"})
136 )
137 (muse / "HEAD").write_text("ref: refs/heads/main\n")
138 (muse / "config.toml").write_text('[remotes.origin]\nurl = "https://hub.example.com/r"\n')
139
140 blob = b"x = 1\n"
141 oid = _sha(blob)
142 write_object(tmp_path, oid, blob)
143 snap_id = compute_snapshot_id({"a.py": oid})
144 write_snapshot(tmp_path, SnapshotRecord(snapshot_id=snap_id, manifest={"a.py": oid}))
145 ts = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
146 cid = compute_commit_id(
147 repo_id="test-repo",
148 parent_ids=[],
149 snapshot_id=snap_id,
150 message="base",
151 committed_at_iso=ts.isoformat(),
152 )
153 write_commit(tmp_path, CommitRecord(
154 commit_id=cid, repo_id="test-repo", created_on_branch="main",
155 snapshot_id=snap_id, message="base", committed_at=ts,
156 ))
157 (muse / "refs" / "heads" / "main").write_text(cid)
158
159 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
160 monkeypatch.chdir(tmp_path)
161 return tmp_path
162
163
164 # ---------------------------------------------------------------------------
165 # Unit — dead code, parser flags, TypedDict, negotiate helper
166 # ---------------------------------------------------------------------------
167
168 class TestDeadCodeRemoval:
169 def test_no_current_branch_wrapper(self) -> None:
170 import muse.cli.commands.pull as m
171 assert not hasattr(m, "_current_branch"), "_current_branch must be deleted"
172
173 def test_no_restore_from_manifest_wrapper(self) -> None:
174 import muse.cli.commands.pull as m
175 assert not hasattr(m, "_restore_from_manifest"), "_restore_from_manifest must be deleted"
176
177 def test_json_module_removed_or_used(self) -> None:
178 """json is imported and used (for json.dumps in run)."""
179 import muse.cli.commands.pull as m
180 import inspect
181 src = inspect.getsource(m)
182 assert "json.dumps" in src, "json module must be used"
183
184 def test_pull_json_typeddict_keys(self) -> None:
185 from muse.cli.commands.pull import _PullJson
186 required = {
187 "status", "remote", "branch", "local_branch",
188 "commits_received", "objects_written", "head",
189 "conflict_paths", "dry_run",
190 }
191 assert required <= set(_PullJson.__annotations__.keys())
192
193
194 class TestRegisterFlags:
195 def _parse(self, *args: str) -> argparse.Namespace:
196 import argparse, muse.cli.commands.pull as m
197 p = argparse.ArgumentParser()
198 sub = p.add_subparsers()
199 m.register(sub)
200 return p.parse_args(["pull", *args])
201
202 def test_ff_only_flag(self) -> None:
203 ns = self._parse("--ff-only")
204 assert getattr(ns, "ff_only") is True
205
206 def test_dry_run_short(self) -> None:
207 ns = self._parse("-n")
208 assert getattr(ns, "dry_run") is True
209
210 def test_dry_run_long(self) -> None:
211 ns = self._parse("--dry-run")
212 assert getattr(ns, "dry_run") is True
213
214 def test_default_json_out_is_false(self) -> None:
215 ns = self._parse()
216 assert ns.json_out is False
217
218 def test_json_flag_sets_json_out(self) -> None:
219 ns = self._parse("--json")
220 assert ns.json_out is True
221
222 def test_j_shorthand_sets_json_out(self) -> None:
223 ns = self._parse("-j")
224 assert ns.json_out is True
225
226 def test_no_merge_flag(self) -> None:
227 ns = self._parse("--no-merge")
228 assert getattr(ns, "no_merge") is True
229
230 def test_message_flag(self) -> None:
231 ns = self._parse("-m", "custom msg")
232 assert getattr(ns, "message") == "custom msg"
233
234 def test_branch_flag(self) -> None:
235 ns = self._parse("-b", "dev")
236 assert getattr(ns, "branch_flag") == "dev"
237
238
239 class TestNegotiateHave:
240 def _make_transport(
241 self,
242 ready_after: int = 1,
243 ack_ids: list[str] | None = None,
244 ) -> MagicMock:
245 """Mock transport where negotiate is ready after *ready_after* calls."""
246 call_count = 0
247
248 def negotiate(url: str, token: str | None, want: list[str], have: list[str]) -> "NegotiateResponse":
249 nonlocal call_count
250 call_count += 1
251 ready = call_count >= ready_after
252 return {"ready": ready, "ack": ack_ids or have, "common_base": None}
253
254 t = MagicMock()
255 t.negotiate.side_effect = negotiate
256 return t
257
258 def test_empty_all_local_returns_empty(self) -> None:
259 from muse.core.transport import negotiate_have as _negotiate_have
260 t = self._make_transport()
261 result = _negotiate_have(t, "http://x", None, ["want"], [])
262 assert result == []
263
264 def test_ready_on_first_round_returns_ack(self) -> None:
265 from muse.core.transport import negotiate_have as _negotiate_have
266 t = self._make_transport(ready_after=1, ack_ids=["abc"])
267 commits = ["c1", "c2", "c3"]
268 result = _negotiate_have(t, "http://x", None, ["want"], commits)
269 assert result == ["abc"]
270
271 def test_ready_after_two_rounds(self) -> None:
272 from muse.core.transport import negotiate_have as _negotiate_have
273 from muse.core.transport import NEGOTIATE_DEPTH
274 commits = [f"c{i}" for i in range(NEGOTIATE_DEPTH * 2)]
275 t = self._make_transport(ready_after=2)
276 result = _negotiate_have(t, "http://x", None, ["want"], commits)
277 # Should return ack from second batch
278 assert len(result) > 0
279
280 def test_exhausted_returns_full_list(self) -> None:
281 from muse.core.transport import negotiate_have as _negotiate_have
282
283 def never_ready(url: str, token: str | None, want: list[str], have: list[str]) -> "NegotiateResponse":
284 return {"ready": False, "ack": [], "common_base": None}
285
286 t = MagicMock()
287 t.negotiate.side_effect = never_ready
288 commits = [f"c{i}" for i in range(10)]
289 result = _negotiate_have(t, "http://x", None, ["want"], commits)
290 assert result == commits # full list returned as fallback
291
292
293 # ---------------------------------------------------------------------------
294 # Integration — JSON schema, error routing
295 # ---------------------------------------------------------------------------
296
297 class _REQUIRED:
298 KEYS = {
299 "status", "remote", "branch", "local_branch",
300 "commits_received", "objects_written", "head",
301 "conflict_paths", "dry_run",
302 }
303
304
305 class TestErrorRouting:
306 def test_remote_not_configured_to_stderr(self, repo: pathlib.Path) -> None:
307 r = runner.invoke(cli, ["pull", "no_such_remote"], env=_env(repo))
308 assert r.exit_code != 0
309 assert "not configured" in (r.stderr or "").lower()
310
311 def test_branch_not_on_remote_to_stderr(self, repo: pathlib.Path) -> None:
312 info = _make_remote_info({"main": "a" * 64})
313 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
314 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
315 with patch("muse.cli.commands.pull.make_transport") as mt:
316 mt.return_value.fetch_remote_info.return_value = info
317 r = runner.invoke(cli, ["pull", "origin", "--branch", "nonexistent"], env=_env(repo))
318 assert r.exit_code != 0
319 assert "does not exist" in (r.stderr or "").lower()
320
321 def test_fetch_transport_error_to_stderr(self, repo: pathlib.Path) -> None:
322 from muse.core.transport import TransportError
323 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
324 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
325 with patch("muse.cli.commands.pull.make_transport") as mt:
326 mt.return_value.fetch_remote_info.side_effect = TransportError("timeout", 503)
327 r = runner.invoke(cli, ["pull"], env=_env(repo))
328 assert r.exit_code != 0
329 assert "cannot reach" in (r.stderr or "").lower()
330
331 def test_fetch_stream_error_to_stderr(self, repo: pathlib.Path) -> None:
332 from muse.core.transport import TransportError
333 info = _make_remote_info({"main": "b" * 64})
334 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
335 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
336 with patch("muse.cli.commands.pull.make_transport") as mt:
337 mt.return_value.fetch_remote_info.return_value = info
338 mt.return_value.negotiate.return_value = {"ready": True, "ack": [], "common_base": None}
339 mt.return_value.fetch_stream.side_effect = TransportError("stream failed", 500)
340 r = runner.invoke(cli, ["pull"], env=_env(repo))
341 assert r.exit_code != 0
342 assert "fetch failed" in (r.stderr or "").lower()
343
344 def test_unknown_flag_exits_nonzero(self, repo: pathlib.Path) -> None:
345 r = runner.invoke(cli, ["pull", "--format", "xml"], env=_env(repo))
346 assert r.exit_code != 0
347
348 def test_already_up_to_date_to_stderr(self, repo: pathlib.Path) -> None:
349 """'Already up to date' must go to stderr, not stdout."""
350 from muse.core.store import get_head_commit_id
351 head = get_head_commit_id(repo, "main") or ""
352 info = _make_remote_info({"main": head})
353 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
354 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
355 with patch("muse.cli.commands.pull.make_transport") as mt:
356 mt.return_value.fetch_remote_info.return_value = info
357 with patch("muse.cli.commands.pull.get_remote_head", return_value=head):
358 r = runner.invoke(cli, ["pull"], env=_env(repo))
359 assert r.exit_code == 0
360 assert "already up to date" in (r.stderr or "").lower()
361 # stdout must be empty (text mode)
362 json_lines = [l for l in r.output.splitlines() if l.strip().startswith("{")]
363 assert len(json_lines) == 0
364
365
366 class TestJsonSchema:
367 def _run(
368 self,
369 repo: pathlib.Path,
370 extra_args: list[str] | None = None,
371 remote_head: str | None = None,
372 apply_result: _IntMap | None = None,
373 ) -> InvokeResult:
374 from muse.core.store import get_head_commit_id
375 local_head = get_head_commit_id(repo, "main") or "a" * 64
376 rhead = remote_head or local_head
377 info = _make_remote_info({"main": rhead})
378 ar = apply_result or {"commits_written": 2, "snapshots_written": 1,
379 "objects_written": 5, "objects_skipped": 0}
380
381 objects_count = ar.get("objects_written", 5) if ar else 5
382
383 def _fetch_stream(url, signing, want, have, on_object=None, **kwargs):
384 if callable(on_object):
385 for i in range(objects_count):
386 content = f"pull-blob-{i}".encode()
387 on_object({"object_id": blob_id(content), "content": content, "path": f"f{i}.txt"})
388 return {"commits": [], "snapshots": [], "objects_received": objects_count}
389
390 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
391 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
392 with patch("muse.cli.commands.pull.make_transport") as mt:
393 mt.return_value.fetch_remote_info.return_value = info
394 mt.return_value.negotiate.return_value = {"ready": True, "ack": [local_head], "common_base": None}
395 mt.return_value.fetch_stream.side_effect = _fetch_stream
396 with patch("muse.cli.commands.pull.apply_mpack", return_value=ar):
397 with patch("muse.cli.commands.pull.write_object", return_value=True):
398 with patch("muse.cli.commands.pull.set_remote_head"):
399 return runner.invoke(
400 cli, ["pull", "--json"] + (extra_args or []),
401 env=_env(repo),
402 )
403
404 def test_up_to_date_schema(self, repo: pathlib.Path) -> None:
405 from muse.core.store import get_head_commit_id
406 head = get_head_commit_id(repo, "main") or ""
407 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
408 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
409 with patch("muse.cli.commands.pull.make_transport") as mt:
410 mt.return_value.fetch_remote_info.return_value = _make_remote_info({"main": head})
411 with patch("muse.cli.commands.pull.get_remote_head", return_value=head):
412 r = runner.invoke(cli, ["pull", "--json"], env=_env(repo))
413 assert r.exit_code == 0, r.output
414 d = _json_line(r)
415 assert _REQUIRED.KEYS <= d.keys()
416 assert d["status"] in ("up_to_date",)
417 assert d["commits_received"] == 0
418
419 def test_fetched_schema_no_merge(self, repo: pathlib.Path) -> None:
420 r = self._run(repo, extra_args=["--no-merge"], remote_head="b" * 64)
421 assert r.exit_code == 0, r.output
422 d = _json_line(r)
423 assert _REQUIRED.KEYS <= d.keys()
424 assert d["status"] == "fetched"
425 assert d["commits_received"] == 2
426 assert d["objects_written"] == 5
427
428 def test_dry_run_schema(self, repo: pathlib.Path) -> None:
429 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
430 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
431 with patch("muse.cli.commands.pull.make_transport") as mt:
432 mt.return_value.fetch_remote_info.return_value = _make_remote_info({"main": "b" * 64})
433 r = runner.invoke(cli, ["pull", "--dry-run", "--json"], env=_env(repo))
434 assert r.exit_code == 0, r.output
435 d = _json_line(r)
436 assert _REQUIRED.KEYS <= d.keys()
437 assert d["status"] == "dry_run"
438 assert d["dry_run"] is True
439 assert d["head"] is None
440
441
442 class TestFastForwardOrdering:
443 def test_apply_manifest_before_write_branch_ref(self, repo: pathlib.Path) -> None:
444 """apply_manifest must be called BEFORE write_branch_ref in fast-forward.
445
446 Uses muse code cat to confirm the ordering contract: apply_manifest first
447 so that a crash between the two operations leaves the working tree consistent
448 with the branch pointer (the tree is safe; the pointer not yet advanced).
449 """
450 from muse.core.store import CommitRecord, SnapshotRecord, get_head_commit_id
451
452 local_head = get_head_commit_id(repo, "main") or ""
453 call_order: list[str] = []
454 remote_cid = "c" * 64
455 snap_id = "d" * 64
456
457 fake_commit = CommitRecord(
458 commit_id=remote_cid,
459 repo_id="test-repo",
460 created_on_branch="main",
461 snapshot_id=snap_id,
462 message="remote",
463 committed_at=datetime.datetime(2026, 1, 2, tzinfo=datetime.timezone.utc),
464 )
465 fake_snap = SnapshotRecord(
466 snapshot_id=snap_id,
467 manifest={"a.py": "e" * 64},
468 )
469
470 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
471 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
472 with patch("muse.cli.commands.pull.make_transport") as mt:
473 mt.return_value.fetch_remote_info.return_value = _make_remote_info({"main": remote_cid})
474 mt.return_value.negotiate.return_value = {"ready": True, "ack": [local_head], "common_base": None}
475 mt.return_value.fetch_pack.return_value = _make_bundle()
476 with patch("muse.cli.commands.pull.apply_mpack", return_value={
477 "commits_written": 1, "snapshots_written": 1,
478 "objects_written": 2, "objects_skipped": 0,
479 }):
480 with patch("muse.cli.commands.pull.set_remote_head"):
481 with patch("muse.cli.commands.pull.find_merge_base", return_value=local_head):
482 with patch("muse.cli.commands.pull.read_commit", return_value=fake_commit):
483 with patch("muse.cli.commands.pull.read_snapshot", return_value=fake_snap):
484 with patch(
485 "muse.cli.commands.pull.apply_manifest",
486 side_effect=lambda *a, **kw: call_order.append("apply"),
487 ):
488 with patch(
489 "muse.cli.commands.pull.write_branch_ref",
490 side_effect=lambda *a, **kw: call_order.append("write_ref"),
491 ):
492 runner.invoke(cli, ["pull"], env=_env(repo))
493
494 assert "apply" in call_order, "apply_manifest must be called in fast-forward path"
495 assert "write_ref" in call_order, "write_branch_ref must be called in fast-forward path"
496 assert call_order.index("apply") < call_order.index("write_ref"), (
497 "apply_manifest must happen BEFORE write_branch_ref in fast-forward"
498 )
499
500 def test_bootstrap_apply_manifest_before_write_branch_ref(self, repo: pathlib.Path) -> None:
501 """Same ordering contract in the bootstrap path (no local commits yet)."""
502 from muse.core.store import CommitRecord, SnapshotRecord
503
504 call_order: list[str] = []
505 remote_cid = "f" * 64
506 snap_id = "ab" * 32 # valid lowercase hex (64 chars)
507
508 fake_commit = CommitRecord(
509 commit_id=remote_cid,
510 repo_id="test-repo",
511 created_on_branch="main",
512 snapshot_id=snap_id,
513 message="remote",
514 committed_at=datetime.datetime(2026, 1, 2, tzinfo=datetime.timezone.utc),
515 )
516 fake_snap = SnapshotRecord(
517 snapshot_id=snap_id,
518 manifest={"a.py": "cd" * 32}, # valid lowercase hex object ID (64 chars)
519 )
520
521 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
522 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
523 with patch("muse.cli.commands.pull.make_transport") as mt:
524 mt.return_value.fetch_remote_info.return_value = _make_remote_info({"main": remote_cid})
525 mt.return_value.negotiate.return_value = {"ready": True, "ack": [], "common_base": None}
526 mt.return_value.fetch_pack.return_value = _make_bundle()
527 mt.return_value.fetch_objects.return_value = []
528 with patch("muse.cli.commands.pull.apply_mpack", return_value={
529 "commits_written": 1, "snapshots_written": 1,
530 "objects_written": 2, "objects_skipped": 0,
531 }):
532 with patch("muse.cli.commands.pull.set_remote_head"):
533 # ours_commit_id is None → bootstrap path
534 with patch("muse.cli.commands.pull.get_head_commit_id", return_value=None):
535 with patch("muse.cli.commands.pull.read_repo_id", return_value="test-repo"):
536 with patch("muse.cli.commands.pull.read_commit", return_value=fake_commit):
537 with patch("muse.cli.commands.pull.read_snapshot", return_value=fake_snap):
538 with patch(
539 "muse.cli.commands.pull.apply_manifest",
540 side_effect=lambda *a, **kw: call_order.append("apply"),
541 ):
542 with patch(
543 "muse.cli.commands.pull.write_branch_ref",
544 side_effect=lambda *a, **kw: call_order.append("write_ref"),
545 ):
546 runner.invoke(cli, ["pull"], env=_env(repo))
547
548 assert "apply" in call_order, "apply_manifest must be called in bootstrap path"
549 assert "write_ref" in call_order, "write_branch_ref must be called in bootstrap path"
550 assert call_order.index("apply") < call_order.index("write_ref"), (
551 "apply_manifest must happen BEFORE write_branch_ref in bootstrap path"
552 )
553
554
555 class TestFFOnly:
556 def test_ff_only_diverged_exits_1(self, repo: pathlib.Path) -> None:
557 from muse.core.store import get_head_commit_id
558 local_head = get_head_commit_id(repo, "main") or ""
559 remote_cid = "d" * 64
560
561 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
562 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
563 with patch("muse.cli.commands.pull.make_transport") as mt:
564 mt.return_value.fetch_remote_info.return_value = _make_remote_info({"main": remote_cid})
565 mt.return_value.negotiate.return_value = {"ready": True, "ack": [local_head], "common_base": None}
566 mt.return_value.fetch_pack.return_value = _make_bundle()
567 with patch("muse.cli.commands.pull.apply_mpack", return_value={
568 "commits_written": 1, "snapshots_written": 1,
569 "objects_written": 1, "objects_skipped": 0
570 }):
571 with patch("muse.cli.commands.pull.set_remote_head"):
572 # Simulate diverged: merge_base is neither ours nor theirs
573 with patch("muse.cli.commands.pull.find_merge_base", return_value="e" * 64):
574 r = runner.invoke(cli, ["pull", "--ff-only"], env=_env(repo))
575
576 assert r.exit_code == 1
577 assert "fast-forward" in (r.stderr or "").lower()
578
579 def test_ff_only_fast_forward_succeeds(self, repo: pathlib.Path) -> None:
580 from muse.core.store import get_head_commit_id
581 local_head = get_head_commit_id(repo, "main") or ""
582 remote_cid = "f" * 64
583
584 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
585 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
586 with patch("muse.cli.commands.pull.make_transport") as mt:
587 mt.return_value.fetch_remote_info.return_value = _make_remote_info({"main": remote_cid})
588 mt.return_value.negotiate.return_value = {"ready": True, "ack": [local_head], "common_base": None}
589 mt.return_value.fetch_pack.return_value = _make_bundle()
590 with patch("muse.cli.commands.pull.apply_mpack", return_value={
591 "commits_written": 1, "snapshots_written": 1,
592 "objects_written": 1, "objects_skipped": 0
593 }):
594 with patch("muse.cli.commands.pull.set_remote_head"):
595 with patch("muse.cli.commands.pull.find_merge_base", return_value=local_head):
596 fake_commit = MagicMock()
597 fake_commit.snapshot_id = "a" * 64
598 fake_snap = MagicMock()
599 fake_snap.manifest = {}
600 with patch("muse.cli.commands.pull.read_commit", return_value=fake_commit):
601 with patch("muse.cli.commands.pull.read_snapshot", return_value=fake_snap):
602 with patch("muse.cli.commands.pull.apply_manifest"):
603 with patch("muse.cli.commands.pull.write_branch_ref"):
604 r = runner.invoke(cli, ["pull", "--ff-only"], env=_env(repo))
605
606 assert r.exit_code == 0, r.output
607
608
609 class TestCommitsReceivedFromApplyResult:
610 def test_commits_received_uses_commits_written(self, repo: pathlib.Path) -> None:
611 """commits_received in JSON must come from apply_mpack result, not bundle length."""
612 remote_cid = "g" * 64
613 info = _make_remote_info({"main": remote_cid})
614 ar = {"commits_written": 7, "snapshots_written": 7,
615 "objects_written": 21, "objects_skipped": 0}
616
617 def _fetch_stream(url, signing, want, have, on_object=None, **kwargs):
618 if callable(on_object):
619 for i in range(21):
620 content = f"pull-blob-{i}".encode()
621 on_object({"object_id": blob_id(content), "content": content, "path": f"f{i}.txt"})
622 return {"commits": [], "snapshots": [], "objects_received": 21}
623
624 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
625 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
626 with patch("muse.cli.commands.pull.make_transport") as mt:
627 mt.return_value.fetch_remote_info.return_value = info
628 mt.return_value.negotiate.return_value = {"ready": True, "ack": [], "common_base": None}
629 mt.return_value.fetch_stream.side_effect = _fetch_stream
630 with patch("muse.cli.commands.pull.apply_mpack", return_value=ar):
631 with patch("muse.cli.commands.pull.write_object", return_value=True):
632 with patch("muse.cli.commands.pull.set_remote_head"):
633 r = runner.invoke(
634 cli, ["pull", "--no-merge", "--json"], env=_env(repo)
635 )
636 assert r.exit_code == 0, r.output
637 d = _json_line(r)
638 assert d["commits_received"] == 7
639 assert d["objects_written"] == 21
640
641
642 # ---------------------------------------------------------------------------
643 # End-to-end with file:// transport
644 # ---------------------------------------------------------------------------
645
646 @pytest.fixture()
647 def two_repos(
648 tmp_path: pathlib.Path,
649 monkeypatch: pytest.MonkeyPatch,
650 ) -> tuple[pathlib.Path, pathlib.Path]:
651 """Return (local, remote) pair — local already has remote configured."""
652 local = tmp_path / "local"
653 remote = tmp_path / "remote"
654 local.mkdir()
655 remote.mkdir()
656
657 from muse._version import __version__
658 from muse.core.object_store import write_object
659 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
660 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
661
662 def _scaffold(root: pathlib.Path, msg: str, content: bytes) -> str:
663 muse = root / ".muse"
664 for sub in ("refs/heads", "objects", "commits", "snapshots"):
665 (muse / sub).mkdir(parents=True, exist_ok=True)
666 (muse / "repo.json").write_text(
667 json.dumps({"repo_id": "e2e-repo", "schema_version": __version__, "domain": "code"})
668 )
669 (muse / "HEAD").write_text("ref: refs/heads/main\n")
670 blob = content
671 oid = _sha(blob)
672 write_object(root, oid, blob)
673 snap_id = compute_snapshot_id({"a.py": oid})
674 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest={"a.py": oid}))
675 ts = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
676 cid = compute_commit_id(
677 repo_id="e2e-repo",
678 parent_ids=[],
679 snapshot_id=snap_id,
680 message=msg,
681 committed_at_iso=ts.isoformat(),
682 )
683 write_commit(root, CommitRecord(
684 commit_id=cid, repo_id="e2e-repo", created_on_branch="main",
685 snapshot_id=snap_id, message=msg, committed_at=ts,
686 ))
687 (muse / "refs" / "heads" / "main").write_text(cid)
688 (muse / "config.toml").write_text(f'[remotes.origin]\nurl = "file://{root}"\n')
689 return cid
690
691 _scaffold(remote, "remote-base", b"x = 1\n")
692 _scaffold(local, "local-base", b"x = 1\n")
693 # Point local's remote at the remote repo
694 (local / ".muse" / "config.toml").write_text(
695 f'[remotes.origin]\nurl = "file://{remote}"\n'
696 )
697
698 monkeypatch.chdir(local)
699 monkeypatch.setenv("MUSE_REPO_ROOT", str(local))
700 return local, remote
701
702
703 class TestEndToEnd:
704 def test_pull_no_merge_fetches(self, two_repos: tuple[pathlib.Path, pathlib.Path]) -> None:
705 local, remote = two_repos
706 r = runner.invoke(cli, ["pull", "--no-merge"], env=_env(local), catch_exceptions=False)
707 assert r.exit_code == 0, r.output
708
709 def test_pull_json_fetched_schema(self, two_repos: tuple[pathlib.Path, pathlib.Path]) -> None:
710 local, remote = two_repos
711 r = runner.invoke(
712 cli, ["pull", "--no-merge", "--json"],
713 env=_env(local), catch_exceptions=False,
714 )
715 assert r.exit_code == 0, r.output
716 d = _json_line(r)
717 assert _REQUIRED.KEYS <= d.keys()
718 assert d["status"] == "fetched"
719
720 def test_dry_run_no_side_effects(self, two_repos: tuple[pathlib.Path, pathlib.Path]) -> None:
721 local, remote = two_repos
722 # Record state before dry run
723 from muse.core.store import get_head_commit_id
724 head_before = get_head_commit_id(local, "main")
725 r = runner.invoke(
726 cli, ["pull", "--dry-run"],
727 env=_env(local), catch_exceptions=False,
728 )
729 assert r.exit_code == 0, r.output
730 head_after = get_head_commit_id(local, "main")
731 assert head_before == head_after, "dry-run must not advance local HEAD"
732
733 def test_dry_run_json_schema(self, two_repos: tuple[pathlib.Path, pathlib.Path]) -> None:
734 local, remote = two_repos
735 r = runner.invoke(
736 cli, ["pull", "--dry-run", "--json"],
737 env=_env(local), catch_exceptions=False,
738 )
739 assert r.exit_code == 0, r.output
740 d = _json_line(r)
741 assert _REQUIRED.KEYS <= d.keys()
742 assert d["dry_run"] is True
743
744 def test_ff_only_refuses_diverged(self, two_repos: tuple[pathlib.Path, pathlib.Path]) -> None:
745 """When local and remote have diverged, --ff-only must exit non-zero."""
746 local, remote = two_repos
747 # Advance remote past local (add a new commit on remote)
748 from muse.core.store import get_head_commit_id
749 import muse.core.snapshot as snap_mod
750 import muse.core.store as store_mod
751 remote_head = get_head_commit_id(remote, "main") or ""
752 # Write a new commit on the remote with a different snapshot
753 from muse.core.object_store import write_object
754 blob = b"x = 2\n"
755 oid = _sha(blob)
756 write_object(remote, oid, blob)
757 snap_id = snap_mod.compute_snapshot_id({"a.py": oid})
758 store_mod.write_snapshot(remote, store_mod.SnapshotRecord(snapshot_id=snap_id, manifest={"a.py": oid}))
759 ts = datetime.datetime(2026, 1, 2, tzinfo=datetime.timezone.utc)
760 cid = snap_mod.compute_commit_id(
761 repo_id="e2e-repo",
762 parent_ids=[remote_head],
763 snapshot_id=snap_id,
764 message="remote-advance",
765 committed_at_iso=ts.isoformat(),
766 )
767 store_mod.write_commit(remote, store_mod.CommitRecord(
768 commit_id=cid, repo_id="e2e-repo", created_on_branch="main",
769 snapshot_id=snap_id, message="remote-advance", committed_at=ts,
770 parent_commit_id=remote_head,
771 ))
772 (remote / ".muse" / "refs" / "heads" / "main").write_text(cid)
773 # Also diverge local (add a local-only commit)
774 local_head = get_head_commit_id(local, "main") or ""
775 blob2 = b"y = 1\n"
776 oid2 = _sha(blob2)
777 write_object(local, oid2, blob2)
778 snap_id2 = snap_mod.compute_snapshot_id({"b.py": oid2})
779 store_mod.write_snapshot(local, store_mod.SnapshotRecord(snapshot_id=snap_id2, manifest={"b.py": oid2}))
780 ts2 = datetime.datetime(2026, 1, 2, tzinfo=datetime.timezone.utc)
781 cid2 = snap_mod.compute_commit_id(
782 repo_id="e2e-repo",
783 parent_ids=[local_head],
784 snapshot_id=snap_id2,
785 message="local-advance",
786 committed_at_iso=ts2.isoformat(),
787 )
788 store_mod.write_commit(local, store_mod.CommitRecord(
789 commit_id=cid2, repo_id="e2e-repo", created_on_branch="main",
790 snapshot_id=snap_id2, message="local-advance", committed_at=ts2,
791 parent_commit_id=local_head,
792 ))
793 (local / ".muse" / "refs" / "heads" / "main").write_text(cid2)
794
795 r = runner.invoke(cli, ["pull", "--ff-only"], env=_env(local))
796 assert r.exit_code == 1
797 assert "fast-forward" in (r.stderr or "").lower()
798
799
800 # ---------------------------------------------------------------------------
801 # Security
802 # ---------------------------------------------------------------------------
803
804 class TestSecurity:
805 def test_remote_name_ansi_sanitized(self, repo: pathlib.Path) -> None:
806 ansi = "\x1b[31mevil\x1b[0m"
807 r = runner.invoke(cli, ["pull", ansi], env=_env(repo))
808 assert r.exit_code != 0
809 assert "\x1b[31m" not in (r.stderr or "")
810
811 def test_branch_name_sanitized_in_not_found(self, repo: pathlib.Path) -> None:
812 info = _make_remote_info({"main": "a" * 64})
813 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
814 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
815 with patch("muse.cli.commands.pull.make_transport") as mt:
816 mt.return_value.fetch_remote_info.return_value = info
817 r = runner.invoke(
818 cli, ["pull", "origin", "--branch", "\x1b[31mevil\x1b[0m"],
819 env=_env(repo),
820 )
821 assert "\x1b[31m" not in (r.stderr or "")
822 assert "\x1b[31m" not in r.output
823
824 def test_progress_not_in_stdout_on_json(self, repo: pathlib.Path) -> None:
825 """--json: stdout must contain exactly one JSON line, no mixed progress."""
826 from muse.core.store import get_head_commit_id
827 head = get_head_commit_id(repo, "main") or ""
828 info = _make_remote_info({"main": head})
829 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
830 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
831 with patch("muse.cli.commands.pull.make_transport") as mt:
832 mt.return_value.fetch_remote_info.return_value = info
833 with patch("muse.cli.commands.pull.get_remote_head", return_value=head):
834 r = runner.invoke(cli, ["pull", "--json"], env=_env(repo))
835 json_lines = [l for l in r.output.splitlines() if l.strip().startswith("{")]
836 assert len(json_lines) == 1
837 json.loads(json_lines[0]) # must be valid JSON
838
839 def test_unknown_flag_exits_nonzero_yaml(self, repo: pathlib.Path) -> None:
840 r = runner.invoke(cli, ["pull", "--format", "yaml"], env=_env(repo))
841 assert r.exit_code != 0
842
843 def test_conflict_paths_sanitized_in_text(self, repo: pathlib.Path) -> None:
844 """File paths in CONFLICT lines must be run through sanitize_display."""
845 from muse.core.store import get_head_commit_id
846 local_head = get_head_commit_id(repo, "main") or ""
847 remote_cid = "h" * 64
848
849 evil_path = "\x1b[31mevil.py\x1b[0m"
850
851 from unittest.mock import MagicMock as MM
852 merge_result = MM()
853 merge_result.is_clean = False
854 merge_result.conflicts = {evil_path}
855 merge_result.applied_strategies = {}
856
857 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
858 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
859 with patch("muse.cli.commands.pull.make_transport") as mt:
860 mt.return_value.fetch_remote_info.return_value = _make_remote_info({"main": remote_cid})
861 mt.return_value.negotiate.return_value = {"ready": True, "ack": [local_head], "common_base": None}
862 mt.return_value.fetch_pack.return_value = _make_bundle()
863 with patch("muse.cli.commands.pull.apply_mpack", return_value={
864 "commits_written": 1, "snapshots_written": 1,
865 "objects_written": 1, "objects_skipped": 0
866 }):
867 with patch("muse.cli.commands.pull.set_remote_head"):
868 with patch("muse.cli.commands.pull.find_merge_base", return_value="z" * 64):
869 with patch("muse.cli.commands.pull.resolve_plugin") as rp:
870 with patch("muse.cli.commands.pull.read_domain", return_value="code"):
871 plugin = MM()
872 plugin.__class__ = type("P", (), {"merge": None})
873 rp.return_value = plugin
874 plugin.merge.return_value = merge_result
875 with patch("muse.cli.commands.pull.write_merge_state"):
876 r = runner.invoke(cli, ["pull"], env=_env(repo))
877
878 assert "\x1b[31m" not in (r.stderr or "")
879 assert "\x1b[31m" not in r.output
880
881
882 # ---------------------------------------------------------------------------
883 # Stress
884 # ---------------------------------------------------------------------------
885
886 class TestStress:
887 @pytest.mark.slow
888 def test_negotiate_have_10k_commits(self) -> None:
889 """_negotiate_have must terminate in finite rounds with 10 000 commits."""
890 from muse.core.transport import negotiate_have as _negotiate_have
891 from muse.core.transport import NEGOTIATE_DEPTH
892
893 call_count = 0
894
895 def never_ready(url: str, token: str | None, want: list[str], have: list[str]) -> "NegotiateResponse":
896 nonlocal call_count
897 call_count += 1
898 return {"ready": False, "ack": [], "common_base": None}
899
900 t = MagicMock()
901 t.negotiate.side_effect = never_ready
902 commits = [_sha(str(i).encode()) for i in range(10_000)]
903 result = _negotiate_have(t, "http://x", None, ["want"], commits)
904
905 assert result == commits # full fallback
906 expected_rounds = (len(commits) + NEGOTIATE_DEPTH - 1) // NEGOTIATE_DEPTH
907 assert call_count == expected_rounds
908
909 @pytest.mark.slow
910 def test_concurrent_negotiate_have(self) -> None:
911 """Concurrent _negotiate_have calls on isolated state must not interfere."""
912 from muse.core.transport import negotiate_have as _negotiate_have
913
914 errors: list[str] = []
915
916 def run_one(idx: int) -> None:
917 commits = [_sha(f"{idx}-{i}".encode()) for i in range(50)]
918
919 call_n = 0
920 def ready_second(url: str, token: str | None, want: list[str], have: list[str]) -> "NegotiateResponse":
921 nonlocal call_n
922 call_n += 1
923 return {"ready": call_n >= 2, "ack": have, "common_base": None}
924
925 t = MagicMock()
926 t.negotiate.side_effect = ready_second
927 result = _negotiate_have(t, "http://x", None, [f"want-{idx}"], commits)
928 if not result:
929 errors.append(f"worker {idx}: empty result")
930
931 threads = [threading.Thread(target=run_one, args=(i,)) for i in range(16)]
932 for th in threads:
933 th.start()
934 for th in threads:
935 th.join()
936 assert not errors, f"Concurrent errors: {errors}"
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago