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