gabriel / muse public
test_cmd_checkout.py python
1,103 lines 47.8 KB
Raw
1 """Tests for ``muse checkout``.
2
3 Coverage tiers
4 --------------
5 Unit — parser flags, dead-code removal, docstring schema.
6 Integration — switch, create, already_on, detach, --dry-run, conflict resolution.
7 End-to-end — full CLI invocations: text and JSON output, all operations.
8 Security — ANSI injection in target, error routing to stderr.
9 Stress — checkout under high file counts, concurrent checkouts.
10 """
11
12 from __future__ import annotations
13
14 import json
15 import os
16 import pathlib
17 import subprocess
18 import threading
19 import time
20 from typing import TYPE_CHECKING
21
22 import pytest
23
24 from tests.cli_test_helper import CliRunner, InvokeResult
25 from muse.core.store import get_head_commit_id, read_current_branch
26
27 if TYPE_CHECKING:
28 import argparse
29
30 runner = CliRunner()
31
32 # ──────────────────────────────────────────────────────────────────────────────
33 # Helpers
34 # ──────────────────────────────────────────────────────────────────────────────
35
36
37 def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult:
38 saved = os.getcwd()
39 try:
40 os.chdir(repo)
41 return runner.invoke(None, args)
42 finally:
43 os.chdir(saved)
44
45
46 def _checkout(repo: pathlib.Path, *extra: str) -> InvokeResult:
47 return _invoke(repo, ["checkout", *extra])
48
49
50 def _commit(repo: pathlib.Path, *extra: str) -> InvokeResult:
51 return _invoke(repo, ["commit", *extra])
52
53
54 def _branch(repo: pathlib.Path, *extra: str) -> InvokeResult:
55 return _invoke(repo, ["branch", *extra])
56
57
58 @pytest.fixture()
59 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
60 """Initialised repo with one commit on ``main``."""
61 saved = os.getcwd()
62 try:
63 os.chdir(tmp_path)
64 runner.invoke(None, ["init"])
65 finally:
66 os.chdir(saved)
67 (tmp_path / "a.py").write_text("x = 1\n")
68 _commit(tmp_path, "-m", "initial")
69 return tmp_path
70
71
72 @pytest.fixture()
73 def two_branch_repo(repo: pathlib.Path) -> pathlib.Path:
74 """Repo with ``main`` and ``feat`` branches, each with unique content."""
75 _branch(repo, "feat")
76 _checkout(repo, "feat")
77 (repo / "feat.py").write_text("f = 1\n")
78 _commit(repo, "-m", "feat commit")
79 _checkout(repo, "main")
80 return repo
81
82
83 # ──────────────────────────────────────────────────────────────────────────────
84 # Unit — parser flags
85 # ──────────────────────────────────────────────────────────────────────────────
86
87
88 class TestRegisterFlags:
89 def _parse(self, *args: str) -> "argparse.Namespace":
90 import argparse
91
92 from muse.cli.commands.checkout import register
93
94 p = argparse.ArgumentParser()
95 sub = p.add_subparsers()
96 register(sub)
97 return p.parse_args(["checkout", *args])
98
99 def test_default_fmt_is_text(self) -> None:
100 ns = self._parse("main")
101 assert ns.fmt == "text"
102
103 def test_json_flag_sets_fmt(self) -> None:
104 ns = self._parse("main", "--json")
105 assert ns.fmt == "json"
106
107 def test_format_json_flag(self) -> None:
108 ns = self._parse("main", "--format", "json")
109 assert ns.fmt == "json"
110
111 def test_create_flag(self) -> None:
112 ns = self._parse("-b", "new")
113 assert ns.create is True
114
115 def test_force_flag(self) -> None:
116 ns = self._parse("main", "--force")
117 assert ns.force is True
118
119 def test_force_short_flag(self) -> None:
120 ns = self._parse("main", "-f")
121 assert ns.force is True
122
123 def test_dry_run_flag(self) -> None:
124 ns = self._parse("main", "--dry-run")
125 assert ns.dry_run is True
126
127 def test_dry_run_short_flag(self) -> None:
128 ns = self._parse("main", "-n")
129 assert ns.dry_run is True
130
131 def test_dry_run_default_false(self) -> None:
132 ns = self._parse("main")
133 assert ns.dry_run is False
134
135 def test_ours_flag(self) -> None:
136 ns = self._parse("--ours", "file.py")
137 assert ns.resolve_ours is True
138
139 def test_theirs_flag(self) -> None:
140 ns = self._parse("--theirs", "file.py")
141 assert ns.resolve_theirs is True
142
143 def test_all_flag(self) -> None:
144 ns = self._parse("--ours", "--all")
145 assert ns.resolve_all is True
146
147 def test_target_optional(self) -> None:
148 ns = self._parse()
149 assert ns.target is None
150
151
152 # ──────────────────────────────────────────────────────────────────────────────
153 # Unit — dead-code removal
154 # ──────────────────────────────────────────────────────────────────────────────
155
156
157 class TestDeadCodeRemoved:
158 def test_read_current_branch_wrapper_removed(self) -> None:
159 import muse.cli.commands.checkout as m
160
161 assert not hasattr(m, "_read_current_branch"), (
162 "_read_current_branch was a dead one-liner wrapper and must be deleted"
163 )
164
165 def test_inline_sanitize_display_import_removed(self) -> None:
166 import inspect
167
168 import muse.cli.commands.checkout as m
169
170 src = inspect.getsource(m.run)
171 assert "sanitize_display as _sd" not in src, (
172 "The inline 'from muse.core.validation import sanitize_display as _sd' "
173 "inside run() was a redundant re-import of the module-level sanitize_display"
174 )
175
176
177 # ──────────────────────────────────────────────────────────────────────────────
178 # Integration — SWITCH (existing branch)
179 # ──────────────────────────────────────────────────────────────────────────────
180
181
182 class TestSwitch:
183 def test_switch_exits_0(self, two_branch_repo: pathlib.Path) -> None:
184 result = _checkout(two_branch_repo, "feat")
185 assert result.exit_code == 0
186
187 def test_switch_changes_branch(self, two_branch_repo: pathlib.Path) -> None:
188 _checkout(two_branch_repo, "feat")
189 assert read_current_branch(two_branch_repo) == "feat"
190
191 def test_switch_text_output(self, two_branch_repo: pathlib.Path) -> None:
192 result = _checkout(two_branch_repo, "feat")
193 assert "feat" in result.output
194 assert "Switched" in result.output
195
196 def test_switch_json_schema(self, two_branch_repo: pathlib.Path) -> None:
197 result = _checkout(two_branch_repo, "feat", "--json")
198 data = json.loads(result.output)
199 assert data["action"] == "switched"
200 assert data["branch"] == "feat"
201 assert data["from_branch"] == "main"
202 assert "commit_id" in data
203 assert data.get("dry_run") is False
204
205 def test_switch_restores_files(self, two_branch_repo: pathlib.Path) -> None:
206 """Files unique to ``feat`` appear after checkout and disappear on return."""
207 _checkout(two_branch_repo, "feat")
208 assert (two_branch_repo / "feat.py").exists()
209 _checkout(two_branch_repo, "main")
210 assert not (two_branch_repo / "feat.py").exists()
211
212 def test_switch_to_nonexistent_exits_1(self, repo: pathlib.Path) -> None:
213 result = _checkout(repo, "does-not-exist")
214 assert result.exit_code == 1
215
216 def test_switch_error_to_stderr(self, repo: pathlib.Path) -> None:
217 result = _checkout(repo, "ghost")
218 assert result.exit_code == 1
219 # Error must appear in stderr, not exclusively stdout
220 assert "not a branch" in (result.stderr or "").lower()
221
222
223 # ──────────────────────────────────────────────────────────────────────────────
224 # Integration — ALREADY_ON
225 # ──────────────────────────────────────────────────────────────────────────────
226
227
228 class TestAlreadyOn:
229 def test_already_on_exits_0(self, repo: pathlib.Path) -> None:
230 result = _checkout(repo, "main")
231 assert result.exit_code == 0
232
233 def test_already_on_text(self, repo: pathlib.Path) -> None:
234 result = _checkout(repo, "main")
235 assert "Already on" in result.output
236
237 def test_already_on_json_schema(self, repo: pathlib.Path) -> None:
238 result = _checkout(repo, "main", "--json")
239 data = json.loads(result.output)
240 assert data["action"] == "already_on"
241 assert data["branch"] == "main"
242 assert data["from_branch"] == "main"
243 assert "commit_id" in data
244
245
246 # ──────────────────────────────────────────────────────────────────────────────
247 # Integration — FORCE on current branch (working-tree restore)
248 # ──────────────────────────────────────────────────────────────────────────────
249
250
251 class TestForceOnCurrentBranch:
252 """checkout --force <current-branch> must restore the working tree to HEAD.
253
254 Regression: previously this was a no-op ('Already on main') regardless of
255 --force. Git's behaviour is to restore missing/modified tracked files even
256 when the branch is already current.
257 """
258
259 def test_force_current_branch_exits_0(self, repo: pathlib.Path) -> None:
260 result = _checkout(repo, "--force", "main")
261 assert result.exit_code == 0
262
263 def test_force_current_branch_restores_deleted_file(self, repo: pathlib.Path) -> None:
264 (repo / "a.py").unlink()
265 assert not (repo / "a.py").exists()
266 _checkout(repo, "--force", "main")
267 assert (repo / "a.py").exists(), "force checkout must restore deleted tracked file"
268
269 def test_force_current_branch_restores_modified_file(self, repo: pathlib.Path) -> None:
270 original = (repo / "a.py").read_text()
271 (repo / "a.py").write_text("corrupted content\n")
272 _checkout(repo, "--force", "main")
273 assert (repo / "a.py").read_text() == original, (
274 "force checkout must restore modified tracked file to HEAD content"
275 )
276
277 def test_force_current_branch_text_output(self, repo: pathlib.Path) -> None:
278 result = _checkout(repo, "--force", "main")
279 assert "restored" in result.output
280
281 def test_force_current_branch_json_action(self, repo: pathlib.Path) -> None:
282 result = _checkout(repo, "--force", "main", "--json")
283 data = json.loads(result.output)
284 assert data["action"] == "restored"
285 assert data["branch"] == "main"
286
287 def test_force_current_branch_dry_run_does_not_restore(
288 self, repo: pathlib.Path
289 ) -> None:
290 (repo / "a.py").unlink()
291 _checkout(repo, "--force", "--dry-run", "main")
292 assert not (repo / "a.py").exists(), (
293 "--dry-run must not actually restore files"
294 )
295
296 def test_force_current_branch_dry_run_json(self, repo: pathlib.Path) -> None:
297 result = _checkout(repo, "--force", "--dry-run", "main", "--json")
298 data = json.loads(result.output)
299 assert data["dry_run"] is True
300 assert data["action"] == "restored"
301
302 def test_without_force_still_noop_on_current_branch(
303 self, repo: pathlib.Path
304 ) -> None:
305 """Without --force, checkout on current branch is still a no-op."""
306 result = _checkout(repo, "main")
307 assert "Already on" in result.output
308
309
310 # ──────────────────────────────────────────────────────────────────────────────
311 # Integration — CREATE (-b)
312 # ──────────────────────────────────────────────────────────────────────────────
313
314
315 class TestCreate:
316 def test_create_exits_0(self, repo: pathlib.Path) -> None:
317 result = _checkout(repo, "-b", "new-branch")
318 assert result.exit_code == 0
319
320 def test_create_switches_to_new_branch(self, repo: pathlib.Path) -> None:
321 _checkout(repo, "-b", "new-branch")
322 assert read_current_branch(repo) == "new-branch"
323
324 def test_create_text_output(self, repo: pathlib.Path) -> None:
325 result = _checkout(repo, "-b", "my-branch")
326 assert "my-branch" in result.output
327
328 def test_create_json_schema(self, repo: pathlib.Path) -> None:
329 result = _checkout(repo, "-b", "json-branch", "--json")
330 data = json.loads(result.output)
331 assert data["action"] == "created"
332 assert data["branch"] == "json-branch"
333 assert data["from_branch"] == "main"
334 assert "commit_id" in data
335 assert data.get("dry_run") is False
336
337 def test_create_duplicate_exits_1(self, repo: pathlib.Path) -> None:
338 _checkout(repo, "-b", "dup")
339 _checkout(repo, "main")
340 result = _checkout(repo, "-b", "dup")
341 assert result.exit_code == 1
342
343 def test_create_duplicate_error_to_stderr(self, repo: pathlib.Path) -> None:
344 _checkout(repo, "-b", "dup2")
345 _checkout(repo, "main")
346 result = _checkout(repo, "-b", "dup2")
347 # Error must appear in stderr
348 assert "already exists" in (result.stderr or "").lower()
349
350 def test_create_invalid_name_exits_1(self, repo: pathlib.Path) -> None:
351 result = _checkout(repo, "-b", "bad..name")
352 assert result.exit_code == 1
353
354 def test_create_invalid_name_error_to_stderr(self, repo: pathlib.Path) -> None:
355 result = _checkout(repo, "-b", "bad..name")
356 assert "Invalid" in (result.stderr or "")
357
358 def test_create_with_dirty_workdir_succeeds(self, repo: pathlib.Path) -> None:
359 """checkout -b must succeed even with uncommitted changes.
360
361 Creating a new branch starts at the current HEAD — no file content
362 changes, so dirty tracked files cannot be overwritten. Blocking
363 here forces an unnecessary stash/unstash dance.
364 """
365 # Dirty the tracked file without committing
366 (repo / "a.py").write_text("x = 2\n")
367 result = _checkout(repo, "-b", "task/dirty-ok")
368 assert result.exit_code == 0
369 assert read_current_branch(repo) == "task/dirty-ok"
370 # The dirty file must still be present (not lost)
371 assert (repo / "a.py").read_text() == "x = 2\n"
372
373
374 # ──────────────────────────────────────────────────────────────────────────────
375 # Integration — DETACH HEAD
376 # ──────────────────────────────────────────────────────────────────────────────
377
378
379 class TestDetach:
380 def test_detach_full_sha_exits_0(self, repo: pathlib.Path) -> None:
381 sha = get_head_commit_id(repo, "main")
382 assert sha is not None
383 result = _checkout(repo, sha)
384 assert result.exit_code == 0
385
386 def test_detach_full_sha_text_output(self, repo: pathlib.Path) -> None:
387 sha = get_head_commit_id(repo, "main")
388 assert sha is not None
389 result = _checkout(repo, sha)
390 assert sha[:8] in result.output
391
392 def test_detach_full_sha_json_schema(self, repo: pathlib.Path) -> None:
393 sha = get_head_commit_id(repo, "main")
394 assert sha is not None
395 result = _checkout(repo, sha, "--json")
396 data = json.loads(result.output)
397 assert data["action"] == "detached"
398 assert data["branch"] is None
399 assert data["commit_id"] == sha
400 assert data["from_branch"] == "main"
401 assert data.get("dry_run") is False
402
403 def test_detach_partial_sha_exits_0(self, repo: pathlib.Path) -> None:
404 sha = get_head_commit_id(repo, "main")
405 assert sha is not None
406 result = _checkout(repo, sha[:12])
407 assert result.exit_code == 0
408
409 def test_detach_partial_sha_points_to_correct_commit(self, repo: pathlib.Path) -> None:
410 """A partial SHA must resolve to the correct commit, not be treated as a branch."""
411 from muse.core.store import get_commits_for_branch, read_current_branch
412 from muse.core.repo import read_repo_id
413
414 (repo / "b.py").write_text("b=1\n")
415 _commit(repo, "-m", "second")
416
417 repo_id = read_repo_id(repo)
418 branch = read_current_branch(repo)
419 commits = get_commits_for_branch(repo, repo_id, branch)
420 first_sha = commits[-1].commit_id # oldest
421
422 result = _checkout(repo, first_sha[:12])
423 assert result.exit_code == 0
424 assert first_sha[:8] in result.output
425
426 def test_detach_bad_ref_exits_1(self, repo: pathlib.Path) -> None:
427 result = _checkout(repo, "deadbeefdeadbeef")
428 assert result.exit_code == 1
429
430 def test_detach_error_to_stderr(self, repo: pathlib.Path) -> None:
431 result = _checkout(repo, "deadbeefdeadbeef")
432 assert "not a branch" in (result.stderr or "").lower()
433
434
435 # ──────────────────────────────────────────────────────────────────────────────
436 # Integration — DRY-RUN
437 # ──────────────────────────────────────────────────────────────────────────────
438
439
440 class TestDryRun:
441 def test_dry_run_switch_exits_0(self, two_branch_repo: pathlib.Path) -> None:
442 result = _checkout(two_branch_repo, "--dry-run", "feat")
443 assert result.exit_code == 0
444
445 def test_dry_run_does_not_switch_branch(self, two_branch_repo: pathlib.Path) -> None:
446 _checkout(two_branch_repo, "--dry-run", "feat")
447 assert read_current_branch(two_branch_repo) == "main"
448
449 def test_dry_run_text_says_would(self, two_branch_repo: pathlib.Path) -> None:
450 result = _checkout(two_branch_repo, "--dry-run", "feat")
451 assert "Would" in result.output
452 assert "feat" in result.output
453
454 def test_dry_run_json_schema(self, two_branch_repo: pathlib.Path) -> None:
455 result = _checkout(two_branch_repo, "--dry-run", "feat", "--json")
456 data = json.loads(result.output)
457 assert data["dry_run"] is True
458 assert data["action"] == "switched"
459 assert data["branch"] == "feat"
460 assert data["from_branch"] == "main"
461
462 def test_dry_run_does_not_restore_files(self, two_branch_repo: pathlib.Path) -> None:
463 """feat.py exists only on feat branch; dry-run must not create it on main."""
464 _checkout(two_branch_repo, "--dry-run", "feat")
465 assert not (two_branch_repo / "feat.py").exists()
466
467 def test_dry_run_create_exits_0(self, repo: pathlib.Path) -> None:
468 result = _checkout(repo, "-b", "dry-branch", "--dry-run")
469 assert result.exit_code == 0
470
471 def test_dry_run_create_does_not_create_branch(self, repo: pathlib.Path) -> None:
472 _checkout(repo, "-b", "dry-branch", "--dry-run")
473 result = _invoke(repo, ["branch", "--json"])
474 names = [b["name"] for b in json.loads(result.output)]
475 assert "dry-branch" not in names
476
477 def test_dry_run_create_json_schema(self, repo: pathlib.Path) -> None:
478 result = _checkout(repo, "-b", "dry-new", "--dry-run", "--json")
479 data = json.loads(result.output)
480 assert data["dry_run"] is True
481 assert data["action"] == "created"
482 assert data["from_branch"] == "main"
483
484 def test_dry_run_detach_exits_0(self, repo: pathlib.Path) -> None:
485 sha = get_head_commit_id(repo, "main")
486 assert sha is not None
487 result = _checkout(repo, "--dry-run", sha)
488 assert result.exit_code == 0
489
490 def test_dry_run_detach_does_not_detach(self, repo: pathlib.Path) -> None:
491 sha = get_head_commit_id(repo, "main")
492 assert sha is not None
493 _checkout(repo, "--dry-run", sha)
494 assert read_current_branch(repo) == "main"
495
496 def test_dry_run_detach_json(self, repo: pathlib.Path) -> None:
497 sha = get_head_commit_id(repo, "main")
498 assert sha is not None
499 result = _checkout(repo, "--dry-run", sha, "--json")
500 data = json.loads(result.output)
501 assert data["dry_run"] is True
502 assert data["action"] == "detached"
503 assert data["branch"] is None
504
505 def test_dry_run_nonexistent_branch_exits_1(self, repo: pathlib.Path) -> None:
506 result = _checkout(repo, "--dry-run", "no-such-branch")
507 assert result.exit_code == 1
508
509 def test_dry_run_already_on_exits_0(self, repo: pathlib.Path) -> None:
510 result = _checkout(repo, "--dry-run", "main")
511 assert result.exit_code == 0
512
513 def test_dry_run_already_on_json(self, repo: pathlib.Path) -> None:
514 result = _checkout(repo, "--dry-run", "main", "--json")
515 data = json.loads(result.output)
516 assert data["dry_run"] is True
517 assert data["action"] == "already_on"
518
519
520 # ──────────────────────────────────────────────────────────────────────────────
521 # Integration — JSON schema consistency
522 # ──────────────────────────────────────────────────────────────────────────────
523
524
525 class TestJsonSchema:
526 REQUIRED_KEYS = {"action", "branch", "commit_id", "from_branch", "dry_run"}
527
528 def test_create_has_all_keys(self, repo: pathlib.Path) -> None:
529 result = _checkout(repo, "-b", "k-test", "--json")
530 data = json.loads(result.output)
531 missing = self.REQUIRED_KEYS - set(data)
532 assert not missing, f"Missing keys in 'created' JSON: {missing}"
533
534 def test_switch_has_all_keys(self, two_branch_repo: pathlib.Path) -> None:
535 result = _checkout(two_branch_repo, "feat", "--json")
536 data = json.loads(result.output)
537 missing = self.REQUIRED_KEYS - set(data)
538 assert not missing, f"Missing keys in 'switched' JSON: {missing}"
539
540 def test_already_on_has_all_keys(self, repo: pathlib.Path) -> None:
541 result = _checkout(repo, "main", "--json")
542 data = json.loads(result.output)
543 missing = self.REQUIRED_KEYS - set(data)
544 assert not missing, f"Missing keys in 'already_on' JSON: {missing}"
545
546 def test_detach_has_all_keys(self, repo: pathlib.Path) -> None:
547 sha = get_head_commit_id(repo, "main")
548 assert sha is not None
549 result = _checkout(repo, sha, "--json")
550 data = json.loads(result.output)
551 missing = self.REQUIRED_KEYS - set(data)
552 assert not missing, f"Missing keys in 'detached' JSON: {missing}"
553
554 def test_detach_branch_is_null(self, repo: pathlib.Path) -> None:
555 sha = get_head_commit_id(repo, "main")
556 assert sha is not None
557 result = _checkout(repo, sha, "--json")
558 data = json.loads(result.output)
559 assert data["branch"] is None
560
561 def test_from_branch_reflects_previous(self, two_branch_repo: pathlib.Path) -> None:
562 _checkout(two_branch_repo, "feat")
563 result = _checkout(two_branch_repo, "main", "--json")
564 data = json.loads(result.output)
565 assert data["from_branch"] == "feat"
566
567
568 # ──────────────────────────────────────────────────────────────────────────────
569 # Integration — validation
570 # ──────────────────────────────────────────────────────────────────────────────
571
572
573 class TestValidation:
574 def test_no_target_exits_1(self, repo: pathlib.Path) -> None:
575 result = _checkout(repo)
576 assert result.exit_code == 1
577
578 def test_no_target_error_to_stderr(self, repo: pathlib.Path) -> None:
579 result = _checkout(repo)
580 assert "Specify" in (result.stderr or "")
581
582 def test_unknown_format_exits_1(self, repo: pathlib.Path) -> None:
583 result = _checkout(repo, "main", "--format", "xml")
584 assert result.exit_code == 1
585
586 def test_unknown_format_error_to_stderr(self, repo: pathlib.Path) -> None:
587 result = _checkout(repo, "main", "--format", "xml")
588 assert "Unknown" in (result.stderr or "")
589
590 def test_ours_without_theirs_context_exits_1(self, repo: pathlib.Path) -> None:
591 result = _checkout(repo, "--ours", "file.py")
592 assert result.exit_code == 1
593
594 def test_ours_and_theirs_together_exits_1(self, repo: pathlib.Path) -> None:
595 result = _checkout(repo, "--ours", "--theirs", "--all")
596 assert result.exit_code == 1
597
598
599 # ──────────────────────────────────────────────────────────────────────────────
600 # Security — ANSI injection
601 # ──────────────────────────────────────────────────────────────────────────────
602
603
604 class TestSecurityAnsi:
605 def _has_ansi(self, s: str) -> bool:
606 return "\x1b[" in s
607
608 def test_ansi_in_target_sanitized(self, repo: pathlib.Path) -> None:
609 result = _checkout(repo, "\x1b[31mevil\x1b[0m")
610 assert not self._has_ansi(result.output)
611
612 def test_ansi_in_create_name_sanitized(self, repo: pathlib.Path) -> None:
613 result = _checkout(repo, "-b", "\x1b[31mevil\x1b[0m")
614 assert not self._has_ansi(result.output)
615
616 def test_ansi_in_format_sanitized(self, repo: pathlib.Path) -> None:
617 result = _checkout(repo, "main", "--format", "\x1b[31mxml\x1b[0m")
618 assert not self._has_ansi(result.output)
619
620 def test_error_not_a_branch_sanitized(self, repo: pathlib.Path) -> None:
621 """The 'not a branch' error message must not echo raw ANSI from target."""
622 result = _checkout(repo, "\x1b[31mnotabranch\x1b[0m")
623 assert not self._has_ansi(result.output)
624 assert not self._has_ansi(result.stderr or "")
625
626 def test_all_errors_to_stderr(self, repo: pathlib.Path) -> None:
627 """Every ❌ error must go to stderr; stderr must contain the error."""
628 error_cases = [
629 ["ghost"],
630 ["-b", "bad..name"],
631 ["--format", "xml"],
632 ]
633 for case in error_cases:
634 result = _checkout(repo, *case)
635 assert result.exit_code != 0, f"Expected failure for args {case}"
636 assert "❌" in (result.stderr or ""), (
637 f"Error not in stderr for args {case}: stderr={result.stderr!r}"
638 )
639
640
641 # ──────────────────────────────────────────────────────────────────────────────
642 # Integration — conflict resolution
643 # ──────────────────────────────────────────────────────────────────────────────
644
645
646 class TestConflictResolution:
647 def _setup_merge_conflict(
648 self, repo: pathlib.Path
649 ) -> tuple[str, str]:
650 """Create a merge conflict on ``repo``. Returns (ours_commit, theirs_commit)."""
651 # ours: commit on main
652 (repo / "shared.py").write_text("x = 1\n")
653 _commit(repo, "-m", "main: set x=1")
654 ours_cid = get_head_commit_id(repo, "main") or ""
655
656 # theirs: commit on feature branch
657 _branch(repo, "feat2")
658 _invoke(repo, ["checkout", "feat2"])
659 (repo / "shared.py").write_text("x = 2\n")
660 _commit(repo, "-m", "feat: set x=2")
661 theirs_cid = get_head_commit_id(repo, "feat2") or ""
662
663 _invoke(repo, ["checkout", "main"])
664 # Force a merge conflict via merge_engine plumbing
665 from muse.core.merge_engine import write_merge_state
666
667 write_merge_state(
668 repo,
669 base_commit="",
670 ours_commit=ours_cid,
671 theirs_commit=theirs_cid,
672 conflict_paths=["shared.py"],
673 other_branch="feat2",
674 )
675 return ours_cid, theirs_cid
676
677 def test_ours_no_merge_state_exits_1(self, repo: pathlib.Path) -> None:
678 result = _checkout(repo, "--ours", "file.py")
679 assert result.exit_code == 1
680
681 def test_theirs_no_merge_state_exits_1(self, repo: pathlib.Path) -> None:
682 result = _checkout(repo, "--theirs", "file.py")
683 assert result.exit_code == 1
684
685 def test_ours_and_theirs_both_exits_1(self, repo: pathlib.Path) -> None:
686 result = _checkout(repo, "--ours", "--theirs", "--all")
687 assert result.exit_code == 1
688
689 def test_ours_resolves_conflict(self, repo: pathlib.Path) -> None:
690 self._setup_merge_conflict(repo)
691 result = _checkout(repo, "--ours", "shared.py")
692 assert result.exit_code == 0
693
694 def test_theirs_resolves_conflict(self, repo: pathlib.Path) -> None:
695 self._setup_merge_conflict(repo)
696 result = _checkout(repo, "--theirs", "shared.py")
697 assert result.exit_code == 0
698
699 def test_resolve_all_ours_json(self, repo: pathlib.Path) -> None:
700 self._setup_merge_conflict(repo)
701 result = _checkout(repo, "--ours", "--all", "--json")
702 assert result.exit_code == 0
703 data = json.loads(result.output)
704 assert data["action"] == "conflict_resolved_all"
705 assert data["side"] == "ours"
706 assert "resolved_count" in data
707 assert "remaining_conflicts" in data
708
709 def test_resolve_all_theirs_json(self, repo: pathlib.Path) -> None:
710 self._setup_merge_conflict(repo)
711 result = _checkout(repo, "--theirs", "--all", "--json")
712 assert result.exit_code == 0
713 data = json.loads(result.output)
714 assert data["action"] == "conflict_resolved_all"
715 assert data["side"] == "theirs"
716
717 def test_resolve_single_file_json(self, repo: pathlib.Path) -> None:
718 self._setup_merge_conflict(repo)
719 result = _checkout(repo, "--ours", "shared.py", "--json")
720 assert result.exit_code == 0
721 data = json.loads(result.output)
722 assert data["action"] == "conflict_resolved"
723 assert data["file"] == "shared.py"
724 assert data["side"] == "ours"
725 assert "remaining_conflicts" in data
726
727 def test_resolve_all_empty_conflicts_exits_0(self, repo: pathlib.Path) -> None:
728 """--ours --all when no conflicts exist still exits 0."""
729 from muse.core.merge_engine import write_merge_state
730
731 ours = get_head_commit_id(repo, "main") or ""
732 write_merge_state(
733 repo,
734 base_commit="",
735 ours_commit=ours,
736 theirs_commit=ours,
737 conflict_paths=[],
738 other_branch="feat",
739 )
740 result = _checkout(repo, "--ours", "--all")
741 assert result.exit_code == 0
742
743 def test_resolve_nonexistent_path_exits_0(self, repo: pathlib.Path) -> None:
744 """A path not in the conflict list is informational, not an error."""
745 self._setup_merge_conflict(repo)
746 result = _checkout(repo, "--ours", "not_conflicted.py")
747 assert result.exit_code == 0
748
749 def test_missing_ours_theirs_without_all_exits_1(self, repo: pathlib.Path) -> None:
750 result = _checkout(repo, "--ours")
751 assert result.exit_code == 1
752
753
754 # ──────────────────────────────────────────────────────────────────────────────
755 # Stress
756 # ──────────────────────────────────────────────────────────────────────────────
757
758
759 @pytest.mark.slow
760 class TestStress:
761 def test_checkout_100_file_branch_fast(self, repo: pathlib.Path) -> None:
762 """Switching between branches with 100 modified files under 2s."""
763 for i in range(100):
764 (repo / f"f{i:03d}.py").write_text(f"x={i}\n")
765 _commit(repo, "-m", "big main")
766 _branch(repo, "big-alt")
767 _checkout(repo, "big-alt")
768 for i in range(100):
769 (repo / f"f{i:03d}.py").write_text(f"y={i}\n")
770 _commit(repo, "-m", "big alt")
771 _checkout(repo, "main")
772
773 t0 = time.perf_counter()
774 result = _checkout(repo, "big-alt")
775 elapsed = (time.perf_counter() - t0) * 1000
776 assert result.exit_code == 0
777 assert elapsed < 2000, f"checkout 100-file branch took {elapsed:.0f}ms (limit 2s)"
778
779 def test_dry_run_100_file_branch_fast(self, repo: pathlib.Path) -> None:
780 """dry-run on 100-file branch should be very fast (no restore)."""
781 for i in range(100):
782 (repo / f"g{i:03d}.py").write_text(f"x={i}\n")
783 _commit(repo, "-m", "big2")
784 _branch(repo, "big2-alt")
785
786 t0 = time.perf_counter()
787 result = _checkout(repo, "--dry-run", "big2-alt")
788 elapsed = (time.perf_counter() - t0) * 1000
789 assert result.exit_code == 0
790 assert elapsed < 500, f"dry-run took {elapsed:.0f}ms (limit 500ms)"
791
792 def test_concurrent_checkouts_separate_repos(self, tmp_path: pathlib.Path) -> None:
793 """Multiple threads checking out branches in separate repos must not interfere."""
794 errors: list[str] = []
795
796 def do_checkout(idx: int) -> None:
797 repo_dir = tmp_path / f"repo_{idx}"
798 repo_dir.mkdir()
799 subprocess.run(["muse", "init"], cwd=str(repo_dir), capture_output=True)
800 (repo_dir / "x.py").write_text(f"x={idx}\n")
801 subprocess.run(
802 ["muse", "commit", "-m", f"base{idx}"],
803 cwd=str(repo_dir), capture_output=True,
804 )
805 subprocess.run(
806 ["muse", "branch", "alt"], cwd=str(repo_dir), capture_output=True
807 )
808 subprocess.run(
809 ["muse", "checkout", "alt"], cwd=str(repo_dir), capture_output=True
810 )
811 (repo_dir / "y.py").write_text(f"y={idx}\n")
812 subprocess.run(
813 ["muse", "commit", "-m", f"alt{idx}"],
814 cwd=str(repo_dir), capture_output=True,
815 )
816 r = subprocess.run(
817 ["muse", "checkout", "main", "--json"],
818 cwd=str(repo_dir), capture_output=True, text=True,
819 )
820 if r.returncode != 0:
821 errors.append(f"repo_{idx}: checkout failed")
822 return
823 data = json.loads(r.stdout)
824 if data.get("action") != "switched":
825 errors.append(f"repo_{idx}: expected switched, got {data.get('action')}")
826
827 threads = [threading.Thread(target=do_checkout, args=(i,)) for i in range(6)]
828 for t in threads:
829 t.start()
830 for t in threads:
831 t.join()
832 assert not errors, "Concurrent checkout errors:\n" + "\n".join(errors)
833
834 def test_repeated_back_and_forth_100_times(self, two_branch_repo: pathlib.Path) -> None:
835 """Switching back and forth 100 times must not corrupt the working tree."""
836 for i in range(50):
837 r1 = _checkout(two_branch_repo, "feat")
838 assert r1.exit_code == 0, f"Iteration {i}: switch to feat failed"
839 assert (two_branch_repo / "feat.py").exists()
840 r2 = _checkout(two_branch_repo, "main")
841 assert r2.exit_code == 0, f"Iteration {i}: switch to main failed"
842
843
844 # ──────────────────────────────────────────────────────────────────────────────
845 # TestCheckoutMerge — muse checkout -m (Cohen Transform carry-forward)
846 # ──────────────────────────────────────────────────────────────────────────────
847
848
849 def _make_diverged_repo(tmp_path: pathlib.Path) -> pathlib.Path:
850 """Repo with main and *other* branches that have diverged file content.
851
852 Layout after setup::
853
854 main: shared.py = "line1\\nline2\\nline3\\n" (committed)
855 other: shared.py = "line1\\nLINE2\\nline3\\n" (committed — different line 2)
856
857 The caller is left on *main* with a dirty working tree.
858 """
859 saved = os.getcwd()
860 try:
861 os.chdir(tmp_path)
862 runner.invoke(None, ["init"])
863 finally:
864 os.chdir(saved)
865
866 (tmp_path / "shared.py").write_text("line1\nline2\nline3\n")
867 _commit(tmp_path, "-m", "initial")
868
869 # Create other branch with a different version of shared.py
870 _branch(tmp_path, "other")
871 _checkout(tmp_path, "other")
872 (tmp_path / "shared.py").write_text("line1\nLINE2\nline3\n")
873 _commit(tmp_path, "-m", "other changes line2")
874
875 # Back on main
876 _checkout(tmp_path, "main")
877 return tmp_path
878
879
880 class TestCheckoutMergeParser:
881 """Parser-level tests for the ``-m`` / ``--merge`` flag."""
882
883 def _parse(self, *args: str) -> "argparse.Namespace":
884 import argparse
885
886 from muse.cli.commands.checkout import register
887
888 parser = argparse.ArgumentParser()
889 sub = parser.add_subparsers()
890 register(sub)
891 return parser.parse_args(["checkout", *args])
892
893 def test_merge_short_flag_parsed(self) -> None:
894 ns = self._parse("-m", "feat")
895 assert ns.merge is True
896
897 def test_merge_long_flag_parsed(self) -> None:
898 ns = self._parse("--merge", "feat")
899 assert ns.merge is True
900
901 def test_merge_false_by_default(self) -> None:
902 ns = self._parse("feat")
903 assert ns.merge is False
904
905 def test_merge_and_dry_run_coexist(self) -> None:
906 ns = self._parse("-m", "--dry-run", "feat")
907 assert ns.merge is True
908 assert ns.dry_run is True
909
910 def test_merge_and_json_coexist(self) -> None:
911 ns = self._parse("-m", "--json", "feat")
912 assert ns.merge is True
913 assert ns.fmt == "json"
914
915
916 class TestCheckoutMergeClean:
917 """Clean-merge scenarios — no conflict markers should appear."""
918
919 def test_untracked_file_survives_checkout(self, repo: pathlib.Path) -> None:
920 """Untracked files must not be disturbed by -m checkout."""
921 _branch(repo, "feat")
922 (repo / "untracked.txt").write_text("I am untracked\n")
923 r = _checkout(repo, "-m", "feat")
924 assert r.exit_code == 0
925 assert (repo / "untracked.txt").read_text() == "I am untracked\n"
926
927 def test_ours_only_change_carried_cleanly(self, tmp_path: pathlib.Path) -> None:
928 """When we modify a file that target branch left untouched, the change carries.
929
930 'clean' branch diverged by adding a brand-new file, leaving shared.py
931 identical to main's HEAD. Our uncommitted change to shared.py has no
932 competition from the target and must merge cleanly.
933 """
934 saved = os.getcwd()
935 try:
936 os.chdir(tmp_path)
937 runner.invoke(None, ["init"])
938 finally:
939 os.chdir(saved)
940 (tmp_path / "shared.py").write_text("line1\nline2\nline3\n")
941 _commit(tmp_path, "-m", "initial")
942
943 # Create 'clean' branch — only adds a new file, does NOT touch shared.py
944 _branch(tmp_path, "clean")
945 _checkout(tmp_path, "clean")
946 (tmp_path / "extra.py").write_text("# extra\n")
947 _commit(tmp_path, "-m", "add extra.py")
948 _checkout(tmp_path, "main")
949
950 # Dirty workdir on main: modify shared.py
951 (tmp_path / "shared.py").write_text("line1\nline2\nLINE3\n")
952 r = _checkout(tmp_path, "-m", "clean")
953 assert r.exit_code == 0
954 content = (tmp_path / "shared.py").read_text()
955 assert "LINE3" in content, "Our uncommitted change must be in merged result"
956
957 def test_clean_merge_json_output(self, tmp_path: pathlib.Path) -> None:
958 """JSON output reports clean_merges and empty conflicts on success."""
959 saved = os.getcwd()
960 try:
961 os.chdir(tmp_path)
962 runner.invoke(None, ["init"])
963 finally:
964 os.chdir(saved)
965 (tmp_path / "shared.py").write_text("line1\nline2\nline3\n")
966 _commit(tmp_path, "-m", "initial")
967
968 _branch(tmp_path, "clean")
969 _checkout(tmp_path, "clean")
970 (tmp_path / "extra.py").write_text("# extra\n")
971 _commit(tmp_path, "-m", "add extra.py")
972 _checkout(tmp_path, "main")
973
974 (tmp_path / "shared.py").write_text("line1\nline2\nLINE3\n")
975 r = _checkout(tmp_path, "-m", "--json", "clean")
976 assert r.exit_code == 0
977 data = json.loads(r.output)
978 assert data["action"] == "switched"
979 assert data["branch"] == "clean"
980 assert isinstance(data["clean_merges"], list)
981 assert isinstance(data["conflicts"], list)
982 assert len(data["conflicts"]) == 0
983
984 def test_switched_branch_recorded(self, tmp_path: pathlib.Path) -> None:
985 """After a clean -m checkout the current branch is the target."""
986 repo = _make_diverged_repo(tmp_path)
987 (repo / "shared.py").write_text("line1\nline2\nLINE3\n")
988 _checkout(repo, "-m", "other")
989 assert read_current_branch(repo) == "other"
990
991 def test_no_dirty_files_succeeds_silently(self, repo: pathlib.Path) -> None:
992 """If the working tree is clean, -m behaves like a normal checkout."""
993 _branch(repo, "feat")
994 r = _checkout(repo, "-m", "feat")
995 assert r.exit_code == 0
996 assert read_current_branch(repo) == "feat"
997
998 def test_dry_run_does_not_switch_branch(self, tmp_path: pathlib.Path) -> None:
999 """-m --dry-run must not actually switch branches."""
1000 repo = _make_diverged_repo(tmp_path)
1001 (repo / "shared.py").write_text("line1\nline2\nLINE3\n")
1002 r = _checkout(repo, "-m", "--dry-run", "other")
1003 assert r.exit_code == 0
1004 assert read_current_branch(repo) == "main"
1005
1006 def test_dry_run_json_reports_dry_run_true(self, tmp_path: pathlib.Path) -> None:
1007 repo = _make_diverged_repo(tmp_path)
1008 (repo / "shared.py").write_text("line1\nline2\nLINE3\n")
1009 r = _checkout(repo, "-m", "--dry-run", "--json", "other")
1010 assert r.exit_code == 0
1011 data = json.loads(r.output)
1012 assert data["dry_run"] is True
1013 assert data["branch"] == "other"
1014
1015
1016 class TestCheckoutMergeConflict:
1017 """Conflict scenarios — conflict markers and MERGE_STATE must appear."""
1018
1019 def test_conflicting_change_exits_1(self, tmp_path: pathlib.Path) -> None:
1020 """Same line changed on both sides → conflict → exit code 1."""
1021 repo = _make_diverged_repo(tmp_path)
1022 # Also change line2 on main (uncommitted) — same line other branch changed
1023 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1024 r = _checkout(repo, "-m", "other")
1025 assert r.exit_code == 1
1026
1027 def test_conflict_markers_written_to_file(self, tmp_path: pathlib.Path) -> None:
1028 """Conflicting file must contain diff3-style conflict markers after -m."""
1029 repo = _make_diverged_repo(tmp_path)
1030 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1031 _checkout(repo, "-m", "other")
1032 content = (repo / "shared.py").read_text()
1033 assert "<<<<<<<" in content
1034 assert "=======" in content
1035 assert ">>>>>>>" in content
1036
1037 def test_conflict_markers_contain_cohen_action_labels(self, tmp_path: pathlib.Path) -> None:
1038 """Cohen-style action labels ([modified], [inserted], [deleted]) must appear."""
1039 repo = _make_diverged_repo(tmp_path)
1040 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1041 _checkout(repo, "-m", "other")
1042 content = (repo / "shared.py").read_text()
1043 # At least one action label must be present
1044 assert any(label in content for label in ("[modified]", "[inserted]", "[deleted]"))
1045
1046 def test_merge_state_written_on_conflict(self, tmp_path: pathlib.Path) -> None:
1047 """MERGE_STATE.json must exist after a conflicting -m checkout."""
1048 repo = _make_diverged_repo(tmp_path)
1049 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1050 _checkout(repo, "-m", "other")
1051 merge_state_file = repo / ".muse" / "MERGE_STATE.json"
1052 assert merge_state_file.exists()
1053
1054 def test_merge_state_lists_conflict_path(self, tmp_path: pathlib.Path) -> None:
1055 """MERGE_STATE.json must name the conflicting path."""
1056 repo = _make_diverged_repo(tmp_path)
1057 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1058 _checkout(repo, "-m", "other")
1059 data = json.loads((repo / ".muse" / "MERGE_STATE.json").read_text())
1060 assert "shared.py" in data.get("conflict_paths", [])
1061
1062 def test_conflict_json_output_contains_conflicts_list(self, tmp_path: pathlib.Path) -> None:
1063 """JSON output must list the conflict paths even on exit code 1."""
1064 repo = _make_diverged_repo(tmp_path)
1065 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1066 r = _checkout(repo, "-m", "--json", "other")
1067 data = json.loads(r.output)
1068 assert "conflicts" in data
1069 assert len(data["conflicts"]) >= 1
1070
1071 def test_branch_is_switched_despite_conflict(self, tmp_path: pathlib.Path) -> None:
1072 """Even when conflicts exist, we are on the target branch after -m."""
1073 repo = _make_diverged_repo(tmp_path)
1074 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1075 _checkout(repo, "-m", "other")
1076 assert read_current_branch(repo) == "other"
1077
1078
1079 class TestCheckoutMergeErrors:
1080 """Error cases for -m flag."""
1081
1082 def test_merge_with_create_flag_ignored(self, repo: pathlib.Path) -> None:
1083 """``-m -b new-branch`` must NOT invoke _checkout_with_merge (target doesn't exist)."""
1084 # -m with -b falls through to regular create logic; no conflict-carry
1085 r = _checkout(repo, "-m", "-b", "new-branch")
1086 # Should succeed as a regular branch creation (no carry logic for new branches)
1087 assert r.exit_code == 0
1088
1089 def test_merge_missing_branch_errors(self, repo: pathlib.Path) -> None:
1090 """``-m nonexistent`` must exit 1 with an error message."""
1091 r = _checkout(repo, "-m", "nonexistent")
1092 assert r.exit_code == 1
1093
1094 def test_merge_already_on_branch_is_noop(self, repo: pathlib.Path) -> None:
1095 """``-m main`` when already on main must print 'Already on' and exit 0."""
1096 r = _checkout(repo, "-m", "main")
1097 assert r.exit_code == 0
1098 assert "Already on" in r.output or "already" in r.output.lower()
1099
1100 def test_merge_with_invalid_branch_name_errors(self, repo: pathlib.Path) -> None:
1101 """``-m ..invalid`` must exit 1 before attempting any merge."""
1102 r = _checkout(repo, "-m", "../bad/name")
1103 assert r.exit_code == 1
File History 3 commits
sha256:f6cd81bc71702f5c1c6890bd39aaba994fe58c75f019d7c03934724fa2739bb4 fix: carry dev changes harmony dropped in merge — detached … Sonnet 4.6 minor 43 days ago
sha256:45e1291ec44e0e86fe353b0b55306b2689a7f6ffa39bafb4bd2782b5be1c9cb8 fix: checkout from detached HEAD state raises ValueError Sonnet 4.6 minor 45 days ago