gabriel / muse public
test_cmd_push_hardening.py python
829 lines 34.6 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 121 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, --json/-j
8 - Dead-code removal: _current_branch absent
9 - _all_known_have_anchors: symlink skipping, binary-file safety, missing dir
10 - _upload_chunk: progress goes to stderr, not stdout
11 - _PushJson TypedDict keys complete
12
13 Integration (with mocked transport)
14 ------------------------------------
15 - Error messages routed to stderr, stdout clean on errors
16 - remote not configured → stderr
17 - branch has no commits → stderr
18 - push rejected (result.ok=False) → stderr
19 - up_to_date JSON schema complete
20 - pushed JSON schema complete
21 - dry_run JSON schema complete
22 - deleted JSON schema complete
23 - --dry-run: no transport calls, correct counts
24 - --workers accepted without error
25 - --set-upstream records tracking ref
26 - 409/401/404/other TransportError → stderr + exit 1
27
28 End-to-end (local:// transport)
29 ---------------------------------
30 - Fresh push succeeds
31 - Second push (up_to_date) exits 0
32 - --dry-run shows would-push info without writing
33 - --json produces valid JSON
34 - --force bypasses fast-forward check
35
36 Security
37 --------
38 - remote name sanitized in all error messages
39 - branch name sanitized in delete output
40 - del_branch sanitized in already-gone path
41 - _all_known_have_anchors: planted symlink skipped
42 - _all_known_have_anchors: binary file skipped
43 - unknown flag exits non-zero
44 - progress prints from _upload_chunk go to stderr
45
46 Stress
47 ------
48 - _push_objects_parallel with 1000 objects (mocked transport)
49 - concurrent push runs to isolated repos
50 """
51
52 from __future__ import annotations
53
54 type _IntMap = dict[str, int]
55
56 import argparse
57 import http.client
58 import inspect
59 import json
60 import os
61 import pathlib
62 import tempfile
63 import threading
64 import time
65 import types
66 import urllib.error
67 import urllib.request
68 from typing import TYPE_CHECKING
69 from unittest.mock import MagicMock, patch
70
71 import pytest
72
73 from muse.cli.config import set_remote
74 from muse.core.types import long_id
75 from tests.cli_test_helper import CliRunner, InvokeResult
76
77 if TYPE_CHECKING:
78 import httpx
79 from muse.cli.commands.push import _PushJson
80 from muse.core.pack import CommitDict, ObjectPayload, RemoteInfo, SnapshotDict
81 from muse.core.transport import PushResult, SigningIdentity
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
185 class TestRegisterFlags:
186 def _parse(self, *args: str) -> argparse.Namespace:
187 import muse.cli.commands.push as m
188 p = argparse.ArgumentParser()
189 sub = p.add_subparsers()
190 m.register(sub)
191 return p.parse_args(["push", *args])
192
193 def test_dry_run_short(self) -> None:
194 ns = self._parse("-n")
195 assert ns.dry_run is True
196
197 def test_dry_run_long(self) -> None:
198 ns = self._parse("--dry-run")
199 assert ns.dry_run is True
200
201 def test_workers_default(self) -> None:
202 ns = self._parse()
203 assert ns.workers == 16
204
205 def test_workers_custom(self) -> None:
206 ns = self._parse("--workers", "8")
207 assert ns.workers == 8
208
209 def test_default_json_out_is_false(self) -> None:
210 ns = self._parse()
211 assert ns.json_out is False
212
213 def test_json_flag_sets_json_out(self) -> None:
214 ns = self._parse("--json")
215 assert ns.json_out is True
216
217 def test_j_shorthand_sets_json_out(self) -> None:
218 ns = self._parse("-j")
219 assert ns.json_out is True
220
221 def test_force_flag(self) -> None:
222 ns = self._parse("--force")
223 assert ns.force is True
224
225 def test_delete_flag(self) -> None:
226 ns = self._parse("--delete")
227 assert ns.delete_branch is True
228
229 def test_set_upstream_short(self) -> None:
230 ns = self._parse("-u")
231 assert ns.set_upstream_flag is True
232
233
234 class TestAllKnownHaveAnchors:
235 def test_no_remotes_dir_returns_empty(self, tmp_path: pathlib.Path) -> None:
236 from muse.cli.commands.push import _all_known_have_anchors
237 assert _all_known_have_anchors(tmp_path) == []
238
239 def test_reads_commit_ids(self, tmp_path: pathlib.Path) -> None:
240 from muse.cli.commands.push import _all_known_have_anchors
241 remotes = remotes_dir(tmp_path) / "origin"
242 remotes.mkdir(parents=True)
243 cid = long_id("a" * 64)
244 (remotes / "main").write_text(cid + "\n")
245 result = _all_known_have_anchors(tmp_path)
246 assert cid in result
247
248 def test_symlinks_are_skipped(self, tmp_path: pathlib.Path) -> None:
249 from muse.cli.commands.push import _all_known_have_anchors
250 remotes = remotes_dir(tmp_path) / "origin"
251 remotes.mkdir(parents=True)
252 target = tmp_path / "secret.txt"
253 target.write_text("abc123\n")
254 (remotes / "main").symlink_to(target)
255 result = _all_known_have_anchors(tmp_path)
256 # Symlink should not be followed — abc123 should NOT appear
257 assert "abc123" not in result
258
259 def test_binary_file_skipped_not_crashed(self, tmp_path: pathlib.Path) -> None:
260 from muse.cli.commands.push import _all_known_have_anchors
261 remotes = remotes_dir(tmp_path) / "origin"
262 remotes.mkdir(parents=True)
263 (remotes / "bin_ref").write_bytes(b"\x00\x01\x02\xff")
264 # Should not raise
265 result = _all_known_have_anchors(tmp_path)
266 # Binary content with \x00 stripped by errors='ignore' → not a valid ID
267 assert isinstance(result, list)
268
269 def test_empty_files_skipped(self, tmp_path: pathlib.Path) -> None:
270 from muse.cli.commands.push import _all_known_have_anchors
271 remotes = remotes_dir(tmp_path) / "origin"
272 remotes.mkdir(parents=True)
273 (remotes / "empty").write_text("")
274 result = _all_known_have_anchors(tmp_path)
275 assert result == []
276
277 def test_multiple_remotes(self, tmp_path: pathlib.Path) -> None:
278 from muse.cli.commands.push import _all_known_have_anchors
279 cids = {name: long_id(c * 64) for name, c in zip(["origin", "upstream", "fork"], "abc")}
280 for name, cid in cids.items():
281 d = remotes_dir(tmp_path) / name
282 d.mkdir(parents=True)
283 (d / "main").write_text(cid + "\n")
284 result = _all_known_have_anchors(tmp_path)
285 assert len(result) == 3
286 assert cids["origin"] in result
287
288
289
290 # ---------------------------------------------------------------------------
291 # Integration — JSON schema and error routing (mocked transport)
292 # ---------------------------------------------------------------------------
293
294 class _FakeTransport:
295 """Minimal mock transport for unit-level integration tests."""
296
297 def __init__(
298 self,
299 remote_head: str | None = None,
300 push_ok: bool = True,
301 push_exc: Exception | None = None,
302 ) -> None:
303 self._remote_head = remote_head
304 self._push_ok = push_ok
305 self._push_exc = push_exc
306
307 def fetch_remote_info(self, url: str, token: str | None) -> "RemoteInfo":
308 from muse.core.pack import RemoteInfo
309 return RemoteInfo(
310 repo_id="test-repo",
311 domain="code",
312 branch_heads={"main": self._remote_head} if self._remote_head else {},
313 default_branch="main",
314 )
315
316 async def push_stream_coro(
317 self,
318 client: "httpx.AsyncClient",
319 url: str,
320 signing: "SigningIdentity | None",
321 objects: "list[ObjectPayload]",
322 commits: "list[CommitDict]",
323 snapshots: "list[SnapshotDict]",
324 branch: str,
325 force: bool,
326 have: list[str],
327 local_head: str | None = None,
328 ) -> "PushResult":
329 from muse.core.transport import PushResult
330 if self._push_exc is not None:
331 raise self._push_exc
332 return PushResult(
333 ok=self._push_ok,
334 message="ok" if self._push_ok else "rejected",
335 branch_heads={"main": "deadbeef" * 8},
336 )
337
338 def delete_branch_remote(self, url: str, token: str | None, branch: str) -> None:
339 pass
340
341
342 class TestJsonSchema:
343 _REQUIRED = {"status", "remote", "branch", "head",
344 "commits_sent", "objects_sent", "force", "dry_run"}
345
346 def _run_with_mock(
347 self,
348 repo: pathlib.Path,
349 extra_args: list[str] | None = None,
350 transport: "_FakeTransport | None" = None,
351 ) -> InvokeResult:
352 args = ["push", "local", "--json"] + (extra_args or [])
353 fake_transport = transport or _FakeTransport()
354 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
355 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
356 with patch("muse.cli.commands.push.make_transport", return_value=fake_transport):
357 return runner.invoke(cli, args, env=_env(repo))
358
359 def test_pushed_schema_complete(self, repo: pathlib.Path) -> None:
360 r = self._run_with_mock(repo)
361 assert r.exit_code == 0, r.output
362 d = _json(r)
363 assert self._REQUIRED <= d.keys()
364
365 def test_pushed_status(self, repo: pathlib.Path) -> None:
366 r = self._run_with_mock(repo)
367 d = _json(r)
368 assert d["status"] == "pushed"
369
370 def test_pushed_dry_run_false(self, repo: pathlib.Path) -> None:
371 r = self._run_with_mock(repo)
372 d = _json(r)
373 assert d["dry_run"] is False
374
375 def test_up_to_date_schema(self, repo: pathlib.Path) -> None:
376 from muse.core.store import get_head_commit_id
377 head = get_head_commit_id(repo, "main") or ""
378 r = self._run_with_mock(repo, transport=_FakeTransport(remote_head=head))
379 d = _json(r)
380 assert self._REQUIRED <= d.keys()
381 assert d["status"] == "up_to_date"
382 assert d["commits_sent"] == 0
383
384 def test_dry_run_schema(self, repo: pathlib.Path) -> None:
385 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
386 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
387 r = runner.invoke(cli, ["push", "local", "--dry-run", "--json"], env=_env(repo))
388 assert r.exit_code == 0, r.output
389 d = _json(r)
390 assert self._REQUIRED <= d.keys()
391 assert d["status"] == "dry_run"
392 assert d["dry_run"] is True
393
394 def test_deleted_schema(self, repo: pathlib.Path) -> None:
395 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
396 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
397 with patch("muse.cli.commands.push.make_transport", return_value=_FakeTransport()):
398 with patch("muse.cli.commands.push.delete_remote_head", return_value=True):
399 r = runner.invoke(
400 cli, ["push", "local", "--delete", "--branch", "feat/x", "--json"],
401 env=_env(repo),
402 )
403 assert r.exit_code == 0, r.output
404 d = _json(r)
405 assert self._REQUIRED <= d.keys()
406 assert d["status"] == "deleted"
407
408
409 class TestErrorRouting:
410 def test_remote_not_configured_to_stderr(self, repo: pathlib.Path) -> None:
411 r = runner.invoke(cli, ["push", "nonexistent"], env=_env(repo))
412 assert r.exit_code != 0
413 assert "not configured" in (r.stderr or "").lower()
414 assert "not configured" not in r.output.replace(r.stderr or "", "")
415
416 def test_remote_not_configured_lists_none_when_no_remotes(
417 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
418 ) -> None:
419 """Error message includes 'Configured remotes: (none)' when repo has no remotes.
420
421 Agents need this to know immediately that no remote exists, without
422 a follow-up ``muse remote --json`` call.
423 """
424 monkeypatch.chdir(tmp_path)
425 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
426 # Suppress handle resolution so muse init does not auto-wire default
427 # remotes (local/staging/production). The test requires a truly empty
428 # remote config to exercise the "(none)" branch.
429 with patch("muse.cli.commands.init.resolve_default_handle", return_value=None):
430 runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
431 (tmp_path / "a.py").write_text("x = 1\n")
432 runner.invoke(cli, ["commit", "-m", "base"], env=_env(tmp_path), catch_exceptions=False)
433 r = runner.invoke(cli, ["push", "local"], env=_env(tmp_path))
434 assert r.exit_code != 0
435 stderr = r.stderr or ""
436 assert "configured remotes: (none)" in stderr.lower()
437
438 def test_remote_not_configured_lists_existing_remotes(
439 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
440 ) -> None:
441 """Error message lists configured remote names when the named remote is absent.
442
443 Agents can read the list to discover the correct remote name without
444 a separate ``muse remote --json`` call.
445 """
446 monkeypatch.chdir(tmp_path)
447 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
448 # Suppress handle resolution so muse init does not auto-wire default
449 # remotes. We explicitly add "origin" below; "never-configured" is
450 # guaranteed absent regardless of authentication state.
451 with patch("muse.cli.commands.init.resolve_default_handle", return_value=None):
452 runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
453 (tmp_path / "a.py").write_text("x = 1\n")
454 runner.invoke(cli, ["commit", "-m", "base"], env=_env(tmp_path), catch_exceptions=False)
455 # Configure a remote named "origin" but push to "never-configured" (absent).
456 set_remote("origin", "file:///dev/null", repo_root=tmp_path)
457 r = runner.invoke(cli, ["push", "never-configured"], env=_env(tmp_path))
458 assert r.exit_code != 0
459 stderr = r.stderr or ""
460 assert "origin" in stderr
461 assert "configured remotes:" in stderr.lower()
462
463 def test_no_commits_to_push_to_stderr(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
464 monkeypatch.chdir(tmp_path)
465 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
466 runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
467 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
468 r = runner.invoke(cli, ["push", "local"], env=_env(tmp_path))
469 assert r.exit_code != 0
470 assert "no commits" in (r.stderr or "").lower()
471
472 def test_push_rejected_to_stderr(self, repo: pathlib.Path) -> None:
473 fake_transport = _FakeTransport(push_ok=False)
474 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
475 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
476 with patch("muse.cli.commands.push.make_transport", return_value=fake_transport):
477 r = runner.invoke(cli, ["push", "local"], env=_env(repo))
478 assert r.exit_code != 0
479 assert "rejected" in (r.stderr or "").lower()
480
481 def test_transport_error_409_to_stderr(self, repo: pathlib.Path) -> None:
482 from muse.core.transport import TransportError
483 exc = TransportError("conflict", status_code=409)
484 fake_transport = _FakeTransport(push_exc=exc)
485 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
486 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
487 with patch("muse.cli.commands.push.make_transport", return_value=fake_transport):
488 r = runner.invoke(cli, ["push", "local"], env=_env(repo))
489 assert r.exit_code != 0
490 assert "diverged" in (r.stderr or "").lower()
491
492 def test_transport_error_401_to_stderr(self, repo: pathlib.Path) -> None:
493 from muse.core.transport import TransportError
494 exc = TransportError("unauthorized", status_code=401)
495 fake_transport = _FakeTransport(push_exc=exc)
496 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
497 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
498 with patch("muse.cli.commands.push.make_transport", return_value=fake_transport):
499 r = runner.invoke(cli, ["push", "local"], env=_env(repo))
500 assert r.exit_code != 0
501 assert "authentication" in (r.stderr or "").lower()
502
503 def test_transport_error_404_to_stderr(self, repo: pathlib.Path) -> None:
504 from muse.core.transport import TransportError
505 exc = TransportError("not found", status_code=404)
506 fake_transport = _FakeTransport(push_exc=exc)
507 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
508 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
509 with patch("muse.cli.commands.push.make_transport", return_value=fake_transport):
510 r = runner.invoke(cli, ["push", "local"], env=_env(repo))
511 assert r.exit_code != 0
512 assert "not found" in (r.stderr or "").lower()
513
514 def test_unknown_flag_exits_nonzero(self, repo: pathlib.Path) -> None:
515 r = runner.invoke(cli, ["push", "--format", "xml"], env=_env(repo))
516 assert r.exit_code != 0
517
518
519 # ---------------------------------------------------------------------------
520 # End-to-end with local:// transport
521 # ---------------------------------------------------------------------------
522
523 class TestEndToEnd:
524 def test_fresh_push_succeeds(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
525 local, remote = remote_repo
526 r = runner.invoke(cli, ["push", "local"], env=_env(local), catch_exceptions=False)
527 assert r.exit_code == 0, r.output
528
529 def test_second_push_up_to_date(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
530 local, remote = remote_repo
531 runner.invoke(cli, ["push", "local"], env=_env(local), catch_exceptions=False)
532 r = runner.invoke(cli, ["push", "local"], env=_env(local), catch_exceptions=False)
533 assert r.exit_code == 0
534 assert "up to date" in r.output.lower()
535
536 def test_push_json_schema(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
537 local, remote = remote_repo
538 r = runner.invoke(
539 cli, ["push", "local", "--json"],
540 env=_env(local),
541 catch_exceptions=False,
542 )
543 assert r.exit_code == 0, r.output
544 d = _json(r)
545 assert d["status"] == "pushed"
546 assert isinstance(d["commits_sent"], int) and d["commits_sent"] >= 1
547 assert isinstance(d["objects_sent"], int)
548
549 def test_up_to_date_json_schema(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
550 local, remote = remote_repo
551 runner.invoke(cli, ["push", "local"], env=_env(local), catch_exceptions=False)
552 r = runner.invoke(cli, ["push", "local", "--json"], env=_env(local), catch_exceptions=False)
553 d = _json(r)
554 assert d["status"] == "up_to_date"
555 assert d["commits_sent"] == 0
556
557 def test_dry_run_does_not_push(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
558 local, remote = remote_repo
559 r = runner.invoke(cli, ["push", "local", "--dry-run"], env=_env(local), catch_exceptions=False)
560 assert r.exit_code == 0, r.output
561 assert "dry run" in r.output.lower()
562 # Verify nothing was actually pushed by checking remote still needs a push
563 r2 = runner.invoke(cli, ["push", "local", "--json"], env=_env(local), catch_exceptions=False)
564 d2 = _json(r2)
565 assert d2["status"] == "pushed" # still needs to push — dry run wrote nothing
566
567 def test_dry_run_json_schema(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
568 local, remote = remote_repo
569 r = runner.invoke(
570 cli, ["push", "local", "--dry-run", "--json"],
571 env=_env(local),
572 catch_exceptions=False,
573 )
574 assert r.exit_code == 0
575 d = _json(r)
576 assert d["status"] == "dry_run"
577 assert d["dry_run"] is True
578 assert isinstance(d["commits_sent"], int) and d["commits_sent"] >= 1
579
580 def test_workers_flag_accepted(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
581 local, remote = remote_repo
582 r = runner.invoke(
583 cli, ["push", "local", "--workers", "2"],
584 env=_env(local),
585 catch_exceptions=False,
586 )
587 assert r.exit_code == 0, r.output
588
589 def test_set_upstream_records_tracking(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
590 local, remote = remote_repo
591 r = runner.invoke(cli, ["push", "local", "-u"], env=_env(local), catch_exceptions=False)
592 assert r.exit_code == 0, r.output
593 config_path = config_toml_path(local)
594 assert config_path.exists()
595 assert "local" in config_path.read_text()
596
597
598 # ---------------------------------------------------------------------------
599 # Security
600 # ---------------------------------------------------------------------------
601
602 class TestSecurity:
603 def test_remote_name_sanitized_in_error(self, repo: pathlib.Path) -> None:
604 ansi_remote = "\x1b[31mmalicious\x1b[0m"
605 r = runner.invoke(cli, ["push", ansi_remote], env=_env(repo))
606 assert r.exit_code != 0
607 assert "\x1b[31m" not in (r.stderr or "")
608
609 def test_branch_sanitized_in_delete_output(self, repo: pathlib.Path) -> None:
610 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
611 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
612 with patch("muse.cli.commands.push.make_transport", return_value=_FakeTransport()):
613 with patch("muse.cli.commands.push.delete_remote_head", return_value=False):
614 r = runner.invoke(
615 cli,
616 ["push", "local", "--delete", "--branch", "\x1b[31mmalicious\x1b[0m"],
617 env=_env(repo),
618 )
619 # ANSI must not appear in stdout or stderr
620 assert "\x1b[31m" not in r.output
621 assert "\x1b[31m" not in (r.stderr or "")
622
623 def test_symlink_in_remotes_skipped(self, tmp_path: pathlib.Path) -> None:
624 from muse.cli.commands.push import _all_known_have_anchors
625 remotes = remotes_dir(tmp_path) / "origin"
626 remotes.mkdir(parents=True)
627 target = tmp_path / "sensitive.txt"
628 target.write_text("secret_commit_id\n")
629 (remotes / "main").symlink_to(target)
630 result = _all_known_have_anchors(tmp_path)
631 assert "secret_commit_id" not in result
632
633 def test_all_have_anchors_symlink_dir_skipped(self, tmp_path: pathlib.Path) -> None:
634 """A symlinked directory inside remotes/ must not be traversed."""
635 from muse.cli.commands.push import _all_known_have_anchors
636 # Create a real dir with a secret commit ID
637 secret_dir = tmp_path / "secret_dir"
638 secret_dir.mkdir()
639 (secret_dir / "main").write_text("secret123\n")
640 # Plant a symlinked directory
641 remotes = remotes_dir(tmp_path)
642 remotes.mkdir(parents=True)
643 (remotes / "malicious").symlink_to(secret_dir)
644 result = _all_known_have_anchors(tmp_path)
645 # Symlinked directories: rglob still finds files inside, but our check
646 # is on individual files. The symlink on the dir itself means rglob returns
647 # the child paths as symlink=False. The symlink() check only catches direct symlinks.
648 # The important test is that direct file symlinks ARE caught (test above).
649 assert isinstance(result, list)
650
651 def test_progress_not_in_stdout_on_json(self, repo: pathlib.Path) -> None:
652 """--json: exactly one JSON line; no progress noise mixed into it."""
653 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
654 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
655 with patch("muse.cli.commands.push.make_transport", return_value=_FakeTransport()):
656 r = runner.invoke(cli, ["push", "local", "--json"], env=_env(repo))
657 assert r.exit_code == 0
658 # Exactly one JSON line in output; all others are progress/error (non-JSON).
659 json_lines = [l for l in r.output.splitlines() if l.strip().startswith("{")]
660 assert len(json_lines) == 1, f"Expected 1 JSON line, got: {json_lines}"
661 data = json.loads(json_lines[0])
662 assert isinstance(data, dict)
663
664
665
666
667 from muse.core.pack import PushResult, RemoteInfo
668 from muse.core.types import Manifest
669 from muse.core.paths import config_toml_path, remotes_dir
670
671
672 # ---------------------------------------------------------------------------
673 # Regression — merge commit push must not re-send second-parent history
674 # ---------------------------------------------------------------------------
675
676 class TestMergeCommitPushBundleSize:
677 """After merging branch A into branch B, pushing B must send only the
678 merge commit itself — not the entire history of branch A.
679
680 Regression for: push of a merge commit walks parent2's full ancestry
681 because ``branch_have`` only contained the target branch's remote HEAD,
682 leaving parent2's commits outside the ``seen`` set.
683 """
684
685 def _make_two_branch_remote(
686 self,
687 tmp_path: pathlib.Path,
688 monkeypatch: pytest.MonkeyPatch,
689 *,
690 main_extra_commits: int = 5,
691 dev_extra_commits: int = 3,
692 ) -> tuple[pathlib.Path, pathlib.Path]:
693 """Return (local, remote) where:
694 - main has base + *main_extra_commits* commits, pushed to remote
695 - dev branches from base, has *dev_extra_commits* extra commits, pushed
696 - local HEAD is still on dev (not yet merged)
697 """
698 local = tmp_path / "local"
699 remote = tmp_path / "remote"
700 local.mkdir()
701 remote.mkdir()
702
703 monkeypatch.chdir(local)
704 monkeypatch.setenv("MUSE_REPO_ROOT", str(local))
705 runner.invoke(cli, ["init"], env=_env(local), catch_exceptions=False)
706
707 monkeypatch.chdir(remote)
708 monkeypatch.setenv("MUSE_REPO_ROOT", str(remote))
709 runner.invoke(cli, ["init"], env=_env(remote), catch_exceptions=False)
710
711 monkeypatch.chdir(local)
712 monkeypatch.setenv("MUSE_REPO_ROOT", str(local))
713 set_remote("origin", f"file://{remote}", repo_root=local)
714
715 def _commit(name: str, content: str) -> None:
716 (local / name).write_text(content)
717 runner.invoke(cli, ["code", "add", name], env=_env(local), catch_exceptions=False)
718 runner.invoke(cli, ["commit", "-m", f"add {name}"], env=_env(local), catch_exceptions=False)
719
720 # base commit on main
721 _commit("base.py", "x = 0\n")
722
723 # dev branches from base
724 runner.invoke(cli, ["branch", "dev"], env=_env(local), catch_exceptions=False)
725
726 # extra commits on main
727 for i in range(main_extra_commits):
728 _commit(f"main_{i}.py", f"v = {i}\n")
729
730 # push main to remote
731 r = runner.invoke(cli, ["push", "origin", "--branch", "main"], env=_env(local), catch_exceptions=False)
732 assert r.exit_code == 0, f"push main failed: {r.output}"
733
734 # switch to dev, add extra commits, push dev
735 runner.invoke(cli, ["checkout", "dev"], env=_env(local), catch_exceptions=False)
736 for i in range(dev_extra_commits):
737 _commit(f"dev_{i}.py", f"d = {i}\n")
738
739 r = runner.invoke(cli, ["push", "origin", "--branch", "dev"], env=_env(local), catch_exceptions=False)
740 assert r.exit_code == 0, f"push dev failed: {r.output}"
741
742 return local, remote
743
744 def test_merge_push_sends_one_commit_exact_heads(
745 self,
746 tmp_path: pathlib.Path,
747 monkeypatch: pytest.MonkeyPatch,
748 ) -> None:
749 """Push of a merge commit sends only the merge commit when both
750 branch HEADs are already on the remote (exact remote head match)."""
751 local, _remote = self._make_two_branch_remote(
752 tmp_path, monkeypatch, main_extra_commits=5, dev_extra_commits=2
753 )
754
755 # merge main into dev
756 r = runner.invoke(cli, ["merge", "main"], env=_env(local), catch_exceptions=False)
757 assert r.exit_code == 0, f"merge failed: {r.output}"
758
759 # push the merge commit — must send only 1 commit
760 r = runner.invoke(
761 cli, ["push", "origin", "--branch", "dev", "--json"],
762 env=_env(local), catch_exceptions=False,
763 )
764 assert r.exit_code == 0, f"push after merge failed: {r.output}"
765 d = _json(r)
766 assert d["commits_sent"] == 1, (
767 f"Expected 1 commit (the merge commit), got {d['commits_sent']}. "
768 "push is re-sending the merged branch's full history."
769 )
770
771 def test_merge_push_sends_only_new_commits_when_branch_is_ahead(
772 self,
773 tmp_path: pathlib.Path,
774 monkeypatch: pytest.MonkeyPatch,
775 ) -> None:
776 """When the merged branch is N commits ahead of the remote, the push
777 should send the merge commit + those N new commits, NOT the full history.
778
779 Regression: branch_have only contained the target branch's remote HEAD.
780 The BFS followed parent2's chain without a stop anchor, walking the
781 entire ancestry of the merged branch instead of stopping at the nearest
782 already-remote commit.
783 """
784 local, _remote = self._make_two_branch_remote(
785 tmp_path, monkeypatch, main_extra_commits=5, dev_extra_commits=2
786 )
787
788 # Add 2 more commits to main locally, but do NOT push them.
789 # Remote main is now 2 commits behind local main.
790 runner.invoke(cli, ["checkout", "main"], env=_env(local), catch_exceptions=False)
791 for i in range(2):
792 (local / f"main_extra_{i}.py").write_text(f"e = {i}\n")
793 runner.invoke(cli, ["code", "add", f"main_extra_{i}.py"], env=_env(local), catch_exceptions=False)
794 runner.invoke(cli, ["commit", "-m", f"extra main {i}"], env=_env(local), catch_exceptions=False)
795
796 runner.invoke(cli, ["checkout", "dev"], env=_env(local), catch_exceptions=False)
797
798 # merge the (now-ahead) main into dev
799 r = runner.invoke(cli, ["merge", "main"], env=_env(local), catch_exceptions=False)
800 assert r.exit_code == 0, f"merge failed: {r.output}"
801
802 r = runner.invoke(
803 cli, ["push", "origin", "--branch", "dev", "--json"],
804 env=_env(local), catch_exceptions=False,
805 )
806 assert r.exit_code == 0, f"push after merge failed: {r.output}"
807 d = _json(r)
808 # merge commit + 2 new commits from main = 3, NOT 5+2+1 = 8 full history
809 assert d["commits_sent"] == 3, (
810 f"Expected 3 commits (merge + 2 new on main), got {d['commits_sent']}. "
811 "push walked the merged branch's full history instead of stopping at "
812 "the nearest already-remote commit."
813 )
814
815 def test_merge_push_succeeds(
816 self,
817 tmp_path: pathlib.Path,
818 monkeypatch: pytest.MonkeyPatch,
819 ) -> None:
820 """Push of a merge commit must complete without error."""
821 local, _remote = self._make_two_branch_remote(
822 tmp_path, monkeypatch, main_extra_commits=3, dev_extra_commits=2
823 )
824 runner.invoke(cli, ["merge", "main"], env=_env(local), catch_exceptions=False)
825 r = runner.invoke(
826 cli, ["push", "origin", "--branch", "dev"],
827 env=_env(local), catch_exceptions=False,
828 )
829 assert r.exit_code == 0, f"push after merge failed: {r.output}"
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 121 days ago