test_octopus_merge.py
python
sha256:7cc8abb049561fa05083f7bdf6b66bd5cf0466506a38600bc92ac3e6f4cf7b3a
fix: muse mist push crashed unconditionally with AttributeE…
Sonnet 5
patch
1 hour ago
| 1 | """muse#105 — N-way (octopus) merge: core engine tests. |
| 2 | |
| 3 | Git's octopus strategy merges 3+ branches into one commit when their changes |
| 4 | don't overlap. Muse had no equivalent: every existing strategy is two-way, |
| 5 | and `muse merge`'s CLI only accepts one branch argument. |
| 6 | |
| 7 | Covers the core engine (`muse.core.octopus_merge`) against a real repo built |
| 8 | via the CLI (branches, commits) — not mocked CommitRecord/SnapshotRecord |
| 9 | objects, since the point is to prove this works against actual on-disk |
| 10 | history, not an idealized model of it. |
| 11 | """ |
| 12 | from __future__ import annotations |
| 13 | |
| 14 | import os |
| 15 | import pathlib |
| 16 | |
| 17 | import pytest |
| 18 | |
| 19 | from tests.cli_test_helper import CliRunner, InvokeResult |
| 20 | from muse.core.commits import commit_parent_ids, read_commit |
| 21 | from muse.core.octopus_merge import ( |
| 22 | OctopusConflictError, |
| 23 | OctopusNoCommonAncestorError, |
| 24 | compute_octopus_merge, |
| 25 | ) |
| 26 | from muse.core.refs import get_head_commit_id |
| 27 | |
| 28 | runner = CliRunner() |
| 29 | |
| 30 | |
| 31 | def _run(repo: pathlib.Path, *args: str) -> InvokeResult: |
| 32 | saved = os.getcwd() |
| 33 | try: |
| 34 | os.chdir(repo) |
| 35 | return runner.invoke(None, list(args)) |
| 36 | finally: |
| 37 | os.chdir(saved) |
| 38 | |
| 39 | |
| 40 | def _commit_file(repo: pathlib.Path, path: str, content: str, message: str) -> None: |
| 41 | (repo / path).write_text(content) |
| 42 | r = _run(repo, "code", "add", path) |
| 43 | assert r.exit_code == 0, r.stderr |
| 44 | r = _run(repo, "commit", "-m", message) |
| 45 | assert r.exit_code == 0, r.stderr |
| 46 | |
| 47 | |
| 48 | @pytest.fixture() |
| 49 | def disjoint_branches_repo(tmp_path: pathlib.Path) -> pathlib.Path: |
| 50 | """main with a base commit, then three branches each touching a |
| 51 | different, disjoint file.""" |
| 52 | r = _run(tmp_path, "init") |
| 53 | assert r.exit_code == 0, r.stderr |
| 54 | _commit_file(tmp_path, "base.py", "base = 1\n", "base commit") |
| 55 | |
| 56 | for name, fname in (("feat/a", "a.py"), ("feat/b", "b.py"), ("feat/c", "c.py")): |
| 57 | r = _run(tmp_path, "branch", name) |
| 58 | assert r.exit_code == 0, r.stderr |
| 59 | r = _run(tmp_path, "checkout", name) |
| 60 | assert r.exit_code == 0, r.stderr |
| 61 | _commit_file(tmp_path, fname, f"{fname} = 1\n", f"add {fname}") |
| 62 | r = _run(tmp_path, "checkout", "main") |
| 63 | assert r.exit_code == 0, r.stderr |
| 64 | |
| 65 | return tmp_path |
| 66 | |
| 67 | |
| 68 | def _tip(repo: pathlib.Path, branch: str) -> str: |
| 69 | cid = get_head_commit_id(repo, branch) |
| 70 | assert cid is not None |
| 71 | return cid |
| 72 | |
| 73 | |
| 74 | # --------------------------------------------------------------------------- |
| 75 | # Phase 4 — CLI accepts N branches (Phase 1 pinned this as a gap: argparse's |
| 76 | # old nargs='?' rejected more than one positional; now nargs='*' routes 2+ |
| 77 | # branches to the octopus path) |
| 78 | # --------------------------------------------------------------------------- |
| 79 | |
| 80 | def test_muse_merge_cli_accepts_multiple_branches(disjoint_branches_repo: pathlib.Path) -> None: |
| 81 | r = _run(disjoint_branches_repo, "merge", "feat/a", "feat/b", "feat/c") |
| 82 | assert r.exit_code == 0, r.stderr |
| 83 | assert "Octopus merge" in r.output |
| 84 | |
| 85 | commit = read_commit(disjoint_branches_repo, get_head_commit_id(disjoint_branches_repo, "main")) |
| 86 | assert commit is not None |
| 87 | assert len(commit_parent_ids(commit)) == 4 |
| 88 | |
| 89 | |
| 90 | def test_muse_merge_cli_json_reports_conflict(tmp_path: pathlib.Path) -> None: |
| 91 | r = _run(tmp_path, "init") |
| 92 | assert r.exit_code == 0, r.stderr |
| 93 | _commit_file(tmp_path, "shared.py", "x = 1\n", "base") |
| 94 | _run(tmp_path, "branch", "feat/a") |
| 95 | _run(tmp_path, "checkout", "feat/a") |
| 96 | _commit_file(tmp_path, "shared.py", "x = 2\n", "a changes shared") |
| 97 | _run(tmp_path, "checkout", "main") |
| 98 | _run(tmp_path, "branch", "feat/b") |
| 99 | _run(tmp_path, "checkout", "feat/b") |
| 100 | _commit_file(tmp_path, "shared.py", "x = 3\n", "b changes shared differently") |
| 101 | _run(tmp_path, "checkout", "main") |
| 102 | |
| 103 | r = _run(tmp_path, "merge", "feat/a", "feat/b", "--json") |
| 104 | assert r.exit_code != 0 |
| 105 | import json as _json |
| 106 | payload = _json.loads(r.output) |
| 107 | assert payload["status"] == "conflict" |
| 108 | assert {payload["conflict"]["branch_a"], payload["conflict"]["branch_b"]} == {"feat/a", "feat/b"} |
| 109 | assert "shared.py" in payload["conflict"]["paths"] |
| 110 | |
| 111 | |
| 112 | # --------------------------------------------------------------------------- |
| 113 | # Phase 4 — clean N-way merge |
| 114 | # --------------------------------------------------------------------------- |
| 115 | |
| 116 | def test_octopus_merge_three_disjoint_branches_succeeds(disjoint_branches_repo: pathlib.Path) -> None: |
| 117 | repo = disjoint_branches_repo |
| 118 | current = _tip(repo, "main") |
| 119 | targets = [_tip(repo, "feat/a"), _tip(repo, "feat/b"), _tip(repo, "feat/c")] |
| 120 | |
| 121 | result = compute_octopus_merge(repo, current, targets) |
| 122 | |
| 123 | assert result.parent_commit_ids == [current] + targets |
| 124 | assert "base.py" in result.manifest |
| 125 | assert "a.py" in result.manifest |
| 126 | assert "b.py" in result.manifest |
| 127 | assert "c.py" in result.manifest |
| 128 | |
| 129 | |
| 130 | def test_octopus_merge_four_way_succeeds(tmp_path: pathlib.Path) -> None: |
| 131 | r = _run(tmp_path, "init") |
| 132 | assert r.exit_code == 0, r.stderr |
| 133 | _commit_file(tmp_path, "base.py", "base = 1\n", "base commit") |
| 134 | |
| 135 | names = ["feat/w", "feat/x", "feat/y", "feat/z"] |
| 136 | for name in names: |
| 137 | fname = name.split("/")[1] + ".py" |
| 138 | _run(tmp_path, "branch", name) |
| 139 | _run(tmp_path, "checkout", name) |
| 140 | _commit_file(tmp_path, fname, f"{fname} = 1\n", f"add {fname}") |
| 141 | _run(tmp_path, "checkout", "main") |
| 142 | |
| 143 | current = _tip(tmp_path, "main") |
| 144 | targets = [_tip(tmp_path, n) for n in names] |
| 145 | result = compute_octopus_merge(tmp_path, current, targets) |
| 146 | |
| 147 | assert result.parent_commit_ids == [current] + targets |
| 148 | for name in names: |
| 149 | fname = name.split("/")[1] + ".py" |
| 150 | assert fname in result.manifest |
| 151 | |
| 152 | |
| 153 | # --------------------------------------------------------------------------- |
| 154 | # Safety: overlap detection refuses, names the conflicting pair |
| 155 | # --------------------------------------------------------------------------- |
| 156 | |
| 157 | def test_octopus_merge_refuses_when_two_branches_touch_same_path(tmp_path: pathlib.Path) -> None: |
| 158 | r = _run(tmp_path, "init") |
| 159 | assert r.exit_code == 0, r.stderr |
| 160 | _commit_file(tmp_path, "shared.py", "x = 1\n", "base commit") |
| 161 | |
| 162 | _run(tmp_path, "branch", "feat/a") |
| 163 | _run(tmp_path, "checkout", "feat/a") |
| 164 | _commit_file(tmp_path, "shared.py", "x = 2\n", "a changes shared.py") |
| 165 | _run(tmp_path, "checkout", "main") |
| 166 | |
| 167 | _run(tmp_path, "branch", "feat/b") |
| 168 | _run(tmp_path, "checkout", "feat/b") |
| 169 | _commit_file(tmp_path, "shared.py", "x = 3\n", "b changes shared.py differently") |
| 170 | _run(tmp_path, "checkout", "main") |
| 171 | |
| 172 | _run(tmp_path, "branch", "feat/c") |
| 173 | _run(tmp_path, "checkout", "feat/c") |
| 174 | _commit_file(tmp_path, "c.py", "c = 1\n", "c adds disjoint file") |
| 175 | _run(tmp_path, "checkout", "main") |
| 176 | |
| 177 | current = _tip(tmp_path, "main") |
| 178 | targets = [_tip(tmp_path, "feat/a"), _tip(tmp_path, "feat/b"), _tip(tmp_path, "feat/c")] |
| 179 | |
| 180 | with pytest.raises(OctopusConflictError) as exc_info: |
| 181 | compute_octopus_merge( |
| 182 | tmp_path, current, targets, |
| 183 | display_names={targets[0]: "feat/a", targets[1]: "feat/b"}, |
| 184 | ) |
| 185 | err = exc_info.value |
| 186 | assert {err.branch_a, err.branch_b} == {"feat/a", "feat/b"} |
| 187 | assert "shared.py" in err.paths |
| 188 | |
| 189 | |
| 190 | def test_octopus_merge_tolerates_convergent_changes(tmp_path: pathlib.Path) -> None: |
| 191 | """Two branches independently making the IDENTICAL change to the same |
| 192 | path is not a conflict -- matches ordinary two-way merge semantics |
| 193 | (detect_conflicts only flags divergent results).""" |
| 194 | r = _run(tmp_path, "init") |
| 195 | assert r.exit_code == 0, r.stderr |
| 196 | _commit_file(tmp_path, "shared.py", "x = 1\n", "base commit") |
| 197 | |
| 198 | _run(tmp_path, "branch", "feat/a") |
| 199 | _run(tmp_path, "checkout", "feat/a") |
| 200 | _commit_file(tmp_path, "shared.py", "x = 2\n", "a changes shared.py") |
| 201 | _run(tmp_path, "checkout", "main") |
| 202 | |
| 203 | _run(tmp_path, "branch", "feat/b") |
| 204 | _run(tmp_path, "checkout", "feat/b") |
| 205 | _commit_file(tmp_path, "shared.py", "x = 2\n", "b makes the identical change") |
| 206 | _run(tmp_path, "checkout", "main") |
| 207 | |
| 208 | current = _tip(tmp_path, "main") |
| 209 | targets = [_tip(tmp_path, "feat/a"), _tip(tmp_path, "feat/b")] |
| 210 | |
| 211 | result = compute_octopus_merge(tmp_path, current, targets) |
| 212 | assert result.manifest["shared.py"] is not None |
| 213 | |
| 214 | |
| 215 | def test_octopus_merge_requires_at_least_two_targets(disjoint_branches_repo: pathlib.Path) -> None: |
| 216 | repo = disjoint_branches_repo |
| 217 | current = _tip(repo, "main") |
| 218 | with pytest.raises(ValueError): |
| 219 | compute_octopus_merge(repo, current, [_tip(repo, "feat/a")]) |
| 220 | |
| 221 | |
| 222 | def test_octopus_merge_no_common_ancestor_raises(tmp_path: pathlib.Path) -> None: |
| 223 | """Two completely independent root histories have no common ancestor.""" |
| 224 | repo_a = tmp_path / "a" |
| 225 | repo_a.mkdir() |
| 226 | _run(repo_a, "init") |
| 227 | _commit_file(repo_a, "a.py", "a = 1\n", "root a") |
| 228 | |
| 229 | repo_b = tmp_path / "b" |
| 230 | repo_b.mkdir() |
| 231 | _run(repo_b, "init") |
| 232 | _commit_file(repo_b, "b.py", "b = 1\n", "root b") |
| 233 | |
| 234 | current = _tip(repo_a, "main") |
| 235 | # Deliberately pass a commit ID that doesn't exist in repo_a's object |
| 236 | # store to simulate "no shared history" without cross-repo plumbing -- |
| 237 | # find_merge_base returns None when no common ancestor is reachable. |
| 238 | fake_target = _tip(repo_b, "main") |
| 239 | with pytest.raises((OctopusNoCommonAncestorError, ValueError)): |
| 240 | compute_octopus_merge(repo_a, current, [fake_target, current]) |
| 241 | |
| 242 | |
| 243 | # --------------------------------------------------------------------------- |
| 244 | # muse read / commit_parent_ids: N-parent commit record integrity |
| 245 | # --------------------------------------------------------------------------- |
| 246 | |
| 247 | def test_write_commit_with_three_parents_round_trips(tmp_path: pathlib.Path) -> None: |
| 248 | """Direct schema test: a CommitRecord with parent_commit_ids=[3 ids] |
| 249 | round-trips through to_dict/from_dict and commit_parent_ids correctly, |
| 250 | independent of the merge CLI.""" |
| 251 | import datetime as _dt |
| 252 | from muse.core.commits import CommitRecord |
| 253 | from muse.core.ids import hash_commit |
| 254 | from muse.core.types import blob_id |
| 255 | |
| 256 | p1, p2, p3 = (blob_id(f"parent{i}".encode()) for i in range(3)) |
| 257 | snap = blob_id(b"snap") |
| 258 | now = _dt.datetime.now(tz=_dt.timezone.utc).isoformat() |
| 259 | cid = hash_commit( |
| 260 | parent_ids=[p1, p2, p3], |
| 261 | snapshot_id=snap, |
| 262 | message="octopus merge", |
| 263 | committed_at_iso=now, |
| 264 | author="gabriel", |
| 265 | ) |
| 266 | record = CommitRecord( |
| 267 | commit_id=cid, |
| 268 | branch="main", |
| 269 | snapshot_id=snap, |
| 270 | message="octopus merge", |
| 271 | committed_at=_dt.datetime.fromisoformat(now), |
| 272 | parent_commit_id=p1, |
| 273 | parent2_commit_id=p2, |
| 274 | parent_commit_ids=[p1, p2, p3], |
| 275 | author="gabriel", |
| 276 | ) |
| 277 | d = record.to_dict() |
| 278 | assert d["parent_commit_ids"] == [p1, p2, p3] |
| 279 | restored = CommitRecord.from_dict(d) |
| 280 | assert commit_parent_ids(restored) == [p1, p2, p3] |
| 281 | |
| 282 | |
| 283 | def test_commit_parent_ids_falls_back_for_legacy_two_parent_commits() -> None: |
| 284 | """Ordinary 2-parent commits (parent_commit_ids never populated) must |
| 285 | resolve identically to before -- the additive-schema guarantee.""" |
| 286 | import datetime as _dt |
| 287 | from muse.core.commits import CommitRecord |
| 288 | from muse.core.types import blob_id |
| 289 | |
| 290 | p1, p2 = blob_id(b"p1"), blob_id(b"p2") |
| 291 | record = CommitRecord( |
| 292 | commit_id=blob_id(b"c"), |
| 293 | branch="main", |
| 294 | snapshot_id=blob_id(b"s"), |
| 295 | message="ordinary merge", |
| 296 | committed_at=_dt.datetime.now(tz=_dt.timezone.utc), |
| 297 | parent_commit_id=p1, |
| 298 | parent2_commit_id=p2, |
| 299 | ) |
| 300 | assert commit_parent_ids(record) == [p1, p2] |
| 301 | |
| 302 | |
| 303 | def test_commit_parent_ids_single_parent_unaffected() -> None: |
| 304 | import datetime as _dt |
| 305 | from muse.core.commits import CommitRecord |
| 306 | from muse.core.types import blob_id |
| 307 | |
| 308 | p1 = blob_id(b"p1") |
| 309 | record = CommitRecord( |
| 310 | commit_id=blob_id(b"c"), |
| 311 | branch="main", |
| 312 | snapshot_id=blob_id(b"s"), |
| 313 | message="ordinary commit", |
| 314 | committed_at=_dt.datetime.now(tz=_dt.timezone.utc), |
| 315 | parent_commit_id=p1, |
| 316 | ) |
| 317 | assert commit_parent_ids(record) == [p1] |
File History
1 commit
sha256:7cc8abb049561fa05083f7bdf6b66bd5cf0466506a38600bc92ac3e6f4cf7b3a
fix: muse mist push crashed unconditionally with AttributeE…
Sonnet 5
patch
1 hour ago