gabriel / muse public
test_cmd_fetch_hardening.py python
841 lines 38.1 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 133 days ago
1 """Comprehensive hardening tests for ``muse fetch``.
2
3 Coverage
4 --------
5 Unit
6 - _stale_ref_names: no-dir, all-live, stale detected, nested branches, symlink skip
7 - _prune_stale_refs: dry-run, live delete, empty-parent cleanup, return values
8 - negotiate_have (in transport): empty list, single-round ready, fallback, large-stress
9
10 Integration (mocked transport)
11 - _fetch_one: up-to-date, fetched, dry-run writes nothing, unknown remote, transport
12 error, branch missing without prune, branch missing with prune, negotiate called
13 before fetch_pack, negotiate fallback, set_remote_head after apply_mpack
14
15 Security
16 - ANSI injection in remote name stripped in stderr
17 - ANSI injection in branch name stripped in stderr
18 - available-branches list sanitized before output
19 - symlink traversal blocked in _stale_ref_names
20 - all diagnostics go to stderr, not stdout
21
22 E2E (via CliRunner)
23 - basic fetch exits 0
24 - already-up-to-date exits 0
25 - --json output schema correct
26 - --format json equivalent to --json
27 - --dry-run exits 0
28 - --dry-run --json status = "dry_run"
29 - --branch flag
30 - --branch --json carries correct branch
31 - unknown remote exits non-zero
32 - --prune flag
33 - --prune --json includes pruned list
34 - --all fetches every remote
35 - --all --json has N results
36 - --all + --branch fetches named branch from every remote
37 - --all with no remotes exits non-zero
38
39 Performance
40 - negotiate_have result used as have, not raw all_local
41 - 10 000-commit negotiation converges in 3 rounds
42
43 Stress
44 - 8 concurrent prune scans on isolated repos
45 - 8 concurrent negotiate_have calls
46 """
47
48 from __future__ import annotations
49
50 import contextlib
51 import json
52 import pathlib
53 import threading
54 from typing import TYPE_CHECKING
55 from unittest.mock import MagicMock, patch
56
57 import pytest
58
59 from tests.cli_test_helper import CliRunner, InvokeResult
60
61 if TYPE_CHECKING:
62 from muse.cli.commands.fetch import _FetchJson, _RemoteResultJson
63 from muse.core.pack import ApplyResult, MPackBundle
64 from muse.core.transport import MuseTransport, NegotiateResponse
65
66 cli = None
67 runner = CliRunner()
68
69 REMOTE_ID = "a" * 64
70 OLD_REMOTE_ID = "b" * 64
71
72 from muse.core._types import Manifest, blob_id
73
74 type _RemoteInfoMap = dict[str, str | dict[str, str]]
75
76
77 # ── typed helpers ─────────────────────────────────────────────────────────────
78
79 def _make_apply_result(
80 commits_written: int = 3,
81 objects_written: int = 7,
82 ) -> "ApplyResult":
83 from muse.core.pack import ApplyResult
84 return ApplyResult(
85 commits_written=commits_written,
86 snapshots_written=commits_written,
87 objects_written=objects_written,
88 objects_skipped=0,
89 )
90
91
92 def _make_bundle() -> "MPackBundle":
93 from muse.core.pack import MPackBundle
94 return MPackBundle(commits=[], snapshots=[], objects=[])
95
96
97 def _make_fetch_stream_result(
98 commits_count: int = 0,
99 ) -> Mapping[str, object]:
100 """Return a FetchStreamResult-compatible dict for mocking fetch_stream."""
101 from muse.core.transport import FetchStreamResult
102 return FetchStreamResult(
103 repo_id="test-repo-id",
104 domain="code",
105 default_branch="main",
106 branch_heads={"main": REMOTE_ID},
107 commits=[],
108 snapshots=[],
109 objects_received=commits_count,
110 )
111
112
113 def _make_remote_info(
114 branch_heads: Manifest | None = None,
115 ) -> _RemoteInfoMap:
116 return {
117 "repo_id": "test-repo-id",
118 "domain": "code",
119 "default_branch": "main",
120 "branch_heads": branch_heads or {"main": REMOTE_ID},
121 }
122
123
124 def _make_negotiate_response(
125 ack: list[str] | None = None,
126 ready: bool = True,
127 ) -> "NegotiateResponse":
128 return {"ack": ack or [], "common_base": None, "ready": ready}
129
130
131 def _make_transport_mock(
132 branch_heads: Manifest | None = None,
133 objects_count: int = 7,
134 ) -> MagicMock:
135 t = MagicMock()
136 t.fetch_remote_info.return_value = _make_remote_info(branch_heads)
137
138 def _fetch_stream(
139 url: str, token, want: list[str], have: list[str],
140 on_object=None, **kwargs,
141 ) -> Mapping[str, object]:
142 if callable(on_object):
143 for i in range(objects_count):
144 # Content-addressed: OID matches actual content so integrity check passes.
145 content = f"fake-blob-{i}".encode()
146 oid = blob_id(content)
147 on_object({"object_id": oid, "content": content, "path": f"f{i}.txt"})
148 return _make_fetch_stream_result()
149
150 t.fetch_stream.side_effect = _fetch_stream
151 t.negotiate.return_value = _make_negotiate_response(ready=True)
152 return t
153
154
155 def _json_line(result: InvokeResult) -> "_FetchJson":
156 """Extract the JSON object from cli_test_helper's combined output.
157
158 The test helper mixes stderr into result.output, so we scan for the first
159 line beginning with '{'.
160 """
161 for line in result.output.splitlines():
162 stripped = line.strip()
163 if stripped.startswith("{"):
164 parsed: _FetchJson = json.loads(stripped)
165 return parsed
166 raise ValueError(f"No JSON line in output:\n{result.output!r}")
167
168
169 def _init_repo(tmp_path: pathlib.Path) -> None:
170 muse_dir = tmp_path / ".muse"
171 for sub in ("objects", "commits", "snapshots", "remotes", "refs/heads", "branches"):
172 (muse_dir / sub).mkdir(parents=True, exist_ok=True)
173 (muse_dir / "HEAD").write_text("ref: refs/heads/main\n")
174 (muse_dir / "refs" / "heads" / "main").write_text("")
175 (muse_dir / "config.toml").write_text(
176 '[remotes.origin]\nurl = "http://localhost:19999"\n'
177 )
178 (muse_dir / "repo.json").write_text('{"id": "test-repo-id"}')
179
180
181 def _write_remote_ref(
182 tmp_path: pathlib.Path, remote: str, branch: str, commit_id: str
183 ) -> None:
184 ref_file = tmp_path / ".muse" / "remotes" / remote / branch
185 ref_file.parent.mkdir(parents=True, exist_ok=True)
186 ref_file.write_text(commit_id)
187
188
189 # ── Unit: _stale_ref_names ────────────────────────────────────────────────────
190
191 class TestStaleRefNames:
192 def test_no_refs_dir_returns_empty(self, tmp_path: pathlib.Path) -> None:
193 from muse.cli.commands.fetch import _stale_ref_names
194 assert _stale_ref_names(tmp_path, "origin", {"main": REMOTE_ID}) == []
195
196 def test_all_live_returns_empty(self, tmp_path: pathlib.Path) -> None:
197 from muse.cli.commands.fetch import _stale_ref_names
198 _init_repo(tmp_path)
199 _write_remote_ref(tmp_path, "origin", "main", REMOTE_ID)
200 assert _stale_ref_names(tmp_path, "origin", {"main": REMOTE_ID}) == []
201
202 def test_stale_branch_detected(self, tmp_path: pathlib.Path) -> None:
203 from muse.cli.commands.fetch import _stale_ref_names
204 _init_repo(tmp_path)
205 _write_remote_ref(tmp_path, "origin", "main", REMOTE_ID)
206 _write_remote_ref(tmp_path, "origin", "feat/old", OLD_REMOTE_ID)
207 stale = _stale_ref_names(tmp_path, "origin", {"main": REMOTE_ID})
208 assert stale == ["feat/old"]
209
210 def test_nested_branch_name_preserved(self, tmp_path: pathlib.Path) -> None:
211 """Slashes in branch names stored as nested files must round-trip correctly."""
212 from muse.cli.commands.fetch import _stale_ref_names
213 _init_repo(tmp_path)
214 _write_remote_ref(tmp_path, "origin", "feat/ui/redesign", REMOTE_ID)
215 stale = _stale_ref_names(tmp_path, "origin", {})
216 assert "feat/ui/redesign" in stale
217
218 def test_symlinks_skipped(self, tmp_path: pathlib.Path) -> None:
219 """Symlinks inside the refs dir must not be followed (path-traversal guard)."""
220 from muse.cli.commands.fetch import _stale_ref_names
221 _init_repo(tmp_path)
222 refs_dir = tmp_path / ".muse" / "remotes" / "origin"
223 refs_dir.mkdir(parents=True, exist_ok=True)
224 target = tmp_path / "outside.txt"
225 target.write_text("sensitive")
226 (refs_dir / "evil-link").symlink_to(target)
227 stale = _stale_ref_names(tmp_path, "origin", {})
228 assert "evil-link" not in stale
229
230
231 # ── Unit: _prune_stale_refs ───────────────────────────────────────────────────
232
233 class TestPruneStaleRefs:
234 def test_dry_run_does_not_delete(
235 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
236 ) -> None:
237 from muse.cli.commands.fetch import _prune_stale_refs
238 _init_repo(tmp_path)
239 _write_remote_ref(tmp_path, "origin", "dead-branch", OLD_REMOTE_ID)
240 pruned = _prune_stale_refs(tmp_path, "origin", {}, dry_run=True)
241 assert pruned == ["origin/dead-branch"]
242 assert (tmp_path / ".muse" / "remotes" / "origin" / "dead-branch").exists()
243 assert "Would prune" in capsys.readouterr().err
244
245 def test_live_delete_removes_file(
246 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
247 ) -> None:
248 from muse.cli.commands.fetch import _prune_stale_refs
249 _init_repo(tmp_path)
250 _write_remote_ref(tmp_path, "origin", "dead-branch", OLD_REMOTE_ID)
251 pruned = _prune_stale_refs(tmp_path, "origin", {}, dry_run=False)
252 assert pruned == ["origin/dead-branch"]
253 assert not (tmp_path / ".muse" / "remotes" / "origin" / "dead-branch").exists()
254 assert "[deleted]" in capsys.readouterr().err
255
256 def test_empty_parent_dirs_removed(self, tmp_path: pathlib.Path) -> None:
257 from muse.cli.commands.fetch import _prune_stale_refs
258 _init_repo(tmp_path)
259 _write_remote_ref(tmp_path, "origin", "feat/old-thing", OLD_REMOTE_ID)
260 _prune_stale_refs(tmp_path, "origin", {}, dry_run=False)
261 assert not (tmp_path / ".muse" / "remotes" / "origin" / "feat").exists()
262
263 def test_returns_qualified_remote_branch_names(self, tmp_path: pathlib.Path) -> None:
264 from muse.cli.commands.fetch import _prune_stale_refs
265 _init_repo(tmp_path)
266 _write_remote_ref(tmp_path, "origin", "stale-a", OLD_REMOTE_ID)
267 _write_remote_ref(tmp_path, "origin", "stale-b", OLD_REMOTE_ID)
268 pruned = _prune_stale_refs(tmp_path, "origin", {}, dry_run=False)
269 assert "origin/stale-a" in pruned
270 assert "origin/stale-b" in pruned
271
272 def test_no_refs_dir_is_noop(self, tmp_path: pathlib.Path) -> None:
273 from muse.cli.commands.fetch import _prune_stale_refs
274 _init_repo(tmp_path)
275 assert _prune_stale_refs(tmp_path, "no-remote", {}, dry_run=False) == []
276
277 def test_output_goes_to_stderr_not_stdout(
278 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
279 ) -> None:
280 from muse.cli.commands.fetch import _prune_stale_refs
281 _init_repo(tmp_path)
282 _write_remote_ref(tmp_path, "origin", "dead", OLD_REMOTE_ID)
283 _prune_stale_refs(tmp_path, "origin", {}, dry_run=False)
284 assert capsys.readouterr().out == ""
285
286
287 # ── Unit: negotiate_have ──────────────────────────────────────────────────────
288
289 class TestNegotiateHave:
290 def _make_transport(
291 self,
292 ready_after: int = 1,
293 ack_ids: list[str] | None = None,
294 ) -> MagicMock:
295 call_count = 0
296
297 def negotiate(
298 url: str, token: str | None, want: list[str], have: list[str]
299 ) -> "NegotiateResponse":
300 nonlocal call_count
301 call_count += 1
302 return {"ack": ack_ids or have, "common_base": None, "ready": call_count >= ready_after}
303
304 t = MagicMock()
305 t.negotiate.side_effect = negotiate
306 return t
307
308 def test_empty_local_returns_empty_no_network(self) -> None:
309 from muse.core.transport import negotiate_have
310 transport = self._make_transport()
311 assert negotiate_have(transport, "http://x", None, ["want"], []) == []
312 transport.negotiate.assert_not_called()
313
314 def test_single_round_ready_returns_ack(self) -> None:
315 from muse.core.transport import negotiate_have
316 transport = self._make_transport(ready_after=1, ack_ids=["common"])
317 result = negotiate_have(transport, "http://x", None, ["want"], ["c1", "c2"])
318 assert result == ["common"]
319
320 def test_falls_back_to_full_list_when_never_ready(self) -> None:
321 from muse.core.transport import negotiate_have, NEGOTIATE_DEPTH
322 transport = self._make_transport(ready_after=999)
323 all_local = [f"c{i}" for i in range(NEGOTIATE_DEPTH + 5)]
324 result = negotiate_have(transport, "http://x", None, ["want"], all_local)
325 assert result == all_local
326
327 def test_stress_10k_commits_3_rounds(self) -> None:
328 """10 000-commit history must converge in exactly 3 rounds."""
329 from muse.core.transport import negotiate_have
330 transport = self._make_transport(ready_after=3)
331 all_local = [f"c{i}" for i in range(10_000)]
332 result = negotiate_have(transport, "http://x", None, ["want"], all_local)
333 assert len(result) > 0
334 assert transport.negotiate.call_count == 3
335
336
337 # ── Integration: _fetch_one ───────────────────────────────────────────────────
338
339 class TestFetchOne:
340 def _patches(
341 self,
342 already_known: str | None = None,
343 branch_heads: Manifest | None = None,
344 apply_result: "ApplyResult | None" = None,
345 objects_count: int = 7,
346 ) -> contextlib.ExitStack:
347 stack = contextlib.ExitStack()
348 transport = _make_transport_mock(branch_heads or {"main": REMOTE_ID}, objects_count=objects_count)
349 stack.enter_context(patch("muse.cli.commands.fetch.get_remote", return_value="http://localhost:19999"))
350 stack.enter_context(patch("muse.cli.commands.fetch.get_signing_identity", return_value=None))
351 stack.enter_context(patch("muse.cli.commands.fetch.make_transport", return_value=transport))
352 stack.enter_context(patch("muse.cli.commands.fetch.get_remote_head", return_value=already_known))
353 stack.enter_context(patch("muse.cli.commands.fetch.set_remote_head"))
354 stack.enter_context(patch("muse.cli.commands.fetch.apply_mpack", return_value=apply_result or _make_apply_result()))
355 stack.enter_context(patch("muse.cli.commands.fetch.get_all_commits", return_value=[]))
356 stack.enter_context(patch("muse.cli.commands.fetch.negotiate_have", return_value=[]))
357 # write_object returns True when the object is new (written), enabling objects_written counting
358 stack.enter_context(patch("muse.cli.commands.fetch.write_object", return_value=True))
359 return stack
360
361 def test_up_to_date_status(self, tmp_path: pathlib.Path) -> None:
362 from muse.cli.commands.fetch import _fetch_one
363 with self._patches(already_known=REMOTE_ID):
364 result = _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=False)
365 assert result["status"] == "up_to_date"
366 assert result["commits_received"] == 0
367
368 def test_fetched_status(self, tmp_path: pathlib.Path) -> None:
369 from muse.cli.commands.fetch import _fetch_one
370 with self._patches():
371 result = _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=False)
372 assert result["status"] == "fetched"
373 assert result["commits_received"] == 3
374 assert result["objects_written"] == 7
375
376 def test_commits_received_from_apply_result_not_bundle(self, tmp_path: pathlib.Path) -> None:
377 """Regression: use apply_result['commits_written'], not len(bundle['commits'])."""
378 from muse.cli.commands.fetch import _fetch_one
379 with self._patches(apply_result=_make_apply_result(commits_written=5, objects_written=12), objects_count=12):
380 result = _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=False)
381 assert result["commits_received"] == 5
382 assert result["objects_written"] == 12
383
384 def test_dry_run_does_not_write(self, tmp_path: pathlib.Path) -> None:
385 from muse.cli.commands.fetch import _fetch_one
386 set_mock = MagicMock()
387 with self._patches() as stack:
388 stack.enter_context(patch("muse.cli.commands.fetch.set_remote_head", set_mock))
389 result = _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=True)
390 assert result["status"] == "dry_run"
391
392 def test_unknown_remote_exits_user_error(self, tmp_path: pathlib.Path) -> None:
393 from muse.cli.commands.fetch import _fetch_one
394 from muse.core.errors import ExitCode
395 with patch("muse.cli.commands.fetch.get_remote", return_value=None):
396 with pytest.raises(SystemExit) as exc:
397 _fetch_one(tmp_path, "no-such", "main", prune=False, dry_run=False)
398 assert exc.value.code == ExitCode.USER_ERROR
399
400 def test_branch_missing_without_prune_exits(self, tmp_path: pathlib.Path) -> None:
401 from muse.cli.commands.fetch import _fetch_one
402 with self._patches(branch_heads={"dev": REMOTE_ID}):
403 with pytest.raises(SystemExit):
404 _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=False)
405
406 def test_branch_missing_with_prune_returns_branch_missing(self, tmp_path: pathlib.Path) -> None:
407 from muse.cli.commands.fetch import _fetch_one
408 with self._patches(branch_heads={"dev": REMOTE_ID}):
409 result = _fetch_one(tmp_path, "origin", "main", prune=True, dry_run=False)
410 assert result["status"] == "branch_missing"
411
412 def test_negotiate_called_before_fetch_stream(self, tmp_path: pathlib.Path) -> None:
413 """MWP negotiation must precede fetch_stream to minimise wire transfer."""
414 from muse.cli.commands.fetch import _fetch_one
415 call_order: list[str] = []
416
417 def _neg(
418 _t: "MuseTransport", _url: str, _token: str | None,
419 _want: list[str], _all: list[str],
420 ) -> list[str]:
421 call_order.append("negotiate_have")
422 return ["common"]
423
424 transport = MagicMock()
425 transport.fetch_remote_info.return_value = _make_remote_info({"main": REMOTE_ID})
426
427 def _fs(
428 url: str, token: str | None, want: list[str], have: list[str], **kwargs,
429 ) -> Mapping[str, object]:
430 call_order.append("fetch_stream")
431 return _make_fetch_stream_result()
432
433 transport.fetch_stream.side_effect = _fs
434
435 with (
436 patch("muse.cli.commands.fetch.get_remote", return_value="http://x"),
437 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
438 patch("muse.cli.commands.fetch.make_transport", return_value=transport),
439 patch("muse.cli.commands.fetch.get_remote_head", return_value=None),
440 patch("muse.cli.commands.fetch.set_remote_head"),
441 patch("muse.cli.commands.fetch.apply_mpack", return_value=_make_apply_result()),
442 patch("muse.cli.commands.fetch.get_all_commits", return_value=[]),
443 patch("muse.cli.commands.fetch.negotiate_have", _neg),
444 ):
445 _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=False)
446
447 assert call_order.index("negotiate_have") < call_order.index("fetch_stream")
448
449 def test_negotiate_failure_falls_back_to_full_have(self, tmp_path: pathlib.Path) -> None:
450 """If negotiate_have raises TransportError the full local list is used."""
451 from muse.cli.commands.fetch import _fetch_one
452 from muse.core.transport import TransportError
453
454 local_commits = [MagicMock(commit_id=f"c{i}") for i in range(5)]
455 captured_have: list[list[str]] = []
456 transport = MagicMock()
457 transport.fetch_remote_info.return_value = _make_remote_info({"main": REMOTE_ID})
458
459 def _fs(url: str, token: str | None, want: list[str], have: list[str], **kwargs) -> Mapping[str, object]:
460 captured_have.append(have)
461 return _make_fetch_stream_result()
462
463 transport.fetch_stream.side_effect = _fs
464
465 def _neg_raise(
466 _t: "MuseTransport", _url: str, _token: str | None,
467 _want: list[str], _all: list[str],
468 ) -> list[str]:
469 raise TransportError("negotiate not supported", 501)
470
471 with (
472 patch("muse.cli.commands.fetch.get_remote", return_value="http://x"),
473 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
474 patch("muse.cli.commands.fetch.make_transport", return_value=transport),
475 patch("muse.cli.commands.fetch.get_remote_head", return_value=None),
476 patch("muse.cli.commands.fetch.set_remote_head"),
477 patch("muse.cli.commands.fetch.apply_mpack", return_value=_make_apply_result()),
478 patch("muse.cli.commands.fetch.get_all_commits", return_value=local_commits),
479 patch("muse.cli.commands.fetch.negotiate_have", _neg_raise),
480 ):
481 _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=False)
482
483 assert captured_have[0] == [f"c{i}" for i in range(5)]
484
485 def test_set_remote_head_called_after_apply_pack(self, tmp_path: pathlib.Path) -> None:
486 """Remote tracking pointer must only advance after apply_mpack succeeds."""
487 from muse.cli.commands.fetch import _fetch_one
488 call_order: list[str] = []
489
490 def _apply(_root: pathlib.Path, _bundle: "MPackBundle") -> "ApplyResult":
491 call_order.append("apply_mpack")
492 return _make_apply_result()
493
494 def _set_head(
495 remote_name: str, branch: str, commit_id: str,
496 repo_root: pathlib.Path | None = None,
497 ) -> None:
498 call_order.append("set_remote_head")
499
500 with (
501 patch("muse.cli.commands.fetch.get_remote", return_value="http://x"),
502 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
503 patch("muse.cli.commands.fetch.make_transport", return_value=_make_transport_mock()),
504 patch("muse.cli.commands.fetch.get_remote_head", return_value=None),
505 patch("muse.cli.commands.fetch.apply_mpack", _apply),
506 patch("muse.cli.commands.fetch.set_remote_head", _set_head),
507 patch("muse.cli.commands.fetch.get_all_commits", return_value=[]),
508 patch("muse.cli.commands.fetch.negotiate_have", return_value=[]),
509 patch("muse.cli.commands.fetch.write_object", return_value=True),
510 ):
511 _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=False)
512
513 assert call_order.index("apply_mpack") < call_order.index("set_remote_head")
514
515
516 # ── Security ──────────────────────────────────────────────────────────────────
517
518 class TestSecurity:
519 def test_ansi_in_remote_name_stripped(
520 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
521 ) -> None:
522 from muse.cli.commands.fetch import _fetch_one
523 evil = "\x1b[31mEVIL\x1b[0m"
524 with patch("muse.cli.commands.fetch.get_remote", return_value=None):
525 with pytest.raises(SystemExit):
526 _fetch_one(tmp_path, evil, "main", prune=False, dry_run=False)
527 assert "\x1b[" not in capsys.readouterr().err
528
529 def test_ansi_in_branch_name_stripped(
530 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
531 ) -> None:
532 from muse.cli.commands.fetch import _fetch_one
533 evil_branch = "\x1b[31mHACKED\x1b[0m"
534 with (
535 patch("muse.cli.commands.fetch.get_remote", return_value="http://x"),
536 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
537 patch("muse.cli.commands.fetch.make_transport", return_value=_make_transport_mock({"main": REMOTE_ID})),
538 ):
539 with pytest.raises(SystemExit):
540 _fetch_one(tmp_path, "origin", evil_branch, prune=False, dry_run=False)
541 assert "\x1b[" not in capsys.readouterr().err
542
543 def test_available_branches_sanitized_in_error(
544 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
545 ) -> None:
546 """Branch names returned by the remote must be sanitized before printing."""
547 from muse.cli.commands.fetch import _fetch_one
548 evil_branch = "\x1b[32mhijacked\x1b[0m"
549 with (
550 patch("muse.cli.commands.fetch.get_remote", return_value="http://x"),
551 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
552 patch("muse.cli.commands.fetch.make_transport", return_value=_make_transport_mock({evil_branch: REMOTE_ID})),
553 ):
554 with pytest.raises(SystemExit):
555 _fetch_one(tmp_path, "origin", "no-such", prune=False, dry_run=False)
556 assert "\x1b[" not in capsys.readouterr().err
557
558 def test_symlink_traversal_blocked_in_stale_ref_names(
559 self, tmp_path: pathlib.Path
560 ) -> None:
561 from muse.cli.commands.fetch import _stale_ref_names
562 _init_repo(tmp_path)
563 refs_dir = tmp_path / ".muse" / "remotes" / "origin"
564 refs_dir.mkdir(parents=True, exist_ok=True)
565 (tmp_path / "secret.txt").write_text("top-secret")
566 (refs_dir / "evil").symlink_to(tmp_path / "secret.txt")
567 assert "evil" not in _stale_ref_names(tmp_path, "origin", {})
568
569 def test_all_diagnostics_go_to_stderr_not_stdout(
570 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
571 ) -> None:
572 from muse.cli.commands.fetch import _fetch_one
573 with (
574 patch("muse.cli.commands.fetch.get_remote", return_value="http://x"),
575 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
576 patch("muse.cli.commands.fetch.make_transport", return_value=_make_transport_mock()),
577 patch("muse.cli.commands.fetch.get_remote_head", return_value=None),
578 patch("muse.cli.commands.fetch.set_remote_head"),
579 patch("muse.cli.commands.fetch.apply_mpack", return_value=_make_apply_result()),
580 patch("muse.cli.commands.fetch.get_all_commits", return_value=[]),
581 patch("muse.cli.commands.fetch.negotiate_have", return_value=[]),
582 patch("muse.cli.commands.fetch.write_object", return_value=True),
583 ):
584 _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=False)
585 assert capsys.readouterr().out == ""
586
587
588 # ── E2E: CLI via CliRunner ────────────────────────────────────────────────────
589
590 def _invoke(*args: str, branch_heads: Manifest | None = None) -> InvokeResult:
591 """Invoke ``muse fetch`` with all transport-layer functions mocked."""
592 transport = _make_transport_mock(branch_heads)
593 with (
594 patch("muse.cli.commands.fetch.require_repo", return_value=pathlib.Path("/fake")),
595 patch("muse.cli.commands.fetch.read_current_branch", return_value="main"),
596 patch("muse.cli.commands.fetch.get_remote", return_value="http://localhost:19999"),
597 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
598 patch("muse.cli.commands.fetch.make_transport", return_value=transport),
599 patch("muse.cli.commands.fetch.get_remote_head", return_value=None),
600 patch("muse.cli.commands.fetch.set_remote_head"),
601 patch("muse.cli.commands.fetch.apply_mpack", return_value=_make_apply_result()),
602 patch("muse.cli.commands.fetch.get_all_commits", return_value=[]),
603 patch("muse.cli.commands.fetch.negotiate_have", return_value=[]),
604 patch("muse.cli.commands.fetch.write_object", return_value=True),
605 ):
606 return runner.invoke(cli, ["fetch", *args])
607
608
609 class TestCLIFetch:
610 def test_basic_fetch_exits_zero(self) -> None:
611 assert _invoke().exit_code == 0
612
613 def test_already_up_to_date_exits_zero(self) -> None:
614 with (
615 patch("muse.cli.commands.fetch.require_repo", return_value=pathlib.Path("/fake")),
616 patch("muse.cli.commands.fetch.read_current_branch", return_value="main"),
617 patch("muse.cli.commands.fetch.get_remote", return_value="http://localhost:19999"),
618 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
619 patch("muse.cli.commands.fetch.make_transport", return_value=_make_transport_mock()),
620 patch("muse.cli.commands.fetch.get_remote_head", return_value=REMOTE_ID),
621 patch("muse.cli.commands.fetch.set_remote_head"),
622 patch("muse.cli.commands.fetch.apply_mpack", return_value=_make_apply_result()),
623 patch("muse.cli.commands.fetch.get_all_commits", return_value=[]),
624 patch("muse.cli.commands.fetch.negotiate_have", return_value=[]),
625 ):
626 result = runner.invoke(cli, ["fetch"])
627 assert result.exit_code == 0
628
629 def test_json_schema_complete(self) -> None:
630 result = _invoke("--json")
631 assert result.exit_code == 0
632 data = _json_line(result)
633 assert "results" in data
634 assert "dry_run" in data
635 r = data["results"][0]
636 for key in ("remote", "branch", "status", "commits_received", "objects_written", "head", "pruned", "dry_run"):
637 assert key in r, f"Missing key: {key}"
638 assert r["status"] in {"fetched", "up_to_date", "dry_run", "branch_missing"}
639
640 def test_json_flag_produces_valid_json(self) -> None:
641 data = _json_line(_invoke("--json"))
642 assert "exit_code" in data
643
644 def test_dry_run_exits_zero(self) -> None:
645 assert _invoke("--dry-run").exit_code == 0
646
647 def test_dry_run_json_status(self) -> None:
648 result = _invoke("--dry-run", "--json")
649 assert result.exit_code == 0
650 data = _json_line(result)
651 assert data["dry_run"] is True
652 assert data["results"][0]["status"] == "dry_run"
653
654 def test_branch_flag(self) -> None:
655 assert _invoke("--branch", "dev", branch_heads={"dev": REMOTE_ID}).exit_code == 0
656
657 def test_branch_flag_json_carries_branch(self) -> None:
658 result = _invoke("--branch", "dev", "--json", branch_heads={"dev": REMOTE_ID})
659 assert result.exit_code == 0
660 assert _json_line(result)["results"][0]["branch"] == "dev"
661
662 def test_unknown_remote_exits_nonzero(self) -> None:
663 with (
664 patch("muse.cli.commands.fetch.require_repo", return_value=pathlib.Path("/fake")),
665 patch("muse.cli.commands.fetch.read_current_branch", return_value="main"),
666 patch("muse.cli.commands.fetch.get_remote", return_value=None),
667 ):
668 result = runner.invoke(cli, ["fetch", "no-such-remote"])
669 assert result.exit_code != 0
670
671 def test_prune_flag_succeeds(self) -> None:
672 assert _invoke("--prune").exit_code == 0
673
674 def test_prune_json_has_pruned_list(self) -> None:
675 result = _invoke("--prune", "--json")
676 assert result.exit_code == 0
677 assert isinstance(_json_line(result)["results"][0]["pruned"], list)
678
679 def test_json_on_stdout_parseable(self) -> None:
680 result = _invoke("--json")
681 assert result.exit_code == 0
682 data = _json_line(result)
683 assert "results" in data
684
685
686 class TestCLIFetchAll:
687 def _invoke_all(self, *extra: str, branch_heads: Manifest | None = None) -> InvokeResult:
688 remotes = [
689 {"name": "origin", "url": "http://origin"},
690 {"name": "upstream", "url": "http://upstream"},
691 ]
692 transport = _make_transport_mock(branch_heads or {"main": REMOTE_ID})
693 with (
694 patch("muse.cli.commands.fetch.require_repo", return_value=pathlib.Path("/fake")),
695 patch("muse.cli.commands.fetch.read_current_branch", return_value="main"),
696 patch("muse.cli.commands.fetch.list_remotes", return_value=remotes),
697 patch("muse.cli.commands.fetch.get_remote", return_value="http://localhost:19999"),
698 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
699 patch("muse.cli.commands.fetch.make_transport", return_value=transport),
700 patch("muse.cli.commands.fetch.get_remote_head", return_value=None),
701 patch("muse.cli.commands.fetch.set_remote_head"),
702 patch("muse.cli.commands.fetch.apply_mpack", return_value=_make_apply_result()),
703 patch("muse.cli.commands.fetch.get_all_commits", return_value=[]),
704 patch("muse.cli.commands.fetch.negotiate_have", return_value=[]),
705 patch("muse.cli.commands.fetch.write_object", return_value=True),
706 ):
707 return runner.invoke(cli, ["fetch", "--all", *extra])
708
709 def test_all_exits_zero(self) -> None:
710 assert self._invoke_all().exit_code == 0
711
712 def test_all_json_has_result_per_remote(self) -> None:
713 result = self._invoke_all("--json")
714 assert result.exit_code == 0
715 data = _json_line(result)
716 assert len(data["results"]) == 2
717 remotes_seen = {r["remote"] for r in data["results"]}
718 assert "origin" in remotes_seen
719 assert "upstream" in remotes_seen
720
721 def test_all_plus_branch_uses_named_branch(self) -> None:
722 """--all --branch dev must fetch 'dev' from every remote."""
723 result = self._invoke_all("--branch", "dev", "--json", branch_heads={"dev": REMOTE_ID})
724 assert result.exit_code == 0
725 data = _json_line(result)
726 for r in data["results"]:
727 assert r["branch"] == "dev"
728
729 def test_all_no_remotes_exits_nonzero(self) -> None:
730 with (
731 patch("muse.cli.commands.fetch.require_repo", return_value=pathlib.Path("/fake")),
732 patch("muse.cli.commands.fetch.read_current_branch", return_value="main"),
733 patch("muse.cli.commands.fetch.list_remotes", return_value=[]),
734 ):
735 result = runner.invoke(cli, ["fetch", "--all"])
736 assert result.exit_code != 0
737
738
739 # ── Performance ───────────────────────────────────────────────────────────────
740
741 class TestPerformance:
742 def test_negotiate_result_used_as_have_not_all_local(self) -> None:
743 """fetch_stream must receive negotiate_have output, not the raw all_local list."""
744 minimal = ["common-base-only"]
745 captured_have: list[list[str]] = []
746 transport = MagicMock()
747 transport.fetch_remote_info.return_value = _make_remote_info()
748
749 def _fs(url: str, token: str | None, want: list[str], have: list[str], **kwargs) -> Mapping[str, object]:
750 captured_have.append(have)
751 return _make_fetch_stream_result()
752
753 transport.fetch_stream.side_effect = _fs
754
755 with (
756 patch("muse.cli.commands.fetch.get_remote", return_value="http://x"),
757 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
758 patch("muse.cli.commands.fetch.make_transport", return_value=transport),
759 patch("muse.cli.commands.fetch.get_remote_head", return_value=None),
760 patch("muse.cli.commands.fetch.set_remote_head"),
761 patch("muse.cli.commands.fetch.apply_mpack", return_value=_make_apply_result()),
762 patch(
763 "muse.cli.commands.fetch.get_all_commits",
764 return_value=[MagicMock(commit_id=f"c{i}") for i in range(1_000)],
765 ),
766 patch("muse.cli.commands.fetch.negotiate_have", return_value=minimal),
767 ):
768 from muse.cli.commands.fetch import _fetch_one
769 _fetch_one(pathlib.Path("/fake"), "origin", "main", prune=False, dry_run=False)
770
771 assert captured_have[0] == minimal
772
773 def test_large_negotiation_converges_in_3_rounds(self) -> None:
774 from muse.core.transport import negotiate_have
775 transport = MagicMock()
776 rounds: list[int] = [0]
777
778 def _neg(url: str, token: str | None, want: list[str], have: list[str]) -> "NegotiateResponse":
779 rounds[0] += 1
780 return {"ack": have[:1], "common_base": None, "ready": rounds[0] >= 3}
781
782 transport.negotiate.side_effect = _neg
783 result = negotiate_have(
784 transport, "http://x", None, ["want"], [f"c{i}" for i in range(10_000)]
785 )
786 assert len(result) > 0
787 assert rounds[0] == 3
788
789
790 # ── Stress: concurrent filesystem and negotiation ────────────────────────────
791
792 class TestStressConcurrent:
793 def test_8_concurrent_prune_scans_isolated_repos(self, tmp_path: pathlib.Path) -> None:
794 """_prune_stale_refs on isolated repos must not interfere across threads."""
795 from muse.cli.commands.fetch import _prune_stale_refs
796 errors: list[str] = []
797
798 def _do(idx: int) -> None:
799 try:
800 repo = tmp_path / f"repo{idx}"
801 repo.mkdir()
802 _init_repo(repo)
803 _write_remote_ref(repo, "origin", "stale-branch", OLD_REMOTE_ID)
804 pruned = _prune_stale_refs(repo, "origin", {}, dry_run=False)
805 assert pruned == ["origin/stale-branch"]
806 assert not (repo / ".muse" / "remotes" / "origin" / "stale-branch").exists()
807 except Exception as exc:
808 errors.append(f"Thread {idx}: {exc}")
809
810 threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)]
811 for t in threads:
812 t.start()
813 for t in threads:
814 t.join()
815 assert errors == [], f"Concurrent prune failures: {errors}"
816
817 def test_8_concurrent_negotiate_have_calls(self) -> None:
818 """negotiate_have is stateless — 8 concurrent calls must not interfere."""
819 from muse.core.transport import negotiate_have
820 errors: list[str] = []
821
822 def _do(idx: int) -> None:
823 try:
824 transport = MagicMock()
825 transport.negotiate.return_value = _make_negotiate_response(
826 ack=[f"common-{idx}"], ready=True
827 )
828 result = negotiate_have(
829 transport, "http://x", None, [f"want-{idx}"],
830 [f"c{i}" for i in range(100)]
831 )
832 assert result == [f"common-{idx}"]
833 except Exception as exc:
834 errors.append(f"Thread {idx}: {exc}")
835
836 threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)]
837 for t in threads:
838 t.start()
839 for t in threads:
840 t.join()
841 assert errors == [], f"Concurrent negotiate_have failures: {errors}"
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 133 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 139 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 142 days ago