gabriel / muse public
test_cmd_cherry_pick_hardening.py python
1,185 lines 46.9 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 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.refs 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.refs 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.refs 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.refs import get_head_commit_id
378 from muse.core.commits import get_all_commits
379 root, cid = two_branch_repo
380 before_count = len(get_all_commits(root))
381 before_head = get_head_commit_id(root, "main")
382 r = runner.invoke(
383 cli, ["cherry-pick", cid, "--dry-run"],
384 env=_env(root), catch_exceptions=False,
385 )
386 assert r.exit_code == 0, r.output
387 assert len(get_all_commits(root)) == before_count
388 assert get_head_commit_id(root, "main") == before_head
389
390 def test_workdir_unchanged(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
391 root, cid = two_branch_repo
392 extra = root / "extra.py"
393 content_before = extra.read_text() if extra.exists() else None
394 r = runner.invoke(
395 cli, ["cherry-pick", cid, "--dry-run"],
396 env=_env(root), catch_exceptions=False,
397 )
398 assert r.exit_code == 0, r.output
399 assert (extra.read_text() if extra.exists() else None) == content_before
400
401 def test_reflog_unchanged(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
402 from muse.core.reflog import read_reflog
403 root, cid = two_branch_repo
404 before = len(read_reflog(root, "main"))
405 runner.invoke(cli, ["cherry-pick", cid, "--dry-run"], env=_env(root), catch_exceptions=False)
406 assert len(read_reflog(root, "main")) == before
407
408 def test_dry_run_text_output_mentions_dry_run(
409 self, two_branch_repo: tuple[pathlib.Path, str]
410 ) -> None:
411 root, cid = two_branch_repo
412 r = runner.invoke(
413 cli, ["cherry-pick", cid, "--dry-run"],
414 env=_env(root), catch_exceptions=False,
415 )
416 assert "dry-run" in r.output.lower() or "would" in r.output.lower()
417
418 def test_dry_run_invalid_ref_errors(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
419 root, _ = two_branch_repo
420 r = runner.invoke(cli, ["cherry-pick", "no-such-ref", "--dry-run"], env=_env(root))
421 assert r.exit_code != 0
422
423 def test_dry_run_json_snapshot_id_present(
424 self, two_branch_repo: tuple[pathlib.Path, str]
425 ) -> None:
426 root, cid = two_branch_repo
427 r = runner.invoke(
428 cli, ["cherry-pick", cid, "--dry-run", "--json"],
429 env=_env(root), catch_exceptions=False,
430 )
431 d = json.loads(r.output)
432 # Canonical Muse IDs are "sha256:<64 hex chars>" = 71 chars total
433 assert d["snapshot_id"] is not None
434 assert d["snapshot_id"].startswith("sha256:")
435 assert len(d["snapshot_id"]) == 71
436
437
438 class TestNoCommit:
439 def test_branch_ref_not_advanced(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
440 from muse.core.refs import get_head_commit_id
441 root, cid = two_branch_repo
442 before_head = get_head_commit_id(root, "main")
443 r = runner.invoke(
444 cli, ["cherry-pick", cid, "--no-commit"],
445 env=_env(root), catch_exceptions=False,
446 )
447 assert r.exit_code == 0, r.output
448 assert get_head_commit_id(root, "main") == before_head
449
450 def test_workdir_modified(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
451 root, cid = two_branch_repo
452 r = runner.invoke(
453 cli, ["cherry-pick", cid, "--no-commit"],
454 env=_env(root), catch_exceptions=False,
455 )
456 assert r.exit_code == 0, r.output
457 # extra.py was added by the feat branch commit
458 assert (root / "extra.py").exists()
459
460 def test_no_commit_in_json(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
461 root, cid = two_branch_repo
462 r = runner.invoke(
463 cli, ["cherry-pick", cid, "--no-commit", "--json"],
464 env=_env(root), catch_exceptions=False,
465 )
466 d = json.loads(r.output)
467 assert d["no_commit"] is True
468 assert d["commit_id"] is None
469 assert d["status"] == "applied"
470
471 def test_reflog_not_written_for_no_commit(
472 self, two_branch_repo: tuple[pathlib.Path, str]
473 ) -> None:
474 from muse.core.reflog import read_reflog
475 root, cid = two_branch_repo
476 before = len(read_reflog(root, "main"))
477 runner.invoke(
478 cli, ["cherry-pick", cid, "--no-commit"],
479 env=_env(root), catch_exceptions=False,
480 )
481 assert len(read_reflog(root, "main")) == before
482
483
484 class TestReflog:
485 def test_reflog_appended(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
486 from muse.core.reflog import read_reflog
487 root, cid = two_branch_repo
488 before = len(read_reflog(root, "main"))
489 runner.invoke(cli, ["cherry-pick", cid], env=_env(root), catch_exceptions=False)
490 assert len(read_reflog(root, "main")) > before
491
492 def test_reflog_operation_contains_cherry_pick(
493 self, two_branch_repo: tuple[pathlib.Path, str]
494 ) -> None:
495 from muse.core.reflog import read_reflog
496 root, cid = two_branch_repo
497 runner.invoke(cli, ["cherry-pick", cid], env=_env(root), catch_exceptions=False)
498 entries = read_reflog(root, "main")
499 # read_reflog returns newest-first
500 assert "cherry-pick" in entries[0].operation.lower()
501
502
503 class TestMessageFlag:
504 def test_custom_message_in_json(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
505 root, cid = two_branch_repo
506 r = runner.invoke(
507 cli, ["cherry-pick", cid, "-m", "custom pick msg", "--json"],
508 env=_env(root), catch_exceptions=False,
509 )
510 assert r.exit_code == 0, r.output
511 d = json.loads(r.output)
512 assert d["message"] == "custom pick msg"
513
514 def test_custom_message_in_text(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
515 root, cid = two_branch_repo
516 r = runner.invoke(
517 cli, ["cherry-pick", cid, "-m", "undo extra"],
518 env=_env(root), catch_exceptions=False,
519 )
520 assert r.exit_code == 0, r.output
521 assert "undo extra" in r.output
522
523 def test_default_message_is_source_message(
524 self, two_branch_repo: tuple[pathlib.Path, str]
525 ) -> None:
526 root, cid = two_branch_repo
527 r = runner.invoke(
528 cli, ["cherry-pick", cid, "--json"],
529 env=_env(root), catch_exceptions=False,
530 )
531 d = json.loads(r.output)
532 assert d["message"] == "extra on feat"
533
534 def test_message_stored_in_commit_record(
535 self, two_branch_repo: tuple[pathlib.Path, str]
536 ) -> None:
537 from muse.core.refs import get_head_commit_id
538 from muse.core.commits import read_commit
539 root, cid = two_branch_repo
540 runner.invoke(
541 cli, ["cherry-pick", cid, "-m", "my override"],
542 env=_env(root), catch_exceptions=False,
543 )
544 new_cid = get_head_commit_id(root, "main")
545 assert new_cid is not None
546 rec = read_commit(root, new_cid)
547 assert rec is not None
548 assert rec.message == "my override"
549
550
551 class TestWriteOrdering:
552 def test_write_snapshot_before_apply_manifest(
553 self, two_branch_repo: tuple[pathlib.Path, str]
554 ) -> None:
555 """write_snapshot must be called before apply_manifest in the normal path."""
556 from unittest.mock import patch
557 import muse.cli.commands.cherry_pick as cp_mod
558 from muse.core import store as s
559 events: list[str] = []
560 orig_ws = s.write_snapshot
561 from muse.core.workdir import apply_manifest as orig_am_fn
562
563 def tracking_write_snapshot(root: pathlib.Path, rec: s.SnapshotRecord) -> None:
564 orig_ws(root, rec)
565 events.append("write_snapshot")
566
567 def tracking_apply(root: pathlib.Path, prev: Manifest, manifest: Manifest) -> None:
568 orig_am_fn(root, prev, manifest)
569 events.append("apply_manifest")
570
571 root, cid = two_branch_repo
572 with (
573 patch.object(cp_mod, "write_snapshot", tracking_write_snapshot),
574 patch("muse.cli.commands.cherry_pick.apply_manifest", tracking_apply),
575 ):
576 runner.invoke(cli, ["cherry-pick", cid], env=_env(root), catch_exceptions=False)
577
578 assert "write_snapshot" in events
579 assert "apply_manifest" in events
580 assert events.index("write_snapshot") < events.index("apply_manifest"), (
581 f"write_snapshot must precede apply_manifest, got: {events}"
582 )
583
584
585 class TestFailFast:
586 def test_missing_parent_commit_is_error(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
587 """When the target has a parent_commit_id but the parent object is missing,
588 cherry-pick must exit INTERNAL_ERROR — not silently use empty base."""
589 import datetime
590 monkeypatch.chdir(tmp_path)
591 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
592 runner.invoke(cli, ["init"], env=_env(tmp_path), catch_exceptions=False)
593
594 from muse.core.refs import get_head_commit_id
595 from muse.core.commits import (
596 CommitRecord,
597 write_commit,
598 )
599 from muse.core.snapshots import (
600 SnapshotRecord,
601 write_snapshot,
602 )
603 from muse.core.ids import hash_commit, hash_snapshot
604
605 repo_id = (repo_json_path(tmp_path)).read_text()
606 import json as _json
607 repo_id = _json.loads(repo_id)["repo_id"]
608
609 # Create a base commit
610 m1: Manifest = {}
611 s1 = hash_snapshot(m1)
612 t1 = datetime.datetime.now(datetime.timezone.utc)
613 c1 = hash_commit( parent_ids=[],
614 snapshot_id=s1,
615 message="base",
616 committed_at_iso=t1.isoformat(),
617 )
618 write_snapshot(tmp_path, SnapshotRecord(snapshot_id=s1, manifest=m1))
619 write_commit(tmp_path, CommitRecord(
620 commit_id=c1, branch="main",
621 snapshot_id=s1, message="base", committed_at=t1,
622 parent_commit_id=None,
623 ))
624 (heads_dir(tmp_path) / "main").write_text(c1)
625
626 # Create a second commit that references c1 as parent
627 m2: Manifest = {}
628 s2 = hash_snapshot(m2)
629 t2 = datetime.datetime.now(datetime.timezone.utc)
630 c2 = hash_commit( parent_ids=[c1],
631 snapshot_id=s2,
632 message="target",
633 committed_at_iso=t2.isoformat(),
634 )
635 write_snapshot(tmp_path, SnapshotRecord(snapshot_id=s2, manifest=m2))
636 write_commit(tmp_path, CommitRecord(
637 commit_id=c2, branch="main",
638 snapshot_id=s2, message="target", committed_at=t2,
639 parent_commit_id=c1,
640 ))
641
642 # Now delete the parent commit object to simulate object-store corruption.
643 # Commits are stored in the unified object store: objects/<algo>/<2>/<62>
644 from muse.core.object_store import object_path as _obj_path
645 commit_obj = _obj_path(tmp_path, c1)
646 if commit_obj.exists():
647 import os as _os
648 _os.chmod(commit_obj, 0o644)
649 commit_obj.unlink()
650
651 # Reset HEAD to base so we can cherry-pick c2 "from another branch"
652 # Switch to a fresh branch at c1's snapshot
653 runner.invoke(cli, ["branch", "target-branch"], env=_env(tmp_path), catch_exceptions=False)
654 runner.invoke(cli, ["checkout", "target-branch"], env=_env(tmp_path), catch_exceptions=False)
655 (heads_dir(tmp_path) / "target-branch").write_text(c1)
656
657 r = runner.invoke(cli, ["cherry-pick", c2], env=_env(tmp_path))
658 # Must fail with INTERNAL_ERROR (exit code 3), not succeed silently
659 assert r.exit_code != 0
660
661
662 # ---------------------------------------------------------------------------
663 # End-to-end — text and JSON output
664 # ---------------------------------------------------------------------------
665
666 class TestTextOutput:
667 def test_text_shows_branch_and_short_id(
668 self, two_branch_repo: tuple[pathlib.Path, str]
669 ) -> None:
670 root, cid = two_branch_repo
671 r = runner.invoke(cli, ["cherry-pick", cid], env=_env(root), catch_exceptions=False)
672 assert r.exit_code == 0
673 assert "main" in r.output
674
675 def test_no_commit_text_mentions_workdir(
676 self, two_branch_repo: tuple[pathlib.Path, str]
677 ) -> None:
678 root, cid = two_branch_repo
679 r = runner.invoke(
680 cli, ["cherry-pick", cid, "--no-commit"],
681 env=_env(root), catch_exceptions=False,
682 )
683 output = r.output.lower()
684 assert "working tree" in output or "applied" in output or "commit" in output
685
686 def test_workdir_has_cherry_picked_file(
687 self, two_branch_repo: tuple[pathlib.Path, str]
688 ) -> None:
689 root, cid = two_branch_repo
690 runner.invoke(cli, ["cherry-pick", cid], env=_env(root), catch_exceptions=False)
691 assert (root / "extra.py").exists()
692 assert (root / "extra.py").read_text() == "extra\n"
693
694
695 class TestJsonOutput:
696 def test_source_commit_id_matches(
697 self, two_branch_repo: tuple[pathlib.Path, str]
698 ) -> None:
699 root, cid = two_branch_repo
700 r = runner.invoke(cli, ["cherry-pick", cid, "--json"], env=_env(root), catch_exceptions=False)
701 d = json.loads(r.output)
702 assert d["source_commit_id"] == cid
703
704 def test_branch_field(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
705 root, cid = two_branch_repo
706 r = runner.invoke(cli, ["cherry-pick", cid, "--json"], env=_env(root), catch_exceptions=False)
707 d = json.loads(r.output)
708 assert d["branch"] == "main"
709
710 def test_new_commit_id_different_from_source(
711 self, two_branch_repo: tuple[pathlib.Path, str]
712 ) -> None:
713 root, cid = two_branch_repo
714 r = runner.invoke(cli, ["cherry-pick", cid, "--json"], env=_env(root), catch_exceptions=False)
715 d = json.loads(r.output)
716 assert d["commit_id"] != cid
717 # Canonical Muse IDs are "sha256:<64 hex chars>" = 71 chars total
718 assert isinstance(d["commit_id"], str)
719 assert d["commit_id"].startswith("sha256:")
720 assert len(d["commit_id"]) == 71
721
722 def test_conflicts_empty_on_success(
723 self, two_branch_repo: tuple[pathlib.Path, str]
724 ) -> None:
725 root, cid = two_branch_repo
726 r = runner.invoke(cli, ["cherry-pick", cid, "--json"], env=_env(root), catch_exceptions=False)
727 d = json.loads(r.output)
728 assert d["conflicts"] == []
729
730
731 class TestForce:
732 def test_force_bypasses_dirty_check(
733 self, two_branch_repo: tuple[pathlib.Path, str]
734 ) -> None:
735 root, cid = two_branch_repo
736 (root / "base.py").write_text("modified but uncommitted\n")
737 r = runner.invoke(
738 cli, ["cherry-pick", cid, "--force"],
739 env=_env(root), catch_exceptions=False,
740 )
741 assert r.exit_code == 0, r.output
742
743 def test_without_force_dirty_tree_fails(
744 self, two_branch_repo: tuple[pathlib.Path, str]
745 ) -> None:
746 root, cid = two_branch_repo
747 (root / "base.py").write_text("uncommitted change\n")
748 r = runner.invoke(cli, ["cherry-pick", cid], env=_env(root))
749 assert r.exit_code != 0
750
751
752 # ---------------------------------------------------------------------------
753 # Security — ANSI injection and sanitization
754 # ---------------------------------------------------------------------------
755
756 class TestSecurity:
757 def test_ansi_in_ref_error_in_stderr(self, two_branch_repo: tuple[pathlib.Path, str]) -> None:
758 root, _ = two_branch_repo
759 ansi_ref = "\x1b[31mbadref\x1b[0m"
760 r = runner.invoke(cli, ["cherry-pick", ansi_ref], env=_env(root))
761 assert r.exit_code != 0
762 assert "\x1b[31m" not in (r.stdout or "")
763 assert "badref" in (r.stderr or "")
764
765 def test_ansi_in_commit_message_not_in_stored_commit(
766 self, two_branch_repo: tuple[pathlib.Path, str]
767 ) -> None:
768 """If the source commit has ANSI in its message, the cherry-pick commit
769 stored on disk must not contain raw escape sequences."""
770 from unittest.mock import patch
771 from muse.core import store as s
772 import muse.cli.commands.cherry_pick as cp_mod
773
774 root, cid = two_branch_repo
775 orig_rc = s.read_commit
776
777 def poisoned_read_commit(root: pathlib.Path, c: str) -> s.CommitRecord | None:
778 rec = orig_rc(root, c)
779 if rec is not None and rec.commit_id == cid:
780 return s.CommitRecord(
781 commit_id=rec.commit_id,
782 branch=rec.branch, snapshot_id=rec.snapshot_id,
783 message="\x1b[31mmalicious\x1b[0m",
784 committed_at=rec.committed_at,
785 parent_commit_id=rec.parent_commit_id,
786 )
787 return rec
788
789 with patch.object(cp_mod, "read_commit", poisoned_read_commit):
790 r = runner.invoke(
791 cli, ["cherry-pick", cid, "--json"],
792 env=_env(root), catch_exceptions=False,
793 )
794
795 if r.exit_code == 0:
796 d = json.loads(r.output)
797 assert "\x1b[" not in d.get("message", ""), (
798 "Cherry-pick commit message must not contain raw ANSI from source"
799 )
800
801
802
803 # ---------------------------------------------------------------------------
804 # Stress
805 # ---------------------------------------------------------------------------
806
807 class TestStress:
808 @pytest.mark.slow
809 def test_cherry_pick_deep_in_history(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
810 """Cherry-pick a commit that's deep in a 200-commit chain."""
811 monkeypatch.chdir(tmp_path)
812 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
813 env = _env(tmp_path)
814 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
815 (tmp_path / "seed.py").write_text("seed\n")
816 runner.invoke(cli, ["commit", "-m", "seed"], env=env, catch_exceptions=False)
817
818 runner.invoke(cli, ["branch", "source"], env=env, catch_exceptions=False)
819 runner.invoke(cli, ["checkout", "source"], env=env, catch_exceptions=False)
820
821 (tmp_path / "target.py").write_text("target\n")
822 runner.invoke(cli, ["commit", "-m", "target commit"], env=env, catch_exceptions=False)
823 from muse.core.refs import get_head_commit_id
824 target_cid = get_head_commit_id(tmp_path, "source")
825 assert target_cid is not None
826
827 for i in range(198):
828 (tmp_path / f"f{i}.py").write_text(f"{i}\n")
829 runner.invoke(cli, ["commit", "-m", f"c{i}"], env=env, catch_exceptions=False)
830
831 runner.invoke(cli, ["checkout", "main"], env=env, catch_exceptions=False)
832 r = runner.invoke(
833 cli, ["cherry-pick", target_cid, "--json"],
834 env=env, catch_exceptions=False,
835 )
836 assert r.exit_code == 0, r.output
837 d = json.loads(r.output)
838 assert d["source_commit_id"] == target_cid
839 assert d["status"] == "picked"
840
841 @pytest.mark.slow
842 def test_sequential_cherry_picks(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
843 """30 sequential cherry-picks must all succeed."""
844 monkeypatch.chdir(tmp_path)
845 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
846 env = _env(tmp_path)
847 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
848 (tmp_path / "base.py").write_text("base\n")
849 runner.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
850
851 runner.invoke(cli, ["branch", "source"], env=env, catch_exceptions=False)
852 runner.invoke(cli, ["checkout", "source"], env=env, catch_exceptions=False)
853
854 # Create 30 non-conflicting commits on source
855 source_cids: list[str] = []
856 for i in range(30):
857 (tmp_path / f"s{i}.py").write_text(f"s{i}\n")
858 runner.invoke(cli, ["commit", "-m", f"src{i}"], env=env, catch_exceptions=False)
859 from muse.core.refs import get_head_commit_id
860 cid = get_head_commit_id(tmp_path, "source")
861 assert cid is not None
862 source_cids.append(cid)
863
864 runner.invoke(cli, ["checkout", "main"], env=env, catch_exceptions=False)
865 failures: list[str] = []
866 for i, cid in enumerate(source_cids):
867 r = runner.invoke(cli, ["cherry-pick", cid, "--json"], env=env)
868 if r.exit_code != 0:
869 failures.append(f"pick {i}: exit={r.exit_code} {r.output.strip()[:60]}")
870
871 assert not failures, f"Sequential failures: {failures}"
872
873
874 @pytest.mark.slow
875 def test_dry_run_performance(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
876 """--dry-run on a large repo must complete in < 3 s."""
877 monkeypatch.chdir(tmp_path)
878 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
879 env = _env(tmp_path)
880 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
881 (tmp_path / "base.py").write_text("base\n")
882 runner.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
883 runner.invoke(cli, ["branch", "src"], env=env, catch_exceptions=False)
884 runner.invoke(cli, ["checkout", "src"], env=env, catch_exceptions=False)
885
886 (tmp_path / "target.py").write_text("target\n")
887 runner.invoke(cli, ["commit", "-m", "target"], env=env, catch_exceptions=False)
888 from muse.core.refs import get_head_commit_id
889 target_cid = get_head_commit_id(tmp_path, "src")
890 assert target_cid is not None
891
892 for i in range(100):
893 (tmp_path / f"f{i}.py").write_text(f"{i}\n")
894 runner.invoke(cli, ["commit", "-m", f"c{i}"], env=env, catch_exceptions=False)
895
896 runner.invoke(cli, ["checkout", "main"], env=env, catch_exceptions=False)
897 start = time.perf_counter()
898 r = runner.invoke(
899 cli, ["cherry-pick", target_cid, "--dry-run", "--json"],
900 env=env, catch_exceptions=False,
901 )
902 elapsed = time.perf_counter() - start
903 assert r.exit_code == 0, r.output
904 assert elapsed < 3.0, f"--dry-run took {elapsed:.2f}s"
905
906
907 # ---------------------------------------------------------------------------
908 # Agent supercharge — duration_ms and exit_code in every JSON output
909 # ---------------------------------------------------------------------------
910
911
912 class TestElapsed:
913 """Every JSON output path must include ``duration_ms`` as a float."""
914
915 def test_picked_json_has_elapsed(
916 self, two_branch_repo: tuple[pathlib.Path, str]
917 ) -> None:
918 root, cid = two_branch_repo
919 r = runner.invoke(cli, ["cherry-pick", cid, "--json"], env=_env(root), catch_exceptions=False)
920 d = json.loads(r.output)
921 assert "duration_ms" in d
922 assert isinstance(d["duration_ms"], float)
923
924 def test_applied_json_has_elapsed(
925 self, two_branch_repo: tuple[pathlib.Path, str]
926 ) -> None:
927 root, cid = two_branch_repo
928 r = runner.invoke(
929 cli, ["cherry-pick", cid, "--no-commit", "--json"],
930 env=_env(root), catch_exceptions=False,
931 )
932 d = json.loads(r.output)
933 assert "duration_ms" in d
934 assert isinstance(d["duration_ms"], float)
935
936 def test_dry_run_json_has_elapsed(
937 self, two_branch_repo: tuple[pathlib.Path, str]
938 ) -> None:
939 root, cid = two_branch_repo
940 r = runner.invoke(
941 cli, ["cherry-pick", cid, "--dry-run", "--json"],
942 env=_env(root), catch_exceptions=False,
943 )
944 d = json.loads(r.output)
945 assert "duration_ms" in d
946 assert isinstance(d["duration_ms"], float)
947
948 def test_conflict_json_has_elapsed(
949 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
950 ) -> None:
951 """The conflict JSON path must also include duration_ms."""
952 monkeypatch.chdir(tmp_path)
953 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
954 env = _env(tmp_path)
955 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
956
957 (tmp_path / "shared.py").write_text("line1\nline2\nline3\n")
958 runner.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
959
960 runner.invoke(cli, ["branch", "src"], env=env, catch_exceptions=False)
961 runner.invoke(cli, ["checkout", "src"], env=env, catch_exceptions=False)
962 (tmp_path / "shared.py").write_text("line1\nSRC_LINE2\nline3\n")
963 runner.invoke(cli, ["commit", "-m", "src change"], env=env, catch_exceptions=False)
964 from muse.core.refs import get_head_commit_id as _gci
965 src_cid = _gci(tmp_path, "src")
966 assert src_cid is not None
967
968 runner.invoke(cli, ["checkout", "main"], env=env, catch_exceptions=False)
969 (tmp_path / "shared.py").write_text("line1\nMAIN_LINE2\nline3\n")
970 runner.invoke(cli, ["commit", "-m", "main change"], env=env, catch_exceptions=False)
971
972 r = runner.invoke(cli, ["cherry-pick", src_cid, "--json"], env=env)
973 # Conflict should produce JSON with duration_ms even on exit 1
974 assert r.exit_code == 1
975 d = json.loads(r.output)
976 assert "duration_ms" in d
977 assert isinstance(d["duration_ms"], float)
978
979
980 class TestExitCode:
981 """Every successful JSON path includes ``exit_code: 0``; conflict path has ``exit_code: 1``."""
982
983 def test_picked_json_exit_code_0(
984 self, two_branch_repo: tuple[pathlib.Path, str]
985 ) -> None:
986 root, cid = two_branch_repo
987 r = runner.invoke(cli, ["cherry-pick", cid, "--json"], env=_env(root), catch_exceptions=False)
988 d = json.loads(r.output)
989 assert d["exit_code"] == 0
990
991 def test_applied_json_exit_code_0(
992 self, two_branch_repo: tuple[pathlib.Path, str]
993 ) -> None:
994 root, cid = two_branch_repo
995 r = runner.invoke(
996 cli, ["cherry-pick", cid, "--no-commit", "--json"],
997 env=_env(root), catch_exceptions=False,
998 )
999 d = json.loads(r.output)
1000 assert d["exit_code"] == 0
1001
1002 def test_dry_run_json_exit_code_0(
1003 self, two_branch_repo: tuple[pathlib.Path, str]
1004 ) -> None:
1005 root, cid = two_branch_repo
1006 r = runner.invoke(
1007 cli, ["cherry-pick", cid, "--dry-run", "--json"],
1008 env=_env(root), catch_exceptions=False,
1009 )
1010 d = json.loads(r.output)
1011 assert d["exit_code"] == 0
1012
1013 def test_conflict_json_exit_code_1(
1014 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
1015 ) -> None:
1016 """Conflict JSON must report exit_code: 1, mirroring the process exit."""
1017 monkeypatch.chdir(tmp_path)
1018 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
1019 env = _env(tmp_path)
1020 runner.invoke(cli, ["init"], env=env, catch_exceptions=False)
1021
1022 (tmp_path / "shared.py").write_text("line1\nline2\nline3\n")
1023 runner.invoke(cli, ["commit", "-m", "base"], env=env, catch_exceptions=False)
1024
1025 runner.invoke(cli, ["branch", "src"], env=env, catch_exceptions=False)
1026 runner.invoke(cli, ["checkout", "src"], env=env, catch_exceptions=False)
1027 (tmp_path / "shared.py").write_text("line1\nSRC_LINE2\nline3\n")
1028 runner.invoke(cli, ["commit", "-m", "src change"], env=env, catch_exceptions=False)
1029 from muse.core.refs import get_head_commit_id as _gci
1030 src_cid = _gci(tmp_path, "src")
1031 assert src_cid is not None
1032
1033 runner.invoke(cli, ["checkout", "main"], env=env, catch_exceptions=False)
1034 (tmp_path / "shared.py").write_text("line1\nMAIN_LINE2\nline3\n")
1035 runner.invoke(cli, ["commit", "-m", "main change"], env=env, catch_exceptions=False)
1036
1037 r = runner.invoke(cli, ["cherry-pick", src_cid, "--json"], env=env)
1038 assert r.exit_code == 1
1039 d = json.loads(r.output)
1040 assert d["exit_code"] == 1
1041
1042
1043 class TestJsonSchemaComplete:
1044 """``duration_ms`` and ``exit_code`` must be in every JSON output."""
1045
1046 _FULL_KEYS = {
1047 "status", "commit_id", "branch", "ref",
1048 "source_commit_id", "snapshot_id", "message",
1049 "no_commit", "dry_run", "conflicts",
1050 "duration_ms", "exit_code",
1051 "muse_version", "schema", "timestamp", "warnings",
1052 }
1053
1054 def test_picked_has_complete_schema(
1055 self, two_branch_repo: tuple[pathlib.Path, str]
1056 ) -> None:
1057 root, cid = two_branch_repo
1058 r = runner.invoke(cli, ["cherry-pick", cid, "--json"], env=_env(root), catch_exceptions=False)
1059 d = json.loads(r.output)
1060 missing = self._FULL_KEYS - d.keys()
1061 assert not missing, f"Missing keys in 'picked' JSON: {missing}"
1062
1063 def test_applied_has_complete_schema(
1064 self, two_branch_repo: tuple[pathlib.Path, str]
1065 ) -> None:
1066 root, cid = two_branch_repo
1067 r = runner.invoke(
1068 cli, ["cherry-pick", cid, "--no-commit", "--json"],
1069 env=_env(root), catch_exceptions=False,
1070 )
1071 d = json.loads(r.output)
1072 missing = self._FULL_KEYS - d.keys()
1073 assert not missing, f"Missing keys in 'applied' JSON: {missing}"
1074
1075 def test_dry_run_has_complete_schema(
1076 self, two_branch_repo: tuple[pathlib.Path, str]
1077 ) -> None:
1078 root, cid = two_branch_repo
1079 r = runner.invoke(
1080 cli, ["cherry-pick", cid, "--dry-run", "--json"],
1081 env=_env(root), catch_exceptions=False,
1082 )
1083 d = json.loads(r.output)
1084 missing = self._FULL_KEYS - d.keys()
1085 assert not missing, f"Missing keys in 'dry_run' JSON: {missing}"
1086
1087 def test_all_schemas_identical(
1088 self, two_branch_repo: tuple[pathlib.Path, str]
1089 ) -> None:
1090 """All three success paths must have identical key sets."""
1091 root, cid = two_branch_repo
1092 r_dr = runner.invoke(
1093 cli, ["cherry-pick", cid, "--dry-run", "--json"],
1094 env=_env(root), catch_exceptions=False,
1095 )
1096 r_nc = runner.invoke(
1097 cli, ["cherry-pick", cid, "--no-commit", "--json"],
1098 env=_env(root), catch_exceptions=False,
1099 )
1100 runner.invoke(cli, ["commit", "-m", "after nc"], env=_env(root), catch_exceptions=False)
1101 r_nm = runner.invoke(
1102 cli, ["cherry-pick", cid, "--json"],
1103 env=_env(root), catch_exceptions=False,
1104 )
1105 keys_dr = set(json.loads(r_dr.output).keys())
1106 keys_nc = set(json.loads(r_nc.output).keys())
1107 keys_nm = set(json.loads(r_nm.output).keys())
1108 assert keys_dr == keys_nc == keys_nm == self._FULL_KEYS
1109
1110
1111 class TestTextOutputHex:
1112 """Text output must show sha256: prefix + 8 hex chars — canonical and algorithm-identifying."""
1113
1114 def test_picked_text_shows_prefixed_short_id(
1115 self, two_branch_repo: tuple[pathlib.Path, str]
1116 ) -> None:
1117 root, cid = two_branch_repo
1118 r = runner.invoke(cli, ["cherry-pick", cid], env=_env(root), catch_exceptions=False)
1119 assert r.exit_code == 0
1120 from muse.core.refs import get_head_commit_id
1121 new_cid = get_head_commit_id(root, "main")
1122 assert new_cid is not None
1123 short = new_cid[:len("sha256:") + 8]
1124 assert short in r.output, (
1125 f"Expected '{short}' in cherry-pick output, got: {r.output!r}"
1126 )
1127
1128 def test_dry_run_text_shows_prefixed_full_id(
1129 self, two_branch_repo: tuple[pathlib.Path, str]
1130 ) -> None:
1131 import re
1132 root, cid = two_branch_repo
1133 r = runner.invoke(
1134 cli, ["cherry-pick", cid, "--dry-run"],
1135 env=_env(root), catch_exceptions=False,
1136 )
1137 assert r.exit_code == 0
1138 # The dry-run text shows the source full ID in parens: (sha256:<64hex>)
1139 match = re.search(r'\(sha256:([0-9a-f]{64})\)', r.output)
1140 assert match is not None, (
1141 f"Expected '(sha256:<64hex>)' in dry-run output, got: {r.output!r}"
1142 )
1143 assert long_id(match.group(1)) == cid, (
1144 f"Expected {cid} in parens, got {long_id(match.group(1))}"
1145 )
1146
1147
1148 # ---------------------------------------------------------------------------
1149 # Flag registration tests
1150 # ---------------------------------------------------------------------------
1151
1152 import argparse as _argparse
1153 from muse.cli.commands.cherry_pick import register as _register_cherry_pick
1154 from muse.core.paths import heads_dir, repo_json_path
1155
1156
1157 def _parse_cp(*args: str) -> _argparse.Namespace:
1158 """Build an argument parser via register() and parse args."""
1159 root_p = _argparse.ArgumentParser()
1160 subs = root_p.add_subparsers(dest="cmd")
1161 _register_cherry_pick(subs)
1162 return root_p.parse_args(["cherry-pick", *args])
1163
1164
1165 class TestRegisterFlags:
1166 def test_default_json_out_is_false(self) -> None:
1167 ns = _parse_cp(fake_id("a"))
1168 assert ns.json_out is False
1169
1170 def test_json_flag_sets_json_out(self) -> None:
1171 ns = _parse_cp(fake_id("a"), "--json")
1172 assert ns.json_out is True
1173
1174 def test_j_shorthand_sets_json_out(self) -> None:
1175 ns = _parse_cp(fake_id("a"), "-j")
1176 assert ns.json_out is True
1177
1178 def test_no_commit_flag(self) -> None:
1179 ns = _parse_cp(fake_id("a"), "--no-commit")
1180 assert ns.no_commit is True
1181
1182 def test_no_commit_has_no_n_shorthand(self) -> None:
1183 import pytest
1184 with pytest.raises(SystemExit):
1185 _parse_cp(fake_id("a"), "-n")
File History 1 commit