gabriel / muse public
test_cmd_switch.py python
394 lines 13.5 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 140 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.output_json is True
109
110 def test_merge_flag(self) -> None:
111 ns = self._parse("--merge", "main")
112 assert ns.merge is True
113
114 def test_autoshelf_flag(self) -> None:
115 ns = self._parse("--autoshelf", "main")
116 assert ns.autoshelf is True
117
118 def test_detach_flag(self) -> None:
119 ns = self._parse("--detach", "main")
120 assert ns.detach is True
121
122
123 # ---------------------------------------------------------------------------
124 # Unit — PREV_BRANCH helpers
125 # ---------------------------------------------------------------------------
126
127
128 def test_read_prev_branch_missing_returns_none(tmp_path: pathlib.Path) -> None:
129 from muse.cli.commands.switch import _read_prev_branch
130 repo = tmp_path / "repo"
131 repo.mkdir()
132 (repo / ".muse").mkdir()
133 assert _read_prev_branch(repo) is None
134
135
136 def test_write_then_read_prev_branch(tmp_path: pathlib.Path) -> None:
137 from muse.cli.commands.switch import _read_prev_branch, _write_prev_branch
138 repo = tmp_path / "repo"
139 repo.mkdir()
140 (repo / ".muse").mkdir()
141 _write_prev_branch(repo, "feat")
142 assert _read_prev_branch(repo) == "feat"
143
144
145 # ---------------------------------------------------------------------------
146 # Integration — basic switch
147 # ---------------------------------------------------------------------------
148
149
150 def test_switch_to_existing_branch(two_branch_repo: pathlib.Path) -> None:
151 result = _invoke(two_branch_repo, "feat")
152 assert result.exit_code == 0
153 assert read_current_branch(two_branch_repo) == "feat"
154
155
156 def test_switch_updates_head_file(two_branch_repo: pathlib.Path) -> None:
157 _invoke(two_branch_repo, "feat")
158 head = (two_branch_repo / ".muse" / "HEAD").read_text()
159 assert "feat" in head
160
161
162 def test_switch_text_output(two_branch_repo: pathlib.Path) -> None:
163 result = _invoke(two_branch_repo, "feat")
164 assert result.exit_code == 0
165 assert "feat" in result.output
166
167
168 def test_switch_already_on_branch(two_branch_repo: pathlib.Path) -> None:
169 result = _invoke(two_branch_repo, "main")
170 assert result.exit_code == 0
171 # Should mention "already" or still report main
172 assert "main" in result.output or result.exit_code == 0
173
174
175 def test_switch_nonexistent_branch_exits_nonzero(repo: pathlib.Path) -> None:
176 result = _invoke(repo, "ghost-branch")
177 assert result.exit_code != 0
178
179
180 # ---------------------------------------------------------------------------
181 # Integration — -c / create
182 # ---------------------------------------------------------------------------
183
184
185 def test_switch_c_creates_and_switches(repo: pathlib.Path) -> None:
186 result = _invoke(repo, "-c", "new-feat")
187 assert result.exit_code == 0
188 assert read_current_branch(repo) == "new-feat"
189 assert (repo / ".muse" / "refs" / "heads" / "new-feat").exists()
190
191
192 def test_switch_c_fails_if_branch_exists(two_branch_repo: pathlib.Path) -> None:
193 result = _invoke(two_branch_repo, "-c", "feat")
194 assert result.exit_code != 0
195
196
197 def test_switch_c_points_to_current_head(repo: pathlib.Path) -> None:
198 head_before = get_head_commit_id(repo, "main")
199 _invoke(repo, "-c", "new-feat")
200 head_after = get_head_commit_id(repo, "new-feat")
201 assert head_before == head_after
202
203
204 # ---------------------------------------------------------------------------
205 # Integration — -C / force-create
206 # ---------------------------------------------------------------------------
207
208
209 def test_switch_C_creates_when_not_exists(repo: pathlib.Path) -> None:
210 result = _invoke(repo, "-C", "brand-new")
211 assert result.exit_code == 0
212 assert read_current_branch(repo) == "brand-new"
213
214
215 def test_switch_C_overwrites_existing_branch(two_branch_repo: pathlib.Path) -> None:
216 """Force-create resets feat to current HEAD (main's tip)."""
217 main_tip = get_head_commit_id(two_branch_repo, "main")
218 result = _invoke(two_branch_repo, "-C", "feat")
219 assert result.exit_code == 0
220 assert read_current_branch(two_branch_repo) == "feat"
221 assert get_head_commit_id(two_branch_repo, "feat") == main_tip
222
223
224 # ---------------------------------------------------------------------------
225 # Integration — switch - (previous branch)
226 # ---------------------------------------------------------------------------
227
228
229 def test_switch_dash_returns_to_previous(two_branch_repo: pathlib.Path) -> None:
230 """switch - should go back to main after switching to feat."""
231 _invoke(two_branch_repo, "feat")
232 result = _invoke(two_branch_repo, "-")
233 assert result.exit_code == 0
234 assert read_current_branch(two_branch_repo) == "main"
235
236
237 def test_switch_dash_without_history_exits_nonzero(repo: pathlib.Path) -> None:
238 """switch - with no PREV_BRANCH recorded should fail cleanly."""
239 result = _invoke(repo, "-")
240 assert result.exit_code != 0
241
242
243 def test_switch_writes_prev_branch_on_switch(two_branch_repo: pathlib.Path) -> None:
244 _invoke(two_branch_repo, "feat")
245 assert _prev_branch_path(two_branch_repo).exists()
246 prev = _prev_branch_path(two_branch_repo).read_text().strip()
247 assert prev == "main"
248
249
250 def test_switch_dash_then_dash_bounces(two_branch_repo: pathlib.Path) -> None:
251 """Alternating switch - should toggle between two branches."""
252 _invoke(two_branch_repo, "feat")
253 _invoke(two_branch_repo, "-")
254 assert read_current_branch(two_branch_repo) == "main"
255 _invoke(two_branch_repo, "-")
256 assert read_current_branch(two_branch_repo) == "feat"
257
258
259 # ---------------------------------------------------------------------------
260 # Integration — --discard-changes
261 # ---------------------------------------------------------------------------
262
263
264 def test_switch_dirty_tree_blocked_without_flag(repo: pathlib.Path) -> None:
265 """A locally modified file blocks the switch when the target branch has a different version.
266
267 This is the true conflict case: both branches diverged on the same file.
268 Carry-through (same content on both branches) is intentionally allowed —
269 this test verifies the *blocking* half of that contract.
270 """
271 # Create feat branch where a.py has diverged from main.
272 _run(repo, "branch", "feat")
273 _run(repo, "checkout", "feat")
274 (repo / "a.py").write_text("feat version\n")
275 _run(repo, "commit", "-m", "feat changes a.py")
276 _run(repo, "checkout", "main")
277 # Now dirty a.py locally; feat has a different version → must block.
278 (repo / "a.py").write_text("dirty\n")
279 result = _invoke(repo, "feat")
280 assert result.exit_code != 0
281
282
283 def test_switch_discard_changes_allows_dirty_switch(two_branch_repo: pathlib.Path) -> None:
284 (two_branch_repo / "a.py").write_text("dirty\n")
285 result = _invoke(two_branch_repo, "--discard-changes", "feat")
286 assert result.exit_code == 0
287 assert read_current_branch(two_branch_repo) == "feat"
288
289
290 # ---------------------------------------------------------------------------
291 # Integration — --dry-run
292 # ---------------------------------------------------------------------------
293
294
295 def test_switch_dry_run_does_not_change_branch(two_branch_repo: pathlib.Path) -> None:
296 result = _invoke(two_branch_repo, "--dry-run", "feat")
297 assert result.exit_code == 0
298 assert read_current_branch(two_branch_repo) == "main"
299
300
301 def test_switch_dry_run_no_prev_branch_written(two_branch_repo: pathlib.Path) -> None:
302 _invoke(two_branch_repo, "--dry-run", "feat")
303 assert not _prev_branch_path(two_branch_repo).exists()
304
305
306 def test_switch_dry_run_c_does_not_create_branch(repo: pathlib.Path) -> None:
307 _invoke(repo, "--dry-run", "-c", "ghost")
308 assert not (repo / ".muse" / "refs" / "heads" / "ghost").exists()
309
310
311 # ---------------------------------------------------------------------------
312 # Integration — --json
313 # ---------------------------------------------------------------------------
314
315
316 def test_switch_json_action_switched(two_branch_repo: pathlib.Path) -> None:
317 result = _invoke(two_branch_repo, "--json", "feat")
318 assert result.exit_code == 0
319 data = json.loads(result.stdout)
320 assert data["action"] in ("switched",)
321 assert data["branch"] == "feat"
322 assert data["from_branch"] == "main"
323 assert "commit_id" in data
324
325
326 def test_switch_json_action_created(repo: pathlib.Path) -> None:
327 result = _invoke(repo, "--json", "-c", "new-feat")
328 assert result.exit_code == 0
329 data = json.loads(result.stdout)
330 assert data["action"] == "created"
331 assert data["branch"] == "new-feat"
332
333
334 def test_switch_json_dry_run(two_branch_repo: pathlib.Path) -> None:
335 result = _invoke(two_branch_repo, "--json", "--dry-run", "feat")
336 assert result.exit_code == 0
337 data = json.loads(result.stdout)
338 assert data["dry_run"] is True
339 assert data["branch"] == "feat"
340
341
342 # ---------------------------------------------------------------------------
343 # Integration — --detach
344 # ---------------------------------------------------------------------------
345
346
347 def test_switch_detach_moves_to_commit(repo: pathlib.Path) -> None:
348 commit_id = get_head_commit_id(repo, "main")
349 result = _invoke(repo, "--detach", commit_id)
350 assert result.exit_code == 0
351 # HEAD should point directly to the commit, not a branch
352 head = (repo / ".muse" / "HEAD").read_text().strip()
353 assert commit_id in head
354
355
356 def test_switch_detach_json(repo: pathlib.Path) -> None:
357 commit_id = get_head_commit_id(repo, "main")
358 result = _invoke(repo, "--json", "--detach", commit_id)
359 assert result.exit_code == 0
360 data = json.loads(result.stdout)
361 assert data["action"] == "detached"
362 assert data["branch"] is None
363 assert data["commit_id"] == commit_id
364
365
366 # ---------------------------------------------------------------------------
367 # Security
368 # ---------------------------------------------------------------------------
369
370
371 def test_switch_ansi_in_branch_name_rejected(repo: pathlib.Path) -> None:
372 result = _invoke(repo, "\x1b[31mbad\x1b[0m")
373 assert result.exit_code != 0
374
375
376 def test_switch_error_goes_to_stderr(repo: pathlib.Path) -> None:
377 result = _invoke(repo, "no-such-branch")
378 assert result.exit_code != 0
379
380
381 # ---------------------------------------------------------------------------
382 # Stress
383 # ---------------------------------------------------------------------------
384
385
386 def test_switch_rapid_toggle(two_branch_repo: pathlib.Path) -> None:
387 """20 rapid switches must leave the repo in a consistent final state."""
388 branches = ["main", "feat"]
389 for i in range(20):
390 target = branches[i % 2]
391 result = _invoke(two_branch_repo, target)
392 assert result.exit_code == 0
393 # After 20 switches (0-indexed → last is index 19 → feat)
394 assert read_current_branch(two_branch_repo) == "feat"
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 140 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 143 days ago