gabriel / muse public
test_octopus_merge.py python
317 lines 11.7 KB
Raw
sha256:e0f2b12aa5c1779d0fae81a602971c72a6e39c0b37db13b980ce00bc1f97bb31 chore: bump version to 0.2.1rc13 Sonnet 5 patch 3 minutes 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:e0f2b12aa5c1779d0fae81a602971c72a6e39c0b37db13b980ce00bc1f97bb31 chore: bump version to 0.2.1rc13 Sonnet 5 patch 3 minutes ago