gabriel / muse public

test_cmd_checkout.py file-level

at sha256:7 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:c feat(code-test): audit cache internals, add cache_hit/--cache-status, c… · gabriel · Oct 22, 2026
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.refs import (
27 get_head_commit_id,
28 read_current_branch,
29 )
30 from muse.cli.config import read_branch_meta
31
32 if TYPE_CHECKING:
33 import argparse
34
35 runner = CliRunner()
36
37 # ──────────────────────────────────────────────────────────────────────────────
38 # Helpers
39 # ──────────────────────────────────────────────────────────────────────────────
40
41
42 def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult:
43 saved = os.getcwd()
44 try:
45 os.chdir(repo)
46 return runner.invoke(None, args)
47 finally:
48 os.chdir(saved)
49
50
51 def _checkout(repo: pathlib.Path, *extra: str) -> InvokeResult:
52 return _invoke(repo, ["checkout", *extra])
53
54
55 def _commit(repo: pathlib.Path, *extra: str) -> InvokeResult:
56 _invoke(repo, ["code", "add", "."])
57 return _invoke(repo, ["commit", *extra])
58
59
60 def _branch(repo: pathlib.Path, *extra: str) -> InvokeResult:
61 return _invoke(repo, ["branch", *extra])
62
63
64 @pytest.fixture()
65 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
66 """Initialised repo with one commit on ``main``."""
67 saved = os.getcwd()
68 try:
69 os.chdir(tmp_path)
70 runner.invoke(None, ["init"])
71 finally:
72 os.chdir(saved)
73 (tmp_path / "a.py").write_text("x = 1\n")
74 _commit(tmp_path, "-m", "initial")
75 return tmp_path
76
77
78 @pytest.fixture()
79 def two_branch_repo(repo: pathlib.Path) -> pathlib.Path:
80 """Repo with ``main`` and ``feat`` branches, each with unique content."""
81 _branch(repo, "feat")
82 _checkout(repo, "feat")
83 (repo / "feat.py").write_text("f = 1\n")
84 _commit(repo, "-m", "feat commit")
85 _checkout(repo, "main")
86 return repo
87
88
89 # ──────────────────────────────────────────────────────────────────────────────
90 # Unit β€” parser flags
91 # ──────────────────────────────────────────────────────────────────────────────
92
93
94 class TestRegisterFlags:
95 def _parse(self, *args: str) -> "argparse.Namespace":
96 import argparse
97
98 from muse.cli.commands.checkout import register
99
100 p = argparse.ArgumentParser()
101 sub = p.add_subparsers()
102 register(sub)
103 return p.parse_args(["checkout", *args])
104
105 def test_default_json_out_is_false(self) -> None:
106 ns = self._parse("main")
107 assert ns.json_out is False
108
109 def test_json_flag_sets_json_out(self) -> None:
110 ns = self._parse("main", "--json")
111 assert ns.json_out is True
112
113 def test_j_shorthand_sets_json_out(self) -> None:
114 ns = self._parse("main", "-j")
115 assert ns.json_out is True
116
117 def test_create_flag(self) -> None:
118 ns = self._parse("-b", "new")
119 assert ns.create is True
120
121 def test_force_flag(self) -> None:
122 ns = self._parse("main", "--force")
123 assert ns.force is True
124
125 def test_force_short_flag(self) -> None:
126 ns = self._parse("main", "-f")
127 assert ns.force is True
128
129 def test_dry_run_flag(self) -> None:
130 ns = self._parse("main", "--dry-run")
131 assert ns.dry_run is True
132
133 def test_dry_run_short_flag(self) -> None:
134 ns = self._parse("main", "-n")
135 assert ns.dry_run is True
136
137 def test_dry_run_default_false(self) -> None:
138 ns = self._parse("main")
139 assert ns.dry_run is False
140
141 def test_ours_flag(self) -> None:
142 ns = self._parse("--ours", "file.py")
143 assert ns.resolve_ours is True
144
145 def test_theirs_flag(self) -> None:
146 ns = self._parse("--theirs", "file.py")
147 assert ns.resolve_theirs is True
148
149 def test_all_flag(self) -> None:
150 ns = self._parse("--ours", "--all")
151 assert ns.resolve_all is True
152
153 def test_target_optional(self) -> None:
154 ns = self._parse()
155 assert ns.target is None
156
157
158 # ──────────────────────────────────────────────────────────────────────────────
159 # Unit β€” dead-code removal
160 # ──────────────────────────────────────────────────────────────────────────────
161
162
163 class TestDeadCodeRemoved:
164 def test_read_current_branch_wrapper_removed(self) -> None:
165 import muse.cli.commands.checkout as m
166
167 assert not hasattr(m, "_read_current_branch"), (
168 "_read_current_branch was a dead one-liner wrapper and must be deleted"
169 )
170
171 def test_inline_sanitize_display_import_removed(self) -> None:
172 import inspect
173
174 import muse.cli.commands.checkout as m
175
176 src = inspect.getsource(m.run)
177 assert "sanitize_display as _sd" not in src, (
178 "The inline 'from muse.core.validation import sanitize_display as _sd' "
179 "inside run() was a redundant re-import of the module-level sanitize_display"
180 )
181
182
183 # ──────────────────────────────────────────────────────────────────────────────
184 # Integration β€” SWITCH (existing branch)
185 # ──────────────────────────────────────────────────────────────────────────────
186
187
188 class TestSwitch:
189 def test_switch_exits_0(self, two_branch_repo: pathlib.Path) -> None:
190 result = _checkout(two_branch_repo, "feat")
191 assert result.exit_code == 0
192
193 def test_switch_changes_branch(self, two_branch_repo: pathlib.Path) -> None:
194 _checkout(two_branch_repo, "feat")
195 assert read_current_branch(two_branch_repo) == "feat"
196
197 def test_switch_text_output(self, two_branch_repo: pathlib.Path) -> None:
198 result = _checkout(two_branch_repo, "feat")
199 assert "feat" in result.output
200 assert "Switched" in result.output
201
202 def test_switch_json_schema(self, two_branch_repo: pathlib.Path) -> None:
203 result = _checkout(two_branch_repo, "feat", "--json")
204 data = json.loads(result.output)
205 assert data["action"] == "switched"
206 assert data["branch"] == "feat"
207 assert data["from_branch"] == "main"
208 assert "commit_id" in data
209 assert data.get("dry_run") is False
210
211 def test_switch_restores_files(self, two_branch_repo: pathlib.Path) -> None:
212 """Files unique to ``feat`` appear after checkout and disappear on return."""
213 _checkout(two_branch_repo, "feat")
214 assert (two_branch_repo / "feat.py").exists()
215 _checkout(two_branch_repo, "main")
216 assert not (two_branch_repo / "feat.py").exists()
217
218 def test_switch_to_nonexistent_exits_1(self, repo: pathlib.Path) -> None:
219 result = _checkout(repo, "does-not-exist")
220 assert result.exit_code == 1
221
222 def test_switch_error_to_stderr(self, repo: pathlib.Path) -> None:
223 result = _checkout(repo, "ghost")
224 assert result.exit_code == 1
225 # Error must appear in stderr, not exclusively stdout
226 assert "not a branch" in (result.stderr or "").lower()
227
228
229 # ──────────────────────────────────────────────────────────────────────────────
230 # Integration β€” ALREADY_ON
231 # ──────────────────────────────────────────────────────────────────────────────
232
233
234 class TestAlreadyOn:
235 def test_already_on_exits_0(self, repo: pathlib.Path) -> None:
236 result = _checkout(repo, "main")
237 assert result.exit_code == 0
238
239 def test_already_on_text(self, repo: pathlib.Path) -> None:
240 result = _checkout(repo, "main")
241 assert "Already on" in result.output
242
243 def test_already_on_json_schema(self, repo: pathlib.Path) -> None:
244 result = _checkout(repo, "main", "--json")
245 data = json.loads(result.output)
246 assert data["action"] == "already_on"
247 assert data["branch"] == "main"
248 assert data["from_branch"] == "main"
249 assert "commit_id" in data
250
251
252 # ──────────────────────────────────────────────────────────────────────────────
253 # Integration β€” FORCE on current branch (working-tree restore)
254 # ──────────────────────────────────────────────────────────────────────────────
255
256
257 class TestForceOnCurrentBranch:
258 """checkout --force <current-branch> must restore the working tree to HEAD.
259
260 Regression: previously this was a no-op ('Already on main') regardless of
261 --force. Git's behaviour is to restore missing/modified tracked files even
262 when the branch is already current.
263 """
264
265 def test_force_current_branch_exits_0(self, repo: pathlib.Path) -> None:
266 result = _checkout(repo, "--force", "main")
267 assert result.exit_code == 0
268
269 def test_force_current_branch_restores_deleted_file(self, repo: pathlib.Path) -> None:
270 (repo / "a.py").unlink()
271 assert not (repo / "a.py").exists()
272 _checkout(repo, "--force", "main")
273 assert (repo / "a.py").exists(), "force checkout must restore deleted tracked file"
274
275 def test_force_current_branch_restores_modified_file(self, repo: pathlib.Path) -> None:
276 original = (repo / "a.py").read_text()
277 (repo / "a.py").write_text("corrupted content\n")
278 _checkout(repo, "--force", "main")
279 assert (repo / "a.py").read_text() == original, (
280 "force checkout must restore modified tracked file to HEAD content"
281 )
282
283 def test_force_current_branch_text_output(self, repo: pathlib.Path) -> None:
284 result = _checkout(repo, "--force", "main")
285 assert "restored" in result.output
286
287 def test_force_current_branch_json_action(self, repo: pathlib.Path) -> None:
288 result = _checkout(repo, "--force", "main", "--json")
289 data = json.loads(result.output)
290 assert data["action"] == "restored"
291 assert data["branch"] == "main"
292
293 def test_force_current_branch_dry_run_does_not_restore(
294 self, repo: pathlib.Path
295 ) -> None:
296 (repo / "a.py").unlink()
297 _checkout(repo, "--force", "--dry-run", "main")
298 assert not (repo / "a.py").exists(), (
299 "--dry-run must not actually restore files"
300 )
301
302 def test_force_current_branch_dry_run_json(self, repo: pathlib.Path) -> None:
303 result = _checkout(repo, "--force", "--dry-run", "main", "--json")
304 data = json.loads(result.output)
305 assert data["dry_run"] is True
306 assert data["action"] == "restored"
307
308 def test_without_force_still_noop_on_current_branch(
309 self, repo: pathlib.Path
310 ) -> None:
311 """Without --force, checkout on current branch is still a no-op."""
312 result = _checkout(repo, "main")
313 assert "Already on" in result.output
314
315
316 # ──────────────────────────────────────────────────────────────────────────────
317 # Integration β€” CREATE (-b)
318 # ──────────────────────────────────────────────────────────────────────────────
319
320
321 class TestCreate:
322 def test_create_exits_0(self, repo: pathlib.Path) -> None:
323 result = _checkout(repo, "-b", "new-branch")
324 assert result.exit_code == 0
325
326 def test_create_switches_to_new_branch(self, repo: pathlib.Path) -> None:
327 _checkout(repo, "-b", "new-branch")
328 assert read_current_branch(repo) == "new-branch"
329
330 def test_create_text_output(self, repo: pathlib.Path) -> None:
331 result = _checkout(repo, "-b", "my-branch")
332 assert "my-branch" in result.output
333
334 def test_create_json_schema(self, repo: pathlib.Path) -> None:
335 result = _checkout(repo, "-b", "json-branch", "--json")
336 data = json.loads(result.output)
337 assert data["action"] == "created"
338 assert data["branch"] == "json-branch"
339 assert data["from_branch"] == "main"
340 assert "commit_id" in data
341 assert data.get("dry_run") is False
342
343 def test_create_duplicate_exits_1(self, repo: pathlib.Path) -> None:
344 _checkout(repo, "-b", "dup")
345 _checkout(repo, "main")
346 result = _checkout(repo, "-b", "dup")
347 assert result.exit_code == 1
348
349 def test_create_duplicate_error_to_stderr(self, repo: pathlib.Path) -> None:
350 _checkout(repo, "-b", "dup2")
351 _checkout(repo, "main")
352 result = _checkout(repo, "-b", "dup2")
353 # Error must appear in stderr
354 assert "already exists" in (result.stderr or "").lower()
355
356 def test_create_invalid_name_exits_1(self, repo: pathlib.Path) -> None:
357 result = _checkout(repo, "-b", "bad..name")
358 assert result.exit_code == 1
359
360 def test_create_invalid_name_error_to_stderr(self, repo: pathlib.Path) -> None:
361 result = _checkout(repo, "-b", "bad..name")
362 assert "Invalid" in (result.stderr or "")
363
364 def test_create_with_dirty_workdir_succeeds(self, repo: pathlib.Path) -> None:
365 """checkout -b must succeed even with uncommitted changes.
366
367 Creating a new branch starts at the current HEAD β€” no file content
368 changes, so dirty tracked files cannot be overwritten. Blocking
369 here forces an unnecessary shelf/pop dance.
370 """
371 # Dirty the tracked file without committing
372 (repo / "a.py").write_text("x = 2\n")
373 result = _checkout(repo, "-b", "task/dirty-ok")
374 assert result.exit_code == 0
375 assert read_current_branch(repo) == "task/dirty-ok"
376 # The dirty file must still be present (not lost)
377 assert (repo / "a.py").read_text() == "x = 2\n"
378
379
380 # ──────────────────────────────────────────────────────────────────────────────
381 # Integration β€” DIRTY WORKDIR BLEED-THROUGH (regression)
382 # ──────────────────────────────────────────────────────────────────────────────
383
384
385 @pytest.fixture()
386 def shared_file_repo(tmp_path: pathlib.Path) -> pathlib.Path:
387 """Repo where ``main`` and ``feat`` share the same file at the same content.
388
389 Both branches commit ``shared.py`` with identical content. This is the
390 scenario that triggers the bleed-through bug: a dirty ``shared.py`` would
391 not appear in the delta between the two snapshots, so the old code let it
392 through silently instead of refusing the checkout.
393 """
394 saved = os.getcwd()
395 try:
396 os.chdir(tmp_path)
397 runner.invoke(None, ["init"])
398 finally:
399 os.chdir(saved)
400 (tmp_path / "shared.py").write_text("x = 1\n")
401 _commit(tmp_path, "-m", "initial")
402 # Create feat branch β€” shared.py is the same on both branches
403 _checkout(tmp_path, "-b", "feat")
404 (tmp_path / "feat_only.py").write_text("f = 1\n")
405 _commit(tmp_path, "-m", "feat commit")
406 _checkout(tmp_path, "main")
407 return tmp_path
408
409
410 class TestDirtyWorkdirBleedThrough:
411 """checkout must refuse when the working tree is dirty.
412
413 Regression: the old ``require_clean_workdir`` only blocked files that
414 the target branch would *overwrite*. Files modified locally but
415 identical on both branches were silently carried through, causing
416 dirty working-tree state to appear on branches the user never
417 touched β€” exactly the bug that left 80+ modified files on ``main``.
418
419 The fix: always refuse checkout on any dirty tracked file. Users
420 must explicitly commit, shelf, use ``--autoshelf``, or ``--force``.
421 """
422
423 def test_dirty_shared_file_blocks_checkout(
424 self, shared_file_repo: pathlib.Path
425 ) -> None:
426 """A file modified locally but identical on both branches must block checkout."""
427 (shared_file_repo / "shared.py").write_text("x = 999\n")
428 result = _checkout(shared_file_repo, "feat")
429 assert result.exit_code == 1, (
430 "checkout must refuse when shared.py is dirty, "
431 "even though main and feat have the same committed version"
432 )
433
434 def test_dirty_shared_file_error_names_file(
435 self, shared_file_repo: pathlib.Path
436 ) -> None:
437 """The refusal message must name the dirty file."""
438 (shared_file_repo / "shared.py").write_text("x = 999\n")
439 result = _checkout(shared_file_repo, "feat")
440 assert "shared.py" in (result.stderr or ""), (
441 "error message must name the dirty file"
442 )
443
444 def test_dirty_shared_file_json_error(
445 self, shared_file_repo: pathlib.Path
446 ) -> None:
447 """JSON mode must emit a machine-readable error, not bleed through."""
448 (shared_file_repo / "shared.py").write_text("x = 999\n")
449 result = _checkout(shared_file_repo, "feat", "--json")
450 assert result.exit_code == 1
451 data = json.loads(result.output)
452 assert data["error"] == "dirty_workdir"
453 assert "shared.py" in data["files"]
454
455 def test_dirty_shared_file_not_silently_moved(
456 self, shared_file_repo: pathlib.Path
457 ) -> None:
458 """After a refused checkout, we must still be on the original branch."""
459 (shared_file_repo / "shared.py").write_text("x = 999\n")
460 _checkout(shared_file_repo, "feat")
461 assert read_current_branch(shared_file_repo) == "main", (
462 "failed checkout must not switch the branch"
463 )
464
465 def test_deleted_shared_file_blocks_checkout(
466 self, shared_file_repo: pathlib.Path
467 ) -> None:
468 """A tracked file deleted locally but present on both branches must block checkout."""
469 (shared_file_repo / "shared.py").unlink()
470 result = _checkout(shared_file_repo, "feat")
471 assert result.exit_code == 1
472
473 def test_force_bypasses_dirty_check(
474 self, shared_file_repo: pathlib.Path
475 ) -> None:
476 """``--force`` must still bypass the dirty check (discards local changes)."""
477 (shared_file_repo / "shared.py").write_text("x = 999\n")
478 result = _checkout(shared_file_repo, "--force", "feat")
479 assert result.exit_code == 0
480 assert read_current_branch(shared_file_repo) == "feat"
481 # --force restores the target branch's version, discarding local edit
482 assert (shared_file_repo / "shared.py").read_text() == "x = 1\n"
483
484 def test_autoshelf_bypasses_dirty_check(
485 self, shared_file_repo: pathlib.Path
486 ) -> None:
487 """``--autoshelf`` must shelve the dirty file and switch cleanly."""
488 (shared_file_repo / "shared.py").write_text("x = 999\n")
489 result = _checkout(shared_file_repo, "--autoshelf", "feat")
490 assert result.exit_code == 0
491 assert read_current_branch(shared_file_repo) == "feat"
492
493 def test_untracked_file_does_not_block_checkout(
494 self, shared_file_repo: pathlib.Path
495 ) -> None:
496 """A brand-new untracked file must never block checkout."""
497 (shared_file_repo / "new_untracked.py").write_text("new = 1\n")
498 result = _checkout(shared_file_repo, "feat")
499 assert result.exit_code == 0, (
500 "untracked files are never in any snapshot so checkout must allow them"
501 )
502
503 def test_create_branch_allows_dirty_workdir(
504 self, shared_file_repo: pathlib.Path
505 ) -> None:
506 """``-b`` (create) must still succeed with dirty files β€” no snapshot change."""
507 (shared_file_repo / "shared.py").write_text("x = 999\n")
508 result = _checkout(shared_file_repo, "-b", "task/new")
509 assert result.exit_code == 0, (
510 "creating a branch does not change any file content; dirty tree is fine"
511 )
512
513
514 # ──────────────────────────────────────────────────────────────────────────────
515 # Integration β€” DETACH HEAD
516 # ──────────────────────────────────────────────────────────────────────────────
517
518
519 class TestDetach:
520 def test_detach_full_sha_exits_0(self, repo: pathlib.Path) -> None:
521 sha = get_head_commit_id(repo, "main")
522 assert sha is not None
523 result = _checkout(repo, sha)
524 assert result.exit_code == 0
525
526 def test_detach_full_sha_text_output(self, repo: pathlib.Path) -> None:
527 sha = get_head_commit_id(repo, "main")
528 assert sha is not None
529 result = _checkout(repo, sha)
530 # Output shows sha256: prefix + 8 hex chars β€” canonical and algorithm-identifying.
531 assert sha[:len("sha256:") + 8] in result.output
532
533 def test_detach_full_sha_json_schema(self, repo: pathlib.Path) -> None:
534 sha = get_head_commit_id(repo, "main")
535 assert sha is not None
536 result = _checkout(repo, sha, "--json")
537 data = json.loads(result.output)
538 assert data["action"] == "detached"
539 assert data["branch"] is None
540 assert data["commit_id"] == sha
541 assert data["from_branch"] == "main"
542 assert data.get("dry_run") is False
543
544 def test_detach_partial_sha_exits_0(self, repo: pathlib.Path) -> None:
545 sha = get_head_commit_id(repo, "main")
546 assert sha is not None
547 # Pass bare hex prefix to checkout β€” the command resolves it
548 hex_prefix = short_id(sha, strip=True)
549 result = _checkout(repo, hex_prefix)
550 assert result.exit_code == 0
551
552 def test_detach_partial_sha_points_to_correct_commit(self, repo: pathlib.Path) -> None:
553 """A partial SHA must resolve to the correct commit, not be treated as a branch."""
554 from muse.core.refs import read_current_branch
555 from muse.core.commits import get_commits_for_branch
556
557 (repo / "b.py").write_text("b=1\n")
558 _commit(repo, "-m", "second")
559
560 branch = read_current_branch(repo)
561 commits = get_commits_for_branch(repo, branch)
562 first_sha = commits[-1].commit_id # oldest
563
564 # Pass bare hex prefix to checkout β€” the command resolves it
565 hex_prefix = short_id(first_sha, strip=True)
566 result = _checkout(repo, hex_prefix)
567 assert result.exit_code == 0
568 assert first_sha[:len("sha256:") + 8] in result.output
569
570 def test_detach_bad_ref_exits_1(self, repo: pathlib.Path) -> None:
571 result = _checkout(repo, "deadbeefdeadbeef")
572 assert result.exit_code == 1
573
574 def test_detach_error_to_stderr(self, repo: pathlib.Path) -> None:
575 result = _checkout(repo, "deadbeefdeadbeef")
576 assert "not a branch" in (result.stderr or "").lower()
577
578
579 class TestDetachTildeRef:
580 """muse#57 item 5's underlying mechanism (tilde ref resolution) was
581 blocked at the CLI layer for ANY tilde ref, not just version tags:
582 `_is_commit_ref`'s detection regex never recognised `~`, so a tilde
583 target fell through to `validate_branch_name`, which rejects `~` as a
584 forbidden character, before `resolve_commit_ref` (the only place that
585 understands tilde syntax) ever got a chance to run."""
586
587 def test_checkout_head_tilde_1(self, repo: pathlib.Path) -> None:
588 (repo / "b.py").write_text("b=1\n")
589 _commit(repo, "-m", "second")
590 from muse.core.refs import get_head_commit_id
591 first_commit = None
592 from muse.core.commits import get_commits_for_branch
593 commits = get_commits_for_branch(repo, "main")
594 assert len(commits) == 2
595 oldest = commits[-1].commit_id
596
597 result = _checkout(repo, "HEAD~1", "--json")
598 assert result.exit_code == 0, result.output
599 data = json.loads(result.output)
600 assert data["action"] == "detached"
601 assert data["commit_id"] == oldest
602
603 def test_checkout_head_tilde_from_already_detached_head(
604 self, repo: pathlib.Path
605 ) -> None:
606 """A second HEAD~N checkout issued while ALREADY in detached HEAD
607 must not crash -- resolve_commit_ref's HEAD lookup is branch-ref
608 based and knows nothing of the separate detached-HEAD state; the
609 call site must substitute the literal detached commit ID rather
610 than pass an empty branch into it."""
611 from muse.core.commits import get_commits_for_branch
612
613 for i in range(3):
614 (repo / f"f{i}.py").write_text(f"x={i}\n")
615 _commit(repo, "-m", f"commit {i}")
616 commits = get_commits_for_branch(repo, "main")
617 assert len(commits) == 4 # initial + 3
618
619 first = _checkout(repo, "HEAD~1", "--json")
620 assert first.exit_code == 0, first.output
621 first_data = json.loads(first.output)
622 assert first_data["branch"] is None # now detached
623
624 second = _checkout(repo, "HEAD~1", "--json")
625 assert second.exit_code == 0, second.output
626 second_data = json.loads(second.output)
627 # Walked one further step back from the already-detached commit.
628 assert second_data["commit_id"] == commits[2].commit_id
629 assert second_data["commit_id"] != first_data["commit_id"]
630
631
632 # ──────────────────────────────────────────────────────────────────────────────
633 # Integration β€” DETACHED HEAD RECOVERY (checkout branch from detached HEAD)
634 # ──────────────────────────────────────────────────────────────────────────────
635
636
637 class TestDetachedHeadRecovery:
638 """Recovering from detached HEAD state via 'muse checkout <branch>'."""
639
640 @pytest.fixture()
641 def detached_repo(self, repo: pathlib.Path) -> pathlib.Path:
642 """Repo with HEAD detached at the main commit."""
643 sha = get_head_commit_id(repo, "main")
644 assert sha is not None
645 _checkout(repo, sha)
646 return repo
647
648 def test_recover_to_branch_exits_0(self, detached_repo: pathlib.Path) -> None:
649 result = _checkout(detached_repo, "main")
650 assert result.exit_code == 0
651
652 def test_recover_to_branch_restores_symbolic_head(self, detached_repo: pathlib.Path) -> None:
653 _checkout(detached_repo, "main")
654 assert read_current_branch(detached_repo) == "main"
655
656 def test_recover_to_branch_text_output(self, detached_repo: pathlib.Path) -> None:
657 result = _checkout(detached_repo, "main")
658 assert "main" in result.output
659
660 def test_recover_to_branch_json_action(self, detached_repo: pathlib.Path) -> None:
661 result = _checkout(detached_repo, "main", "--json")
662 data = json.loads(result.output)
663 assert data["action"] in ("switched", "already_on")
664 assert data["branch"] == "main"
665
666 def test_recover_to_branch_from_branch_is_null(self, detached_repo: pathlib.Path) -> None:
667 """from_branch is None when recovering from detached HEAD."""
668 result = _checkout(detached_repo, "main", "--json")
669 data = json.loads(result.output)
670 assert data["from_branch"] is None
671
672 def test_create_branch_from_detached_exits_0(self, detached_repo: pathlib.Path) -> None:
673 result = _checkout(detached_repo, "-b", "rescue")
674 assert result.exit_code == 0
675
676 def test_create_branch_from_detached_switches_head(self, detached_repo: pathlib.Path) -> None:
677 _checkout(detached_repo, "-b", "rescue")
678 assert read_current_branch(detached_repo) == "rescue"
679
680 def test_create_branch_from_detached_inherits_commit(self, detached_repo: pathlib.Path) -> None:
681 sha = get_head_commit_id(detached_repo, "main")
682 _checkout(detached_repo, "-b", "rescue")
683 assert get_head_commit_id(detached_repo, "rescue") == sha
684
685 def test_dry_run_recover_exits_0(self, detached_repo: pathlib.Path) -> None:
686 result = _checkout(detached_repo, "--dry-run", "main")
687 assert result.exit_code == 0
688
689 def test_dry_run_recover_does_not_change_head(self, detached_repo: pathlib.Path) -> None:
690 _checkout(detached_repo, "--dry-run", "main")
691 from muse.core.refs import read_head
692 state = read_head(detached_repo)
693 assert state["kind"] == "commit", "HEAD must still be detached after dry-run"
694
695 def test_merge_flag_from_detached_exits_1(self, detached_repo: pathlib.Path) -> None:
696 result = _checkout(detached_repo, "--merge", "main")
697 assert result.exit_code == 1
698
699 def test_merge_flag_from_detached_stderr_message(self, detached_repo: pathlib.Path) -> None:
700 result = _checkout(detached_repo, "--merge", "main")
701 assert "detach" in (result.stderr or "").lower() or "branch" in (result.stderr or "").lower()
702
703
704 # ──────────────────────────────────────────────────────────────────────────────
705 # Integration β€” DRY-RUN
706 # ──────────────────────────────────────────────────────────────────────────────
707
708
709 class TestDryRun:
710 def test_dry_run_switch_exits_0(self, two_branch_repo: pathlib.Path) -> None:
711 result = _checkout(two_branch_repo, "--dry-run", "feat")
712 assert result.exit_code == 0
713
714 def test_dry_run_does_not_switch_branch(self, two_branch_repo: pathlib.Path) -> None:
715 _checkout(two_branch_repo, "--dry-run", "feat")
716 assert read_current_branch(two_branch_repo) == "main"
717
718 def test_dry_run_text_says_would(self, two_branch_repo: pathlib.Path) -> None:
719 result = _checkout(two_branch_repo, "--dry-run", "feat")
720 assert "Would" in result.output
721 assert "feat" in result.output
722
723 def test_dry_run_json_schema(self, two_branch_repo: pathlib.Path) -> None:
724 result = _checkout(two_branch_repo, "--dry-run", "feat", "--json")
725 data = json.loads(result.output)
726 assert data["dry_run"] is True
727 assert data["action"] == "switched"
728 assert data["branch"] == "feat"
729 assert data["from_branch"] == "main"
730
731 def test_dry_run_does_not_restore_files(self, two_branch_repo: pathlib.Path) -> None:
732 """feat.py exists only on feat branch; dry-run must not create it on main."""
733 _checkout(two_branch_repo, "--dry-run", "feat")
734 assert not (two_branch_repo / "feat.py").exists()
735
736 def test_dry_run_create_exits_0(self, repo: pathlib.Path) -> None:
737 result = _checkout(repo, "-b", "dry-branch", "--dry-run")
738 assert result.exit_code == 0
739
740 def test_dry_run_create_does_not_create_branch(self, repo: pathlib.Path) -> None:
741 _checkout(repo, "-b", "dry-branch", "--dry-run")
742 result = _invoke(repo, ["branch", "--json"])
743 names = [b["name"] for b in json.loads(result.output)]
744 assert "dry-branch" not in names
745
746 def test_dry_run_create_json_schema(self, repo: pathlib.Path) -> None:
747 result = _checkout(repo, "-b", "dry-new", "--dry-run", "--json")
748 data = json.loads(result.output)
749 assert data["dry_run"] is True
750 assert data["action"] == "created"
751 assert data["from_branch"] == "main"
752
753 def test_dry_run_detach_exits_0(self, repo: pathlib.Path) -> None:
754 sha = get_head_commit_id(repo, "main")
755 assert sha is not None
756 result = _checkout(repo, "--dry-run", sha)
757 assert result.exit_code == 0
758
759 def test_dry_run_detach_does_not_detach(self, repo: pathlib.Path) -> None:
760 sha = get_head_commit_id(repo, "main")
761 assert sha is not None
762 _checkout(repo, "--dry-run", sha)
763 assert read_current_branch(repo) == "main"
764
765 def test_dry_run_detach_json(self, repo: pathlib.Path) -> None:
766 sha = get_head_commit_id(repo, "main")
767 assert sha is not None
768 result = _checkout(repo, "--dry-run", sha, "--json")
769 data = json.loads(result.output)
770 assert data["dry_run"] is True
771 assert data["action"] == "detached"
772 assert data["branch"] is None
773
774 def test_dry_run_nonexistent_branch_exits_1(self, repo: pathlib.Path) -> None:
775 result = _checkout(repo, "--dry-run", "no-such-branch")
776 assert result.exit_code == 1
777
778 def test_dry_run_already_on_exits_0(self, repo: pathlib.Path) -> None:
779 result = _checkout(repo, "--dry-run", "main")
780 assert result.exit_code == 0
781
782 def test_dry_run_already_on_json(self, repo: pathlib.Path) -> None:
783 result = _checkout(repo, "--dry-run", "main", "--json")
784 data = json.loads(result.output)
785 assert data["dry_run"] is True
786 assert data["action"] == "already_on"
787
788
789 # ──────────────────────────────────────────────────────────────────────────────
790 # Integration β€” JSON schema consistency
791 # ──────────────────────────────────────────────────────────────────────────────
792
793
794 class TestJsonSchema:
795 REQUIRED_KEYS = {"action", "branch", "commit_id", "from_branch", "dry_run"}
796
797 def test_create_has_all_keys(self, repo: pathlib.Path) -> None:
798 result = _checkout(repo, "-b", "k-test", "--json")
799 data = json.loads(result.output)
800 missing = self.REQUIRED_KEYS - set(data)
801 assert not missing, f"Missing keys in 'created' JSON: {missing}"
802
803 def test_switch_has_all_keys(self, two_branch_repo: pathlib.Path) -> None:
804 result = _checkout(two_branch_repo, "feat", "--json")
805 data = json.loads(result.output)
806 missing = self.REQUIRED_KEYS - set(data)
807 assert not missing, f"Missing keys in 'switched' JSON: {missing}"
808
809 def test_already_on_has_all_keys(self, repo: pathlib.Path) -> None:
810 result = _checkout(repo, "main", "--json")
811 data = json.loads(result.output)
812 missing = self.REQUIRED_KEYS - set(data)
813 assert not missing, f"Missing keys in 'already_on' JSON: {missing}"
814
815 def test_detach_has_all_keys(self, repo: pathlib.Path) -> None:
816 sha = get_head_commit_id(repo, "main")
817 assert sha is not None
818 result = _checkout(repo, sha, "--json")
819 data = json.loads(result.output)
820 missing = self.REQUIRED_KEYS - set(data)
821 assert not missing, f"Missing keys in 'detached' JSON: {missing}"
822
823 def test_detach_branch_is_null(self, repo: pathlib.Path) -> None:
824 sha = get_head_commit_id(repo, "main")
825 assert sha is not None
826 result = _checkout(repo, sha, "--json")
827 data = json.loads(result.output)
828 assert data["branch"] is None
829
830 def test_from_branch_reflects_previous(self, two_branch_repo: pathlib.Path) -> None:
831 _checkout(two_branch_repo, "feat")
832 result = _checkout(two_branch_repo, "main", "--json")
833 data = json.loads(result.output)
834 assert data["from_branch"] == "feat"
835
836
837 # ──────────────────────────────────────────────────────────────────────────────
838 # Integration β€” validation
839 # ──────────────────────────────────────────────────────────────────────────────
840
841
842 class TestValidation:
843 def test_no_target_exits_1(self, repo: pathlib.Path) -> None:
844 result = _checkout(repo)
845 assert result.exit_code == 1
846
847 def test_no_target_error_to_stderr(self, repo: pathlib.Path) -> None:
848 result = _checkout(repo)
849 assert "Specify" in (result.stderr or "")
850
851 def test_unknown_flag_exits_nonzero(self, repo: pathlib.Path) -> None:
852 result = _checkout(repo, "main", "--no-such-flag")
853 assert result.exit_code != 0
854
855 def test_ours_without_theirs_context_exits_1(self, repo: pathlib.Path) -> None:
856 result = _checkout(repo, "--ours", "file.py")
857 assert result.exit_code == 1
858
859 def test_ours_and_theirs_together_exits_1(self, repo: pathlib.Path) -> None:
860 result = _checkout(repo, "--ours", "--theirs", "--all")
861 assert result.exit_code == 1
862
863
864 # ──────────────────────────────────────────────────────────────────────────────
865 # Security β€” ANSI injection
866 # ──────────────────────────────────────────────────────────────────────────────
867
868
869 class TestSecurityAnsi:
870 def _has_ansi(self, s: str) -> bool:
871 return "\x1b[" in s
872
873 def test_ansi_in_target_sanitized(self, repo: pathlib.Path) -> None:
874 result = _checkout(repo, "\x1b[31mmalicious\x1b[0m")
875 assert not self._has_ansi(result.output)
876
877 def test_ansi_in_create_name_sanitized(self, repo: pathlib.Path) -> None:
878 result = _checkout(repo, "-b", "\x1b[31mmalicious\x1b[0m")
879 assert not self._has_ansi(result.output)
880
881 def test_error_not_a_branch_sanitized(self, repo: pathlib.Path) -> None:
882 """The 'not a branch' error message must not echo raw ANSI from target."""
883 result = _checkout(repo, "\x1b[31mnotabranch\x1b[0m")
884 assert not self._has_ansi(result.output)
885 assert not self._has_ansi(result.stderr or "")
886
887 def test_all_errors_to_stderr(self, repo: pathlib.Path) -> None:
888 """Every ❌ error must go to stderr; stderr must contain the error."""
889 error_cases = [
890 ["ghost"],
891 ["-b", "bad..name"],
892 ]
893 for case in error_cases:
894 result = _checkout(repo, *case)
895 assert result.exit_code != 0, f"Expected failure for args {case}"
896 assert "❌" in (result.stderr or ""), (
897 f"Error not in stderr for args {case}: stderr={result.stderr!r}"
898 )
899
900
901 # ──────────────────────────────────────────────────────────────────────────────
902 # Integration β€” conflict resolution
903 # ──────────────────────────────────────────────────────────────────────────────
904
905
906 class TestConflictResolution:
907 def _setup_merge_conflict(
908 self, repo: pathlib.Path
909 ) -> tuple[str, str]:
910 """Create a merge conflict on ``repo``. Returns (ours_commit, theirs_commit)."""
911 # ours: commit on main
912 (repo / "shared.py").write_text("x = 1\n")
913 _commit(repo, "-m", "main: set x=1")
914 ours_cid = get_head_commit_id(repo, "main") or ""
915
916 # theirs: commit on feature branch
917 _branch(repo, "feat2")
918 _invoke(repo, ["checkout", "feat2"])
919 (repo / "shared.py").write_text("x = 2\n")
920 _commit(repo, "-m", "feat: set x=2")
921 theirs_cid = get_head_commit_id(repo, "feat2") or ""
922
923 _invoke(repo, ["checkout", "main"])
924 # Force a merge conflict via merge_engine internals
925 from muse.core.merge_engine import write_merge_state
926
927 write_merge_state(
928 repo,
929 base_commit="",
930 ours_commit=ours_cid,
931 theirs_commit=theirs_cid,
932 conflict_paths=["shared.py"],
933 other_branch="feat2",
934 )
935 return ours_cid, theirs_cid
936
937 def test_ours_no_merge_state_exits_1(self, repo: pathlib.Path) -> None:
938 result = _checkout(repo, "--ours", "file.py")
939 assert result.exit_code == 1
940
941 def test_theirs_no_merge_state_exits_1(self, repo: pathlib.Path) -> None:
942 result = _checkout(repo, "--theirs", "file.py")
943 assert result.exit_code == 1
944
945 def test_ours_and_theirs_both_exits_1(self, repo: pathlib.Path) -> None:
946 result = _checkout(repo, "--ours", "--theirs", "--all")
947 assert result.exit_code == 1
948
949 def test_ours_resolves_conflict(self, repo: pathlib.Path) -> None:
950 self._setup_merge_conflict(repo)
951 result = _checkout(repo, "--ours", "shared.py")
952 assert result.exit_code == 0
953
954 def test_theirs_resolves_conflict(self, repo: pathlib.Path) -> None:
955 self._setup_merge_conflict(repo)
956 result = _checkout(repo, "--theirs", "shared.py")
957 assert result.exit_code == 0
958
959 def test_resolve_all_ours_json(self, repo: pathlib.Path) -> None:
960 self._setup_merge_conflict(repo)
961 result = _checkout(repo, "--ours", "--all", "--json")
962 assert result.exit_code == 0
963 data = json.loads(result.output)
964 assert data["action"] == "conflict_resolved_all"
965 assert data["side"] == "ours"
966 assert "resolved_count" in data
967 assert "remaining_conflicts" in data
968
969 def test_resolve_all_theirs_json(self, repo: pathlib.Path) -> None:
970 self._setup_merge_conflict(repo)
971 result = _checkout(repo, "--theirs", "--all", "--json")
972 assert result.exit_code == 0
973 data = json.loads(result.output)
974 assert data["action"] == "conflict_resolved_all"
975 assert data["side"] == "theirs"
976
977 def test_resolve_single_file_json(self, repo: pathlib.Path) -> None:
978 self._setup_merge_conflict(repo)
979 result = _checkout(repo, "--ours", "shared.py", "--json")
980 assert result.exit_code == 0
981 data = json.loads(result.output)
982 assert data["action"] == "conflict_resolved"
983 assert data["file"] == "shared.py"
984 assert data["side"] == "ours"
985 assert "remaining_conflicts" in data
986
987 def test_resolve_all_empty_conflicts_exits_0(self, repo: pathlib.Path) -> None:
988 """--ours --all when no conflicts exist still exits 0."""
989 from muse.core.merge_engine import write_merge_state
990
991 ours = get_head_commit_id(repo, "main") or ""
992 write_merge_state(
993 repo,
994 base_commit="",
995 ours_commit=ours,
996 theirs_commit=ours,
997 conflict_paths=[],
998 other_branch="feat",
999 )
1000 result = _checkout(repo, "--ours", "--all")
1001 assert result.exit_code == 0
1002
1003 def test_resolve_nonexistent_path_exits_0(self, repo: pathlib.Path) -> None:
1004 """A path not in the conflict list is informational, not an error."""
1005 self._setup_merge_conflict(repo)
1006 result = _checkout(repo, "--ours", "not_conflicted.py")
1007 assert result.exit_code == 0
1008
1009 def test_missing_ours_theirs_without_all_exits_1(self, repo: pathlib.Path) -> None:
1010 result = _checkout(repo, "--ours")
1011 assert result.exit_code == 1
1012
1013
1014 # ──────────────────────────────────────────────────────────────────────────────
1015 # Stress
1016 # ──────────────────────────────────────────────────────────────────────────────
1017
1018
1019 @pytest.mark.slow
1020 class TestStress:
1021 def test_checkout_100_file_branch_fast(self, repo: pathlib.Path) -> None:
1022 """Switching between branches with 100 modified files under 2s."""
1023 for i in range(100):
1024 (repo / f"f{i:03d}.py").write_text(f"x={i}\n")
1025 _commit(repo, "-m", "big main")
1026 _branch(repo, "big-alt")
1027 _checkout(repo, "big-alt")
1028 for i in range(100):
1029 (repo / f"f{i:03d}.py").write_text(f"y={i}\n")
1030 _commit(repo, "-m", "big alt")
1031 _checkout(repo, "main")
1032
1033 t0 = time.perf_counter()
1034 result = _checkout(repo, "big-alt")
1035 elapsed = (time.perf_counter() - t0) * 1000
1036 assert result.exit_code == 0
1037 assert elapsed < 2000, f"checkout 100-file branch took {elapsed:.0f}ms (limit 2s)"
1038
1039 def test_dry_run_100_file_branch_fast(self, repo: pathlib.Path) -> None:
1040 """dry-run on 100-file branch should be very fast (no restore)."""
1041 for i in range(100):
1042 (repo / f"g{i:03d}.py").write_text(f"x={i}\n")
1043 _commit(repo, "-m", "big2")
1044 _branch(repo, "big2-alt")
1045
1046 t0 = time.perf_counter()
1047 result = _checkout(repo, "--dry-run", "big2-alt")
1048 elapsed = (time.perf_counter() - t0) * 1000
1049 assert result.exit_code == 0
1050 assert elapsed < 500, f"dry-run took {elapsed:.0f}ms (limit 500ms)"
1051
1052 def test_concurrent_checkouts_separate_repos(self, tmp_path: pathlib.Path) -> None:
1053 """Multiple threads checking out branches in separate repos must not interfere."""
1054 errors: list[str] = []
1055
1056 def do_checkout(idx: int) -> None:
1057 repo_dir = tmp_path / f"repo_{idx}"
1058 repo_dir.mkdir()
1059 subprocess.run(["muse", "init"], cwd=str(repo_dir), capture_output=True)
1060 (repo_dir / "x.py").write_text(f"x={idx}\n")
1061 subprocess.run(
1062 ["muse", "commit", "-m", f"base{idx}"],
1063 cwd=str(repo_dir), capture_output=True,
1064 )
1065 subprocess.run(
1066 ["muse", "branch", "alt"], cwd=str(repo_dir), capture_output=True
1067 )
1068 subprocess.run(
1069 ["muse", "checkout", "alt"], cwd=str(repo_dir), capture_output=True
1070 )
1071 (repo_dir / "y.py").write_text(f"y={idx}\n")
1072 subprocess.run(
1073 ["muse", "commit", "-m", f"alt{idx}"],
1074 cwd=str(repo_dir), capture_output=True,
1075 )
1076 r = subprocess.run(
1077 ["muse", "checkout", "main", "--json"],
1078 cwd=str(repo_dir), capture_output=True, text=True,
1079 )
1080 if r.returncode != 0:
1081 errors.append(f"repo_{idx}: checkout failed")
1082 return
1083 data = json.loads(r.stdout)
1084 if data.get("action") != "switched":
1085 errors.append(f"repo_{idx}: expected switched, got {data.get('action')}")
1086
1087 threads = [threading.Thread(target=do_checkout, args=(i,)) for i in range(6)]
1088 for t in threads:
1089 t.start()
1090 for t in threads:
1091 t.join()
1092 assert not errors, f"Concurrent checkout errors:\n{'\n'.join(errors)}"
1093
1094 def test_repeated_back_and_forth_100_times(self, two_branch_repo: pathlib.Path) -> None:
1095 """Switching back and forth 100 times must not corrupt the working tree."""
1096 for i in range(50):
1097 r1 = _checkout(two_branch_repo, "feat")
1098 assert r1.exit_code == 0, f"Iteration {i}: switch to feat failed"
1099 assert (two_branch_repo / "feat.py").exists()
1100 r2 = _checkout(two_branch_repo, "main")
1101 assert r2.exit_code == 0, f"Iteration {i}: switch to main failed"
1102
1103
1104 # ──────────────────────────────────────────────────────────────────────────────
1105 # TestCheckoutMerge β€” muse checkout -m (Cohen Transform carry-forward)
1106 # ──────────────────────────────────────────────────────────────────────────────
1107
1108
1109 def _make_diverged_repo(tmp_path: pathlib.Path) -> pathlib.Path:
1110 """Repo with main and *other* branches that have diverged file content.
1111
1112 Layout after setup::
1113
1114 main: shared.py = "line1\\nline2\\nline3\\n" (committed)
1115 other: shared.py = "line1\\nLINE2\\nline3\\n" (committed β€” different line 2)
1116
1117 The caller is left on *main* with a dirty working tree.
1118 """
1119 saved = os.getcwd()
1120 try:
1121 os.chdir(tmp_path)
1122 runner.invoke(None, ["init"])
1123 finally:
1124 os.chdir(saved)
1125
1126 (tmp_path / "shared.py").write_text("line1\nline2\nline3\n")
1127 _commit(tmp_path, "-m", "initial")
1128
1129 # Create other branch with a different version of shared.py
1130 _branch(tmp_path, "other")
1131 _checkout(tmp_path, "other")
1132 (tmp_path / "shared.py").write_text("line1\nLINE2\nline3\n")
1133 _commit(tmp_path, "-m", "other changes line2")
1134
1135 # Back on main
1136 _checkout(tmp_path, "main")
1137 return tmp_path
1138
1139
1140 class TestCheckoutMergeParser:
1141 """Parser-level tests for the ``-m`` / ``--merge`` flag."""
1142
1143 def _parse(self, *args: str) -> "argparse.Namespace":
1144 import argparse
1145
1146 from muse.cli.commands.checkout import register
1147
1148 parser = argparse.ArgumentParser()
1149 sub = parser.add_subparsers()
1150 register(sub)
1151 return parser.parse_args(["checkout", *args])
1152
1153 def test_merge_short_flag_parsed(self) -> None:
1154 ns = self._parse("-m", "feat")
1155 assert ns.merge is True
1156
1157 def test_merge_long_flag_parsed(self) -> None:
1158 ns = self._parse("--merge", "feat")
1159 assert ns.merge is True
1160
1161 def test_merge_false_by_default(self) -> None:
1162 ns = self._parse("feat")
1163 assert ns.merge is False
1164
1165 def test_merge_and_dry_run_coexist(self) -> None:
1166 ns = self._parse("-m", "--dry-run", "feat")
1167 assert ns.merge is True
1168 assert ns.dry_run is True
1169
1170 def test_merge_and_json_coexist(self) -> None:
1171 ns = self._parse("-m", "--json", "feat")
1172 assert ns.merge is True
1173 assert ns.json_out is True
1174
1175
1176 class TestCheckoutMergeClean:
1177 """Clean-merge scenarios β€” no conflict markers should appear."""
1178
1179 def test_untracked_file_survives_checkout(self, repo: pathlib.Path) -> None:
1180 """Untracked files must not be disturbed by -m checkout."""
1181 _branch(repo, "feat")
1182 (repo / "untracked.txt").write_text("I am untracked\n")
1183 r = _checkout(repo, "-m", "feat")
1184 assert r.exit_code == 0
1185 assert (repo / "untracked.txt").read_text() == "I am untracked\n"
1186
1187 def test_ours_only_change_carried_cleanly(self, tmp_path: pathlib.Path) -> None:
1188 """When we modify a file that target branch left untouched, the change carries.
1189
1190 'clean' branch diverged by adding a brand-new file, leaving shared.py
1191 identical to main's HEAD. Our uncommitted change to shared.py has no
1192 competition from the target and must merge cleanly.
1193 """
1194 saved = os.getcwd()
1195 try:
1196 os.chdir(tmp_path)
1197 runner.invoke(None, ["init"])
1198 finally:
1199 os.chdir(saved)
1200 (tmp_path / "shared.py").write_text("line1\nline2\nline3\n")
1201 _commit(tmp_path, "-m", "initial")
1202
1203 # Create 'clean' branch β€” only adds a new file, does NOT touch shared.py
1204 _branch(tmp_path, "clean")
1205 _checkout(tmp_path, "clean")
1206 (tmp_path / "extra.py").write_text("# extra\n")
1207 _commit(tmp_path, "-m", "add extra.py")
1208 _checkout(tmp_path, "main")
1209
1210 # Dirty workdir on main: modify shared.py
1211 (tmp_path / "shared.py").write_text("line1\nline2\nLINE3\n")
1212 r = _checkout(tmp_path, "-m", "clean")
1213 assert r.exit_code == 0
1214 content = (tmp_path / "shared.py").read_text()
1215 assert "LINE3" in content, "Our uncommitted change must be in merged result"
1216
1217 def test_clean_merge_json_output(self, tmp_path: pathlib.Path) -> None:
1218 """JSON output reports clean_merges and empty conflicts on success."""
1219 saved = os.getcwd()
1220 try:
1221 os.chdir(tmp_path)
1222 runner.invoke(None, ["init"])
1223 finally:
1224 os.chdir(saved)
1225 (tmp_path / "shared.py").write_text("line1\nline2\nline3\n")
1226 _commit(tmp_path, "-m", "initial")
1227
1228 _branch(tmp_path, "clean")
1229 _checkout(tmp_path, "clean")
1230 (tmp_path / "extra.py").write_text("# extra\n")
1231 _commit(tmp_path, "-m", "add extra.py")
1232 _checkout(tmp_path, "main")
1233
1234 (tmp_path / "shared.py").write_text("line1\nline2\nLINE3\n")
1235 r = _checkout(tmp_path, "-m", "--json", "clean")
1236 assert r.exit_code == 0
1237 data = json.loads(r.output)
1238 assert data["action"] == "switched"
1239 assert data["branch"] == "clean"
1240 assert isinstance(data["clean_merges"], list)
1241 assert isinstance(data["conflicts"], list)
1242 assert len(data["conflicts"]) == 0
1243
1244 def test_switched_branch_recorded(self, tmp_path: pathlib.Path) -> None:
1245 """After a clean -m checkout the current branch is the target."""
1246 repo = _make_diverged_repo(tmp_path)
1247 (repo / "shared.py").write_text("line1\nline2\nLINE3\n")
1248 _checkout(repo, "-m", "other")
1249 assert read_current_branch(repo) == "other"
1250
1251 def test_no_dirty_files_succeeds_silently(self, repo: pathlib.Path) -> None:
1252 """If the working tree is clean, -m behaves like a normal checkout."""
1253 _branch(repo, "feat")
1254 r = _checkout(repo, "-m", "feat")
1255 assert r.exit_code == 0
1256 assert read_current_branch(repo) == "feat"
1257
1258 def test_dry_run_does_not_switch_branch(self, tmp_path: pathlib.Path) -> None:
1259 """-m --dry-run must not actually switch branches."""
1260 repo = _make_diverged_repo(tmp_path)
1261 (repo / "shared.py").write_text("line1\nline2\nLINE3\n")
1262 r = _checkout(repo, "-m", "--dry-run", "other")
1263 assert r.exit_code == 0
1264 assert read_current_branch(repo) == "main"
1265
1266 def test_dry_run_json_reports_dry_run_true(self, tmp_path: pathlib.Path) -> None:
1267 repo = _make_diverged_repo(tmp_path)
1268 (repo / "shared.py").write_text("line1\nline2\nLINE3\n")
1269 r = _checkout(repo, "-m", "--dry-run", "--json", "other")
1270 assert r.exit_code == 0
1271 data = json.loads(r.output)
1272 assert data["dry_run"] is True
1273 assert data["branch"] == "other"
1274
1275
1276 class TestCheckoutMergeConflict:
1277 """Conflict scenarios β€” conflict markers and MERGE_STATE must appear."""
1278
1279 def test_conflicting_change_exits_1(self, tmp_path: pathlib.Path) -> None:
1280 """Same line changed on both sides β†’ conflict β†’ exit code 1."""
1281 repo = _make_diverged_repo(tmp_path)
1282 # Also change line2 on main (uncommitted) β€” same line other branch changed
1283 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1284 r = _checkout(repo, "-m", "other")
1285 assert r.exit_code == 1
1286
1287 def test_conflict_markers_written_to_file(self, tmp_path: pathlib.Path) -> None:
1288 """Conflicting file must contain diff3-style conflict markers after -m."""
1289 repo = _make_diverged_repo(tmp_path)
1290 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1291 _checkout(repo, "-m", "other")
1292 content = (repo / "shared.py").read_text()
1293 assert "<<<<<<<" in content
1294 assert "=======" in content
1295 assert ">>>>>>>" in content
1296
1297 def test_conflict_markers_contain_cohen_action_labels(self, tmp_path: pathlib.Path) -> None:
1298 """Cohen-style action labels ([modified], [inserted], [deleted]) must appear."""
1299 repo = _make_diverged_repo(tmp_path)
1300 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1301 _checkout(repo, "-m", "other")
1302 content = (repo / "shared.py").read_text()
1303 # At least one action label must be present
1304 assert any(label in content for label in ("[modified]", "[inserted]", "[deleted]"))
1305
1306 def test_merge_state_written_on_conflict(self, tmp_path: pathlib.Path) -> None:
1307 """MERGE_STATE.json must exist after a conflicting -m checkout."""
1308 repo = _make_diverged_repo(tmp_path)
1309 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1310 _checkout(repo, "-m", "other")
1311 merge_state_file = merge_state_path(repo)
1312 assert merge_state_file.exists()
1313
1314 def test_merge_state_lists_conflict_path(self, tmp_path: pathlib.Path) -> None:
1315 """MERGE_STATE.json must name the conflicting path."""
1316 repo = _make_diverged_repo(tmp_path)
1317 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1318 _checkout(repo, "-m", "other")
1319 data = json.loads((merge_state_path(repo)).read_text())
1320 assert "shared.py" in data.get("conflict_paths", [])
1321
1322 def test_conflict_json_output_contains_conflicts_list(self, tmp_path: pathlib.Path) -> None:
1323 """JSON output must list the conflict paths even on exit code 1."""
1324 repo = _make_diverged_repo(tmp_path)
1325 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1326 r = _checkout(repo, "-m", "--json", "other")
1327 data = json.loads(r.output)
1328 assert "conflicts" in data
1329 assert len(data["conflicts"]) >= 1
1330
1331 def test_branch_is_switched_despite_conflict(self, tmp_path: pathlib.Path) -> None:
1332 """Even when conflicts exist, we are on the target branch after -m."""
1333 repo = _make_diverged_repo(tmp_path)
1334 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1335 _checkout(repo, "-m", "other")
1336 assert read_current_branch(repo) == "other"
1337
1338
1339 class TestCheckoutMergeErrors:
1340 """Error cases for -m flag."""
1341
1342 def test_merge_with_create_flag_ignored(self, repo: pathlib.Path) -> None:
1343 """``-m -b new-branch`` must NOT invoke _checkout_with_merge (target doesn't exist)."""
1344 # -m with -b falls through to regular create logic; no conflict-carry
1345 r = _checkout(repo, "-m", "-b", "new-branch")
1346 # Should succeed as a regular branch creation (no carry logic for new branches)
1347 assert r.exit_code == 0
1348
1349 def test_merge_missing_branch_errors(self, repo: pathlib.Path) -> None:
1350 """``-m nonexistent`` must exit 1 with an error message."""
1351 r = _checkout(repo, "-m", "nonexistent")
1352 assert r.exit_code == 1
1353
1354 def test_merge_already_on_branch_is_noop(self, repo: pathlib.Path) -> None:
1355 """``-m main`` when already on main must print 'Already on' and exit 0."""
1356 r = _checkout(repo, "-m", "main")
1357 assert r.exit_code == 0
1358 assert "Already on" in r.output or "already" in r.output.lower()
1359
1360 def test_merge_with_invalid_branch_name_errors(self, repo: pathlib.Path) -> None:
1361 """``-m ..invalid`` must exit 1 before attempting any merge."""
1362 r = _checkout(repo, "-m", "../bad/name")
1363 assert r.exit_code == 1
1364
1365
1366 # ──────────────────────────────────────────────────────────────────────────────
1367 # Non-conflicting carry-forward: checkout <branch> with dirty files that the
1368 # target branch does not touch should succeed without --merge or --autoshelf.
1369 #
1370 # This is the core ergonomics fix: an agent editing app.py on dev, then doing
1371 # checkout always refuses a dirty tracked file regardless of whether the target
1372 # branch shares the same committed version β€” see TestDirtyWorkdirBleedThrough.
1373 # Untracked files are never blocked (they are not in any snapshot).
1374 # ──────────────────────────────────────────────────────────────────────────────
1375
1376
1377 class TestSwitchWithNonConflictingChanges:
1378 @pytest.fixture()
1379 def two_branch_clean(self, tmp_path: pathlib.Path) -> pathlib.Path:
1380 """Repo with main and feat; feat adds a NEW file, leaves shared.py alone."""
1381 saved = os.getcwd()
1382 try:
1383 os.chdir(tmp_path)
1384 runner.invoke(None, ["init"])
1385 finally:
1386 os.chdir(saved)
1387 (tmp_path / "shared.py").write_text("x = 1\n")
1388 _commit(tmp_path, "-m", "initial")
1389 # Create feat branch with an extra file β€” shared.py is untouched
1390 _checkout(tmp_path, "-b", "feat")
1391 (tmp_path / "feat_only.py").write_text("f = 1\n")
1392 _commit(tmp_path, "-m", "feat commit")
1393 _checkout(tmp_path, "main")
1394 return tmp_path
1395
1396 def test_switch_still_blocks_on_true_conflict(
1397 self, tmp_path: pathlib.Path
1398 ) -> None:
1399 """When the target branch has a different version of a modified file, block."""
1400 saved = os.getcwd()
1401 try:
1402 os.chdir(tmp_path)
1403 runner.invoke(None, ["init"])
1404 finally:
1405 os.chdir(saved)
1406 (tmp_path / "shared.py").write_text("x = 1\n")
1407 _commit(tmp_path, "-m", "initial")
1408 # feat branch changes shared.py
1409 _checkout(tmp_path, "-b", "feat")
1410 (tmp_path / "shared.py").write_text("x = feat\n")
1411 _commit(tmp_path, "-m", "feat changes shared")
1412 _checkout(tmp_path, "main")
1413 # Now dirty shared.py locally β€” feat has a different version β†’ must block
1414 (tmp_path / "shared.py").write_text("x = local\n")
1415 result = _checkout(tmp_path, "feat")
1416 assert result.exit_code != 0
1417
1418 def test_switch_new_file_carries_through(
1419 self, two_branch_clean: pathlib.Path
1420 ) -> None:
1421 """Brand-new untracked files always carry through (unchanged behaviour)."""
1422 repo = two_branch_clean
1423 (repo / "new_file.py").write_text("new = True\n")
1424 result = _checkout(repo, "feat")
1425 assert result.exit_code == 0
1426 assert (repo / "new_file.py").exists()
1427
1428
1429 # ──────────────────────────────────────────────────────────────────────────────
1430 # Agent supercharge β€” duration_ms and exit_code in every JSON output
1431 # ──────────────────────────────────────────────────────────────────────────────
1432
1433
1434 class TestElapsed:
1435 """Every JSON output path must include an ``duration_ms`` float."""
1436
1437 def test_switch_json_has_elapsed(self, two_branch_repo: pathlib.Path) -> None:
1438 result = _checkout(two_branch_repo, "feat", "--json")
1439 data = json.loads(result.output)
1440 assert "duration_ms" in data
1441 assert isinstance(data["duration_ms"], float)
1442
1443 def test_create_json_has_elapsed(self, repo: pathlib.Path) -> None:
1444 result = _checkout(repo, "-b", "elapsed-branch", "--json")
1445 data = json.loads(result.output)
1446 assert "duration_ms" in data
1447 assert isinstance(data["duration_ms"], float)
1448
1449 def test_already_on_json_has_elapsed(self, repo: pathlib.Path) -> None:
1450 result = _checkout(repo, "main", "--json")
1451 data = json.loads(result.output)
1452 assert "duration_ms" in data
1453 assert isinstance(data["duration_ms"], float)
1454
1455 def test_detach_json_has_elapsed(self, repo: pathlib.Path) -> None:
1456 sha = get_head_commit_id(repo, "main")
1457 assert sha is not None
1458 result = _checkout(repo, sha, "--json")
1459 data = json.loads(result.output)
1460 assert "duration_ms" in data
1461 assert isinstance(data["duration_ms"], float)
1462
1463 def test_dry_run_switch_json_has_elapsed(self, two_branch_repo: pathlib.Path) -> None:
1464 result = _checkout(two_branch_repo, "--dry-run", "feat", "--json")
1465 data = json.loads(result.output)
1466 assert "duration_ms" in data
1467
1468 def test_dry_run_create_json_has_elapsed(self, repo: pathlib.Path) -> None:
1469 result = _checkout(repo, "-b", "dry-elapsed", "--dry-run", "--json")
1470 data = json.loads(result.output)
1471 assert "duration_ms" in data
1472
1473 def test_dry_run_detach_json_has_elapsed(self, repo: pathlib.Path) -> None:
1474 sha = get_head_commit_id(repo, "main")
1475 assert sha is not None
1476 result = _checkout(repo, "--dry-run", sha, "--json")
1477 data = json.loads(result.output)
1478 assert "duration_ms" in data
1479
1480 def test_restored_json_has_elapsed(self, repo: pathlib.Path) -> None:
1481 result = _checkout(repo, "--force", "main", "--json")
1482 data = json.loads(result.output)
1483 assert "duration_ms" in data
1484
1485 def test_conflict_resolved_all_json_has_elapsed(self, repo: pathlib.Path) -> None:
1486 from muse.core.merge_engine import write_merge_state
1487
1488 ours = get_head_commit_id(repo, "main") or ""
1489 write_merge_state(
1490 repo,
1491 base_commit="",
1492 ours_commit=ours,
1493 theirs_commit=ours,
1494 conflict_paths=["a.py"],
1495 other_branch="feat",
1496 )
1497 result = _checkout(repo, "--ours", "--all", "--json")
1498 data = json.loads(result.output)
1499 assert "duration_ms" in data
1500
1501 def test_conflict_resolved_single_json_has_elapsed(self, repo: pathlib.Path) -> None:
1502 from muse.core.merge_engine import write_merge_state
1503 from muse.core.refs import get_head_commit_id as _gci
1504
1505 ours = _gci(repo, "main") or ""
1506 write_merge_state(
1507 repo,
1508 base_commit="",
1509 ours_commit=ours,
1510 theirs_commit=ours,
1511 conflict_paths=["a.py"],
1512 other_branch="feat",
1513 )
1514 result = _checkout(repo, "--ours", "a.py", "--json")
1515 data = json.loads(result.output)
1516 assert "duration_ms" in data
1517
1518
1519 class TestExitCode:
1520 """Every successful JSON output path must include ``exit_code: 0``."""
1521
1522 def test_switch_json_exit_code_0(self, two_branch_repo: pathlib.Path) -> None:
1523 result = _checkout(two_branch_repo, "feat", "--json")
1524 data = json.loads(result.output)
1525 assert data["exit_code"] == 0
1526
1527 def test_create_json_exit_code_0(self, repo: pathlib.Path) -> None:
1528 result = _checkout(repo, "-b", "ec-branch", "--json")
1529 data = json.loads(result.output)
1530 assert data["exit_code"] == 0
1531
1532 def test_already_on_json_exit_code_0(self, repo: pathlib.Path) -> None:
1533 result = _checkout(repo, "main", "--json")
1534 data = json.loads(result.output)
1535 assert data["exit_code"] == 0
1536
1537 def test_detach_json_exit_code_0(self, repo: pathlib.Path) -> None:
1538 sha = get_head_commit_id(repo, "main")
1539 assert sha is not None
1540 result = _checkout(repo, sha, "--json")
1541 data = json.loads(result.output)
1542 assert data["exit_code"] == 0
1543
1544 def test_dry_run_switch_json_exit_code_0(self, two_branch_repo: pathlib.Path) -> None:
1545 result = _checkout(two_branch_repo, "--dry-run", "feat", "--json")
1546 data = json.loads(result.output)
1547 assert data["exit_code"] == 0
1548
1549 def test_dry_run_create_json_exit_code_0(self, repo: pathlib.Path) -> None:
1550 result = _checkout(repo, "-b", "dry-ec", "--dry-run", "--json")
1551 data = json.loads(result.output)
1552 assert data["exit_code"] == 0
1553
1554 def test_dry_run_detach_json_exit_code_0(self, repo: pathlib.Path) -> None:
1555 sha = get_head_commit_id(repo, "main")
1556 assert sha is not None
1557 result = _checkout(repo, "--dry-run", sha, "--json")
1558 data = json.loads(result.output)
1559 assert data["exit_code"] == 0
1560
1561 def test_restored_json_exit_code_0(self, repo: pathlib.Path) -> None:
1562 result = _checkout(repo, "--force", "main", "--json")
1563 data = json.loads(result.output)
1564 assert data["exit_code"] == 0
1565
1566 def test_conflict_resolved_all_json_exit_code_0(self, repo: pathlib.Path) -> None:
1567 from muse.core.merge_engine import write_merge_state
1568
1569 ours = get_head_commit_id(repo, "main") or ""
1570 write_merge_state(
1571 repo,
1572 base_commit="",
1573 ours_commit=ours,
1574 theirs_commit=ours,
1575 conflict_paths=["a.py"],
1576 other_branch="feat",
1577 )
1578 result = _checkout(repo, "--ours", "--all", "--json")
1579 data = json.loads(result.output)
1580 assert data["exit_code"] == 0
1581
1582 def test_conflict_resolved_single_json_exit_code_0(self, repo: pathlib.Path) -> None:
1583 from muse.core.merge_engine import write_merge_state
1584 from muse.core.refs import get_head_commit_id as _gci
1585
1586 ours = _gci(repo, "main") or ""
1587 write_merge_state(
1588 repo,
1589 base_commit="",
1590 ours_commit=ours,
1591 theirs_commit=ours,
1592 conflict_paths=["a.py"],
1593 other_branch="feat",
1594 )
1595 result = _checkout(repo, "--ours", "a.py", "--json")
1596 data = json.loads(result.output)
1597 assert data["exit_code"] == 0
1598
1599
1600 class TestJsonSchemaComplete:
1601 """``duration_ms`` and ``exit_code`` must be in ``REQUIRED_KEYS``."""
1602
1603 REQUIRED_KEYS = {
1604 "action", "branch", "commit_id", "from_branch", "dry_run",
1605 "duration_ms", "exit_code",
1606 }
1607
1608 def test_switch_has_complete_schema(self, two_branch_repo: pathlib.Path) -> None:
1609 result = _checkout(two_branch_repo, "feat", "--json")
1610 data = json.loads(result.output)
1611 missing = self.REQUIRED_KEYS - set(data)
1612 assert not missing, f"Missing keys in 'switched' JSON: {missing}"
1613
1614 def test_create_has_complete_schema(self, repo: pathlib.Path) -> None:
1615 result = _checkout(repo, "-b", "schema-branch", "--json")
1616 data = json.loads(result.output)
1617 missing = self.REQUIRED_KEYS - set(data)
1618 assert not missing, f"Missing keys in 'created' JSON: {missing}"
1619
1620 def test_detach_has_complete_schema(self, repo: pathlib.Path) -> None:
1621 sha = get_head_commit_id(repo, "main")
1622 assert sha is not None
1623 result = _checkout(repo, sha, "--json")
1624 data = json.loads(result.output)
1625 missing = self.REQUIRED_KEYS - set(data)
1626 assert not missing, f"Missing keys in 'detached' JSON: {missing}"
1627
1628 def test_already_on_has_complete_schema(self, repo: pathlib.Path) -> None:
1629 result = _checkout(repo, "main", "--json")
1630 data = json.loads(result.output)
1631 missing = self.REQUIRED_KEYS - set(data)
1632 assert not missing, f"Missing keys in 'already_on' JSON: {missing}"
1633
1634
1635 # ──────────────────────────────────────────────────────────────────────────────
1636 # checkout -b --intent / --resumable
1637 # ──────────────────────────────────────────────────────────────────────────────
1638
1639
1640 class TestCheckoutCreateWithMeta:
1641 """``muse checkout -b <name> --intent <text> --resumable`` stores metadata."""
1642
1643 # ── Parser ────────────────────────────────────────────────────────────────
1644
1645 def _parse(self, *args: str) -> "argparse.Namespace":
1646 import argparse
1647 from muse.cli.commands.checkout import register
1648 p = argparse.ArgumentParser()
1649 sub = p.add_subparsers()
1650 register(sub)
1651 return p.parse_args(["checkout", *args])
1652
1653 def test_intent_flag_parsed(self) -> None:
1654 ns = self._parse("-b", "task/x", "--intent", "do the thing")
1655 assert ns.intent == "do the thing"
1656
1657 def test_resumable_flag_parsed(self) -> None:
1658 ns = self._parse("-b", "task/x", "--resumable")
1659 assert ns.resumable is True
1660
1661 def test_intent_default_none(self) -> None:
1662 ns = self._parse("main")
1663 assert ns.intent is None
1664
1665 def test_resumable_default_false(self) -> None:
1666 ns = self._parse("main")
1667 assert ns.resumable is False
1668
1669 def test_intent_without_create_is_error(self, repo: pathlib.Path) -> None:
1670 """--intent without -b should be rejected."""
1671 result = _checkout(repo, "main", "--intent", "oops")
1672 assert result.exit_code != 0
1673
1674 def test_resumable_without_create_is_error(self, repo: pathlib.Path) -> None:
1675 """--resumable without -b should be rejected."""
1676 result = _checkout(repo, "main", "--resumable")
1677 assert result.exit_code != 0
1678
1679 # ── Integration: intent stored ─────────────────────────────────────────
1680
1681 def test_intent_stored_in_branch_meta(self, repo: pathlib.Path) -> None:
1682 result = _checkout(repo, "-b", "task/work", "--intent", "implement auth")
1683 assert result.exit_code == 0
1684 meta = read_branch_meta(repo, "task/work")
1685 assert meta.get("intent") == "implement auth"
1686
1687 def test_resumable_stored_in_branch_meta(self, repo: pathlib.Path) -> None:
1688 result = _checkout(repo, "-b", "task/work", "--resumable")
1689 assert result.exit_code == 0
1690 meta = read_branch_meta(repo, "task/work")
1691 assert meta.get("resumable") is True
1692
1693 def test_intent_and_resumable_together(self, repo: pathlib.Path) -> None:
1694 result = _checkout(
1695 repo, "-b", "task/work", "--intent", "add feature", "--resumable"
1696 )
1697 assert result.exit_code == 0
1698 meta = read_branch_meta(repo, "task/work")
1699 assert meta.get("intent") == "add feature"
1700 assert meta.get("resumable") is True
1701
1702 def test_branch_switched_after_create_with_meta(self, repo: pathlib.Path) -> None:
1703 _checkout(repo, "-b", "task/work", "--intent", "x", "--resumable")
1704 assert read_current_branch(repo) == "task/work"
1705
1706 def test_no_metadata_when_flags_absent(self, repo: pathlib.Path) -> None:
1707 _checkout(repo, "-b", "task/plain")
1708 meta = read_branch_meta(repo, "task/plain")
1709 assert meta.get("intent") is None
1710 assert not meta.get("resumable")
1711
1712 def test_intent_only_no_resumable_set(self, repo: pathlib.Path) -> None:
1713 _checkout(repo, "-b", "task/work", "--intent", "just intent")
1714 meta = read_branch_meta(repo, "task/work")
1715 assert meta.get("intent") == "just intent"
1716 assert not meta.get("resumable")
1717
1718 def test_resumable_only_no_intent_set(self, repo: pathlib.Path) -> None:
1719 _checkout(repo, "-b", "task/work", "--resumable")
1720 meta = read_branch_meta(repo, "task/work")
1721 assert meta.get("resumable") is True
1722 assert meta.get("intent") is None
1723
1724 # ── JSON output still correct ──────────────────────────────────────────
1725
1726 def test_json_action_is_created(self, repo: pathlib.Path) -> None:
1727 result = _checkout(
1728 repo, "-b", "task/work", "--intent", "x", "--resumable", "--json"
1729 )
1730 assert result.exit_code == 0
1731 data = json.loads(result.output)
1732 assert data["action"] == "created"
1733
1734 def test_json_branch_name_correct(self, repo: pathlib.Path) -> None:
1735 result = _checkout(
1736 repo, "-b", "task/work", "--intent", "x", "--json"
1737 )
1738 data = json.loads(result.output)
1739 assert data["branch"] == "task/work"
1740
1741 # ── branch --json listing reflects metadata ────────────────────────────
1742
1743 def test_branch_list_json_shows_intent(self, repo: pathlib.Path) -> None:
1744 _checkout(repo, "-b", "task/work", "--intent", "my intent")
1745 result = _invoke(repo, ["branch", "--json"])
1746 branches = json.loads(result.output)
1747 entry = next(b for b in branches if b["name"] == "task/work")
1748 assert entry.get("intent") == "my intent"
1749
1750 def test_branch_list_json_shows_resumable(self, repo: pathlib.Path) -> None:
1751 _checkout(repo, "-b", "task/work", "--resumable")
1752 result = _invoke(repo, ["branch", "--json"])
1753 branches = json.loads(result.output)
1754 entry = next(b for b in branches if b["name"] == "task/work")
1755 assert entry.get("resumable") is True
1756
1757 def test_resumable_filter_finds_branch(self, repo: pathlib.Path) -> None:
1758 _checkout(repo, "-b", "task/work", "--resumable")
1759 _checkout(repo, "main")
1760 _checkout(repo, "-b", "task/plain")
1761 result = _invoke(repo, ["branch", "--resumable", "--json"])
1762 names = [b["name"] for b in json.loads(result.output)]
1763 assert "task/work" in names
1764 assert "task/plain" not in names
1765
1766 # ── Security: ANSI in intent ───────────────────────────────────────────
1767
1768 def test_ansi_in_intent_sanitized_in_text_output(self, repo: pathlib.Path) -> None:
1769 malicious = "\x1b[31mmalicious\x1b[0m"
1770 result = _checkout(repo, "-b", "task/work", "--intent", malicious)
1771 assert result.exit_code == 0
1772 assert "\x1b" not in result.output
1773
1774
1775 # ---------------------------------------------------------------------------
1776 # Flag registration tests
1777 # ---------------------------------------------------------------------------
1778
1779 import argparse as _argparse
1780 from muse.cli.commands.checkout import register as _register_checkout
1781 from muse.core.paths import merge_state_path
1782
1783
1784 def _parse_checkout(*args: str) -> _argparse.Namespace:
1785 """Build an argument parser via register() and parse args."""
1786 root_p = _argparse.ArgumentParser()
1787 subs = root_p.add_subparsers(dest="cmd")
1788 _register_checkout(subs)
1789 return root_p.parse_args(["checkout", *args])
1790
1791
1792 class TestRegisterFlags:
1793 def test_default_json_out_is_false(self) -> None:
1794 ns = _parse_checkout("dev")
1795 assert ns.json_out is False
1796
1797 def test_json_flag_sets_json_out(self) -> None:
1798 ns = _parse_checkout("dev", "--json")
1799 assert ns.json_out is True
1800
1801 def test_j_shorthand_sets_json_out(self) -> None:
1802 ns = _parse_checkout("dev", "-j")
1803 assert ns.json_out is True
1804
1805 def test_create_branch_flag(self) -> None:
1806 ns = _parse_checkout("-b", "task/foo")
1807 assert ns.create is True
1808
1809 def test_force_flag(self) -> None:
1810 ns = _parse_checkout("dev", "--force")
1811 assert ns.force is True
1812
1813 def test_f_shorthand_for_force(self) -> None:
1814 ns = _parse_checkout("dev", "-f")
1815 assert ns.force is True
1816
1817 def test_dry_run_flag(self) -> None:
1818 ns = _parse_checkout("dev", "--dry-run")
1819 assert ns.dry_run is True
1820
1821 def test_n_shorthand_for_dry_run(self) -> None:
1822 ns = _parse_checkout("dev", "-n")
1823 assert ns.dry_run is True
1824
1825
1826 # ──────────────────────────────────────────────────────────────────────────────
1827 # Regression β€” autoshelf must NOT bleed committed task-branch changes back
1828 # ──────────────────────────────────────────────────────────────────────────────
1829
1830
1831 @pytest.fixture()
1832 def task_branch_repo(tmp_path: pathlib.Path) -> pathlib.Path:
1833 """Repo that reproduces the autoshelf phantom-modification bug.
1834
1835 Layout
1836 ------
1837 main
1838 committed.py = "base\n"
1839 dirty.py = "base\n"
1840
1841 task (branched from main)
1842 committed.py = "task-committed\n" ← committed on task branch
1843 dirty.py = "dirty-edit\n" ← NOT committed (working-tree only)
1844
1845 This is the exact shape that triggered the bug: after
1846 ``muse checkout main --autoshelf``, committed.py was appearing as
1847 "modified" on main even though no one edited it there.
1848 """
1849 saved = os.getcwd()
1850 try:
1851 os.chdir(tmp_path)
1852 runner.invoke(None, ["init"])
1853 finally:
1854 os.chdir(saved)
1855
1856 (tmp_path / "committed.py").write_text("base\n")
1857 (tmp_path / "dirty.py").write_text("base\n")
1858 _commit(tmp_path, "-m", "initial")
1859
1860 _checkout(tmp_path, "-b", "task")
1861 (tmp_path / "committed.py").write_text("task-committed\n")
1862 _commit(tmp_path, "-m", "task: update committed.py")
1863
1864 # Leave dirty.py modified but NOT committed β€” this is the legitimate dirt.
1865 (tmp_path / "dirty.py").write_text("dirty-edit\n")
1866
1867 return tmp_path
1868
1869
1870 class TestAutoshelfDirtyIsolation:
1871 """autoshelf must only restore files that were actually dirty (unstaged/uncommitted).
1872
1873 Regression: the old implementation saved the full working-tree snapshot
1874 and applied it verbatim on the target branch. Committed-on-source changes
1875 bled into the target working tree as phantom modifications, causing merge
1876 to refuse with "your local changes would be overwritten".
1877
1878 The invariant: after ``checkout <target> --autoshelf``, only files that
1879 were genuinely uncommitted on the source branch should appear as modified
1880 on the target. Files that were committed on the source (but not yet on the
1881 target) are the merge's job β€” not the shelf's.
1882 """
1883
1884 def test_committed_source_file_not_in_working_tree_after_autoshelf(
1885 self,
1886 task_branch_repo: pathlib.Path,
1887 ) -> None:
1888 """committed.py was committed on task β€” it must NOT appear modified on main."""
1889 result = _checkout(task_branch_repo, "main", "--autoshelf")
1890 assert result.exit_code == 0, result.stderr
1891 assert read_current_branch(task_branch_repo) == "main"
1892
1893 content = (task_branch_repo / "committed.py").read_text()
1894 assert content == "base\n", (
1895 "committed.py must reflect main HEAD after autoshelf β€” "
1896 f"got {content!r}, expected 'base\\n'. "
1897 "The task-branch committed version must NOT be restored by the shelf."
1898 )
1899
1900 def test_dirty_file_is_restored_after_autoshelf(
1901 self,
1902 task_branch_repo: pathlib.Path,
1903 ) -> None:
1904 """dirty.py was uncommitted on task β€” it MUST be restored on main."""
1905 _checkout(task_branch_repo, "main", "--autoshelf")
1906
1907 content = (task_branch_repo / "dirty.py").read_text()
1908 assert content == "dirty-edit\n", (
1909 "dirty.py (uncommitted working-tree change) must survive the autoshelf "
1910 f"round-trip β€” got {content!r}, expected 'dirty-edit\\n'."
1911 )
1912
1913 def test_working_tree_clean_except_dirty_file_after_autoshelf(
1914 self,
1915 task_branch_repo: pathlib.Path,
1916 ) -> None:
1917 """After autoshelf to main, only dirty.py should be modified β€” nothing else."""
1918 _checkout(task_branch_repo, "main", "--autoshelf")
1919
1920 result = _invoke(task_branch_repo, ["status", "--json"])
1921 assert result.exit_code == 0, result.stderr
1922 data = json.loads(result.output)
1923
1924 modified = set(data.get("modified", []))
1925 assert modified == {"dirty.py"}, (
1926 f"Only dirty.py should be modified after autoshelf β€” got {modified}. "
1927 "committed.py is a phantom: it was committed on task, belongs to the "
1928 "merge, and must not bleed into the working tree via the shelf."
1929 )