gabriel / muse public
test_phase4_harmony_history.py python
327 lines 13.9 KB
Raw
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