Add N-way octopus merge strategy (no equivalent to Git's octopus merge exists today)
No equivalent to Git's octopus merge strategy — N-way merge in one commit
Background
Git's octopus strategy merges three or more branches into a single merge commit in one step, provided none of them conflict with each other. It's used when several independent, non-overlapping lines of work need to land together (e.g. a release branch absorbing several feature branches at once) without a chain of N-1 sequential two-way merges and their N-1 separate merge commits.
Muse has no equivalent today.
Confirmed gap (not yet fixed, this is the filing)
muse merge's CLI signature takes exactly onebranchargument (nargs="?"inmuse/cli/commands/merge.py) — there is no way to pass multiple branches in one invocation.- Every one of the six top-level merge strategies documented in
muse/docs/domain-extensibility.md(recursive,overlay,snapshot,replay,ours,theirs) is structurally two-way: each is a(diff_unit, resolution)pair operating on one base + two sides. - The domain plugin protocol itself bakes in two-way merge at the
lowest level:
def merge(self, base, left, right) -> MergeResult— there is no N-way variant of this method signature anywhere inmuse/core/merge_engine.pyor theMuseDomainPluginprotocol. - Today, merging three branches into
devrequires three sequentialmuse mergecalls and (at minimum) three merge commits — there is no single-commit N-way equivalent, and no detection of the "all N branches touch disjoint content" case that would make an octopus-style fast path safe.
Goal
Give Muse a merge mode that accepts more than one branch in a single
muse merge invocation, producing one merge commit with N parents,
when (and only when) the branches' changes don't overlap in a way that
would require real conflict resolution — the same safety condition
Git's own octopus strategy relies on (it silently refuses and tells you
to fall back to sequential pairwise merges the moment two of the
branches touch the same content).
Proposed scope
- N-parent commit support — confirm/extend the commit record
schema to support more than 2 parent commit IDs (today's merge
commits are
parent_commit_id+parent2_commit_id; octopus needs an arbitrary-length parent list). This is likely the single largest structural change and should be scoped precisely in Phase 1 below. - CLI surface —
muse merge <branch1> <branch2> <branch3> ...(multiple positional branch args) or an explicitmuse merge --octopus <branch1> <branch2> ...flag, whichever reads cleaner alongside the existing single-branch invocation. Needs a decision, not an assumption — see Phase 2. - Safety condition — before writing anything, compute the pairwise diff-unit overlap across all N branches relative to their common ancestor(s). If any two branches modify the same path (or, for domains with finer-grained diff units, the same symbol/dimension), refuse the octopus merge with a clear error naming the conflicting pair and path, and suggest falling back to sequential two-way merges — exactly Git's own behavior, don't invent a different failure mode.
- Domain plugin protocol — decide whether N-way merge is
implemented purely in the engine (compute N pairwise diffs against
a shared base, union non-overlapping changes, never call into a
per-domain N-way method) or whether
MuseDomainPluginneeds a real N-waymergevariant. Recommend the former initially — it reuses the existing two-waymerge/diffmethods every domain already implements, and defers the (much larger) protocol-surface change to a later phase if a domain ever needs true N-way domain-specific logic beyond "these changes don't overlap."
Multi-phase TDD roadmap
- Phase 1 — Reproduce the gap + scope the schema change
- New test file
tests/test_octopus_merge.py, first test class confirming the current gap: attempting to pass multiple branches tomuse mergetoday either errors or silently merges only the first — pin down and assert the actual current behavior before changing anything. - Read
muse/core/commits.py(or wherever the commit record is defined) and confirm exactly howparent2_commit_idis stored, and what the minimal schema change looks like to supportparent_commit_ids: list[str](or equivalent) without breaking every existing two-parent merge commit already in every repo's history. This must be a additive, backward- compatible change — existing two-parent commits keep reading correctly.
- New test file
- Phase 2 — Design decision
- CLI surface: multiple positional args vs. an explicit
--octopusflag. Confirm with gabriel before implementing. - Confirm the "engine computes N pairwise diffs, never calls a new domain-plugin method" approach from Proposed Scope item 4, or an alternative, before Phase 3 starts.
- Write the exact overlap-detection algorithm up explicitly (what counts as "the same path" per domain, given some domains diff at symbol/dimension granularity, not just file granularity).
- CLI surface: multiple positional args vs. an explicit
- Phase 3 — Implement
- Commit record: N-parent support, additive schema change, read-time compatibility with existing 1- and 2-parent commits.
muse mergeCLI: accept N branches per Phase 2's decision.- Overlap-detection safety check across all N branches; clear refusal error naming the conflicting pair + path when unsafe.
- Successful-path commit: one commit, N parents, unioned non-overlapping changes from all N branches.
- Phase 4 — Green the suite
- All Phase 1 reproduction tests updated to assert the new, correct behavior.
- New tests: N=3 and N=4 clean octopus merge (disjoint changes,
single commit, all N parents recorded correctly); overlap
detection correctly refuses when any two of N branches touch
the same path; refusal error names the correct conflicting
pair; existing two-parent merge commits (
muse read,muse log,muse merge-base,muse commit-graph) are unaffected by the schema change. - Full regression suite for
merge_engine,cmd_merge,commit,commit-graph,merge-base,rev-list— zero regressions.
- Phase 5 — Real-world verification
- Real scratch repo: create 3+ feature branches with disjoint
changes off a common base, octopus-merge them into
devin one command, confirm one commit with 3+ parents inmuse read --json --manifestand correct final content. - Real scratch repo: same setup but two branches deliberately touch the same file — confirm clean refusal with the correct error, no partial/corrupt commit written.
- Real scratch repo: create 3+ feature branches with disjoint
changes off a common base, octopus-merge them into
- Phase 6 — Ship
- Version bump, same pipeline as every other fix this session (build, publish to staging, smoke test, install, verify against the real CLI).
- Push
dev/maintolocal/staging/production. - Update
muse/docs/domain-extensibility.md's Axis B section to document the new strategy alongside the existing six.
Acceptance criteria
muse merge(ormuse merge --octopus, per Phase 2's decision) can merge 3 or more branches into the current branch in a single command, producing one commit with all N branches as parents, when their changes don't overlap.- The same command cleanly refuses, with a clear error naming the conflicting branches and path, when any two of the N branches touch the same content — no silent data loss, no partial commit.
- Existing two-parent merge commits and all commands that read parent commit IDs continue to work unchanged.
- Full regression suite passes; new octopus-specific tests cover both the clean-merge and safety-refusal paths.
Implemented and shipped to source (not yet in a published release — same note as #106).
Design decisions (both confirmed before starting, per Phase 2):
muse merge branch1 branch2 branch3— not a--octopusflag. This is git's own actual octopus syntax too; no special flag needed there either.branchwent fromnargs="?"tonargs="*"; 1 arg is the unchanged existing two-way path, 2+ routes to the new octopus path.diff_snapshots/detect_conflictsprimitives every domain already implements.Good news on Phase 1's "likely the single largest structural change": it wasn't.
hash_commit/commit_identity_bytesalready accepted an arbitrary-lengthparent_ids: list[str]and sorted it before hashing — N-way commit IDs needed zero changes to the hashing layer. The actual gap was just storage:CommitRecordonly had two discrete parent fields. Addedparent_commit_ids: list[str](empty for ordinary 0/1/2-parent commits, which keep using the legacy two fields exactly as before — purely additive) and one helper,commit_parent_ids(record), that's now the single source of truth for "how many parents does this commit have."Found and fixed real correctness gaps while auditing every
parent_commit_id/parent2_commit_idcall site (not just the ones the ticket anticipated):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 a 3-way octopus merge, deleted one of the three merged branches' own ref (so its tip is reachable only via the merge commit's 3rd parent), rangc— commit survived. This was a real, silent-data-loss-on-GC bug waiting to happen.verify.py's two ancestor walks had the identical gap (would under-verify octopus ancestry).name_rev.py's BFS had the same gap.graph.py'siter_ancestors/find_merge_base— the shared primitives nearly everything else delegates through — were the actual leverage point; fixing these two functions propagates correct N-way traversal to every downstream command built on them for free.read.py's text "Parent:" display andcommit_graph.py's node output only showed 2 of N parents — now show the full list.rev_list.py's non-first-parent walk had the same gap.Left unchanged, deliberately: cherry-pick, revert, rebase, bisect, patch-id, format-patch, symlog, note-hotspots, midi-query, query_engine's
follow_merges— all legitimately first-parent-only by design, matching git's own default merge-commit handling for these.Testing: new
tests/test_octopus_merge.py, 11 tests — clean 3-way and 4-way merges, conflict refusal naming the exact conflicting pair + paths, convergent-change tolerance (matching two-way semantics), minimum-2-targets validation, no-common-ancestor refusal, 3-parent commit round-trip, legacy 1/2-parent backward compatibility. All green. Regression suite across every touched file (merge, merge_engine, graph, merge-base, commit-graph, log, commit, verify, read, rev-list, name-rev, gc): 589 passed, zero regressions.Real-world verification (muse-dev against scratch repos, not just pytest): clean 3-way octopus merge → one commit, 4 parents, correct unioned file set; two branches touching the same path → clean refusal naming the pair in both text and
--json; the gc data-loss scenario above, reproduced and confirmed fixed.Docs:
docs/domain-extensibility.md's Axis B section now documents octopus as a seventh (N-way, structurally distinct) mode.Not done: version bump + publish — in source on all three remotes, not yet in a published
muserelease (current published build is 0.2.1rc12). Same batching question as #106.Closing — all acceptance criteria met.