gabriel / muse public
test_cmd_merge_dry_run.py python
365 lines 16.4 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 120 days ago
1 """Tests for ``muse merge --dry-run``.
2
3 Verifies that --dry-run:
4 - Reports the correct outcome for all three cases (up-to-date, fast-forward,
5 three-way merge)
6 - NEVER writes to the working tree, ref files, snapshot store, or commits
7 - Works with --format json (identical shape, dry_run: true field added)
8 - Reports conflicts without writing MERGE_STATE.json
9 - Includes files_changed stats on fast-forward and clean merge
10 - Skips the require_clean_workdir check (dry-run never needs a clean tree)
11 """
12 from __future__ import annotations
13
14 import datetime
15 import json
16 import pathlib
17
18 import pytest
19 from tests.cli_test_helper import CliRunner
20 from muse.core.types import blob_id, fake_id
21 from muse.core.paths import commits_dir, heads_dir, logs_dir, merge_state_path, muse_dir, ref_path, snapshots_dir
22
23 cli = None
24 runner = CliRunner()
25
26
27 # ---------------------------------------------------------------------------
28 # Helpers (shared with test_cmd_merge.py — intentionally duplicated for isolation)
29 # ---------------------------------------------------------------------------
30
31
32 def _env(root: pathlib.Path) -> Manifest:
33 return {"MUSE_REPO_ROOT": str(root)}
34
35
36 def _init_repo(tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
37 dot_muse = muse_dir(tmp_path)
38 dot_muse.mkdir()
39 repo_id = fake_id("repo")
40 (dot_muse / "repo.json").write_text(json.dumps({
41 "repo_id": repo_id,
42 "domain": "code",
43 "default_branch": "main",
44 "created_at": "2025-01-01T00:00:00+00:00",
45 }), encoding="utf-8")
46 (dot_muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
47 (dot_muse / "refs" / "heads").mkdir(parents=True)
48 (dot_muse / "snapshots").mkdir()
49 (dot_muse / "commits").mkdir()
50 (dot_muse / "objects").mkdir()
51 return tmp_path, repo_id
52
53
54 def _make_commit(
55 root: pathlib.Path,
56 repo_id: str,
57 branch: str = "main",
58 message: str = "test",
59 manifest: Manifest | None = None,
60 ) -> str:
61 from muse.core.store import CommitRecord, SnapshotRecord, write_commit, write_snapshot
62 from muse.core.snapshot import compute_snapshot_id, compute_commit_id
63
64 ref_file = ref_path(root, branch)
65 parent_id = ref_file.read_text().strip() if ref_file.exists() else None
66 m = manifest or {}
67 snap_id = compute_snapshot_id(m)
68 committed_at = datetime.datetime.now(datetime.timezone.utc)
69 commit_id = compute_commit_id( parent_ids=[parent_id] if parent_id else [],
70 snapshot_id=snap_id, message=message,
71 committed_at_iso=committed_at.isoformat(),
72 )
73 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=m))
74 write_commit(root, CommitRecord(
75 commit_id=commit_id, repo_id=repo_id, branch=branch,
76 snapshot_id=snap_id, message=message, committed_at=committed_at,
77 parent_commit_id=parent_id,
78 ))
79 ref_file.parent.mkdir(parents=True, exist_ok=True)
80 ref_file.write_text(commit_id, encoding="utf-8")
81 return commit_id
82
83
84 def _write_object(root: pathlib.Path, content: bytes) -> str:
85 from muse.core.object_store import write_object
86 obj_id = blob_id(content)
87 write_object(root, obj_id, content)
88 return obj_id
89
90
91 def _head_ref(root: pathlib.Path, branch: str = "main") -> str:
92 return (ref_path(root, branch)).read_text().strip()
93
94
95 # ---------------------------------------------------------------------------
96 # up-to-date
97 # ---------------------------------------------------------------------------
98
99
100 class TestDryRunUpToDate:
101 def test_text_output(self, tmp_path: pathlib.Path) -> None:
102 root, repo_id = _init_repo(tmp_path)
103 commit_id = _make_commit(root, repo_id, branch="main")
104 # feature branch = same commit
105 (heads_dir(root) / "feature").write_text(commit_id)
106
107 result = runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root), catch_exceptions=False)
108 assert result.exit_code == 0
109 assert "up to date" in result.output.lower()
110
111 def test_json_output_has_dry_run_true(self, tmp_path: pathlib.Path) -> None:
112 root, repo_id = _init_repo(tmp_path)
113 commit_id = _make_commit(root, repo_id, branch="main")
114 (heads_dir(root) / "feature").write_text(commit_id)
115
116 result = runner.invoke(cli, ["merge", "--dry-run", "--json", "feature"],
117 env=_env(root), catch_exceptions=False)
118 assert result.exit_code == 0
119 data = json.loads(result.output)
120 assert data["status"] == "up_to_date"
121 assert data["dry_run"] is True
122
123 def test_refs_not_modified(self, tmp_path: pathlib.Path) -> None:
124 root, repo_id = _init_repo(tmp_path)
125 commit_id = _make_commit(root, repo_id, branch="main")
126 (heads_dir(root) / "feature").write_text(commit_id)
127
128 before = _head_ref(root, "main")
129 runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root), catch_exceptions=False)
130 assert _head_ref(root, "main") == before
131
132
133 # ---------------------------------------------------------------------------
134 # fast-forward
135 # ---------------------------------------------------------------------------
136
137
138 class TestDryRunFastForward:
139 def _setup(self, tmp_path: pathlib.Path) -> tuple[pathlib.Path, str, str, str]:
140 root, repo_id = _init_repo(tmp_path)
141 base_id = _make_commit(root, repo_id, branch="main", message="base")
142 (heads_dir(root) / "feature").write_text(base_id)
143 obj = _write_object(root, b"new track data")
144 feature_id = _make_commit(root, repo_id, branch="feature",
145 message="add track", manifest={"track.mid": obj})
146 return root, repo_id, base_id, feature_id
147
148 def test_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
149 root, _, _, _ = self._setup(tmp_path)
150 result = runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root), catch_exceptions=False)
151 assert result.exit_code == 0
152
153 def test_main_ref_not_advanced(self, tmp_path: pathlib.Path) -> None:
154 root, _, base_id, _ = self._setup(tmp_path)
155 runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root), catch_exceptions=False)
156 # main must still point to base, not the feature commit
157 assert _head_ref(root, "main") == base_id
158
159 def test_working_tree_not_modified(self, tmp_path: pathlib.Path) -> None:
160 root, _, _, _ = self._setup(tmp_path)
161 # No files should appear in the repo root after dry-run
162 runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root), catch_exceptions=False)
163 assert not (root / "track.mid").exists()
164
165 def test_no_reflog_entry_written(self, tmp_path: pathlib.Path) -> None:
166 root, _, _, _ = self._setup(tmp_path)
167 reflog = logs_dir(root) / "refs" / "heads" / "main"
168 existed_before = reflog.exists()
169 runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root), catch_exceptions=False)
170 if not existed_before:
171 assert not reflog.exists()
172 else:
173 # If it existed, ensure no new entry was appended for the dry-run
174 lines_before = reflog.read_text().splitlines() if reflog.exists() else []
175 runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root))
176 lines_after = reflog.read_text().splitlines() if reflog.exists() else []
177 assert len(lines_after) == len(lines_before)
178
179 def test_text_mentions_would_fast_forward(self, tmp_path: pathlib.Path) -> None:
180 root, _, _, _ = self._setup(tmp_path)
181 result = runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root), catch_exceptions=False)
182 assert "would fast-forward" in result.output.lower() or "dry-run" in result.output.lower()
183
184 def test_json_status_and_dry_run_field(self, tmp_path: pathlib.Path) -> None:
185 root, _, base_id, feature_id = self._setup(tmp_path)
186 result = runner.invoke(cli, ["merge", "--dry-run", "--json", "feature"],
187 env=_env(root), catch_exceptions=False)
188 assert result.exit_code == 0
189 data = json.loads(result.output)
190 assert data["status"] == "fast_forward"
191 assert data["dry_run"] is True
192 # commit_id is None in dry-run (nothing committed)
193 assert data["commit_id"] is None
194 assert "files_changed" in data
195
196 def test_files_changed_stats_correct(self, tmp_path: pathlib.Path) -> None:
197 root, _, _, _ = self._setup(tmp_path)
198 result = runner.invoke(cli, ["merge", "--dry-run", "--json", "feature"],
199 env=_env(root), catch_exceptions=False)
200 data = json.loads(result.output)
201 fc = data["files_changed"]
202 assert fc["added"] == 1
203 assert fc["modified"] == 0
204 assert fc["deleted"] == 0
205
206
207 # ---------------------------------------------------------------------------
208 # three-way clean merge
209 # ---------------------------------------------------------------------------
210
211
212 class TestDryRunThreeWayClean:
213 def _setup(self, tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
214 root, repo_id = _init_repo(tmp_path)
215 base_obj = _write_object(root, b"base track")
216 base_id = _make_commit(root, repo_id, branch="main", message="base",
217 manifest={"base.mid": base_obj})
218 (heads_dir(root) / "feature").write_text(base_id)
219 # main and feature both diverge from base — true three-way
220 main_obj = _write_object(root, b"main track addition")
221 _make_commit(root, repo_id, branch="main", message="main work",
222 manifest={"base.mid": base_obj, "main.mid": main_obj})
223 feat_obj = _write_object(root, b"feature track addition")
224 _make_commit(root, repo_id, branch="feature", message="feat work",
225 manifest={"base.mid": base_obj, "feat.mid": feat_obj})
226 return root, repo_id
227
228 def test_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
229 root, _ = self._setup(tmp_path)
230 result = runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root), catch_exceptions=False)
231 assert result.exit_code == 0
232
233 def test_main_ref_not_advanced(self, tmp_path: pathlib.Path) -> None:
234 root, _ = self._setup(tmp_path)
235 before = _head_ref(root, "main")
236 runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root), catch_exceptions=False)
237 assert _head_ref(root, "main") == before
238
239 def test_no_new_snapshot_written(self, tmp_path: pathlib.Path) -> None:
240 root, _ = self._setup(tmp_path)
241 snaps_before = set((snapshots_dir(root)).iterdir())
242 runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root), catch_exceptions=False)
243 snaps_after = set((snapshots_dir(root)).iterdir())
244 assert snaps_after == snaps_before
245
246 def test_no_new_commit_written(self, tmp_path: pathlib.Path) -> None:
247 root, _ = self._setup(tmp_path)
248 commits_before = set((commits_dir(root)).iterdir())
249 runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root), catch_exceptions=False)
250 commits_after = set((commits_dir(root)).iterdir())
251 assert commits_after == commits_before
252
253 def test_json_dry_run_true_and_no_commit_id(self, tmp_path: pathlib.Path) -> None:
254 root, _ = self._setup(tmp_path)
255 result = runner.invoke(cli, ["merge", "--dry-run", "--json", "feature"],
256 env=_env(root), catch_exceptions=False)
257 assert result.exit_code == 0
258 data = json.loads(result.output)
259 assert data["status"] == "merged"
260 assert data["dry_run"] is True
261 assert data["commit_id"] is None
262 assert data["conflicts"] == []
263 assert "files_changed" in data
264
265 def test_dirty_workdir_allowed_with_dry_run(self, tmp_path: pathlib.Path) -> None:
266 """--dry-run skips the require_clean_workdir check."""
267 root, _ = self._setup(tmp_path)
268 # Create an uncommitted file to make the working tree dirty
269 (root / "untracked.txt").write_text("dirty")
270 result = runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root), catch_exceptions=False)
271 # Should succeed even with a dirty workdir
272 assert result.exit_code == 0
273
274
275 # ---------------------------------------------------------------------------
276 # three-way with conflicts
277 # ---------------------------------------------------------------------------
278
279
280 class TestDryRunConflict:
281 def _setup(self, tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
282 root, repo_id = _init_repo(tmp_path)
283 shared_obj_v1 = _write_object(root, b"shared v1")
284 base_id = _make_commit(root, repo_id, branch="main", message="base",
285 manifest={"shared.mid": shared_obj_v1})
286 (heads_dir(root) / "feature").write_text(base_id)
287 # Both branches modify the same file differently → conflict
288 shared_main = _write_object(root, b"shared main version")
289 _make_commit(root, repo_id, branch="main", message="main mod",
290 manifest={"shared.mid": shared_main})
291 shared_feat = _write_object(root, b"shared feature version")
292 _make_commit(root, repo_id, branch="feature", message="feat mod",
293 manifest={"shared.mid": shared_feat})
294 return root, repo_id
295
296 def test_exit_code_nonzero(self, tmp_path: pathlib.Path) -> None:
297 root, _ = self._setup(tmp_path)
298 result = runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root))
299 assert result.exit_code != 0
300
301 def test_no_merge_state_written(self, tmp_path: pathlib.Path) -> None:
302 root, _ = self._setup(tmp_path)
303 merge_state = merge_state_path(root)
304 runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root))
305 assert not merge_state.exists()
306
307 def test_ref_not_modified_on_conflict(self, tmp_path: pathlib.Path) -> None:
308 root, _ = self._setup(tmp_path)
309 before = _head_ref(root, "main")
310 runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root))
311 assert _head_ref(root, "main") == before
312
313 def test_json_conflict_status_and_dry_run(self, tmp_path: pathlib.Path) -> None:
314 root, _ = self._setup(tmp_path)
315 result = runner.invoke(cli, ["merge", "--dry-run", "--json", "feature"],
316 env=_env(root))
317 data = json.loads(result.output)
318 assert data["status"] == "conflict"
319 assert data["dry_run"] is True
320 assert len(data["conflicts"]) > 0
321
322 def test_live_merge_after_dry_run_still_reports_conflict(self, tmp_path: pathlib.Path) -> None:
323 """Dry-run must not leave any state that affects a subsequent live merge."""
324 root, _ = self._setup(tmp_path)
325 # dry-run first
326 runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root))
327 # live merge
328 live = runner.invoke(cli, ["merge", "feature"], env=_env(root))
329 assert live.exit_code != 0 # still conflicts
330
331
332 # ---------------------------------------------------------------------------
333 # semver impact (Muse-unique)
334 # ---------------------------------------------------------------------------
335
336
337 class TestDryRunSemverImpact:
338 """The semver_impact field is Muse-unique: git has no equivalent."""
339
340 def test_json_includes_semver_impact_key(self, tmp_path: pathlib.Path) -> None:
341 root, repo_id = _init_repo(tmp_path)
342 base_id = _make_commit(root, repo_id, branch="main", message="base")
343 (heads_dir(root) / "feature").write_text(base_id)
344 obj = _write_object(root, b"data")
345 _make_commit(root, repo_id, branch="feature", message="feat", manifest={"f.mid": obj})
346 result = runner.invoke(cli, ["merge", "--dry-run", "--json", "feature"],
347 env=_env(root), catch_exceptions=False)
348 data = json.loads(result.output)
349 assert "semver_impact" in data
350
351
352 # ---------------------------------------------------------------------------
353 # Live merge unaffected by --dry-run flag absence
354 # ---------------------------------------------------------------------------
355
356
357 class TestDryRunFlagAbsent:
358 def test_live_merge_still_commits(self, tmp_path: pathlib.Path) -> None:
359 root, repo_id = _init_repo(tmp_path)
360 base_id = _make_commit(root, repo_id, branch="main", message="base")
361 (heads_dir(root) / "feature").write_text(base_id)
362 _make_commit(root, repo_id, branch="feature", message="feat")
363 before = _head_ref(root, "main")
364 runner.invoke(cli, ["merge", "feature"], env=_env(root), catch_exceptions=False)
365 assert _head_ref(root, "main") != before
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 120 days ago