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