test_harmony_rebase_loop.py
python
sha256:a7991bbc89b2af67cdd97580946aad7500cc737dd0c0d89088df831e198bdf84
fix: call Harmony inside muse rebase's per-commit replay lo…
Sonnet 5
patch
22 hours ago
| 1 | """muse#33 — call Harmony inside the ``muse rebase`` replay loop. |
| 2 | |
| 3 | Before this fix, ``muse rebase``'s real per-commit replay loop |
| 4 | (``_run_replay_loop`` / ``replay_one`` in ``muse/core/rebase.py``) never |
| 5 | consulted Harmony at all: a conflict always paused the rebase, even when |
| 6 | Harmony already held a saved resolution for the exact same conflict |
| 7 | pattern. And once a rebase conflict WAS resolved manually via |
| 8 | ``muse resolve`` + ``muse rebase --continue``, the resolution was never |
| 9 | recorded — ``--continue`` wrote the new commit directly, bypassing the |
| 10 | ``record_resolutions`` call that ``muse commit`` makes for an equivalent |
| 11 | ``muse merge`` conflict. ``MERGE_STATE.json`` was also left behind on disk |
| 12 | after a rebase completed, since nothing ever cleared it. |
| 13 | |
| 14 | This test drives the real CLI (not synthetic objects) through both halves |
| 15 | of the fix: |
| 16 | |
| 17 | 1. A rebase conflict, resolved manually, must be recorded by Harmony |
| 18 | using the replayed commit's OWN ours/theirs context (HR_01, HR_02, |
| 19 | RB_03) — not any squash-equivalent snapshot (RB_04) — and must clear |
| 20 | MERGE_STATE.json once finalised. |
| 21 | 2. The identical conflict pattern, replayed again in a fresh rebase, |
| 22 | must be auto-resolved by Harmony without pausing (RB_02). |
| 23 | """ |
| 24 | from __future__ import annotations |
| 25 | |
| 26 | import json |
| 27 | import os |
| 28 | import pathlib |
| 29 | |
| 30 | from tests.cli_test_helper import CliRunner, InvokeResult |
| 31 | |
| 32 | runner = CliRunner() |
| 33 | |
| 34 | |
| 35 | def _run(repo: pathlib.Path, *args: str) -> InvokeResult: |
| 36 | saved = os.getcwd() |
| 37 | try: |
| 38 | os.chdir(repo) |
| 39 | return runner.invoke(None, list(args)) |
| 40 | finally: |
| 41 | os.chdir(saved) |
| 42 | |
| 43 | |
| 44 | def _commit_calc(repo: pathlib.Path, body: str, message: str) -> None: |
| 45 | (repo / "calc.py").write_text(f"def add(a, b):\n return {body}\n") |
| 46 | r = _run(repo, "code", "add", "calc.py") |
| 47 | assert r.exit_code == 0, r.stderr |
| 48 | r = _run(repo, "commit", "-m", message) |
| 49 | assert r.exit_code == 0, r.stderr |
| 50 | |
| 51 | |
| 52 | def _branch_from(repo: pathlib.Path, base: str, name: str) -> None: |
| 53 | r = _run(repo, "checkout", base) |
| 54 | assert r.exit_code == 0, r.stderr |
| 55 | r = _run(repo, "branch", name) |
| 56 | assert r.exit_code == 0, r.stderr |
| 57 | r = _run(repo, "checkout", name) |
| 58 | assert r.exit_code == 0, r.stderr |
| 59 | |
| 60 | |
| 61 | def test_rebase_conflict_resolution_is_recorded_and_merge_state_cleared( |
| 62 | tmp_path: pathlib.Path, |
| 63 | ) -> None: |
| 64 | repo = tmp_path |
| 65 | r = _run(repo, "init") |
| 66 | assert r.exit_code == 0, r.stderr |
| 67 | _commit_calc(repo, "a + b", "base") |
| 68 | |
| 69 | # feat: the branch whose commit will be replayed ("theirs" during rebase). |
| 70 | _branch_from(repo, "main", "feat") |
| 71 | _commit_calc(repo, "a + b + 1", "feat changes add") |
| 72 | |
| 73 | # main moves on independently ("ours" — the new rebase target tip). |
| 74 | r = _run(repo, "checkout", "main") |
| 75 | assert r.exit_code == 0, r.stderr |
| 76 | _commit_calc(repo, "a + b + 100", "main changes add") |
| 77 | |
| 78 | r = _run(repo, "checkout", "feat") |
| 79 | assert r.exit_code == 0, r.stderr |
| 80 | r = _run(repo, "rebase", "main", "--json") |
| 81 | assert r.exit_code == 1, r.output |
| 82 | payload = json.loads(r.output) |
| 83 | assert payload["status"] == "conflict" |
| 84 | assert payload["conflicts"] == ["calc.py"] |
| 85 | |
| 86 | # MERGE_STATE.json must exist while the rebase is paused. |
| 87 | assert (repo / ".muse" / "MERGE_STATE.json").exists() |
| 88 | |
| 89 | # Resolve manually to a THIRD value (neither side) and continue. |
| 90 | (repo / "calc.py").write_text("def add(a, b):\n return a + b + 101\n") |
| 91 | r = _run(repo, "resolve", "calc.py") |
| 92 | assert r.exit_code == 0, r.stderr |
| 93 | r = _run(repo, "rebase", "--continue", "--json") |
| 94 | assert r.exit_code == 0, r.output |
| 95 | payload = json.loads(r.output) |
| 96 | assert payload["status"] == "completed" |
| 97 | |
| 98 | # MERGE_STATE.json must be cleared once the conflict's commit lands. |
| 99 | assert not (repo / ".muse" / "MERGE_STATE.json").exists() |
| 100 | |
| 101 | # Harmony must have recorded exactly this resolution. |
| 102 | r = _run(repo, "harmony", "list", "--json") |
| 103 | patterns = json.loads(r.output)["patterns"] |
| 104 | assert len(patterns) == 1 |
| 105 | assert patterns[0]["resolution_count"] == 1 |
| 106 | |
| 107 | r = _run(repo, "cat", "calc.py") |
| 108 | assert r.exit_code == 0, r.stderr |
| 109 | assert "+ 101" in r.output |
| 110 | |
| 111 | |
| 112 | def test_rebase_auto_resolves_identical_conflict_via_harmony( |
| 113 | tmp_path: pathlib.Path, |
| 114 | ) -> None: |
| 115 | repo = tmp_path |
| 116 | r = _run(repo, "init") |
| 117 | assert r.exit_code == 0, r.stderr |
| 118 | _commit_calc(repo, "a + b", "base") |
| 119 | # Stable pointer at the original base commit — both feat/a and feat/b |
| 120 | # must branch from here (not from main's moving tip) so their replay |
| 121 | # produces the identical base/ours/theirs blob triple both times. |
| 122 | r = _run(repo, "branch", "base") |
| 123 | assert r.exit_code == 0, r.stderr |
| 124 | |
| 125 | # First occurrence — same shape as the test above — teaches harmony. |
| 126 | _branch_from(repo, "base", "feat/a") |
| 127 | _commit_calc(repo, "a + b + 1", "a changes add") |
| 128 | r = _run(repo, "checkout", "main") |
| 129 | assert r.exit_code == 0, r.stderr |
| 130 | _commit_calc(repo, "a + b + 100", "main changes add (a)") |
| 131 | r = _run(repo, "checkout", "feat/a") |
| 132 | assert r.exit_code == 0, r.stderr |
| 133 | r = _run(repo, "rebase", "main", "--json") |
| 134 | assert json.loads(r.output)["status"] == "conflict" |
| 135 | (repo / "calc.py").write_text("def add(a, b):\n return a + b + 101\n") |
| 136 | r = _run(repo, "resolve", "calc.py") |
| 137 | assert r.exit_code == 0, r.stderr |
| 138 | r = _run(repo, "rebase", "--continue", "--json") |
| 139 | assert r.exit_code == 0, r.output |
| 140 | assert json.loads(r.output)["status"] == "completed" |
| 141 | |
| 142 | r = _run(repo, "harmony", "list", "--json") |
| 143 | patterns = json.loads(r.output)["patterns"] |
| 144 | assert len(patterns) == 1 |
| 145 | assert patterns[0]["resolution_count"] == 1 |
| 146 | |
| 147 | # Second occurrence — identical conflict pattern: feat/b branches from |
| 148 | # the same original "base" pointer (not main's tip, which has already |
| 149 | # moved to "a + b + 100") and gets the identical "a + b + 1" content, |
| 150 | # reproducing the exact same base/ours/theirs blob triple as feat/a. |
| 151 | _branch_from(repo, "base", "feat/b") |
| 152 | _commit_calc(repo, "a + b + 1", "b changes add") |
| 153 | |
| 154 | r = _run(repo, "rebase", "main", "--json") |
| 155 | assert r.exit_code == 0, r.output |
| 156 | payload = json.loads(r.output) |
| 157 | assert payload["status"] == "completed", ( |
| 158 | "harmony should have auto-resolved this during replay -- rebase " |
| 159 | f"must report 'completed', not pause: {payload}" |
| 160 | ) |
| 161 | |
| 162 | # No conflict ever paused this rebase, so no MERGE_STATE.json should |
| 163 | # ever have existed for it. |
| 164 | assert not (repo / ".muse" / "MERGE_STATE.json").exists() |
| 165 | |
| 166 | r = _run(repo, "cat", "calc.py") |
| 167 | assert r.exit_code == 0, r.stderr |
| 168 | assert "+ 101" in r.output |
| 169 | assert "+ 1\n" not in r.output.replace("+ 101", "") |
| 170 | assert "+ 100" not in r.output |
File History
1 commit
sha256:a7991bbc89b2af67cdd97580946aad7500cc737dd0c0d89088df831e198bdf84
fix: call Harmony inside muse rebase's per-commit replay lo…
Sonnet 5
patch
22 hours ago