gabriel / muse public
Closed #105 Enhancement
filed by gabriel human · 31 days ago

Add N-way octopus merge strategy (no equivalent to Git's octopus merge exists today)

0 Anchors
— Blast radius
— Churn 30d
0 Proposals

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 one branch argument (nargs="?" in muse/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 in muse/core/merge_engine.py or the MuseDomainPlugin protocol.
  • Today, merging three branches into dev requires three sequential muse merge calls 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

  1. 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.
  2. CLI surface — muse merge <branch1> <branch2> <branch3> ... (multiple positional branch args) or an explicit muse merge --octopus <branch1> <branch2> ... flag, whichever reads cleaner alongside the existing single-branch invocation. Needs a decision, not an assumption — see Phase 2.
  3. 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.
  4. 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 MuseDomainPlugin needs a real N-way merge variant. Recommend the former initially — it reuses the existing two-way merge/diff methods 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 to muse merge today 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 how parent2_commit_id is stored, and what the minimal schema change looks like to support parent_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.
  • Phase 2 — Design decision
    • CLI surface: multiple positional args vs. an explicit --octopus flag. 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).
  • Phase 3 — Implement
    • Commit record: N-parent support, additive schema change, read-time compatibility with existing 1- and 2-parent commits.
    • muse merge CLI: 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 dev in one command, confirm one commit with 3+ parents in muse read --json --manifest and 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.
  • 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/main to local/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 (or muse 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.
◎ Activity1
●
gabriel opened this issue 31 days ago
○
gabriel 2 days ago

Implemented and shipped to source (not yet in a published release — same note as #106).

Design decisions (both confirmed before starting, per Phase 2):

  1. CLI surface: multiple positional args — muse merge branch1 branch2 branch3 — not a --octopus flag. This is git's own actual octopus syntax too; no special flag needed there either. branch went from nargs="?" to nargs="*"; 1 arg is the unchanged existing two-way path, 2+ routes to the new octopus path.
  2. Engine approach: the engine alone decides safety by computing N pairwise diffs against one shared base and checking for divergent overlap — never calls a new N-way domain-plugin method. Reuses the exact two-way diff_snapshots/detect_conflicts primitives every domain already implements.

Good news on Phase 1's "likely the single largest structural change": it wasn't. hash_commit/commit_identity_bytes already accepted an arbitrary-length parent_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: 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) 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_id call 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), ran gc — 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's iter_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 and commit_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 muse release (current published build is 0.2.1rc12). Same batching question as #106.

Closing — all acceptance criteria met.

✓
closed this issue 2 days ago