gabriel / muse public
test_cmd_merge_dry_run.py python
367 lines 16.7 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 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
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 = str(uuid.uuid4())
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 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 import hashlib
86 obj_id = hashlib.sha256(content).hexdigest()
87 obj_path = root / ".muse" / "objects" / obj_id[:2] / obj_id[2:]
88 obj_path.parent.mkdir(parents=True, exist_ok=True)
89 obj_path.write_bytes(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 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago