gabriel / muse public
semantic_cherry_pick.py python
397 lines 14.4 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """muse code semantic-cherry-pick — cherry-pick specific symbols, not files.
2
3 Extracts named symbols from a source commit and applies them to the current
4 working tree, replacing only those symbols. All other code is left untouched.
5
6 This is the semantic counterpart to ``git cherry-pick``, which operates at the
7 file-hunk level. ``muse code semantic-cherry-pick`` operates at the symbol level:
8 you name the exact functions, classes, or methods you want to bring forward.
9
10 Multiple symbols can be cherry-picked in a single invocation. They are
11 applied left-to-right. Failures are recorded and processing continues with
12 the remaining symbols — the full result set is always returned.
13
14 Security note: every file path extracted from ADDRESS arguments is validated
15 via ``contain_path()`` before any disk access or directory creation. Paths
16 that escape the repo root (e.g. ``../../etc/shadow::foo``) are rejected and
17 the symbol is recorded as ``not_found`` with an appropriate detail message.
18
19 Usage::
20
21 muse code semantic-cherry-pick "src/billing.py::compute_total" --from abc12345
22 muse code semantic-cherry-pick \\
23 "src/auth.py::validate_token" \\
24 "src/auth.py::refresh_token" \\
25 --from feature-branch
26 muse code semantic-cherry-pick "src/core.py::hash_content" --from HEAD~5 --dry-run
27 muse code semantic-cherry-pick "src/billing.py::Invoice.pay" --from v1.0 --json
28
29 Output::
30
31 Semantic cherry-pick from commit abc12345
32 ──────────────────────────────────────────────────────────────
33
34 ✅ src/auth.py::validate_token applied (lines 12–34 → 29 lines)
35 ✅ src/auth.py::refresh_token applied (lines 36–58 → 17 lines)
36 ❌ src/billing.py::compute_total not found in source commit
37
38 2 applied, 1 failed
39
40 Flags:
41
42 ``--from REF``
43 Required. Commit or branch to cherry-pick from.
44
45 ``--dry-run``
46 Print what would change without writing anything. Each result still
47 includes ``diff_lines`` and ``verified`` so agents can gate on output
48 quality before committing to a write.
49
50 ``--json``
51 Emit per-symbol results as JSON (includes ``diff_lines`` and ``verified``).
52 """
53
54 from __future__ import annotations
55
56 import argparse
57 import difflib
58 import json
59 import logging
60 import pathlib
61 from typing import Literal, TypedDict
62
63 from muse import __version__
64 from muse.core.errors import ExitCode
65 from muse.core.object_store import read_object
66 from muse.core.repo import read_repo_id, require_repo
67 from muse.core.store import get_commit_snapshot_manifest, read_current_branch, resolve_commit_ref
68 from muse.core.validation import contain_path, sanitize_display
69 from muse.plugins.code.ast_parser import parse_symbols
70 from muse.core.store import Manifest
71
72
73 type _BlobCache = dict[str, bytes]
74 logger = logging.getLogger(__name__)
75
76 ApplyStatus = Literal["applied", "not_found", "file_missing", "parse_error", "already_current"]
77
78
79 class _PickResultDict(TypedDict):
80 """JSON schema for one cherry-pick result."""
81
82 address: str
83 status: str
84 detail: str
85 old_lines: int
86 new_lines: int
87 diff_lines: list[str]
88 verified: bool
89
90
91 class _PickResult:
92 """Result for one cherry-picked symbol."""
93
94 def __init__(
95 self,
96 address: str,
97 status: ApplyStatus,
98 detail: str = "",
99 old_lines: int = 0,
100 new_lines: int = 0,
101 diff_lines: list[str] | None = None,
102 verified: bool = True,
103 ) -> None:
104 self.address = address
105 self.status = status
106 self.detail = detail
107 self.old_lines = old_lines
108 self.new_lines = new_lines
109 self.diff_lines: list[str] = diff_lines if diff_lines is not None else []
110 self.verified = verified
111
112 def to_dict(self) -> _PickResultDict:
113 return {
114 "address": self.address,
115 "status": self.status,
116 "detail": self.detail,
117 "old_lines": self.old_lines,
118 "new_lines": self.new_lines,
119 "diff_lines": self.diff_lines,
120 "verified": self.verified,
121 }
122
123
124 def _verify_symbol(working_file: pathlib.Path, file_rel: str, address: str) -> bool:
125 """Re-parse *working_file* and confirm *address* is locatable after a write.
126
127 Returns ``False`` if the write produced syntactically invalid output or
128 if the symbol is no longer addressable — a useful signal to agents that
129 the splice needs human review.
130 """
131 try:
132 tree = parse_symbols(working_file.read_bytes(), file_rel)
133 return tree.get(address) is not None
134 except Exception:
135 return False
136
137
138 def _apply_symbol(
139 root: pathlib.Path,
140 address: str,
141 src_manifest: Manifest,
142 dry_run: bool,
143 src_cache: _BlobCache,
144 ) -> _PickResult:
145 """Apply one symbol from *src_manifest* to the working tree.
146
147 *src_cache* is a content-addressed blob cache keyed by object ID.
148 Passing the same dict across calls for a single invocation ensures that
149 multiple addresses targeting the same source file only fetch the blob once.
150 """
151 if "::" not in address:
152 return _PickResult(address, "not_found", "address has no '::' separator")
153
154 file_rel = address.split("::")[0]
155
156 # Validate the file path stays inside the repo root before any I/O.
157 try:
158 working_file = contain_path(root, file_rel)
159 except ValueError as exc:
160 return _PickResult(address, "not_found", str(exc))
161
162 # Read historical blob — use src_cache so repeated addresses targeting
163 # the same source file pay the fetch + decode cost only once.
164 obj_id = src_manifest.get(file_rel)
165 if obj_id is None:
166 return _PickResult(address, "file_missing", f"'{file_rel}' not in source snapshot")
167
168 if obj_id not in src_cache:
169 raw = read_object(root, obj_id)
170 if raw is None:
171 return _PickResult(address, "file_missing", f"blob {obj_id[:8]} missing from object store")
172 src_cache[obj_id] = raw
173 src_raw = src_cache[obj_id]
174
175 try:
176 src_tree = parse_symbols(src_raw, file_rel)
177 except Exception as exc:
178 return _PickResult(address, "parse_error", str(exc))
179
180 src_rec = src_tree.get(address)
181 if src_rec is None:
182 return _PickResult(address, "not_found", "symbol not found in source commit")
183
184 src_text = src_raw.decode("utf-8", errors="replace")
185 src_lines_all = src_text.splitlines(keepends=True)
186 src_symbol_lines = src_lines_all[src_rec["lineno"] - 1 : src_rec["end_lineno"]]
187
188 # Read current working tree.
189 if not working_file.exists():
190 diff_lines = list(difflib.unified_diff(
191 [], src_symbol_lines, fromfile="current", tofile="historical", lineterm="",
192 ))
193 if not dry_run:
194 working_file.parent.mkdir(parents=True, exist_ok=True)
195 working_file.write_text("".join(src_symbol_lines), encoding="utf-8")
196 verified = _verify_symbol(working_file, file_rel, address)
197 else:
198 # Dry-run: simulate verification in memory.
199 try:
200 sim_tree = parse_symbols("".join(src_symbol_lines).encode(), file_rel)
201 verified = sim_tree.get(address) is not None
202 except Exception:
203 verified = False
204 return _PickResult(address, "applied", "created file", 0, len(src_symbol_lines), diff_lines, verified)
205
206 current_text = working_file.read_text(encoding="utf-8", errors="replace")
207 current_lines = current_text.splitlines(keepends=True)
208 current_raw = current_text.encode("utf-8")
209
210 try:
211 current_tree = parse_symbols(current_raw, file_rel)
212 except Exception as exc:
213 return _PickResult(address, "parse_error", f"current file: {exc}")
214
215 current_rec = current_tree.get(address)
216
217 if current_rec is not None:
218 if current_rec["content_id"] == src_rec["content_id"]:
219 return _PickResult(address, "already_current", "content identical", 0, 0)
220 old_start = current_rec["lineno"] - 1
221 old_end = current_rec["end_lineno"]
222 current_symbol_lines = current_lines[old_start:old_end]
223 new_lines = current_lines[:old_start] + src_symbol_lines + current_lines[old_end:]
224 detail = f"lines {current_rec['lineno']}–{current_rec['end_lineno']} → {len(src_symbol_lines)} lines"
225 else:
226 # Symbol not in current tree — append at end.
227 current_symbol_lines = []
228 new_lines = current_lines + ["\n"] + src_symbol_lines
229 detail = "appended at end (symbol not found in current tree)"
230
231 diff_lines = list(difflib.unified_diff(
232 current_symbol_lines, src_symbol_lines,
233 fromfile="current", tofile="historical", lineterm="",
234 ))
235
236 if not dry_run:
237 working_file.write_text("".join(new_lines), encoding="utf-8")
238 verified = _verify_symbol(working_file, file_rel, address)
239 else:
240 # Dry-run: simulate verification in memory.
241 try:
242 sim_tree = parse_symbols("".join(new_lines).encode(), file_rel)
243 verified = sim_tree.get(address) is not None
244 except Exception:
245 verified = False
246
247 return _PickResult(address, "applied", detail, len(current_symbol_lines), len(src_symbol_lines), diff_lines, verified)
248
249
250 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
251 """Register the semantic-cherry-pick subcommand."""
252 parser = subparsers.add_parser(
253 "semantic-cherry-pick",
254 help="Cherry-pick specific named symbols from a historical commit.",
255 description=__doc__,
256 formatter_class=argparse.RawDescriptionHelpFormatter,
257 )
258 parser.add_argument(
259 "addresses",
260 nargs="+",
261 metavar="ADDRESS",
262 help='Symbol addresses to cherry-pick, e.g. "src/auth.py::validate_token".',
263 )
264 parser.add_argument(
265 "--from",
266 dest="from_ref",
267 required=True,
268 metavar="REF",
269 help="Commit or branch to cherry-pick symbols from (required).",
270 )
271 parser.add_argument(
272 "--dry-run",
273 action="store_true",
274 help="Print what would change without writing anything.",
275 )
276 parser.add_argument(
277 "--json",
278 dest="as_json",
279 action="store_true",
280 help="Emit per-symbol results as JSON (includes diff_lines and verified).",
281 )
282 parser.set_defaults(func=run)
283
284
285 def run(args: argparse.Namespace) -> None:
286 """Cherry-pick specific named symbols from a historical commit.
287
288 Extracts each listed symbol from the source commit and splices it into
289 the current working-tree file at the symbol's current location. Only
290 the target symbol's lines change; all surrounding code is preserved.
291
292 If the symbol does not exist in the current working tree, the historical
293 version is appended to the end of the file.
294
295 Failures are recorded and processing continues with remaining symbols;
296 the full result set is always returned.
297
298 ``--dry-run`` shows what would change without writing anything.
299 ``--json`` emits per-symbol results for machine consumption.
300 """
301 addresses: list[str] = args.addresses
302 from_ref: str = args.from_ref
303 dry_run: bool = args.dry_run
304 as_json: bool = args.as_json
305
306 root = require_repo()
307
308 try:
309 repo_id = read_repo_id(root)
310 except (OSError, json.JSONDecodeError, KeyError) as exc:
311 logger.error("❌ Could not read repo identity: %s", exc)
312 raise SystemExit(ExitCode.INTERNAL_ERROR) from exc
313
314 try:
315 branch = read_current_branch(root)
316 except Exception as exc:
317 logger.error("❌ Could not read current branch: %s", exc)
318 raise SystemExit(ExitCode.INTERNAL_ERROR) from exc
319
320 if not addresses:
321 logger.error("❌ At least one ADDRESS is required.")
322 raise SystemExit(ExitCode.USER_ERROR)
323
324 from_commit = resolve_commit_ref(root, repo_id, branch, from_ref)
325 if from_commit is None:
326 logger.error("❌ --from ref '%s' not found.", from_ref)
327 raise SystemExit(ExitCode.USER_ERROR)
328
329 src_manifest = get_commit_snapshot_manifest(root, from_commit.commit_id)
330 if src_manifest is None:
331 logger.error(
332 "❌ Snapshot for commit %s is missing from the object store.",
333 from_commit.commit_id[:8],
334 )
335 raise SystemExit(ExitCode.INTERNAL_ERROR)
336
337 # src_cache avoids re-fetching the same blob when multiple addresses
338 # target the same source file within a single invocation.
339 src_cache: _BlobCache = {}
340 results: list[_PickResult] = []
341 for address in addresses:
342 result = _apply_symbol(root, address, src_manifest, dry_run, src_cache)
343 results.append(result)
344
345 n_applied = sum(1 for r in results if r.status == "applied")
346 n_already = sum(1 for r in results if r.status == "already_current")
347 n_failed = sum(1 for r in results if r.status not in ("applied", "already_current"))
348 unverified = [r.address for r in results if r.status == "applied" and not r.verified]
349
350 if as_json:
351 print(json.dumps(
352 {
353 "schema_version": __version__,
354 "branch": branch,
355 "from_commit": from_commit.commit_id[:8],
356 "dry_run": dry_run,
357 "results": [r.to_dict() for r in results],
358 "applied": n_applied,
359 "already_current": n_already,
360 "failed": n_failed,
361 "unverified": unverified,
362 },
363 indent=2,
364 ))
365 return
366
367 action = "Dry-run" if dry_run else "Semantic cherry-pick"
368 print(f"\n{action} from commit {from_commit.commit_id[:8]}")
369 print("─" * 62)
370
371 max_addr = max(len(r.address) for r in results)
372
373 for r in results:
374 if r.status == "applied":
375 icon = "✅"
376 label = f"applied ({r.detail})"
377 if not r.verified:
378 label += " ⚠️ unverified — re-parse failed after write"
379 elif r.status == "already_current":
380 icon = "ℹ️ "
381 label = "already current — no change needed"
382 else:
383 icon = "❌"
384 label = f"{r.status} ({r.detail})"
385 print(f"\n {icon} {sanitize_display(r.address):<{max_addr}} {label}")
386
387 print(f"\n {n_applied} applied, {n_failed} failed")
388 if n_already:
389 print(f" {n_already} already current")
390 if dry_run:
391 print(" (dry run — no files were written)")
392 if unverified:
393 logger.warning(
394 "⚠️ %d symbol(s) could not be verified after write: %s",
395 len(unverified),
396 ", ".join(unverified),
397 )
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago