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