"""muse#105 — N-way (octopus) merge: core engine tests. Git's octopus strategy merges 3+ branches into one commit when their changes don't overlap. Muse had no equivalent: every existing strategy is two-way, and `muse merge`'s CLI only accepts one branch argument. Covers the core engine (`muse.core.octopus_merge`) against a real repo built via the CLI (branches, commits) — not mocked CommitRecord/SnapshotRecord objects, since the point is to prove this works against actual on-disk history, not an idealized model of it. """ from __future__ import annotations import os import pathlib import pytest from tests.cli_test_helper import CliRunner, InvokeResult from muse.core.commits import commit_parent_ids, read_commit from muse.core.octopus_merge import ( OctopusConflictError, OctopusNoCommonAncestorError, compute_octopus_merge, ) from muse.core.refs import get_head_commit_id runner = CliRunner() def _run(repo: pathlib.Path, *args: str) -> InvokeResult: saved = os.getcwd() try: os.chdir(repo) return runner.invoke(None, list(args)) finally: os.chdir(saved) def _commit_file(repo: pathlib.Path, path: str, content: str, message: str) -> None: (repo / path).write_text(content) r = _run(repo, "code", "add", path) assert r.exit_code == 0, r.stderr r = _run(repo, "commit", "-m", message) assert r.exit_code == 0, r.stderr @pytest.fixture() def disjoint_branches_repo(tmp_path: pathlib.Path) -> pathlib.Path: """main with a base commit, then three branches each touching a different, disjoint file.""" r = _run(tmp_path, "init") assert r.exit_code == 0, r.stderr _commit_file(tmp_path, "base.py", "base = 1\n", "base commit") for name, fname in (("feat/a", "a.py"), ("feat/b", "b.py"), ("feat/c", "c.py")): r = _run(tmp_path, "branch", name) assert r.exit_code == 0, r.stderr r = _run(tmp_path, "checkout", name) assert r.exit_code == 0, r.stderr _commit_file(tmp_path, fname, f"{fname} = 1\n", f"add {fname}") r = _run(tmp_path, "checkout", "main") assert r.exit_code == 0, r.stderr return tmp_path def _tip(repo: pathlib.Path, branch: str) -> str: cid = get_head_commit_id(repo, branch) assert cid is not None return cid # --------------------------------------------------------------------------- # Phase 4 — CLI accepts N branches (Phase 1 pinned this as a gap: argparse's # old nargs='?' rejected more than one positional; now nargs='*' routes 2+ # branches to the octopus path) # --------------------------------------------------------------------------- def test_muse_merge_cli_accepts_multiple_branches(disjoint_branches_repo: pathlib.Path) -> None: r = _run(disjoint_branches_repo, "merge", "feat/a", "feat/b", "feat/c") assert r.exit_code == 0, r.stderr assert "Octopus merge" in r.output commit = read_commit(disjoint_branches_repo, get_head_commit_id(disjoint_branches_repo, "main")) assert commit is not None assert len(commit_parent_ids(commit)) == 4 def test_muse_merge_cli_json_reports_conflict(tmp_path: pathlib.Path) -> None: r = _run(tmp_path, "init") assert r.exit_code == 0, r.stderr _commit_file(tmp_path, "shared.py", "x = 1\n", "base") _run(tmp_path, "branch", "feat/a") _run(tmp_path, "checkout", "feat/a") _commit_file(tmp_path, "shared.py", "x = 2\n", "a changes shared") _run(tmp_path, "checkout", "main") _run(tmp_path, "branch", "feat/b") _run(tmp_path, "checkout", "feat/b") _commit_file(tmp_path, "shared.py", "x = 3\n", "b changes shared differently") _run(tmp_path, "checkout", "main") r = _run(tmp_path, "merge", "feat/a", "feat/b", "--json") assert r.exit_code != 0 import json as _json payload = _json.loads(r.output) assert payload["status"] == "conflict" assert {payload["conflict"]["branch_a"], payload["conflict"]["branch_b"]} == {"feat/a", "feat/b"} assert "shared.py" in payload["conflict"]["paths"] # --------------------------------------------------------------------------- # Phase 4 — clean N-way merge # --------------------------------------------------------------------------- def test_octopus_merge_three_disjoint_branches_succeeds(disjoint_branches_repo: pathlib.Path) -> None: repo = disjoint_branches_repo current = _tip(repo, "main") targets = [_tip(repo, "feat/a"), _tip(repo, "feat/b"), _tip(repo, "feat/c")] result = compute_octopus_merge(repo, current, targets) assert result.parent_commit_ids == [current] + targets assert "base.py" in result.manifest assert "a.py" in result.manifest assert "b.py" in result.manifest assert "c.py" in result.manifest def test_octopus_merge_four_way_succeeds(tmp_path: pathlib.Path) -> None: r = _run(tmp_path, "init") assert r.exit_code == 0, r.stderr _commit_file(tmp_path, "base.py", "base = 1\n", "base commit") names = ["feat/w", "feat/x", "feat/y", "feat/z"] for name in names: fname = name.split("/")[1] + ".py" _run(tmp_path, "branch", name) _run(tmp_path, "checkout", name) _commit_file(tmp_path, fname, f"{fname} = 1\n", f"add {fname}") _run(tmp_path, "checkout", "main") current = _tip(tmp_path, "main") targets = [_tip(tmp_path, n) for n in names] result = compute_octopus_merge(tmp_path, current, targets) assert result.parent_commit_ids == [current] + targets for name in names: fname = name.split("/")[1] + ".py" assert fname in result.manifest # --------------------------------------------------------------------------- # Safety: overlap detection refuses, names the conflicting pair # --------------------------------------------------------------------------- def test_octopus_merge_refuses_when_two_branches_touch_same_path(tmp_path: pathlib.Path) -> None: r = _run(tmp_path, "init") assert r.exit_code == 0, r.stderr _commit_file(tmp_path, "shared.py", "x = 1\n", "base commit") _run(tmp_path, "branch", "feat/a") _run(tmp_path, "checkout", "feat/a") _commit_file(tmp_path, "shared.py", "x = 2\n", "a changes shared.py") _run(tmp_path, "checkout", "main") _run(tmp_path, "branch", "feat/b") _run(tmp_path, "checkout", "feat/b") _commit_file(tmp_path, "shared.py", "x = 3\n", "b changes shared.py differently") _run(tmp_path, "checkout", "main") _run(tmp_path, "branch", "feat/c") _run(tmp_path, "checkout", "feat/c") _commit_file(tmp_path, "c.py", "c = 1\n", "c adds disjoint file") _run(tmp_path, "checkout", "main") current = _tip(tmp_path, "main") targets = [_tip(tmp_path, "feat/a"), _tip(tmp_path, "feat/b"), _tip(tmp_path, "feat/c")] with pytest.raises(OctopusConflictError) as exc_info: compute_octopus_merge( tmp_path, current, targets, display_names={targets[0]: "feat/a", targets[1]: "feat/b"}, ) err = exc_info.value assert {err.branch_a, err.branch_b} == {"feat/a", "feat/b"} assert "shared.py" in err.paths def test_octopus_merge_tolerates_convergent_changes(tmp_path: pathlib.Path) -> None: """Two branches independently making the IDENTICAL change to the same path is not a conflict -- matches ordinary two-way merge semantics (detect_conflicts only flags divergent results).""" r = _run(tmp_path, "init") assert r.exit_code == 0, r.stderr _commit_file(tmp_path, "shared.py", "x = 1\n", "base commit") _run(tmp_path, "branch", "feat/a") _run(tmp_path, "checkout", "feat/a") _commit_file(tmp_path, "shared.py", "x = 2\n", "a changes shared.py") _run(tmp_path, "checkout", "main") _run(tmp_path, "branch", "feat/b") _run(tmp_path, "checkout", "feat/b") _commit_file(tmp_path, "shared.py", "x = 2\n", "b makes the identical change") _run(tmp_path, "checkout", "main") current = _tip(tmp_path, "main") targets = [_tip(tmp_path, "feat/a"), _tip(tmp_path, "feat/b")] result = compute_octopus_merge(tmp_path, current, targets) assert result.manifest["shared.py"] is not None def test_octopus_merge_requires_at_least_two_targets(disjoint_branches_repo: pathlib.Path) -> None: repo = disjoint_branches_repo current = _tip(repo, "main") with pytest.raises(ValueError): compute_octopus_merge(repo, current, [_tip(repo, "feat/a")]) def test_octopus_merge_no_common_ancestor_raises(tmp_path: pathlib.Path) -> None: """Two completely independent root histories have no common ancestor.""" repo_a = tmp_path / "a" repo_a.mkdir() _run(repo_a, "init") _commit_file(repo_a, "a.py", "a = 1\n", "root a") repo_b = tmp_path / "b" repo_b.mkdir() _run(repo_b, "init") _commit_file(repo_b, "b.py", "b = 1\n", "root b") current = _tip(repo_a, "main") # Deliberately pass a commit ID that doesn't exist in repo_a's object # store to simulate "no shared history" without cross-repo plumbing -- # find_merge_base returns None when no common ancestor is reachable. fake_target = _tip(repo_b, "main") with pytest.raises((OctopusNoCommonAncestorError, ValueError)): compute_octopus_merge(repo_a, current, [fake_target, current]) # --------------------------------------------------------------------------- # muse read / commit_parent_ids: N-parent commit record integrity # --------------------------------------------------------------------------- def test_write_commit_with_three_parents_round_trips(tmp_path: pathlib.Path) -> None: """Direct schema test: a CommitRecord with parent_commit_ids=[3 ids] round-trips through to_dict/from_dict and commit_parent_ids correctly, independent of the merge CLI.""" import datetime as _dt from muse.core.commits import CommitRecord from muse.core.ids import hash_commit from muse.core.types import blob_id p1, p2, p3 = (blob_id(f"parent{i}".encode()) for i in range(3)) snap = blob_id(b"snap") now = _dt.datetime.now(tz=_dt.timezone.utc).isoformat() cid = hash_commit( parent_ids=[p1, p2, p3], snapshot_id=snap, message="octopus merge", committed_at_iso=now, author="gabriel", ) record = CommitRecord( commit_id=cid, branch="main", snapshot_id=snap, message="octopus merge", committed_at=_dt.datetime.fromisoformat(now), parent_commit_id=p1, parent2_commit_id=p2, parent_commit_ids=[p1, p2, p3], author="gabriel", ) d = record.to_dict() assert d["parent_commit_ids"] == [p1, p2, p3] restored = CommitRecord.from_dict(d) assert commit_parent_ids(restored) == [p1, p2, p3] def test_commit_parent_ids_falls_back_for_legacy_two_parent_commits() -> None: """Ordinary 2-parent commits (parent_commit_ids never populated) must resolve identically to before -- the additive-schema guarantee.""" import datetime as _dt from muse.core.commits import CommitRecord from muse.core.types import blob_id p1, p2 = blob_id(b"p1"), blob_id(b"p2") record = CommitRecord( commit_id=blob_id(b"c"), branch="main", snapshot_id=blob_id(b"s"), message="ordinary merge", committed_at=_dt.datetime.now(tz=_dt.timezone.utc), parent_commit_id=p1, parent2_commit_id=p2, ) assert commit_parent_ids(record) == [p1, p2] def test_commit_parent_ids_single_parent_unaffected() -> None: import datetime as _dt from muse.core.commits import CommitRecord from muse.core.types import blob_id p1 = blob_id(b"p1") record = CommitRecord( commit_id=blob_id(b"c"), branch="main", snapshot_id=blob_id(b"s"), message="ordinary commit", committed_at=_dt.datetime.now(tz=_dt.timezone.utc), parent_commit_id=p1, ) assert commit_parent_ids(record) == [p1]