gabriel / muse public
test_cmd_pull_hardening.py python
892 lines 41.6 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 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_pack 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 hashlib
61 import json
62 import pathlib
63 import threading
64 from typing import TYPE_CHECKING
65 from unittest.mock import MagicMock, call, patch
66
67 import pytest
68
69 from tests.cli_test_helper import CliRunner, InvokeResult
70
71 if TYPE_CHECKING:
72 from muse.cli.commands.pull import _PullJson
73 from muse.core.pack import PackBundle, 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 hashlib.sha256(content).hexdigest()
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 ) -> "PackBundle":
115 from muse.core.pack import PackBundle
116 return PackBundle(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_pack_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.side_effect = TransportError("no negotiate", 404)
329 mt.return_value.fetch_pack.side_effect = TransportError("pack 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 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
373 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
374 with patch("muse.cli.commands.pull.make_transport") as mt:
375 mt.return_value.fetch_remote_info.return_value = info
376 mt.return_value.negotiate.return_value = {"ready": True, "ack": [local_head], "common_base": None}
377 mt.return_value.fetch_pack.return_value = _make_bundle()
378 with patch("muse.cli.commands.pull.apply_pack", return_value=ar):
379 with patch("muse.cli.commands.pull.set_remote_head"):
380 return runner.invoke(
381 cli, ["pull", "--json"] + (extra_args or []),
382 env=_env(repo),
383 )
384
385 def test_up_to_date_schema(self, repo: pathlib.Path) -> None:
386 from muse.core.store import get_head_commit_id
387 head = get_head_commit_id(repo, "main") or ""
388 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
389 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
390 with patch("muse.cli.commands.pull.make_transport") as mt:
391 mt.return_value.fetch_remote_info.return_value = _make_remote_info({"main": head})
392 with patch("muse.cli.commands.pull.get_remote_head", return_value=head):
393 r = runner.invoke(cli, ["pull", "--json"], env=_env(repo))
394 assert r.exit_code == 0, r.output
395 d = _json_line(r)
396 assert _REQUIRED.KEYS <= d.keys()
397 assert d["status"] in ("up_to_date",)
398 assert d["commits_received"] == 0
399
400 def test_fetched_schema_no_merge(self, repo: pathlib.Path) -> None:
401 r = self._run(repo, extra_args=["--no-merge"], remote_head="b" * 64)
402 assert r.exit_code == 0, r.output
403 d = _json_line(r)
404 assert _REQUIRED.KEYS <= d.keys()
405 assert d["status"] == "fetched"
406 assert d["commits_received"] == 2
407 assert d["objects_written"] == 5
408
409 def test_dry_run_schema(self, repo: pathlib.Path) -> None:
410 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
411 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
412 with patch("muse.cli.commands.pull.make_transport") as mt:
413 mt.return_value.fetch_remote_info.return_value = _make_remote_info({"main": "b" * 64})
414 r = runner.invoke(cli, ["pull", "--dry-run", "--json"], env=_env(repo))
415 assert r.exit_code == 0, r.output
416 d = _json_line(r)
417 assert _REQUIRED.KEYS <= d.keys()
418 assert d["status"] == "dry_run"
419 assert d["dry_run"] is True
420 assert d["head"] is None
421
422
423 class TestFastForwardOrdering:
424 def test_apply_manifest_before_write_branch_ref(self, repo: pathlib.Path) -> None:
425 """apply_manifest must be called BEFORE write_branch_ref in fast-forward.
426
427 Uses muse code cat to confirm the ordering contract: apply_manifest first
428 so that a crash between the two operations leaves the working tree consistent
429 with the branch pointer (the tree is safe; the pointer not yet advanced).
430 """
431 from muse.core.store import CommitRecord, SnapshotRecord, get_head_commit_id
432
433 local_head = get_head_commit_id(repo, "main") or ""
434 call_order: list[str] = []
435 remote_cid = "c" * 64
436 snap_id = "d" * 64
437
438 fake_commit = CommitRecord(
439 commit_id=remote_cid,
440 repo_id="test-repo",
441 branch="main",
442 snapshot_id=snap_id,
443 message="remote",
444 committed_at=datetime.datetime(2026, 1, 2, tzinfo=datetime.timezone.utc),
445 )
446 fake_snap = SnapshotRecord(
447 snapshot_id=snap_id,
448 manifest={"a.py": "e" * 64},
449 )
450
451 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
452 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
453 with patch("muse.cli.commands.pull.make_transport") as mt:
454 mt.return_value.fetch_remote_info.return_value = _make_remote_info({"main": remote_cid})
455 mt.return_value.negotiate.return_value = {"ready": True, "ack": [local_head], "common_base": None}
456 mt.return_value.fetch_pack.return_value = _make_bundle()
457 with patch("muse.cli.commands.pull.apply_pack", return_value={
458 "commits_written": 1, "snapshots_written": 1,
459 "objects_written": 2, "objects_skipped": 0,
460 }):
461 with patch("muse.cli.commands.pull.set_remote_head"):
462 with patch("muse.cli.commands.pull.find_merge_base", return_value=local_head):
463 with patch("muse.cli.commands.pull.read_commit", return_value=fake_commit):
464 with patch("muse.cli.commands.pull.read_snapshot", return_value=fake_snap):
465 with patch(
466 "muse.cli.commands.pull.apply_manifest",
467 side_effect=lambda *a, **kw: call_order.append("apply"),
468 ):
469 with patch(
470 "muse.cli.commands.pull.write_branch_ref",
471 side_effect=lambda *a, **kw: call_order.append("write_ref"),
472 ):
473 runner.invoke(cli, ["pull"], env=_env(repo))
474
475 assert "apply" in call_order, "apply_manifest must be called in fast-forward path"
476 assert "write_ref" in call_order, "write_branch_ref must be called in fast-forward path"
477 assert call_order.index("apply") < call_order.index("write_ref"), (
478 "apply_manifest must happen BEFORE write_branch_ref in fast-forward"
479 )
480
481 def test_bootstrap_apply_manifest_before_write_branch_ref(self, repo: pathlib.Path) -> None:
482 """Same ordering contract in the bootstrap path (no local commits yet)."""
483 from muse.core.store import CommitRecord, SnapshotRecord
484
485 call_order: list[str] = []
486 remote_cid = "f" * 64
487 snap_id = "ab" * 32 # valid lowercase hex (64 chars)
488
489 fake_commit = CommitRecord(
490 commit_id=remote_cid,
491 repo_id="test-repo",
492 branch="main",
493 snapshot_id=snap_id,
494 message="remote",
495 committed_at=datetime.datetime(2026, 1, 2, tzinfo=datetime.timezone.utc),
496 )
497 fake_snap = SnapshotRecord(
498 snapshot_id=snap_id,
499 manifest={"a.py": "cd" * 32}, # valid lowercase hex object ID (64 chars)
500 )
501
502 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
503 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
504 with patch("muse.cli.commands.pull.make_transport") as mt:
505 mt.return_value.fetch_remote_info.return_value = _make_remote_info({"main": remote_cid})
506 mt.return_value.negotiate.return_value = {"ready": True, "ack": [], "common_base": None}
507 mt.return_value.fetch_pack.return_value = _make_bundle()
508 mt.return_value.fetch_objects.return_value = []
509 with patch("muse.cli.commands.pull.apply_pack", return_value={
510 "commits_written": 1, "snapshots_written": 1,
511 "objects_written": 2, "objects_skipped": 0,
512 }):
513 with patch("muse.cli.commands.pull.set_remote_head"):
514 # ours_commit_id is None → bootstrap path
515 with patch("muse.cli.commands.pull.get_head_commit_id", return_value=None):
516 with patch("muse.cli.commands.pull.read_repo_id", return_value="test-repo"):
517 with patch("muse.cli.commands.pull.read_commit", return_value=fake_commit):
518 with patch("muse.cli.commands.pull.read_snapshot", return_value=fake_snap):
519 with patch(
520 "muse.cli.commands.pull.apply_manifest",
521 side_effect=lambda *a, **kw: call_order.append("apply"),
522 ):
523 with patch(
524 "muse.cli.commands.pull.write_branch_ref",
525 side_effect=lambda *a, **kw: call_order.append("write_ref"),
526 ):
527 runner.invoke(cli, ["pull"], env=_env(repo))
528
529 assert "apply" in call_order, "apply_manifest must be called in bootstrap path"
530 assert "write_ref" in call_order, "write_branch_ref must be called in bootstrap path"
531 assert call_order.index("apply") < call_order.index("write_ref"), (
532 "apply_manifest must happen BEFORE write_branch_ref in bootstrap path"
533 )
534
535
536 class TestFFOnly:
537 def test_ff_only_diverged_exits_1(self, repo: pathlib.Path) -> None:
538 from muse.core.store import get_head_commit_id
539 local_head = get_head_commit_id(repo, "main") or ""
540 remote_cid = "d" * 64
541
542 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
543 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
544 with patch("muse.cli.commands.pull.make_transport") as mt:
545 mt.return_value.fetch_remote_info.return_value = _make_remote_info({"main": remote_cid})
546 mt.return_value.negotiate.return_value = {"ready": True, "ack": [local_head], "common_base": None}
547 mt.return_value.fetch_pack.return_value = _make_bundle()
548 with patch("muse.cli.commands.pull.apply_pack", return_value={
549 "commits_written": 1, "snapshots_written": 1,
550 "objects_written": 1, "objects_skipped": 0
551 }):
552 with patch("muse.cli.commands.pull.set_remote_head"):
553 # Simulate diverged: merge_base is neither ours nor theirs
554 with patch("muse.cli.commands.pull.find_merge_base", return_value="e" * 64):
555 r = runner.invoke(cli, ["pull", "--ff-only"], env=_env(repo))
556
557 assert r.exit_code == 1
558 assert "fast-forward" in (r.stderr or "").lower()
559
560 def test_ff_only_fast_forward_succeeds(self, repo: pathlib.Path) -> None:
561 from muse.core.store import get_head_commit_id
562 local_head = get_head_commit_id(repo, "main") or ""
563 remote_cid = "f" * 64
564
565 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
566 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
567 with patch("muse.cli.commands.pull.make_transport") as mt:
568 mt.return_value.fetch_remote_info.return_value = _make_remote_info({"main": remote_cid})
569 mt.return_value.negotiate.return_value = {"ready": True, "ack": [local_head], "common_base": None}
570 mt.return_value.fetch_pack.return_value = _make_bundle()
571 with patch("muse.cli.commands.pull.apply_pack", return_value={
572 "commits_written": 1, "snapshots_written": 1,
573 "objects_written": 1, "objects_skipped": 0
574 }):
575 with patch("muse.cli.commands.pull.set_remote_head"):
576 with patch("muse.cli.commands.pull.find_merge_base", return_value=local_head):
577 fake_commit = MagicMock()
578 fake_commit.snapshot_id = "a" * 64
579 fake_snap = MagicMock()
580 fake_snap.manifest = {}
581 with patch("muse.cli.commands.pull.read_commit", return_value=fake_commit):
582 with patch("muse.cli.commands.pull.read_snapshot", return_value=fake_snap):
583 with patch("muse.cli.commands.pull.apply_manifest"):
584 with patch("muse.cli.commands.pull.write_branch_ref"):
585 r = runner.invoke(cli, ["pull", "--ff-only"], env=_env(repo))
586
587 assert r.exit_code == 0, r.output
588
589
590 class TestCommitsReceivedFromApplyResult:
591 def test_commits_received_uses_commits_written(self, repo: pathlib.Path) -> None:
592 """commits_received in JSON must come from apply_pack result, not bundle length."""
593 remote_cid = "g" * 64
594 info = _make_remote_info({"main": remote_cid})
595 ar = {"commits_written": 7, "snapshots_written": 7,
596 "objects_written": 21, "objects_skipped": 0}
597
598 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
599 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
600 with patch("muse.cli.commands.pull.make_transport") as mt:
601 mt.return_value.fetch_remote_info.return_value = info
602 mt.return_value.negotiate.return_value = {"ready": True, "ack": [], "common_base": None}
603 mt.return_value.fetch_pack.return_value = _make_bundle()
604 with patch("muse.cli.commands.pull.apply_pack", return_value=ar):
605 with patch("muse.cli.commands.pull.set_remote_head"):
606 r = runner.invoke(
607 cli, ["pull", "--no-merge", "--json"], env=_env(repo)
608 )
609 assert r.exit_code == 0, r.output
610 d = _json_line(r)
611 assert d["commits_received"] == 7
612 assert d["objects_written"] == 21
613
614
615 # ---------------------------------------------------------------------------
616 # End-to-end with file:// transport
617 # ---------------------------------------------------------------------------
618
619 @pytest.fixture()
620 def two_repos(
621 tmp_path: pathlib.Path,
622 monkeypatch: pytest.MonkeyPatch,
623 ) -> tuple[pathlib.Path, pathlib.Path]:
624 """Return (local, remote) pair — local already has remote configured."""
625 local = tmp_path / "local"
626 remote = tmp_path / "remote"
627 local.mkdir()
628 remote.mkdir()
629
630 from muse._version import __version__
631 from muse.core.object_store import write_object
632 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
633 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
634
635 def _scaffold(root: pathlib.Path, msg: str, content: bytes) -> str:
636 muse = root / ".muse"
637 for sub in ("refs/heads", "objects", "commits", "snapshots"):
638 (muse / sub).mkdir(parents=True, exist_ok=True)
639 (muse / "repo.json").write_text(
640 json.dumps({"repo_id": "e2e-repo", "schema_version": __version__, "domain": "code"})
641 )
642 (muse / "HEAD").write_text("ref: refs/heads/main\n")
643 blob = content
644 oid = _sha(blob)
645 write_object(root, oid, blob)
646 snap_id = compute_snapshot_id({"a.py": oid})
647 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest={"a.py": oid}))
648 ts = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
649 cid = compute_commit_id([], snap_id, msg, ts.isoformat())
650 write_commit(root, CommitRecord(
651 commit_id=cid, repo_id="e2e-repo", branch="main",
652 snapshot_id=snap_id, message=msg, committed_at=ts,
653 ))
654 (muse / "refs" / "heads" / "main").write_text(cid)
655 (muse / "config.toml").write_text(f'[remotes.origin]\nurl = "file://{root}"\n')
656 return cid
657
658 _scaffold(remote, "remote-base", b"x = 1\n")
659 _scaffold(local, "local-base", b"x = 1\n")
660 # Point local's remote at the remote repo
661 (local / ".muse" / "config.toml").write_text(
662 f'[remotes.origin]\nurl = "file://{remote}"\n'
663 )
664
665 monkeypatch.chdir(local)
666 monkeypatch.setenv("MUSE_REPO_ROOT", str(local))
667 return local, remote
668
669
670 class TestEndToEnd:
671 def test_pull_no_merge_fetches(self, two_repos: tuple[pathlib.Path, pathlib.Path]) -> None:
672 local, remote = two_repos
673 r = runner.invoke(cli, ["pull", "--no-merge"], env=_env(local), catch_exceptions=False)
674 assert r.exit_code == 0, r.output
675
676 def test_pull_json_fetched_schema(self, two_repos: tuple[pathlib.Path, pathlib.Path]) -> None:
677 local, remote = two_repos
678 r = runner.invoke(
679 cli, ["pull", "--no-merge", "--json"],
680 env=_env(local), catch_exceptions=False,
681 )
682 assert r.exit_code == 0, r.output
683 d = _json_line(r)
684 assert _REQUIRED.KEYS <= d.keys()
685 assert d["status"] == "fetched"
686
687 def test_dry_run_no_side_effects(self, two_repos: tuple[pathlib.Path, pathlib.Path]) -> None:
688 local, remote = two_repos
689 # Record state before dry run
690 from muse.core.store import get_head_commit_id
691 head_before = get_head_commit_id(local, "main")
692 r = runner.invoke(
693 cli, ["pull", "--dry-run"],
694 env=_env(local), catch_exceptions=False,
695 )
696 assert r.exit_code == 0, r.output
697 head_after = get_head_commit_id(local, "main")
698 assert head_before == head_after, "dry-run must not advance local HEAD"
699
700 def test_dry_run_json_schema(self, two_repos: tuple[pathlib.Path, pathlib.Path]) -> None:
701 local, remote = two_repos
702 r = runner.invoke(
703 cli, ["pull", "--dry-run", "--json"],
704 env=_env(local), catch_exceptions=False,
705 )
706 assert r.exit_code == 0, r.output
707 d = _json_line(r)
708 assert _REQUIRED.KEYS <= d.keys()
709 assert d["dry_run"] is True
710
711 def test_ff_only_refuses_diverged(self, two_repos: tuple[pathlib.Path, pathlib.Path]) -> None:
712 """When local and remote have diverged, --ff-only must exit non-zero."""
713 local, remote = two_repos
714 # Advance remote past local (add a new commit on remote)
715 from muse.core.store import get_head_commit_id
716 import muse.core.snapshot as snap_mod
717 import muse.core.store as store_mod
718 remote_head = get_head_commit_id(remote, "main") or ""
719 # Write a new commit on the remote with a different snapshot
720 from muse.core.object_store import write_object
721 blob = b"x = 2\n"
722 oid = _sha(blob)
723 write_object(remote, oid, blob)
724 snap_id = snap_mod.compute_snapshot_id({"a.py": oid})
725 store_mod.write_snapshot(remote, store_mod.SnapshotRecord(snapshot_id=snap_id, manifest={"a.py": oid}))
726 ts = datetime.datetime(2026, 1, 2, tzinfo=datetime.timezone.utc)
727 cid = snap_mod.compute_commit_id([remote_head], snap_id, "remote-advance", ts.isoformat())
728 store_mod.write_commit(remote, store_mod.CommitRecord(
729 commit_id=cid, repo_id="e2e-repo", branch="main",
730 snapshot_id=snap_id, message="remote-advance", committed_at=ts,
731 parent_commit_id=remote_head,
732 ))
733 (remote / ".muse" / "refs" / "heads" / "main").write_text(cid)
734 # Also diverge local (add a local-only commit)
735 local_head = get_head_commit_id(local, "main") or ""
736 blob2 = b"y = 1\n"
737 oid2 = _sha(blob2)
738 write_object(local, oid2, blob2)
739 snap_id2 = snap_mod.compute_snapshot_id({"b.py": oid2})
740 store_mod.write_snapshot(local, store_mod.SnapshotRecord(snapshot_id=snap_id2, manifest={"b.py": oid2}))
741 ts2 = datetime.datetime(2026, 1, 2, tzinfo=datetime.timezone.utc)
742 cid2 = snap_mod.compute_commit_id([local_head], snap_id2, "local-advance", ts2.isoformat())
743 store_mod.write_commit(local, store_mod.CommitRecord(
744 commit_id=cid2, repo_id="e2e-repo", branch="main",
745 snapshot_id=snap_id2, message="local-advance", committed_at=ts2,
746 parent_commit_id=local_head,
747 ))
748 (local / ".muse" / "refs" / "heads" / "main").write_text(cid2)
749
750 r = runner.invoke(cli, ["pull", "--ff-only"], env=_env(local))
751 assert r.exit_code == 1
752 assert "fast-forward" in (r.stderr or "").lower()
753
754
755 # ---------------------------------------------------------------------------
756 # Security
757 # ---------------------------------------------------------------------------
758
759 class TestSecurity:
760 def test_remote_name_ansi_sanitized(self, repo: pathlib.Path) -> None:
761 ansi = "\x1b[31mevil\x1b[0m"
762 r = runner.invoke(cli, ["pull", ansi], env=_env(repo))
763 assert r.exit_code != 0
764 assert "\x1b[31m" not in (r.stderr or "")
765
766 def test_branch_name_sanitized_in_not_found(self, repo: pathlib.Path) -> None:
767 info = _make_remote_info({"main": "a" * 64})
768 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
769 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
770 with patch("muse.cli.commands.pull.make_transport") as mt:
771 mt.return_value.fetch_remote_info.return_value = info
772 r = runner.invoke(
773 cli, ["pull", "origin", "--branch", "\x1b[31mevil\x1b[0m"],
774 env=_env(repo),
775 )
776 assert "\x1b[31m" not in (r.stderr or "")
777 assert "\x1b[31m" not in r.output
778
779 def test_progress_not_in_stdout_on_json(self, repo: pathlib.Path) -> None:
780 """--json: stdout must contain exactly one JSON line, no mixed progress."""
781 from muse.core.store import get_head_commit_id
782 head = get_head_commit_id(repo, "main") or ""
783 info = _make_remote_info({"main": head})
784 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
785 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
786 with patch("muse.cli.commands.pull.make_transport") as mt:
787 mt.return_value.fetch_remote_info.return_value = info
788 with patch("muse.cli.commands.pull.get_remote_head", return_value=head):
789 r = runner.invoke(cli, ["pull", "--json"], env=_env(repo))
790 json_lines = [l for l in r.output.splitlines() if l.strip().startswith("{")]
791 assert len(json_lines) == 1
792 json.loads(json_lines[0]) # must be valid JSON
793
794 def test_invalid_format_exits_to_stderr(self, repo: pathlib.Path) -> None:
795 r = runner.invoke(cli, ["pull", "--format", "yaml"], env=_env(repo))
796 assert r.exit_code == 1
797 assert "yaml" in (r.stderr or "").lower()
798
799 def test_conflict_paths_sanitized_in_text(self, repo: pathlib.Path) -> None:
800 """File paths in CONFLICT lines must be run through sanitize_display."""
801 from muse.core.store import get_head_commit_id
802 local_head = get_head_commit_id(repo, "main") or ""
803 remote_cid = "h" * 64
804
805 evil_path = "\x1b[31mevil.py\x1b[0m"
806
807 from unittest.mock import MagicMock as MM
808 merge_result = MM()
809 merge_result.is_clean = False
810 merge_result.conflicts = {evil_path}
811 merge_result.applied_strategies = {}
812
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 = _make_remote_info({"main": remote_cid})
817 mt.return_value.negotiate.return_value = {"ready": True, "ack": [local_head], "common_base": None}
818 mt.return_value.fetch_pack.return_value = _make_bundle()
819 with patch("muse.cli.commands.pull.apply_pack", return_value={
820 "commits_written": 1, "snapshots_written": 1,
821 "objects_written": 1, "objects_skipped": 0
822 }):
823 with patch("muse.cli.commands.pull.set_remote_head"):
824 with patch("muse.cli.commands.pull.find_merge_base", return_value="z" * 64):
825 with patch("muse.cli.commands.pull.resolve_plugin") as rp:
826 with patch("muse.cli.commands.pull.read_domain", return_value="code"):
827 plugin = MM()
828 plugin.__class__ = type("P", (), {"merge": None})
829 rp.return_value = plugin
830 plugin.merge.return_value = merge_result
831 with patch("muse.cli.commands.pull.write_merge_state"):
832 r = runner.invoke(cli, ["pull"], env=_env(repo))
833
834 assert "\x1b[31m" not in (r.stderr or "")
835 assert "\x1b[31m" not in r.output
836
837
838 # ---------------------------------------------------------------------------
839 # Stress
840 # ---------------------------------------------------------------------------
841
842 class TestStress:
843 @pytest.mark.slow
844 def test_negotiate_have_10k_commits(self) -> None:
845 """_negotiate_have must terminate in finite rounds with 10 000 commits."""
846 from muse.core.transport import negotiate_have as _negotiate_have
847 from muse.core.transport import NEGOTIATE_DEPTH
848
849 call_count = 0
850
851 def never_ready(url: str, token: str | None, want: list[str], have: list[str]) -> "NegotiateResponse":
852 nonlocal call_count
853 call_count += 1
854 return {"ready": False, "ack": [], "common_base": None}
855
856 t = MagicMock()
857 t.negotiate.side_effect = never_ready
858 commits = [_sha(str(i).encode()) for i in range(10_000)]
859 result = _negotiate_have(t, "http://x", None, ["want"], commits)
860
861 assert result == commits # full fallback
862 expected_rounds = (len(commits) + NEGOTIATE_DEPTH - 1) // NEGOTIATE_DEPTH
863 assert call_count == expected_rounds
864
865 @pytest.mark.slow
866 def test_concurrent_negotiate_have(self) -> None:
867 """Concurrent _negotiate_have calls on isolated state must not interfere."""
868 from muse.core.transport import negotiate_have as _negotiate_have
869
870 errors: list[str] = []
871
872 def run_one(idx: int) -> None:
873 commits = [_sha(f"{idx}-{i}".encode()) for i in range(50)]
874
875 call_n = 0
876 def ready_second(url: str, token: str | None, want: list[str], have: list[str]) -> "NegotiateResponse":
877 nonlocal call_n
878 call_n += 1
879 return {"ready": call_n >= 2, "ack": have, "common_base": None}
880
881 t = MagicMock()
882 t.negotiate.side_effect = ready_second
883 result = _negotiate_have(t, "http://x", None, [f"want-{idx}"], commits)
884 if not result:
885 errors.append(f"worker {idx}: empty result")
886
887 threads = [threading.Thread(target=run_one, args=(i,)) for i in range(16)]
888 for th in threads:
889 th.start()
890 for th in threads:
891 th.join()
892 assert not errors, f"Concurrent errors: {errors}"
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago