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