test_phase4_harmony_history.py
python
sha256:75bbcdb47b6efaccafb75a02ff98f2d2fab4e9c5f803282868bd968a7180d5a4
test(phase4): Harmony learning across all history modes — 6…
Sonnet 4.6
23 hours ago
| 1 | """TDD tests for Phase 4 — Harmony integration per history mode. |
| 2 | |
| 3 | Issue #86 Phase 4 deliverables: |
| 4 | HA_01: --history merge conflict → resolve → commit → reset → re-merge → |
| 5 | Harmony auto-resolves (no conflict) — baseline |
| 6 | HA_02: --history squash conflict → resolve → commit → reset → re-merge → |
| 7 | Harmony auto-resolves (no conflict) |
| 8 | HA_03: --history rebase conflict → resolve → commit → reset → re-merge → |
| 9 | Harmony auto-resolves (no conflict) |
| 10 | (rebase is squash-equivalent in Phase 3; per-commit Harmony in replay |
| 11 | loop is deferred to Phase 6) |
| 12 | HA_04: MERGE_STATE.theirs_commit is set for squash merges (guard: ensures |
| 13 | the condition at commit.py:676 fires and Harmony actually records) |
| 14 | HA_05: Harmony learns from all three history modes and the pattern is present |
| 15 | in the store after commit |
| 16 | |
| 17 | Background |
| 18 | ---------- |
| 19 | Harmony keying is content-based: blob_fingerprint(ours_object_id, theirs_object_id) |
| 20 | uses the *file content hashes* from the ours/theirs snapshots, NOT commit IDs. |
| 21 | MERGE_STATE always carries theirs_commit_id regardless of --history mode, so |
| 22 | the commit.py guard (merge_state.ours_commit and merge_state.theirs_commit) fires |
| 23 | correctly for all three modes. No code change was required — this phase adds |
| 24 | coverage to guarantee the behaviour and catch regressions. |
| 25 | """ |
| 26 | from __future__ import annotations |
| 27 | |
| 28 | import datetime |
| 29 | import json |
| 30 | import pathlib |
| 31 | |
| 32 | import pytest |
| 33 | from tests.cli_test_helper import CliRunner |
| 34 | from muse.core.types import blob_id, fake_id |
| 35 | from muse.core.object_store import write_object, read_object |
| 36 | from muse.core.paths import heads_dir, muse_dir, ref_path |
| 37 | from muse.core.merge_engine import read_merge_state |
| 38 | |
| 39 | runner = CliRunner() |
| 40 | cli = None |
| 41 | |
| 42 | |
| 43 | # --------------------------------------------------------------------------- |
| 44 | # Helpers (same pattern as Phase 2/3) |
| 45 | # --------------------------------------------------------------------------- |
| 46 | |
| 47 | def _env(root: pathlib.Path) -> dict: |
| 48 | return {"MUSE_REPO_ROOT": str(root)} |
| 49 | |
| 50 | |
| 51 | def _init_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]: |
| 52 | dot_muse = muse_dir(tmp_path) |
| 53 | dot_muse.mkdir() |
| 54 | repo_id = fake_id("repo") |
| 55 | (dot_muse / "repo.json").write_text(json.dumps({ |
| 56 | "repo_id": repo_id, |
| 57 | "domain": "code", |
| 58 | "default_branch": "main", |
| 59 | "created_at": "2025-01-01T00:00:00+00:00", |
| 60 | }), encoding="utf-8") |
| 61 | (dot_muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8") |
| 62 | (dot_muse / "refs" / "heads").mkdir(parents=True) |
| 63 | (dot_muse / "snapshots").mkdir() |
| 64 | (dot_muse / "commits").mkdir() |
| 65 | (dot_muse / "objects").mkdir() |
| 66 | return tmp_path, repo_id |
| 67 | |
| 68 | |
| 69 | def _write_obj(root: pathlib.Path, content: bytes) -> str: |
| 70 | oid = blob_id(content) |
| 71 | write_object(root, oid, content) |
| 72 | return oid |
| 73 | |
| 74 | |
| 75 | def _make_commit( |
| 76 | root: pathlib.Path, |
| 77 | repo_id: str, |
| 78 | branch: str = "main", |
| 79 | message: str = "test", |
| 80 | manifest: dict | None = None, |
| 81 | parent_id: str | None = None, |
| 82 | ) -> str: |
| 83 | from muse.core.commits import CommitRecord, write_commit |
| 84 | from muse.core.snapshots import SnapshotRecord, write_snapshot |
| 85 | from muse.core.ids import hash_snapshot, hash_commit |
| 86 | |
| 87 | ref_file = ref_path(root, branch) |
| 88 | if parent_id is None: |
| 89 | parent_id = ref_file.read_text().strip() if ref_file.exists() else None |
| 90 | m = manifest or {} |
| 91 | snap_id = hash_snapshot(m) |
| 92 | committed_at = datetime.datetime.now(datetime.timezone.utc) |
| 93 | commit_id = hash_commit( |
| 94 | parent_ids=[parent_id] if parent_id else [], |
| 95 | snapshot_id=snap_id, |
| 96 | message=message, |
| 97 | committed_at_iso=committed_at.isoformat(), |
| 98 | ) |
| 99 | write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=m)) |
| 100 | write_commit(root, CommitRecord( |
| 101 | commit_id=commit_id, |
| 102 | branch=branch, |
| 103 | snapshot_id=snap_id, |
| 104 | message=message, |
| 105 | committed_at=committed_at, |
| 106 | parent_commit_id=parent_id, |
| 107 | )) |
| 108 | ref_file.parent.mkdir(parents=True, exist_ok=True) |
| 109 | ref_file.write_text(commit_id, encoding="utf-8") |
| 110 | return commit_id |
| 111 | |
| 112 | |
| 113 | def _checkout(root: pathlib.Path, branch: str, manifest: dict) -> None: |
| 114 | """Set HEAD to branch and write manifest file contents to disk.""" |
| 115 | (muse_dir(root) / "HEAD").write_text(f"ref: refs/heads/{branch}", encoding="utf-8") |
| 116 | for path, oid in manifest.items(): |
| 117 | content = read_object(root, oid) |
| 118 | if content is not None: |
| 119 | dest = root / path |
| 120 | dest.parent.mkdir(parents=True, exist_ok=True) |
| 121 | dest.write_bytes(content) |
| 122 | |
| 123 | |
| 124 | def _setup_conflict_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str, str, str, str]: |
| 125 | """Create a repo where main and feat diverge on config.py. |
| 126 | |
| 127 | Returns (root, repo_id, ours_commit_id, cfg_ours, cfg_theirs). |
| 128 | |
| 129 | Both sides use valid Python (single assignment) so the CodePlugin detects a |
| 130 | genuine symbol-level conflict without triggering the independence-merge path. |
| 131 | """ |
| 132 | root, repo_id = _init_repo(tmp_path) |
| 133 | cfg_base = _write_obj(root, b"config = 1\n") |
| 134 | base_id = _make_commit(root, repo_id, "main", "base", {"config.py": cfg_base}) |
| 135 | cfg_ours = _write_obj(root, b"config = 2\n") |
| 136 | cfg_theirs = _write_obj(root, b"config = 3\n") |
| 137 | (heads_dir(root) / "feat").write_text(base_id, encoding="utf-8") |
| 138 | _make_commit(root, repo_id, "feat", "feat changes config", |
| 139 | {"config.py": cfg_theirs}, parent_id=base_id) |
| 140 | ours_commit_id = _make_commit(root, repo_id, "main", "main changes config", |
| 141 | {"config.py": cfg_ours}, parent_id=base_id) |
| 142 | _checkout(root, "main", {"config.py": cfg_ours}) |
| 143 | return root, repo_id, ours_commit_id, cfg_ours, cfg_theirs |
| 144 | |
| 145 | |
| 146 | def _run_conflict_cycle( |
| 147 | root: pathlib.Path, |
| 148 | history: str, |
| 149 | resolved_content: bytes, |
| 150 | ) -> None: |
| 151 | """Run a full conflict → resolve → commit cycle for the given history mode. |
| 152 | |
| 153 | After this function returns: |
| 154 | - Harmony has recorded the resolution for the conflict pattern. |
| 155 | - MERGE_STATE.json is cleared. |
| 156 | - main branch tip is the merge commit. |
| 157 | """ |
| 158 | # 1. Merge → conflict |
| 159 | r = runner.invoke(cli, ["merge", "feat", "--history", history, "--json"], |
| 160 | env=_env(root), catch_exceptions=False) |
| 161 | data = json.loads(r.output.strip().splitlines()[-1]) |
| 162 | assert len(data.get("conflicts", [])) > 0, ( |
| 163 | f"Expected a conflict for --history {history}, got {data.get('conflicts')}" |
| 164 | ) |
| 165 | |
| 166 | # 2. Write resolved content to disk |
| 167 | resolved_id = _write_obj(root, resolved_content) |
| 168 | (root / "config.py").write_bytes(resolved_content) |
| 169 | |
| 170 | # 3. Mark resolved (stages automatically) |
| 171 | r2 = runner.invoke(cli, ["resolve", "config.py", "--json"], |
| 172 | env=_env(root), catch_exceptions=False) |
| 173 | assert json.loads(r2.output).get("exit_code") == 0, ( |
| 174 | f"muse resolve failed: {r2.output}" |
| 175 | ) |
| 176 | |
| 177 | # 4. Commit → Harmony records the pattern |
| 178 | r3 = runner.invoke(cli, ["commit", "-m", f"merge: {history} with resolution", |
| 179 | "--json"], |
| 180 | env=_env(root), catch_exceptions=False) |
| 181 | d3 = json.loads(r3.output.strip().splitlines()[-1]) |
| 182 | assert d3.get("exit_code") == 0, f"muse commit failed: {r3.output}" |
| 183 | |
| 184 | |
| 185 | # --------------------------------------------------------------------------- |
| 186 | # Group 1 — Harmony learns from all three history modes (HA_01–HA_03) |
| 187 | # --------------------------------------------------------------------------- |
| 188 | |
| 189 | class TestHarmonyLearnsPerHistoryMode: |
| 190 | """After a conflict is resolved and committed, re-running the same merge |
| 191 | must auto-resolve via Harmony — regardless of history mode.""" |
| 192 | |
| 193 | def _run_full_harmony_test(self, tmp_path: pathlib.Path, history: str) -> None: |
| 194 | root, repo_id, ours_commit_id, cfg_ours, cfg_theirs = _setup_conflict_repo(tmp_path) |
| 195 | |
| 196 | # Teach Harmony: conflict → resolve → commit |
| 197 | resolved_content = b"config = 99\n" |
| 198 | _run_conflict_cycle(root, history, resolved_content) |
| 199 | |
| 200 | # Check Harmony learned the pattern |
| 201 | from muse.core.harmony.engine import list_patterns |
| 202 | patterns = list_patterns(root) |
| 203 | assert len(patterns) >= 1, ( |
| 204 | f"HA: Harmony must have at least 1 pattern after --history {history} commit" |
| 205 | ) |
| 206 | |
| 207 | # Reset main back to ours_commit_id and restore disk content |
| 208 | ref_path(root, "main").write_text(ours_commit_id, encoding="utf-8") |
| 209 | ours_content = read_object(root, cfg_ours) |
| 210 | assert ours_content is not None |
| 211 | (root / "config.py").write_bytes(ours_content) |
| 212 | |
| 213 | # Re-run the same merge — Harmony must auto-resolve (no conflict) |
| 214 | r_final = runner.invoke(cli, ["merge", "feat", "--history", history, "--json"], |
| 215 | env=_env(root), catch_exceptions=False) |
| 216 | data = json.loads(r_final.output.strip().splitlines()[-1]) |
| 217 | assert data.get("conflicts", []) == [], ( |
| 218 | f"HA: Harmony must auto-resolve conflict on re-merge with --history {history}; " |
| 219 | f"got conflicts={data.get('conflicts')}" |
| 220 | ) |
| 221 | assert data.get("exit_code") == 0, ( |
| 222 | f"HA: re-merge must succeed with --history {history}" |
| 223 | ) |
| 224 | |
| 225 | def test_HA_01_merge_harmony_auto_resolves(self, tmp_path: pathlib.Path) -> None: |
| 226 | """HA_01 — --history merge: conflict → resolve → commit → re-merge → Harmony auto-resolves.""" |
| 227 | self._run_full_harmony_test(tmp_path, "merge") |
| 228 | |
| 229 | def test_HA_02_squash_harmony_auto_resolves(self, tmp_path: pathlib.Path) -> None: |
| 230 | """HA_02 — --history squash: conflict → resolve → commit → re-merge → Harmony auto-resolves. |
| 231 | |
| 232 | Squash commits have no parent2_commit_id. Harmony still learns because |
| 233 | MERGE_STATE.theirs_commit is set from the merge operation (before the |
| 234 | commit), not from the commit record itself. |
| 235 | """ |
| 236 | self._run_full_harmony_test(tmp_path, "squash") |
| 237 | |
| 238 | def test_HA_03_rebase_harmony_auto_resolves(self, tmp_path: pathlib.Path) -> None: |
| 239 | """HA_03 — --history rebase: conflict → resolve → commit → re-merge → Harmony auto-resolves. |
| 240 | |
| 241 | In Phase 3, --history rebase produces a single-parent commit (same as |
| 242 | squash). Full commit-by-commit replay with per-commit Harmony recording |
| 243 | is deferred to Phase 6. This test ensures the squash-equivalent path |
| 244 | works correctly. |
| 245 | """ |
| 246 | self._run_full_harmony_test(tmp_path, "rebase") |
| 247 | |
| 248 | |
| 249 | # --------------------------------------------------------------------------- |
| 250 | # Group 2 — Structural guarantees (HA_04–HA_05) |
| 251 | # --------------------------------------------------------------------------- |
| 252 | |
| 253 | class TestHarmonyStructuralGuarantees: |
| 254 | """Lower-level checks on the data invariants that make Harmony learning work.""" |
| 255 | |
| 256 | def test_HA_04_merge_state_theirs_commit_set_for_squash( |
| 257 | self, tmp_path: pathlib.Path |
| 258 | ) -> None: |
| 259 | """HA_04 — MERGE_STATE.theirs_commit is non-None after a squash conflict. |
| 260 | |
| 261 | This is the guard that makes commit.py:676 fire and Harmony actually record. |
| 262 | If theirs_commit were None, the condition would short-circuit and Harmony |
| 263 | would silently skip learning. |
| 264 | """ |
| 265 | root, *_ = _setup_conflict_repo(tmp_path) |
| 266 | runner.invoke(cli, ["merge", "feat", "--history", "squash", "--json"], |
| 267 | env=_env(root), catch_exceptions=False) |
| 268 | |
| 269 | ms = read_merge_state(root) |
| 270 | assert ms is not None, "HA_04: MERGE_STATE must be written after a squash conflict" |
| 271 | assert ms.theirs_commit is not None, ( |
| 272 | "HA_04: MERGE_STATE.theirs_commit must be set for squash merges so " |
| 273 | "commit.py Harmony recording fires" |
| 274 | ) |
| 275 | assert ms.ours_commit is not None, ( |
| 276 | "HA_04: MERGE_STATE.ours_commit must be set" |
| 277 | ) |
| 278 | |
| 279 | def test_HA_05_harmony_pattern_in_store_after_squash_commit( |
| 280 | self, tmp_path: pathlib.Path |
| 281 | ) -> None: |
| 282 | """HA_05 — After a squash merge commit, a Harmony pattern exists in the store.""" |
| 283 | root, *_ = _setup_conflict_repo(tmp_path) |
| 284 | _run_conflict_cycle(root, "squash", b"config = 99\n") |
| 285 | |
| 286 | from muse.core.harmony.engine import list_patterns |
| 287 | patterns = list_patterns(root) |
| 288 | assert len(patterns) >= 1, ( |
| 289 | "HA_05: Harmony must have at least 1 pattern after a squash merge commit" |
| 290 | ) |
| 291 | p = patterns[0] |
| 292 | assert p.path is not None, "HA_05: pattern must have a path" |
| 293 | # Pattern should reference the file involved in the conflict |
| 294 | assert "config.py" in p.path, ( |
| 295 | f"HA_05: pattern path must reference config.py, got {p.path!r}" |
| 296 | ) |
| 297 | |
| 298 | def test_HA_06_harmony_does_not_fire_for_clean_squash_merge( |
| 299 | self, tmp_path: pathlib.Path |
| 300 | ) -> None: |
| 301 | """HA_06 — A clean squash merge (no conflicts) produces no Harmony patterns. |
| 302 | |
| 303 | Harmony only learns when conflicts are surfaced and resolved. Clean merges |
| 304 | that never write MERGE_STATE must not accidentally teach Harmony anything. |
| 305 | """ |
| 306 | root, repo_id = _init_repo(tmp_path) |
| 307 | a_id = _write_obj(root, b"x = 1\n") |
| 308 | b_id = _write_obj(root, b"y = 1\n") |
| 309 | base_id = _make_commit(root, repo_id, "main", "base", |
| 310 | {"a.py": a_id, "b.py": b_id}) |
| 311 | b_v2 = _write_obj(root, b"y = 2\n") |
| 312 | (heads_dir(root) / "feat").write_text(base_id, encoding="utf-8") |
| 313 | _make_commit(root, repo_id, "feat", "feat changes b", |
| 314 | {"a.py": a_id, "b.py": b_v2}, parent_id=base_id) |
| 315 | _checkout(root, "main", {"a.py": a_id, "b.py": b_id}) |
| 316 | |
| 317 | r = runner.invoke(cli, ["merge", "feat", "--history", "squash", "--json"], |
| 318 | env=_env(root), catch_exceptions=False) |
| 319 | data = json.loads(r.output.strip().splitlines()[-1]) |
| 320 | assert data.get("exit_code") == 0, "HA_06: clean squash merge must succeed" |
| 321 | assert data.get("conflicts", []) == [], "HA_06: clean squash merge must have no conflicts" |
| 322 | |
| 323 | from muse.core.harmony.engine import list_patterns |
| 324 | patterns = list_patterns(root) |
| 325 | assert len(patterns) == 0, ( |
| 326 | f"HA_06: clean squash merge must not create Harmony patterns, got {len(patterns)}" |
| 327 | ) |
File History
1 commit
sha256:75bbcdb47b6efaccafb75a02ff98f2d2fab4e9c5f803282868bd968a7180d5a4
test(phase4): Harmony learning across all history modes — 6…
Sonnet 4.6
23 hours ago