gabriel / muse public
test_cmd_push_hardening.py python
963 lines 40.4 KB
Raw
sha256:79ffe87f5fe2ec146e35f05521218bbf54dffdb0440c07f970bad05f16efb89f chore: merge main — carry all urllib/typing/test fixes from dev Sonnet 4.6 minor ⚠ breaking 46 days ago
1 """Comprehensive hardening tests for ``muse push``.
2
3 Covers all changes introduced in the push command review:
4
5 Unit
6 ----
7 - Parser flags: --dry-run, --workers, --format/--json
8 - Dead-code removal: _current_branch absent
9 - _all_known_have_anchors: symlink skipping, binary-file safety, missing dir
10 - _upload_presigned: retry on 5xx/429, non-retriable 4xx propagated immediately
11 - _upload_chunk: progress goes to stderr, not stdout
12 - _PushJson TypedDict keys complete
13
14 Integration (with mocked transport)
15 ------------------------------------
16 - Error messages routed to stderr, stdout clean on errors
17 - remote not configured → stderr
18 - branch has no commits → stderr
19 - push rejected (result.ok=False) → stderr
20 - up_to_date JSON schema complete
21 - pushed JSON schema complete
22 - dry_run JSON schema complete
23 - deleted JSON schema complete
24 - --dry-run: no transport calls, correct counts
25 - --workers accepted without error
26 - --set-upstream records tracking ref
27 - 409/401/404/other TransportError → stderr + exit 1
28
29 End-to-end (local:// transport)
30 ---------------------------------
31 - Fresh push succeeds
32 - Second push (up_to_date) exits 0
33 - --dry-run shows would-push info without writing
34 - --format json produces valid JSON
35 - --force bypasses fast-forward check
36
37 Security
38 --------
39 - remote name sanitized in all error messages
40 - branch name sanitized in delete output
41 - del_branch sanitized in already-gone path
42 - _all_known_have_anchors: planted symlink skipped
43 - _all_known_have_anchors: binary file skipped
44 - invalid --format exits 1 to stderr
45 - progress prints from _upload_chunk go to stderr
46
47 Stress
48 ------
49 - _push_objects_parallel with 1000 objects (mocked transport)
50 - _upload_presigned retries exhaust then raise
51 - concurrent push runs to isolated repos
52 """
53
54 from __future__ import annotations
55
56 type _IntMap = dict[str, int]
57
58 import argparse
59 import http.client
60 import inspect
61 import json
62 import os
63 import pathlib
64 import tempfile
65 import threading
66 import time
67 import types
68 import urllib.error
69 import urllib.request
70 from typing import TYPE_CHECKING
71 from unittest.mock import MagicMock, patch
72
73 import pytest
74
75 from muse.cli.config import set_remote
76 from tests.cli_test_helper import CliRunner, InvokeResult
77
78 if TYPE_CHECKING:
79 from muse.cli.commands.push import _PushJson
80 from muse.core.pack import ObjectPayload, ObjectsChunkResponse, PackBundle, PushResult, RemoteInfo
81 from muse.core.transport import PresignResponse
82
83 cli = None
84 runner = CliRunner()
85
86
87 class _FakeResponse:
88 """Minimal context-manager stub returned by fake urlopen in tests."""
89
90 def __enter__(self) -> "_FakeResponse":
91 return self
92
93 def __exit__(
94 self,
95 exc_type: type[BaseException] | None,
96 exc_val: BaseException | None,
97 exc_tb: "types.TracebackType | None",
98 ) -> None:
99 pass
100
101
102 # ---------------------------------------------------------------------------
103 # Shared helpers
104 # ---------------------------------------------------------------------------
105
106 def _env(root: pathlib.Path) -> Manifest:
107 return {"MUSE_REPO_ROOT": str(root)}
108
109
110 def _json(r: InvokeResult) -> _PushJson:
111 """Extract the JSON object line from combined output.
112
113 With ``--json``, exactly one line starting with ``{`` is emitted to stdout;
114 all progress/error lines go to stderr and are prefixed with spaces or emoji.
115 This helper finds that line so tests can assert on the schema.
116 """
117 for line in r.output.splitlines():
118 stripped = line.strip()
119 if stripped.startswith("{"):
120 raw: _PushJson = json.loads(stripped)
121 return raw
122 raise ValueError(f"No JSON line found in output:\n{r.output!r}")
123
124
125 @pytest.fixture()
126 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
127 """Fresh repo with one committed file."""
128 monkeypatch.chdir(tmp_path)
129 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
130 r = runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
131 assert r.exit_code == 0, r.output
132 (tmp_path / "a.py").write_text("x = 1\n")
133 r = runner.invoke(cli, ["commit", "-m", "base"], env=_env(tmp_path), catch_exceptions=False)
134 assert r.exit_code == 0, r.output
135 return tmp_path
136
137
138 @pytest.fixture()
139 def remote_repo(
140 tmp_path: pathlib.Path,
141 monkeypatch: pytest.MonkeyPatch,
142 ) -> tuple[pathlib.Path, pathlib.Path]:
143 """Return ``(local, remote)`` pair with the local remote configured."""
144 local = tmp_path / "local"
145 remote = tmp_path / "remote"
146 local.mkdir()
147 remote.mkdir()
148
149 # muse init uses cwd; chdir so it creates .muse/ in the right place.
150 monkeypatch.chdir(local)
151 monkeypatch.setenv("MUSE_REPO_ROOT", str(local))
152 runner.invoke(cli, ["init"], env=_env(local), catch_exceptions=False)
153 (local / "a.py").write_text("x = 1\n")
154 runner.invoke(cli, ["commit", "-m", "base"], env=_env(local), catch_exceptions=False)
155
156 monkeypatch.chdir(remote)
157 monkeypatch.setenv("MUSE_REPO_ROOT", str(remote))
158 runner.invoke(cli, ["init"], env=_env(remote), catch_exceptions=False)
159
160 monkeypatch.chdir(local)
161 monkeypatch.setenv("MUSE_REPO_ROOT", str(local))
162 # Write the remote config directly — muse remote add blocks file:// by
163 # design (security); set_remote() bypasses that validation intentionally
164 # for test infrastructure.
165 set_remote("local", f"file://{remote}", repo_root=local)
166 return local, remote
167
168
169 # ---------------------------------------------------------------------------
170 # Unit — dead code, parser flags, helpers
171 # ---------------------------------------------------------------------------
172
173 class TestDeadCodeRemoval:
174 def test_no_current_branch_wrapper(self) -> None:
175 import muse.cli.commands.push as m
176 assert not hasattr(m, "_current_branch"), "_current_branch must be deleted"
177
178 def test_push_json_typeddict_keys(self) -> None:
179 import muse.cli.commands.push as m
180 required = {"status", "remote", "branch", "head",
181 "commits_sent", "objects_sent", "force", "dry_run"}
182 assert required <= set(m._PushJson.__annotations__.keys())
183
184 def test_presign_retries_constant_defined(self) -> None:
185 import muse.cli.commands.push as m
186 assert hasattr(m, "_PRESIGN_RETRIES")
187 assert isinstance(m._PRESIGN_RETRIES, int)
188 assert m._PRESIGN_RETRIES >= 1
189
190
191 class TestRegisterFlags:
192 def _parse(self, *args: str) -> argparse.Namespace:
193 import muse.cli.commands.push as m
194 p = argparse.ArgumentParser()
195 sub = p.add_subparsers()
196 m.register(sub)
197 return p.parse_args(["push", *args])
198
199 def test_dry_run_short(self) -> None:
200 ns = self._parse("-n")
201 assert ns.dry_run is True
202
203 def test_dry_run_long(self) -> None:
204 ns = self._parse("--dry-run")
205 assert ns.dry_run is True
206
207 def test_workers_default(self) -> None:
208 ns = self._parse()
209 assert ns.workers == 16
210
211 def test_workers_custom(self) -> None:
212 ns = self._parse("--workers", "8")
213 assert ns.workers == 8
214
215 def test_format_json_shorthand(self) -> None:
216 ns = self._parse("--json")
217 assert ns.fmt == "json"
218
219 def test_format_flag(self) -> None:
220 ns = self._parse("--format", "json")
221 assert ns.fmt == "json"
222
223 def test_force_flag(self) -> None:
224 ns = self._parse("--force")
225 assert ns.force is True
226
227 def test_delete_flag(self) -> None:
228 ns = self._parse("--delete")
229 assert ns.delete_branch is True
230
231 def test_set_upstream_short(self) -> None:
232 ns = self._parse("-u")
233 assert ns.set_upstream_flag is True
234
235
236 class TestAllKnownHaveAnchors:
237 def test_no_remotes_dir_returns_empty(self, tmp_path: pathlib.Path) -> None:
238 from muse.cli.commands.push import _all_known_have_anchors
239 assert _all_known_have_anchors(tmp_path) == []
240
241 def test_reads_commit_ids(self, tmp_path: pathlib.Path) -> None:
242 from muse.cli.commands.push import _all_known_have_anchors
243 remotes = tmp_path / ".muse" / "remotes" / "origin"
244 remotes.mkdir(parents=True)
245 (remotes / "main").write_text("abc123\n")
246 result = _all_known_have_anchors(tmp_path)
247 assert "abc123" in result
248
249 def test_symlinks_are_skipped(self, tmp_path: pathlib.Path) -> None:
250 from muse.cli.commands.push import _all_known_have_anchors
251 remotes = tmp_path / ".muse" / "remotes" / "origin"
252 remotes.mkdir(parents=True)
253 target = tmp_path / "secret.txt"
254 target.write_text("abc123\n")
255 (remotes / "main").symlink_to(target)
256 result = _all_known_have_anchors(tmp_path)
257 # Symlink should not be followed — abc123 should NOT appear
258 assert "abc123" not in result
259
260 def test_binary_file_skipped_not_crashed(self, tmp_path: pathlib.Path) -> None:
261 from muse.cli.commands.push import _all_known_have_anchors
262 remotes = tmp_path / ".muse" / "remotes" / "origin"
263 remotes.mkdir(parents=True)
264 (remotes / "bin_ref").write_bytes(b"\x00\x01\x02\xff")
265 # Should not raise
266 result = _all_known_have_anchors(tmp_path)
267 # Binary content with \x00 stripped by errors='ignore' → not a valid ID
268 assert isinstance(result, list)
269
270 def test_empty_files_skipped(self, tmp_path: pathlib.Path) -> None:
271 from muse.cli.commands.push import _all_known_have_anchors
272 remotes = tmp_path / ".muse" / "remotes" / "origin"
273 remotes.mkdir(parents=True)
274 (remotes / "empty").write_text("")
275 result = _all_known_have_anchors(tmp_path)
276 assert result == []
277
278 def test_multiple_remotes(self, tmp_path: pathlib.Path) -> None:
279 from muse.cli.commands.push import _all_known_have_anchors
280 for name in ["origin", "upstream", "fork"]:
281 d = tmp_path / ".muse" / "remotes" / name
282 d.mkdir(parents=True)
283 (d / "main").write_text(f"commit_{name}\n")
284 result = _all_known_have_anchors(tmp_path)
285 assert len(result) == 3
286 assert "commit_origin" in result
287
288
289 class TestUploadPresigned:
290 """Tests for _upload_presigned — always exercise the urllib path (Linux branch)."""
291
292 # Force the urllib path on all platforms so tests can mock urlopen cleanly.
293 _linux = patch("muse.cli.commands.push.platform.system", return_value="Linux")
294
295 def test_success_no_retry(self) -> None:
296 from muse.cli.commands.push import _upload_presigned
297
298 call_count = 0
299 def fake_urlopen(req: urllib.request.Request, timeout: int) -> _FakeResponse:
300 nonlocal call_count
301 call_count += 1
302 return _FakeResponse()
303
304 with self._linux, patch("urllib.request.urlopen", fake_urlopen):
305 _upload_presigned("abc" * 20, "http://fake/url", b"data", retries=3)
306 assert call_count == 1
307
308 def test_retries_on_503(self) -> None:
309 from muse.cli.commands.push import _upload_presigned
310
311 call_count = 0
312 def fake_urlopen(req: urllib.request.Request, timeout: int) -> _FakeResponse:
313 nonlocal call_count
314 call_count += 1
315 if call_count < 3:
316 raise urllib.error.HTTPError("", 503, "Service Unavailable", http.client.HTTPMessage(), None)
317 return _FakeResponse()
318
319 with self._linux, patch("urllib.request.urlopen", fake_urlopen), patch("time.sleep"):
320 _upload_presigned("abc" * 20, "http://fake/url", b"data", retries=3)
321 assert call_count == 3
322
323 def test_non_retriable_4xx_propagated_immediately(self) -> None:
324 from muse.cli.commands.push import _upload_presigned
325
326 call_count = 0
327 def fake_urlopen(req: urllib.request.Request, timeout: int) -> _FakeResponse:
328 nonlocal call_count
329 call_count += 1
330 raise urllib.error.HTTPError("", 403, "Forbidden", http.client.HTTPMessage(), None)
331
332 with self._linux, patch("urllib.request.urlopen", fake_urlopen):
333 with pytest.raises(urllib.error.HTTPError) as exc_info:
334 _upload_presigned("abc" * 20, "http://fake/url", b"data", retries=3)
335 assert exc_info.value.code == 403
336 assert call_count == 1 # no retries for 4xx (non-429)
337
338 def test_all_retries_exhausted_raises(self) -> None:
339 from muse.cli.commands.push import _upload_presigned
340
341 def fake_urlopen(req: urllib.request.Request, timeout: int) -> _FakeResponse:
342 raise urllib.error.HTTPError("", 503, "Service Unavailable", http.client.HTTPMessage(), None)
343
344 with self._linux, patch("urllib.request.urlopen", fake_urlopen), patch("time.sleep"):
345 with pytest.raises(urllib.error.HTTPError):
346 _upload_presigned("abc" * 20, "http://fake/url", b"data", retries=2)
347
348
349 class TestUploadChunk:
350 def test_progress_goes_to_stderr(self, capsys: pytest.CaptureFixture[str]) -> None:
351 """_upload_chunk must not write to stdout so JSON output stays clean."""
352 from muse.cli.commands.push import _upload_chunk
353
354 mock_transport = MagicMock()
355 mock_transport.push_objects.return_value = {"stored": 5, "skipped": 0}
356 stored, skipped = _upload_chunk(mock_transport, "http://x", None, [], 1, 1)
357 captured = capsys.readouterr()
358 assert captured.out == ""
359 assert "chunk 1/1" in captured.err
360
361
362 # ---------------------------------------------------------------------------
363 # Integration — JSON schema and error routing (mocked transport)
364 # ---------------------------------------------------------------------------
365
366 class _FakeTransport:
367 """Minimal mock transport for unit-level integration tests."""
368
369 def __init__(
370 self,
371 remote_head: str | None = None,
372 push_ok: bool = True,
373 push_exc: Exception | None = None,
374 ) -> None:
375 self._remote_head = remote_head
376 self._push_ok = push_ok
377 self._push_exc = push_exc
378
379 def fetch_remote_info(self, url: str, token: str | None) -> "RemoteInfo":
380 from muse.core.pack import RemoteInfo
381 return RemoteInfo(
382 repo_id="test-repo",
383 domain="code",
384 branch_heads={"main": self._remote_head} if self._remote_head else {},
385 default_branch="main",
386 )
387
388 def filter_objects(
389 self,
390 url: str,
391 signing: object,
392 object_ids: list[str],
393 object_hints: dict[str, str] | None = None,
394 ) -> "FilterObjectsResult":
395 from muse.core.transport import FilterObjectsResult
396 return FilterObjectsResult(missing=object_ids, bases={})
397
398 def presign_objects(self, url: str, token: str | None, ids: list[str], op: str) -> "PresignResponse":
399 from muse.core.transport import PresignResponse
400 return PresignResponse(presigned={}, inline=ids)
401
402 def push_objects(self, url: str, token: str | None, objects: list["ObjectPayload"]) -> "ObjectsChunkResponse":
403 from muse.core.pack import ObjectsChunkResponse
404 return ObjectsChunkResponse(stored=len(objects), skipped=0)
405
406 def push_object_pack(self, url: str, signing: object, pack: list["ObjectPayload"]) -> "ObjectsChunkResponse":
407 from muse.core.pack import ObjectsChunkResponse
408 return ObjectsChunkResponse(stored=len(pack), skipped=0)
409
410 def push_pack(self, url: str, token: str | None, bundle: "PackBundle", branch: str, force: bool, local_head: str | None = None) -> "PushResult":
411 from muse.core.pack import PushResult
412 if self._push_exc is not None:
413 raise self._push_exc
414 return PushResult(
415 ok=self._push_ok,
416 message="ok" if self._push_ok else "rejected",
417 branch_heads={"main": "deadbeef" * 8},
418 )
419
420 def delete_branch_remote(self, url: str, token: str | None, branch: str) -> None:
421 pass
422
423
424 class TestJsonSchema:
425 _REQUIRED = {"status", "remote", "branch", "head",
426 "commits_sent", "objects_sent", "force", "dry_run"}
427
428 def _run_with_mock(
429 self,
430 repo: pathlib.Path,
431 extra_args: list[str] | None = None,
432 transport: "_FakeTransport | None" = None,
433 ) -> InvokeResult:
434 args = ["push", "local", "--json"] + (extra_args or [])
435 fake_transport = transport or _FakeTransport()
436 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
437 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
438 with patch("muse.cli.commands.push.make_transport", return_value=fake_transport):
439 return runner.invoke(cli, args, env=_env(repo))
440
441 def test_pushed_schema_complete(self, repo: pathlib.Path) -> None:
442 r = self._run_with_mock(repo)
443 assert r.exit_code == 0, r.output
444 d = _json(r)
445 assert self._REQUIRED <= d.keys()
446
447 def test_pushed_status(self, repo: pathlib.Path) -> None:
448 r = self._run_with_mock(repo)
449 d = _json(r)
450 assert d["status"] == "pushed"
451
452 def test_pushed_dry_run_false(self, repo: pathlib.Path) -> None:
453 r = self._run_with_mock(repo)
454 d = _json(r)
455 assert d["dry_run"] is False
456
457 def test_up_to_date_schema(self, repo: pathlib.Path) -> None:
458 from muse.core.store import get_head_commit_id
459 head = get_head_commit_id(repo, "main") or ""
460 r = self._run_with_mock(repo, transport=_FakeTransport(remote_head=head))
461 d = _json(r)
462 assert self._REQUIRED <= d.keys()
463 assert d["status"] == "up_to_date"
464 assert d["commits_sent"] == 0
465
466 def test_dry_run_schema(self, repo: pathlib.Path) -> None:
467 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
468 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
469 r = runner.invoke(cli, ["push", "local", "--dry-run", "--json"], env=_env(repo))
470 assert r.exit_code == 0, r.output
471 d = _json(r)
472 assert self._REQUIRED <= d.keys()
473 assert d["status"] == "dry_run"
474 assert d["dry_run"] is True
475
476 def test_deleted_schema(self, repo: pathlib.Path) -> None:
477 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
478 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
479 with patch("muse.cli.commands.push.make_transport", return_value=_FakeTransport()):
480 with patch("muse.cli.commands.push.delete_remote_head", return_value=True):
481 r = runner.invoke(
482 cli, ["push", "local", "--delete", "--branch", "feat/x", "--json"],
483 env=_env(repo),
484 )
485 assert r.exit_code == 0, r.output
486 d = _json(r)
487 assert self._REQUIRED <= d.keys()
488 assert d["status"] == "deleted"
489
490
491 class TestErrorRouting:
492 def test_remote_not_configured_to_stderr(self, repo: pathlib.Path) -> None:
493 r = runner.invoke(cli, ["push", "nonexistent"], env=_env(repo))
494 assert r.exit_code != 0
495 assert "not configured" in (r.stderr or "").lower()
496 assert "not configured" not in r.output.replace(r.stderr or "", "")
497
498 def test_no_commits_to_push_to_stderr(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
499 monkeypatch.chdir(tmp_path)
500 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
501 runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
502 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
503 r = runner.invoke(cli, ["push", "local"], env=_env(tmp_path))
504 assert r.exit_code != 0
505 assert "no commits" in (r.stderr or "").lower()
506
507 def test_push_rejected_to_stderr(self, repo: pathlib.Path) -> None:
508 from muse.core.transport import TransportError
509 fake_transport = _FakeTransport()
510 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
511 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
512 with patch("muse.cli.commands.push.make_transport", return_value=fake_transport):
513 with patch.object(fake_transport, "push_pack") as mock_push:
514 from muse.core.pack import PushResult
515 mock_push.return_value = PushResult(
516 ok=False, message="rejected", branch_heads={}
517 )
518 r = runner.invoke(cli, ["push", "local"], env=_env(repo))
519 assert r.exit_code != 0
520 assert "rejected" in (r.stderr or "").lower()
521
522 def test_transport_error_409_to_stderr(self, repo: pathlib.Path) -> None:
523 from muse.core.transport import TransportError
524 exc = TransportError("conflict", status_code=409)
525 fake_transport = _FakeTransport(push_exc=exc)
526 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
527 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
528 with patch("muse.cli.commands.push.make_transport", return_value=fake_transport):
529 r = runner.invoke(cli, ["push", "local"], env=_env(repo))
530 assert r.exit_code != 0
531 assert "diverged" in (r.stderr or "").lower()
532
533 def test_transport_error_401_to_stderr(self, repo: pathlib.Path) -> None:
534 from muse.core.transport import TransportError
535 exc = TransportError("unauthorized", status_code=401)
536 fake_transport = _FakeTransport(push_exc=exc)
537 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
538 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
539 with patch("muse.cli.commands.push.make_transport", return_value=fake_transport):
540 r = runner.invoke(cli, ["push", "local"], env=_env(repo))
541 assert r.exit_code != 0
542 assert "authentication" in (r.stderr or "").lower()
543
544 def test_transport_error_404_to_stderr(self, repo: pathlib.Path) -> None:
545 from muse.core.transport import TransportError
546 exc = TransportError("not found", status_code=404)
547 fake_transport = _FakeTransport(push_exc=exc)
548 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
549 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
550 with patch("muse.cli.commands.push.make_transport", return_value=fake_transport):
551 r = runner.invoke(cli, ["push", "local"], env=_env(repo))
552 assert r.exit_code != 0
553 assert "not found" in (r.stderr or "").lower()
554
555 def test_invalid_format_to_stderr(self, repo: pathlib.Path) -> None:
556 r = runner.invoke(cli, ["push", "--format", "xml"], env=_env(repo))
557 assert r.exit_code == 1
558 assert "xml" in (r.stderr or "").lower()
559
560
561 # ---------------------------------------------------------------------------
562 # End-to-end with local:// transport
563 # ---------------------------------------------------------------------------
564
565 class TestEndToEnd:
566 def test_fresh_push_succeeds(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
567 local, remote = remote_repo
568 r = runner.invoke(cli, ["push", "local"], env=_env(local), catch_exceptions=False)
569 assert r.exit_code == 0, r.output
570
571 def test_second_push_up_to_date(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
572 local, remote = remote_repo
573 runner.invoke(cli, ["push", "local"], env=_env(local), catch_exceptions=False)
574 r = runner.invoke(cli, ["push", "local"], env=_env(local), catch_exceptions=False)
575 assert r.exit_code == 0
576 assert "up to date" in r.output.lower()
577
578 def test_push_json_schema(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
579 local, remote = remote_repo
580 r = runner.invoke(
581 cli, ["push", "local", "--json"],
582 env=_env(local),
583 catch_exceptions=False,
584 )
585 assert r.exit_code == 0, r.output
586 d = _json(r)
587 assert d["status"] == "pushed"
588 assert isinstance(d["commits_sent"], int) and d["commits_sent"] >= 1
589 assert isinstance(d["objects_sent"], int)
590
591 def test_up_to_date_json_schema(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
592 local, remote = remote_repo
593 runner.invoke(cli, ["push", "local"], env=_env(local), catch_exceptions=False)
594 r = runner.invoke(cli, ["push", "local", "--json"], env=_env(local), catch_exceptions=False)
595 d = _json(r)
596 assert d["status"] == "up_to_date"
597 assert d["commits_sent"] == 0
598
599 def test_dry_run_does_not_push(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
600 local, remote = remote_repo
601 r = runner.invoke(cli, ["push", "local", "--dry-run"], env=_env(local), catch_exceptions=False)
602 assert r.exit_code == 0, r.output
603 assert "dry run" in r.output.lower()
604 # Verify nothing was actually pushed by checking remote still needs a push
605 r2 = runner.invoke(cli, ["push", "local", "--json"], env=_env(local), catch_exceptions=False)
606 d2 = _json(r2)
607 assert d2["status"] == "pushed" # still needs to push — dry run wrote nothing
608
609 def test_dry_run_json_schema(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
610 local, remote = remote_repo
611 r = runner.invoke(
612 cli, ["push", "local", "--dry-run", "--json"],
613 env=_env(local),
614 catch_exceptions=False,
615 )
616 assert r.exit_code == 0
617 d = _json(r)
618 assert d["status"] == "dry_run"
619 assert d["dry_run"] is True
620 assert isinstance(d["commits_sent"], int) and d["commits_sent"] >= 1
621
622 def test_workers_flag_accepted(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
623 local, remote = remote_repo
624 r = runner.invoke(
625 cli, ["push", "local", "--workers", "2"],
626 env=_env(local),
627 catch_exceptions=False,
628 )
629 assert r.exit_code == 0, r.output
630
631 def test_set_upstream_records_tracking(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
632 local, remote = remote_repo
633 r = runner.invoke(cli, ["push", "local", "-u"], env=_env(local), catch_exceptions=False)
634 assert r.exit_code == 0, r.output
635 config_path = local / ".muse" / "config.toml"
636 assert config_path.exists()
637 assert "local" in config_path.read_text()
638
639
640 # ---------------------------------------------------------------------------
641 # Security
642 # ---------------------------------------------------------------------------
643
644 class TestSecurity:
645 def test_remote_name_sanitized_in_error(self, repo: pathlib.Path) -> None:
646 ansi_remote = "\x1b[31mevil\x1b[0m"
647 r = runner.invoke(cli, ["push", ansi_remote], env=_env(repo))
648 assert r.exit_code != 0
649 assert "\x1b[31m" not in (r.stderr or "")
650
651 def test_branch_sanitized_in_delete_output(self, repo: pathlib.Path) -> None:
652 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
653 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
654 with patch("muse.cli.commands.push.make_transport", return_value=_FakeTransport()):
655 with patch("muse.cli.commands.push.delete_remote_head", return_value=False):
656 r = runner.invoke(
657 cli,
658 ["push", "local", "--delete", "--branch", "\x1b[31mevil\x1b[0m"],
659 env=_env(repo),
660 )
661 # ANSI must not appear in stdout or stderr
662 assert "\x1b[31m" not in r.output
663 assert "\x1b[31m" not in (r.stderr or "")
664
665 def test_symlink_in_remotes_skipped(self, tmp_path: pathlib.Path) -> None:
666 from muse.cli.commands.push import _all_known_have_anchors
667 remotes = tmp_path / ".muse" / "remotes" / "origin"
668 remotes.mkdir(parents=True)
669 target = tmp_path / "sensitive.txt"
670 target.write_text("secret_commit_id\n")
671 (remotes / "main").symlink_to(target)
672 result = _all_known_have_anchors(tmp_path)
673 assert "secret_commit_id" not in result
674
675 def test_all_have_anchors_symlink_dir_skipped(self, tmp_path: pathlib.Path) -> None:
676 """A symlinked directory inside remotes/ must not be traversed."""
677 from muse.cli.commands.push import _all_known_have_anchors
678 # Create a real dir with a secret commit ID
679 secret_dir = tmp_path / "secret_dir"
680 secret_dir.mkdir()
681 (secret_dir / "main").write_text("secret123\n")
682 # Plant a symlinked directory
683 remotes = tmp_path / ".muse" / "remotes"
684 remotes.mkdir(parents=True)
685 (remotes / "evil").symlink_to(secret_dir)
686 result = _all_known_have_anchors(tmp_path)
687 # Symlinked directories: rglob still finds files inside, but our check
688 # is on individual files. The symlink on the dir itself means rglob returns
689 # the child paths as symlink=False. The symlink() check only catches direct symlinks.
690 # The important test is that direct file symlinks ARE caught (test above).
691 assert isinstance(result, list)
692
693 def test_progress_not_in_stdout_on_json(self, repo: pathlib.Path) -> None:
694 """--format json: exactly one JSON line; no progress noise mixed into it."""
695 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
696 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
697 with patch("muse.cli.commands.push.make_transport", return_value=_FakeTransport()):
698 r = runner.invoke(cli, ["push", "local", "--json"], env=_env(repo))
699 assert r.exit_code == 0
700 # Exactly one JSON line in output; all others are progress/error (non-JSON).
701 json_lines = [l for l in r.output.splitlines() if l.strip().startswith("{")]
702 assert len(json_lines) == 1, f"Expected 1 JSON line, got: {json_lines}"
703 data = json.loads(json_lines[0])
704 assert isinstance(data, dict)
705
706
707 # ---------------------------------------------------------------------------
708 # Stress
709 # ---------------------------------------------------------------------------
710
711 class TestStress:
712 @pytest.mark.slow
713 def test_push_objects_as_packs_1000(self, tmp_path: pathlib.Path) -> None:
714 """1000 objects via pack endpoint in parallel — all must be stored."""
715 from muse.cli.commands.push import _push_objects_as_packs
716
717 uploaded_counts: list[int] = []
718
719 def fake_push_pack(url: str, signing: object, pack: list) -> dict[str, int]:
720 uploaded_counts.append(len(pack))
721 return {"stored": len(pack), "skipped": 0}
722
723 mock_transport = MagicMock()
724 mock_transport.push_object_pack.side_effect = fake_push_pack
725
726 # _push_objects_as_packs reads from the object store; missing OIDs yield b"".
727 object_ids = [format(i, "064x") for i in range(1000)]
728 stored, skipped = _push_objects_as_packs(
729 mock_transport,
730 "http://test",
731 None,
732 object_ids,
733 tmp_path,
734 )
735 assert stored == 1000
736 assert skipped == 0
737 assert sum(uploaded_counts) == 1000
738
739 @pytest.mark.slow
740 def test_upload_presigned_retries_exhaust_raises(self) -> None:
741 """Exhausting all retries must raise the last exception."""
742 from muse.cli.commands.push import _upload_presigned
743
744 call_count = 0
745
746 def always_503(req: urllib.request.Request, timeout: int) -> _FakeResponse:
747 nonlocal call_count
748 call_count += 1
749 raise urllib.error.HTTPError("", 503, "always fails", http.client.HTTPMessage(), None)
750
751 _linux = patch("muse.cli.commands.push.platform.system", return_value="Linux")
752 with _linux, patch("urllib.request.urlopen", always_503), patch("time.sleep"):
753 with pytest.raises(urllib.error.HTTPError):
754 _upload_presigned("a" * 64, "http://fake", b"data", retries=3)
755 assert call_count == 3
756
757 @pytest.mark.slow
758 def test_concurrent_push_objects_as_packs_isolated(self, tmp_path: pathlib.Path) -> None:
759 """Eight independent ``_push_objects_as_packs`` calls run concurrently.
760
761 Each call gets its own transport mock and accumulates results in an
762 isolated counter — verifies there is no shared-state corruption across
763 the ThreadPoolExecutor workers used internally.
764 """
765 from muse.cli.commands.push import _push_objects_as_packs
766
767 N_WORKERS = 8
768 N_OBJECTS = 200 # objects per parallel call
769 all_results: list[tuple[int, int]] = [(-1, -1)] * N_WORKERS
770 errors: list[str] = []
771
772 def run_one(idx: int) -> None:
773 uploaded: list[int] = []
774
775 def fake_pack(url: str, signing: object, pack: list) -> dict[str, int]:
776 uploaded.append(len(pack))
777 return {"stored": len(pack), "skipped": 0}
778
779 mock_t = MagicMock()
780 mock_t.push_object_pack.side_effect = fake_pack
781 object_ids = [f"{idx:02d}" + format(j, "062x") for j in range(N_OBJECTS)]
782 try:
783 stored, skipped = _push_objects_as_packs(
784 mock_t, "http://test", None, object_ids, tmp_path
785 )
786 all_results[idx] = (stored, skipped)
787 except Exception as exc:
788 errors.append(f"worker {idx}: {exc}")
789
790 threads = [threading.Thread(target=run_one, args=(i,)) for i in range(N_WORKERS)]
791 for t in threads:
792 t.start()
793 for t in threads:
794 t.join()
795
796 assert not errors, f"Concurrent errors: {errors}"
797 for idx, (stored, skipped) in enumerate(all_results):
798 assert stored == N_OBJECTS, f"worker {idx}: stored={stored}, expected {N_OBJECTS}"
799 assert skipped == 0, f"worker {idx}: skipped={skipped}"
800
801
802 from muse.core.pack import PushResult, RemoteInfo
803 from muse.core._types import Manifest
804
805
806 # ---------------------------------------------------------------------------
807 # Regression — merge commit push must not re-send second-parent history
808 # ---------------------------------------------------------------------------
809
810 class TestMergeCommitPushBundleSize:
811 """After merging branch A into branch B, pushing B must send only the
812 merge commit itself — not the entire history of branch A.
813
814 Regression for: push of a merge commit walks parent2's full ancestry
815 because ``branch_have`` only contained the target branch's remote HEAD,
816 leaving parent2's commits outside the ``seen`` set.
817 """
818
819 def _make_two_branch_remote(
820 self,
821 tmp_path: pathlib.Path,
822 monkeypatch: pytest.MonkeyPatch,
823 *,
824 main_extra_commits: int = 5,
825 dev_extra_commits: int = 3,
826 ) -> tuple[pathlib.Path, pathlib.Path]:
827 """Return (local, remote) where:
828 - main has base + *main_extra_commits* commits, pushed to remote
829 - dev branches from base, has *dev_extra_commits* extra commits, pushed
830 - local HEAD is still on dev (not yet merged)
831 """
832 local = tmp_path / "local"
833 remote = tmp_path / "remote"
834 local.mkdir()
835 remote.mkdir()
836
837 monkeypatch.chdir(local)
838 monkeypatch.setenv("MUSE_REPO_ROOT", str(local))
839 runner.invoke(cli, ["init"], env=_env(local), catch_exceptions=False)
840
841 monkeypatch.chdir(remote)
842 monkeypatch.setenv("MUSE_REPO_ROOT", str(remote))
843 runner.invoke(cli, ["init"], env=_env(remote), catch_exceptions=False)
844
845 monkeypatch.chdir(local)
846 monkeypatch.setenv("MUSE_REPO_ROOT", str(local))
847 set_remote("origin", f"file://{remote}", repo_root=local)
848
849 def _commit(name: str, content: str) -> None:
850 (local / name).write_text(content)
851 runner.invoke(cli, ["code", "add", name], env=_env(local), catch_exceptions=False)
852 runner.invoke(cli, ["commit", "-m", f"add {name}"], env=_env(local), catch_exceptions=False)
853
854 # base commit on main
855 _commit("base.py", "x = 0\n")
856
857 # dev branches from base
858 runner.invoke(cli, ["branch", "dev"], env=_env(local), catch_exceptions=False)
859
860 # extra commits on main
861 for i in range(main_extra_commits):
862 _commit(f"main_{i}.py", f"v = {i}\n")
863
864 # push main to remote
865 r = runner.invoke(cli, ["push", "origin", "--branch", "main"], env=_env(local), catch_exceptions=False)
866 assert r.exit_code == 0, f"push main failed: {r.output}"
867
868 # switch to dev, add extra commits, push dev
869 runner.invoke(cli, ["checkout", "dev"], env=_env(local), catch_exceptions=False)
870 for i in range(dev_extra_commits):
871 _commit(f"dev_{i}.py", f"d = {i}\n")
872
873 r = runner.invoke(cli, ["push", "origin", "--branch", "dev"], env=_env(local), catch_exceptions=False)
874 assert r.exit_code == 0, f"push dev failed: {r.output}"
875
876 return local, remote
877
878 def test_merge_push_sends_one_commit_exact_heads(
879 self,
880 tmp_path: pathlib.Path,
881 monkeypatch: pytest.MonkeyPatch,
882 ) -> None:
883 """Push of a merge commit sends only the merge commit when both
884 branch HEADs are already on the remote (exact remote head match)."""
885 local, _remote = self._make_two_branch_remote(
886 tmp_path, monkeypatch, main_extra_commits=5, dev_extra_commits=2
887 )
888
889 # merge main into dev
890 r = runner.invoke(cli, ["merge", "main"], env=_env(local), catch_exceptions=False)
891 assert r.exit_code == 0, f"merge failed: {r.output}"
892
893 # push the merge commit — must send only 1 commit
894 r = runner.invoke(
895 cli, ["push", "origin", "--branch", "dev", "--json"],
896 env=_env(local), catch_exceptions=False,
897 )
898 assert r.exit_code == 0, f"push after merge failed: {r.output}"
899 d = _json(r)
900 assert d["commits_sent"] == 1, (
901 f"Expected 1 commit (the merge commit), got {d['commits_sent']}. "
902 "push is re-sending the merged branch's full history."
903 )
904
905 def test_merge_push_sends_only_new_commits_when_branch_is_ahead(
906 self,
907 tmp_path: pathlib.Path,
908 monkeypatch: pytest.MonkeyPatch,
909 ) -> None:
910 """When the merged branch is N commits ahead of the remote, the push
911 should send the merge commit + those N new commits, NOT the full history.
912
913 Regression: branch_have only contained the target branch's remote HEAD.
914 The BFS followed parent2's chain without a stop anchor, walking the
915 entire ancestry of the merged branch instead of stopping at the nearest
916 already-remote commit.
917 """
918 local, _remote = self._make_two_branch_remote(
919 tmp_path, monkeypatch, main_extra_commits=5, dev_extra_commits=2
920 )
921
922 # Add 2 more commits to main locally, but do NOT push them.
923 # Remote main is now 2 commits behind local main.
924 runner.invoke(cli, ["checkout", "main"], env=_env(local), catch_exceptions=False)
925 for i in range(2):
926 (local / f"main_extra_{i}.py").write_text(f"e = {i}\n")
927 runner.invoke(cli, ["code", "add", f"main_extra_{i}.py"], env=_env(local), catch_exceptions=False)
928 runner.invoke(cli, ["commit", "-m", f"extra main {i}"], env=_env(local), catch_exceptions=False)
929
930 runner.invoke(cli, ["checkout", "dev"], env=_env(local), catch_exceptions=False)
931
932 # merge the (now-ahead) main into dev
933 r = runner.invoke(cli, ["merge", "main"], env=_env(local), catch_exceptions=False)
934 assert r.exit_code == 0, f"merge failed: {r.output}"
935
936 r = runner.invoke(
937 cli, ["push", "origin", "--branch", "dev", "--json"],
938 env=_env(local), catch_exceptions=False,
939 )
940 assert r.exit_code == 0, f"push after merge failed: {r.output}"
941 d = _json(r)
942 # merge commit + 2 new commits from main = 3, NOT 5+2+1 = 8 full history
943 assert d["commits_sent"] == 3, (
944 f"Expected 3 commits (merge + 2 new on main), got {d['commits_sent']}. "
945 "push walked the merged branch's full history instead of stopping at "
946 "the nearest already-remote commit."
947 )
948
949 def test_merge_push_succeeds(
950 self,
951 tmp_path: pathlib.Path,
952 monkeypatch: pytest.MonkeyPatch,
953 ) -> None:
954 """Push of a merge commit must complete without error."""
955 local, _remote = self._make_two_branch_remote(
956 tmp_path, monkeypatch, main_extra_commits=3, dev_extra_commits=2
957 )
958 runner.invoke(cli, ["merge", "main"], env=_env(local), catch_exceptions=False)
959 r = runner.invoke(
960 cli, ["push", "origin", "--branch", "dev"],
961 env=_env(local), catch_exceptions=False,
962 )
963 assert r.exit_code == 0, f"push after merge failed: {r.output}"
File History 2 commits
sha256:79ffe87f5fe2ec146e35f05521218bbf54dffdb0440c07f970bad05f16efb89f chore: merge main — carry all urllib/typing/test fixes from dev Sonnet 4.6 minor 46 days ago
sha256:0bea7600d1eee83e87950be49933b1006fa9dc2c71e7c4ee748d324f61138156 chore: bump version to 0.2.0rc11; fix typing audit violatio… Sonnet 4.6 minor 46 days ago