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