gabriel / musehub public
proposal_merge_strategies.py python
678 lines 26.8 KB
Raw
sha256:0c088142e487b1154ae4e867abda064d4a52242ece13787372bc4c663a192699 feat(phase6): route canonical strategies through run_merge(… Sonnet 4.6 patch 9 days ago
1 """Merge strategy engine for proposal merges — issue #37 Phase 3.
2
3 Five strategies, each producing a ``MergeResult`` from two branch manifests:
4
5 OVERLAY — from_branch wins all file conflicts (current default)
6 WEAVE — three-way at file level: ancestor base isolates true conflicts
7 REPLAY — apply from_branch delta (vs ancestor) on top of to_branch
8 SELECTIVE — only apply from_branch changes for files in selective_domains
9 PHASED — merge in dependency-DAG order; each phase is an OVERLAY
10 of one dep's delta; yields a phase audit trail
11
12 Domain classification uses file extension and path-prefix heuristics. The
13 ``DomainClassifier`` is the single authority — all strategies call it.
14
15 All functions are pure with respect to DB I/O — they operate on
16 ``StrDict`` manifests (``dict[str, str]``, path → object_id) that callers
17 have already loaded from the snapshot service.
18 """
19
20 from __future__ import annotations
21
22 import logging
23 from dataclasses import dataclass, field
24 from pathlib import PurePosixPath
25 from typing import Sequence
26
27 from musehub.types.json_types import StrDict
28
29 logger = logging.getLogger(__name__)
30
31 # ─────────────────────────────────────────────────────────────────────────────
32 # Domain classification
33 # ─────────────────────────────────────────────────────────────────────────────
34
35 # Extension sets per domain — order matters for the classifier (first match wins)
36 _DOMAIN_EXTENSIONS: dict[str, frozenset[str]] = {
37 "midi": frozenset({".mid", ".midi", ".smf"}),
38 "stem": frozenset({".wav", ".aiff", ".aif", ".flac", ".mp3", ".ogg", ".opus", ".m4a"}),
39 "payment": frozenset({".mpay", ".claim", ".settle"}),
40 "identity": frozenset({".toml"})
41 if False else # .toml is too broad — use path-prefix below
42 frozenset({".mpay"}), # placeholder; identity matched by path prefix
43 "code": frozenset({
44 ".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".java",
45 ".c", ".cpp", ".h", ".hpp", ".cs", ".rb", ".swift", ".kt",
46 ".sh", ".bash", ".zsh", ".fish", ".sql", ".yaml", ".yml",
47 ".toml", ".json", ".md", ".rst", ".txt", ".cfg", ".ini",
48 ".dockerfile", ".makefile",
49 }),
50 }
51
52 # Path prefixes that override extension-based classification
53 _DOMAIN_PATH_PREFIXES: dict[str, tuple[str, ...]] = {
54 "identity": ("identity/", ".muse/identity", "keys/", "auth/"),
55 "payment": ("payments/", "claims/", "ledger/", "settle/"),
56 "midi": ("midi/", "tracks/", "sequences/"),
57 "stem": ("stems/", "audio/", "samples/", "recordings/"),
58 }
59
60
61 def classify_domain(path: str) -> str:
62 """Return the domain name for a file path.
63
64 Priority: path-prefix rules → extension rules → fallback "code".
65 """
66 lower = path.lower()
67 for domain, prefixes in _DOMAIN_PATH_PREFIXES.items():
68 if any(lower.startswith(p) for p in prefixes):
69 return domain
70 ext = PurePosixPath(path).suffix.lower()
71 for domain, exts in _DOMAIN_EXTENSIONS.items():
72 if ext in exts:
73 return domain
74 return "code"
75
76
77 def paths_for_domains(manifest: StrDict, domains: Sequence[str]) -> frozenset[str]:
78 """Return the set of paths in ``manifest`` that belong to any of ``domains``."""
79 domain_set = frozenset(domains)
80 return frozenset(p for p in manifest if classify_domain(p) in domain_set)
81
82
83 # ─────────────────────────────────────────────────────────────────────────────
84 # Result types
85 # ─────────────────────────────────────────────────────────────────────────────
86
87
88 @dataclass
89 class ConflictEntry:
90 """A file where both branches modified the content since the common ancestor."""
91 path: str
92 to_branch_object_id: str
93 from_branch_object_id: str
94 resolution: str = "from_wins" # from_wins | to_wins | manual_required
95
96
97 @dataclass
98 class PhaseResult:
99 """Outcome of one phase in a PHASED merge."""
100 phase_index: int
101 dependency_proposal_id: str
102 files_applied: int
103 domains_touched: list[str]
104
105
106 @dataclass
107 class MergeResult:
108 """The output of a merge strategy execution.
109
110 ``manifest`` is the final merged ``{path: object_id}`` mapping that callers
111 should persist as a new snapshot.
112
113 ``conflicts`` contains entries where both branches diverged from the ancestor;
114 for strategies that auto-resolve (OVERLAY, PHASED) these are recorded
115 but do not block the merge. WEAVE surfaces them for review.
116
117 ``domains_merged`` lists the domains actually touched by from_branch changes.
118 For SELECTIVE, it reflects only the requested subset.
119 """
120 strategy: str
121 manifest: StrDict
122 files_added: int = 0
123 files_modified: int = 0
124 files_removed: int = 0
125 files_skipped: int = 0
126 conflicts: list[ConflictEntry] = field(default_factory=list)
127 domains_merged: list[str] = field(default_factory=list)
128 phase_results: list[PhaseResult] = field(default_factory=list)
129 # Populated by STATE_WEAVE: ancestor snapshot_id used for three-way base
130 ancestor_snapshot_id: str | None = None
131
132
133 # ─────────────────────────────────────────────────────────────────────────────
134 # Shared manifest diff helpers
135 # ─────────────────────────────────────────────────────────────────────────────
136
137
138 def _manifest_delta(
139 base: StrDict,
140 head: StrDict,
141 ) -> tuple[set[str], set[str], set[str]]:
142 """Return (added, modified, removed) path sets from base→head."""
143 base_paths = set(base)
144 head_paths = set(head)
145 added = head_paths - base_paths
146 removed = base_paths - head_paths
147 modified = {p for p in base_paths & head_paths if base[p] != head[p]}
148 return added, modified, removed
149
150
151 def _apply_delta(
152 target: StrDict,
153 delta_head: StrDict,
154 delta_base: StrDict,
155 ) -> tuple[StrDict, int, int, int]:
156 """Apply changes from delta_base→delta_head onto target.
157
158 Returns (merged_manifest, added, modified, removed).
159 """
160 result = dict(target)
161 added, modified, removed = _manifest_delta(delta_base, delta_head)
162 for path in added | modified:
163 result[path] = delta_head[path]
164 for path in removed:
165 result.pop(path, None)
166 return result, len(added), len(modified), len(removed)
167
168
169 def _domain_summary(paths: set[str]) -> list[str]:
170 """Return sorted unique domain names for a set of paths."""
171 return sorted({classify_domain(p) for p in paths})
172
173
174 # ─────────────────────────────────────────────────────────────────────────────
175 # Strategy implementations
176 # ─────────────────────────────────────────────────────────────────────────────
177
178
179 def merge_overlay(
180 to_manifest: StrDict,
181 from_manifest: StrDict,
182 *,
183 ancestor_manifest: StrDict | None = None,
184 ) -> MergeResult:
185 """OVERLAY — from_branch wins all conflicts.
186
187 The simplest and fastest strategy: the merged manifest is
188 ``{**to_manifest, **from_manifest}``. Files only on to_branch are kept;
189 files only on from_branch are added; files on both use from_branch's
190 version. Ancestor is used only to populate the conflicts list for audit.
191 """
192 merged: StrDict = {**to_manifest, **from_manifest}
193
194 added, modified, removed = _manifest_delta(to_manifest, merged)
195 # removed relative to to_manifest = paths in to but not in from
196 actual_removed = set(to_manifest) - set(from_manifest)
197 actual_added = set(from_manifest) - set(to_manifest)
198 actual_modified = {p for p in set(from_manifest) & set(to_manifest) if from_manifest[p] != to_manifest[p]}
199
200 conflicts: list[ConflictEntry] = []
201 if ancestor_manifest is not None:
202 # Paths changed on BOTH branches since ancestor = true conflicts
203 _, to_modified, _ = _manifest_delta(ancestor_manifest, to_manifest)
204 _, from_modified, _ = _manifest_delta(ancestor_manifest, from_manifest)
205 for path in to_modified & from_modified:
206 if to_manifest.get(path) != from_manifest.get(path):
207 conflicts.append(ConflictEntry(
208 path=path,
209 to_branch_object_id=to_manifest[path],
210 from_branch_object_id=from_manifest[path],
211 resolution="from_wins",
212 ))
213
214 changed_paths = actual_added | actual_modified
215 return MergeResult(
216 strategy="overlay",
217 manifest=merged,
218 files_added=len(actual_added),
219 files_modified=len(actual_modified),
220 files_removed=len(actual_removed),
221 conflicts=conflicts,
222 domains_merged=_domain_summary(changed_paths),
223 )
224
225
226 def merge_weave(
227 to_manifest: StrDict,
228 from_manifest: StrDict,
229 *,
230 ancestor_manifest: StrDict,
231 ) -> MergeResult:
232 """WEAVE — three-way file-level merge with conflict surfacing.
233
234 Uses the common ancestor to distinguish true conflicts (both sides changed
235 the same file) from clean additions (only one side changed it).
236
237 Resolution rules per file:
238 - Only to_branch changed it → keep to_branch version (clean)
239 - Only from_branch changed it → take from_branch version (clean)
240 - Both changed it differently → from_branch wins; record ConflictEntry
241 - Neither changed it → keep ancestor version
242 - Added only on from_branch → include (clean)
243 - Added only on to_branch → include (clean)
244 - Added on both, different → from_branch wins; ConflictEntry
245 - Removed on from_branch → exclude
246 - Removed on to_branch → exclude (respect both deletions)
247 """
248 all_paths = set(to_manifest) | set(from_manifest) | set(ancestor_manifest)
249 merged: StrDict = {}
250 conflicts: list[ConflictEntry] = []
251 added = modified = removed = 0
252
253 for path in all_paths:
254 in_anc = path in ancestor_manifest
255 in_to = path in to_manifest
256 in_frm = path in from_manifest
257
258 anc_id = ancestor_manifest.get(path)
259 to_id = to_manifest.get(path)
260 frm_id = from_manifest.get(path)
261
262 to_changed = in_to and (not in_anc or to_id != anc_id)
263 frm_changed = in_frm and (not in_anc or frm_id != anc_id)
264 to_deleted = in_anc and not in_to
265 frm_deleted = in_anc and not in_frm
266
267 if frm_deleted:
268 # from_branch deleted it — honour the deletion
269 if in_anc and in_to and not to_changed:
270 removed += 1 # only in ancestor + to (unchanged) — delete
271 # if to_branch also changed it, we still honour the deletion (from wins)
272 elif in_anc:
273 removed += 1
274 continue # don't include in merged
275
276 if to_deleted and not frm_changed:
277 # to_branch deleted it, from_branch didn't change it — honour deletion
278 removed += 1
279 continue
280
281 if frm_changed and to_changed and frm_id != to_id:
282 # True conflict — from_branch wins but record for review
283 merged[path] = frm_id # type: ignore[assignment]
284 conflicts.append(ConflictEntry(
285 path=path,
286 to_branch_object_id=to_id, # type: ignore[arg-type]
287 from_branch_object_id=frm_id, # type: ignore[arg-type]
288 resolution="from_wins",
289 ))
290 if in_anc:
291 modified += 1
292 else:
293 added += 1
294 elif frm_changed:
295 merged[path] = frm_id # type: ignore[assignment]
296 if in_anc:
297 modified += 1
298 else:
299 added += 1
300 elif to_changed:
301 merged[path] = to_id # type: ignore[assignment]
302 if in_anc:
303 modified += 1
304 else:
305 added += 1
306 elif in_anc:
307 # Neither side changed it — keep ancestor value
308 merged[path] = anc_id # type: ignore[assignment]
309
310 changed_paths = {c.path for c in conflicts}
311 changed_paths |= {p for p in merged if p not in ancestor_manifest or merged[p] != ancestor_manifest.get(p)}
312
313 return MergeResult(
314 strategy="weave",
315 manifest=merged,
316 files_added=added,
317 files_modified=modified,
318 files_removed=removed,
319 conflicts=conflicts,
320 domains_merged=_domain_summary(changed_paths),
321 ancestor_snapshot_id=None, # caller fills this in
322 )
323
324
325 def merge_replay(
326 to_manifest: StrDict,
327 from_manifest: StrDict,
328 *,
329 ancestor_manifest: StrDict,
330 ) -> MergeResult:
331 """REPLAY — apply from_branch delta (vs ancestor) on top of to_branch.
332
333 Identifies exactly which files from_branch changed relative to the common
334 ancestor, then applies only those changes onto the current to_branch state.
335 Files that to_branch changed but from_branch didn't touch are preserved
336 untouched — this is the key difference from OVERLAY.
337
338 Conflict rule: if the same file was changed on both sides since the ancestor,
339 from_branch wins (recorded as ConflictEntry).
340 """
341 from_added, from_modified, from_removed = _manifest_delta(ancestor_manifest, from_manifest)
342 to_added, to_modified, _ = _manifest_delta(ancestor_manifest, to_manifest)
343
344 merged: StrDict = dict(to_manifest)
345 conflicts: list[ConflictEntry] = []
346
347 # Apply from_branch additions and modifications
348 for path in from_added | from_modified:
349 if path in to_modified or path in to_added:
350 if to_manifest.get(path) != from_manifest[path]:
351 conflicts.append(ConflictEntry(
352 path=path,
353 to_branch_object_id=to_manifest.get(path, ""),
354 from_branch_object_id=from_manifest[path],
355 resolution="from_wins",
356 ))
357 merged[path] = from_manifest[path]
358
359 # Apply from_branch removals
360 for path in from_removed:
361 merged.pop(path, None)
362
363 changed = from_added | from_modified
364 return MergeResult(
365 strategy="replay",
366 manifest=merged,
367 files_added=len(from_added),
368 files_modified=len(from_modified),
369 files_removed=len(from_removed),
370 conflicts=conflicts,
371 domains_merged=_domain_summary(changed),
372 )
373
374
375 def merge_selective(
376 to_manifest: StrDict,
377 from_manifest: StrDict,
378 *,
379 selective_domains: list[str],
380 ancestor_manifest: StrDict | None = None,
381 ) -> MergeResult:
382 """SELECTIVE — only apply from_branch changes for the specified domains.
383
384 Files outside ``selective_domains`` remain exactly as they are on to_branch.
385 Files inside the domains use from_branch's version (OVERLAY semantics
386 scoped to the domain subset).
387
388 Conflicts are recorded for files in the selected domains that were changed
389 on both sides since the ancestor.
390 """
391 if not selective_domains:
392 raise ValueError("SELECTIVE strategy requires at least one domain in selective_domains")
393
394 selected = frozenset(selective_domains)
395 merged: StrDict = dict(to_manifest)
396 conflicts: list[ConflictEntry] = []
397 added = modified = removed = skipped = 0
398
399 # Collect all paths that from_branch wants to change
400 from_added_raw, from_modified_raw, from_removed_raw = _manifest_delta(
401 ancestor_manifest or to_manifest, from_manifest
402 )
403
404 for path in from_added_raw | from_modified_raw:
405 if classify_domain(path) in selected:
406 if path in to_manifest and to_manifest[path] != from_manifest[path]:
407 if ancestor_manifest:
408 anc_id = ancestor_manifest.get(path)
409 to_changed = to_manifest[path] != anc_id
410 if to_changed:
411 conflicts.append(ConflictEntry(
412 path=path,
413 to_branch_object_id=to_manifest[path],
414 from_branch_object_id=from_manifest[path],
415 resolution="from_wins",
416 ))
417 modified += 1
418 else:
419 added += 1
420 merged[path] = from_manifest[path]
421 else:
422 skipped += 1
423
424 for path in from_removed_raw:
425 if classify_domain(path) in selected:
426 merged.pop(path, None)
427 removed += 1
428 else:
429 skipped += 1
430
431 return MergeResult(
432 strategy="selective",
433 manifest=merged,
434 files_added=added,
435 files_modified=modified,
436 files_removed=removed,
437 files_skipped=skipped,
438 conflicts=conflicts,
439 domains_merged=sorted(selective_domains),
440 )
441
442
443 def merge_phased(
444 to_manifest: StrDict,
445 from_manifest: StrDict,
446 *,
447 ancestor_manifest: StrDict | None = None,
448 dependency_order: list[str] | None = None,
449 phase_manifests: dict[str, StrDict] | None = None,
450 ) -> MergeResult:
451 """PHASED — merge in dependency-DAG topological order.
452
453 When ``phase_manifests`` is provided (a map from proposal_id → manifest for
454 each dependency), the merge applies each dependency's delta in Kahn order,
455 building up a cumulative result. This ensures that intermediate state is
456 consistent at each phase boundary.
457
458 When ``phase_manifests`` is not provided (the common case: all deps already
459 merged), falls back to STATE_OVERLAY semantics on the final manifests and
460 records a single phase result covering the entire merge.
461
462 ``dependency_order``: topological order of proposal IDs (from proposal_dag).
463 """
464 phase_results: list[PhaseResult] = []
465
466 if phase_manifests and dependency_order:
467 # Full phased execution: apply each dep delta in order
468 current = dict(to_manifest)
469 prev_manifest = dict(ancestor_manifest or to_manifest)
470 total_added = total_modified = total_removed = 0
471 all_conflicts: list[ConflictEntry] = []
472
473 for phase_idx, dep_id in enumerate(dependency_order):
474 dep_manifest = phase_manifests.get(dep_id, {})
475 if not dep_manifest:
476 continue
477
478 phase_merged, p_added, p_modified, p_removed = _apply_delta(
479 current, dep_manifest, prev_manifest
480 )
481 delta_paths = set(dep_manifest) - set(prev_manifest)
482 delta_paths |= {p for p in dep_manifest if dep_manifest.get(p) != prev_manifest.get(p)}
483
484 phase_results.append(PhaseResult(
485 phase_index=phase_idx,
486 dependency_proposal_id=dep_id,
487 files_applied=p_added + p_modified + p_removed,
488 domains_touched=_domain_summary(delta_paths),
489 ))
490 current = phase_merged
491 prev_manifest = dict(dep_manifest)
492 total_added += p_added
493 total_modified += p_modified
494 total_removed += p_removed
495
496 # Final overlay: apply the proposal's own changes on top
497 final, f_added, f_modified, f_removed = _apply_delta(
498 current, from_manifest, prev_manifest
499 )
500 phase_results.append(PhaseResult(
501 phase_index=len(dependency_order),
502 dependency_proposal_id="self",
503 files_applied=f_added + f_modified + f_removed,
504 domains_touched=_domain_summary(set(from_manifest)),
505 ))
506
507 changed = {p for p in final if final.get(p) != to_manifest.get(p)}
508 return MergeResult(
509 strategy="phased",
510 manifest=final,
511 files_added=total_added + f_added,
512 files_modified=total_modified + f_modified,
513 files_removed=total_removed + f_removed,
514 conflicts=all_conflicts,
515 domains_merged=_domain_summary(changed),
516 phase_results=phase_results,
517 )
518
519 # Fallback: deps already merged into to_branch — single-phase overlay
520 result = merge_overlay(to_manifest, from_manifest, ancestor_manifest=ancestor_manifest)
521 result.strategy = "phased"
522 changed_paths = {p for p in from_manifest if from_manifest.get(p) != to_manifest.get(p)}
523 changed_paths |= set(from_manifest) - set(to_manifest)
524 result.phase_results = [PhaseResult(
525 phase_index=0,
526 dependency_proposal_id="self",
527 files_applied=result.files_added + result.files_modified + result.files_removed,
528 domains_touched=_domain_summary(changed_paths),
529 )]
530 return result
531
532
533 def merge_cherry_pick(
534 to_manifest: StrDict,
535 *,
536 cherry_pick_commits: list[tuple[StrDict, StrDict]],
537 ) -> MergeResult:
538 """CHERRY_PICK — apply the delta of each specified commit onto to_branch.
539
540 Each entry in ``cherry_pick_commits`` is a ``(parent_manifest, commit_manifest)``
541 pair. The delta introduced by each commit (relative to its parent) is applied
542 in order onto the accumulating result. Files not touched by any picked commit
543 are preserved exactly as they are on to_branch.
544
545 Conflict rule: if a file is both changed by a picked commit and already
546 differs on to_branch relative to the commit's parent, from_wins is recorded.
547 """
548 if not cherry_pick_commits:
549 raise ValueError("cherry_pick strategy requires at least one commit in cherry_pick_commits")
550
551 current: StrDict = dict(to_manifest)
552 conflicts: list[ConflictEntry] = []
553 total_added = total_modified = total_removed = 0
554
555 for parent_manifest, commit_manifest in cherry_pick_commits:
556 added, modified, removed = _manifest_delta(parent_manifest, commit_manifest)
557
558 for path in added | modified:
559 frm_id = commit_manifest[path]
560 cur_id = current.get(path)
561 par_id = parent_manifest.get(path)
562 if cur_id is not None and cur_id != frm_id and cur_id != par_id:
563 conflicts.append(ConflictEntry(
564 path=path,
565 to_branch_object_id=cur_id,
566 from_branch_object_id=frm_id,
567 resolution="from_wins",
568 ))
569 if path not in current:
570 total_added += 1
571 elif cur_id != frm_id:
572 total_modified += 1
573 current[path] = frm_id
574
575 for path in removed:
576 if path in current:
577 current.pop(path)
578 total_removed += 1
579
580 changed = {p for p in current if current.get(p) != to_manifest.get(p)}
581 changed |= set(to_manifest) - set(current)
582
583 return MergeResult(
584 strategy="cherry_pick",
585 manifest=current,
586 files_added=total_added,
587 files_modified=total_modified,
588 files_removed=total_removed,
589 conflicts=conflicts,
590 domains_merged=_domain_summary(changed),
591 )
592
593
594 # ─────────────────────────────────────────────────────────────────────────────
595 # Strategy router
596 # ─────────────────────────────────────────────────────────────────────────────
597
598
599 def execute_merge_strategy(
600 strategy: str,
601 to_manifest: StrDict,
602 from_manifest: StrDict,
603 *,
604 ancestor_manifest: StrDict | None = None,
605 selective_domains: list[str] | None = None,
606 dependency_order: list[str] | None = None,
607 phase_manifests: dict[str, StrDict] | None = None,
608 cherry_pick_commits: list[tuple[StrDict, StrDict]] | None = None,
609 ) -> MergeResult:
610 """Route to the correct strategy implementation.
611
612 Args:
613 strategy: One of the MergeStrategy values.
614 to_manifest: Current to_branch manifest.
615 from_manifest: Current from_branch manifest.
616 ancestor_manifest: Common ancestor manifest. Required for weave
617 and replay; used for conflict detection in
618 overlay and selective when provided.
619 selective_domains: Required for selective.
620 dependency_order: Topological order of dep IDs for phased.
621 phase_manifests: Per-dep manifests for phased (optional).
622 cherry_pick_commits: Ordered (parent_manifest, commit_manifest) pairs
623 for cherry_pick.
624
625 Raises:
626 ValueError: Unknown strategy or missing required parameters.
627 """
628 if strategy == "overlay":
629 return merge_overlay(
630 to_manifest, from_manifest, ancestor_manifest=ancestor_manifest
631 )
632 elif strategy == "weave":
633 if ancestor_manifest is None:
634 # No ancestor available — fall back to overlay with a warning
635 logger.warning(
636 "weave requested but no ancestor manifest available; "
637 "falling back to overlay"
638 )
639 result = merge_overlay(to_manifest, from_manifest)
640 result.strategy = "weave"
641 return result
642 return merge_weave(
643 to_manifest, from_manifest, ancestor_manifest=ancestor_manifest
644 )
645 elif strategy == "replay":
646 if ancestor_manifest is None:
647 logger.warning(
648 "replay requested but no ancestor manifest available; "
649 "falling back to overlay"
650 )
651 result = merge_overlay(to_manifest, from_manifest)
652 result.strategy = "replay"
653 return result
654 return merge_replay(
655 to_manifest, from_manifest, ancestor_manifest=ancestor_manifest
656 )
657 elif strategy == "selective":
658 return merge_selective(
659 to_manifest,
660 from_manifest,
661 selective_domains=selective_domains or [],
662 ancestor_manifest=ancestor_manifest,
663 )
664 elif strategy == "phased":
665 return merge_phased(
666 to_manifest,
667 from_manifest,
668 ancestor_manifest=ancestor_manifest,
669 dependency_order=dependency_order,
670 phase_manifests=phase_manifests,
671 )
672 elif strategy == "cherry_pick":
673 return merge_cherry_pick(
674 to_manifest,
675 cherry_pick_commits=cherry_pick_commits or [],
676 )
677 else:
678 raise ValueError(f"Unknown merge strategy: {strategy!r}")
File History 3 commits
sha256:0c088142e487b1154ae4e867abda064d4a52242ece13787372bc4c663a192699 feat(phase6): route canonical strategies through run_merge(… Sonnet 4.6 patch 9 days ago
sha256:3999d4bb3fa84f8659211aa88a6e01fa9142ffe0cba939ed13ce6ce59810b657 feat: route execute_merge_strategy through STRATEGY_MAP fro… Sonnet 4.6 minor 9 days ago
sha256:50b52eda7afb2f122863aef47d684d1a9e4684b48f5f95367fc956e28ceb7d42 refactor: rename merge strategy aliases to canonical names Sonnet 4.6 minor 13 days ago