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