gabriel / muse public
test_cmd_merge_dry_run.py python
372 lines 16.4 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 20 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.commits import (
62 CommitRecord,
63 write_commit,
64 )
65 from muse.core.snapshots import (
66 SnapshotRecord,
67 write_snapshot,
68 )
69 from muse.core.ids import hash_snapshot, hash_commit
70
71 ref_file = ref_path(root, branch)
72 parent_id = ref_file.read_text().strip() if ref_file.exists() else None
73 m = manifest or {}
74 snap_id = hash_snapshot(m)
75 committed_at = datetime.datetime.now(datetime.timezone.utc)
76 commit_id = hash_commit( parent_ids=[parent_id] if parent_id else [],
77 snapshot_id=snap_id, message=message,
78 committed_at_iso=committed_at.isoformat(),
79 )
80 write_snapshot(root, SnapshotRecord(snapshot_id=snap_id, manifest=m))
81 write_commit(root, CommitRecord(
82 commit_id=commit_id, branch=branch,
83 snapshot_id=snap_id, message=message, committed_at=committed_at,
84 parent_commit_id=parent_id,
85 ))
86 ref_file.parent.mkdir(parents=True, exist_ok=True)
87 ref_file.write_text(commit_id, encoding="utf-8")
88 return commit_id
89
90
91 def _write_object(root: pathlib.Path, content: bytes) -> str:
92 from muse.core.object_store import write_object
93 obj_id = blob_id(content)
94 write_object(root, obj_id, content)
95 return obj_id
96
97
98 def _head_ref(root: pathlib.Path, branch: str = "main") -> str:
99 return (ref_path(root, branch)).read_text().strip()
100
101
102 # ---------------------------------------------------------------------------
103 # up-to-date
104 # ---------------------------------------------------------------------------
105
106
107 class TestDryRunUpToDate:
108 def test_text_output(self, tmp_path: pathlib.Path) -> None:
109 root, repo_id = _init_repo(tmp_path)
110 commit_id = _make_commit(root, repo_id, branch="main")
111 # feature branch = same commit
112 (heads_dir(root) / "feature").write_text(commit_id)
113
114 result = runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root), catch_exceptions=False)
115 assert result.exit_code == 0
116 assert "up to date" in result.output.lower()
117
118 def test_json_output_has_dry_run_true(self, tmp_path: pathlib.Path) -> None:
119 root, repo_id = _init_repo(tmp_path)
120 commit_id = _make_commit(root, repo_id, branch="main")
121 (heads_dir(root) / "feature").write_text(commit_id)
122
123 result = runner.invoke(cli, ["merge", "--dry-run", "--json", "feature"],
124 env=_env(root), catch_exceptions=False)
125 assert result.exit_code == 0
126 data = json.loads(result.output)
127 assert data["status"] == "up_to_date"
128 assert data["dry_run"] is True
129
130 def test_refs_not_modified(self, tmp_path: pathlib.Path) -> None:
131 root, repo_id = _init_repo(tmp_path)
132 commit_id = _make_commit(root, repo_id, branch="main")
133 (heads_dir(root) / "feature").write_text(commit_id)
134
135 before = _head_ref(root, "main")
136 runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root), catch_exceptions=False)
137 assert _head_ref(root, "main") == before
138
139
140 # ---------------------------------------------------------------------------
141 # fast-forward
142 # ---------------------------------------------------------------------------
143
144
145 class TestDryRunFastForward:
146 def _setup(self, tmp_path: pathlib.Path) -> tuple[pathlib.Path, str, str, str]:
147 root, repo_id = _init_repo(tmp_path)
148 base_id = _make_commit(root, repo_id, branch="main", message="base")
149 (heads_dir(root) / "feature").write_text(base_id)
150 obj = _write_object(root, b"new track data")
151 feature_id = _make_commit(root, repo_id, branch="feature",
152 message="add track", manifest={"track.mid": obj})
153 return root, repo_id, base_id, feature_id
154
155 def test_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
156 root, _, _, _ = self._setup(tmp_path)
157 result = runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root), catch_exceptions=False)
158 assert result.exit_code == 0
159
160 def test_main_ref_not_advanced(self, tmp_path: pathlib.Path) -> None:
161 root, _, base_id, _ = self._setup(tmp_path)
162 runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root), catch_exceptions=False)
163 # main must still point to base, not the feature commit
164 assert _head_ref(root, "main") == base_id
165
166 def test_working_tree_not_modified(self, tmp_path: pathlib.Path) -> None:
167 root, _, _, _ = self._setup(tmp_path)
168 # No files should appear in the repo root after dry-run
169 runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root), catch_exceptions=False)
170 assert not (root / "track.mid").exists()
171
172 def test_no_reflog_entry_written(self, tmp_path: pathlib.Path) -> None:
173 root, _, _, _ = self._setup(tmp_path)
174 reflog = logs_dir(root) / "refs" / "heads" / "main"
175 existed_before = reflog.exists()
176 runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root), catch_exceptions=False)
177 if not existed_before:
178 assert not reflog.exists()
179 else:
180 # If it existed, ensure no new entry was appended for the dry-run
181 lines_before = reflog.read_text().splitlines() if reflog.exists() else []
182 runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root))
183 lines_after = reflog.read_text().splitlines() if reflog.exists() else []
184 assert len(lines_after) == len(lines_before)
185
186 def test_text_mentions_would_fast_forward(self, tmp_path: pathlib.Path) -> None:
187 root, _, _, _ = self._setup(tmp_path)
188 result = runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root), catch_exceptions=False)
189 assert "would fast-forward" in result.output.lower() or "dry-run" in result.output.lower()
190
191 def test_json_status_and_dry_run_field(self, tmp_path: pathlib.Path) -> None:
192 root, _, base_id, feature_id = self._setup(tmp_path)
193 result = runner.invoke(cli, ["merge", "--dry-run", "--json", "feature"],
194 env=_env(root), catch_exceptions=False)
195 assert result.exit_code == 0
196 data = json.loads(result.output)
197 assert data["status"] == "fast_forward"
198 assert data["dry_run"] is True
199 # commit_id is None in dry-run (nothing committed)
200 assert data["commit_id"] is None
201 assert "files_changed" in data
202
203 def test_files_changed_stats_correct(self, tmp_path: pathlib.Path) -> None:
204 root, _, _, _ = self._setup(tmp_path)
205 result = runner.invoke(cli, ["merge", "--dry-run", "--json", "feature"],
206 env=_env(root), catch_exceptions=False)
207 data = json.loads(result.output)
208 fc = data["files_changed"]
209 assert fc["added"] == 1
210 assert fc["modified"] == 0
211 assert fc["deleted"] == 0
212
213
214 # ---------------------------------------------------------------------------
215 # three-way clean merge
216 # ---------------------------------------------------------------------------
217
218
219 class TestDryRunThreeWayClean:
220 def _setup(self, tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
221 root, repo_id = _init_repo(tmp_path)
222 base_obj = _write_object(root, b"base track")
223 base_id = _make_commit(root, repo_id, branch="main", message="base",
224 manifest={"base.mid": base_obj})
225 (heads_dir(root) / "feature").write_text(base_id)
226 # main and feature both diverge from base — true three-way
227 main_obj = _write_object(root, b"main track addition")
228 _make_commit(root, repo_id, branch="main", message="main work",
229 manifest={"base.mid": base_obj, "main.mid": main_obj})
230 feat_obj = _write_object(root, b"feature track addition")
231 _make_commit(root, repo_id, branch="feature", message="feat work",
232 manifest={"base.mid": base_obj, "feat.mid": feat_obj})
233 return root, repo_id
234
235 def test_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
236 root, _ = self._setup(tmp_path)
237 result = runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root), catch_exceptions=False)
238 assert result.exit_code == 0
239
240 def test_main_ref_not_advanced(self, tmp_path: pathlib.Path) -> None:
241 root, _ = self._setup(tmp_path)
242 before = _head_ref(root, "main")
243 runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root), catch_exceptions=False)
244 assert _head_ref(root, "main") == before
245
246 def test_no_new_snapshot_written(self, tmp_path: pathlib.Path) -> None:
247 root, _ = self._setup(tmp_path)
248 snaps_before = set((snapshots_dir(root)).iterdir())
249 runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root), catch_exceptions=False)
250 snaps_after = set((snapshots_dir(root)).iterdir())
251 assert snaps_after == snaps_before
252
253 def test_no_new_commit_written(self, tmp_path: pathlib.Path) -> None:
254 root, _ = self._setup(tmp_path)
255 commits_before = set((commits_dir(root)).iterdir())
256 runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root), catch_exceptions=False)
257 commits_after = set((commits_dir(root)).iterdir())
258 assert commits_after == commits_before
259
260 def test_json_dry_run_true_and_no_commit_id(self, tmp_path: pathlib.Path) -> None:
261 root, _ = self._setup(tmp_path)
262 result = runner.invoke(cli, ["merge", "--dry-run", "--json", "feature"],
263 env=_env(root), catch_exceptions=False)
264 assert result.exit_code == 0
265 data = json.loads(result.output)
266 assert data["status"] == "merged"
267 assert data["dry_run"] is True
268 assert data["commit_id"] is None
269 assert data["conflicts"] == []
270 assert "files_changed" in data
271
272 def test_dirty_workdir_allowed_with_dry_run(self, tmp_path: pathlib.Path) -> None:
273 """--dry-run skips the require_clean_workdir check."""
274 root, _ = self._setup(tmp_path)
275 # Create an uncommitted file to make the working tree dirty
276 (root / "untracked.txt").write_text("dirty")
277 result = runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root), catch_exceptions=False)
278 # Should succeed even with a dirty workdir
279 assert result.exit_code == 0
280
281
282 # ---------------------------------------------------------------------------
283 # three-way with conflicts
284 # ---------------------------------------------------------------------------
285
286
287 class TestDryRunConflict:
288 def _setup(self, tmp_path: pathlib.Path) -> tuple[pathlib.Path, str]:
289 root, repo_id = _init_repo(tmp_path)
290 shared_obj_v1 = _write_object(root, b"shared v1")
291 base_id = _make_commit(root, repo_id, branch="main", message="base",
292 manifest={"shared.mid": shared_obj_v1})
293 (heads_dir(root) / "feature").write_text(base_id)
294 # Both branches modify the same file differently → conflict
295 shared_main = _write_object(root, b"shared main version")
296 _make_commit(root, repo_id, branch="main", message="main mod",
297 manifest={"shared.mid": shared_main})
298 shared_feat = _write_object(root, b"shared feature version")
299 _make_commit(root, repo_id, branch="feature", message="feat mod",
300 manifest={"shared.mid": shared_feat})
301 return root, repo_id
302
303 def test_exit_code_nonzero(self, tmp_path: pathlib.Path) -> None:
304 root, _ = self._setup(tmp_path)
305 result = runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root))
306 assert result.exit_code != 0
307
308 def test_no_merge_state_written(self, tmp_path: pathlib.Path) -> None:
309 root, _ = self._setup(tmp_path)
310 merge_state = merge_state_path(root)
311 runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root))
312 assert not merge_state.exists()
313
314 def test_ref_not_modified_on_conflict(self, tmp_path: pathlib.Path) -> None:
315 root, _ = self._setup(tmp_path)
316 before = _head_ref(root, "main")
317 runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root))
318 assert _head_ref(root, "main") == before
319
320 def test_json_conflict_status_and_dry_run(self, tmp_path: pathlib.Path) -> None:
321 root, _ = self._setup(tmp_path)
322 result = runner.invoke(cli, ["merge", "--dry-run", "--json", "feature"],
323 env=_env(root))
324 data = json.loads(result.output)
325 assert data["status"] == "conflict"
326 assert data["dry_run"] is True
327 assert len(data["conflicts"]) > 0
328
329 def test_live_merge_after_dry_run_still_reports_conflict(self, tmp_path: pathlib.Path) -> None:
330 """Dry-run must not leave any state that affects a subsequent live merge."""
331 root, _ = self._setup(tmp_path)
332 # dry-run first
333 runner.invoke(cli, ["merge", "--dry-run", "feature"], env=_env(root))
334 # live merge
335 live = runner.invoke(cli, ["merge", "feature"], env=_env(root))
336 assert live.exit_code != 0 # still conflicts
337
338
339 # ---------------------------------------------------------------------------
340 # semver impact (Muse-unique)
341 # ---------------------------------------------------------------------------
342
343
344 class TestDryRunSemverImpact:
345 """The semver_impact field is Muse-unique: git has no equivalent."""
346
347 def test_json_includes_semver_impact_key(self, tmp_path: pathlib.Path) -> None:
348 root, repo_id = _init_repo(tmp_path)
349 base_id = _make_commit(root, repo_id, branch="main", message="base")
350 (heads_dir(root) / "feature").write_text(base_id)
351 obj = _write_object(root, b"data")
352 _make_commit(root, repo_id, branch="feature", message="feat", manifest={"f.mid": obj})
353 result = runner.invoke(cli, ["merge", "--dry-run", "--json", "feature"],
354 env=_env(root), catch_exceptions=False)
355 data = json.loads(result.output)
356 assert "semver_impact" in data
357
358
359 # ---------------------------------------------------------------------------
360 # Live merge unaffected by --dry-run flag absence
361 # ---------------------------------------------------------------------------
362
363
364 class TestDryRunFlagAbsent:
365 def test_live_merge_still_commits(self, tmp_path: pathlib.Path) -> None:
366 root, repo_id = _init_repo(tmp_path)
367 base_id = _make_commit(root, repo_id, branch="main", message="base")
368 (heads_dir(root) / "feature").write_text(base_id)
369 _make_commit(root, repo_id, branch="feature", message="feat")
370 before = _head_ref(root, "main")
371 runner.invoke(cli, ["merge", "feature"], env=_env(root), catch_exceptions=False)
372 assert _head_ref(root, "main") != before
File History 4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 20 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 22 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 28 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 28 days ago