gabriel / muse public
test_cmd_fetch_hardening.py python
620 lines 27.5 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 20 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
9 Integration (mocked transport)
10 - _fetch_one: up-to-date, fetched, dry-run writes nothing, unknown remote, transport
11 error, branch missing without prune, branch missing with prune,
12 set_remote_head after apply_mpack
13
14 Security
15 - ANSI injection in remote name stripped in stderr
16 - ANSI injection in branch name stripped in stderr
17 - available-branches list sanitized before output
18 - symlink traversal blocked in _stale_ref_names
19 - all diagnostics go to stderr, not stdout
20
21 E2E (via CliRunner)
22 - basic fetch exits 0
23 - already-up-to-date exits 0
24 - --json output schema correct
25 - --format json equivalent to --json
26 - --dry-run exits 0
27 - --dry-run --json status = "dry_run"
28 - --branch flag
29 - --branch --json carries correct branch
30 - unknown remote exits non-zero
31 - --prune flag
32 - --prune --json includes pruned list
33 - --all fetches every remote
34 - --all --json has N results
35 - --all + --branch fetches named branch from every remote
36 - --all with no remotes exits non-zero
37
38 Stress
39 - 8 concurrent prune scans on isolated repos
40 """
41
42 from __future__ import annotations
43
44 import contextlib
45 import json
46 import pathlib
47 import threading
48 from typing import TYPE_CHECKING
49 from unittest.mock import MagicMock, patch
50
51 import pytest
52
53 from tests.cli_test_helper import CliRunner, InvokeResult
54
55 if TYPE_CHECKING:
56 from muse.cli.commands.fetch import _FetchJson, _RemoteResultJson
57 from muse.core.mpack import ApplyResult, MPack
58 from muse.core.transport import MuseTransport
59
60 cli = None
61 runner = CliRunner()
62
63 REMOTE_ID = "a" * 64
64 OLD_REMOTE_ID = "b" * 64
65
66 from muse.core.types import Manifest, blob_id
67 from muse.core.paths import muse_dir, remotes_dir
68
69 type _RemoteInfoMap = dict[str, str | dict[str, str]]
70
71
72 # ── typed helpers ─────────────────────────────────────────────────────────────
73
74 def _make_apply_result(
75 commits_written: int = 3,
76 blobs_written: int = 7,
77 ) -> "ApplyResult":
78 from muse.core.mpack import ApplyResult
79 return ApplyResult(
80 commits_written=commits_written,
81 snapshots_written=commits_written,
82 blobs_written=blobs_written,
83 blobs_skipped=0,
84 tags_written=0,
85 failed_blobs=[],
86 skipped_snapshots=[],
87 )
88
89
90 def _make_bundle() -> "MPack":
91 from muse.core.mpack import MPack
92 return MPack(commits=[], snapshots=[], blobs=[])
93
94
95 def _make_fetch_mpack_result(
96 commits_count: int = 0,
97 objects_count: int = 0,
98 ) -> Mapping[str, object]:
99 """Return a FetchMPackResult-shaped dict for mocking fetch_mpack."""
100 from muse.core.transport import FetchMPackResult
101 blobs: list[dict] = []
102 for i in range(objects_count):
103 content = f"fake-blob-{i}".encode()
104 blobs.append({"object_id": blob_id(content), "content": content})
105 return FetchMPackResult(
106 repo_id="test-repo-id",
107 domain="code",
108 default_branch="main",
109 branch_heads={"main": REMOTE_ID},
110 commits=[],
111 snapshots=[],
112 blobs=blobs,
113 blobs_received=len(blobs),
114 shallow_commits=[],
115 )
116
117
118 def _make_remote_info(
119 branch_heads: Manifest | None = None,
120 ) -> _RemoteInfoMap:
121 return {
122 "repo_id": "test-repo-id",
123 "domain": "code",
124 "default_branch": "main",
125 "branch_heads": branch_heads or {"main": REMOTE_ID},
126 }
127
128
129 def _make_transport_mock(
130 branch_heads: Manifest | None = None,
131 objects_count: int = 7,
132 ) -> MagicMock:
133 t = MagicMock()
134 t.fetch_remote_info.return_value = _make_remote_info(branch_heads)
135
136 def _fetch_mpack(
137 url: str, token: str | None, want: list[str], have: list[str], **kwargs: str,
138 ) -> Mapping[str, object]:
139 return _make_fetch_mpack_result(objects_count=objects_count)
140
141 t.fetch_mpack.side_effect = _fetch_mpack
142 return t
143
144
145 def _json_line(result: InvokeResult) -> "_FetchJson":
146 """Extract the JSON object from cli_test_helper's combined output.
147
148 The test helper mixes stderr into result.output, so we scan for the first
149 line beginning with '{'.
150 """
151 for line in result.output.splitlines():
152 stripped = line.strip()
153 if stripped.startswith("{"):
154 parsed: _FetchJson = json.loads(stripped)
155 return parsed
156 raise ValueError(f"No JSON line in output:\n{result.output!r}")
157
158
159 def _init_repo(tmp_path: pathlib.Path) -> None:
160 dot_muse = muse_dir(tmp_path)
161 for sub in ("objects", "commits", "snapshots", "remotes", "refs/heads", "branches"):
162 (dot_muse / sub).mkdir(parents=True, exist_ok=True)
163 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
164 (dot_muse / "refs" / "heads" / "main").write_text("")
165 (dot_muse / "config.toml").write_text(
166 '[remotes.origin]\nurl = "http://localhost:19999"\n'
167 )
168 (dot_muse / "repo.json").write_text('{"id": "test-repo-id"}')
169
170
171 def _write_remote_ref(
172 tmp_path: pathlib.Path, remote: str, branch: str, commit_id: str
173 ) -> None:
174 ref_file = remotes_dir(tmp_path) / remote / branch
175 ref_file.parent.mkdir(parents=True, exist_ok=True)
176 ref_file.write_text(commit_id)
177
178
179 # ── Unit: _stale_ref_names ────────────────────────────────────────────────────
180
181 class TestStaleRefNames:
182 def test_no_refs_dir_returns_empty(self, tmp_path: pathlib.Path) -> None:
183 from muse.cli.commands.fetch import _stale_ref_names
184 assert _stale_ref_names(tmp_path, "origin", {"main": REMOTE_ID}) == []
185
186 def test_all_live_returns_empty(self, tmp_path: pathlib.Path) -> None:
187 from muse.cli.commands.fetch import _stale_ref_names
188 _init_repo(tmp_path)
189 _write_remote_ref(tmp_path, "origin", "main", REMOTE_ID)
190 assert _stale_ref_names(tmp_path, "origin", {"main": REMOTE_ID}) == []
191
192 def test_stale_branch_detected(self, tmp_path: pathlib.Path) -> None:
193 from muse.cli.commands.fetch import _stale_ref_names
194 _init_repo(tmp_path)
195 _write_remote_ref(tmp_path, "origin", "main", REMOTE_ID)
196 _write_remote_ref(tmp_path, "origin", "feat/old", OLD_REMOTE_ID)
197 stale = _stale_ref_names(tmp_path, "origin", {"main": REMOTE_ID})
198 assert stale == ["feat/old"]
199
200 def test_nested_branch_name_preserved(self, tmp_path: pathlib.Path) -> None:
201 """Slashes in branch names stored as nested files must round-trip correctly."""
202 from muse.cli.commands.fetch import _stale_ref_names
203 _init_repo(tmp_path)
204 _write_remote_ref(tmp_path, "origin", "feat/ui/redesign", REMOTE_ID)
205 stale = _stale_ref_names(tmp_path, "origin", {})
206 assert "feat/ui/redesign" in stale
207
208 def test_symlinks_skipped(self, tmp_path: pathlib.Path) -> None:
209 """Symlinks inside the refs dir must not be followed (path-traversal guard)."""
210 from muse.cli.commands.fetch import _stale_ref_names
211 _init_repo(tmp_path)
212 refs_dir = remotes_dir(tmp_path) / "origin"
213 refs_dir.mkdir(parents=True, exist_ok=True)
214 target = tmp_path / "outside.txt"
215 target.write_text("sensitive")
216 (refs_dir / "malicious-link").symlink_to(target)
217 stale = _stale_ref_names(tmp_path, "origin", {})
218 assert "malicious-link" not in stale
219
220
221 # ── Unit: _prune_stale_refs ───────────────────────────────────────────────────
222
223 class TestPruneStaleRefs:
224 def test_dry_run_does_not_delete(
225 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
226 ) -> None:
227 from muse.cli.commands.fetch import _prune_stale_refs
228 _init_repo(tmp_path)
229 _write_remote_ref(tmp_path, "origin", "dead-branch", OLD_REMOTE_ID)
230 pruned = _prune_stale_refs(tmp_path, "origin", {}, dry_run=True)
231 assert pruned == ["origin/dead-branch"]
232 assert (remotes_dir(tmp_path) / "origin" / "dead-branch").exists()
233 assert "Would prune" in capsys.readouterr().err
234
235 def test_live_delete_removes_file(
236 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
237 ) -> None:
238 from muse.cli.commands.fetch import _prune_stale_refs
239 _init_repo(tmp_path)
240 _write_remote_ref(tmp_path, "origin", "dead-branch", OLD_REMOTE_ID)
241 pruned = _prune_stale_refs(tmp_path, "origin", {}, dry_run=False)
242 assert pruned == ["origin/dead-branch"]
243 assert not (remotes_dir(tmp_path) / "origin" / "dead-branch").exists()
244 assert "[deleted]" in capsys.readouterr().err
245
246 def test_empty_parent_dirs_removed(self, tmp_path: pathlib.Path) -> None:
247 from muse.cli.commands.fetch import _prune_stale_refs
248 _init_repo(tmp_path)
249 _write_remote_ref(tmp_path, "origin", "feat/old-thing", OLD_REMOTE_ID)
250 _prune_stale_refs(tmp_path, "origin", {}, dry_run=False)
251 assert not (remotes_dir(tmp_path) / "origin" / "feat").exists()
252
253 def test_returns_qualified_remote_branch_names(self, tmp_path: pathlib.Path) -> None:
254 from muse.cli.commands.fetch import _prune_stale_refs
255 _init_repo(tmp_path)
256 _write_remote_ref(tmp_path, "origin", "stale-a", OLD_REMOTE_ID)
257 _write_remote_ref(tmp_path, "origin", "stale-b", OLD_REMOTE_ID)
258 pruned = _prune_stale_refs(tmp_path, "origin", {}, dry_run=False)
259 assert "origin/stale-a" in pruned
260 assert "origin/stale-b" in pruned
261
262 def test_no_refs_dir_is_noop(self, tmp_path: pathlib.Path) -> None:
263 from muse.cli.commands.fetch import _prune_stale_refs
264 _init_repo(tmp_path)
265 assert _prune_stale_refs(tmp_path, "no-remote", {}, dry_run=False) == []
266
267 def test_output_goes_to_stderr_not_stdout(
268 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
269 ) -> None:
270 from muse.cli.commands.fetch import _prune_stale_refs
271 _init_repo(tmp_path)
272 _write_remote_ref(tmp_path, "origin", "dead", OLD_REMOTE_ID)
273 _prune_stale_refs(tmp_path, "origin", {}, dry_run=False)
274 assert capsys.readouterr().out == ""
275
276
277 # ── Integration: _fetch_one ───────────────────────────────────────────────────
278
279 class TestFetchOne:
280 def _patches(
281 self,
282 already_known: str | None = None,
283 branch_heads: Manifest | None = None,
284 apply_result: "ApplyResult | None" = None,
285 objects_count: int = 7,
286 ) -> contextlib.ExitStack:
287 stack = contextlib.ExitStack()
288 transport = _make_transport_mock(branch_heads or {"main": REMOTE_ID}, objects_count=objects_count)
289 stack.enter_context(patch("muse.cli.commands.fetch.get_remote", return_value="http://localhost:19999"))
290 stack.enter_context(patch("muse.cli.commands.fetch.get_signing_identity", return_value=None))
291 stack.enter_context(patch("muse.cli.commands.fetch.make_transport", return_value=transport))
292 stack.enter_context(patch("muse.cli.commands.fetch.get_remote_head", return_value=already_known))
293 stack.enter_context(patch("muse.cli.commands.fetch.set_remote_head"))
294 stack.enter_context(patch("muse.cli.commands.fetch.apply_mpack", return_value=apply_result or _make_apply_result()))
295 stack.enter_context(patch("muse.cli.commands.fetch.get_all_commits", return_value=[]))
296 return stack
297
298 def test_up_to_date_status(self, tmp_path: pathlib.Path) -> None:
299 from muse.cli.commands.fetch import _fetch_one
300 with self._patches(already_known=REMOTE_ID):
301 result = _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=False)
302 assert result["status"] == "up_to_date"
303 assert result["commits_received"] == 0
304
305 def test_fetched_status(self, tmp_path: pathlib.Path) -> None:
306 from muse.cli.commands.fetch import _fetch_one
307 with self._patches():
308 result = _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=False)
309 assert result["status"] == "fetched"
310 assert result["commits_received"] == 3
311 assert result["blobs_written"] == 7
312
313 def test_commits_received_from_apply_result_not_bundle(self, tmp_path: pathlib.Path) -> None:
314 """Regression: use apply_result['commits_written'], not len(mpack['commits'])."""
315 from muse.cli.commands.fetch import _fetch_one
316 with self._patches(apply_result=_make_apply_result(commits_written=5, blobs_written=12), objects_count=12):
317 result = _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=False)
318 assert result["commits_received"] == 5
319 assert result["blobs_written"] == 12
320
321 def test_dry_run_does_not_write(self, tmp_path: pathlib.Path) -> None:
322 from muse.cli.commands.fetch import _fetch_one
323 set_mock = MagicMock()
324 with self._patches() as stack:
325 stack.enter_context(patch("muse.cli.commands.fetch.set_remote_head", set_mock))
326 result = _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=True)
327 assert result["status"] == "dry_run"
328
329 def test_unknown_remote_exits_user_error(self, tmp_path: pathlib.Path) -> None:
330 from muse.cli.commands.fetch import _fetch_one
331 from muse.core.errors import ExitCode
332 with patch("muse.cli.commands.fetch.get_remote", return_value=None):
333 with pytest.raises(SystemExit) as exc:
334 _fetch_one(tmp_path, "no-such", "main", prune=False, dry_run=False)
335 assert exc.value.code == ExitCode.USER_ERROR
336
337 def test_branch_missing_without_prune_exits(self, tmp_path: pathlib.Path) -> None:
338 from muse.cli.commands.fetch import _fetch_one
339 with self._patches(branch_heads={"dev": REMOTE_ID}):
340 with pytest.raises(SystemExit):
341 _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=False)
342
343 def test_branch_missing_with_prune_returns_branch_missing(self, tmp_path: pathlib.Path) -> None:
344 from muse.cli.commands.fetch import _fetch_one
345 with self._patches(branch_heads={"dev": REMOTE_ID}):
346 result = _fetch_one(tmp_path, "origin", "main", prune=True, dry_run=False)
347 assert result["status"] == "branch_missing"
348
349 def test_set_remote_head_called_after_apply_mpack(self, tmp_path: pathlib.Path) -> None:
350 """Remote tracking pointer must only advance after apply_mpack succeeds."""
351 from muse.cli.commands.fetch import _fetch_one
352 call_order: list[str] = []
353
354 def _apply(_root: pathlib.Path, _bundle: "MPack") -> "ApplyResult":
355 call_order.append("apply_mpack")
356 return _make_apply_result()
357
358 def _set_head(
359 remote_name: str, branch: str, commit_id: str,
360 repo_root: pathlib.Path | None = None,
361 ) -> None:
362 call_order.append("set_remote_head")
363
364 with (
365 patch("muse.cli.commands.fetch.get_remote", return_value="http://x"),
366 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
367 patch("muse.cli.commands.fetch.make_transport", return_value=_make_transport_mock()),
368 patch("muse.cli.commands.fetch.get_remote_head", return_value=None),
369 patch("muse.cli.commands.fetch.apply_mpack", _apply),
370 patch("muse.cli.commands.fetch.set_remote_head", _set_head),
371 patch("muse.cli.commands.fetch.get_all_commits", return_value=[]),
372 ):
373 _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=False)
374
375 assert call_order.index("apply_mpack") < call_order.index("set_remote_head")
376
377
378 # ── Security ──────────────────────────────────────────────────────────────────
379
380 class TestSecurity:
381 def test_ansi_in_remote_name_stripped(
382 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
383 ) -> None:
384 from muse.cli.commands.fetch import _fetch_one
385 malicious = "\x1b[31mEVIL\x1b[0m"
386 with patch("muse.cli.commands.fetch.get_remote", return_value=None):
387 with pytest.raises(SystemExit):
388 _fetch_one(tmp_path, malicious, "main", prune=False, dry_run=False)
389 assert "\x1b[" not in capsys.readouterr().err
390
391 def test_ansi_in_branch_name_stripped(
392 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
393 ) -> None:
394 from muse.cli.commands.fetch import _fetch_one
395 malicious_branch = "\x1b[31mHACKED\x1b[0m"
396 with (
397 patch("muse.cli.commands.fetch.get_remote", return_value="http://x"),
398 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
399 patch("muse.cli.commands.fetch.make_transport", return_value=_make_transport_mock({"main": REMOTE_ID})),
400 ):
401 with pytest.raises(SystemExit):
402 _fetch_one(tmp_path, "origin", malicious_branch, prune=False, dry_run=False)
403 assert "\x1b[" not in capsys.readouterr().err
404
405 def test_available_branches_sanitized_in_error(
406 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
407 ) -> None:
408 """Branch names returned by the remote must be sanitized before printing."""
409 from muse.cli.commands.fetch import _fetch_one
410 malicious_branch = "\x1b[32mhijacked\x1b[0m"
411 with (
412 patch("muse.cli.commands.fetch.get_remote", return_value="http://x"),
413 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
414 patch("muse.cli.commands.fetch.make_transport", return_value=_make_transport_mock({malicious_branch: REMOTE_ID})),
415 ):
416 with pytest.raises(SystemExit):
417 _fetch_one(tmp_path, "origin", "no-such", prune=False, dry_run=False)
418 assert "\x1b[" not in capsys.readouterr().err
419
420 def test_symlink_traversal_blocked_in_stale_ref_names(
421 self, tmp_path: pathlib.Path
422 ) -> None:
423 from muse.cli.commands.fetch import _stale_ref_names
424 _init_repo(tmp_path)
425 refs_dir = remotes_dir(tmp_path) / "origin"
426 refs_dir.mkdir(parents=True, exist_ok=True)
427 (tmp_path / "secret.txt").write_text("top-secret")
428 (refs_dir / "malicious").symlink_to(tmp_path / "secret.txt")
429 assert "malicious" not in _stale_ref_names(tmp_path, "origin", {})
430
431 def test_all_diagnostics_go_to_stderr_not_stdout(
432 self, tmp_path: pathlib.Path, capsys: pytest.CaptureFixture[str]
433 ) -> None:
434 from muse.cli.commands.fetch import _fetch_one
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=_make_transport_mock()),
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 ):
444 _fetch_one(tmp_path, "origin", "main", prune=False, dry_run=False)
445 assert capsys.readouterr().out == ""
446
447
448 # ── E2E: CLI via CliRunner ────────────────────────────────────────────────────
449
450 def _invoke(*args: str, branch_heads: Manifest | None = None) -> InvokeResult:
451 """Invoke ``muse fetch`` with all transport-layer functions mocked."""
452 transport = _make_transport_mock(branch_heads)
453 with (
454 patch("muse.cli.commands.fetch.require_repo", return_value=pathlib.Path("/fake")),
455 patch("muse.cli.commands.fetch.read_current_branch", return_value="main"),
456 patch("muse.cli.commands.fetch.get_remote", return_value="http://localhost:19999"),
457 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
458 patch("muse.cli.commands.fetch.make_transport", return_value=transport),
459 patch("muse.cli.commands.fetch.get_remote_head", return_value=None),
460 patch("muse.cli.commands.fetch.set_remote_head"),
461 patch("muse.cli.commands.fetch.apply_mpack", return_value=_make_apply_result()),
462 patch("muse.cli.commands.fetch.get_all_commits", return_value=[]),
463 ):
464 return runner.invoke(cli, ["fetch", *args])
465
466
467 class TestCLIFetch:
468 def test_basic_fetch_exits_zero(self) -> None:
469 assert _invoke().exit_code == 0
470
471 def test_already_up_to_date_exits_zero(self) -> None:
472 with (
473 patch("muse.cli.commands.fetch.require_repo", return_value=pathlib.Path("/fake")),
474 patch("muse.cli.commands.fetch.read_current_branch", return_value="main"),
475 patch("muse.cli.commands.fetch.get_remote", return_value="http://localhost:19999"),
476 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
477 patch("muse.cli.commands.fetch.make_transport", return_value=_make_transport_mock()),
478 patch("muse.cli.commands.fetch.get_remote_head", return_value=REMOTE_ID),
479 patch("muse.cli.commands.fetch.set_remote_head"),
480 patch("muse.cli.commands.fetch.apply_mpack", return_value=_make_apply_result()),
481 patch("muse.cli.commands.fetch.get_all_commits", return_value=[]),
482 ):
483 result = runner.invoke(cli, ["fetch"])
484 assert result.exit_code == 0
485
486 def test_json_schema_complete(self) -> None:
487 result = _invoke("--json")
488 assert result.exit_code == 0
489 data = _json_line(result)
490 assert "results" in data
491 assert "dry_run" in data
492 r = data["results"][0]
493 for key in ("remote", "branch", "status", "commits_received", "blobs_written", "head", "pruned", "dry_run"):
494 assert key in r, f"Missing key: {key}"
495 assert r["status"] in {"fetched", "up_to_date", "dry_run", "branch_missing"}
496
497 def test_json_flag_produces_valid_json(self) -> None:
498 data = _json_line(_invoke("--json"))
499 assert "exit_code" in data
500
501 def test_dry_run_exits_zero(self) -> None:
502 assert _invoke("--dry-run").exit_code == 0
503
504 def test_dry_run_json_status(self) -> None:
505 result = _invoke("--dry-run", "--json")
506 assert result.exit_code == 0
507 data = _json_line(result)
508 assert data["dry_run"] is True
509 assert data["results"][0]["status"] == "dry_run"
510
511 def test_branch_flag(self) -> None:
512 assert _invoke("--branch", "dev", branch_heads={"dev": REMOTE_ID}).exit_code == 0
513
514 def test_branch_flag_json_carries_branch(self) -> None:
515 result = _invoke("--branch", "dev", "--json", branch_heads={"dev": REMOTE_ID})
516 assert result.exit_code == 0
517 assert _json_line(result)["results"][0]["branch"] == "dev"
518
519 def test_unknown_remote_exits_nonzero(self) -> None:
520 with (
521 patch("muse.cli.commands.fetch.require_repo", return_value=pathlib.Path("/fake")),
522 patch("muse.cli.commands.fetch.read_current_branch", return_value="main"),
523 patch("muse.cli.commands.fetch.get_remote", return_value=None),
524 ):
525 result = runner.invoke(cli, ["fetch", "no-such-remote"])
526 assert result.exit_code != 0
527
528 def test_prune_flag_succeeds(self) -> None:
529 assert _invoke("--prune").exit_code == 0
530
531 def test_prune_json_has_pruned_list(self) -> None:
532 result = _invoke("--prune", "--json")
533 assert result.exit_code == 0
534 assert isinstance(_json_line(result)["results"][0]["pruned"], list)
535
536 def test_json_on_stdout_parseable(self) -> None:
537 result = _invoke("--json")
538 assert result.exit_code == 0
539 data = _json_line(result)
540 assert "results" in data
541
542
543 class TestCLIFetchAll:
544 def _invoke_all(self, *extra: str, branch_heads: Manifest | None = None) -> InvokeResult:
545 remotes = [
546 {"name": "origin", "url": "http://origin"},
547 {"name": "upstream", "url": "http://upstream"},
548 ]
549 transport = _make_transport_mock(branch_heads or {"main": REMOTE_ID})
550 with (
551 patch("muse.cli.commands.fetch.require_repo", return_value=pathlib.Path("/fake")),
552 patch("muse.cli.commands.fetch.read_current_branch", return_value="main"),
553 patch("muse.cli.commands.fetch.list_remotes", return_value=remotes),
554 patch("muse.cli.commands.fetch.get_remote", return_value="http://localhost:19999"),
555 patch("muse.cli.commands.fetch.get_signing_identity", return_value=None),
556 patch("muse.cli.commands.fetch.make_transport", return_value=transport),
557 patch("muse.cli.commands.fetch.get_remote_head", return_value=None),
558 patch("muse.cli.commands.fetch.set_remote_head"),
559 patch("muse.cli.commands.fetch.apply_mpack", return_value=_make_apply_result()),
560 patch("muse.cli.commands.fetch.get_all_commits", return_value=[]),
561 ):
562 return runner.invoke(cli, ["fetch", "--all", *extra])
563
564 def test_all_exits_zero(self) -> None:
565 assert self._invoke_all().exit_code == 0
566
567 def test_all_json_has_result_per_remote(self) -> None:
568 result = self._invoke_all("--json")
569 assert result.exit_code == 0
570 data = _json_line(result)
571 assert len(data["results"]) == 2
572 remotes_seen = {r["remote"] for r in data["results"]}
573 assert "origin" in remotes_seen
574 assert "upstream" in remotes_seen
575
576 def test_all_plus_branch_uses_named_branch(self) -> None:
577 """--all --branch dev must fetch 'dev' from every remote."""
578 result = self._invoke_all("--branch", "dev", "--json", branch_heads={"dev": REMOTE_ID})
579 assert result.exit_code == 0
580 data = _json_line(result)
581 for r in data["results"]:
582 assert r["branch"] == "dev"
583
584 def test_all_no_remotes_exits_nonzero(self) -> None:
585 with (
586 patch("muse.cli.commands.fetch.require_repo", return_value=pathlib.Path("/fake")),
587 patch("muse.cli.commands.fetch.read_current_branch", return_value="main"),
588 patch("muse.cli.commands.fetch.list_remotes", return_value=[]),
589 ):
590 result = runner.invoke(cli, ["fetch", "--all"])
591 assert result.exit_code != 0
592
593
594 # ── Stress: concurrent filesystem ────────────────────────────────────────────
595
596 class TestStressConcurrent:
597 def test_8_concurrent_prune_scans_isolated_repos(self, tmp_path: pathlib.Path) -> None:
598 """_prune_stale_refs on isolated repos must not interfere across threads."""
599 from muse.cli.commands.fetch import _prune_stale_refs
600 errors: list[str] = []
601
602 def _do(idx: int) -> None:
603 try:
604 repo = tmp_path / f"repo{idx}"
605 repo.mkdir()
606 _init_repo(repo)
607 _write_remote_ref(repo, "origin", "stale-branch", OLD_REMOTE_ID)
608 pruned = _prune_stale_refs(repo, "origin", {}, dry_run=False)
609 assert pruned == ["origin/stale-branch"]
610 assert not (remotes_dir(repo) / "origin" / "stale-branch").exists()
611 except Exception as exc:
612 errors.append(f"Thread {idx}: {exc}")
613
614 threads = [threading.Thread(target=_do, args=(i,)) for i in range(8)]
615 for t in threads:
616 t.start()
617 for t in threads:
618 t.join()
619 assert errors == [], f"Concurrent prune failures: {errors}"
620
File History 5 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 20 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 22 days ago
sha256:0313c134f0ef4518a9c3a0ec359ffdc42546dc720010730374edfe0857caf7ef rename: delta_add → delta_upsert across wire format, source… Sonnet 4.6 minor 22 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 28 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 28 days ago