gabriel / muse public
test_code_add_resolves_conflicts.py python
272 lines 9.9 KB
Raw
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 7 days ago
1 """TDD tests for idiomatic conflict resolution via muse code add.
2
3 In git, `git add <file>` after editing a conflict file is the resolution
4 signal — no separate step needed. muse code add must behave the same way:
5 staging a file that is in conflict_paths removes it from MERGE_STATE.json
6 automatically, so `muse commit` can proceed without an explicit `muse resolve`.
7 """
8
9 from __future__ import annotations
10
11 import datetime
12 import json
13 import pathlib
14 from collections.abc import Mapping
15
16 import pytest
17 from tests.cli_test_helper import CliRunner
18 from muse.core.types import blob_id, fake_id
19 from muse.core.object_store import write_object
20 from muse.core.paths import heads_dir, muse_dir, ref_path
21 from muse.core.merge_engine import read_merge_state, write_merge_state
22
23 runner = CliRunner()
24 cli = None
25
26 type Manifest = dict[str, str]
27
28
29 # ---------------------------------------------------------------------------
30 # Helpers
31 # ---------------------------------------------------------------------------
32
33
34 def _init_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
35 dot_muse = muse_dir(tmp_path)
36 dot_muse.mkdir()
37 repo_id = fake_id("repo")
38 (dot_muse / "repo.json").write_text(json.dumps({
39 "repo_id": repo_id,
40 "domain": "code",
41 "default_branch": "main",
42 "created_at": "2025-01-01T00:00:00+00:00",
43 }), encoding="utf-8")
44 (dot_muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
45 (dot_muse / "refs" / "heads").mkdir(parents=True)
46 (dot_muse / "snapshots").mkdir()
47 (dot_muse / "commits").mkdir()
48 (dot_muse / "objects").mkdir()
49 return tmp_path, repo_id
50
51
52 def _write_blob(root: pathlib.Path, content: bytes) -> str:
53 oid = blob_id(content)
54 write_object(root, oid, content)
55 return oid
56
57
58 def _make_commit(
59 root: pathlib.Path,
60 repo_id: str,
61 branch: str,
62 files: dict[str, bytes],
63 parent_id: str | None = None,
64 message: str = "commit",
65 ) -> str:
66 from muse.core.commits import CommitRecord, write_commit
67 from muse.core.snapshots import SnapshotRecord, write_snapshot
68 from muse.core.ids import hash_snapshot, hash_commit
69
70 manifest: Manifest = {}
71 for rel, content in files.items():
72 oid = _write_blob(root, content)
73 manifest[rel] = oid
74 dest = root / rel
75 dest.parent.mkdir(parents=True, exist_ok=True)
76 dest.write_bytes(content)
77
78 snap_id = hash_snapshot(manifest)
79 committed_at = datetime.datetime.now(datetime.timezone.utc)
80 commit_id = hash_commit(
81 parent_ids=[parent_id] if parent_id else [],
82 snapshot_id=snap_id,
83 message=message,
84 committed_at_iso=committed_at.isoformat(),
85 )
86 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=manifest))
87 write_commit(root, CommitRecord(
88 commit_id=commit_id, branch=branch,
89 snapshot_id=snap_id, message=message, committed_at=committed_at,
90 parent_commit_id=parent_id,
91 ))
92 rf = ref_path(root, branch)
93 rf.parent.mkdir(parents=True, exist_ok=True)
94 rf.write_text(commit_id, encoding="utf-8")
95 return commit_id
96
97
98 def _env(root: pathlib.Path) -> Mapping[str, str]:
99 return {"MUSE_REPO_ROOT": str(root)}
100
101
102 def _do_conflicted_merge(tmp_path: pathlib.Path) -> pathlib.Path:
103 """Set up a repo with an in-progress conflict on hello.txt and return root."""
104 root, repo_id = _init_repo(tmp_path)
105
106 base_id = _make_commit(root, repo_id, "main",
107 {"hello.txt": b"base\n"}, message="base")
108 (heads_dir(root) / "feat").write_text(base_id)
109 _make_commit(root, repo_id, "feat",
110 {"hello.txt": b"Version B\n"}, parent_id=base_id, message="feat")
111 _make_commit(root, repo_id, "main",
112 {"hello.txt": b"Version A\n"}, parent_id=base_id, message="main")
113
114 runner.invoke(cli, ["merge", "feat"], env=_env(root))
115
116 # Confirm merge is in progress with hello.txt as a conflict
117 state = read_merge_state(root)
118 assert state is not None and "hello.txt" in state.conflict_paths
119 return root
120
121
122 # ---------------------------------------------------------------------------
123 # Tests
124 # ---------------------------------------------------------------------------
125
126
127 class TestCodeAddClearsConflictPath:
128
129 def test_staging_conflict_file_removes_it_from_conflict_paths(
130 self, tmp_path: pathlib.Path
131 ) -> None:
132 """After manually editing and staging, hello.txt must leave conflict_paths."""
133 root = _do_conflicted_merge(tmp_path)
134
135 (root / "hello.txt").write_bytes(b"Version B\n")
136 runner.invoke(cli, ["code", "add", str(root / "hello.txt")], env=_env(root))
137
138 state = read_merge_state(root)
139 assert state is None or "hello.txt" not in state.conflict_paths, (
140 "hello.txt must be removed from conflict_paths after muse code add"
141 )
142
143 def test_staging_conflict_file_allows_commit(
144 self, tmp_path: pathlib.Path
145 ) -> None:
146 """muse commit must succeed after staging the resolved conflict file."""
147 root = _do_conflicted_merge(tmp_path)
148
149 (root / "hello.txt").write_bytes(b"Version B\n")
150 runner.invoke(cli, ["code", "add", str(root / "hello.txt")], env=_env(root))
151
152 result = runner.invoke(
153 cli,
154 ["commit", "-m", "merge: resolve hello.txt — keep version B"],
155 env=_env(root),
156 )
157 assert result.exit_code == 0, (
158 f"commit should succeed after staging resolved file, got:\n{result.stderr}"
159 )
160
161 def test_staging_conflict_file_sets_ready_to_commit_when_last_conflict(
162 self, tmp_path: pathlib.Path
163 ) -> None:
164 """When the last conflict is staged, merge_in_progress must become false."""
165 root = _do_conflicted_merge(tmp_path)
166
167 (root / "hello.txt").write_bytes(b"Version B\n")
168 runner.invoke(cli, ["code", "add", str(root / "hello.txt")], env=_env(root))
169
170 status = json.loads(
171 runner.invoke(cli, ["status", "--json"], env=_env(root)).stdout
172 )
173 assert status["merge_in_progress"] is False or status["conflict_count"] == 0, (
174 "No conflicts should remain after staging the only conflict file"
175 )
176
177 def test_staging_non_conflict_file_does_not_touch_merge_state(
178 self, tmp_path: pathlib.Path
179 ) -> None:
180 """Staging an unrelated file must not affect MERGE_STATE conflict list."""
181 root = _do_conflicted_merge(tmp_path)
182
183 (root / "notes.txt").write_bytes(b"some notes\n")
184 runner.invoke(cli, ["code", "add", str(root / "notes.txt")], env=_env(root))
185
186 state = read_merge_state(root)
187 assert state is not None and "hello.txt" in state.conflict_paths, (
188 "hello.txt must remain in conflict_paths when an unrelated file is staged"
189 )
190
191 def test_staging_one_of_two_conflict_files_leaves_other_unresolved(
192 self, tmp_path: pathlib.Path
193 ) -> None:
194 """Staging one conflicted file must leave the other in conflict_paths."""
195 root, repo_id = _init_repo(tmp_path)
196
197 base_id = _make_commit(root, repo_id, "main", {
198 "alpha.txt": b"base alpha\n",
199 "beta.txt": b"base beta\n",
200 }, message="base")
201
202 (heads_dir(root) / "feat").write_text(base_id)
203 _make_commit(root, repo_id, "feat", {
204 "alpha.txt": b"theirs alpha\n",
205 "beta.txt": b"theirs beta\n",
206 }, parent_id=base_id, message="feat")
207 _make_commit(root, repo_id, "main", {
208 "alpha.txt": b"ours alpha\n",
209 "beta.txt": b"ours beta\n",
210 }, parent_id=base_id, message="main")
211
212 runner.invoke(cli, ["merge", "feat"], env=_env(root))
213
214 (root / "alpha.txt").write_bytes(b"resolved alpha\n")
215 runner.invoke(cli, ["code", "add", str(root / "alpha.txt")], env=_env(root))
216
217 state = read_merge_state(root)
218 assert state is not None
219 assert "alpha.txt" not in state.conflict_paths, (
220 "alpha.txt must be cleared after staging"
221 )
222 assert "beta.txt" in state.conflict_paths, (
223 "beta.txt must remain unresolved"
224 )
225
226 def test_code_add_dot_clears_all_conflict_files(
227 self, tmp_path: pathlib.Path
228 ) -> None:
229 """muse code add <root> must clear all conflicted files from conflict_paths."""
230 root, repo_id = _init_repo(tmp_path)
231
232 base_id = _make_commit(root, repo_id, "main", {
233 "alpha.txt": b"base alpha\n",
234 "beta.txt": b"base beta\n",
235 }, message="base")
236
237 (heads_dir(root) / "feat").write_text(base_id)
238 _make_commit(root, repo_id, "feat", {
239 "alpha.txt": b"theirs alpha\n",
240 "beta.txt": b"theirs beta\n",
241 }, parent_id=base_id, message="feat")
242 _make_commit(root, repo_id, "main", {
243 "alpha.txt": b"ours alpha\n",
244 "beta.txt": b"ours beta\n",
245 }, parent_id=base_id, message="main")
246
247 runner.invoke(cli, ["merge", "feat"], env=_env(root))
248
249 (root / "alpha.txt").write_bytes(b"resolved alpha\n")
250 (root / "beta.txt").write_bytes(b"resolved beta\n")
251 runner.invoke(cli, ["code", "add",
252 str(root / "alpha.txt"),
253 str(root / "beta.txt")], env=_env(root))
254
255 state = read_merge_state(root)
256 remaining = state.conflict_paths if state else []
257 assert remaining == [], (
258 f"All conflicts must be cleared after staging all files, got: {remaining}"
259 )
260
261 def test_no_merge_in_progress_code_add_is_unaffected(
262 self, tmp_path: pathlib.Path
263 ) -> None:
264 """muse code add must work normally when no merge is in progress."""
265 root, repo_id = _init_repo(tmp_path)
266 _make_commit(root, repo_id, "main", {"hello.txt": b"hello\n"}, message="init")
267
268 (root / "hello.txt").write_bytes(b"hello world\n")
269 result = runner.invoke(cli, ["code", "add", str(root / "hello.txt")], env=_env(root))
270
271 assert result.exit_code == 0
272 assert read_merge_state(root) is None
File History 1 commit
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 7 days ago