gabriel / musehub public
proposal_merge_strategies.py python
736 lines 29.4 KB
Raw
sha256:7281683f5c41e5d88b6d8811fbdafebd3e01a0c9dcd90975cfcb444ba71e8e81 docs: add local source-of-truth for musehub#225, #226, #227… Sonnet 5 16 hours 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 # musehub#182: "overlay"'s diff_unit is "snapshot", which run_merge()
670 # forces to an EMPTY base regardless of what ancestor_manifest we
671 # pass — i.e. "from_branch wins" for every path it happens to carry,
672 # even ones it never touched since the fork. For a long-lived
673 # from_branch forked well before to_branch's later, unrelated
674 # changes, this silently reverts/deletes them (confirmed: real
675 # incident, 47 files, musehub#144 proposal #9). When a real ancestor
676 # is available, swap to the "theirs" engine instead — same
677 # diff_unit family, same "from_branch wins conflicts" resolution,
678 # but a proper three-way diff against the real ancestor so paths
679 # from_branch never touched are left alone. Only the untouched-path
680 # behavior changes; genuine conflicts still resolve to from_branch,
681 # matching overlay's documented contract.
682 if strategy == "overlay" and ancestor_manifest is not None:
683 engine = STRATEGY_MAP["theirs"]
684
685 needs_ancestor = engine.diff_unit in ("three_way", "replay_ours", "replay_theirs")
686 if needs_ancestor and ancestor_manifest is None:
687 logger.warning(
688 "%s requested but no ancestor manifest available; falling back to overlay",
689 strategy,
690 )
691 result = merge_overlay(to_manifest, from_manifest)
692 result.strategy = strategy
693 return result
694 muse_result = run_merge(
695 ancestor_manifest or {},
696 to_manifest,
697 from_manifest,
698 engine,
699 )
700 return _to_local_result(strategy, muse_result, to_manifest, from_manifest)
701
702 # Non-canonical strategies: weave, selective, phased, cherry_pick
703 if strategy == "weave":
704 if ancestor_manifest is None:
705 logger.warning(
706 "weave requested but no ancestor manifest available; "
707 "falling back to overlay"
708 )
709 result = merge_overlay(to_manifest, from_manifest)
710 result.strategy = "weave"
711 return result
712 return merge_weave(
713 to_manifest, from_manifest, ancestor_manifest=ancestor_manifest
714 )
715 elif strategy == "selective":
716 return merge_selective(
717 to_manifest,
718 from_manifest,
719 selective_domains=selective_domains or [],
720 ancestor_manifest=ancestor_manifest,
721 )
722 elif strategy == "phased":
723 return merge_phased(
724 to_manifest,
725 from_manifest,
726 ancestor_manifest=ancestor_manifest,
727 dependency_order=dependency_order,
728 phase_manifests=phase_manifests,
729 )
730 elif strategy == "cherry_pick":
731 return merge_cherry_pick(
732 to_manifest,
733 cherry_pick_commits=cherry_pick_commits or [],
734 )
735 else:
736 raise ValueError(f"Unknown merge strategy: {strategy!r}")
File History 1 commit
sha256:7281683f5c41e5d88b6d8811fbdafebd3e01a0c9dcd90975cfcb444ba71e8e81 docs: add local source-of-truth for musehub#225, #226, #227… Sonnet 5 16 hours ago