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