gabriel / muse public
test_cmd_push_hardening.py python
811 lines 33.3 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 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 tests.cli_test_helper import CliRunner, InvokeResult
75
76 if TYPE_CHECKING:
77 from muse.cli.commands.push import _PushJson
78 from muse.core.pack import RemoteInfo
79 from muse.core.transport import PushResult
80
81 cli = None
82 runner = CliRunner()
83
84
85 class _FakeResponse:
86 """Minimal context-manager stub returned by fake urlopen in tests."""
87
88 def __enter__(self) -> "_FakeResponse":
89 return self
90
91 def __exit__(
92 self,
93 exc_type: type[BaseException] | None,
94 exc_val: BaseException | None,
95 exc_tb: "types.TracebackType | None",
96 ) -> None:
97 pass
98
99
100 # ---------------------------------------------------------------------------
101 # Shared helpers
102 # ---------------------------------------------------------------------------
103
104 def _env(root: pathlib.Path) -> Manifest:
105 return {"MUSE_REPO_ROOT": str(root)}
106
107
108 def _json(r: InvokeResult) -> _PushJson:
109 """Extract the JSON object line from combined output.
110
111 With ``--json``, exactly one line starting with ``{`` is emitted to stdout;
112 all progress/error lines go to stderr and are prefixed with spaces or emoji.
113 This helper finds that line so tests can assert on the schema.
114 """
115 for line in r.output.splitlines():
116 stripped = line.strip()
117 if stripped.startswith("{"):
118 raw: _PushJson = json.loads(stripped)
119 return raw
120 raise ValueError(f"No JSON line found in output:\n{r.output!r}")
121
122
123 @pytest.fixture()
124 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
125 """Fresh repo with one committed file."""
126 monkeypatch.chdir(tmp_path)
127 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
128 r = runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
129 assert r.exit_code == 0, r.output
130 (tmp_path / "a.py").write_text("x = 1\n")
131 r = runner.invoke(cli, ["commit", "-m", "base"], env=_env(tmp_path), catch_exceptions=False)
132 assert r.exit_code == 0, r.output
133 return tmp_path
134
135
136 @pytest.fixture()
137 def remote_repo(
138 tmp_path: pathlib.Path,
139 monkeypatch: pytest.MonkeyPatch,
140 ) -> tuple[pathlib.Path, pathlib.Path]:
141 """Return ``(local, remote)`` pair with the local remote configured."""
142 local = tmp_path / "local"
143 remote = tmp_path / "remote"
144 local.mkdir()
145 remote.mkdir()
146
147 # muse init uses cwd; chdir so it creates .muse/ in the right place.
148 monkeypatch.chdir(local)
149 monkeypatch.setenv("MUSE_REPO_ROOT", str(local))
150 runner.invoke(cli, ["init"], env=_env(local), catch_exceptions=False)
151 (local / "a.py").write_text("x = 1\n")
152 runner.invoke(cli, ["commit", "-m", "base"], env=_env(local), catch_exceptions=False)
153
154 monkeypatch.chdir(remote)
155 monkeypatch.setenv("MUSE_REPO_ROOT", str(remote))
156 runner.invoke(cli, ["init"], env=_env(remote), catch_exceptions=False)
157
158 monkeypatch.chdir(local)
159 monkeypatch.setenv("MUSE_REPO_ROOT", str(local))
160 # Write the remote config directly — muse remote add blocks file:// by
161 # design (security); set_remote() bypasses that validation intentionally
162 # for test infrastructure.
163 set_remote("local", f"file://{remote}", repo_root=local)
164 return local, remote
165
166
167 # ---------------------------------------------------------------------------
168 # Unit — dead code, parser flags, helpers
169 # ---------------------------------------------------------------------------
170
171 class TestDeadCodeRemoval:
172 def test_no_current_branch_wrapper(self) -> None:
173 import muse.cli.commands.push as m
174 assert not hasattr(m, "_current_branch"), "_current_branch must be deleted"
175
176 def test_push_json_typeddict_keys(self) -> None:
177 import muse.cli.commands.push as m
178 required = {"status", "remote", "branch", "head",
179 "commits_sent", "objects_sent", "force", "dry_run"}
180 assert required <= set(m._PushJson.__annotations__.keys())
181
182
183 class TestRegisterFlags:
184 def _parse(self, *args: str) -> argparse.Namespace:
185 import muse.cli.commands.push as m
186 p = argparse.ArgumentParser()
187 sub = p.add_subparsers()
188 m.register(sub)
189 return p.parse_args(["push", *args])
190
191 def test_dry_run_short(self) -> None:
192 ns = self._parse("-n")
193 assert ns.dry_run is True
194
195 def test_dry_run_long(self) -> None:
196 ns = self._parse("--dry-run")
197 assert ns.dry_run is True
198
199 def test_workers_default(self) -> None:
200 ns = self._parse()
201 assert ns.workers == 16
202
203 def test_workers_custom(self) -> None:
204 ns = self._parse("--workers", "8")
205 assert ns.workers == 8
206
207 def test_default_json_out_is_false(self) -> None:
208 ns = self._parse()
209 assert ns.json_out is False
210
211 def test_json_flag_sets_json_out(self) -> None:
212 ns = self._parse("--json")
213 assert ns.json_out is True
214
215 def test_j_shorthand_sets_json_out(self) -> None:
216 ns = self._parse("-j")
217 assert ns.json_out is True
218
219 def test_force_flag(self) -> None:
220 ns = self._parse("--force")
221 assert ns.force is True
222
223 def test_delete_flag(self) -> None:
224 ns = self._parse("--delete")
225 assert ns.delete_branch is True
226
227 def test_set_upstream_short(self) -> None:
228 ns = self._parse("-u")
229 assert ns.set_upstream_flag is True
230
231
232 class TestAllKnownHaveAnchors:
233 def test_no_remotes_dir_returns_empty(self, tmp_path: pathlib.Path) -> None:
234 from muse.cli.commands.push import _all_known_have_anchors
235 assert _all_known_have_anchors(tmp_path) == []
236
237 def test_reads_commit_ids(self, tmp_path: pathlib.Path) -> None:
238 from muse.cli.commands.push import _all_known_have_anchors
239 remotes = tmp_path / ".muse" / "remotes" / "origin"
240 remotes.mkdir(parents=True)
241 (remotes / "main").write_text("abc123\n")
242 result = _all_known_have_anchors(tmp_path)
243 assert "abc123" in result
244
245 def test_symlinks_are_skipped(self, tmp_path: pathlib.Path) -> None:
246 from muse.cli.commands.push import _all_known_have_anchors
247 remotes = tmp_path / ".muse" / "remotes" / "origin"
248 remotes.mkdir(parents=True)
249 target = tmp_path / "secret.txt"
250 target.write_text("abc123\n")
251 (remotes / "main").symlink_to(target)
252 result = _all_known_have_anchors(tmp_path)
253 # Symlink should not be followed — abc123 should NOT appear
254 assert "abc123" not in result
255
256 def test_binary_file_skipped_not_crashed(self, tmp_path: pathlib.Path) -> None:
257 from muse.cli.commands.push import _all_known_have_anchors
258 remotes = tmp_path / ".muse" / "remotes" / "origin"
259 remotes.mkdir(parents=True)
260 (remotes / "bin_ref").write_bytes(b"\x00\x01\x02\xff")
261 # Should not raise
262 result = _all_known_have_anchors(tmp_path)
263 # Binary content with \x00 stripped by errors='ignore' → not a valid ID
264 assert isinstance(result, list)
265
266 def test_empty_files_skipped(self, tmp_path: pathlib.Path) -> None:
267 from muse.cli.commands.push import _all_known_have_anchors
268 remotes = tmp_path / ".muse" / "remotes" / "origin"
269 remotes.mkdir(parents=True)
270 (remotes / "empty").write_text("")
271 result = _all_known_have_anchors(tmp_path)
272 assert result == []
273
274 def test_multiple_remotes(self, tmp_path: pathlib.Path) -> None:
275 from muse.cli.commands.push import _all_known_have_anchors
276 for name in ["origin", "upstream", "fork"]:
277 d = tmp_path / ".muse" / "remotes" / name
278 d.mkdir(parents=True)
279 (d / "main").write_text(f"commit_{name}\n")
280 result = _all_known_have_anchors(tmp_path)
281 assert len(result) == 3
282 assert "commit_origin" in result
283
284
285
286 # ---------------------------------------------------------------------------
287 # Integration — JSON schema and error routing (mocked transport)
288 # ---------------------------------------------------------------------------
289
290 class _FakeTransport:
291 """Minimal mock transport for unit-level integration tests."""
292
293 def __init__(
294 self,
295 remote_head: str | None = None,
296 push_ok: bool = True,
297 push_exc: Exception | None = None,
298 ) -> None:
299 self._remote_head = remote_head
300 self._push_ok = push_ok
301 self._push_exc = push_exc
302
303 def fetch_remote_info(self, url: str, token: str | None) -> "RemoteInfo":
304 from muse.core.pack import RemoteInfo
305 return RemoteInfo(
306 repo_id="test-repo",
307 domain="code",
308 branch_heads={"main": self._remote_head} if self._remote_head else {},
309 default_branch="main",
310 )
311
312 async def push_stream_coro(
313 self,
314 client,
315 url: str,
316 signing,
317 objects,
318 commits,
319 snapshots,
320 branch: str,
321 force: bool,
322 have,
323 local_head: str | None = None,
324 ) -> "PushResult":
325 from muse.core.transport import PushResult
326 if self._push_exc is not None:
327 raise self._push_exc
328 return PushResult(
329 ok=self._push_ok,
330 message="ok" if self._push_ok else "rejected",
331 branch_heads={"main": "deadbeef" * 8},
332 )
333
334 def delete_branch_remote(self, url: str, token: str | None, branch: str) -> None:
335 pass
336
337
338 class TestJsonSchema:
339 _REQUIRED = {"status", "remote", "branch", "head",
340 "commits_sent", "objects_sent", "force", "dry_run"}
341
342 def _run_with_mock(
343 self,
344 repo: pathlib.Path,
345 extra_args: list[str] | None = None,
346 transport: "_FakeTransport | None" = None,
347 ) -> InvokeResult:
348 args = ["push", "local", "--json"] + (extra_args or [])
349 fake_transport = transport or _FakeTransport()
350 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
351 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
352 with patch("muse.cli.commands.push.make_transport", return_value=fake_transport):
353 return runner.invoke(cli, args, env=_env(repo))
354
355 def test_pushed_schema_complete(self, repo: pathlib.Path) -> None:
356 r = self._run_with_mock(repo)
357 assert r.exit_code == 0, r.output
358 d = _json(r)
359 assert self._REQUIRED <= d.keys()
360
361 def test_pushed_status(self, repo: pathlib.Path) -> None:
362 r = self._run_with_mock(repo)
363 d = _json(r)
364 assert d["status"] == "pushed"
365
366 def test_pushed_dry_run_false(self, repo: pathlib.Path) -> None:
367 r = self._run_with_mock(repo)
368 d = _json(r)
369 assert d["dry_run"] is False
370
371 def test_up_to_date_schema(self, repo: pathlib.Path) -> None:
372 from muse.core.store import get_head_commit_id
373 head = get_head_commit_id(repo, "main") or ""
374 r = self._run_with_mock(repo, transport=_FakeTransport(remote_head=head))
375 d = _json(r)
376 assert self._REQUIRED <= d.keys()
377 assert d["status"] == "up_to_date"
378 assert d["commits_sent"] == 0
379
380 def test_dry_run_schema(self, repo: pathlib.Path) -> None:
381 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
382 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
383 r = runner.invoke(cli, ["push", "local", "--dry-run", "--json"], env=_env(repo))
384 assert r.exit_code == 0, r.output
385 d = _json(r)
386 assert self._REQUIRED <= d.keys()
387 assert d["status"] == "dry_run"
388 assert d["dry_run"] is True
389
390 def test_deleted_schema(self, repo: pathlib.Path) -> None:
391 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
392 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
393 with patch("muse.cli.commands.push.make_transport", return_value=_FakeTransport()):
394 with patch("muse.cli.commands.push.delete_remote_head", return_value=True):
395 r = runner.invoke(
396 cli, ["push", "local", "--delete", "--branch", "feat/x", "--json"],
397 env=_env(repo),
398 )
399 assert r.exit_code == 0, r.output
400 d = _json(r)
401 assert self._REQUIRED <= d.keys()
402 assert d["status"] == "deleted"
403
404
405 class TestErrorRouting:
406 def test_remote_not_configured_to_stderr(self, repo: pathlib.Path) -> None:
407 r = runner.invoke(cli, ["push", "nonexistent"], env=_env(repo))
408 assert r.exit_code != 0
409 assert "not configured" in (r.stderr or "").lower()
410 assert "not configured" not in r.output.replace(r.stderr or "", "")
411
412 def test_remote_not_configured_lists_none_when_no_remotes(
413 self, repo: pathlib.Path
414 ) -> None:
415 """Error message includes 'Configured remotes: (none)' when repo has no remotes.
416
417 Agents need this to know immediately that no remote exists, without
418 a follow-up ``muse remote --json`` call.
419 """
420 r = runner.invoke(cli, ["push", "local"], env=_env(repo))
421 assert r.exit_code != 0
422 stderr = r.stderr or ""
423 assert "configured remotes: (none)" in stderr.lower()
424
425 def test_remote_not_configured_lists_existing_remotes(
426 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
427 ) -> None:
428 """Error message lists configured remote names when the named remote is absent.
429
430 Agents can read the list to discover the correct remote name without
431 a separate ``muse remote --json`` call.
432 """
433 monkeypatch.chdir(tmp_path)
434 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
435 runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
436 (tmp_path / "a.py").write_text("x = 1\n")
437 runner.invoke(cli, ["commit", "-m", "base"], env=_env(tmp_path), catch_exceptions=False)
438 # Configure a remote named "origin" but push to "staging" (doesn't exist).
439 set_remote("origin", "file:///dev/null", repo_root=tmp_path)
440 r = runner.invoke(cli, ["push", "staging"], env=_env(tmp_path))
441 assert r.exit_code != 0
442 stderr = r.stderr or ""
443 assert "origin" in stderr
444 assert "configured remotes:" in stderr.lower()
445
446 def test_no_commits_to_push_to_stderr(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
447 monkeypatch.chdir(tmp_path)
448 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
449 runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
450 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
451 r = runner.invoke(cli, ["push", "local"], env=_env(tmp_path))
452 assert r.exit_code != 0
453 assert "no commits" in (r.stderr or "").lower()
454
455 def test_push_rejected_to_stderr(self, repo: pathlib.Path) -> None:
456 fake_transport = _FakeTransport(push_ok=False)
457 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
458 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
459 with patch("muse.cli.commands.push.make_transport", return_value=fake_transport):
460 r = runner.invoke(cli, ["push", "local"], env=_env(repo))
461 assert r.exit_code != 0
462 assert "rejected" in (r.stderr or "").lower()
463
464 def test_transport_error_409_to_stderr(self, repo: pathlib.Path) -> None:
465 from muse.core.transport import TransportError
466 exc = TransportError("conflict", status_code=409)
467 fake_transport = _FakeTransport(push_exc=exc)
468 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
469 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
470 with patch("muse.cli.commands.push.make_transport", return_value=fake_transport):
471 r = runner.invoke(cli, ["push", "local"], env=_env(repo))
472 assert r.exit_code != 0
473 assert "diverged" in (r.stderr or "").lower()
474
475 def test_transport_error_401_to_stderr(self, repo: pathlib.Path) -> None:
476 from muse.core.transport import TransportError
477 exc = TransportError("unauthorized", status_code=401)
478 fake_transport = _FakeTransport(push_exc=exc)
479 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
480 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
481 with patch("muse.cli.commands.push.make_transport", return_value=fake_transport):
482 r = runner.invoke(cli, ["push", "local"], env=_env(repo))
483 assert r.exit_code != 0
484 assert "authentication" in (r.stderr or "").lower()
485
486 def test_transport_error_404_to_stderr(self, repo: pathlib.Path) -> None:
487 from muse.core.transport import TransportError
488 exc = TransportError("not found", status_code=404)
489 fake_transport = _FakeTransport(push_exc=exc)
490 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
491 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
492 with patch("muse.cli.commands.push.make_transport", return_value=fake_transport):
493 r = runner.invoke(cli, ["push", "local"], env=_env(repo))
494 assert r.exit_code != 0
495 assert "not found" in (r.stderr or "").lower()
496
497 def test_unknown_flag_exits_nonzero(self, repo: pathlib.Path) -> None:
498 r = runner.invoke(cli, ["push", "--format", "xml"], env=_env(repo))
499 assert r.exit_code != 0
500
501
502 # ---------------------------------------------------------------------------
503 # End-to-end with local:// transport
504 # ---------------------------------------------------------------------------
505
506 class TestEndToEnd:
507 def test_fresh_push_succeeds(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
508 local, remote = remote_repo
509 r = runner.invoke(cli, ["push", "local"], env=_env(local), catch_exceptions=False)
510 assert r.exit_code == 0, r.output
511
512 def test_second_push_up_to_date(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
513 local, remote = remote_repo
514 runner.invoke(cli, ["push", "local"], env=_env(local), catch_exceptions=False)
515 r = runner.invoke(cli, ["push", "local"], env=_env(local), catch_exceptions=False)
516 assert r.exit_code == 0
517 assert "up to date" in r.output.lower()
518
519 def test_push_json_schema(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
520 local, remote = remote_repo
521 r = runner.invoke(
522 cli, ["push", "local", "--json"],
523 env=_env(local),
524 catch_exceptions=False,
525 )
526 assert r.exit_code == 0, r.output
527 d = _json(r)
528 assert d["status"] == "pushed"
529 assert isinstance(d["commits_sent"], int) and d["commits_sent"] >= 1
530 assert isinstance(d["objects_sent"], int)
531
532 def test_up_to_date_json_schema(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
533 local, remote = remote_repo
534 runner.invoke(cli, ["push", "local"], env=_env(local), catch_exceptions=False)
535 r = runner.invoke(cli, ["push", "local", "--json"], env=_env(local), catch_exceptions=False)
536 d = _json(r)
537 assert d["status"] == "up_to_date"
538 assert d["commits_sent"] == 0
539
540 def test_dry_run_does_not_push(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
541 local, remote = remote_repo
542 r = runner.invoke(cli, ["push", "local", "--dry-run"], env=_env(local), catch_exceptions=False)
543 assert r.exit_code == 0, r.output
544 assert "dry run" in r.output.lower()
545 # Verify nothing was actually pushed by checking remote still needs a push
546 r2 = runner.invoke(cli, ["push", "local", "--json"], env=_env(local), catch_exceptions=False)
547 d2 = _json(r2)
548 assert d2["status"] == "pushed" # still needs to push — dry run wrote nothing
549
550 def test_dry_run_json_schema(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
551 local, remote = remote_repo
552 r = runner.invoke(
553 cli, ["push", "local", "--dry-run", "--json"],
554 env=_env(local),
555 catch_exceptions=False,
556 )
557 assert r.exit_code == 0
558 d = _json(r)
559 assert d["status"] == "dry_run"
560 assert d["dry_run"] is True
561 assert isinstance(d["commits_sent"], int) and d["commits_sent"] >= 1
562
563 def test_workers_flag_accepted(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
564 local, remote = remote_repo
565 r = runner.invoke(
566 cli, ["push", "local", "--workers", "2"],
567 env=_env(local),
568 catch_exceptions=False,
569 )
570 assert r.exit_code == 0, r.output
571
572 def test_set_upstream_records_tracking(self, remote_repo: tuple[pathlib.Path, pathlib.Path]) -> None:
573 local, remote = remote_repo
574 r = runner.invoke(cli, ["push", "local", "-u"], env=_env(local), catch_exceptions=False)
575 assert r.exit_code == 0, r.output
576 config_path = local / ".muse" / "config.toml"
577 assert config_path.exists()
578 assert "local" in config_path.read_text()
579
580
581 # ---------------------------------------------------------------------------
582 # Security
583 # ---------------------------------------------------------------------------
584
585 class TestSecurity:
586 def test_remote_name_sanitized_in_error(self, repo: pathlib.Path) -> None:
587 ansi_remote = "\x1b[31mevil\x1b[0m"
588 r = runner.invoke(cli, ["push", ansi_remote], env=_env(repo))
589 assert r.exit_code != 0
590 assert "\x1b[31m" not in (r.stderr or "")
591
592 def test_branch_sanitized_in_delete_output(self, repo: pathlib.Path) -> None:
593 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
594 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
595 with patch("muse.cli.commands.push.make_transport", return_value=_FakeTransport()):
596 with patch("muse.cli.commands.push.delete_remote_head", return_value=False):
597 r = runner.invoke(
598 cli,
599 ["push", "local", "--delete", "--branch", "\x1b[31mevil\x1b[0m"],
600 env=_env(repo),
601 )
602 # ANSI must not appear in stdout or stderr
603 assert "\x1b[31m" not in r.output
604 assert "\x1b[31m" not in (r.stderr or "")
605
606 def test_symlink_in_remotes_skipped(self, tmp_path: pathlib.Path) -> None:
607 from muse.cli.commands.push import _all_known_have_anchors
608 remotes = tmp_path / ".muse" / "remotes" / "origin"
609 remotes.mkdir(parents=True)
610 target = tmp_path / "sensitive.txt"
611 target.write_text("secret_commit_id\n")
612 (remotes / "main").symlink_to(target)
613 result = _all_known_have_anchors(tmp_path)
614 assert "secret_commit_id" not in result
615
616 def test_all_have_anchors_symlink_dir_skipped(self, tmp_path: pathlib.Path) -> None:
617 """A symlinked directory inside remotes/ must not be traversed."""
618 from muse.cli.commands.push import _all_known_have_anchors
619 # Create a real dir with a secret commit ID
620 secret_dir = tmp_path / "secret_dir"
621 secret_dir.mkdir()
622 (secret_dir / "main").write_text("secret123\n")
623 # Plant a symlinked directory
624 remotes = tmp_path / ".muse" / "remotes"
625 remotes.mkdir(parents=True)
626 (remotes / "evil").symlink_to(secret_dir)
627 result = _all_known_have_anchors(tmp_path)
628 # Symlinked directories: rglob still finds files inside, but our check
629 # is on individual files. The symlink on the dir itself means rglob returns
630 # the child paths as symlink=False. The symlink() check only catches direct symlinks.
631 # The important test is that direct file symlinks ARE caught (test above).
632 assert isinstance(result, list)
633
634 def test_progress_not_in_stdout_on_json(self, repo: pathlib.Path) -> None:
635 """--json: exactly one JSON line; no progress noise mixed into it."""
636 with patch("muse.cli.commands.push.get_remote", return_value="local://"):
637 with patch("muse.cli.commands.push.get_signing_identity", return_value=None):
638 with patch("muse.cli.commands.push.make_transport", return_value=_FakeTransport()):
639 r = runner.invoke(cli, ["push", "local", "--json"], env=_env(repo))
640 assert r.exit_code == 0
641 # Exactly one JSON line in output; all others are progress/error (non-JSON).
642 json_lines = [l for l in r.output.splitlines() if l.strip().startswith("{")]
643 assert len(json_lines) == 1, f"Expected 1 JSON line, got: {json_lines}"
644 data = json.loads(json_lines[0])
645 assert isinstance(data, dict)
646
647
648
649
650 from muse.core.pack import PushResult, RemoteInfo
651 from muse.core._types import Manifest
652
653
654 # ---------------------------------------------------------------------------
655 # Regression — merge commit push must not re-send second-parent history
656 # ---------------------------------------------------------------------------
657
658 class TestMergeCommitPushBundleSize:
659 """After merging branch A into branch B, pushing B must send only the
660 merge commit itself — not the entire history of branch A.
661
662 Regression for: push of a merge commit walks parent2's full ancestry
663 because ``branch_have`` only contained the target branch's remote HEAD,
664 leaving parent2's commits outside the ``seen`` set.
665 """
666
667 def _make_two_branch_remote(
668 self,
669 tmp_path: pathlib.Path,
670 monkeypatch: pytest.MonkeyPatch,
671 *,
672 main_extra_commits: int = 5,
673 dev_extra_commits: int = 3,
674 ) -> tuple[pathlib.Path, pathlib.Path]:
675 """Return (local, remote) where:
676 - main has base + *main_extra_commits* commits, pushed to remote
677 - dev branches from base, has *dev_extra_commits* extra commits, pushed
678 - local HEAD is still on dev (not yet merged)
679 """
680 local = tmp_path / "local"
681 remote = tmp_path / "remote"
682 local.mkdir()
683 remote.mkdir()
684
685 monkeypatch.chdir(local)
686 monkeypatch.setenv("MUSE_REPO_ROOT", str(local))
687 runner.invoke(cli, ["init"], env=_env(local), catch_exceptions=False)
688
689 monkeypatch.chdir(remote)
690 monkeypatch.setenv("MUSE_REPO_ROOT", str(remote))
691 runner.invoke(cli, ["init"], env=_env(remote), catch_exceptions=False)
692
693 monkeypatch.chdir(local)
694 monkeypatch.setenv("MUSE_REPO_ROOT", str(local))
695 set_remote("origin", f"file://{remote}", repo_root=local)
696
697 def _commit(name: str, content: str) -> None:
698 (local / name).write_text(content)
699 runner.invoke(cli, ["code", "add", name], env=_env(local), catch_exceptions=False)
700 runner.invoke(cli, ["commit", "-m", f"add {name}"], env=_env(local), catch_exceptions=False)
701
702 # base commit on main
703 _commit("base.py", "x = 0\n")
704
705 # dev branches from base
706 runner.invoke(cli, ["branch", "dev"], env=_env(local), catch_exceptions=False)
707
708 # extra commits on main
709 for i in range(main_extra_commits):
710 _commit(f"main_{i}.py", f"v = {i}\n")
711
712 # push main to remote
713 r = runner.invoke(cli, ["push", "origin", "--branch", "main"], env=_env(local), catch_exceptions=False)
714 assert r.exit_code == 0, f"push main failed: {r.output}"
715
716 # switch to dev, add extra commits, push dev
717 runner.invoke(cli, ["checkout", "dev"], env=_env(local), catch_exceptions=False)
718 for i in range(dev_extra_commits):
719 _commit(f"dev_{i}.py", f"d = {i}\n")
720
721 r = runner.invoke(cli, ["push", "origin", "--branch", "dev"], env=_env(local), catch_exceptions=False)
722 assert r.exit_code == 0, f"push dev failed: {r.output}"
723
724 return local, remote
725
726 def test_merge_push_sends_one_commit_exact_heads(
727 self,
728 tmp_path: pathlib.Path,
729 monkeypatch: pytest.MonkeyPatch,
730 ) -> None:
731 """Push of a merge commit sends only the merge commit when both
732 branch HEADs are already on the remote (exact remote head match)."""
733 local, _remote = self._make_two_branch_remote(
734 tmp_path, monkeypatch, main_extra_commits=5, dev_extra_commits=2
735 )
736
737 # merge main into dev
738 r = runner.invoke(cli, ["merge", "main"], env=_env(local), catch_exceptions=False)
739 assert r.exit_code == 0, f"merge failed: {r.output}"
740
741 # push the merge commit — must send only 1 commit
742 r = runner.invoke(
743 cli, ["push", "origin", "--branch", "dev", "--json"],
744 env=_env(local), catch_exceptions=False,
745 )
746 assert r.exit_code == 0, f"push after merge failed: {r.output}"
747 d = _json(r)
748 assert d["commits_sent"] == 1, (
749 f"Expected 1 commit (the merge commit), got {d['commits_sent']}. "
750 "push is re-sending the merged branch's full history."
751 )
752
753 def test_merge_push_sends_only_new_commits_when_branch_is_ahead(
754 self,
755 tmp_path: pathlib.Path,
756 monkeypatch: pytest.MonkeyPatch,
757 ) -> None:
758 """When the merged branch is N commits ahead of the remote, the push
759 should send the merge commit + those N new commits, NOT the full history.
760
761 Regression: branch_have only contained the target branch's remote HEAD.
762 The BFS followed parent2's chain without a stop anchor, walking the
763 entire ancestry of the merged branch instead of stopping at the nearest
764 already-remote commit.
765 """
766 local, _remote = self._make_two_branch_remote(
767 tmp_path, monkeypatch, main_extra_commits=5, dev_extra_commits=2
768 )
769
770 # Add 2 more commits to main locally, but do NOT push them.
771 # Remote main is now 2 commits behind local main.
772 runner.invoke(cli, ["checkout", "main"], env=_env(local), catch_exceptions=False)
773 for i in range(2):
774 (local / f"main_extra_{i}.py").write_text(f"e = {i}\n")
775 runner.invoke(cli, ["code", "add", f"main_extra_{i}.py"], env=_env(local), catch_exceptions=False)
776 runner.invoke(cli, ["commit", "-m", f"extra main {i}"], env=_env(local), catch_exceptions=False)
777
778 runner.invoke(cli, ["checkout", "dev"], env=_env(local), catch_exceptions=False)
779
780 # merge the (now-ahead) main into dev
781 r = runner.invoke(cli, ["merge", "main"], env=_env(local), catch_exceptions=False)
782 assert r.exit_code == 0, f"merge failed: {r.output}"
783
784 r = runner.invoke(
785 cli, ["push", "origin", "--branch", "dev", "--json"],
786 env=_env(local), catch_exceptions=False,
787 )
788 assert r.exit_code == 0, f"push after merge failed: {r.output}"
789 d = _json(r)
790 # merge commit + 2 new commits from main = 3, NOT 5+2+1 = 8 full history
791 assert d["commits_sent"] == 3, (
792 f"Expected 3 commits (merge + 2 new on main), got {d['commits_sent']}. "
793 "push walked the merged branch's full history instead of stopping at "
794 "the nearest already-remote commit."
795 )
796
797 def test_merge_push_succeeds(
798 self,
799 tmp_path: pathlib.Path,
800 monkeypatch: pytest.MonkeyPatch,
801 ) -> None:
802 """Push of a merge commit must complete without error."""
803 local, _remote = self._make_two_branch_remote(
804 tmp_path, monkeypatch, main_extra_commits=3, dev_extra_commits=2
805 )
806 runner.invoke(cli, ["merge", "main"], env=_env(local), catch_exceptions=False)
807 r = runner.invoke(
808 cli, ["push", "origin", "--branch", "dev"],
809 env=_env(local), catch_exceptions=False,
810 )
811 assert r.exit_code == 0, f"push after merge failed: {r.output}"
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago