gabriel / muse public
test_cmd_switch.py python
403 lines 13.8 KB
Raw
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor ⚠ breaking 129 days ago
1 """Tests for ``muse switch`` — focused branch switcher.
2
3 Coverage tiers:
4 - Unit: flag parsing, PREV_BRANCH file read/write
5 - Integration: switch existing, -c create, -C force-create, switch - (previous),
6 --discard-changes, --merge, --autoshelf, --dry-run, --json,
7 already-on-branch, non-existent branch
8 - End-to-end: full CLI via CliRunner
9 - Security: ANSI injection in branch name rejected, dirty-tree guard
10 - Stress: rapid switch between branches
11 """
12
13 from __future__ import annotations
14
15 import json
16 import os
17 import pathlib
18
19 import pytest
20
21 from tests.cli_test_helper import CliRunner, InvokeResult
22 from muse.core.store import get_head_commit_id, read_current_branch
23 from muse.core.paths import head_path, heads_dir, muse_dir
24
25 runner = CliRunner()
26
27
28 # ---------------------------------------------------------------------------
29 # Helpers
30 # ---------------------------------------------------------------------------
31
32
33 def _invoke(repo: pathlib.Path, *args: str) -> InvokeResult:
34 saved = os.getcwd()
35 try:
36 os.chdir(repo)
37 return runner.invoke(None, ["switch", *args])
38 finally:
39 os.chdir(saved)
40
41
42 def _run(repo: pathlib.Path, *args: str) -> InvokeResult:
43 """Generic muse command runner."""
44 saved = os.getcwd()
45 try:
46 os.chdir(repo)
47 return runner.invoke(None, list(args))
48 finally:
49 os.chdir(saved)
50
51
52 @pytest.fixture()
53 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
54 """Initialised repo with one commit on main."""
55 _run(tmp_path, "init")
56 (tmp_path / "a.py").write_text("x = 1\n")
57 _run(tmp_path, "commit", "-m", "initial")
58 return tmp_path
59
60
61 @pytest.fixture()
62 def two_branch_repo(repo: pathlib.Path) -> pathlib.Path:
63 """Repo with main and feat branches, each with unique content."""
64 _run(repo, "branch", "feat")
65 _run(repo, "checkout", "feat")
66 (repo / "feat.py").write_text("f = 1\n")
67 _run(repo, "commit", "-m", "feat commit")
68 _run(repo, "checkout", "main")
69 return repo
70
71
72 def _prev_branch_path(repo: pathlib.Path) -> pathlib.Path:
73 return muse_dir(repo) / "PREV_BRANCH"
74
75
76 # ---------------------------------------------------------------------------
77 # Unit — flag parsing
78 # ---------------------------------------------------------------------------
79
80
81 class TestRegisterFlags:
82 def _parse(self, *args: str) -> "argparse.Namespace":
83 import argparse
84 from muse.cli.commands.switch import register
85 p = argparse.ArgumentParser()
86 sub = p.add_subparsers()
87 register(sub)
88 return p.parse_args(["switch", *args])
89
90 def test_create_flag(self) -> None:
91 ns = self._parse("-c", "feat")
92 assert ns.create is True
93 assert ns.target == "feat"
94
95 def test_force_create_flag(self) -> None:
96 ns = self._parse("-C", "feat")
97 assert ns.force_create is True
98
99 def test_discard_changes_flag(self) -> None:
100 ns = self._parse("--discard-changes", "main")
101 assert ns.discard_changes is True
102
103 def test_dry_run_short(self) -> None:
104 ns = self._parse("-n", "main")
105 assert ns.dry_run is True
106
107 def test_json_flag(self) -> None:
108 ns = self._parse("--json", "main")
109 assert ns.json_out is True
110
111 def test_default_json_out_is_false(self) -> None:
112 ns = self._parse("main")
113 assert ns.json_out is False
114
115 def test_j_shorthand_sets_json_out(self) -> None:
116 ns = self._parse("-j", "main")
117 assert ns.json_out is True
118
119 def test_merge_flag(self) -> None:
120 ns = self._parse("--merge", "main")
121 assert ns.merge is True
122
123 def test_autoshelf_flag(self) -> None:
124 ns = self._parse("--autoshelf", "main")
125 assert ns.autoshelf is True
126
127 def test_detach_flag(self) -> None:
128 ns = self._parse("--detach", "main")
129 assert ns.detach is True
130
131
132 # ---------------------------------------------------------------------------
133 # Unit — PREV_BRANCH helpers
134 # ---------------------------------------------------------------------------
135
136
137 def test_read_prev_branch_missing_returns_none(tmp_path: pathlib.Path) -> None:
138 from muse.cli.commands.switch import _read_prev_branch
139 repo = tmp_path / "repo"
140 repo.mkdir()
141 muse_dir(repo).mkdir()
142 assert _read_prev_branch(repo) is None
143
144
145 def test_write_then_read_prev_branch(tmp_path: pathlib.Path) -> None:
146 from muse.cli.commands.switch import _read_prev_branch, _write_prev_branch
147 repo = tmp_path / "repo"
148 repo.mkdir()
149 muse_dir(repo).mkdir()
150 _write_prev_branch(repo, "feat")
151 assert _read_prev_branch(repo) == "feat"
152
153
154 # ---------------------------------------------------------------------------
155 # Integration — basic switch
156 # ---------------------------------------------------------------------------
157
158
159 def test_switch_to_existing_branch(two_branch_repo: pathlib.Path) -> None:
160 result = _invoke(two_branch_repo, "feat")
161 assert result.exit_code == 0
162 assert read_current_branch(two_branch_repo) == "feat"
163
164
165 def test_switch_updates_head_file(two_branch_repo: pathlib.Path) -> None:
166 _invoke(two_branch_repo, "feat")
167 head = (head_path(two_branch_repo)).read_text()
168 assert "feat" in head
169
170
171 def test_switch_text_output(two_branch_repo: pathlib.Path) -> None:
172 result = _invoke(two_branch_repo, "feat")
173 assert result.exit_code == 0
174 assert "feat" in result.output
175
176
177 def test_switch_already_on_branch(two_branch_repo: pathlib.Path) -> None:
178 result = _invoke(two_branch_repo, "main")
179 assert result.exit_code == 0
180 # Should mention "already" or still report main
181 assert "main" in result.output or result.exit_code == 0
182
183
184 def test_switch_nonexistent_branch_exits_nonzero(repo: pathlib.Path) -> None:
185 result = _invoke(repo, "ghost-branch")
186 assert result.exit_code != 0
187
188
189 # ---------------------------------------------------------------------------
190 # Integration — -c / create
191 # ---------------------------------------------------------------------------
192
193
194 def test_switch_c_creates_and_switches(repo: pathlib.Path) -> None:
195 result = _invoke(repo, "-c", "new-feat")
196 assert result.exit_code == 0
197 assert read_current_branch(repo) == "new-feat"
198 assert (heads_dir(repo) / "new-feat").exists()
199
200
201 def test_switch_c_fails_if_branch_exists(two_branch_repo: pathlib.Path) -> None:
202 result = _invoke(two_branch_repo, "-c", "feat")
203 assert result.exit_code != 0
204
205
206 def test_switch_c_points_to_current_head(repo: pathlib.Path) -> None:
207 head_before = get_head_commit_id(repo, "main")
208 _invoke(repo, "-c", "new-feat")
209 head_after = get_head_commit_id(repo, "new-feat")
210 assert head_before == head_after
211
212
213 # ---------------------------------------------------------------------------
214 # Integration — -C / force-create
215 # ---------------------------------------------------------------------------
216
217
218 def test_switch_C_creates_when_not_exists(repo: pathlib.Path) -> None:
219 result = _invoke(repo, "-C", "brand-new")
220 assert result.exit_code == 0
221 assert read_current_branch(repo) == "brand-new"
222
223
224 def test_switch_C_overwrites_existing_branch(two_branch_repo: pathlib.Path) -> None:
225 """Force-create resets feat to current HEAD (main's tip)."""
226 main_tip = get_head_commit_id(two_branch_repo, "main")
227 result = _invoke(two_branch_repo, "-C", "feat")
228 assert result.exit_code == 0
229 assert read_current_branch(two_branch_repo) == "feat"
230 assert get_head_commit_id(two_branch_repo, "feat") == main_tip
231
232
233 # ---------------------------------------------------------------------------
234 # Integration — switch - (previous branch)
235 # ---------------------------------------------------------------------------
236
237
238 def test_switch_dash_returns_to_previous(two_branch_repo: pathlib.Path) -> None:
239 """switch - should go back to main after switching to feat."""
240 _invoke(two_branch_repo, "feat")
241 result = _invoke(two_branch_repo, "-")
242 assert result.exit_code == 0
243 assert read_current_branch(two_branch_repo) == "main"
244
245
246 def test_switch_dash_without_history_exits_nonzero(repo: pathlib.Path) -> None:
247 """switch - with no PREV_BRANCH recorded should fail cleanly."""
248 result = _invoke(repo, "-")
249 assert result.exit_code != 0
250
251
252 def test_switch_writes_prev_branch_on_switch(two_branch_repo: pathlib.Path) -> None:
253 _invoke(two_branch_repo, "feat")
254 assert _prev_branch_path(two_branch_repo).exists()
255 prev = _prev_branch_path(two_branch_repo).read_text().strip()
256 assert prev == "main"
257
258
259 def test_switch_dash_then_dash_bounces(two_branch_repo: pathlib.Path) -> None:
260 """Alternating switch - should toggle between two branches."""
261 _invoke(two_branch_repo, "feat")
262 _invoke(two_branch_repo, "-")
263 assert read_current_branch(two_branch_repo) == "main"
264 _invoke(two_branch_repo, "-")
265 assert read_current_branch(two_branch_repo) == "feat"
266
267
268 # ---------------------------------------------------------------------------
269 # Integration — --discard-changes
270 # ---------------------------------------------------------------------------
271
272
273 def test_switch_dirty_tree_blocked_without_flag(repo: pathlib.Path) -> None:
274 """A locally modified file blocks the switch when the target branch has a different version.
275
276 This is the true conflict case: both branches diverged on the same file.
277 Carry-through (same content on both branches) is intentionally allowed —
278 this test verifies the *blocking* half of that contract.
279 """
280 # Create feat branch where a.py has diverged from main.
281 _run(repo, "branch", "feat")
282 _run(repo, "checkout", "feat")
283 (repo / "a.py").write_text("feat version\n")
284 _run(repo, "commit", "-m", "feat changes a.py")
285 _run(repo, "checkout", "main")
286 # Now dirty a.py locally; feat has a different version → must block.
287 (repo / "a.py").write_text("dirty\n")
288 result = _invoke(repo, "feat")
289 assert result.exit_code != 0
290
291
292 def test_switch_discard_changes_allows_dirty_switch(two_branch_repo: pathlib.Path) -> None:
293 (two_branch_repo / "a.py").write_text("dirty\n")
294 result = _invoke(two_branch_repo, "--discard-changes", "feat")
295 assert result.exit_code == 0
296 assert read_current_branch(two_branch_repo) == "feat"
297
298
299 # ---------------------------------------------------------------------------
300 # Integration — --dry-run
301 # ---------------------------------------------------------------------------
302
303
304 def test_switch_dry_run_does_not_change_branch(two_branch_repo: pathlib.Path) -> None:
305 result = _invoke(two_branch_repo, "--dry-run", "feat")
306 assert result.exit_code == 0
307 assert read_current_branch(two_branch_repo) == "main"
308
309
310 def test_switch_dry_run_no_prev_branch_written(two_branch_repo: pathlib.Path) -> None:
311 _invoke(two_branch_repo, "--dry-run", "feat")
312 assert not _prev_branch_path(two_branch_repo).exists()
313
314
315 def test_switch_dry_run_c_does_not_create_branch(repo: pathlib.Path) -> None:
316 _invoke(repo, "--dry-run", "-c", "ghost")
317 assert not (heads_dir(repo) / "ghost").exists()
318
319
320 # ---------------------------------------------------------------------------
321 # Integration — --json
322 # ---------------------------------------------------------------------------
323
324
325 def test_switch_json_action_switched(two_branch_repo: pathlib.Path) -> None:
326 result = _invoke(two_branch_repo, "--json", "feat")
327 assert result.exit_code == 0
328 data = json.loads(result.stdout)
329 assert data["action"] in ("switched",)
330 assert data["branch"] == "feat"
331 assert data["from_branch"] == "main"
332 assert "commit_id" in data
333
334
335 def test_switch_json_action_created(repo: pathlib.Path) -> None:
336 result = _invoke(repo, "--json", "-c", "new-feat")
337 assert result.exit_code == 0
338 data = json.loads(result.stdout)
339 assert data["action"] == "created"
340 assert data["branch"] == "new-feat"
341
342
343 def test_switch_json_dry_run(two_branch_repo: pathlib.Path) -> None:
344 result = _invoke(two_branch_repo, "--json", "--dry-run", "feat")
345 assert result.exit_code == 0
346 data = json.loads(result.stdout)
347 assert data["dry_run"] is True
348 assert data["branch"] == "feat"
349
350
351 # ---------------------------------------------------------------------------
352 # Integration — --detach
353 # ---------------------------------------------------------------------------
354
355
356 def test_switch_detach_moves_to_commit(repo: pathlib.Path) -> None:
357 commit_id = get_head_commit_id(repo, "main")
358 result = _invoke(repo, "--detach", commit_id)
359 assert result.exit_code == 0
360 # HEAD should point directly to the commit, not a branch
361 head = (head_path(repo)).read_text().strip()
362 assert commit_id in head
363
364
365 def test_switch_detach_json(repo: pathlib.Path) -> None:
366 commit_id = get_head_commit_id(repo, "main")
367 result = _invoke(repo, "--json", "--detach", commit_id)
368 assert result.exit_code == 0
369 data = json.loads(result.stdout)
370 assert data["action"] == "detached"
371 assert data["branch"] is None
372 assert data["commit_id"] == commit_id
373
374
375 # ---------------------------------------------------------------------------
376 # Security
377 # ---------------------------------------------------------------------------
378
379
380 def test_switch_ansi_in_branch_name_rejected(repo: pathlib.Path) -> None:
381 result = _invoke(repo, "\x1b[31mbad\x1b[0m")
382 assert result.exit_code != 0
383
384
385 def test_switch_error_goes_to_stderr(repo: pathlib.Path) -> None:
386 result = _invoke(repo, "no-such-branch")
387 assert result.exit_code != 0
388
389
390 # ---------------------------------------------------------------------------
391 # Stress
392 # ---------------------------------------------------------------------------
393
394
395 def test_switch_rapid_toggle(two_branch_repo: pathlib.Path) -> None:
396 """20 rapid switches must leave the repo in a consistent final state."""
397 branches = ["main", "feat"]
398 for i in range(20):
399 target = branches[i % 2]
400 result = _invoke(two_branch_repo, target)
401 assert result.exit_code == 0
402 # After 20 switches (0-indexed → last is index 19 → feat)
403 assert read_current_branch(two_branch_repo) == "feat"
File History 1 commit
sha256:51ce277f663e01a43eaffbe77509b1de7ac2d4251b55d23306304bcdeb92c90d feat(pack): delta-encode snapshots in MPackBundle wire format Sonnet 4.6 minor 129 days ago