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