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