gabriel / muse public
test_cmd_checkout.py python
1,538 lines 67.0 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
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._types import short_id
26 from muse.core.store import get_head_commit_id, read_current_branch
27 from muse.cli.config import read_branch_meta
28
29 if TYPE_CHECKING:
30 import argparse
31
32 runner = CliRunner()
33
34 # ──────────────────────────────────────────────────────────────────────────────
35 # Helpers
36 # ──────────────────────────────────────────────────────────────────────────────
37
38
39 def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult:
40 saved = os.getcwd()
41 try:
42 os.chdir(repo)
43 return runner.invoke(None, args)
44 finally:
45 os.chdir(saved)
46
47
48 def _checkout(repo: pathlib.Path, *extra: str) -> InvokeResult:
49 return _invoke(repo, ["checkout", *extra])
50
51
52 def _commit(repo: pathlib.Path, *extra: str) -> InvokeResult:
53 return _invoke(repo, ["commit", *extra])
54
55
56 def _branch(repo: pathlib.Path, *extra: str) -> InvokeResult:
57 return _invoke(repo, ["branch", *extra])
58
59
60 @pytest.fixture()
61 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
62 """Initialised repo with one commit on ``main``."""
63 saved = os.getcwd()
64 try:
65 os.chdir(tmp_path)
66 runner.invoke(None, ["init"])
67 finally:
68 os.chdir(saved)
69 (tmp_path / "a.py").write_text("x = 1\n")
70 _commit(tmp_path, "-m", "initial")
71 return tmp_path
72
73
74 @pytest.fixture()
75 def two_branch_repo(repo: pathlib.Path) -> pathlib.Path:
76 """Repo with ``main`` and ``feat`` branches, each with unique content."""
77 _branch(repo, "feat")
78 _checkout(repo, "feat")
79 (repo / "feat.py").write_text("f = 1\n")
80 _commit(repo, "-m", "feat commit")
81 _checkout(repo, "main")
82 return repo
83
84
85 # ──────────────────────────────────────────────────────────────────────────────
86 # Unit — parser flags
87 # ──────────────────────────────────────────────────────────────────────────────
88
89
90 class TestRegisterFlags:
91 def _parse(self, *args: str) -> "argparse.Namespace":
92 import argparse
93
94 from muse.cli.commands.checkout import register
95
96 p = argparse.ArgumentParser()
97 sub = p.add_subparsers()
98 register(sub)
99 return p.parse_args(["checkout", *args])
100
101 def test_default_fmt_is_text(self) -> None:
102 ns = self._parse("main")
103 assert ns.fmt == "text"
104
105 def test_json_flag_sets_fmt(self) -> None:
106 ns = self._parse("main", "--json")
107 assert ns.fmt == "json"
108
109 def test_format_json_flag(self) -> None:
110 ns = self._parse("main", "--format", "json")
111 assert ns.fmt == "json"
112
113 def test_create_flag(self) -> None:
114 ns = self._parse("-b", "new")
115 assert ns.create is True
116
117 def test_force_flag(self) -> None:
118 ns = self._parse("main", "--force")
119 assert ns.force is True
120
121 def test_force_short_flag(self) -> None:
122 ns = self._parse("main", "-f")
123 assert ns.force is True
124
125 def test_dry_run_flag(self) -> None:
126 ns = self._parse("main", "--dry-run")
127 assert ns.dry_run is True
128
129 def test_dry_run_short_flag(self) -> None:
130 ns = self._parse("main", "-n")
131 assert ns.dry_run is True
132
133 def test_dry_run_default_false(self) -> None:
134 ns = self._parse("main")
135 assert ns.dry_run is False
136
137 def test_ours_flag(self) -> None:
138 ns = self._parse("--ours", "file.py")
139 assert ns.resolve_ours is True
140
141 def test_theirs_flag(self) -> None:
142 ns = self._parse("--theirs", "file.py")
143 assert ns.resolve_theirs is True
144
145 def test_all_flag(self) -> None:
146 ns = self._parse("--ours", "--all")
147 assert ns.resolve_all is True
148
149 def test_target_optional(self) -> None:
150 ns = self._parse()
151 assert ns.target is None
152
153
154 # ──────────────────────────────────────────────────────────────────────────────
155 # Unit — dead-code removal
156 # ──────────────────────────────────────────────────────────────────────────────
157
158
159 class TestDeadCodeRemoved:
160 def test_read_current_branch_wrapper_removed(self) -> None:
161 import muse.cli.commands.checkout as m
162
163 assert not hasattr(m, "_read_current_branch"), (
164 "_read_current_branch was a dead one-liner wrapper and must be deleted"
165 )
166
167 def test_inline_sanitize_display_import_removed(self) -> None:
168 import inspect
169
170 import muse.cli.commands.checkout as m
171
172 src = inspect.getsource(m.run)
173 assert "sanitize_display as _sd" not in src, (
174 "The inline 'from muse.core.validation import sanitize_display as _sd' "
175 "inside run() was a redundant re-import of the module-level sanitize_display"
176 )
177
178
179 # ──────────────────────────────────────────────────────────────────────────────
180 # Integration — SWITCH (existing branch)
181 # ──────────────────────────────────────────────────────────────────────────────
182
183
184 class TestSwitch:
185 def test_switch_exits_0(self, two_branch_repo: pathlib.Path) -> None:
186 result = _checkout(two_branch_repo, "feat")
187 assert result.exit_code == 0
188
189 def test_switch_changes_branch(self, two_branch_repo: pathlib.Path) -> None:
190 _checkout(two_branch_repo, "feat")
191 assert read_current_branch(two_branch_repo) == "feat"
192
193 def test_switch_text_output(self, two_branch_repo: pathlib.Path) -> None:
194 result = _checkout(two_branch_repo, "feat")
195 assert "feat" in result.output
196 assert "Switched" in result.output
197
198 def test_switch_json_schema(self, two_branch_repo: pathlib.Path) -> None:
199 result = _checkout(two_branch_repo, "feat", "--json")
200 data = json.loads(result.output)
201 assert data["action"] == "switched"
202 assert data["branch"] == "feat"
203 assert data["from_branch"] == "main"
204 assert "commit_id" in data
205 assert data.get("dry_run") is False
206
207 def test_switch_restores_files(self, two_branch_repo: pathlib.Path) -> None:
208 """Files unique to ``feat`` appear after checkout and disappear on return."""
209 _checkout(two_branch_repo, "feat")
210 assert (two_branch_repo / "feat.py").exists()
211 _checkout(two_branch_repo, "main")
212 assert not (two_branch_repo / "feat.py").exists()
213
214 def test_switch_to_nonexistent_exits_1(self, repo: pathlib.Path) -> None:
215 result = _checkout(repo, "does-not-exist")
216 assert result.exit_code == 1
217
218 def test_switch_error_to_stderr(self, repo: pathlib.Path) -> None:
219 result = _checkout(repo, "ghost")
220 assert result.exit_code == 1
221 # Error must appear in stderr, not exclusively stdout
222 assert "not a branch" in (result.stderr or "").lower()
223
224
225 # ──────────────────────────────────────────────────────────────────────────────
226 # Integration — ALREADY_ON
227 # ──────────────────────────────────────────────────────────────────────────────
228
229
230 class TestAlreadyOn:
231 def test_already_on_exits_0(self, repo: pathlib.Path) -> None:
232 result = _checkout(repo, "main")
233 assert result.exit_code == 0
234
235 def test_already_on_text(self, repo: pathlib.Path) -> None:
236 result = _checkout(repo, "main")
237 assert "Already on" in result.output
238
239 def test_already_on_json_schema(self, repo: pathlib.Path) -> None:
240 result = _checkout(repo, "main", "--json")
241 data = json.loads(result.output)
242 assert data["action"] == "already_on"
243 assert data["branch"] == "main"
244 assert data["from_branch"] == "main"
245 assert "commit_id" in data
246
247
248 # ──────────────────────────────────────────────────────────────────────────────
249 # Integration — FORCE on current branch (working-tree restore)
250 # ──────────────────────────────────────────────────────────────────────────────
251
252
253 class TestForceOnCurrentBranch:
254 """checkout --force <current-branch> must restore the working tree to HEAD.
255
256 Regression: previously this was a no-op ('Already on main') regardless of
257 --force. Git's behaviour is to restore missing/modified tracked files even
258 when the branch is already current.
259 """
260
261 def test_force_current_branch_exits_0(self, repo: pathlib.Path) -> None:
262 result = _checkout(repo, "--force", "main")
263 assert result.exit_code == 0
264
265 def test_force_current_branch_restores_deleted_file(self, repo: pathlib.Path) -> None:
266 (repo / "a.py").unlink()
267 assert not (repo / "a.py").exists()
268 _checkout(repo, "--force", "main")
269 assert (repo / "a.py").exists(), "force checkout must restore deleted tracked file"
270
271 def test_force_current_branch_restores_modified_file(self, repo: pathlib.Path) -> None:
272 original = (repo / "a.py").read_text()
273 (repo / "a.py").write_text("corrupted content\n")
274 _checkout(repo, "--force", "main")
275 assert (repo / "a.py").read_text() == original, (
276 "force checkout must restore modified tracked file to HEAD content"
277 )
278
279 def test_force_current_branch_text_output(self, repo: pathlib.Path) -> None:
280 result = _checkout(repo, "--force", "main")
281 assert "restored" in result.output
282
283 def test_force_current_branch_json_action(self, repo: pathlib.Path) -> None:
284 result = _checkout(repo, "--force", "main", "--json")
285 data = json.loads(result.output)
286 assert data["action"] == "restored"
287 assert data["branch"] == "main"
288
289 def test_force_current_branch_dry_run_does_not_restore(
290 self, repo: pathlib.Path
291 ) -> None:
292 (repo / "a.py").unlink()
293 _checkout(repo, "--force", "--dry-run", "main")
294 assert not (repo / "a.py").exists(), (
295 "--dry-run must not actually restore files"
296 )
297
298 def test_force_current_branch_dry_run_json(self, repo: pathlib.Path) -> None:
299 result = _checkout(repo, "--force", "--dry-run", "main", "--json")
300 data = json.loads(result.output)
301 assert data["dry_run"] is True
302 assert data["action"] == "restored"
303
304 def test_without_force_still_noop_on_current_branch(
305 self, repo: pathlib.Path
306 ) -> None:
307 """Without --force, checkout on current branch is still a no-op."""
308 result = _checkout(repo, "main")
309 assert "Already on" in result.output
310
311
312 # ──────────────────────────────────────────────────────────────────────────────
313 # Integration — CREATE (-b)
314 # ──────────────────────────────────────────────────────────────────────────────
315
316
317 class TestCreate:
318 def test_create_exits_0(self, repo: pathlib.Path) -> None:
319 result = _checkout(repo, "-b", "new-branch")
320 assert result.exit_code == 0
321
322 def test_create_switches_to_new_branch(self, repo: pathlib.Path) -> None:
323 _checkout(repo, "-b", "new-branch")
324 assert read_current_branch(repo) == "new-branch"
325
326 def test_create_text_output(self, repo: pathlib.Path) -> None:
327 result = _checkout(repo, "-b", "my-branch")
328 assert "my-branch" in result.output
329
330 def test_create_json_schema(self, repo: pathlib.Path) -> None:
331 result = _checkout(repo, "-b", "json-branch", "--json")
332 data = json.loads(result.output)
333 assert data["action"] == "created"
334 assert data["branch"] == "json-branch"
335 assert data["from_branch"] == "main"
336 assert "commit_id" in data
337 assert data.get("dry_run") is False
338
339 def test_create_duplicate_exits_1(self, repo: pathlib.Path) -> None:
340 _checkout(repo, "-b", "dup")
341 _checkout(repo, "main")
342 result = _checkout(repo, "-b", "dup")
343 assert result.exit_code == 1
344
345 def test_create_duplicate_error_to_stderr(self, repo: pathlib.Path) -> None:
346 _checkout(repo, "-b", "dup2")
347 _checkout(repo, "main")
348 result = _checkout(repo, "-b", "dup2")
349 # Error must appear in stderr
350 assert "already exists" in (result.stderr or "").lower()
351
352 def test_create_invalid_name_exits_1(self, repo: pathlib.Path) -> None:
353 result = _checkout(repo, "-b", "bad..name")
354 assert result.exit_code == 1
355
356 def test_create_invalid_name_error_to_stderr(self, repo: pathlib.Path) -> None:
357 result = _checkout(repo, "-b", "bad..name")
358 assert "Invalid" in (result.stderr or "")
359
360 def test_create_with_dirty_workdir_succeeds(self, repo: pathlib.Path) -> None:
361 """checkout -b must succeed even with uncommitted changes.
362
363 Creating a new branch starts at the current HEAD — no file content
364 changes, so dirty tracked files cannot be overwritten. Blocking
365 here forces an unnecessary shelf/pop dance.
366 """
367 # Dirty the tracked file without committing
368 (repo / "a.py").write_text("x = 2\n")
369 result = _checkout(repo, "-b", "task/dirty-ok")
370 assert result.exit_code == 0
371 assert read_current_branch(repo) == "task/dirty-ok"
372 # The dirty file must still be present (not lost)
373 assert (repo / "a.py").read_text() == "x = 2\n"
374
375
376 # ──────────────────────────────────────────────────────────────────────────────
377 # Integration — DETACH HEAD
378 # ──────────────────────────────────────────────────────────────────────────────
379
380
381 class TestDetach:
382 def test_detach_full_sha_exits_0(self, repo: pathlib.Path) -> None:
383 sha = get_head_commit_id(repo, "main")
384 assert sha is not None
385 result = _checkout(repo, sha)
386 assert result.exit_code == 0
387
388 def test_detach_full_sha_text_output(self, repo: pathlib.Path) -> None:
389 sha = get_head_commit_id(repo, "main")
390 assert sha is not None
391 result = _checkout(repo, sha)
392 # Output shows sha256: prefix + 8 hex chars — canonical and algorithm-identifying.
393 assert sha[:len("sha256:") + 8] in result.output
394
395 def test_detach_full_sha_json_schema(self, repo: pathlib.Path) -> None:
396 sha = get_head_commit_id(repo, "main")
397 assert sha is not None
398 result = _checkout(repo, sha, "--json")
399 data = json.loads(result.output)
400 assert data["action"] == "detached"
401 assert data["branch"] is None
402 assert data["commit_id"] == sha
403 assert data["from_branch"] == "main"
404 assert data.get("dry_run") is False
405
406 def test_detach_partial_sha_exits_0(self, repo: pathlib.Path) -> None:
407 sha = get_head_commit_id(repo, "main")
408 assert sha is not None
409 # Pass bare hex prefix to checkout — the command resolves it
410 hex_prefix = short_id(sha, strip=True)
411 result = _checkout(repo, hex_prefix)
412 assert result.exit_code == 0
413
414 def test_detach_partial_sha_points_to_correct_commit(self, repo: pathlib.Path) -> None:
415 """A partial SHA must resolve to the correct commit, not be treated as a branch."""
416 from muse.core.store import get_commits_for_branch, read_current_branch
417 from muse.core.repo import read_repo_id
418
419 (repo / "b.py").write_text("b=1\n")
420 _commit(repo, "-m", "second")
421
422 repo_id = read_repo_id(repo)
423 branch = read_current_branch(repo)
424 commits = get_commits_for_branch(repo, repo_id, branch)
425 first_sha = commits[-1].commit_id # oldest
426
427 # Pass bare hex prefix to checkout — the command resolves it
428 hex_prefix = short_id(first_sha, strip=True)
429 result = _checkout(repo, hex_prefix)
430 assert result.exit_code == 0
431 assert first_sha[:len("sha256:") + 8] in result.output
432
433 def test_detach_bad_ref_exits_1(self, repo: pathlib.Path) -> None:
434 result = _checkout(repo, "deadbeefdeadbeef")
435 assert result.exit_code == 1
436
437 def test_detach_error_to_stderr(self, repo: pathlib.Path) -> None:
438 result = _checkout(repo, "deadbeefdeadbeef")
439 assert "not a branch" in (result.stderr or "").lower()
440
441
442 # ──────────────────────────────────────────────────────────────────────────────
443 # Integration — DRY-RUN
444 # ──────────────────────────────────────────────────────────────────────────────
445
446
447 class TestDryRun:
448 def test_dry_run_switch_exits_0(self, two_branch_repo: pathlib.Path) -> None:
449 result = _checkout(two_branch_repo, "--dry-run", "feat")
450 assert result.exit_code == 0
451
452 def test_dry_run_does_not_switch_branch(self, two_branch_repo: pathlib.Path) -> None:
453 _checkout(two_branch_repo, "--dry-run", "feat")
454 assert read_current_branch(two_branch_repo) == "main"
455
456 def test_dry_run_text_says_would(self, two_branch_repo: pathlib.Path) -> None:
457 result = _checkout(two_branch_repo, "--dry-run", "feat")
458 assert "Would" in result.output
459 assert "feat" in result.output
460
461 def test_dry_run_json_schema(self, two_branch_repo: pathlib.Path) -> None:
462 result = _checkout(two_branch_repo, "--dry-run", "feat", "--json")
463 data = json.loads(result.output)
464 assert data["dry_run"] is True
465 assert data["action"] == "switched"
466 assert data["branch"] == "feat"
467 assert data["from_branch"] == "main"
468
469 def test_dry_run_does_not_restore_files(self, two_branch_repo: pathlib.Path) -> None:
470 """feat.py exists only on feat branch; dry-run must not create it on main."""
471 _checkout(two_branch_repo, "--dry-run", "feat")
472 assert not (two_branch_repo / "feat.py").exists()
473
474 def test_dry_run_create_exits_0(self, repo: pathlib.Path) -> None:
475 result = _checkout(repo, "-b", "dry-branch", "--dry-run")
476 assert result.exit_code == 0
477
478 def test_dry_run_create_does_not_create_branch(self, repo: pathlib.Path) -> None:
479 _checkout(repo, "-b", "dry-branch", "--dry-run")
480 result = _invoke(repo, ["branch", "--json"])
481 names = [b["name"] for b in json.loads(result.output)]
482 assert "dry-branch" not in names
483
484 def test_dry_run_create_json_schema(self, repo: pathlib.Path) -> None:
485 result = _checkout(repo, "-b", "dry-new", "--dry-run", "--json")
486 data = json.loads(result.output)
487 assert data["dry_run"] is True
488 assert data["action"] == "created"
489 assert data["from_branch"] == "main"
490
491 def test_dry_run_detach_exits_0(self, repo: pathlib.Path) -> None:
492 sha = get_head_commit_id(repo, "main")
493 assert sha is not None
494 result = _checkout(repo, "--dry-run", sha)
495 assert result.exit_code == 0
496
497 def test_dry_run_detach_does_not_detach(self, repo: pathlib.Path) -> None:
498 sha = get_head_commit_id(repo, "main")
499 assert sha is not None
500 _checkout(repo, "--dry-run", sha)
501 assert read_current_branch(repo) == "main"
502
503 def test_dry_run_detach_json(self, repo: pathlib.Path) -> None:
504 sha = get_head_commit_id(repo, "main")
505 assert sha is not None
506 result = _checkout(repo, "--dry-run", sha, "--json")
507 data = json.loads(result.output)
508 assert data["dry_run"] is True
509 assert data["action"] == "detached"
510 assert data["branch"] is None
511
512 def test_dry_run_nonexistent_branch_exits_1(self, repo: pathlib.Path) -> None:
513 result = _checkout(repo, "--dry-run", "no-such-branch")
514 assert result.exit_code == 1
515
516 def test_dry_run_already_on_exits_0(self, repo: pathlib.Path) -> None:
517 result = _checkout(repo, "--dry-run", "main")
518 assert result.exit_code == 0
519
520 def test_dry_run_already_on_json(self, repo: pathlib.Path) -> None:
521 result = _checkout(repo, "--dry-run", "main", "--json")
522 data = json.loads(result.output)
523 assert data["dry_run"] is True
524 assert data["action"] == "already_on"
525
526
527 # ──────────────────────────────────────────────────────────────────────────────
528 # Integration — JSON schema consistency
529 # ──────────────────────────────────────────────────────────────────────────────
530
531
532 class TestJsonSchema:
533 REQUIRED_KEYS = {"action", "branch", "commit_id", "from_branch", "dry_run"}
534
535 def test_create_has_all_keys(self, repo: pathlib.Path) -> None:
536 result = _checkout(repo, "-b", "k-test", "--json")
537 data = json.loads(result.output)
538 missing = self.REQUIRED_KEYS - set(data)
539 assert not missing, f"Missing keys in 'created' JSON: {missing}"
540
541 def test_switch_has_all_keys(self, two_branch_repo: pathlib.Path) -> None:
542 result = _checkout(two_branch_repo, "feat", "--json")
543 data = json.loads(result.output)
544 missing = self.REQUIRED_KEYS - set(data)
545 assert not missing, f"Missing keys in 'switched' JSON: {missing}"
546
547 def test_already_on_has_all_keys(self, repo: pathlib.Path) -> None:
548 result = _checkout(repo, "main", "--json")
549 data = json.loads(result.output)
550 missing = self.REQUIRED_KEYS - set(data)
551 assert not missing, f"Missing keys in 'already_on' JSON: {missing}"
552
553 def test_detach_has_all_keys(self, repo: pathlib.Path) -> None:
554 sha = get_head_commit_id(repo, "main")
555 assert sha is not None
556 result = _checkout(repo, sha, "--json")
557 data = json.loads(result.output)
558 missing = self.REQUIRED_KEYS - set(data)
559 assert not missing, f"Missing keys in 'detached' JSON: {missing}"
560
561 def test_detach_branch_is_null(self, repo: pathlib.Path) -> None:
562 sha = get_head_commit_id(repo, "main")
563 assert sha is not None
564 result = _checkout(repo, sha, "--json")
565 data = json.loads(result.output)
566 assert data["branch"] is None
567
568 def test_from_branch_reflects_previous(self, two_branch_repo: pathlib.Path) -> None:
569 _checkout(two_branch_repo, "feat")
570 result = _checkout(two_branch_repo, "main", "--json")
571 data = json.loads(result.output)
572 assert data["from_branch"] == "feat"
573
574
575 # ──────────────────────────────────────────────────────────────────────────────
576 # Integration — validation
577 # ──────────────────────────────────────────────────────────────────────────────
578
579
580 class TestValidation:
581 def test_no_target_exits_1(self, repo: pathlib.Path) -> None:
582 result = _checkout(repo)
583 assert result.exit_code == 1
584
585 def test_no_target_error_to_stderr(self, repo: pathlib.Path) -> None:
586 result = _checkout(repo)
587 assert "Specify" in (result.stderr or "")
588
589 def test_unknown_format_exits_1(self, repo: pathlib.Path) -> None:
590 result = _checkout(repo, "main", "--format", "xml")
591 assert result.exit_code == 1
592
593 def test_unknown_format_error_to_stderr(self, repo: pathlib.Path) -> None:
594 result = _checkout(repo, "main", "--format", "xml")
595 assert "Unknown" in (result.stderr or "")
596
597 def test_ours_without_theirs_context_exits_1(self, repo: pathlib.Path) -> None:
598 result = _checkout(repo, "--ours", "file.py")
599 assert result.exit_code == 1
600
601 def test_ours_and_theirs_together_exits_1(self, repo: pathlib.Path) -> None:
602 result = _checkout(repo, "--ours", "--theirs", "--all")
603 assert result.exit_code == 1
604
605
606 # ──────────────────────────────────────────────────────────────────────────────
607 # Security — ANSI injection
608 # ──────────────────────────────────────────────────────────────────────────────
609
610
611 class TestSecurityAnsi:
612 def _has_ansi(self, s: str) -> bool:
613 return "\x1b[" in s
614
615 def test_ansi_in_target_sanitized(self, repo: pathlib.Path) -> None:
616 result = _checkout(repo, "\x1b[31mevil\x1b[0m")
617 assert not self._has_ansi(result.output)
618
619 def test_ansi_in_create_name_sanitized(self, repo: pathlib.Path) -> None:
620 result = _checkout(repo, "-b", "\x1b[31mevil\x1b[0m")
621 assert not self._has_ansi(result.output)
622
623 def test_ansi_in_format_sanitized(self, repo: pathlib.Path) -> None:
624 result = _checkout(repo, "main", "--format", "\x1b[31mxml\x1b[0m")
625 assert not self._has_ansi(result.output)
626
627 def test_error_not_a_branch_sanitized(self, repo: pathlib.Path) -> None:
628 """The 'not a branch' error message must not echo raw ANSI from target."""
629 result = _checkout(repo, "\x1b[31mnotabranch\x1b[0m")
630 assert not self._has_ansi(result.output)
631 assert not self._has_ansi(result.stderr or "")
632
633 def test_all_errors_to_stderr(self, repo: pathlib.Path) -> None:
634 """Every ❌ error must go to stderr; stderr must contain the error."""
635 error_cases = [
636 ["ghost"],
637 ["-b", "bad..name"],
638 ["--format", "xml"],
639 ]
640 for case in error_cases:
641 result = _checkout(repo, *case)
642 assert result.exit_code != 0, f"Expected failure for args {case}"
643 assert "❌" in (result.stderr or ""), (
644 f"Error not in stderr for args {case}: stderr={result.stderr!r}"
645 )
646
647
648 # ──────────────────────────────────────────────────────────────────────────────
649 # Integration — conflict resolution
650 # ──────────────────────────────────────────────────────────────────────────────
651
652
653 class TestConflictResolution:
654 def _setup_merge_conflict(
655 self, repo: pathlib.Path
656 ) -> tuple[str, str]:
657 """Create a merge conflict on ``repo``. Returns (ours_commit, theirs_commit)."""
658 # ours: commit on main
659 (repo / "shared.py").write_text("x = 1\n")
660 _commit(repo, "-m", "main: set x=1")
661 ours_cid = get_head_commit_id(repo, "main") or ""
662
663 # theirs: commit on feature branch
664 _branch(repo, "feat2")
665 _invoke(repo, ["checkout", "feat2"])
666 (repo / "shared.py").write_text("x = 2\n")
667 _commit(repo, "-m", "feat: set x=2")
668 theirs_cid = get_head_commit_id(repo, "feat2") or ""
669
670 _invoke(repo, ["checkout", "main"])
671 # Force a merge conflict via merge_engine internals
672 from muse.core.merge_engine import write_merge_state
673
674 write_merge_state(
675 repo,
676 base_commit="",
677 ours_commit=ours_cid,
678 theirs_commit=theirs_cid,
679 conflict_paths=["shared.py"],
680 other_branch="feat2",
681 )
682 return ours_cid, theirs_cid
683
684 def test_ours_no_merge_state_exits_1(self, repo: pathlib.Path) -> None:
685 result = _checkout(repo, "--ours", "file.py")
686 assert result.exit_code == 1
687
688 def test_theirs_no_merge_state_exits_1(self, repo: pathlib.Path) -> None:
689 result = _checkout(repo, "--theirs", "file.py")
690 assert result.exit_code == 1
691
692 def test_ours_and_theirs_both_exits_1(self, repo: pathlib.Path) -> None:
693 result = _checkout(repo, "--ours", "--theirs", "--all")
694 assert result.exit_code == 1
695
696 def test_ours_resolves_conflict(self, repo: pathlib.Path) -> None:
697 self._setup_merge_conflict(repo)
698 result = _checkout(repo, "--ours", "shared.py")
699 assert result.exit_code == 0
700
701 def test_theirs_resolves_conflict(self, repo: pathlib.Path) -> None:
702 self._setup_merge_conflict(repo)
703 result = _checkout(repo, "--theirs", "shared.py")
704 assert result.exit_code == 0
705
706 def test_resolve_all_ours_json(self, repo: pathlib.Path) -> None:
707 self._setup_merge_conflict(repo)
708 result = _checkout(repo, "--ours", "--all", "--json")
709 assert result.exit_code == 0
710 data = json.loads(result.output)
711 assert data["action"] == "conflict_resolved_all"
712 assert data["side"] == "ours"
713 assert "resolved_count" in data
714 assert "remaining_conflicts" in data
715
716 def test_resolve_all_theirs_json(self, repo: pathlib.Path) -> None:
717 self._setup_merge_conflict(repo)
718 result = _checkout(repo, "--theirs", "--all", "--json")
719 assert result.exit_code == 0
720 data = json.loads(result.output)
721 assert data["action"] == "conflict_resolved_all"
722 assert data["side"] == "theirs"
723
724 def test_resolve_single_file_json(self, repo: pathlib.Path) -> None:
725 self._setup_merge_conflict(repo)
726 result = _checkout(repo, "--ours", "shared.py", "--json")
727 assert result.exit_code == 0
728 data = json.loads(result.output)
729 assert data["action"] == "conflict_resolved"
730 assert data["file"] == "shared.py"
731 assert data["side"] == "ours"
732 assert "remaining_conflicts" in data
733
734 def test_resolve_all_empty_conflicts_exits_0(self, repo: pathlib.Path) -> None:
735 """--ours --all when no conflicts exist still exits 0."""
736 from muse.core.merge_engine import write_merge_state
737
738 ours = get_head_commit_id(repo, "main") or ""
739 write_merge_state(
740 repo,
741 base_commit="",
742 ours_commit=ours,
743 theirs_commit=ours,
744 conflict_paths=[],
745 other_branch="feat",
746 )
747 result = _checkout(repo, "--ours", "--all")
748 assert result.exit_code == 0
749
750 def test_resolve_nonexistent_path_exits_0(self, repo: pathlib.Path) -> None:
751 """A path not in the conflict list is informational, not an error."""
752 self._setup_merge_conflict(repo)
753 result = _checkout(repo, "--ours", "not_conflicted.py")
754 assert result.exit_code == 0
755
756 def test_missing_ours_theirs_without_all_exits_1(self, repo: pathlib.Path) -> None:
757 result = _checkout(repo, "--ours")
758 assert result.exit_code == 1
759
760
761 # ──────────────────────────────────────────────────────────────────────────────
762 # Stress
763 # ──────────────────────────────────────────────────────────────────────────────
764
765
766 @pytest.mark.slow
767 class TestStress:
768 def test_checkout_100_file_branch_fast(self, repo: pathlib.Path) -> None:
769 """Switching between branches with 100 modified files under 2s."""
770 for i in range(100):
771 (repo / f"f{i:03d}.py").write_text(f"x={i}\n")
772 _commit(repo, "-m", "big main")
773 _branch(repo, "big-alt")
774 _checkout(repo, "big-alt")
775 for i in range(100):
776 (repo / f"f{i:03d}.py").write_text(f"y={i}\n")
777 _commit(repo, "-m", "big alt")
778 _checkout(repo, "main")
779
780 t0 = time.perf_counter()
781 result = _checkout(repo, "big-alt")
782 elapsed = (time.perf_counter() - t0) * 1000
783 assert result.exit_code == 0
784 assert elapsed < 2000, f"checkout 100-file branch took {elapsed:.0f}ms (limit 2s)"
785
786 def test_dry_run_100_file_branch_fast(self, repo: pathlib.Path) -> None:
787 """dry-run on 100-file branch should be very fast (no restore)."""
788 for i in range(100):
789 (repo / f"g{i:03d}.py").write_text(f"x={i}\n")
790 _commit(repo, "-m", "big2")
791 _branch(repo, "big2-alt")
792
793 t0 = time.perf_counter()
794 result = _checkout(repo, "--dry-run", "big2-alt")
795 elapsed = (time.perf_counter() - t0) * 1000
796 assert result.exit_code == 0
797 assert elapsed < 500, f"dry-run took {elapsed:.0f}ms (limit 500ms)"
798
799 def test_concurrent_checkouts_separate_repos(self, tmp_path: pathlib.Path) -> None:
800 """Multiple threads checking out branches in separate repos must not interfere."""
801 errors: list[str] = []
802
803 def do_checkout(idx: int) -> None:
804 repo_dir = tmp_path / f"repo_{idx}"
805 repo_dir.mkdir()
806 subprocess.run(["muse", "init"], cwd=str(repo_dir), capture_output=True)
807 (repo_dir / "x.py").write_text(f"x={idx}\n")
808 subprocess.run(
809 ["muse", "commit", "-m", f"base{idx}"],
810 cwd=str(repo_dir), capture_output=True,
811 )
812 subprocess.run(
813 ["muse", "branch", "alt"], cwd=str(repo_dir), capture_output=True
814 )
815 subprocess.run(
816 ["muse", "checkout", "alt"], cwd=str(repo_dir), capture_output=True
817 )
818 (repo_dir / "y.py").write_text(f"y={idx}\n")
819 subprocess.run(
820 ["muse", "commit", "-m", f"alt{idx}"],
821 cwd=str(repo_dir), capture_output=True,
822 )
823 r = subprocess.run(
824 ["muse", "checkout", "main", "--json"],
825 cwd=str(repo_dir), capture_output=True, text=True,
826 )
827 if r.returncode != 0:
828 errors.append(f"repo_{idx}: checkout failed")
829 return
830 data = json.loads(r.stdout)
831 if data.get("action") != "switched":
832 errors.append(f"repo_{idx}: expected switched, got {data.get('action')}")
833
834 threads = [threading.Thread(target=do_checkout, args=(i,)) for i in range(6)]
835 for t in threads:
836 t.start()
837 for t in threads:
838 t.join()
839 assert not errors, "Concurrent checkout errors:\n" + "\n".join(errors)
840
841 def test_repeated_back_and_forth_100_times(self, two_branch_repo: pathlib.Path) -> None:
842 """Switching back and forth 100 times must not corrupt the working tree."""
843 for i in range(50):
844 r1 = _checkout(two_branch_repo, "feat")
845 assert r1.exit_code == 0, f"Iteration {i}: switch to feat failed"
846 assert (two_branch_repo / "feat.py").exists()
847 r2 = _checkout(two_branch_repo, "main")
848 assert r2.exit_code == 0, f"Iteration {i}: switch to main failed"
849
850
851 # ──────────────────────────────────────────────────────────────────────────────
852 # TestCheckoutMerge — muse checkout -m (Cohen Transform carry-forward)
853 # ──────────────────────────────────────────────────────────────────────────────
854
855
856 def _make_diverged_repo(tmp_path: pathlib.Path) -> pathlib.Path:
857 """Repo with main and *other* branches that have diverged file content.
858
859 Layout after setup::
860
861 main: shared.py = "line1\\nline2\\nline3\\n" (committed)
862 other: shared.py = "line1\\nLINE2\\nline3\\n" (committed — different line 2)
863
864 The caller is left on *main* with a dirty working tree.
865 """
866 saved = os.getcwd()
867 try:
868 os.chdir(tmp_path)
869 runner.invoke(None, ["init"])
870 finally:
871 os.chdir(saved)
872
873 (tmp_path / "shared.py").write_text("line1\nline2\nline3\n")
874 _commit(tmp_path, "-m", "initial")
875
876 # Create other branch with a different version of shared.py
877 _branch(tmp_path, "other")
878 _checkout(tmp_path, "other")
879 (tmp_path / "shared.py").write_text("line1\nLINE2\nline3\n")
880 _commit(tmp_path, "-m", "other changes line2")
881
882 # Back on main
883 _checkout(tmp_path, "main")
884 return tmp_path
885
886
887 class TestCheckoutMergeParser:
888 """Parser-level tests for the ``-m`` / ``--merge`` flag."""
889
890 def _parse(self, *args: str) -> "argparse.Namespace":
891 import argparse
892
893 from muse.cli.commands.checkout import register
894
895 parser = argparse.ArgumentParser()
896 sub = parser.add_subparsers()
897 register(sub)
898 return parser.parse_args(["checkout", *args])
899
900 def test_merge_short_flag_parsed(self) -> None:
901 ns = self._parse("-m", "feat")
902 assert ns.merge is True
903
904 def test_merge_long_flag_parsed(self) -> None:
905 ns = self._parse("--merge", "feat")
906 assert ns.merge is True
907
908 def test_merge_false_by_default(self) -> None:
909 ns = self._parse("feat")
910 assert ns.merge is False
911
912 def test_merge_and_dry_run_coexist(self) -> None:
913 ns = self._parse("-m", "--dry-run", "feat")
914 assert ns.merge is True
915 assert ns.dry_run is True
916
917 def test_merge_and_json_coexist(self) -> None:
918 ns = self._parse("-m", "--json", "feat")
919 assert ns.merge is True
920 assert ns.fmt == "json"
921
922
923 class TestCheckoutMergeClean:
924 """Clean-merge scenarios — no conflict markers should appear."""
925
926 def test_untracked_file_survives_checkout(self, repo: pathlib.Path) -> None:
927 """Untracked files must not be disturbed by -m checkout."""
928 _branch(repo, "feat")
929 (repo / "untracked.txt").write_text("I am untracked\n")
930 r = _checkout(repo, "-m", "feat")
931 assert r.exit_code == 0
932 assert (repo / "untracked.txt").read_text() == "I am untracked\n"
933
934 def test_ours_only_change_carried_cleanly(self, tmp_path: pathlib.Path) -> None:
935 """When we modify a file that target branch left untouched, the change carries.
936
937 'clean' branch diverged by adding a brand-new file, leaving shared.py
938 identical to main's HEAD. Our uncommitted change to shared.py has no
939 competition from the target and must merge cleanly.
940 """
941 saved = os.getcwd()
942 try:
943 os.chdir(tmp_path)
944 runner.invoke(None, ["init"])
945 finally:
946 os.chdir(saved)
947 (tmp_path / "shared.py").write_text("line1\nline2\nline3\n")
948 _commit(tmp_path, "-m", "initial")
949
950 # Create 'clean' branch — only adds a new file, does NOT touch shared.py
951 _branch(tmp_path, "clean")
952 _checkout(tmp_path, "clean")
953 (tmp_path / "extra.py").write_text("# extra\n")
954 _commit(tmp_path, "-m", "add extra.py")
955 _checkout(tmp_path, "main")
956
957 # Dirty workdir on main: modify shared.py
958 (tmp_path / "shared.py").write_text("line1\nline2\nLINE3\n")
959 r = _checkout(tmp_path, "-m", "clean")
960 assert r.exit_code == 0
961 content = (tmp_path / "shared.py").read_text()
962 assert "LINE3" in content, "Our uncommitted change must be in merged result"
963
964 def test_clean_merge_json_output(self, tmp_path: pathlib.Path) -> None:
965 """JSON output reports clean_merges and empty conflicts on success."""
966 saved = os.getcwd()
967 try:
968 os.chdir(tmp_path)
969 runner.invoke(None, ["init"])
970 finally:
971 os.chdir(saved)
972 (tmp_path / "shared.py").write_text("line1\nline2\nline3\n")
973 _commit(tmp_path, "-m", "initial")
974
975 _branch(tmp_path, "clean")
976 _checkout(tmp_path, "clean")
977 (tmp_path / "extra.py").write_text("# extra\n")
978 _commit(tmp_path, "-m", "add extra.py")
979 _checkout(tmp_path, "main")
980
981 (tmp_path / "shared.py").write_text("line1\nline2\nLINE3\n")
982 r = _checkout(tmp_path, "-m", "--json", "clean")
983 assert r.exit_code == 0
984 data = json.loads(r.output)
985 assert data["action"] == "switched"
986 assert data["branch"] == "clean"
987 assert isinstance(data["clean_merges"], list)
988 assert isinstance(data["conflicts"], list)
989 assert len(data["conflicts"]) == 0
990
991 def test_switched_branch_recorded(self, tmp_path: pathlib.Path) -> None:
992 """After a clean -m checkout the current branch is the target."""
993 repo = _make_diverged_repo(tmp_path)
994 (repo / "shared.py").write_text("line1\nline2\nLINE3\n")
995 _checkout(repo, "-m", "other")
996 assert read_current_branch(repo) == "other"
997
998 def test_no_dirty_files_succeeds_silently(self, repo: pathlib.Path) -> None:
999 """If the working tree is clean, -m behaves like a normal checkout."""
1000 _branch(repo, "feat")
1001 r = _checkout(repo, "-m", "feat")
1002 assert r.exit_code == 0
1003 assert read_current_branch(repo) == "feat"
1004
1005 def test_dry_run_does_not_switch_branch(self, tmp_path: pathlib.Path) -> None:
1006 """-m --dry-run must not actually switch branches."""
1007 repo = _make_diverged_repo(tmp_path)
1008 (repo / "shared.py").write_text("line1\nline2\nLINE3\n")
1009 r = _checkout(repo, "-m", "--dry-run", "other")
1010 assert r.exit_code == 0
1011 assert read_current_branch(repo) == "main"
1012
1013 def test_dry_run_json_reports_dry_run_true(self, tmp_path: pathlib.Path) -> None:
1014 repo = _make_diverged_repo(tmp_path)
1015 (repo / "shared.py").write_text("line1\nline2\nLINE3\n")
1016 r = _checkout(repo, "-m", "--dry-run", "--json", "other")
1017 assert r.exit_code == 0
1018 data = json.loads(r.output)
1019 assert data["dry_run"] is True
1020 assert data["branch"] == "other"
1021
1022
1023 class TestCheckoutMergeConflict:
1024 """Conflict scenarios — conflict markers and MERGE_STATE must appear."""
1025
1026 def test_conflicting_change_exits_1(self, tmp_path: pathlib.Path) -> None:
1027 """Same line changed on both sides → conflict → exit code 1."""
1028 repo = _make_diverged_repo(tmp_path)
1029 # Also change line2 on main (uncommitted) — same line other branch changed
1030 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1031 r = _checkout(repo, "-m", "other")
1032 assert r.exit_code == 1
1033
1034 def test_conflict_markers_written_to_file(self, tmp_path: pathlib.Path) -> None:
1035 """Conflicting file must contain diff3-style conflict markers after -m."""
1036 repo = _make_diverged_repo(tmp_path)
1037 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1038 _checkout(repo, "-m", "other")
1039 content = (repo / "shared.py").read_text()
1040 assert "<<<<<<<" in content
1041 assert "=======" in content
1042 assert ">>>>>>>" in content
1043
1044 def test_conflict_markers_contain_cohen_action_labels(self, tmp_path: pathlib.Path) -> None:
1045 """Cohen-style action labels ([modified], [inserted], [deleted]) must appear."""
1046 repo = _make_diverged_repo(tmp_path)
1047 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1048 _checkout(repo, "-m", "other")
1049 content = (repo / "shared.py").read_text()
1050 # At least one action label must be present
1051 assert any(label in content for label in ("[modified]", "[inserted]", "[deleted]"))
1052
1053 def test_merge_state_written_on_conflict(self, tmp_path: pathlib.Path) -> None:
1054 """MERGE_STATE.json must exist after a conflicting -m checkout."""
1055 repo = _make_diverged_repo(tmp_path)
1056 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1057 _checkout(repo, "-m", "other")
1058 merge_state_file = repo / ".muse" / "MERGE_STATE.json"
1059 assert merge_state_file.exists()
1060
1061 def test_merge_state_lists_conflict_path(self, tmp_path: pathlib.Path) -> None:
1062 """MERGE_STATE.json must name the conflicting path."""
1063 repo = _make_diverged_repo(tmp_path)
1064 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1065 _checkout(repo, "-m", "other")
1066 data = json.loads((repo / ".muse" / "MERGE_STATE.json").read_text())
1067 assert "shared.py" in data.get("conflict_paths", [])
1068
1069 def test_conflict_json_output_contains_conflicts_list(self, tmp_path: pathlib.Path) -> None:
1070 """JSON output must list the conflict paths even on exit code 1."""
1071 repo = _make_diverged_repo(tmp_path)
1072 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1073 r = _checkout(repo, "-m", "--json", "other")
1074 data = json.loads(r.output)
1075 assert "conflicts" in data
1076 assert len(data["conflicts"]) >= 1
1077
1078 def test_branch_is_switched_despite_conflict(self, tmp_path: pathlib.Path) -> None:
1079 """Even when conflicts exist, we are on the target branch after -m."""
1080 repo = _make_diverged_repo(tmp_path)
1081 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1082 _checkout(repo, "-m", "other")
1083 assert read_current_branch(repo) == "other"
1084
1085
1086 class TestCheckoutMergeErrors:
1087 """Error cases for -m flag."""
1088
1089 def test_merge_with_create_flag_ignored(self, repo: pathlib.Path) -> None:
1090 """``-m -b new-branch`` must NOT invoke _checkout_with_merge (target doesn't exist)."""
1091 # -m with -b falls through to regular create logic; no conflict-carry
1092 r = _checkout(repo, "-m", "-b", "new-branch")
1093 # Should succeed as a regular branch creation (no carry logic for new branches)
1094 assert r.exit_code == 0
1095
1096 def test_merge_missing_branch_errors(self, repo: pathlib.Path) -> None:
1097 """``-m nonexistent`` must exit 1 with an error message."""
1098 r = _checkout(repo, "-m", "nonexistent")
1099 assert r.exit_code == 1
1100
1101 def test_merge_already_on_branch_is_noop(self, repo: pathlib.Path) -> None:
1102 """``-m main`` when already on main must print 'Already on' and exit 0."""
1103 r = _checkout(repo, "-m", "main")
1104 assert r.exit_code == 0
1105 assert "Already on" in r.output or "already" in r.output.lower()
1106
1107 def test_merge_with_invalid_branch_name_errors(self, repo: pathlib.Path) -> None:
1108 """``-m ..invalid`` must exit 1 before attempting any merge."""
1109 r = _checkout(repo, "-m", "../bad/name")
1110 assert r.exit_code == 1
1111
1112
1113 # ──────────────────────────────────────────────────────────────────────────────
1114 # Non-conflicting carry-forward: checkout <branch> with dirty files that the
1115 # target branch does not touch should succeed without --merge or --autoshelf.
1116 #
1117 # This is the core ergonomics fix: an agent editing app.py on dev, then doing
1118 # `checkout dev` (switching from a task branch back) must not be blocked if
1119 # the target branch has the same version of app.py at its HEAD.
1120 # ──────────────────────────────────────────────────────────────────────────────
1121
1122
1123 class TestSwitchWithNonConflictingChanges:
1124 @pytest.fixture()
1125 def two_branch_clean(self, tmp_path: pathlib.Path) -> pathlib.Path:
1126 """Repo with main and feat; feat adds a NEW file, leaves shared.py alone."""
1127 saved = os.getcwd()
1128 try:
1129 os.chdir(tmp_path)
1130 runner.invoke(None, ["init"])
1131 finally:
1132 os.chdir(saved)
1133 (tmp_path / "shared.py").write_text("x = 1\n")
1134 _commit(tmp_path, "-m", "initial")
1135 # Create feat branch with an extra file — shared.py is untouched
1136 _checkout(tmp_path, "-b", "feat")
1137 (tmp_path / "feat_only.py").write_text("f = 1\n")
1138 _commit(tmp_path, "-m", "feat commit")
1139 _checkout(tmp_path, "main")
1140 return tmp_path
1141
1142 def test_switch_carries_change_not_in_target(
1143 self, two_branch_clean: pathlib.Path
1144 ) -> None:
1145 """Modifying a file the target branch doesn't touch must not block checkout."""
1146 repo = two_branch_clean
1147 # Dirty shared.py — feat branch has the same version of it (same object_id)
1148 (repo / "shared.py").write_text("x = 2\n")
1149 result = _checkout(repo, "feat")
1150 assert result.exit_code == 0
1151 assert read_current_branch(repo) == "feat"
1152
1153 def test_switch_preserves_non_conflicting_change(
1154 self, two_branch_clean: pathlib.Path
1155 ) -> None:
1156 """The locally modified file must survive the branch switch unchanged."""
1157 repo = two_branch_clean
1158 (repo / "shared.py").write_text("x = 99\n")
1159 _checkout(repo, "feat")
1160 assert (repo / "shared.py").read_text() == "x = 99\n"
1161
1162 def test_switch_still_blocks_on_true_conflict(
1163 self, tmp_path: pathlib.Path
1164 ) -> None:
1165 """When the target branch has a different version of a modified file, block."""
1166 saved = os.getcwd()
1167 try:
1168 os.chdir(tmp_path)
1169 runner.invoke(None, ["init"])
1170 finally:
1171 os.chdir(saved)
1172 (tmp_path / "shared.py").write_text("x = 1\n")
1173 _commit(tmp_path, "-m", "initial")
1174 # feat branch changes shared.py
1175 _checkout(tmp_path, "-b", "feat")
1176 (tmp_path / "shared.py").write_text("x = feat\n")
1177 _commit(tmp_path, "-m", "feat changes shared")
1178 _checkout(tmp_path, "main")
1179 # Now dirty shared.py locally — feat has a different version → must block
1180 (tmp_path / "shared.py").write_text("x = local\n")
1181 result = _checkout(tmp_path, "feat")
1182 assert result.exit_code != 0
1183
1184 def test_switch_new_file_carries_through(
1185 self, two_branch_clean: pathlib.Path
1186 ) -> None:
1187 """Brand-new untracked files always carry through (unchanged behaviour)."""
1188 repo = two_branch_clean
1189 (repo / "new_file.py").write_text("new = True\n")
1190 result = _checkout(repo, "feat")
1191 assert result.exit_code == 0
1192 assert (repo / "new_file.py").exists()
1193
1194
1195 # ──────────────────────────────────────────────────────────────────────────────
1196 # Agent supercharge — duration_ms and exit_code in every JSON output
1197 # ──────────────────────────────────────────────────────────────────────────────
1198
1199
1200 class TestElapsed:
1201 """Every JSON output path must include an ``duration_ms`` float."""
1202
1203 def test_switch_json_has_elapsed(self, two_branch_repo: pathlib.Path) -> None:
1204 result = _checkout(two_branch_repo, "feat", "--json")
1205 data = json.loads(result.output)
1206 assert "duration_ms" in data
1207 assert isinstance(data["duration_ms"], float)
1208
1209 def test_create_json_has_elapsed(self, repo: pathlib.Path) -> None:
1210 result = _checkout(repo, "-b", "elapsed-branch", "--json")
1211 data = json.loads(result.output)
1212 assert "duration_ms" in data
1213 assert isinstance(data["duration_ms"], float)
1214
1215 def test_already_on_json_has_elapsed(self, repo: pathlib.Path) -> None:
1216 result = _checkout(repo, "main", "--json")
1217 data = json.loads(result.output)
1218 assert "duration_ms" in data
1219 assert isinstance(data["duration_ms"], float)
1220
1221 def test_detach_json_has_elapsed(self, repo: pathlib.Path) -> None:
1222 sha = get_head_commit_id(repo, "main")
1223 assert sha is not None
1224 result = _checkout(repo, sha, "--json")
1225 data = json.loads(result.output)
1226 assert "duration_ms" in data
1227 assert isinstance(data["duration_ms"], float)
1228
1229 def test_dry_run_switch_json_has_elapsed(self, two_branch_repo: pathlib.Path) -> None:
1230 result = _checkout(two_branch_repo, "--dry-run", "feat", "--json")
1231 data = json.loads(result.output)
1232 assert "duration_ms" in data
1233
1234 def test_dry_run_create_json_has_elapsed(self, repo: pathlib.Path) -> None:
1235 result = _checkout(repo, "-b", "dry-elapsed", "--dry-run", "--json")
1236 data = json.loads(result.output)
1237 assert "duration_ms" in data
1238
1239 def test_dry_run_detach_json_has_elapsed(self, repo: pathlib.Path) -> None:
1240 sha = get_head_commit_id(repo, "main")
1241 assert sha is not None
1242 result = _checkout(repo, "--dry-run", sha, "--json")
1243 data = json.loads(result.output)
1244 assert "duration_ms" in data
1245
1246 def test_restored_json_has_elapsed(self, repo: pathlib.Path) -> None:
1247 result = _checkout(repo, "--force", "main", "--json")
1248 data = json.loads(result.output)
1249 assert "duration_ms" in data
1250
1251 def test_conflict_resolved_all_json_has_elapsed(self, repo: pathlib.Path) -> None:
1252 from muse.core.merge_engine import write_merge_state
1253
1254 ours = get_head_commit_id(repo, "main") or ""
1255 write_merge_state(
1256 repo,
1257 base_commit="",
1258 ours_commit=ours,
1259 theirs_commit=ours,
1260 conflict_paths=["a.py"],
1261 other_branch="feat",
1262 )
1263 result = _checkout(repo, "--ours", "--all", "--json")
1264 data = json.loads(result.output)
1265 assert "duration_ms" in data
1266
1267 def test_conflict_resolved_single_json_has_elapsed(self, repo: pathlib.Path) -> None:
1268 from muse.core.merge_engine import write_merge_state
1269 from muse.core.store import get_head_commit_id as _gci
1270
1271 ours = _gci(repo, "main") or ""
1272 write_merge_state(
1273 repo,
1274 base_commit="",
1275 ours_commit=ours,
1276 theirs_commit=ours,
1277 conflict_paths=["a.py"],
1278 other_branch="feat",
1279 )
1280 result = _checkout(repo, "--ours", "a.py", "--json")
1281 data = json.loads(result.output)
1282 assert "duration_ms" in data
1283
1284
1285 class TestExitCode:
1286 """Every successful JSON output path must include ``exit_code: 0``."""
1287
1288 def test_switch_json_exit_code_0(self, two_branch_repo: pathlib.Path) -> None:
1289 result = _checkout(two_branch_repo, "feat", "--json")
1290 data = json.loads(result.output)
1291 assert data["exit_code"] == 0
1292
1293 def test_create_json_exit_code_0(self, repo: pathlib.Path) -> None:
1294 result = _checkout(repo, "-b", "ec-branch", "--json")
1295 data = json.loads(result.output)
1296 assert data["exit_code"] == 0
1297
1298 def test_already_on_json_exit_code_0(self, repo: pathlib.Path) -> None:
1299 result = _checkout(repo, "main", "--json")
1300 data = json.loads(result.output)
1301 assert data["exit_code"] == 0
1302
1303 def test_detach_json_exit_code_0(self, repo: pathlib.Path) -> None:
1304 sha = get_head_commit_id(repo, "main")
1305 assert sha is not None
1306 result = _checkout(repo, sha, "--json")
1307 data = json.loads(result.output)
1308 assert data["exit_code"] == 0
1309
1310 def test_dry_run_switch_json_exit_code_0(self, two_branch_repo: pathlib.Path) -> None:
1311 result = _checkout(two_branch_repo, "--dry-run", "feat", "--json")
1312 data = json.loads(result.output)
1313 assert data["exit_code"] == 0
1314
1315 def test_dry_run_create_json_exit_code_0(self, repo: pathlib.Path) -> None:
1316 result = _checkout(repo, "-b", "dry-ec", "--dry-run", "--json")
1317 data = json.loads(result.output)
1318 assert data["exit_code"] == 0
1319
1320 def test_dry_run_detach_json_exit_code_0(self, repo: pathlib.Path) -> None:
1321 sha = get_head_commit_id(repo, "main")
1322 assert sha is not None
1323 result = _checkout(repo, "--dry-run", sha, "--json")
1324 data = json.loads(result.output)
1325 assert data["exit_code"] == 0
1326
1327 def test_restored_json_exit_code_0(self, repo: pathlib.Path) -> None:
1328 result = _checkout(repo, "--force", "main", "--json")
1329 data = json.loads(result.output)
1330 assert data["exit_code"] == 0
1331
1332 def test_conflict_resolved_all_json_exit_code_0(self, repo: pathlib.Path) -> None:
1333 from muse.core.merge_engine import write_merge_state
1334
1335 ours = get_head_commit_id(repo, "main") or ""
1336 write_merge_state(
1337 repo,
1338 base_commit="",
1339 ours_commit=ours,
1340 theirs_commit=ours,
1341 conflict_paths=["a.py"],
1342 other_branch="feat",
1343 )
1344 result = _checkout(repo, "--ours", "--all", "--json")
1345 data = json.loads(result.output)
1346 assert data["exit_code"] == 0
1347
1348 def test_conflict_resolved_single_json_exit_code_0(self, repo: pathlib.Path) -> None:
1349 from muse.core.merge_engine import write_merge_state
1350 from muse.core.store import get_head_commit_id as _gci
1351
1352 ours = _gci(repo, "main") or ""
1353 write_merge_state(
1354 repo,
1355 base_commit="",
1356 ours_commit=ours,
1357 theirs_commit=ours,
1358 conflict_paths=["a.py"],
1359 other_branch="feat",
1360 )
1361 result = _checkout(repo, "--ours", "a.py", "--json")
1362 data = json.loads(result.output)
1363 assert data["exit_code"] == 0
1364
1365
1366 class TestJsonSchemaComplete:
1367 """``duration_ms`` and ``exit_code`` must be in ``REQUIRED_KEYS``."""
1368
1369 REQUIRED_KEYS = {
1370 "action", "branch", "commit_id", "from_branch", "dry_run",
1371 "duration_ms", "exit_code",
1372 }
1373
1374 def test_switch_has_complete_schema(self, two_branch_repo: pathlib.Path) -> None:
1375 result = _checkout(two_branch_repo, "feat", "--json")
1376 data = json.loads(result.output)
1377 missing = self.REQUIRED_KEYS - set(data)
1378 assert not missing, f"Missing keys in 'switched' JSON: {missing}"
1379
1380 def test_create_has_complete_schema(self, repo: pathlib.Path) -> None:
1381 result = _checkout(repo, "-b", "schema-branch", "--json")
1382 data = json.loads(result.output)
1383 missing = self.REQUIRED_KEYS - set(data)
1384 assert not missing, f"Missing keys in 'created' JSON: {missing}"
1385
1386 def test_detach_has_complete_schema(self, repo: pathlib.Path) -> None:
1387 sha = get_head_commit_id(repo, "main")
1388 assert sha is not None
1389 result = _checkout(repo, sha, "--json")
1390 data = json.loads(result.output)
1391 missing = self.REQUIRED_KEYS - set(data)
1392 assert not missing, f"Missing keys in 'detached' JSON: {missing}"
1393
1394 def test_already_on_has_complete_schema(self, repo: pathlib.Path) -> None:
1395 result = _checkout(repo, "main", "--json")
1396 data = json.loads(result.output)
1397 missing = self.REQUIRED_KEYS - set(data)
1398 assert not missing, f"Missing keys in 'already_on' JSON: {missing}"
1399
1400
1401 # ──────────────────────────────────────────────────────────────────────────────
1402 # checkout -b --intent / --resumable
1403 # ──────────────────────────────────────────────────────────────────────────────
1404
1405
1406 class TestCheckoutCreateWithMeta:
1407 """``muse checkout -b <name> --intent <text> --resumable`` stores metadata."""
1408
1409 # ── Parser ────────────────────────────────────────────────────────────────
1410
1411 def _parse(self, *args: str) -> "argparse.Namespace":
1412 import argparse
1413 from muse.cli.commands.checkout import register
1414 p = argparse.ArgumentParser()
1415 sub = p.add_subparsers()
1416 register(sub)
1417 return p.parse_args(["checkout", *args])
1418
1419 def test_intent_flag_parsed(self) -> None:
1420 ns = self._parse("-b", "task/x", "--intent", "do the thing")
1421 assert ns.intent == "do the thing"
1422
1423 def test_resumable_flag_parsed(self) -> None:
1424 ns = self._parse("-b", "task/x", "--resumable")
1425 assert ns.resumable is True
1426
1427 def test_intent_default_none(self) -> None:
1428 ns = self._parse("main")
1429 assert ns.intent is None
1430
1431 def test_resumable_default_false(self) -> None:
1432 ns = self._parse("main")
1433 assert ns.resumable is False
1434
1435 def test_intent_without_create_is_error(self, repo: pathlib.Path) -> None:
1436 """--intent without -b should be rejected."""
1437 result = _checkout(repo, "main", "--intent", "oops")
1438 assert result.exit_code != 0
1439
1440 def test_resumable_without_create_is_error(self, repo: pathlib.Path) -> None:
1441 """--resumable without -b should be rejected."""
1442 result = _checkout(repo, "main", "--resumable")
1443 assert result.exit_code != 0
1444
1445 # ── Integration: intent stored ─────────────────────────────────────────
1446
1447 def test_intent_stored_in_branch_meta(self, repo: pathlib.Path) -> None:
1448 result = _checkout(repo, "-b", "task/work", "--intent", "implement auth")
1449 assert result.exit_code == 0
1450 meta = read_branch_meta(repo, "task/work")
1451 assert meta.get("intent") == "implement auth"
1452
1453 def test_resumable_stored_in_branch_meta(self, repo: pathlib.Path) -> None:
1454 result = _checkout(repo, "-b", "task/work", "--resumable")
1455 assert result.exit_code == 0
1456 meta = read_branch_meta(repo, "task/work")
1457 assert meta.get("resumable") is True
1458
1459 def test_intent_and_resumable_together(self, repo: pathlib.Path) -> None:
1460 result = _checkout(
1461 repo, "-b", "task/work", "--intent", "add feature", "--resumable"
1462 )
1463 assert result.exit_code == 0
1464 meta = read_branch_meta(repo, "task/work")
1465 assert meta.get("intent") == "add feature"
1466 assert meta.get("resumable") is True
1467
1468 def test_branch_switched_after_create_with_meta(self, repo: pathlib.Path) -> None:
1469 _checkout(repo, "-b", "task/work", "--intent", "x", "--resumable")
1470 assert read_current_branch(repo) == "task/work"
1471
1472 def test_no_metadata_when_flags_absent(self, repo: pathlib.Path) -> None:
1473 _checkout(repo, "-b", "task/plain")
1474 meta = read_branch_meta(repo, "task/plain")
1475 assert meta.get("intent") is None
1476 assert not meta.get("resumable")
1477
1478 def test_intent_only_no_resumable_set(self, repo: pathlib.Path) -> None:
1479 _checkout(repo, "-b", "task/work", "--intent", "just intent")
1480 meta = read_branch_meta(repo, "task/work")
1481 assert meta.get("intent") == "just intent"
1482 assert not meta.get("resumable")
1483
1484 def test_resumable_only_no_intent_set(self, repo: pathlib.Path) -> None:
1485 _checkout(repo, "-b", "task/work", "--resumable")
1486 meta = read_branch_meta(repo, "task/work")
1487 assert meta.get("resumable") is True
1488 assert meta.get("intent") is None
1489
1490 # ── JSON output still correct ──────────────────────────────────────────
1491
1492 def test_json_action_is_created(self, repo: pathlib.Path) -> None:
1493 result = _checkout(
1494 repo, "-b", "task/work", "--intent", "x", "--resumable", "--json"
1495 )
1496 assert result.exit_code == 0
1497 data = json.loads(result.output)
1498 assert data["action"] == "created"
1499
1500 def test_json_branch_name_correct(self, repo: pathlib.Path) -> None:
1501 result = _checkout(
1502 repo, "-b", "task/work", "--intent", "x", "--json"
1503 )
1504 data = json.loads(result.output)
1505 assert data["branch"] == "task/work"
1506
1507 # ── branch --json listing reflects metadata ────────────────────────────
1508
1509 def test_branch_list_json_shows_intent(self, repo: pathlib.Path) -> None:
1510 _checkout(repo, "-b", "task/work", "--intent", "my intent")
1511 result = _invoke(repo, ["branch", "--json"])
1512 branches = json.loads(result.output)
1513 entry = next(b for b in branches if b["name"] == "task/work")
1514 assert entry.get("intent") == "my intent"
1515
1516 def test_branch_list_json_shows_resumable(self, repo: pathlib.Path) -> None:
1517 _checkout(repo, "-b", "task/work", "--resumable")
1518 result = _invoke(repo, ["branch", "--json"])
1519 branches = json.loads(result.output)
1520 entry = next(b for b in branches if b["name"] == "task/work")
1521 assert entry.get("resumable") is True
1522
1523 def test_resumable_filter_finds_branch(self, repo: pathlib.Path) -> None:
1524 _checkout(repo, "-b", "task/work", "--resumable")
1525 _checkout(repo, "main")
1526 _checkout(repo, "-b", "task/plain")
1527 result = _invoke(repo, ["branch", "--resumable", "--json"])
1528 names = [b["name"] for b in json.loads(result.output)]
1529 assert "task/work" in names
1530 assert "task/plain" not in names
1531
1532 # ── Security: ANSI in intent ───────────────────────────────────────────
1533
1534 def test_ansi_in_intent_sanitized_in_text_output(self, repo: pathlib.Path) -> None:
1535 evil = "\x1b[31mevil\x1b[0m"
1536 result = _checkout(repo, "-b", "task/work", "--intent", evil)
1537 assert result.exit_code == 0
1538 assert "\x1b" not in result.output
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 142 days ago