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