test_merge_matrix.py
file-level
1
files
1
commits
0
hotspots
0
π§ dead
0
π₯ blast risk
| 1 | """muse#29 β comprehensive merge strategy TDD: strategy x history x conflict level. |
| 2 | |
| 3 | Ground-truth suite for "Muse shines at merges": exercises the real |
| 4 | ``--strategy`` / ``--history`` matrix (correcting the ticket's own stale |
| 5 | names β the three-way conflict-detecting strategy is called ``recursive``, |
| 6 | not ``state_merge``) with deliberate conflicts engineered simultaneously at |
| 7 | all three levels Muse tracks: file, directory, and symbol. Also covers the |
| 8 | octopus (N-way) merge path, which the ticket's original strategy matrix |
| 9 | predates and does not mention at all. |
| 10 | |
| 11 | Real strategy matrix (``muse merge --help``) |
| 12 | --------------------------------------------- |
| 13 | ``--strategy``: recursive (default, three-way + conflict detection), |
| 14 | overlay, snapshot, replay, ours, theirs. |
| 15 | ``--history``: merge, squash, rebase. |
| 16 | |
| 17 | This file's Phase 2 matrix exercises the two strategies whose behaviour |
| 18 | differs meaningfully in the presence of real conflicts β ``recursive`` |
| 19 | (surfaces conflicts for resolution) and ``overlay`` (auto-resolves, theirs |
| 20 | wins) β against all three ``--history`` modes, six cells total. The other |
| 21 | four strategies (snapshot, replay, ours, theirs) and the full |
| 22 | untouched/convergent/divergent/add-add/delete-modify scenario grid are |
| 23 | already covered per-strategy by ``tests/test_phase3_strategy_matrix.py`` |
| 24 | (SM_01-24) β not duplicated here. |
| 25 | |
| 26 | Octopus merge (muse#105) is a structurally different, separate code path: |
| 27 | N-way (2+ branches), ignores ``--strategy``/``--history``/``--on-conflict`` |
| 28 | entirely, refuses outright on any path collision (no resolution, matching |
| 29 | git's own octopus behaviour). Covered here as its own section per the |
| 30 | ticket's explicit ask to include it; engine-level coverage already exists |
| 31 | in ``tests/test_octopus_merge.py``. |
| 32 | |
| 33 | Two real bugs were found and fixed while building this suite (TDD, per the |
| 34 | ticket's own acceptance criteria β "implementation fixed to make tests |
| 35 | pass"): |
| 36 | |
| 37 | 1. Completing a conflicted merge via ``muse resolve`` + ``muse commit`` |
| 38 | always produced a two-parent merge commit, even when ``--history |
| 39 | squash``/``--history rebase`` was requested on the original ``muse |
| 40 | merge`` call β the history mode was silently dropped the moment a |
| 41 | conflict forced completion through ``muse commit`` instead of ``muse |
| 42 | merge``'s own clean-path commit-writing. Fixed by threading ``history`` |
| 43 | through ``MERGE_STATE.json``. |
| 44 | |
| 45 | 2. (The ticket's own named regression.) If a resolved conflict's content |
| 46 | happened to exactly match HEAD's current snapshot (e.g. the user |
| 47 | resolved by keeping "ours"), ``muse commit`` took the "nothing to |
| 48 | commit, working tree clean" early return β exit 0, looks benign β but |
| 49 | never created the merge commit, never set the second parent, and never |
| 50 | cleared ``MERGE_STATE.json``. The repo was left permanently stuck: |
| 51 | every subsequent ``muse merge`` re-surfaced the identical |
| 52 | already-resolved conflict. Fixed by skipping that early return whenever |
| 53 | a merge is actually being completed, regardless of ``--allow-empty``. |
| 54 | """ |
| 55 | from __future__ import annotations |
| 56 | |
| 57 | import os |
| 58 | import pathlib |
| 59 | |
| 60 | import pytest |
| 61 | |
| 62 | from tests.cli_test_helper import CliRunner, InvokeResult |
| 63 | |
| 64 | runner = CliRunner() |
| 65 | |
| 66 | |
| 67 | # --------------------------------------------------------------------------- |
| 68 | # Phase 1 β Fixtures and helpers |
| 69 | # --------------------------------------------------------------------------- |
| 70 | |
| 71 | |
| 72 | def _run(repo: pathlib.Path, *args: str) -> InvokeResult: |
| 73 | return runner.invoke(None, list(args), cwd=repo) |
| 74 | |
| 75 | |
| 76 | def _run_ok(repo: pathlib.Path, *args: str) -> InvokeResult: |
| 77 | r = _run(repo, *args) |
| 78 | assert r.exit_code == 0, f"muse {' '.join(args)} failed: {r.stdout}\n{r.stderr}" |
| 79 | return r |
| 80 | |
| 81 | |
| 82 | def _branch_from(repo: pathlib.Path, base: str, name: str) -> None: |
| 83 | _run_ok(repo, "checkout", base) |
| 84 | _run_ok(repo, "branch", name) |
| 85 | _run_ok(repo, "checkout", name) |
| 86 | |
| 87 | |
| 88 | def make_conflict_repo(tmp_path: pathlib.Path) -> pathlib.Path: |
| 89 | """Build a repo with ours (main) / theirs (feat) branches carrying |
| 90 | deliberate, simultaneous conflicts at all three levels Muse tracks. |
| 91 | |
| 92 | Symbol-level (the only level that actually conflicts): |
| 93 | calc.py::add β main sets it to "+1", feat sets it to "+100". |
| 94 | |
| 95 | File-level (non-overlapping regions of the same file β must auto-merge |
| 96 | cleanly, no conflict, both sides' edits present): |
| 97 | shared.py β main edits alpha(), feat edits beta(). |
| 98 | |
| 99 | Directory-level (additions + a deletion in the same directory β must |
| 100 | auto-merge cleanly, no conflict, every addition present and the |
| 101 | deletion applied): |
| 102 | dir_a/ β main adds dir_a/main_new.py and deletes dir_a/old.py; |
| 103 | feat adds dir_a/feat_new.py; dir_a/keep.py is untouched. |
| 104 | """ |
| 105 | repo = tmp_path |
| 106 | r = _run(repo, "init") |
| 107 | assert r.exit_code == 0, r.stderr |
| 108 | |
| 109 | (repo / "calc.py").write_text("def add(a, b):\n return a + b\n") |
| 110 | (repo / "shared.py").write_text( |
| 111 | "def alpha():\n return 'base alpha'\n\n\ndef beta():\n return 'base beta'\n" |
| 112 | ) |
| 113 | (repo / "dir_a").mkdir() |
| 114 | (repo / "dir_a" / "keep.py").write_text("keep = True\n") |
| 115 | (repo / "dir_a" / "old.py").write_text("old = True\n") |
| 116 | _run_ok(repo, "code", "add", ".") |
| 117 | _run_ok(repo, "commit", "-m", "base") |
| 118 | |
| 119 | _branch_from(repo, "main", "feat") |
| 120 | (repo / "calc.py").write_text("def add(a, b):\n return a + b + 100\n") |
| 121 | (repo / "shared.py").write_text( |
| 122 | "def alpha():\n return 'base alpha'\n\n\ndef beta():\n return 'feat beta'\n" |
| 123 | ) |
| 124 | (repo / "dir_a" / "feat_new.py").write_text("feat_new = True\n") |
| 125 | _run_ok(repo, "code", "add", ".") |
| 126 | _run_ok(repo, "commit", "-m", "feat changes") |
| 127 | |
| 128 | _run_ok(repo, "checkout", "main") |
| 129 | (repo / "calc.py").write_text("def add(a, b):\n return a + b + 1\n") |
| 130 | (repo / "shared.py").write_text( |
| 131 | "def alpha():\n return 'main alpha'\n\n\ndef beta():\n return 'base beta'\n" |
| 132 | ) |
| 133 | (repo / "dir_a" / "main_new.py").write_text("main_new = True\n") |
| 134 | (repo / "dir_a" / "old.py").unlink() |
| 135 | _run_ok(repo, "code", "add", ".") |
| 136 | _run_ok(repo, "code", "add", "-u") |
| 137 | _run_ok(repo, "commit", "-m", "main changes") |
| 138 | |
| 139 | return repo |
| 140 | |
| 141 | |
| 142 | def assert_no_work_lost(repo: pathlib.Path) -> None: |
| 143 | """Verify every non-conflicting change from both sides survived the merge. |
| 144 | |
| 145 | Covers the file-level and directory-level assertions from the ticket's |
| 146 | conflict-level matrix: both sides' independent edits to shared.py are |
| 147 | present, every directory addition is present, and the directory |
| 148 | deletion was applied. |
| 149 | """ |
| 150 | shared = (repo / "shared.py").read_text() |
| 151 | assert "main alpha" in shared, "main's alpha() edit must survive the merge" |
| 152 | assert "feat beta" in shared, "feat's beta() edit must survive the merge" |
| 153 | |
| 154 | assert (repo / "dir_a" / "keep.py").exists(), "untouched dir_a/keep.py must survive" |
| 155 | assert (repo / "dir_a" / "main_new.py").exists(), "main's dir_a addition must survive" |
| 156 | assert (repo / "dir_a" / "feat_new.py").exists(), "feat's dir_a addition must survive" |
| 157 | assert not (repo / "dir_a" / "old.py").exists(), "main's dir_a deletion must be applied" |
| 158 | |
| 159 | |
| 160 | def assert_symbol_conflict_listed(repo: pathlib.Path, expected_path: str = "calc.py::add") -> None: |
| 161 | """Verify ``muse conflicts --json`` lists the expected symbol-level conflict.""" |
| 162 | import json |
| 163 | r = _run_ok(repo, "conflicts", "--json") |
| 164 | data = json.loads(r.output) |
| 165 | paths = [c["path"] for c in data["conflicts"]] |
| 166 | assert expected_path in paths, f"expected {expected_path!r} in conflicts, got {paths}" |
| 167 | |
| 168 | |
| 169 | def assert_merge_history(repo: pathlib.Path, commit_id: str, *, history: str) -> None: |
| 170 | """Verify the commit's parent structure matches the ``--history`` mode. |
| 171 | |
| 172 | ``"merge"`` β two parents (``parent2_commit_id`` set). |
| 173 | ``"squash"``/``"rebase"`` β single parent (``parent2_commit_id`` is None). |
| 174 | """ |
| 175 | from muse.core.commits import read_commit |
| 176 | rec = read_commit(repo, commit_id) |
| 177 | assert rec is not None, f"commit {commit_id} not found" |
| 178 | if history == "merge": |
| 179 | assert rec.parent2_commit_id is not None, ( |
| 180 | f"--history merge must produce a two-parent commit, got {rec!r}" |
| 181 | ) |
| 182 | else: |
| 183 | assert rec.parent2_commit_id is None, ( |
| 184 | f"--history {history} must produce a single-parent commit, got {rec!r}" |
| 185 | ) |
| 186 | |
| 187 | |
| 188 | def assert_verify_clean(repo: pathlib.Path) -> None: |
| 189 | import json |
| 190 | r = _run_ok(repo, "verify", "--json") |
| 191 | data = json.loads(r.output) |
| 192 | assert data["all_ok"] is True, f"muse verify reported problems: {data}" |
| 193 | |
| 194 | |
| 195 | # --------------------------------------------------------------------------- |
| 196 | # Phase 2 β Strategy x History matrix (6 cells), all 3 conflict levels at once |
| 197 | # --------------------------------------------------------------------------- |
| 198 | |
| 199 | |
| 200 | class TestRecursiveStrategyMatrix: |
| 201 | """recursive (real three-way merge with conflict detection) x history. |
| 202 | |
| 203 | recursive surfaces the symbol-level conflict for manual resolution in |
| 204 | all three history modes β it must never silently auto-resolve. |
| 205 | """ |
| 206 | |
| 207 | @pytest.mark.parametrize("history", ["merge", "squash", "rebase"]) |
| 208 | def test_recursive_matrix_cell(self, tmp_path: pathlib.Path, history: str) -> None: |
| 209 | import json |
| 210 | repo = make_conflict_repo(tmp_path) |
| 211 | |
| 212 | r = _run(repo, "merge", "feat", "--strategy", "recursive", "--history", history, "--json") |
| 213 | assert r.exit_code == 1, r.output |
| 214 | data = json.loads(r.output) |
| 215 | assert data["status"] == "conflict" |
| 216 | assert data["conflicts"] == ["calc.py::add"], ( |
| 217 | "recursive must surface exactly the symbol-level conflict β " |
| 218 | "the non-overlapping file/directory changes must auto-merge cleanly" |
| 219 | ) |
| 220 | |
| 221 | assert_symbol_conflict_listed(repo) |
| 222 | |
| 223 | # Resolve the one real conflict manually, to a value distinct from |
| 224 | # both sides, and confirm the resolve stages the merged body (not |
| 225 | # conflict-marker text). |
| 226 | (repo / "calc.py").write_text("def add(a, b):\n return a + b + 101\n") |
| 227 | r = _run_ok(repo, "resolve", "calc.py::add", "--json") |
| 228 | resolve_data = json.loads(r.output) |
| 229 | assert resolve_data["ready_to_commit"] is True |
| 230 | |
| 231 | r = _run_ok(repo, "diff", "--staged") |
| 232 | assert "<<<<<<<" not in r.output, "staged diff must not contain conflict markers" |
| 233 | |
| 234 | r = _run_ok(repo, "commit", "-m", "resolve", "--json") |
| 235 | commit_data = json.loads(r.output) |
| 236 | commit_id = commit_data["commit_id"] |
| 237 | assert commit_id is not None |
| 238 | |
| 239 | assert_no_work_lost(repo) |
| 240 | assert "a + b + 101" in (repo / "calc.py").read_text(), ( |
| 241 | "the manually-resolved symbol body must be what's committed" |
| 242 | ) |
| 243 | assert_merge_history(repo, commit_id, history=history) |
| 244 | assert_verify_clean(repo) |
| 245 | |
| 246 | |
| 247 | class TestOverlayStrategyMatrix: |
| 248 | """overlay (theirs wins on conflict) x history β never pauses for resolution.""" |
| 249 | |
| 250 | @pytest.mark.parametrize("history", ["merge", "squash", "rebase"]) |
| 251 | def test_overlay_matrix_cell(self, tmp_path: pathlib.Path, history: str) -> None: |
| 252 | import json |
| 253 | repo = make_conflict_repo(tmp_path) |
| 254 | |
| 255 | r = _run_ok(repo, "merge", "feat", "--strategy", "overlay", "--history", history, "--json") |
| 256 | data = json.loads(r.output) |
| 257 | assert data["status"] == "merged", f"overlay must auto-resolve, got {data}" |
| 258 | commit_id = data["commit_id"] |
| 259 | assert commit_id is not None |
| 260 | |
| 261 | # overlay is theirs-wins at the whole-file grain (snapshot overlay, |
| 262 | # no base) β calc.py (which theirs touched) ends up as theirs' |
| 263 | # content entirely, not a symbol-merged value. |
| 264 | assert "a + b + 100" in (repo / "calc.py").read_text() |
| 265 | |
| 266 | assert_merge_history(repo, commit_id, history=history) |
| 267 | assert_verify_clean(repo) |
| 268 | |
| 269 | |
| 270 | # --------------------------------------------------------------------------- |
| 271 | # Octopus (N-way) merge β separate code path, no --strategy/--history |
| 272 | # --------------------------------------------------------------------------- |
| 273 | |
| 274 | |
| 275 | class TestOctopusMerge: |
| 276 | """muse#105 β N-way merge. Ignores --strategy/--history entirely; |
| 277 | refuses outright on any path collision (no resolution, like git).""" |
| 278 | |
| 279 | def test_octopus_clean_three_way_no_work_lost(self, tmp_path: pathlib.Path) -> None: |
| 280 | import json |
| 281 | repo = tmp_path |
| 282 | r = _run(repo, "init") |
| 283 | assert r.exit_code == 0, r.stderr |
| 284 | (repo / "base.py").write_text("base = 1\n") |
| 285 | _run_ok(repo, "code", "add", ".") |
| 286 | _run_ok(repo, "commit", "-m", "base") |
| 287 | |
| 288 | for name, fname in (("feat/a", "a.py"), ("feat/b", "b.py"), ("feat/c", "c.py")): |
| 289 | _branch_from(repo, "main", name) |
| 290 | (repo / fname).write_text(f"{fname} = True\n") |
| 291 | _run_ok(repo, "code", "add", fname) |
| 292 | _run_ok(repo, "commit", "-m", f"add {fname}") |
| 293 | |
| 294 | _run_ok(repo, "checkout", "main") |
| 295 | r = _run_ok(repo, "merge", "feat/a", "feat/b", "feat/c", "--json") |
| 296 | data = json.loads(r.output) |
| 297 | assert data["status"] == "merged" |
| 298 | assert data["commit_id"] is not None |
| 299 | |
| 300 | for fname in ("a.py", "b.py", "c.py"): |
| 301 | assert (repo / fname).exists(), f"{fname} from octopus branch must survive" |
| 302 | |
| 303 | from muse.core.commits import read_commit |
| 304 | rec = read_commit(repo, data["commit_id"]) |
| 305 | assert rec is not None |
| 306 | assert len(rec.parent_commit_ids) == 4, "octopus commit must have 4 parents (main + 3 branches)" |
| 307 | |
| 308 | assert_verify_clean(repo) |
| 309 | |
| 310 | def test_octopus_refuses_on_path_collision_no_partial_commit( |
| 311 | self, tmp_path: pathlib.Path |
| 312 | ) -> None: |
| 313 | import json |
| 314 | repo = tmp_path |
| 315 | r = _run(repo, "init") |
| 316 | assert r.exit_code == 0, r.stderr |
| 317 | (repo / "base.py").write_text("base = 1\n") |
| 318 | _run_ok(repo, "code", "add", ".") |
| 319 | _run_ok(repo, "commit", "-m", "base") |
| 320 | |
| 321 | _branch_from(repo, "main", "feat/a") |
| 322 | (repo / "shared.py").write_text("shared = 'a'\n") |
| 323 | _run_ok(repo, "code", "add", "shared.py") |
| 324 | _run_ok(repo, "commit", "-m", "a touches shared") |
| 325 | |
| 326 | _run_ok(repo, "checkout", "main") |
| 327 | _branch_from(repo, "main", "feat/b") |
| 328 | (repo / "shared.py").write_text("shared = 'b'\n") |
| 329 | _run_ok(repo, "code", "add", "shared.py") |
| 330 | _run_ok(repo, "commit", "-m", "b touches shared") |
| 331 | |
| 332 | head_before = (repo / ".muse" / "refs" / "heads" / "main").read_text().strip() |
| 333 | |
| 334 | _run_ok(repo, "checkout", "main") |
| 335 | r = _run(repo, "merge", "feat/a", "feat/b", "--json") |
| 336 | assert r.exit_code != 0 |
| 337 | data = json.loads(r.output) |
| 338 | assert data["status"] == "conflict" |
| 339 | assert "shared.py" in data["conflict"]["paths"] |
| 340 | |
| 341 | # No partial commit, no invented resolution β main's ref must be |
| 342 | # byte-for-byte unmoved, matching git's own octopus behaviour. |
| 343 | head_after = (repo / ".muse" / "refs" / "heads" / "main").read_text().strip() |
| 344 | assert head_after == head_before, "octopus refusal must not move the branch ref at all" |
| 345 | assert_verify_clean(repo) |
| 346 | |
| 347 | |
| 348 | # --------------------------------------------------------------------------- |
| 349 | # Phase 3 β Regression tests for known failure modes |
| 350 | # --------------------------------------------------------------------------- |
| 351 | |
| 352 | |
| 353 | class TestAllowEmptySnapshotRegression: |
| 354 | """muse#29's named regression: resolving a conflict to content identical |
| 355 | to HEAD must still finalize the merge β not silently no-op.""" |
| 356 | |
| 357 | def test_resolve_matching_ours_still_completes_merge_without_allow_empty( |
| 358 | self, tmp_path: pathlib.Path |
| 359 | ) -> None: |
| 360 | import json |
| 361 | repo = make_conflict_repo(tmp_path) |
| 362 | _run(repo, "merge", "feat", "--strategy", "recursive", "--json") |
| 363 | |
| 364 | # Resolve by keeping "ours" exactly β content ends up identical to |
| 365 | # what HEAD already has, except for the dir_a/shared.py changes |
| 366 | # that already auto-merged cleanly. The symbol itself matches ours. |
| 367 | (repo / "calc.py").write_text("def add(a, b):\n return a + b + 1\n") |
| 368 | _run_ok(repo, "resolve", "calc.py::add", "--json") |
| 369 | |
| 370 | r = _run_ok(repo, "commit", "-m", "resolve", "--json") |
| 371 | data = json.loads(r.output) |
| 372 | assert data.get("clean") is not True, ( |
| 373 | "must not take the deceptive 'nothing to commit, working tree " |
| 374 | "clean' early return while a merge is being completed" |
| 375 | ) |
| 376 | assert data["commit_id"] is not None, "merge commit must actually be written" |
| 377 | assert data["parent2_commit_id"] is not None, ( |
| 378 | "the second parent must be recorded even when resolved content matches ours" |
| 379 | ) |
| 380 | assert not (repo / ".muse" / "MERGE_STATE.json").exists(), ( |
| 381 | "MERGE_STATE.json must be cleared once the merge is finalized" |
| 382 | ) |
| 383 | |
| 384 | # The repo must not be stuck in a dangling merge-in-progress state. |
| 385 | r = _run_ok(repo, "status", "--json") |
| 386 | status_data = json.loads(r.output) |
| 387 | assert status_data["merge_in_progress"] is False, ( |
| 388 | f"repo must not be stuck mid-merge after a successful finalize: {status_data}" |
| 389 | ) |
| 390 | |
| 391 | |
| 392 | class TestDryRunNoMutation: |
| 393 | """muse merge --dry-run must not mutate any state.""" |
| 394 | |
| 395 | def test_dry_run_mutates_nothing(self, tmp_path: pathlib.Path) -> None: |
| 396 | repo = make_conflict_repo(tmp_path) |
| 397 | head_before = (repo / ".muse" / "refs" / "heads" / "main").read_text() |
| 398 | calc_before = (repo / "calc.py").read_text() |
| 399 | |
| 400 | # Dry-run on a genuinely conflicting merge correctly reports exit 1 |
| 401 | # (a predicted conflict) β it must still mutate nothing. |
| 402 | r = _run(repo, "merge", "feat", "--strategy", "recursive", "--dry-run", "--json") |
| 403 | assert r.exit_code == 1, r.output |
| 404 | import json |
| 405 | data = json.loads(r.output) |
| 406 | assert data["status"] == "conflict" |
| 407 | assert data["dry_run"] is True |
| 408 | |
| 409 | assert not (repo / ".muse" / "MERGE_STATE.json").exists(), ( |
| 410 | "--dry-run must not write MERGE_STATE.json" |
| 411 | ) |
| 412 | head_after = (repo / ".muse" / "refs" / "heads" / "main").read_text() |
| 413 | assert head_after == head_before, "--dry-run must not move the branch ref" |
| 414 | calc_after = (repo / "calc.py").read_text() |
| 415 | assert calc_after == calc_before, "--dry-run must not touch the working tree" |
| 416 | |
| 417 | r = _run_ok(repo, "reflog", "--json") |
| 418 | reflog_data = json.loads(r.output) |
| 419 | operations = [e.get("operation", "") for e in reflog_data.get("entries", [])] |
| 420 | assert not any("merge" in op.lower() for op in operations), ( |
| 421 | "--dry-run must not write a reflog entry" |
| 422 | ) |
| 423 | |
| 424 | |
| 425 | class TestHarmonyLearnsFromResolution: |
| 426 | """Harmony autoupdate (on by default) must learn from a resolved conflict.""" |
| 427 | |
| 428 | def test_harmony_records_resolution_after_manual_resolve( |
| 429 | self, tmp_path: pathlib.Path |
| 430 | ) -> None: |
| 431 | import json |
| 432 | repo = make_conflict_repo(tmp_path) |
| 433 | _run(repo, "merge", "feat", "--strategy", "recursive", "--json") |
| 434 | |
| 435 | (repo / "calc.py").write_text("def add(a, b):\n return a + b + 101\n") |
| 436 | _run_ok(repo, "resolve", "calc.py::add", "--json") |
| 437 | _run_ok(repo, "commit", "-m", "resolve", "--json") |
| 438 | |
| 439 | r = _run_ok(repo, "harmony", "list", "--json") |
| 440 | patterns = json.loads(r.output)["patterns"] |
| 441 | assert len(patterns) == 1 |
| 442 | assert patterns[0]["resolution_count"] == 1 |
| 443 | |
| 444 | |
| 445 | class TestHistoryModeSurvivesConflictCompletion: |
| 446 | """muse#29 fix: --history squash/rebase must not be silently dropped |
| 447 | when the merge pauses for a real conflict.""" |
| 448 | |
| 449 | @pytest.mark.parametrize("history", ["squash", "rebase"]) |
| 450 | def test_history_mode_honoured_after_manual_resolve( |
| 451 | self, tmp_path: pathlib.Path, history: str |
| 452 | ) -> None: |
| 453 | import json |
| 454 | repo = make_conflict_repo(tmp_path) |
| 455 | _run(repo, "merge", "feat", "--strategy", "recursive", "--history", history, "--json") |
| 456 | |
| 457 | (repo / "calc.py").write_text("def add(a, b):\n return a + b + 101\n") |
| 458 | _run_ok(repo, "resolve", "calc.py::add", "--json") |
| 459 | r = _run_ok(repo, "commit", "-m", "resolve", "--json") |
| 460 | data = json.loads(r.output) |
| 461 | |
| 462 | assert data["parent2_commit_id"] is None, ( |
| 463 | f"--history {history} must produce a single-parent commit even " |
| 464 | f"after a conflict pause, got parent2={data['parent2_commit_id']!r}" |
| 465 | ) |