gabriel / muse public
test_cmd_cherry_pick_hardening.py python
1,180 lines 46.9 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 136 days ago
1 """Comprehensive hardening tests for ``muse cherry-pick``.
2
3 Covers all changes introduced in the cherry-pick command review:
4
5 Unit
6 ----
7 - Parser flags: --dry-run, --message/-m, --force, --no-commit, --format, --json
8 - Dead-code removal: _read_branch absent, pathlib not imported
9 - validate_branch_name called in run()
10 - target.message sanitized before embedding in commit record
11 - ref sanitized in "not found" error
12 - Write ordering: write_snapshot → write_commit → apply_manifest → write_branch_ref
13 - Fail-fast on missing parent commit / parent snapshot (no silent {} fallback)
14
15 Integration
16 -----------
17 - Error messages routed to stderr, stdout clean
18 - JSON schema identical and complete for all code paths
19 (normal, --no-commit, --dry-run, conflict)
20 - --dry-run performs no writes (branch ref, workdir, reflog unchanged)
21 - --no-commit applies workdir changes without advancing the branch ref
22 - Reflog entry appended after normal cherry-pick
23 - -m/--message overrides the cherry-picked commit message
24 - Missing parent commit → INTERNAL_ERROR (not silent fallback)
25 - Missing target snapshot → INTERNAL_ERROR
26
27 End-to-end
28 ----------
29 - Text output format
30 - JSON output format with full schema verification
31 - Cherry-pick from another branch applies correct content
32 - --force bypasses dirty-workdir guard
33
34 Security
35 --------
36 - ANSI escape codes in ref rejected / sanitized in error
37 - ANSI in original commit message not propagated to stored commit
38 - --format with unknown value exits 1 and prints to stderr
39 - Conflict paths sanitized in text output
40
41 Stress
42 ------
43 - Cherry-pick across a 200-commit history
44 - 50 sequential cherry-picks in the same repo
45 - Concurrent cherry-picks to isolated repos
46 """
47
48 from __future__ import annotations
49
50 import argparse
51 import inspect
52 import json
53 import pathlib
54 import threading
55 import time
56
57 import pytest
58
59 from muse.core._types import fake_id, short_id, split_id
60 from tests.cli_test_helper import CliRunner
61
62 cli = None # argparse migration — CliRunner ignores this arg
63 runner = CliRunner()
64
65
66 # ---------------------------------------------------------------------------
67 # Shared helpers
68 # ---------------------------------------------------------------------------
69
70 def _env(root: pathlib.Path) -> Manifest:
71 return {"MUSE_REPO_ROOT": str(root)}
72
73
74 @pytest.fixture()
75 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
76 """Repo on ``main`` with two commits: base (a.py) + target (b.py).
77
78 The caller can immediately cherry-pick the HEAD commit to a new branch.
79 """
80 monkeypatch.chdir(tmp_path)
81 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
82 r = runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
83 assert r.exit_code == 0, r.output
84 (tmp_path / "a.py").write_text("x = 1\n")
85 r = runner.invoke(cli, ["commit", "-m", "base"], env=_env(tmp_path), catch_exceptions=False)
86 assert r.exit_code == 0, r.output
87 (tmp_path / "b.py").write_text("y = 2\n")
88 r = runner.invoke(cli, ["commit", "-m", "add b"], env=_env(tmp_path), catch_exceptions=False)
89 assert r.exit_code == 0, r.output
90 return tmp_path
91
92
93 @pytest.fixture()
94 def two_branch_repo(
95 tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
96 ) -> tuple[pathlib.Path, str]:
97 """Repo with main and feat branches, returns (root, commit-id-on-feat).
98
99 ``main``: base commit only
100 ``feat``: base commit + one extra commit (the one to cherry-pick)
101 """
102 monkeypatch.chdir(tmp_path)
103 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
104 runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
105 (tmp_path / "base.py").write_text("base\n")
106 runner.invoke(cli, ["commit", "-m", "base"], env=_env(tmp_path), catch_exceptions=False)
107
108 runner.invoke(cli, ["branch", "feat"], env=_env(tmp_path), catch_exceptions=False)
109 runner.invoke(cli, ["checkout", "feat"], env=_env(tmp_path), catch_exceptions=False)
110 (tmp_path / "extra.py").write_text("extra\n")
111 runner.invoke(cli, ["commit", "-m", "extra on feat"], env=_env(tmp_path), catch_exceptions=False)
112
113 from muse.core.store import get_head_commit_id
114 feat_cid = get_head_commit_id(tmp_path, "feat")
115 assert feat_cid is not None
116
117 runner.invoke(cli, ["checkout", "main"], env=_env(tmp_path), catch_exceptions=False)
118 return tmp_path, feat_cid
119
120
121 def _head_id(repo: pathlib.Path, branch: str = "main") -> str | None:
122 from muse.core.store import get_head_commit_id
123 return get_head_commit_id(repo, branch)
124
125
126 # ---------------------------------------------------------------------------
127 # Unit — parser flags and dead-code removal
128 # ---------------------------------------------------------------------------
129
130 class TestRegisterFlags:
131 def _parse(self, *args: str) -> argparse.Namespace:
132 import muse.cli.commands.cherry_pick as m
133 p = argparse.ArgumentParser()
134 sub = p.add_subparsers()
135 m.register(sub)
136 return p.parse_args(["cherry-pick", *args])
137
138 def test_dry_run_flag(self) -> None:
139 ns = self._parse("abc123", "--dry-run")
140 assert ns.dry_run is True
141
142 def test_dry_run_default_false(self) -> None:
143 ns = self._parse("abc123")
144 assert ns.dry_run is False
145
146 def test_no_commit_short(self) -> None:
147 ns = self._parse("abc123", "-n")
148 assert ns.no_commit is True
149
150 def test_no_commit_long(self) -> None:
151 ns = self._parse("abc123", "--no-commit")
152 assert ns.no_commit is True
153
154 def test_force_flag(self) -> None:
155 ns = self._parse("abc123", "--force")
156 assert ns.force is True
157
158 def test_message_short(self) -> None:
159 ns = self._parse("abc123", "-m", "my msg")
160 assert ns.message == "my msg"
161
162 def test_message_long(self) -> None:
163 ns = self._parse("abc123", "--message", "my msg")
164 assert ns.message == "my msg"
165
166 def test_message_default_none(self) -> None:
167 ns = self._parse("abc123")
168 assert ns.message is None
169
170 def test_format_json_shorthand(self) -> None:
171 ns = self._parse("abc123", "--json")
172 assert ns.fmt == "json"
173
174 def test_format_explicit_text(self) -> None:
175 ns = self._parse("abc123", "--format", "text")
176 assert ns.fmt == "text"
177
178 def test_ref_positional(self) -> None:
179 ns = self._parse("deadbeef")
180 assert ns.ref == "deadbeef"
181
182
183 class TestDeadCodeRemoval:
184 def test_no_read_branch_wrapper(self) -> None:
185 import muse.cli.commands.cherry_pick as m
186 assert not hasattr(m, "_read_branch"), "_read_branch must be deleted"
187
188 def test_pathlib_not_imported(self) -> None:
189 import muse.cli.commands.cherry_pick as m
190 assert "import pathlib" not in inspect.getsource(m)
191
192 def test_validate_branch_name_in_run(self) -> None:
193 import muse.cli.commands.cherry_pick as m
194 assert "validate_branch_name" in inspect.getsource(m.run)
195
196 def test_target_message_sanitized_in_run(self) -> None:
197 import muse.cli.commands.cherry_pick as m
198 assert "sanitize_display(target.message" in inspect.getsource(m.run)
199
200 def test_ref_sanitized_in_not_found_error(self) -> None:
201 import muse.cli.commands.cherry_pick as m
202 assert "sanitize_display(ref)" in inspect.getsource(m.run)
203
204 def test_write_snapshot_before_apply_manifest_in_normal_path(self) -> None:
205 """Normal path: write_snapshot and write_commit must precede apply_manifest."""
206 import muse.cli.commands.cherry_pick as m
207 src_lines = [
208 (i, l)
209 for i, l in enumerate(inspect.getsource(m.run).split("\n"), 1)
210 if l.strip() and not l.strip().startswith("#")
211 ]
212 ws = next(i for i, l in src_lines if "write_snapshot(" in l)
213 wc = next(i for i, l in src_lines if "write_commit(" in l)
214 wr = next(i for i, l in src_lines if "write_branch_ref(" in l)
215 # Find the LAST apply_manifest (normal path, not no_commit path)
216 all_am = [i for i, l in src_lines if "apply_manifest(" in l]
217 last_am = max(all_am)
218 assert ws < last_am, f"write_snapshot ({ws}) must precede apply_manifest ({last_am})"
219 assert wc < last_am, f"write_commit ({wc}) must precede apply_manifest ({last_am})"
220 assert last_am < wr, f"apply_manifest ({last_am}) must precede write_branch_ref ({wr})"
221
222 def test_parent_snapshot_missing_raises_not_silently_falls_back(self) -> None:
223 """Code must not silently use {} when parent snapshot is missing."""
224 import muse.cli.commands.cherry_pick as m
225 src = inspect.getsource(m.run)
226 # The silent fallback was: `if parent_snap: base_manifest = parent_snap.manifest`
227 # The fix is: `raise SystemExit(ExitCode.INTERNAL_ERROR)` when parent_snap is None
228 assert "INTERNAL_ERROR" in src
229
230
231 # ---------------------------------------------------------------------------
232 # Integration — error routing and behaviour
233 # ---------------------------------------------------------------------------
234
235 class TestErrorRouting:
236 def test_not_found_to_stderr(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
237 root, _ = two_branch_repo
238 r = runner.invoke(cli, ["cherry-pick", "badref"], env=_env(root))
239 assert r.exit_code != 0
240 assert "not found" in (r.stderr or "").lower()
241 assert "badref" in (r.stderr or "")
242
243 def test_not_found_stdout_clean(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
244 root, _ = two_branch_repo
245 r = runner.invoke(cli, ["cherry-pick", "0000000000000000"], env=_env(root))
246 assert r.exit_code != 0
247 # Error must be in stderr; stdout should have no error messages.
248 assert "not found" in (r.stderr or "").lower()
249
250
251
252 class TestJsonSchema:
253 """JSON schema must be identical across all code paths."""
254
255 _REQUIRED_KEYS = {
256 "status", "commit_id", "branch", "ref",
257 "source_commit_id", "snapshot_id", "message",
258 "no_commit", "dry_run", "conflicts",
259 }
260
261 def test_normal_json_schema_complete(
262 self, two_branch_repo: tuple[pathlib.Path, str]
263 ) -> None:
264 root, cid = two_branch_repo
265 r = runner.invoke(
266 cli, ["cherry-pick", cid, "--json"],
267 env=_env(root), catch_exceptions=False,
268 )
269 assert r.exit_code == 0, r.output
270 d = json.loads(r.output)
271 assert self._REQUIRED_KEYS <= d.keys()
272
273 def test_normal_status_is_picked(
274 self, two_branch_repo: tuple[pathlib.Path, str]
275 ) -> None:
276 root, cid = two_branch_repo
277 r = runner.invoke(cli, ["cherry-pick", cid, "--json"], env=_env(root), catch_exceptions=False)
278 d = json.loads(r.output)
279 assert d["status"] == "picked"
280 assert d["no_commit"] is False
281 assert d["dry_run"] is False
282 assert d["conflicts"] == []
283
284 def test_normal_ref_field_matches_input(
285 self, two_branch_repo: tuple[pathlib.Path, str]
286 ) -> None:
287 root, cid = two_branch_repo
288 r = runner.invoke(cli, ["cherry-pick", short_id(cid), "--json"], env=_env(root), catch_exceptions=False)
289 d = json.loads(r.output)
290 assert d["ref"] == short_id(cid)
291
292 def test_normal_snapshot_id_is_string(
293 self, two_branch_repo: tuple[pathlib.Path, str]
294 ) -> None:
295 root, cid = two_branch_repo
296 r = runner.invoke(cli, ["cherry-pick", cid, "--json"], env=_env(root), catch_exceptions=False)
297 d = json.loads(r.output)
298 # Canonical Muse IDs are "sha256:<64 hex chars>" = 71 chars total
299 assert isinstance(d["snapshot_id"], str)
300 assert d["snapshot_id"].startswith("sha256:")
301 assert len(d["snapshot_id"]) == 71
302
303 def test_no_commit_json_schema_complete(
304 self, two_branch_repo: tuple[pathlib.Path, str]
305 ) -> None:
306 root, cid = two_branch_repo
307 r = runner.invoke(
308 cli, ["cherry-pick", cid, "--no-commit", "--json"],
309 env=_env(root), catch_exceptions=False,
310 )
311 assert r.exit_code == 0, r.output
312 d = json.loads(r.output)
313 assert self._REQUIRED_KEYS <= d.keys()
314
315 def test_no_commit_status_is_applied(
316 self, two_branch_repo: tuple[pathlib.Path, str]
317 ) -> None:
318 root, cid = two_branch_repo
319 r = runner.invoke(
320 cli, ["cherry-pick", cid, "--no-commit", "--json"],
321 env=_env(root), catch_exceptions=False,
322 )
323 d = json.loads(r.output)
324 assert d["status"] == "applied"
325 assert d["commit_id"] is None
326 assert d["no_commit"] is True
327 assert d["dry_run"] is False
328
329 def test_dry_run_json_schema_complete(
330 self, two_branch_repo: tuple[pathlib.Path, str]
331 ) -> None:
332 root, cid = two_branch_repo
333 r = runner.invoke(
334 cli, ["cherry-pick", cid, "--dry-run", "--json"],
335 env=_env(root), catch_exceptions=False,
336 )
337 assert r.exit_code == 0, r.output
338 d = json.loads(r.output)
339 assert self._REQUIRED_KEYS <= d.keys()
340
341 def test_dry_run_status(
342 self, two_branch_repo: tuple[pathlib.Path, str]
343 ) -> None:
344 root, cid = two_branch_repo
345 r = runner.invoke(
346 cli, ["cherry-pick", cid, "--dry-run", "--json"],
347 env=_env(root), catch_exceptions=False,
348 )
349 d = json.loads(r.output)
350 assert d["dry_run"] is True
351 assert d["commit_id"] is None
352 assert d["status"] == "dry_run"
353
354 def test_all_three_schemas_identical(
355 self, two_branch_repo: tuple[pathlib.Path, str]
356 ) -> None:
357 root, cid = two_branch_repo
358 r_dr = runner.invoke(cli, ["cherry-pick", cid, "--dry-run", "--json"], env=_env(root), catch_exceptions=False)
359 r_nc = runner.invoke(cli, ["cherry-pick", cid, "--no-commit", "--json"], env=_env(root), catch_exceptions=False)
360
361 # Normal cherry-pick: need to re-fetch after --no-commit modified workdir
362 r_commit = runner.invoke(cli, ["commit", "-m", "after nc"], env=_env(root), catch_exceptions=False)
363 from muse.core.store import get_head_commit_id
364 new_cid = get_head_commit_id(root, "main")
365 assert new_cid is not None
366 # Need a different source commit to cherry-pick after the no-commit pick
367 r_nm = runner.invoke(cli, ["cherry-pick", cid, "--json"], env=_env(root), catch_exceptions=False)
368
369 keys_dr = set(json.loads(r_dr.output).keys())
370 keys_nc = set(json.loads(r_nc.output).keys())
371 keys_nm = set(json.loads(r_nm.output).keys())
372 assert keys_dr == keys_nc == keys_nm
373
374
375 class TestDryRun:
376 def test_no_commit_created(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
377 from muse.core.store import get_all_commits, get_head_commit_id
378 root, cid = two_branch_repo
379 before_count = len(get_all_commits(root))
380 before_head = get_head_commit_id(root, "main")
381 r = runner.invoke(
382 cli, ["cherry-pick", cid, "--dry-run"],
383 env=_env(root), catch_exceptions=False,
384 )
385 assert r.exit_code == 0, r.output
386 assert len(get_all_commits(root)) == before_count
387 assert get_head_commit_id(root, "main") == before_head
388
389 def test_workdir_unchanged(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
390 root, cid = two_branch_repo
391 extra = root / "extra.py"
392 content_before = extra.read_text() if extra.exists() else None
393 r = runner.invoke(
394 cli, ["cherry-pick", cid, "--dry-run"],
395 env=_env(root), catch_exceptions=False,
396 )
397 assert r.exit_code == 0, r.output
398 assert (extra.read_text() if extra.exists() else None) == content_before
399
400 def test_reflog_unchanged(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
401 from muse.core.reflog import read_reflog
402 root, cid = two_branch_repo
403 before = len(read_reflog(root, "main"))
404 runner.invoke(cli, ["cherry-pick", cid, "--dry-run"], env=_env(root), catch_exceptions=False)
405 assert len(read_reflog(root, "main")) == before
406
407 def test_dry_run_text_output_mentions_dry_run(
408 self, two_branch_repo: tuple[pathlib.Path, str]
409 ) -> None:
410 root, cid = two_branch_repo
411 r = runner.invoke(
412 cli, ["cherry-pick", cid, "--dry-run"],
413 env=_env(root), catch_exceptions=False,
414 )
415 assert "dry-run" in r.output.lower() or "would" in r.output.lower()
416
417 def test_dry_run_invalid_ref_errors(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
418 root, _ = two_branch_repo
419 r = runner.invoke(cli, ["cherry-pick", "no-such-ref", "--dry-run"], env=_env(root))
420 assert r.exit_code != 0
421
422 def test_dry_run_json_snapshot_id_present(
423 self, two_branch_repo: tuple[pathlib.Path, str]
424 ) -> None:
425 root, cid = two_branch_repo
426 r = runner.invoke(
427 cli, ["cherry-pick", cid, "--dry-run", "--json"],
428 env=_env(root), catch_exceptions=False,
429 )
430 d = json.loads(r.output)
431 # Canonical Muse IDs are "sha256:<64 hex chars>" = 71 chars total
432 assert d["snapshot_id"] is not None
433 assert d["snapshot_id"].startswith("sha256:")
434 assert len(d["snapshot_id"]) == 71
435
436
437 class TestNoCommit:
438 def test_branch_ref_not_advanced(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
439 from muse.core.store import get_head_commit_id
440 root, cid = two_branch_repo
441 before_head = get_head_commit_id(root, "main")
442 r = runner.invoke(
443 cli, ["cherry-pick", cid, "--no-commit"],
444 env=_env(root), catch_exceptions=False,
445 )
446 assert r.exit_code == 0, r.output
447 assert get_head_commit_id(root, "main") == before_head
448
449 def test_workdir_modified(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
450 root, cid = two_branch_repo
451 r = runner.invoke(
452 cli, ["cherry-pick", cid, "--no-commit"],
453 env=_env(root), catch_exceptions=False,
454 )
455 assert r.exit_code == 0, r.output
456 # extra.py was added by the feat branch commit
457 assert (root / "extra.py").exists()
458
459 def test_no_commit_in_json(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
460 root, cid = two_branch_repo
461 r = runner.invoke(
462 cli, ["cherry-pick", cid, "--no-commit", "--json"],
463 env=_env(root), catch_exceptions=False,
464 )
465 d = json.loads(r.output)
466 assert d["no_commit"] is True
467 assert d["commit_id"] is None
468 assert d["status"] == "applied"
469
470 def test_reflog_not_written_for_no_commit(
471 self, two_branch_repo: tuple[pathlib.Path, str]
472 ) -> None:
473 from muse.core.reflog import read_reflog
474 root, cid = two_branch_repo
475 before = len(read_reflog(root, "main"))
476 runner.invoke(
477 cli, ["cherry-pick", cid, "--no-commit"],
478 env=_env(root), catch_exceptions=False,
479 )
480 assert len(read_reflog(root, "main")) == before
481
482
483 class TestReflog:
484 def test_reflog_appended(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
485 from muse.core.reflog import read_reflog
486 root, cid = two_branch_repo
487 before = len(read_reflog(root, "main"))
488 runner.invoke(cli, ["cherry-pick", cid], env=_env(root), catch_exceptions=False)
489 assert len(read_reflog(root, "main")) > before
490
491 def test_reflog_operation_contains_cherry_pick(
492 self, two_branch_repo: tuple[pathlib.Path, str]
493 ) -> None:
494 from muse.core.reflog import read_reflog
495 root, cid = two_branch_repo
496 runner.invoke(cli, ["cherry-pick", cid], env=_env(root), catch_exceptions=False)
497 entries = read_reflog(root, "main")
498 # read_reflog returns newest-first
499 assert "cherry-pick" in entries[0].operation.lower()
500
501
502 class TestMessageFlag:
503 def test_custom_message_in_json(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
504 root, cid = two_branch_repo
505 r = runner.invoke(
506 cli, ["cherry-pick", cid, "-m", "custom pick msg", "--json"],
507 env=_env(root), catch_exceptions=False,
508 )
509 assert r.exit_code == 0, r.output
510 d = json.loads(r.output)
511 assert d["message"] == "custom pick msg"
512
513 def test_custom_message_in_text(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
514 root, cid = two_branch_repo
515 r = runner.invoke(
516 cli, ["cherry-pick", cid, "-m", "undo extra"],
517 env=_env(root), catch_exceptions=False,
518 )
519 assert r.exit_code == 0, r.output
520 assert "undo extra" in r.output
521
522 def test_default_message_is_source_message(
523 self, two_branch_repo: tuple[pathlib.Path, str]
524 ) -> None:
525 root, cid = two_branch_repo
526 r = runner.invoke(
527 cli, ["cherry-pick", cid, "--json"],
528 env=_env(root), catch_exceptions=False,
529 )
530 d = json.loads(r.output)
531 assert d["message"] == "extra on feat"
532
533 def test_message_stored_in_commit_record(
534 self, two_branch_repo: tuple[pathlib.Path, str]
535 ) -> None:
536 from muse.core.store import get_head_commit_id, read_commit
537 root, cid = two_branch_repo
538 runner.invoke(
539 cli, ["cherry-pick", cid, "-m", "my override"],
540 env=_env(root), catch_exceptions=False,
541 )
542 new_cid = get_head_commit_id(root, "main")
543 assert new_cid is not None
544 rec = read_commit(root, new_cid)
545 assert rec is not None
546 assert rec.message == "my override"
547
548
549 class TestWriteOrdering:
550 def test_write_snapshot_before_apply_manifest(
551 self, two_branch_repo: tuple[pathlib.Path, str]
552 ) -> None:
553 """write_snapshot must be called before apply_manifest in the normal path."""
554 from unittest.mock import patch
555 import muse.cli.commands.cherry_pick as cp_mod
556 from muse.core import store as s
557 events: list[str] = []
558 orig_ws = s.write_snapshot
559 from muse.core.workdir import apply_manifest as orig_am_fn
560
561 def tracking_write_snapshot(root: pathlib.Path, rec: s.SnapshotRecord) -> None:
562 orig_ws(root, rec)
563 events.append("write_snapshot")
564
565 def tracking_apply(root: pathlib.Path, prev: Manifest, manifest: Manifest) -> None:
566 orig_am_fn(root, prev, manifest)
567 events.append("apply_manifest")
568
569 root, cid = two_branch_repo
570 with (
571 patch.object(cp_mod, "write_snapshot", tracking_write_snapshot),
572 patch("muse.cli.commands.cherry_pick.apply_manifest", tracking_apply),
573 ):
574 runner.invoke(cli, ["cherry-pick", cid], env=_env(root), catch_exceptions=False)
575
576 assert "write_snapshot" in events
577 assert "apply_manifest" in events
578 assert events.index("write_snapshot") < events.index("apply_manifest"), (
579 f"write_snapshot must precede apply_manifest, got: {events}"
580 )
581
582
583 class TestFailFast:
584 def test_missing_parent_commit_is_error(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
585 """When the target has a parent_commit_id but the parent object is missing,
586 cherry-pick must exit INTERNAL_ERROR — not silently use empty base."""
587 import uuid, datetime
588 monkeypatch.chdir(tmp_path)
589 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
590 runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
591
592 from muse.core.store import (
593 CommitRecord, SnapshotRecord, write_commit, write_snapshot,
594 get_head_commit_id,
595 )
596 from muse.core.snapshot import compute_snapshot_id, compute_commit_id
597
598 repo_id = (tmp_path / ".muse" / "repo.json").read_text()
599 import json as _json
600 repo_id = _json.loads(repo_id)["repo_id"]
601
602 # Create a base commit
603 m1: Manifest = {}
604 s1 = compute_snapshot_id(m1)
605 t1 = datetime.datetime.now(datetime.timezone.utc)
606 c1 = compute_commit_id(
607 repo_id=repo_id,
608 parent_ids=[],
609 snapshot_id=s1,
610 message="base",
611 committed_at_iso=t1.isoformat(),
612 )
613 write_snapshot(tmp_path, SnapshotRecord(snapshot_id=s1, manifest=m1))
614 write_commit(tmp_path, CommitRecord(
615 commit_id=c1, repo_id=repo_id, created_on_branch="main",
616 snapshot_id=s1, message="base", committed_at=t1,
617 parent_commit_id=None,
618 ))
619 (tmp_path / ".muse" / "refs" / "heads" / "main").write_text(c1)
620
621 # Create a second commit that references c1 as parent
622 m2: Manifest = {}
623 s2 = compute_snapshot_id(m2)
624 t2 = datetime.datetime.now(datetime.timezone.utc)
625 c2 = compute_commit_id(
626 repo_id=repo_id,
627 parent_ids=[c1],
628 snapshot_id=s2,
629 message="target",
630 committed_at_iso=t2.isoformat(),
631 )
632 write_snapshot(tmp_path, SnapshotRecord(snapshot_id=s2, manifest=m2))
633 write_commit(tmp_path, CommitRecord(
634 commit_id=c2, repo_id=repo_id, created_on_branch="main",
635 snapshot_id=s2, message="target", committed_at=t2,
636 parent_commit_id=c1,
637 ))
638
639 # Now delete the parent commit file to simulate object-store corruption
640 # Path shape: .muse/commits/<algo>/<hex>.msgpack
641 algo, hex_str = split_id(c1)
642 commit_file = tmp_path / ".muse" / "commits" / algo / f"{hex_str}.msgpack"
643 if commit_file.exists():
644 commit_file.unlink()
645
646 # Reset HEAD to base so we can cherry-pick c2 "from another branch"
647 # Switch to a fresh branch at c1's snapshot
648 runner.invoke(cli, ["branch", "target-branch"], env=_env(tmp_path), catch_exceptions=False)
649 runner.invoke(cli, ["checkout", "target-branch"], env=_env(tmp_path), catch_exceptions=False)
650 (tmp_path / ".muse" / "refs" / "heads" / "target-branch").write_text(c1)
651
652 r = runner.invoke(cli, ["cherry-pick", c2], env=_env(tmp_path))
653 # Must fail with INTERNAL_ERROR (exit code 3), not succeed silently
654 assert r.exit_code != 0
655
656
657 # ---------------------------------------------------------------------------
658 # End-to-end — text and JSON output
659 # ---------------------------------------------------------------------------
660
661 class TestTextOutput:
662 def test_text_shows_branch_and_short_id(
663 self, two_branch_repo: tuple[pathlib.Path, str]
664 ) -> None:
665 root, cid = two_branch_repo
666 r = runner.invoke(cli, ["cherry-pick", cid], env=_env(root), catch_exceptions=False)
667 assert r.exit_code == 0
668 assert "main" in r.output
669
670 def test_no_commit_text_mentions_workdir(
671 self, two_branch_repo: tuple[pathlib.Path, str]
672 ) -> None:
673 root, cid = two_branch_repo
674 r = runner.invoke(
675 cli, ["cherry-pick", cid, "--no-commit"],
676 env=_env(root), catch_exceptions=False,
677 )
678 output = r.output.lower()
679 assert "working tree" in output or "applied" in output or "commit" in output
680
681 def test_workdir_has_cherry_picked_file(
682 self, two_branch_repo: tuple[pathlib.Path, str]
683 ) -> None:
684 root, cid = two_branch_repo
685 runner.invoke(cli, ["cherry-pick", cid], env=_env(root), catch_exceptions=False)
686 assert (root / "extra.py").exists()
687 assert (root / "extra.py").read_text() == "extra\n"
688
689
690 class TestJsonOutput:
691 def test_source_commit_id_matches(
692 self, two_branch_repo: tuple[pathlib.Path, str]
693 ) -> None:
694 root, cid = two_branch_repo
695 r = runner.invoke(cli, ["cherry-pick", cid, "--json"], env=_env(root), catch_exceptions=False)
696 d = json.loads(r.output)
697 assert d["source_commit_id"] == cid
698
699 def test_branch_field(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
700 root, cid = two_branch_repo
701 r = runner.invoke(cli, ["cherry-pick", cid, "--json"], env=_env(root), catch_exceptions=False)
702 d = json.loads(r.output)
703 assert d["branch"] == "main"
704
705 def test_new_commit_id_different_from_source(
706 self, two_branch_repo: tuple[pathlib.Path, str]
707 ) -> None:
708 root, cid = two_branch_repo
709 r = runner.invoke(cli, ["cherry-pick", cid, "--json"], env=_env(root), catch_exceptions=False)
710 d = json.loads(r.output)
711 assert d["commit_id"] != cid
712 # Canonical Muse IDs are "sha256:<64 hex chars>" = 71 chars total
713 assert isinstance(d["commit_id"], str)
714 assert d["commit_id"].startswith("sha256:")
715 assert len(d["commit_id"]) == 71
716
717 def test_conflicts_empty_on_success(
718 self, two_branch_repo: tuple[pathlib.Path, str]
719 ) -> None:
720 root, cid = two_branch_repo
721 r = runner.invoke(cli, ["cherry-pick", cid, "--json"], env=_env(root), catch_exceptions=False)
722 d = json.loads(r.output)
723 assert d["conflicts"] == []
724
725
726 class TestForce:
727 def test_force_bypasses_dirty_check(
728 self, two_branch_repo: tuple[pathlib.Path, str]
729 ) -> None:
730 root, cid = two_branch_repo
731 (root / "base.py").write_text("modified but uncommitted\n")
732 r = runner.invoke(
733 cli, ["cherry-pick", cid, "--force"],
734 env=_env(root), catch_exceptions=False,
735 )
736 assert r.exit_code == 0, r.output
737
738 def test_without_force_dirty_tree_fails(
739 self, two_branch_repo: tuple[pathlib.Path, str]
740 ) -> None:
741 root, cid = two_branch_repo
742 (root / "base.py").write_text("uncommitted change\n")
743 r = runner.invoke(cli, ["cherry-pick", cid], env=_env(root))
744 assert r.exit_code != 0
745
746
747 # ---------------------------------------------------------------------------
748 # Security — ANSI injection and sanitization
749 # ---------------------------------------------------------------------------
750
751 class TestSecurity:
752 def test_ansi_in_ref_error_in_stderr(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
753 root, _ = two_branch_repo
754 ansi_ref = "\x1b[31mbadref\x1b[0m"
755 r = runner.invoke(cli, ["cherry-pick", ansi_ref], env=_env(root))
756 assert r.exit_code != 0
757 assert "\x1b[31m" not in (r.stdout or "")
758 assert "badref" in (r.stderr or "")
759
760 def test_ansi_in_commit_message_not_in_stored_commit(
761 self, two_branch_repo: tuple[pathlib.Path, str]
762 ) -> None:
763 """If the source commit has ANSI in its message, the cherry-pick commit
764 stored on disk must not contain raw escape sequences."""
765 from unittest.mock import patch
766 from muse.core import store as s
767 import muse.cli.commands.cherry_pick as cp_mod
768
769 root, cid = two_branch_repo
770 orig_rc = s.read_commit
771
772 def poisoned_read_commit(root: pathlib.Path, c: str) -> s.CommitRecord | None:
773 rec = orig_rc(root, c)
774 if rec is not None and rec.commit_id == cid:
775 return s.CommitRecord(
776 commit_id=rec.commit_id, repo_id=rec.repo_id,
777 created_on_branch=rec.created_on_branch, snapshot_id=rec.snapshot_id,
778 message="\x1b[31mmalicious\x1b[0m",
779 committed_at=rec.committed_at,
780 parent_commit_id=rec.parent_commit_id,
781 )
782 return rec
783
784 with patch.object(cp_mod, "read_commit", poisoned_read_commit):
785 r = runner.invoke(
786 cli, ["cherry-pick", cid, "--json"],
787 env=_env(root), catch_exceptions=False,
788 )
789
790 if r.exit_code == 0:
791 d = json.loads(r.output)
792 assert "\x1b[" not in d.get("message", ""), (
793 "Cherry-pick commit message must not contain raw ANSI from source"
794 )
795
796
797
798 # ---------------------------------------------------------------------------
799 # Stress
800 # ---------------------------------------------------------------------------
801
802 class TestStress:
803 @pytest.mark.slow
804 def test_cherry_pick_deep_in_history(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
805 """Cherry-pick a commit that's deep in a 200-commit chain."""
806 monkeypatch.chdir(tmp_path)
807 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
808 env = _env(tmp_path)
809 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
810 (tmp_path / "seed.py").write_text("seed\n")
811 runner.invoke(cli, ["commit", "-m", "seed"], env=env, catch_exceptions=False)
812
813 runner.invoke(cli, ["branch", "source"], env=env, catch_exceptions=False)
814 runner.invoke(cli, ["checkout", "source"], env=env, catch_exceptions=False)
815
816 (tmp_path / "target.py").write_text("target\n")
817 runner.invoke(cli, ["commit", "-m", "target commit"], env=env, catch_exceptions=False)
818 from muse.core.store import get_head_commit_id
819 target_cid = get_head_commit_id(tmp_path, "source")
820 assert target_cid is not None
821
822 for i in range(198):
823 (tmp_path / f"f{i}.py").write_text(f"{i}\n")
824 runner.invoke(cli, ["commit", "-m", f"c{i}"], env=env, catch_exceptions=False)
825
826 runner.invoke(cli, ["checkout", "main"], env=env, catch_exceptions=False)
827 r = runner.invoke(
828 cli, ["cherry-pick", target_cid, "--json"],
829 env=env, catch_exceptions=False,
830 )
831 assert r.exit_code == 0, r.output
832 d = json.loads(r.output)
833 assert d["source_commit_id"] == target_cid
834 assert d["status"] == "picked"
835
836 @pytest.mark.slow
837 def test_sequential_cherry_picks(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
838 """30 sequential cherry-picks must all succeed."""
839 monkeypatch.chdir(tmp_path)
840 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
841 env = _env(tmp_path)
842 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
843 (tmp_path / "base.py").write_text("base\n")
844 runner.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
845
846 runner.invoke(cli, ["branch", "source"], env=env, catch_exceptions=False)
847 runner.invoke(cli, ["checkout", "source"], env=env, catch_exceptions=False)
848
849 # Create 30 non-conflicting commits on source
850 source_cids: list[str] = []
851 for i in range(30):
852 (tmp_path / f"s{i}.py").write_text(f"s{i}\n")
853 runner.invoke(cli, ["commit", "-m", f"src{i}"], env=env, catch_exceptions=False)
854 from muse.core.store import get_head_commit_id
855 cid = get_head_commit_id(tmp_path, "source")
856 assert cid is not None
857 source_cids.append(cid)
858
859 runner.invoke(cli, ["checkout", "main"], env=env, catch_exceptions=False)
860 failures: list[str] = []
861 for i, cid in enumerate(source_cids):
862 r = runner.invoke(cli, ["cherry-pick", cid, "--json"], env=env)
863 if r.exit_code != 0:
864 failures.append(f"pick {i}: exit={r.exit_code} {r.output.strip()[:60]}")
865
866 assert not failures, f"Sequential failures: {failures}"
867
868
869 @pytest.mark.slow
870 def test_dry_run_performance(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
871 """--dry-run on a large repo must complete in < 3 s."""
872 monkeypatch.chdir(tmp_path)
873 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
874 env = _env(tmp_path)
875 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
876 (tmp_path / "base.py").write_text("base\n")
877 runner.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
878 runner.invoke(cli, ["branch", "src"], env=env, catch_exceptions=False)
879 runner.invoke(cli, ["checkout", "src"], env=env, catch_exceptions=False)
880
881 (tmp_path / "target.py").write_text("target\n")
882 runner.invoke(cli, ["commit", "-m", "target"], env=env, catch_exceptions=False)
883 from muse.core.store import get_head_commit_id
884 target_cid = get_head_commit_id(tmp_path, "src")
885 assert target_cid is not None
886
887 for i in range(100):
888 (tmp_path / f"f{i}.py").write_text(f"{i}\n")
889 runner.invoke(cli, ["commit", "-m", f"c{i}"], env=env, catch_exceptions=False)
890
891 runner.invoke(cli, ["checkout", "main"], env=env, catch_exceptions=False)
892 start = time.perf_counter()
893 r = runner.invoke(
894 cli, ["cherry-pick", target_cid, "--dry-run", "--json"],
895 env=env, catch_exceptions=False,
896 )
897 elapsed = time.perf_counter() - start
898 assert r.exit_code == 0, r.output
899 assert elapsed < 3.0, f"--dry-run took {elapsed:.2f}s"
900
901
902 # ---------------------------------------------------------------------------
903 # Agent supercharge — duration_ms and exit_code in every JSON output
904 # ---------------------------------------------------------------------------
905
906
907 class TestElapsed:
908 """Every JSON output path must include ``duration_ms`` as a float."""
909
910 def test_picked_json_has_elapsed(
911 self, two_branch_repo: tuple[pathlib.Path, str]
912 ) -> None:
913 root, cid = two_branch_repo
914 r = runner.invoke(cli, ["cherry-pick", cid, "--json"], env=_env(root), catch_exceptions=False)
915 d = json.loads(r.output)
916 assert "duration_ms" in d
917 assert isinstance(d["duration_ms"], float)
918
919 def test_applied_json_has_elapsed(
920 self, two_branch_repo: tuple[pathlib.Path, str]
921 ) -> None:
922 root, cid = two_branch_repo
923 r = runner.invoke(
924 cli, ["cherry-pick", cid, "--no-commit", "--json"],
925 env=_env(root), catch_exceptions=False,
926 )
927 d = json.loads(r.output)
928 assert "duration_ms" in d
929 assert isinstance(d["duration_ms"], float)
930
931 def test_dry_run_json_has_elapsed(
932 self, two_branch_repo: tuple[pathlib.Path, str]
933 ) -> None:
934 root, cid = two_branch_repo
935 r = runner.invoke(
936 cli, ["cherry-pick", cid, "--dry-run", "--json"],
937 env=_env(root), catch_exceptions=False,
938 )
939 d = json.loads(r.output)
940 assert "duration_ms" in d
941 assert isinstance(d["duration_ms"], float)
942
943 def test_conflict_json_has_elapsed(
944 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
945 ) -> None:
946 """The conflict JSON path must also include duration_ms."""
947 monkeypatch.chdir(tmp_path)
948 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
949 env = _env(tmp_path)
950 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
951
952 (tmp_path / "shared.py").write_text("line1\nline2\nline3\n")
953 runner.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
954
955 runner.invoke(cli, ["branch", "src"], env=env, catch_exceptions=False)
956 runner.invoke(cli, ["checkout", "src"], env=env, catch_exceptions=False)
957 (tmp_path / "shared.py").write_text("line1\nSRC_LINE2\nline3\n")
958 runner.invoke(cli, ["commit", "-m", "src change"], env=env, catch_exceptions=False)
959 from muse.core.store import get_head_commit_id as _gci
960 src_cid = _gci(tmp_path, "src")
961 assert src_cid is not None
962
963 runner.invoke(cli, ["checkout", "main"], env=env, catch_exceptions=False)
964 (tmp_path / "shared.py").write_text("line1\nMAIN_LINE2\nline3\n")
965 runner.invoke(cli, ["commit", "-m", "main change"], env=env, catch_exceptions=False)
966
967 r = runner.invoke(cli, ["cherry-pick", src_cid, "--json"], env=env)
968 # Conflict should produce JSON with duration_ms even on exit 1
969 assert r.exit_code == 1
970 d = json.loads(r.output)
971 assert "duration_ms" in d
972 assert isinstance(d["duration_ms"], float)
973
974
975 class TestExitCode:
976 """Every successful JSON path includes ``exit_code: 0``; conflict path has ``exit_code: 1``."""
977
978 def test_picked_json_exit_code_0(
979 self, two_branch_repo: tuple[pathlib.Path, str]
980 ) -> None:
981 root, cid = two_branch_repo
982 r = runner.invoke(cli, ["cherry-pick", cid, "--json"], env=_env(root), catch_exceptions=False)
983 d = json.loads(r.output)
984 assert d["exit_code"] == 0
985
986 def test_applied_json_exit_code_0(
987 self, two_branch_repo: tuple[pathlib.Path, str]
988 ) -> None:
989 root, cid = two_branch_repo
990 r = runner.invoke(
991 cli, ["cherry-pick", cid, "--no-commit", "--json"],
992 env=_env(root), catch_exceptions=False,
993 )
994 d = json.loads(r.output)
995 assert d["exit_code"] == 0
996
997 def test_dry_run_json_exit_code_0(
998 self, two_branch_repo: tuple[pathlib.Path, str]
999 ) -> None:
1000 root, cid = two_branch_repo
1001 r = runner.invoke(
1002 cli, ["cherry-pick", cid, "--dry-run", "--json"],
1003 env=_env(root), catch_exceptions=False,
1004 )
1005 d = json.loads(r.output)
1006 assert d["exit_code"] == 0
1007
1008 def test_conflict_json_exit_code_1(
1009 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1010 ) -> None:
1011 """Conflict JSON must report exit_code: 1, mirroring the process exit."""
1012 monkeypatch.chdir(tmp_path)
1013 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
1014 env = _env(tmp_path)
1015 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
1016
1017 (tmp_path / "shared.py").write_text("line1\nline2\nline3\n")
1018 runner.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
1019
1020 runner.invoke(cli, ["branch", "src"], env=env, catch_exceptions=False)
1021 runner.invoke(cli, ["checkout", "src"], env=env, catch_exceptions=False)
1022 (tmp_path / "shared.py").write_text("line1\nSRC_LINE2\nline3\n")
1023 runner.invoke(cli, ["commit", "-m", "src change"], env=env, catch_exceptions=False)
1024 from muse.core.store import get_head_commit_id as _gci
1025 src_cid = _gci(tmp_path, "src")
1026 assert src_cid is not None
1027
1028 runner.invoke(cli, ["checkout", "main"], env=env, catch_exceptions=False)
1029 (tmp_path / "shared.py").write_text("line1\nMAIN_LINE2\nline3\n")
1030 runner.invoke(cli, ["commit", "-m", "main change"], env=env, catch_exceptions=False)
1031
1032 r = runner.invoke(cli, ["cherry-pick", src_cid, "--json"], env=env)
1033 assert r.exit_code == 1
1034 d = json.loads(r.output)
1035 assert d["exit_code"] == 1
1036
1037
1038 class TestJsonSchemaComplete:
1039 """``duration_ms`` and ``exit_code`` must be in every JSON output."""
1040
1041 _FULL_KEYS = {
1042 "status", "commit_id", "branch", "ref",
1043 "source_commit_id", "snapshot_id", "message",
1044 "no_commit", "dry_run", "conflicts",
1045 "duration_ms", "exit_code",
1046 "muse_version", "schema", "timestamp", "warnings",
1047 }
1048
1049 def test_picked_has_complete_schema(
1050 self, two_branch_repo: tuple[pathlib.Path, str]
1051 ) -> None:
1052 root, cid = two_branch_repo
1053 r = runner.invoke(cli, ["cherry-pick", cid, "--json"], env=_env(root), catch_exceptions=False)
1054 d = json.loads(r.output)
1055 missing = self._FULL_KEYS - d.keys()
1056 assert not missing, f"Missing keys in 'picked' JSON: {missing}"
1057
1058 def test_applied_has_complete_schema(
1059 self, two_branch_repo: tuple[pathlib.Path, str]
1060 ) -> None:
1061 root, cid = two_branch_repo
1062 r = runner.invoke(
1063 cli, ["cherry-pick", cid, "--no-commit", "--json"],
1064 env=_env(root), catch_exceptions=False,
1065 )
1066 d = json.loads(r.output)
1067 missing = self._FULL_KEYS - d.keys()
1068 assert not missing, f"Missing keys in 'applied' JSON: {missing}"
1069
1070 def test_dry_run_has_complete_schema(
1071 self, two_branch_repo: tuple[pathlib.Path, str]
1072 ) -> None:
1073 root, cid = two_branch_repo
1074 r = runner.invoke(
1075 cli, ["cherry-pick", cid, "--dry-run", "--json"],
1076 env=_env(root), catch_exceptions=False,
1077 )
1078 d = json.loads(r.output)
1079 missing = self._FULL_KEYS - d.keys()
1080 assert not missing, f"Missing keys in 'dry_run' JSON: {missing}"
1081
1082 def test_all_schemas_identical(
1083 self, two_branch_repo: tuple[pathlib.Path, str]
1084 ) -> None:
1085 """All three success paths must have identical key sets."""
1086 root, cid = two_branch_repo
1087 r_dr = runner.invoke(
1088 cli, ["cherry-pick", cid, "--dry-run", "--json"],
1089 env=_env(root), catch_exceptions=False,
1090 )
1091 r_nc = runner.invoke(
1092 cli, ["cherry-pick", cid, "--no-commit", "--json"],
1093 env=_env(root), catch_exceptions=False,
1094 )
1095 runner.invoke(cli, ["commit", "-m", "after nc"], env=_env(root), catch_exceptions=False)
1096 r_nm = runner.invoke(
1097 cli, ["cherry-pick", cid, "--json"],
1098 env=_env(root), catch_exceptions=False,
1099 )
1100 keys_dr = set(json.loads(r_dr.output).keys())
1101 keys_nc = set(json.loads(r_nc.output).keys())
1102 keys_nm = set(json.loads(r_nm.output).keys())
1103 assert keys_dr == keys_nc == keys_nm == self._FULL_KEYS
1104
1105
1106 class TestTextOutputHex:
1107 """Text output must show sha256: prefix + 8 hex chars — canonical and algorithm-identifying."""
1108
1109 def test_picked_text_shows_prefixed_short_id(
1110 self, two_branch_repo: tuple[pathlib.Path, str]
1111 ) -> None:
1112 root, cid = two_branch_repo
1113 r = runner.invoke(cli, ["cherry-pick", cid], env=_env(root), catch_exceptions=False)
1114 assert r.exit_code == 0
1115 from muse.core.store import get_head_commit_id
1116 new_cid = get_head_commit_id(root, "main")
1117 assert new_cid is not None
1118 short = new_cid[:len("sha256:") + 8]
1119 assert short in r.output, (
1120 f"Expected '{short}' in cherry-pick output, got: {r.output!r}"
1121 )
1122
1123 def test_dry_run_text_shows_prefixed_short_id(
1124 self, two_branch_repo: tuple[pathlib.Path, str]
1125 ) -> None:
1126 import re
1127 root, cid = two_branch_repo
1128 r = runner.invoke(
1129 cli, ["cherry-pick", cid, "--dry-run"],
1130 env=_env(root), catch_exceptions=False,
1131 )
1132 assert r.exit_code == 0
1133 # The dry-run text shows the source short ID in parens: (sha256:a1b2c3d4e5f6)
1134 expected = short_id(cid) # sha256:<12hex>
1135 match = re.search(r'\(sha256:([0-9a-f]{12})\)', r.output)
1136 assert match is not None, (
1137 f"Expected '(sha256:<12hex>)' in dry-run output, got: {r.output!r}"
1138 )
1139 assert f"sha256:{match.group(1)}" == expected, (
1140 f"Expected {expected} in parens, got sha256:{match.group(1)}"
1141 )
1142
1143
1144 # ---------------------------------------------------------------------------
1145 # Flag registration tests
1146 # ---------------------------------------------------------------------------
1147
1148 import argparse as _argparse
1149 from muse.cli.commands.cherry_pick import register as _register_cherry_pick
1150
1151
1152 def _parse_cp(*args: str) -> _argparse.Namespace:
1153 """Build an argument parser via register() and parse args."""
1154 root_p = _argparse.ArgumentParser()
1155 subs = root_p.add_subparsers(dest="cmd")
1156 _register_cherry_pick(subs)
1157 return root_p.parse_args(["cherry-pick", *args])
1158
1159
1160 class TestRegisterFlags:
1161 def test_default_json_out_is_false(self) -> None:
1162 ns = _parse_cp(fake_id("a"))
1163 assert ns.json_out is False
1164
1165 def test_json_flag_sets_json_out(self) -> None:
1166 ns = _parse_cp(fake_id("a"), "--json")
1167 assert ns.json_out is True
1168
1169 def test_j_shorthand_sets_json_out(self) -> None:
1170 ns = _parse_cp(fake_id("a"), "-j")
1171 assert ns.json_out is True
1172
1173 def test_no_commit_flag(self) -> None:
1174 ns = _parse_cp(fake_id("a"), "--no-commit")
1175 assert ns.no_commit is True
1176
1177 def test_no_commit_has_no_n_shorthand(self) -> None:
1178 import pytest
1179 with pytest.raises(SystemExit):
1180 _parse_cp(fake_id("a"), "-n")
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 136 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 142 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 145 days ago