feat: add N-way octopus merge strategy
muse#105. Git's octopus strategy merges 3+ branches into one commit when none of them conflict with each other. Muse had no equivalent: every merge strategy in merge_engine.py is structurally two-way (one base, two sides), the CLI's branch argument was nargs='?' (exactly one), and the domain plugin protocol's merge(base, left, right) signature has no N-way variant.
Design decisions (both confirmed before implementation): - CLI surface: multiple positional args (muse merge branch1 branch2 branch3), matching git's actual real octopus syntax -- no new flag. branch went from nargs='?' to nargs='*'; 1 arg is the unchanged existing two-way path, 2+ routes to the new octopus path. - Engine approach: the engine alone decides safety by computing N pairwise diffs against one shared base and checking for divergent overlap -- it never calls a new N-way domain-plugin method. Reuses the exact two-way primitives every domain already implements (diff_snapshots, detect_conflicts), deferring a real N-way protocol extension unless a domain ever needs true N-way semantic logic beyond "these changes don't overlap."
Commit-identity note: hash_commit/commit_identity_bytes already accepted an arbitrary-length parent_ids list and sorted it before hashing -- N-way merge commit IDs needed zero changes to the hashing layer. The actual schema gap was storage: CommitRecord only had two discrete parent fields. Added parent_commit_ids: list[str] (empty for ordinary 0/1/2-parent commits, which keep using the legacy two fields exactly as before -- purely additive, zero impact on existing commits) and a single helper, commit_parent_ids(record), that every parent-walking caller should use instead of reading the fields directly.
Found and fixed real correctness gaps while auditing every parent_commit_id/parent2_commit_id call site for N-way safety: - gc.py's reachability walk hardcoded the 2-parent tuple directly -- for an octopus merge, this would have marked the 3rd+ parent's entire ancestry as unreachable garbage and deleted it. Verified live: created an octopus merge, deleted one of the three merged branches' own ref (so its tip is reachable ONLY via the merge commit's 3rd parent), ran gc -- commit survived. Confirmed this would have been silently deleted before the fix (same code path, same hardcoded tuple). - verify.py's two ancestor walks (shallow-graft snapshot collection, main BFS) had the identical hardcoded-tuple gap -- would have under-verified octopus merge ancestry and could have false-flagged legitimate objects as orphaned. - name_rev.py's BFS had the same gap -- would have mis-named or missed commits reachable only through a 3rd+ parent. - graph.py's iter_ancestors and find_merge_base (the shared primitives nearly everything else delegates through) were the actual leverage point -- fixing these two functions propagates N-way-correct traversal to every downstream command built on them for free. - read.py's plain-text "Parent:" display and commit_graph.py's node output only showed 2 of N parents -- cosmetic but user-visible; both now show the full list. - rev_list.py's non-first-parent walk had the same hardcoded-tuple gap.
Left unchanged, deliberately: cherry-pick, revert, rebase, bisect, patch-id, format-patch, symlog, note-hotspots, midi-query, query_engine's follow_merges path -- all of these are legitimately first-parent-only operations by design (same as git's own default merge-commit handling for these commands), not regressions.
New tests/test_octopus_merge.py: clean 3-way and 4-way merges (one commit, correct parent count, correct unioned manifest); conflict refusal naming the exact conflicting branch pair and paths; convergent changes (two branches agreeing on the same result) correctly NOT treated as a conflict, matching two-way merge semantics; minimum-2-targets validation; no-common-ancestor refusal; CommitRecord round-trip for 3-parent commits; legacy 2-parent and 1-parent commits resolving identically via commit_parent_ids (the additive-schema guarantee). 11 tests, all green.
Regression suite (589 tests across every file touched -- octopus engine, merge CLI, merge_engine, graph, merge-base, commit-graph, log, commit, verify, read, rev-list, name-rev, gc): zero regressions.
Verified against the real CLI (muse-dev, not just pytest): clean 3-way octopus merge produces one commit with 4 parents and the correct unioned file set; two branches touching the same path refuse cleanly with the exact conflicting pair and path named in both text and --json output; the gc data-loss scenario above, reproduced and confirmed fixed live.
Docs: docs/domain-extensibility.md's Axis B section now documents octopus as a seventh (N-way, structurally distinct) merge mode alongside the existing six two-way strategies.
Semantic Changes
85 symbols
Files Changed
+2
~10
1211 in snapshot
0 comments
muse hub commit comment sha256:677e01f6429bf7ecf69b4dfb356d75b84245cd93db6377364ca6dfad0e426784 --body "your comment"
No comments yet. Be the first to start the discussion.