gabriel / muse public
test_cmd_pull_hardening.py python
891 lines 41.5 KB
Raw
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 155 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 = "g" * 64
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": "h" * 64},
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 with patch("muse.cli.commands.pull.apply_pack", return_value={
509 "commits_written": 1, "snapshots_written": 1,
510 "objects_written": 2, "objects_skipped": 0,
511 }):
512 with patch("muse.cli.commands.pull.set_remote_head"):
513 # ours_commit_id is None → bootstrap path
514 with patch("muse.cli.commands.pull.get_head_commit_id", return_value=None):
515 with patch("muse.cli.commands.pull.read_repo_id", return_value="test-repo"):
516 with patch("muse.cli.commands.pull.read_commit", return_value=fake_commit):
517 with patch("muse.cli.commands.pull.read_snapshot", return_value=fake_snap):
518 with patch(
519 "muse.cli.commands.pull.apply_manifest",
520 side_effect=lambda *a, **kw: call_order.append("apply"),
521 ):
522 with patch(
523 "muse.cli.commands.pull.write_branch_ref",
524 side_effect=lambda *a, **kw: call_order.append("write_ref"),
525 ):
526 runner.invoke(cli, ["pull"], env=_env(repo))
527
528 assert "apply" in call_order, "apply_manifest must be called in bootstrap path"
529 assert "write_ref" in call_order, "write_branch_ref must be called in bootstrap path"
530 assert call_order.index("apply") < call_order.index("write_ref"), (
531 "apply_manifest must happen BEFORE write_branch_ref in bootstrap path"
532 )
533
534
535 class TestFFOnly:
536 def test_ff_only_diverged_exits_1(self, repo: pathlib.Path) -> None:
537 from muse.core.store import get_head_commit_id
538 local_head = get_head_commit_id(repo, "main") or ""
539 remote_cid = "d" * 64
540
541 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
542 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
543 with patch("muse.cli.commands.pull.make_transport") as mt:
544 mt.return_value.fetch_remote_info.return_value = _make_remote_info({"main": remote_cid})
545 mt.return_value.negotiate.return_value = {"ready": True, "ack": [local_head], "common_base": None}
546 mt.return_value.fetch_pack.return_value = _make_bundle()
547 with patch("muse.cli.commands.pull.apply_pack", return_value={
548 "commits_written": 1, "snapshots_written": 1,
549 "objects_written": 1, "objects_skipped": 0
550 }):
551 with patch("muse.cli.commands.pull.set_remote_head"):
552 # Simulate diverged: merge_base is neither ours nor theirs
553 with patch("muse.cli.commands.pull.find_merge_base", return_value="e" * 64):
554 r = runner.invoke(cli, ["pull", "--ff-only"], env=_env(repo))
555
556 assert r.exit_code == 1
557 assert "fast-forward" in (r.stderr or "").lower()
558
559 def test_ff_only_fast_forward_succeeds(self, repo: pathlib.Path) -> None:
560 from muse.core.store import get_head_commit_id
561 local_head = get_head_commit_id(repo, "main") or ""
562 remote_cid = "f" * 64
563
564 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
565 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
566 with patch("muse.cli.commands.pull.make_transport") as mt:
567 mt.return_value.fetch_remote_info.return_value = _make_remote_info({"main": remote_cid})
568 mt.return_value.negotiate.return_value = {"ready": True, "ack": [local_head], "common_base": None}
569 mt.return_value.fetch_pack.return_value = _make_bundle()
570 with patch("muse.cli.commands.pull.apply_pack", return_value={
571 "commits_written": 1, "snapshots_written": 1,
572 "objects_written": 1, "objects_skipped": 0
573 }):
574 with patch("muse.cli.commands.pull.set_remote_head"):
575 with patch("muse.cli.commands.pull.find_merge_base", return_value=local_head):
576 fake_commit = MagicMock()
577 fake_commit.snapshot_id = "a" * 64
578 fake_snap = MagicMock()
579 fake_snap.manifest = {}
580 with patch("muse.cli.commands.pull.read_commit", return_value=fake_commit):
581 with patch("muse.cli.commands.pull.read_snapshot", return_value=fake_snap):
582 with patch("muse.cli.commands.pull.apply_manifest"):
583 with patch("muse.cli.commands.pull.write_branch_ref"):
584 r = runner.invoke(cli, ["pull", "--ff-only"], env=_env(repo))
585
586 assert r.exit_code == 0, r.output
587
588
589 class TestCommitsReceivedFromApplyResult:
590 def test_commits_received_uses_commits_written(self, repo: pathlib.Path) -> None:
591 """commits_received in JSON must come from apply_pack result, not bundle length."""
592 remote_cid = "g" * 64
593 info = _make_remote_info({"main": remote_cid})
594 ar = {"commits_written": 7, "snapshots_written": 7,
595 "objects_written": 21, "objects_skipped": 0}
596
597 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
598 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
599 with patch("muse.cli.commands.pull.make_transport") as mt:
600 mt.return_value.fetch_remote_info.return_value = info
601 mt.return_value.negotiate.return_value = {"ready": True, "ack": [], "common_base": None}
602 mt.return_value.fetch_pack.return_value = _make_bundle()
603 with patch("muse.cli.commands.pull.apply_pack", return_value=ar):
604 with patch("muse.cli.commands.pull.set_remote_head"):
605 r = runner.invoke(
606 cli, ["pull", "--no-merge", "--json"], env=_env(repo)
607 )
608 assert r.exit_code == 0, r.output
609 d = _json_line(r)
610 assert d["commits_received"] == 7
611 assert d["objects_written"] == 21
612
613
614 # ---------------------------------------------------------------------------
615 # End-to-end with file:// transport
616 # ---------------------------------------------------------------------------
617
618 @pytest.fixture()
619 def two_repos(
620 tmp_path: pathlib.Path,
621 monkeypatch: pytest.MonkeyPatch,
622 ) -> tuple[pathlib.Path, pathlib.Path]:
623 """Return (local, remote) pair — local already has remote configured."""
624 local = tmp_path / "local"
625 remote = tmp_path / "remote"
626 local.mkdir()
627 remote.mkdir()
628
629 from muse._version import __version__
630 from muse.core.object_store import write_object
631 from muse.core.snapshot import compute_commit_id, compute_snapshot_id
632 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
633
634 def _scaffold(root: pathlib.Path, msg: str, content: bytes) -> str:
635 muse = root / ".muse"
636 for sub in ("refs/heads", "objects", "commits", "snapshots"):
637 (muse / sub).mkdir(parents=True, exist_ok=True)
638 (muse / "repo.json").write_text(
639 json.dumps({"repo_id": "e2e-repo", "schema_version": __version__, "domain": "code"})
640 )
641 (muse / "HEAD").write_text("ref: refs/heads/main\n")
642 blob = content
643 oid = _sha(blob)
644 write_object(root, oid, blob)
645 snap_id = compute_snapshot_id({"a.py": oid})
646 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest={"a.py": oid}))
647 ts = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc)
648 cid = compute_commit_id([], snap_id, msg, ts.isoformat())
649 write_commit(root, CommitRecord(
650 commit_id=cid, repo_id="e2e-repo", branch="main",
651 snapshot_id=snap_id, message=msg, committed_at=ts,
652 ))
653 (muse / "refs" / "heads" / "main").write_text(cid)
654 (muse / "config.toml").write_text(f'[remotes.origin]\nurl = "file://{root}"\n')
655 return cid
656
657 _scaffold(remote, "remote-base", b"x = 1\n")
658 _scaffold(local, "local-base", b"x = 1\n")
659 # Point local's remote at the remote repo
660 (local / ".muse" / "config.toml").write_text(
661 f'[remotes.origin]\nurl = "file://{remote}"\n'
662 )
663
664 monkeypatch.chdir(local)
665 monkeypatch.setenv("MUSE_REPO_ROOT", str(local))
666 return local, remote
667
668
669 class TestEndToEnd:
670 def test_pull_no_merge_fetches(self, two_repos: tuple[pathlib.Path, pathlib.Path]) -> None:
671 local, remote = two_repos
672 r = runner.invoke(cli, ["pull", "--no-merge"], env=_env(local), catch_exceptions=False)
673 assert r.exit_code == 0, r.output
674
675 def test_pull_json_fetched_schema(self, two_repos: tuple[pathlib.Path, pathlib.Path]) -> None:
676 local, remote = two_repos
677 r = runner.invoke(
678 cli, ["pull", "--no-merge", "--json"],
679 env=_env(local), catch_exceptions=False,
680 )
681 assert r.exit_code == 0, r.output
682 d = _json_line(r)
683 assert _REQUIRED.KEYS <= d.keys()
684 assert d["status"] == "fetched"
685
686 def test_dry_run_no_side_effects(self, two_repos: tuple[pathlib.Path, pathlib.Path]) -> None:
687 local, remote = two_repos
688 # Record state before dry run
689 from muse.core.store import get_head_commit_id
690 head_before = get_head_commit_id(local, "main")
691 r = runner.invoke(
692 cli, ["pull", "--dry-run"],
693 env=_env(local), catch_exceptions=False,
694 )
695 assert r.exit_code == 0, r.output
696 head_after = get_head_commit_id(local, "main")
697 assert head_before == head_after, "dry-run must not advance local HEAD"
698
699 def test_dry_run_json_schema(self, two_repos: tuple[pathlib.Path, pathlib.Path]) -> None:
700 local, remote = two_repos
701 r = runner.invoke(
702 cli, ["pull", "--dry-run", "--json"],
703 env=_env(local), catch_exceptions=False,
704 )
705 assert r.exit_code == 0, r.output
706 d = _json_line(r)
707 assert _REQUIRED.KEYS <= d.keys()
708 assert d["dry_run"] is True
709
710 def test_ff_only_refuses_diverged(self, two_repos: tuple[pathlib.Path, pathlib.Path]) -> None:
711 """When local and remote have diverged, --ff-only must exit non-zero."""
712 local, remote = two_repos
713 # Advance remote past local (add a new commit on remote)
714 from muse.core.store import get_head_commit_id
715 import muse.core.snapshot as snap_mod
716 import muse.core.store as store_mod
717 remote_head = get_head_commit_id(remote, "main") or ""
718 # Write a new commit on the remote with a different snapshot
719 from muse.core.object_store import write_object
720 blob = b"x = 2\n"
721 oid = _sha(blob)
722 write_object(remote, oid, blob)
723 snap_id = snap_mod.compute_snapshot_id({"a.py": oid})
724 store_mod.write_snapshot(remote, store_mod.SnapshotRecord(snapshot_id=snap_id, manifest={"a.py": oid}))
725 ts = datetime.datetime(2026, 1, 2, tzinfo=datetime.timezone.utc)
726 cid = snap_mod.compute_commit_id([remote_head], snap_id, "remote-advance", ts.isoformat())
727 store_mod.write_commit(remote, store_mod.CommitRecord(
728 commit_id=cid, repo_id="e2e-repo", branch="main",
729 snapshot_id=snap_id, message="remote-advance", committed_at=ts,
730 parent_commit_id=remote_head,
731 ))
732 (remote / ".muse" / "refs" / "heads" / "main").write_text(cid)
733 # Also diverge local (add a local-only commit)
734 local_head = get_head_commit_id(local, "main") or ""
735 blob2 = b"y = 1\n"
736 oid2 = _sha(blob2)
737 write_object(local, oid2, blob2)
738 snap_id2 = snap_mod.compute_snapshot_id({"b.py": oid2})
739 store_mod.write_snapshot(local, store_mod.SnapshotRecord(snapshot_id=snap_id2, manifest={"b.py": oid2}))
740 ts2 = datetime.datetime(2026, 1, 2, tzinfo=datetime.timezone.utc)
741 cid2 = snap_mod.compute_commit_id([local_head], snap_id2, "local-advance", ts2.isoformat())
742 store_mod.write_commit(local, store_mod.CommitRecord(
743 commit_id=cid2, repo_id="e2e-repo", branch="main",
744 snapshot_id=snap_id2, message="local-advance", committed_at=ts2,
745 parent_commit_id=local_head,
746 ))
747 (local / ".muse" / "refs" / "heads" / "main").write_text(cid2)
748
749 r = runner.invoke(cli, ["pull", "--ff-only"], env=_env(local))
750 assert r.exit_code == 1
751 assert "fast-forward" in (r.stderr or "").lower()
752
753
754 # ---------------------------------------------------------------------------
755 # Security
756 # ---------------------------------------------------------------------------
757
758 class TestSecurity:
759 def test_remote_name_ansi_sanitized(self, repo: pathlib.Path) -> None:
760 ansi = "\x1b[31mevil\x1b[0m"
761 r = runner.invoke(cli, ["pull", ansi], env=_env(repo))
762 assert r.exit_code != 0
763 assert "\x1b[31m" not in (r.stderr or "")
764
765 def test_branch_name_sanitized_in_not_found(self, repo: pathlib.Path) -> None:
766 info = _make_remote_info({"main": "a" * 64})
767 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
768 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
769 with patch("muse.cli.commands.pull.make_transport") as mt:
770 mt.return_value.fetch_remote_info.return_value = info
771 r = runner.invoke(
772 cli, ["pull", "origin", "--branch", "\x1b[31mevil\x1b[0m"],
773 env=_env(repo),
774 )
775 assert "\x1b[31m" not in (r.stderr or "")
776 assert "\x1b[31m" not in r.output
777
778 def test_progress_not_in_stdout_on_json(self, repo: pathlib.Path) -> None:
779 """--json: stdout must contain exactly one JSON line, no mixed progress."""
780 from muse.core.store import get_head_commit_id
781 head = get_head_commit_id(repo, "main") or ""
782 info = _make_remote_info({"main": head})
783 with patch("muse.cli.commands.pull.get_remote", return_value="https://hub"):
784 with patch("muse.cli.commands.pull.get_signing_identity", return_value=None):
785 with patch("muse.cli.commands.pull.make_transport") as mt:
786 mt.return_value.fetch_remote_info.return_value = info
787 with patch("muse.cli.commands.pull.get_remote_head", return_value=head):
788 r = runner.invoke(cli, ["pull", "--json"], env=_env(repo))
789 json_lines = [l for l in r.output.splitlines() if l.strip().startswith("{")]
790 assert len(json_lines) == 1
791 json.loads(json_lines[0]) # must be valid JSON
792
793 def test_invalid_format_exits_to_stderr(self, repo: pathlib.Path) -> None:
794 r = runner.invoke(cli, ["pull", "--format", "yaml"], env=_env(repo))
795 assert r.exit_code == 1
796 assert "yaml" in (r.stderr or "").lower()
797
798 def test_conflict_paths_sanitized_in_text(self, repo: pathlib.Path) -> None:
799 """File paths in CONFLICT lines must be run through sanitize_display."""
800 from muse.core.store import get_head_commit_id
801 local_head = get_head_commit_id(repo, "main") or ""
802 remote_cid = "h" * 64
803
804 evil_path = "\x1b[31mevil.py\x1b[0m"
805
806 from unittest.mock import MagicMock as MM
807 merge_result = MM()
808 merge_result.is_clean = False
809 merge_result.conflicts = {evil_path}
810 merge_result.applied_strategies = {}
811
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 = _make_remote_info({"main": remote_cid})
816 mt.return_value.negotiate.return_value = {"ready": True, "ack": [local_head], "common_base": None}
817 mt.return_value.fetch_pack.return_value = _make_bundle()
818 with patch("muse.cli.commands.pull.apply_pack", return_value={
819 "commits_written": 1, "snapshots_written": 1,
820 "objects_written": 1, "objects_skipped": 0
821 }):
822 with patch("muse.cli.commands.pull.set_remote_head"):
823 with patch("muse.cli.commands.pull.find_merge_base", return_value="z" * 64):
824 with patch("muse.cli.commands.pull.resolve_plugin") as rp:
825 with patch("muse.cli.commands.pull.read_domain", return_value="code"):
826 plugin = MM()
827 plugin.__class__ = type("P", (), {"merge": None})
828 rp.return_value = plugin
829 plugin.merge.return_value = merge_result
830 with patch("muse.cli.commands.pull.write_merge_state"):
831 r = runner.invoke(cli, ["pull"], env=_env(repo))
832
833 assert "\x1b[31m" not in (r.stderr or "")
834 assert "\x1b[31m" not in r.output
835
836
837 # ---------------------------------------------------------------------------
838 # Stress
839 # ---------------------------------------------------------------------------
840
841 class TestStress:
842 @pytest.mark.slow
843 def test_negotiate_have_10k_commits(self) -> None:
844 """_negotiate_have must terminate in finite rounds with 10 000 commits."""
845 from muse.core.transport import negotiate_have as _negotiate_have
846 from muse.core.transport import NEGOTIATE_DEPTH
847
848 call_count = 0
849
850 def never_ready(url: str, token: str | None, want: list[str], have: list[str]) -> "NegotiateResponse":
851 nonlocal call_count
852 call_count += 1
853 return {"ready": False, "ack": [], "common_base": None}
854
855 t = MagicMock()
856 t.negotiate.side_effect = never_ready
857 commits = [_sha(str(i).encode()) for i in range(10_000)]
858 result = _negotiate_have(t, "http://x", None, ["want"], commits)
859
860 assert result == commits # full fallback
861 expected_rounds = (len(commits) + NEGOTIATE_DEPTH - 1) // NEGOTIATE_DEPTH
862 assert call_count == expected_rounds
863
864 @pytest.mark.slow
865 def test_concurrent_negotiate_have(self) -> None:
866 """Concurrent _negotiate_have calls on isolated state must not interfere."""
867 from muse.core.transport import negotiate_have as _negotiate_have
868
869 errors: list[str] = []
870
871 def run_one(idx: int) -> None:
872 commits = [_sha(f"{idx}-{i}".encode()) for i in range(50)]
873
874 call_n = 0
875 def ready_second(url: str, token: str | None, want: list[str], have: list[str]) -> "NegotiateResponse":
876 nonlocal call_n
877 call_n += 1
878 return {"ready": call_n >= 2, "ack": have, "common_base": None}
879
880 t = MagicMock()
881 t.negotiate.side_effect = ready_second
882 result = _negotiate_have(t, "http://x", None, [f"want-{idx}"], commits)
883 if not result:
884 errors.append(f"worker {idx}: empty result")
885
886 threads = [threading.Thread(target=run_one, args=(i,)) for i in range(16)]
887 for th in threads:
888 th.start()
889 for th in threads:
890 th.join()
891 assert not errors, f"Concurrent errors: {errors}"
File History 1 commit
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 155 days ago