gabriel / muse public
test_cmd_semantic_cherry_pick.py python
885 lines 36.9 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
1 """Tests for ``muse code semantic-cherry-pick``.
2
3 Coverage layers
4 ---------------
5 Unit
6 _verify_symbol — hit (symbol parseable), miss (symbol not in tree),
7 file read error, corrupt bytes.
8 _apply_symbol — no-separator address, path-traversal, file_missing
9 (obj not in manifest, blob missing), parse_error source,
10 parse_error current, already_current, applied (replace),
11 applied (append), new-file creation, dry-run (no write),
12 diff_lines populated, verified field populated.
13 src_cache — same blob fetched only once across multiple calls.
14
15 Integration (live repo, CliRunner)
16 Exits zero for valid cherry-pick.
17 JSON schema: all required top-level keys present, correct types.
18 JSON: schema_version, branch, from_commit (8-char hex), dry_run,
19 results[], applied, already_current, failed, unverified.
20 Per-result JSON: address, status, detail, old_lines, new_lines,
21 diff_lines, verified.
22 --dry-run: file not written, diff_lines populated in JSON.
23 --dry-run verified is True for valid output.
24 already_current result when symbol body unchanged.
25 ADDRESS without '::' → status not_found.
26 Path-traversal ADDRESS → status not_found.
27 Unknown --from ref → exits non-zero.
28 Symbol not in source commit → status not_found.
29 File not in source snapshot → status file_missing.
30 Multiple addresses in one invocation: all results present.
31 Multiple addresses to same file: blob fetched once (src_cache).
32 Text output contains commit short-hash, applied/failed counts.
33 Missing repo → exits non-zero.
34 unverified list populated when verification fails (monkeypatched).
35
36 E2E (real symbol changes across commits)
37 Applied symbol replaces only target lines; surrounding code unchanged.
38 Applying from earlier commit restores old implementation.
39 Dry-run leaves file unchanged while returning accurate diff_lines.
40 already_current: re-applying the same symbol is idempotent.
41 Symbol absent from working tree is appended at EOF and verifiable.
42 verified=True after clean write.
43 Multi-symbol single invocation applies all independently.
44 Cross-file cherry-pick applies to the correct file.
45
46 Stress
47 50-symbol repo: all cherry-picked in one invocation, all applied.
48 Large file (1 000 lines): only target symbol lines change.
49 Repeated idempotent cherry-pick: outcome stable, no file growth.
50 """
51
52 from __future__ import annotations
53
54 type _FileStore = dict[str, bytes]
55
56 import json
57 import pathlib
58 import textwrap
59 import time
60 from typing import TypedDict
61 from unittest import mock
62
63 import pytest
64 from tests.cli_test_helper import CliRunner
65
66 from muse.cli.commands.semantic_cherry_pick import (
67 ApplyStatus,
68 _PickResult,
69 _apply_symbol,
70 _verify_symbol,
71 )
72 from muse.plugins.code.ast_parser import parse_symbols
73 from muse.core._types import Manifest, long_id
74 from muse.core.object_store import object_path
75
76 # ---------------------------------------------------------------------------
77 # Shared CLI runner (accepts any first arg for legacy compatibility)
78 # ---------------------------------------------------------------------------
79
80 runner = CliRunner()
81 cli = None # CliRunner always targets muse.cli.app.main
82
83
84 # ---------------------------------------------------------------------------
85 # TypedDicts — strict JSON schema validation
86 # ---------------------------------------------------------------------------
87
88
89 class _ResultEntry(TypedDict):
90 address: str
91 status: str
92 detail: str
93 old_lines: int
94 new_lines: int
95 diff_lines: list[str]
96 verified: bool
97
98
99 class _CherryPickPayload(TypedDict):
100 schema_version: str
101 branch: str
102 from_commit: str
103 dry_run: bool
104 results: list[_ResultEntry]
105 applied: int
106 already_current: int
107 failed: int
108 unverified: list[str]
109
110
111 # ---------------------------------------------------------------------------
112 # Helpers
113 # ---------------------------------------------------------------------------
114
115
116 def _invoke_json(args: list[str]) -> _CherryPickPayload:
117 result = runner.invoke(cli, ["code", "semantic-cherry-pick"] + args + ["--json"])
118 assert result.exit_code == 0, result.output
119 raw: _CherryPickPayload = json.loads(result.output)
120 return raw
121
122
123 # ---------------------------------------------------------------------------
124 # Fixtures
125 # ---------------------------------------------------------------------------
126
127
128 @pytest.fixture
129 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
130 monkeypatch.chdir(tmp_path)
131 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
132 result = runner.invoke(cli, ["init", "--domain", "code"])
133 assert result.exit_code == 0, result.output
134 return tmp_path
135
136
137 @pytest.fixture
138 def two_commit_repo(repo: pathlib.Path) -> tuple[pathlib.Path, str, str, str]:
139 """Repo with two commits with different implementations of compute().
140
141 commit 1 (HEAD~1): compute returns sum(items)
142 commit 2 (HEAD): compute returns sum(items) * 2
143 Returns (root, address, file_rel, HEAD~1_short).
144 """
145 (repo / "billing.py").write_text(textwrap.dedent("""\
146 def header():
147 return "billing"
148
149
150 def compute(items):
151 return sum(items)
152
153
154 def footer():
155 return "end"
156 """))
157 r1 = runner.invoke(cli, ["commit", "-m", "v1"])
158 assert r1.exit_code == 0, r1.output
159
160 (repo / "billing.py").write_text(textwrap.dedent("""\
161 def header():
162 return "billing"
163
164
165 def compute(items):
166 return sum(items) * 2
167
168
169 def footer():
170 return "end"
171 """))
172 r2 = runner.invoke(cli, ["commit", "-m", "v2"])
173 assert r2.exit_code == 0, r2.output
174
175 log_out = runner.invoke(cli, ["log", "--json"])
176 commits: list[dict[str, str]] = json.loads(log_out.output)["commits"]
177 head_minus_1 = commits[1]["commit_id"][:8]
178
179 return repo, "billing.py::compute", "billing.py", head_minus_1
180
181
182 @pytest.fixture
183 def multi_file_repo(repo: pathlib.Path) -> pathlib.Path:
184 """Repo with two files, each containing two functions.
185
186 Useful for cross-file and same-file multi-symbol tests.
187 """
188 (repo / "auth.py").write_text(textwrap.dedent("""\
189 def validate_token(tok):
190 return tok == "secret"
191
192
193 def refresh_token(tok):
194 return tok + "_refreshed"
195 """))
196 (repo / "billing.py").write_text(textwrap.dedent("""\
197 def compute(items):
198 return sum(items)
199
200
201 def discount(items):
202 return sum(items) * 0.9
203 """))
204 r1 = runner.invoke(cli, ["commit", "-m", "v1"])
205 assert r1.exit_code == 0, r1.output
206
207 # v2 — both files change
208 (repo / "auth.py").write_text(textwrap.dedent("""\
209 def validate_nonce(nonce):
210 return len(nonce) == 64
211
212
213 def refresh_nonce(nonce):
214 return nonce + "_v2"
215 """))
216 (repo / "billing.py").write_text(textwrap.dedent("""\
217 def compute(items):
218 return sum(items) * 2
219
220
221 def discount(items):
222 return sum(items) * 0.8
223 """))
224 r2 = runner.invoke(cli, ["commit", "-m", "v2"])
225 assert r2.exit_code == 0, r2.output
226 return repo
227
228
229 # ---------------------------------------------------------------------------
230 # Unit — _verify_symbol
231 # ---------------------------------------------------------------------------
232
233
234 class TestVerifySymbol:
235 def test_valid_symbol_returns_true(self, tmp_path: pathlib.Path) -> None:
236 f = tmp_path / "m.py"
237 f.write_text("def foo():\n return 1\n")
238 assert _verify_symbol(f, "m.py", "m.py::foo") is True
239
240 def test_missing_symbol_returns_false(self, tmp_path: pathlib.Path) -> None:
241 f = tmp_path / "m.py"
242 f.write_text("def bar():\n return 2\n")
243 assert _verify_symbol(f, "m.py", "m.py::foo") is False
244
245 def test_syntax_error_returns_false(self, tmp_path: pathlib.Path) -> None:
246 f = tmp_path / "m.py"
247 f.write_bytes(b"def broken(:\n pass\n")
248 assert _verify_symbol(f, "m.py", "m.py::broken") is False
249
250 def test_missing_file_returns_false(self, tmp_path: pathlib.Path) -> None:
251 f = tmp_path / "nonexistent.py"
252 assert _verify_symbol(f, "nonexistent.py", "nonexistent.py::x") is False
253
254 def test_empty_file_returns_false(self, tmp_path: pathlib.Path) -> None:
255 f = tmp_path / "empty.py"
256 f.write_text("")
257 assert _verify_symbol(f, "empty.py", "empty.py::anything") is False
258
259
260 # ---------------------------------------------------------------------------
261 # Unit — _apply_symbol
262 # ---------------------------------------------------------------------------
263
264
265 class TestApplySymbol:
266 def _manifest_with_blob(
267 self, root: pathlib.Path, file_rel: str, content: bytes
268 ) -> tuple[dict[str, str], dict[str, bytes]]:
269 """Create a synthetic manifest entry by writing a blob to object store."""
270 from muse.core._types import blob_id
271 from muse.core.object_store import write_object as _wo
272 oid = blob_id(content)
273 _wo(root, oid, content)
274 manifest: Manifest = {file_rel: oid}
275 src_cache: _FileStore = {}
276 return manifest, src_cache
277
278 def test_no_separator_returns_not_found(self, tmp_path: pathlib.Path) -> None:
279 result = _apply_symbol(tmp_path, "nocolon", {}, False, {})
280 assert result.status == "not_found"
281 assert "separator" in result.detail
282
283 def test_path_traversal_returns_not_found(self, tmp_path: pathlib.Path) -> None:
284 (tmp_path / ".muse").mkdir()
285 result = _apply_symbol(tmp_path, "../../etc/shadow::root", {}, False, {})
286 assert result.status == "not_found"
287
288 def test_file_not_in_manifest(self, tmp_path: pathlib.Path) -> None:
289 (tmp_path / ".muse").mkdir()
290 result = _apply_symbol(tmp_path, "missing.py::func", {}, False, {})
291 assert result.status == "file_missing"
292 assert "not in source snapshot" in result.detail
293
294 def test_blob_missing_from_object_store(self, tmp_path: pathlib.Path) -> None:
295 (tmp_path / ".muse").mkdir()
296 manifest: Manifest = {"src.py": long_id("a" * 64)}
297 result = _apply_symbol(tmp_path, "src.py::func", manifest, False, {})
298 assert result.status == "file_missing"
299 assert "missing from object store" in result.detail
300
301 def test_source_parse_error(self, tmp_path: pathlib.Path) -> None:
302 """parse_error is returned when parse_symbols raises (e.g. for non-Python files).
303
304 parse_symbols silently returns {} for bad Python (catches SyntaxError
305 internally), so parse_error is only reachable when parse_symbols itself
306 raises — e.g. due to an unsupported file type or an internal adapter bug.
307 We test this via mocking to verify the error handling path in _apply_symbol.
308 """
309 (tmp_path / ".muse").mkdir()
310 content = b"def foo():\n pass\n"
311 manifest, src_cache = self._manifest_with_blob(tmp_path, "src.py", content)
312 with mock.patch(
313 "muse.cli.commands.semantic_cherry_pick.parse_symbols",
314 side_effect=RuntimeError("adapter exploded"),
315 ):
316 result = _apply_symbol(tmp_path, "src.py::foo", manifest, False, src_cache)
317 assert result.status == "parse_error"
318
319 def test_symbol_not_in_source(self, tmp_path: pathlib.Path) -> None:
320 (tmp_path / ".muse").mkdir()
321 content = b"def other():\n pass\n"
322 manifest, src_cache = self._manifest_with_blob(tmp_path, "src.py", content)
323 result = _apply_symbol(tmp_path, "src.py::missing_sym", manifest, False, src_cache)
324 assert result.status == "not_found"
325 assert "not found in source commit" in result.detail
326
327 def test_already_current_returns_correct_status(self, tmp_path: pathlib.Path) -> None:
328 (tmp_path / ".muse").mkdir()
329 content = b"def foo():\n return 1\n"
330 manifest, src_cache = self._manifest_with_blob(tmp_path, "src.py", content)
331 (tmp_path / "src.py").write_bytes(content)
332 result = _apply_symbol(tmp_path, "src.py::foo", manifest, False, src_cache)
333 assert result.status == "already_current"
334 assert result.old_lines == 0
335 assert result.new_lines == 0
336
337 def test_applied_replace_writes_new_body(self, tmp_path: pathlib.Path) -> None:
338 (tmp_path / ".muse").mkdir()
339 src_content = b"def foo():\n return 42\n"
340 manifest, src_cache = self._manifest_with_blob(tmp_path, "src.py", src_content)
341 (tmp_path / "src.py").write_text("def foo():\n return 1\n\ndef bar():\n pass\n")
342 result = _apply_symbol(tmp_path, "src.py::foo", manifest, False, src_cache)
343 assert result.status == "applied"
344 text = (tmp_path / "src.py").read_text()
345 assert "return 42" in text
346 assert "bar" in text # surrounding code preserved
347
348 def test_applied_append_when_symbol_missing_in_current(self, tmp_path: pathlib.Path) -> None:
349 (tmp_path / ".muse").mkdir()
350 src_content = b"def new_func():\n return 99\n"
351 manifest, src_cache = self._manifest_with_blob(tmp_path, "src.py", src_content)
352 (tmp_path / "src.py").write_text("def existing():\n pass\n")
353 result = _apply_symbol(tmp_path, "src.py::new_func", manifest, False, src_cache)
354 assert result.status == "applied"
355 assert "appended" in result.detail
356 text = (tmp_path / "src.py").read_text()
357 assert "new_func" in text
358 assert "existing" in text
359
360 def test_creates_new_file_when_target_absent(self, tmp_path: pathlib.Path) -> None:
361 (tmp_path / ".muse").mkdir()
362 src_content = b"def fresh():\n return 0\n"
363 manifest, src_cache = self._manifest_with_blob(tmp_path, "new_module.py", src_content)
364 result = _apply_symbol(tmp_path, "new_module.py::fresh", manifest, False, src_cache)
365 assert result.status == "applied"
366 assert "created file" in result.detail
367 assert (tmp_path / "new_module.py").exists()
368
369 def test_dry_run_does_not_write(self, tmp_path: pathlib.Path) -> None:
370 (tmp_path / ".muse").mkdir()
371 src_content = b"def foo():\n return 42\n"
372 manifest, src_cache = self._manifest_with_blob(tmp_path, "src.py", src_content)
373 (tmp_path / "src.py").write_text("def foo():\n return 1\n")
374 _apply_symbol(tmp_path, "src.py::foo", manifest, True, src_cache)
375 assert "return 1" in (tmp_path / "src.py").read_text()
376
377 def test_dry_run_new_file_not_created(self, tmp_path: pathlib.Path) -> None:
378 (tmp_path / ".muse").mkdir()
379 src_content = b"def ghost():\n pass\n"
380 manifest, src_cache = self._manifest_with_blob(tmp_path, "ghost.py", src_content)
381 _apply_symbol(tmp_path, "ghost.py::ghost", manifest, True, src_cache)
382 assert not (tmp_path / "ghost.py").exists()
383
384 def test_diff_lines_populated_on_replace(self, tmp_path: pathlib.Path) -> None:
385 (tmp_path / ".muse").mkdir()
386 src_content = b"def foo():\n return 42\n"
387 manifest, src_cache = self._manifest_with_blob(tmp_path, "src.py", src_content)
388 (tmp_path / "src.py").write_text("def foo():\n return 1\n")
389 result = _apply_symbol(tmp_path, "src.py::foo", manifest, False, src_cache)
390 assert result.status == "applied"
391 assert len(result.diff_lines) > 0
392 diff_text = "\n".join(result.diff_lines)
393 assert "-" in diff_text
394 assert "+" in diff_text
395
396 def test_diff_lines_empty_when_already_current(self, tmp_path: pathlib.Path) -> None:
397 (tmp_path / ".muse").mkdir()
398 content = b"def foo():\n return 1\n"
399 manifest, src_cache = self._manifest_with_blob(tmp_path, "src.py", content)
400 (tmp_path / "src.py").write_bytes(content)
401 result = _apply_symbol(tmp_path, "src.py::foo", manifest, False, src_cache)
402 assert result.diff_lines == []
403
404 def test_verified_true_on_clean_write(self, tmp_path: pathlib.Path) -> None:
405 (tmp_path / ".muse").mkdir()
406 src_content = b"def foo():\n return 42\n"
407 manifest, src_cache = self._manifest_with_blob(tmp_path, "src.py", src_content)
408 (tmp_path / "src.py").write_text("def foo():\n return 1\n")
409 result = _apply_symbol(tmp_path, "src.py::foo", manifest, False, src_cache)
410 assert result.verified is True
411
412 def test_src_cache_prevents_double_fetch(self, tmp_path: pathlib.Path) -> None:
413 """Same blob ID requested twice must be fetched only once."""
414 (tmp_path / ".muse").mkdir()
415 content = b"def foo():\n return 1\ndef bar():\n return 2\n"
416 manifest, src_cache = self._manifest_with_blob(tmp_path, "src.py", content)
417 (tmp_path / "src.py").write_bytes(content)
418
419 call_count = 0
420 original_read = __import__(
421 "muse.core.object_store", fromlist=["read_object"]
422 ).read_object
423
424 def counting_read(root: pathlib.Path, obj_id: str) -> bytes | None:
425 nonlocal call_count
426 call_count += 1
427 result: bytes | None = original_read(root, obj_id)
428 return result
429
430 with mock.patch(
431 "muse.cli.commands.semantic_cherry_pick.read_object", side_effect=counting_read
432 ):
433 _apply_symbol(tmp_path, "src.py::foo", manifest, False, src_cache)
434 _apply_symbol(tmp_path, "src.py::bar", manifest, False, src_cache)
435
436 assert call_count == 1, "Same blob should be fetched only once across calls"
437
438
439 # ---------------------------------------------------------------------------
440 # Integration — CLI runner tests
441 # ---------------------------------------------------------------------------
442
443
444 class TestCherryPickCLI:
445 def test_exit_zero_on_valid_pick(self, two_commit_repo: tuple[pathlib.Path, str, str, str]) -> None:
446 _, address, _, head_m1 = two_commit_repo
447 result = runner.invoke(cli, ["code", "semantic-cherry-pick", address, "--from", "HEAD~1"])
448 assert result.exit_code == 0
449
450 def test_json_top_level_keys(self, two_commit_repo: tuple[pathlib.Path, str, str, str]) -> None:
451 _, address, _, head_m1 = two_commit_repo
452 data = _invoke_json([address, "--from", "HEAD~1"])
453 for key in ("schema_version", "branch", "from_commit", "dry_run", "results",
454 "applied", "already_current", "failed", "unverified"):
455 assert key in data, f"Missing top-level key: {key}"
456
457 def test_json_from_commit_is_short_id(self, two_commit_repo: tuple[pathlib.Path, str, str, str]) -> None:
458 _, address, _, _ = two_commit_repo
459 data = _invoke_json([address, "--from", "HEAD~1"])
460 # short_id returns "sha256:<12hex>" = 19 chars
461 assert data["from_commit"].startswith("sha256:")
462 assert len(data["from_commit"]) == 19
463
464 def test_json_result_keys(self, two_commit_repo: tuple[pathlib.Path, str, str, str]) -> None:
465 _, address, _, _ = two_commit_repo
466 data = _invoke_json([address, "--from", "HEAD~1"])
467 assert len(data["results"]) == 1
468 r = data["results"][0]
469 for key in ("address", "status", "detail", "old_lines", "new_lines", "diff_lines", "verified"):
470 assert key in r, f"Missing result key: {key}"
471
472 def test_json_applied_count(self, two_commit_repo: tuple[pathlib.Path, str, str, str]) -> None:
473 _, address, _, _ = two_commit_repo
474 data = _invoke_json([address, "--from", "HEAD~1"])
475 assert data["applied"] == 1
476 assert data["failed"] == 0
477
478 def test_json_dry_run_flag(self, two_commit_repo: tuple[pathlib.Path, str, str, str]) -> None:
479 _, address, _, _ = two_commit_repo
480 result = runner.invoke(
481 cli,
482 ["code", "semantic-cherry-pick", address, "--from", "HEAD~1", "--dry-run", "--json"],
483 )
484 assert result.exit_code == 0
485 data: _CherryPickPayload = json.loads(result.output)
486 assert data["dry_run"] is True
487
488 def test_dry_run_does_not_write(self, two_commit_repo: tuple[pathlib.Path, str, str, str]) -> None:
489 root, address, file_rel, _ = two_commit_repo
490 before = (root / file_rel).read_text()
491 runner.invoke(
492 cli,
493 ["code", "semantic-cherry-pick", address, "--from", "HEAD~1", "--dry-run"],
494 )
495 assert (root / file_rel).read_text() == before
496
497 def test_dry_run_diff_lines_in_json(self, two_commit_repo: tuple[pathlib.Path, str, str, str]) -> None:
498 _, address, _, _ = two_commit_repo
499 result = runner.invoke(
500 cli,
501 ["code", "semantic-cherry-pick", address, "--from", "HEAD~1", "--dry-run", "--json"],
502 )
503 data: _CherryPickPayload = json.loads(result.output)
504 r = data["results"][0]
505 assert isinstance(r["diff_lines"], list)
506 assert len(r["diff_lines"]) > 0
507
508 def test_dry_run_verified_true_for_valid(
509 self, two_commit_repo: tuple[pathlib.Path, str, str, str]
510 ) -> None:
511 _, address, _, _ = two_commit_repo
512 result = runner.invoke(
513 cli,
514 ["code", "semantic-cherry-pick", address, "--from", "HEAD~1", "--dry-run", "--json"],
515 )
516 data: _CherryPickPayload = json.loads(result.output)
517 assert data["results"][0]["verified"] is True
518
519 def test_already_current_on_same_commit(
520 self, two_commit_repo: tuple[pathlib.Path, str, str, str]
521 ) -> None:
522 _, address, _, _ = two_commit_repo
523 data = _invoke_json([address, "--from", "HEAD"])
524 assert data["results"][0]["status"] == "already_current"
525 assert data["already_current"] == 1
526
527 def test_no_separator_address_is_not_found(
528 self, two_commit_repo: tuple[pathlib.Path, str, str, str]
529 ) -> None:
530 _, _, _, _ = two_commit_repo
531 data = _invoke_json(["noseparator", "--from", "HEAD~1"])
532 assert data["results"][0]["status"] == "not_found"
533 assert data["failed"] == 1
534
535 def test_path_traversal_is_not_found(
536 self, two_commit_repo: tuple[pathlib.Path, str, str, str]
537 ) -> None:
538 data = _invoke_json(["../../etc/shadow::passwd", "--from", "HEAD~1"])
539 assert data["results"][0]["status"] == "not_found"
540 assert data["failed"] == 1
541
542 def test_unknown_from_ref_exits_nonzero(
543 self, two_commit_repo: tuple[pathlib.Path, str, str, str]
544 ) -> None:
545 _, address, _, _ = two_commit_repo
546 result = runner.invoke(
547 cli, ["code", "semantic-cherry-pick", address, "--from", "nonexistent-ref"]
548 )
549 assert result.exit_code != 0
550
551 def test_symbol_not_in_source_is_not_found(
552 self, two_commit_repo: tuple[pathlib.Path, str, str, str]
553 ) -> None:
554 _, _, _, _ = two_commit_repo
555 data = _invoke_json(["billing.py::ghost_func", "--from", "HEAD~1"])
556 assert data["results"][0]["status"] == "not_found"
557
558 def test_file_not_in_snapshot_is_file_missing(
559 self, two_commit_repo: tuple[pathlib.Path, str, str, str]
560 ) -> None:
561 data = _invoke_json(["nonexistent_file.py::func", "--from", "HEAD~1"])
562 assert data["results"][0]["status"] == "file_missing"
563
564 def test_multiple_addresses_all_in_results(self, multi_file_repo: pathlib.Path) -> None:
565 data = _invoke_json(
566 ["auth.py::validate_token", "auth.py::refresh_token", "--from", "HEAD~1"]
567 )
568 assert len(data["results"]) == 2
569 addrs = {r["address"] for r in data["results"]}
570 assert "auth.py::validate_token" in addrs
571 assert "auth.py::refresh_token" in addrs
572
573 def test_multiple_addresses_same_file_applied_count(self, multi_file_repo: pathlib.Path) -> None:
574 data = _invoke_json(
575 ["auth.py::validate_token", "auth.py::refresh_token", "--from", "HEAD~1"]
576 )
577 assert data["applied"] == 2
578 assert data["failed"] == 0
579
580 def test_cross_file_addresses_applied(self, multi_file_repo: pathlib.Path) -> None:
581 data = _invoke_json(
582 ["auth.py::validate_token", "billing.py::compute", "--from", "HEAD~1"]
583 )
584 assert data["applied"] == 2
585 assert data["failed"] == 0
586
587 def test_text_output_contains_commit_hash(
588 self, two_commit_repo: tuple[pathlib.Path, str, str, str]
589 ) -> None:
590 _, address, _, head_m1 = two_commit_repo
591 result = runner.invoke(cli, ["code", "semantic-cherry-pick", address, "--from", "HEAD~1"])
592 assert head_m1 in result.output
593
594 def test_text_output_counts(self, two_commit_repo: tuple[pathlib.Path, str, str, str]) -> None:
595 _, address, _, _ = two_commit_repo
596 result = runner.invoke(cli, ["code", "semantic-cherry-pick", address, "--from", "HEAD~1"])
597 assert "1 applied" in result.output
598 assert "0 failed" in result.output
599
600 def test_missing_repo_exits_nonzero(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
601 monkeypatch.chdir(tmp_path)
602 result = runner.invoke(
603 cli, ["code", "semantic-cherry-pick", "a.py::foo", "--from", "HEAD~1"]
604 )
605 assert result.exit_code != 0
606
607 def test_unverified_populated_when_verify_fails(
608 self, two_commit_repo: tuple[pathlib.Path, str, str, str]
609 ) -> None:
610 _, address, _, _ = two_commit_repo
611 with mock.patch(
612 "muse.cli.commands.semantic_cherry_pick._verify_symbol", return_value=False
613 ):
614 result = runner.invoke(
615 cli,
616 ["code", "semantic-cherry-pick", address, "--from", "HEAD~1", "--json"],
617 )
618 data: _CherryPickPayload = json.loads(result.output)
619 assert address in data["unverified"]
620 assert data["results"][0]["verified"] is False
621
622
623 # ---------------------------------------------------------------------------
624 # E2E — real symbol diffs across commits
625 # ---------------------------------------------------------------------------
626
627
628 class TestCherryPickE2E:
629 def test_applied_replaces_only_target_lines(
630 self, two_commit_repo: tuple[pathlib.Path, str, str, str]
631 ) -> None:
632 root, address, file_rel, _ = two_commit_repo
633 runner.invoke(cli, ["code", "semantic-cherry-pick", address, "--from", "HEAD~1"])
634 text = (root / file_rel).read_text()
635 # Old implementation restored
636 assert "return sum(items)" in text
637 assert "* 2" not in text
638 # Surrounding functions untouched
639 assert "def header" in text
640 assert "def footer" in text
641
642 def test_applied_from_earlier_commit_restores_old_impl(
643 self, two_commit_repo: tuple[pathlib.Path, str, str, str]
644 ) -> None:
645 root, address, file_rel, _ = two_commit_repo
646 before_pick = (root / file_rel).read_text()
647 assert "* 2" in before_pick # HEAD has the * 2 version
648 runner.invoke(cli, ["code", "semantic-cherry-pick", address, "--from", "HEAD~1"])
649 after_pick = (root / file_rel).read_text()
650 assert "* 2" not in after_pick
651
652 def test_dry_run_leaves_file_unchanged(
653 self, two_commit_repo: tuple[pathlib.Path, str, str, str]
654 ) -> None:
655 root, address, file_rel, _ = two_commit_repo
656 original = (root / file_rel).read_bytes()
657 runner.invoke(cli, ["code", "semantic-cherry-pick", address, "--from", "HEAD~1", "--dry-run"])
658 assert (root / file_rel).read_bytes() == original
659
660 def test_dry_run_diff_lines_accurate(
661 self, two_commit_repo: tuple[pathlib.Path, str, str, str]
662 ) -> None:
663 _, address, _, _ = two_commit_repo
664 result = runner.invoke(
665 cli,
666 ["code", "semantic-cherry-pick", address, "--from", "HEAD~1", "--dry-run", "--json"],
667 )
668 data: _CherryPickPayload = json.loads(result.output)
669 diff_text = "\n".join(data["results"][0]["diff_lines"])
670 # Should remove the * 2 line and restore the plain sum
671 assert "sum(items)" in diff_text
672
673 def test_already_current_is_idempotent(
674 self, two_commit_repo: tuple[pathlib.Path, str, str, str]
675 ) -> None:
676 root, address, file_rel, _ = two_commit_repo
677 runner.invoke(cli, ["code", "semantic-cherry-pick", address, "--from", "HEAD~1"])
678 text_after_first = (root / file_rel).read_text()
679 # Apply again — must be a no-op
680 runner.invoke(cli, ["code", "semantic-cherry-pick", address, "--from", "HEAD~1"])
681 assert (root / file_rel).read_text() == text_after_first
682
683 def test_second_apply_is_already_current(
684 self, two_commit_repo: tuple[pathlib.Path, str, str, str]
685 ) -> None:
686 _, address, _, _ = two_commit_repo
687 runner.invoke(cli, ["code", "semantic-cherry-pick", address, "--from", "HEAD~1"])
688 data = _invoke_json([address, "--from", "HEAD~1"])
689 assert data["results"][0]["status"] == "already_current"
690
691 def test_appended_symbol_parseable(self, two_commit_repo: tuple[pathlib.Path, str, str, str]) -> None:
692 root, _, _, _ = two_commit_repo
693 # billing.py doesn't have 'header2' in source; cherry-pick should append
694 # Use a symbol that exists in source (HEAD~1) but was removed in HEAD.
695 # Add a new symbol in commit 3 to simulate absence in working tree.
696 (root / "utils.py").write_text("def helper():\n return True\n")
697 runner.invoke(cli, ["commit", "-m", "v3"])
698 # Working tree no longer has utils.py (overwrite with something else)
699 (root / "utils.py").write_text("def other():\n return False\n")
700 runner.invoke(cli, ["commit", "-m", "v4"])
701 # Cherry-pick helper from v3 (HEAD~1 now)
702 runner.invoke(cli, ["code", "semantic-cherry-pick", "utils.py::helper", "--from", "HEAD~1"])
703 raw = (root / "utils.py").read_bytes()
704 tree = parse_symbols(raw, "utils.py")
705 assert "utils.py::helper" in tree
706
707 def test_verified_true_after_clean_write(
708 self, two_commit_repo: tuple[pathlib.Path, str, str, str]
709 ) -> None:
710 _, address, _, _ = two_commit_repo
711 data = _invoke_json([address, "--from", "HEAD~1"])
712 assert data["results"][0]["verified"] is True
713
714 def test_multi_symbol_same_file_applies_all(self, multi_file_repo: pathlib.Path) -> None:
715 root = multi_file_repo
716 runner.invoke(
717 cli,
718 ["code", "semantic-cherry-pick", "auth.py::validate_token", "auth.py::refresh_token",
719 "--from", "HEAD~1"],
720 )
721 text = (root / "auth.py").read_text()
722 # Old implementations restored
723 assert 'tok == "secret"' in text
724 assert '"_refreshed"' in text
725
726 def test_cross_file_cherry_pick_correct_files(self, multi_file_repo: pathlib.Path) -> None:
727 root = multi_file_repo
728 runner.invoke(
729 cli,
730 ["code", "semantic-cherry-pick",
731 "auth.py::validate_token", "billing.py::compute",
732 "--from", "HEAD~1"],
733 )
734 auth_text = (root / "auth.py").read_text()
735 bill_text = (root / "billing.py").read_text()
736 assert 'tok == "secret"' in auth_text
737 assert "return sum(items)" in bill_text
738 assert "* 2" not in bill_text
739
740
741 # ---------------------------------------------------------------------------
742 # E2E — regression: all results returned even on mixed success/failure
743 # ---------------------------------------------------------------------------
744
745
746 class TestCherryPickMixedResults:
747 def test_failure_does_not_stop_remaining_addresses(
748 self, two_commit_repo: tuple[pathlib.Path, str, str, str]
749 ) -> None:
750 """All symbols processed; failure in one doesn't skip subsequent ones."""
751 _, _, _, _ = two_commit_repo
752 data = _invoke_json([
753 "billing.py::ghost_func", # not_found
754 "billing.py::compute", # applied
755 "--from", "HEAD~1",
756 ])
757 statuses = {r["address"]: r["status"] for r in data["results"]}
758 assert statuses["billing.py::ghost_func"] == "not_found"
759 assert statuses["billing.py::compute"] == "applied"
760 assert data["applied"] == 1
761 assert data["failed"] == 1
762
763 def test_mixed_results_counts_accurate(
764 self, two_commit_repo: tuple[pathlib.Path, str, str, str]
765 ) -> None:
766 data = _invoke_json([
767 "billing.py::compute", # applied
768 "billing.py::ghost", # not_found
769 "missing_file.py::func", # file_missing
770 "--from", "HEAD~1",
771 ])
772 assert data["applied"] == 1
773 assert data["failed"] == 2
774
775
776 # ---------------------------------------------------------------------------
777 # Stress
778 # ---------------------------------------------------------------------------
779
780
781 class TestCherryPickStress:
782 def test_many_symbols_all_applied(self, repo: pathlib.Path) -> None:
783 """50 distinct functions, all cherry-picked in one invocation."""
784 n = 50
785 funcs = "\n\n".join(f"def func_{i}():\n return {i}" for i in range(n))
786 (repo / "big.py").write_text(funcs + "\n")
787 r1 = runner.invoke(cli, ["commit", "-m", "v1"])
788 assert r1.exit_code == 0
789
790 new_funcs = "\n\n".join(f"def func_{i}():\n return {i * 10}" for i in range(n))
791 (repo / "big.py").write_text(new_funcs + "\n")
792 r2 = runner.invoke(cli, ["commit", "-m", "v2"])
793 assert r2.exit_code == 0
794
795 addresses = [f"big.py::func_{i}" for i in range(n)]
796 result = runner.invoke(
797 cli,
798 ["code", "semantic-cherry-pick"] + addresses + ["--from", "HEAD~1", "--json"],
799 )
800 assert result.exit_code == 0
801 data: _CherryPickPayload = json.loads(result.output)
802 # Every symbol should be applied or already_current (no failures)
803 assert data["failed"] == 0
804 assert data["applied"] + data["already_current"] == n
805
806 def test_large_file_only_target_lines_change(self, repo: pathlib.Path) -> None:
807 """1 000-line file: cherry-pick changes exactly target symbol, nothing else."""
808 header = "def noop():\n pass\n\n"
809 target_v1 = "def target():\n return 'v1'\n\n"
810 footer_lines = "".join(f"def pad_{i}():\n pass\n\n" for i in range(100))
811
812 (repo / "large.py").write_text(header + target_v1 + footer_lines)
813 runner.invoke(cli, ["commit", "-m", "v1"])
814
815 target_v2 = "def target():\n return 'v2'\n\n"
816 (repo / "large.py").write_text(header + target_v2 + footer_lines)
817 runner.invoke(cli, ["commit", "-m", "v2"])
818
819 runner.invoke(cli, ["code", "semantic-cherry-pick", "large.py::target", "--from", "HEAD~1"])
820 text = (repo / "large.py").read_text()
821 assert "'v1'" in text
822 assert "'v2'" not in text
823 for i in range(100):
824 assert f"def pad_{i}" in text
825
826 def test_repeated_cherry_pick_is_idempotent(
827 self, two_commit_repo: tuple[pathlib.Path, str, str, str]
828 ) -> None:
829 root, address, file_rel, _ = two_commit_repo
830 runner.invoke(cli, ["code", "semantic-cherry-pick", address, "--from", "HEAD~1"])
831 text_1 = (root / file_rel).read_text()
832 for _ in range(5):
833 runner.invoke(cli, ["code", "semantic-cherry-pick", address, "--from", "HEAD~1"])
834 assert (root / file_rel).read_text() == text_1
835
836 def test_repeated_cherry_pick_is_fast(
837 self, two_commit_repo: tuple[pathlib.Path, str, str, str]
838 ) -> None:
839 """Idempotent cherry-picks should complete well within 2 seconds each."""
840 _, address, _, _ = two_commit_repo
841 runner.invoke(cli, ["code", "semantic-cherry-pick", address, "--from", "HEAD~1"])
842 start = time.monotonic()
843 for _ in range(10):
844 runner.invoke(cli, ["code", "semantic-cherry-pick", address, "--from", "HEAD~1"])
845 elapsed = time.monotonic() - start
846 assert elapsed < 20.0, f"10 idempotent cherry-picks took {elapsed:.1f}s — too slow"
847
848 def test_src_cache_scales_with_many_same_file_addresses(
849 self, repo: pathlib.Path
850 ) -> None:
851 """Blob fetch count stays 1 regardless of how many symbols target same file."""
852 n = 20
853 content = "\n\n".join(f"def sym_{i}():\n return {i}" for i in range(n))
854 (repo / "cache_test.py").write_text(content + "\n")
855 runner.invoke(cli, ["commit", "-m", "v1"])
856
857 # Mutate so all symbols differ
858 content_v2 = "\n\n".join(f"def sym_{i}():\n return {i * 100}" for i in range(n))
859 (repo / "cache_test.py").write_text(content_v2 + "\n")
860 runner.invoke(cli, ["commit", "-m", "v2"])
861
862 call_count = 0
863 original_read = __import__(
864 "muse.core.object_store", fromlist=["read_object"]
865 ).read_object
866
867 def counting_read(r: pathlib.Path, obj_id: str) -> bytes | None:
868 nonlocal call_count
869 call_count += 1
870 fetched: bytes | None = original_read(r, obj_id)
871 return fetched
872
873 addresses = [f"cache_test.py::sym_{i}" for i in range(n)]
874 with mock.patch(
875 "muse.cli.commands.semantic_cherry_pick.read_object", side_effect=counting_read
876 ):
877 result = runner.invoke(
878 cli,
879 ["code", "semantic-cherry-pick"] + addresses + ["--from", "HEAD~1", "--json"],
880 )
881 assert result.exit_code == 0
882 # The source blob for cache_test.py must be fetched exactly once
883 assert call_count == 1, (
884 f"Expected 1 blob fetch for {n} symbols in the same file; got {call_count}"
885 )
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 142 days ago