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