gabriel / muse public
test_cmd_checkout.py python
1,876 lines 81.5 KB
Raw
1 """Tests for ``muse checkout``.
2
3 Coverage tiers
4 --------------
5 Unit — parser flags, dead-code removal, docstring schema.
6 Integration — switch, create, already_on, detach, --dry-run, conflict resolution.
7 End-to-end — full CLI invocations: text and JSON output, all operations.
8 Security — ANSI injection in target, error routing to stderr.
9 Stress — checkout under high file counts, concurrent checkouts.
10 """
11
12 from __future__ import annotations
13
14 import json
15 import os
16 import pathlib
17 import subprocess
18 import threading
19 import time
20 from typing import TYPE_CHECKING
21
22 import pytest
23
24 from tests.cli_test_helper import CliRunner, InvokeResult
25 from muse.core.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 # ──────────────────────────────────────────────────────────────────────────────
580 # Integration — DETACHED HEAD RECOVERY (checkout branch from detached HEAD)
581 # ──────────────────────────────────────────────────────────────────────────────
582
583
584 class TestDetachedHeadRecovery:
585 """Recovering from detached HEAD state via 'muse checkout <branch>'."""
586
587 @pytest.fixture()
588 def detached_repo(self, repo: pathlib.Path) -> pathlib.Path:
589 """Repo with HEAD detached at the main commit."""
590 sha = get_head_commit_id(repo, "main")
591 assert sha is not None
592 _checkout(repo, sha)
593 return repo
594
595 def test_recover_to_branch_exits_0(self, detached_repo: pathlib.Path) -> None:
596 result = _checkout(detached_repo, "main")
597 assert result.exit_code == 0
598
599 def test_recover_to_branch_restores_symbolic_head(self, detached_repo: pathlib.Path) -> None:
600 _checkout(detached_repo, "main")
601 assert read_current_branch(detached_repo) == "main"
602
603 def test_recover_to_branch_text_output(self, detached_repo: pathlib.Path) -> None:
604 result = _checkout(detached_repo, "main")
605 assert "main" in result.output
606
607 def test_recover_to_branch_json_action(self, detached_repo: pathlib.Path) -> None:
608 result = _checkout(detached_repo, "main", "--json")
609 data = json.loads(result.output)
610 assert data["action"] in ("switched", "already_on")
611 assert data["branch"] == "main"
612
613 def test_recover_to_branch_from_branch_is_null(self, detached_repo: pathlib.Path) -> None:
614 """from_branch is None when recovering from detached HEAD."""
615 result = _checkout(detached_repo, "main", "--json")
616 data = json.loads(result.output)
617 assert data["from_branch"] is None
618
619 def test_create_branch_from_detached_exits_0(self, detached_repo: pathlib.Path) -> None:
620 result = _checkout(detached_repo, "-b", "rescue")
621 assert result.exit_code == 0
622
623 def test_create_branch_from_detached_switches_head(self, detached_repo: pathlib.Path) -> None:
624 _checkout(detached_repo, "-b", "rescue")
625 assert read_current_branch(detached_repo) == "rescue"
626
627 def test_create_branch_from_detached_inherits_commit(self, detached_repo: pathlib.Path) -> None:
628 sha = get_head_commit_id(detached_repo, "main")
629 _checkout(detached_repo, "-b", "rescue")
630 assert get_head_commit_id(detached_repo, "rescue") == sha
631
632 def test_dry_run_recover_exits_0(self, detached_repo: pathlib.Path) -> None:
633 result = _checkout(detached_repo, "--dry-run", "main")
634 assert result.exit_code == 0
635
636 def test_dry_run_recover_does_not_change_head(self, detached_repo: pathlib.Path) -> None:
637 _checkout(detached_repo, "--dry-run", "main")
638 from muse.core.refs import read_head
639 state = read_head(detached_repo)
640 assert state["kind"] == "commit", "HEAD must still be detached after dry-run"
641
642 def test_merge_flag_from_detached_exits_1(self, detached_repo: pathlib.Path) -> None:
643 result = _checkout(detached_repo, "--merge", "main")
644 assert result.exit_code == 1
645
646 def test_merge_flag_from_detached_stderr_message(self, detached_repo: pathlib.Path) -> None:
647 result = _checkout(detached_repo, "--merge", "main")
648 assert "detach" in (result.stderr or "").lower() or "branch" in (result.stderr or "").lower()
649
650
651 # ──────────────────────────────────────────────────────────────────────────────
652 # Integration — DRY-RUN
653 # ──────────────────────────────────────────────────────────────────────────────
654
655
656 class TestDryRun:
657 def test_dry_run_switch_exits_0(self, two_branch_repo: pathlib.Path) -> None:
658 result = _checkout(two_branch_repo, "--dry-run", "feat")
659 assert result.exit_code == 0
660
661 def test_dry_run_does_not_switch_branch(self, two_branch_repo: pathlib.Path) -> None:
662 _checkout(two_branch_repo, "--dry-run", "feat")
663 assert read_current_branch(two_branch_repo) == "main"
664
665 def test_dry_run_text_says_would(self, two_branch_repo: pathlib.Path) -> None:
666 result = _checkout(two_branch_repo, "--dry-run", "feat")
667 assert "Would" in result.output
668 assert "feat" in result.output
669
670 def test_dry_run_json_schema(self, two_branch_repo: pathlib.Path) -> None:
671 result = _checkout(two_branch_repo, "--dry-run", "feat", "--json")
672 data = json.loads(result.output)
673 assert data["dry_run"] is True
674 assert data["action"] == "switched"
675 assert data["branch"] == "feat"
676 assert data["from_branch"] == "main"
677
678 def test_dry_run_does_not_restore_files(self, two_branch_repo: pathlib.Path) -> None:
679 """feat.py exists only on feat branch; dry-run must not create it on main."""
680 _checkout(two_branch_repo, "--dry-run", "feat")
681 assert not (two_branch_repo / "feat.py").exists()
682
683 def test_dry_run_create_exits_0(self, repo: pathlib.Path) -> None:
684 result = _checkout(repo, "-b", "dry-branch", "--dry-run")
685 assert result.exit_code == 0
686
687 def test_dry_run_create_does_not_create_branch(self, repo: pathlib.Path) -> None:
688 _checkout(repo, "-b", "dry-branch", "--dry-run")
689 result = _invoke(repo, ["branch", "--json"])
690 names = [b["name"] for b in json.loads(result.output)]
691 assert "dry-branch" not in names
692
693 def test_dry_run_create_json_schema(self, repo: pathlib.Path) -> None:
694 result = _checkout(repo, "-b", "dry-new", "--dry-run", "--json")
695 data = json.loads(result.output)
696 assert data["dry_run"] is True
697 assert data["action"] == "created"
698 assert data["from_branch"] == "main"
699
700 def test_dry_run_detach_exits_0(self, repo: pathlib.Path) -> None:
701 sha = get_head_commit_id(repo, "main")
702 assert sha is not None
703 result = _checkout(repo, "--dry-run", sha)
704 assert result.exit_code == 0
705
706 def test_dry_run_detach_does_not_detach(self, repo: pathlib.Path) -> None:
707 sha = get_head_commit_id(repo, "main")
708 assert sha is not None
709 _checkout(repo, "--dry-run", sha)
710 assert read_current_branch(repo) == "main"
711
712 def test_dry_run_detach_json(self, repo: pathlib.Path) -> None:
713 sha = get_head_commit_id(repo, "main")
714 assert sha is not None
715 result = _checkout(repo, "--dry-run", sha, "--json")
716 data = json.loads(result.output)
717 assert data["dry_run"] is True
718 assert data["action"] == "detached"
719 assert data["branch"] is None
720
721 def test_dry_run_nonexistent_branch_exits_1(self, repo: pathlib.Path) -> None:
722 result = _checkout(repo, "--dry-run", "no-such-branch")
723 assert result.exit_code == 1
724
725 def test_dry_run_already_on_exits_0(self, repo: pathlib.Path) -> None:
726 result = _checkout(repo, "--dry-run", "main")
727 assert result.exit_code == 0
728
729 def test_dry_run_already_on_json(self, repo: pathlib.Path) -> None:
730 result = _checkout(repo, "--dry-run", "main", "--json")
731 data = json.loads(result.output)
732 assert data["dry_run"] is True
733 assert data["action"] == "already_on"
734
735
736 # ──────────────────────────────────────────────────────────────────────────────
737 # Integration — JSON schema consistency
738 # ──────────────────────────────────────────────────────────────────────────────
739
740
741 class TestJsonSchema:
742 REQUIRED_KEYS = {"action", "branch", "commit_id", "from_branch", "dry_run"}
743
744 def test_create_has_all_keys(self, repo: pathlib.Path) -> None:
745 result = _checkout(repo, "-b", "k-test", "--json")
746 data = json.loads(result.output)
747 missing = self.REQUIRED_KEYS - set(data)
748 assert not missing, f"Missing keys in 'created' JSON: {missing}"
749
750 def test_switch_has_all_keys(self, two_branch_repo: pathlib.Path) -> None:
751 result = _checkout(two_branch_repo, "feat", "--json")
752 data = json.loads(result.output)
753 missing = self.REQUIRED_KEYS - set(data)
754 assert not missing, f"Missing keys in 'switched' JSON: {missing}"
755
756 def test_already_on_has_all_keys(self, repo: pathlib.Path) -> None:
757 result = _checkout(repo, "main", "--json")
758 data = json.loads(result.output)
759 missing = self.REQUIRED_KEYS - set(data)
760 assert not missing, f"Missing keys in 'already_on' JSON: {missing}"
761
762 def test_detach_has_all_keys(self, repo: pathlib.Path) -> None:
763 sha = get_head_commit_id(repo, "main")
764 assert sha is not None
765 result = _checkout(repo, sha, "--json")
766 data = json.loads(result.output)
767 missing = self.REQUIRED_KEYS - set(data)
768 assert not missing, f"Missing keys in 'detached' JSON: {missing}"
769
770 def test_detach_branch_is_null(self, repo: pathlib.Path) -> None:
771 sha = get_head_commit_id(repo, "main")
772 assert sha is not None
773 result = _checkout(repo, sha, "--json")
774 data = json.loads(result.output)
775 assert data["branch"] is None
776
777 def test_from_branch_reflects_previous(self, two_branch_repo: pathlib.Path) -> None:
778 _checkout(two_branch_repo, "feat")
779 result = _checkout(two_branch_repo, "main", "--json")
780 data = json.loads(result.output)
781 assert data["from_branch"] == "feat"
782
783
784 # ──────────────────────────────────────────────────────────────────────────────
785 # Integration — validation
786 # ──────────────────────────────────────────────────────────────────────────────
787
788
789 class TestValidation:
790 def test_no_target_exits_1(self, repo: pathlib.Path) -> None:
791 result = _checkout(repo)
792 assert result.exit_code == 1
793
794 def test_no_target_error_to_stderr(self, repo: pathlib.Path) -> None:
795 result = _checkout(repo)
796 assert "Specify" in (result.stderr or "")
797
798 def test_unknown_flag_exits_nonzero(self, repo: pathlib.Path) -> None:
799 result = _checkout(repo, "main", "--no-such-flag")
800 assert result.exit_code != 0
801
802 def test_ours_without_theirs_context_exits_1(self, repo: pathlib.Path) -> None:
803 result = _checkout(repo, "--ours", "file.py")
804 assert result.exit_code == 1
805
806 def test_ours_and_theirs_together_exits_1(self, repo: pathlib.Path) -> None:
807 result = _checkout(repo, "--ours", "--theirs", "--all")
808 assert result.exit_code == 1
809
810
811 # ──────────────────────────────────────────────────────────────────────────────
812 # Security — ANSI injection
813 # ──────────────────────────────────────────────────────────────────────────────
814
815
816 class TestSecurityAnsi:
817 def _has_ansi(self, s: str) -> bool:
818 return "\x1b[" in s
819
820 def test_ansi_in_target_sanitized(self, repo: pathlib.Path) -> None:
821 result = _checkout(repo, "\x1b[31mmalicious\x1b[0m")
822 assert not self._has_ansi(result.output)
823
824 def test_ansi_in_create_name_sanitized(self, repo: pathlib.Path) -> None:
825 result = _checkout(repo, "-b", "\x1b[31mmalicious\x1b[0m")
826 assert not self._has_ansi(result.output)
827
828 def test_error_not_a_branch_sanitized(self, repo: pathlib.Path) -> None:
829 """The 'not a branch' error message must not echo raw ANSI from target."""
830 result = _checkout(repo, "\x1b[31mnotabranch\x1b[0m")
831 assert not self._has_ansi(result.output)
832 assert not self._has_ansi(result.stderr or "")
833
834 def test_all_errors_to_stderr(self, repo: pathlib.Path) -> None:
835 """Every ❌ error must go to stderr; stderr must contain the error."""
836 error_cases = [
837 ["ghost"],
838 ["-b", "bad..name"],
839 ]
840 for case in error_cases:
841 result = _checkout(repo, *case)
842 assert result.exit_code != 0, f"Expected failure for args {case}"
843 assert "❌" in (result.stderr or ""), (
844 f"Error not in stderr for args {case}: stderr={result.stderr!r}"
845 )
846
847
848 # ──────────────────────────────────────────────────────────────────────────────
849 # Integration — conflict resolution
850 # ──────────────────────────────────────────────────────────────────────────────
851
852
853 class TestConflictResolution:
854 def _setup_merge_conflict(
855 self, repo: pathlib.Path
856 ) -> tuple[str, str]:
857 """Create a merge conflict on ``repo``. Returns (ours_commit, theirs_commit)."""
858 # ours: commit on main
859 (repo / "shared.py").write_text("x = 1\n")
860 _commit(repo, "-m", "main: set x=1")
861 ours_cid = get_head_commit_id(repo, "main") or ""
862
863 # theirs: commit on feature branch
864 _branch(repo, "feat2")
865 _invoke(repo, ["checkout", "feat2"])
866 (repo / "shared.py").write_text("x = 2\n")
867 _commit(repo, "-m", "feat: set x=2")
868 theirs_cid = get_head_commit_id(repo, "feat2") or ""
869
870 _invoke(repo, ["checkout", "main"])
871 # Force a merge conflict via merge_engine internals
872 from muse.core.merge_engine import write_merge_state
873
874 write_merge_state(
875 repo,
876 base_commit="",
877 ours_commit=ours_cid,
878 theirs_commit=theirs_cid,
879 conflict_paths=["shared.py"],
880 other_branch="feat2",
881 )
882 return ours_cid, theirs_cid
883
884 def test_ours_no_merge_state_exits_1(self, repo: pathlib.Path) -> None:
885 result = _checkout(repo, "--ours", "file.py")
886 assert result.exit_code == 1
887
888 def test_theirs_no_merge_state_exits_1(self, repo: pathlib.Path) -> None:
889 result = _checkout(repo, "--theirs", "file.py")
890 assert result.exit_code == 1
891
892 def test_ours_and_theirs_both_exits_1(self, repo: pathlib.Path) -> None:
893 result = _checkout(repo, "--ours", "--theirs", "--all")
894 assert result.exit_code == 1
895
896 def test_ours_resolves_conflict(self, repo: pathlib.Path) -> None:
897 self._setup_merge_conflict(repo)
898 result = _checkout(repo, "--ours", "shared.py")
899 assert result.exit_code == 0
900
901 def test_theirs_resolves_conflict(self, repo: pathlib.Path) -> None:
902 self._setup_merge_conflict(repo)
903 result = _checkout(repo, "--theirs", "shared.py")
904 assert result.exit_code == 0
905
906 def test_resolve_all_ours_json(self, repo: pathlib.Path) -> None:
907 self._setup_merge_conflict(repo)
908 result = _checkout(repo, "--ours", "--all", "--json")
909 assert result.exit_code == 0
910 data = json.loads(result.output)
911 assert data["action"] == "conflict_resolved_all"
912 assert data["side"] == "ours"
913 assert "resolved_count" in data
914 assert "remaining_conflicts" in data
915
916 def test_resolve_all_theirs_json(self, repo: pathlib.Path) -> None:
917 self._setup_merge_conflict(repo)
918 result = _checkout(repo, "--theirs", "--all", "--json")
919 assert result.exit_code == 0
920 data = json.loads(result.output)
921 assert data["action"] == "conflict_resolved_all"
922 assert data["side"] == "theirs"
923
924 def test_resolve_single_file_json(self, repo: pathlib.Path) -> None:
925 self._setup_merge_conflict(repo)
926 result = _checkout(repo, "--ours", "shared.py", "--json")
927 assert result.exit_code == 0
928 data = json.loads(result.output)
929 assert data["action"] == "conflict_resolved"
930 assert data["file"] == "shared.py"
931 assert data["side"] == "ours"
932 assert "remaining_conflicts" in data
933
934 def test_resolve_all_empty_conflicts_exits_0(self, repo: pathlib.Path) -> None:
935 """--ours --all when no conflicts exist still exits 0."""
936 from muse.core.merge_engine import write_merge_state
937
938 ours = get_head_commit_id(repo, "main") or ""
939 write_merge_state(
940 repo,
941 base_commit="",
942 ours_commit=ours,
943 theirs_commit=ours,
944 conflict_paths=[],
945 other_branch="feat",
946 )
947 result = _checkout(repo, "--ours", "--all")
948 assert result.exit_code == 0
949
950 def test_resolve_nonexistent_path_exits_0(self, repo: pathlib.Path) -> None:
951 """A path not in the conflict list is informational, not an error."""
952 self._setup_merge_conflict(repo)
953 result = _checkout(repo, "--ours", "not_conflicted.py")
954 assert result.exit_code == 0
955
956 def test_missing_ours_theirs_without_all_exits_1(self, repo: pathlib.Path) -> None:
957 result = _checkout(repo, "--ours")
958 assert result.exit_code == 1
959
960
961 # ──────────────────────────────────────────────────────────────────────────────
962 # Stress
963 # ──────────────────────────────────────────────────────────────────────────────
964
965
966 @pytest.mark.slow
967 class TestStress:
968 def test_checkout_100_file_branch_fast(self, repo: pathlib.Path) -> None:
969 """Switching between branches with 100 modified files under 2s."""
970 for i in range(100):
971 (repo / f"f{i:03d}.py").write_text(f"x={i}\n")
972 _commit(repo, "-m", "big main")
973 _branch(repo, "big-alt")
974 _checkout(repo, "big-alt")
975 for i in range(100):
976 (repo / f"f{i:03d}.py").write_text(f"y={i}\n")
977 _commit(repo, "-m", "big alt")
978 _checkout(repo, "main")
979
980 t0 = time.perf_counter()
981 result = _checkout(repo, "big-alt")
982 elapsed = (time.perf_counter() - t0) * 1000
983 assert result.exit_code == 0
984 assert elapsed < 2000, f"checkout 100-file branch took {elapsed:.0f}ms (limit 2s)"
985
986 def test_dry_run_100_file_branch_fast(self, repo: pathlib.Path) -> None:
987 """dry-run on 100-file branch should be very fast (no restore)."""
988 for i in range(100):
989 (repo / f"g{i:03d}.py").write_text(f"x={i}\n")
990 _commit(repo, "-m", "big2")
991 _branch(repo, "big2-alt")
992
993 t0 = time.perf_counter()
994 result = _checkout(repo, "--dry-run", "big2-alt")
995 elapsed = (time.perf_counter() - t0) * 1000
996 assert result.exit_code == 0
997 assert elapsed < 500, f"dry-run took {elapsed:.0f}ms (limit 500ms)"
998
999 def test_concurrent_checkouts_separate_repos(self, tmp_path: pathlib.Path) -> None:
1000 """Multiple threads checking out branches in separate repos must not interfere."""
1001 errors: list[str] = []
1002
1003 def do_checkout(idx: int) -> None:
1004 repo_dir = tmp_path / f"repo_{idx}"
1005 repo_dir.mkdir()
1006 subprocess.run(["muse", "init"], cwd=str(repo_dir), capture_output=True)
1007 (repo_dir / "x.py").write_text(f"x={idx}\n")
1008 subprocess.run(
1009 ["muse", "commit", "-m", f"base{idx}"],
1010 cwd=str(repo_dir), capture_output=True,
1011 )
1012 subprocess.run(
1013 ["muse", "branch", "alt"], cwd=str(repo_dir), capture_output=True
1014 )
1015 subprocess.run(
1016 ["muse", "checkout", "alt"], cwd=str(repo_dir), capture_output=True
1017 )
1018 (repo_dir / "y.py").write_text(f"y={idx}\n")
1019 subprocess.run(
1020 ["muse", "commit", "-m", f"alt{idx}"],
1021 cwd=str(repo_dir), capture_output=True,
1022 )
1023 r = subprocess.run(
1024 ["muse", "checkout", "main", "--json"],
1025 cwd=str(repo_dir), capture_output=True, text=True,
1026 )
1027 if r.returncode != 0:
1028 errors.append(f"repo_{idx}: checkout failed")
1029 return
1030 data = json.loads(r.stdout)
1031 if data.get("action") != "switched":
1032 errors.append(f"repo_{idx}: expected switched, got {data.get('action')}")
1033
1034 threads = [threading.Thread(target=do_checkout, args=(i,)) for i in range(6)]
1035 for t in threads:
1036 t.start()
1037 for t in threads:
1038 t.join()
1039 assert not errors, f"Concurrent checkout errors:\n{'\n'.join(errors)}"
1040
1041 def test_repeated_back_and_forth_100_times(self, two_branch_repo: pathlib.Path) -> None:
1042 """Switching back and forth 100 times must not corrupt the working tree."""
1043 for i in range(50):
1044 r1 = _checkout(two_branch_repo, "feat")
1045 assert r1.exit_code == 0, f"Iteration {i}: switch to feat failed"
1046 assert (two_branch_repo / "feat.py").exists()
1047 r2 = _checkout(two_branch_repo, "main")
1048 assert r2.exit_code == 0, f"Iteration {i}: switch to main failed"
1049
1050
1051 # ──────────────────────────────────────────────────────────────────────────────
1052 # TestCheckoutMerge — muse checkout -m (Cohen Transform carry-forward)
1053 # ──────────────────────────────────────────────────────────────────────────────
1054
1055
1056 def _make_diverged_repo(tmp_path: pathlib.Path) -> pathlib.Path:
1057 """Repo with main and *other* branches that have diverged file content.
1058
1059 Layout after setup::
1060
1061 main: shared.py = "line1\\nline2\\nline3\\n" (committed)
1062 other: shared.py = "line1\\nLINE2\\nline3\\n" (committed — different line 2)
1063
1064 The caller is left on *main* with a dirty working tree.
1065 """
1066 saved = os.getcwd()
1067 try:
1068 os.chdir(tmp_path)
1069 runner.invoke(None, ["init"])
1070 finally:
1071 os.chdir(saved)
1072
1073 (tmp_path / "shared.py").write_text("line1\nline2\nline3\n")
1074 _commit(tmp_path, "-m", "initial")
1075
1076 # Create other branch with a different version of shared.py
1077 _branch(tmp_path, "other")
1078 _checkout(tmp_path, "other")
1079 (tmp_path / "shared.py").write_text("line1\nLINE2\nline3\n")
1080 _commit(tmp_path, "-m", "other changes line2")
1081
1082 # Back on main
1083 _checkout(tmp_path, "main")
1084 return tmp_path
1085
1086
1087 class TestCheckoutMergeParser:
1088 """Parser-level tests for the ``-m`` / ``--merge`` flag."""
1089
1090 def _parse(self, *args: str) -> "argparse.Namespace":
1091 import argparse
1092
1093 from muse.cli.commands.checkout import register
1094
1095 parser = argparse.ArgumentParser()
1096 sub = parser.add_subparsers()
1097 register(sub)
1098 return parser.parse_args(["checkout", *args])
1099
1100 def test_merge_short_flag_parsed(self) -> None:
1101 ns = self._parse("-m", "feat")
1102 assert ns.merge is True
1103
1104 def test_merge_long_flag_parsed(self) -> None:
1105 ns = self._parse("--merge", "feat")
1106 assert ns.merge is True
1107
1108 def test_merge_false_by_default(self) -> None:
1109 ns = self._parse("feat")
1110 assert ns.merge is False
1111
1112 def test_merge_and_dry_run_coexist(self) -> None:
1113 ns = self._parse("-m", "--dry-run", "feat")
1114 assert ns.merge is True
1115 assert ns.dry_run is True
1116
1117 def test_merge_and_json_coexist(self) -> None:
1118 ns = self._parse("-m", "--json", "feat")
1119 assert ns.merge is True
1120 assert ns.json_out is True
1121
1122
1123 class TestCheckoutMergeClean:
1124 """Clean-merge scenarios — no conflict markers should appear."""
1125
1126 def test_untracked_file_survives_checkout(self, repo: pathlib.Path) -> None:
1127 """Untracked files must not be disturbed by -m checkout."""
1128 _branch(repo, "feat")
1129 (repo / "untracked.txt").write_text("I am untracked\n")
1130 r = _checkout(repo, "-m", "feat")
1131 assert r.exit_code == 0
1132 assert (repo / "untracked.txt").read_text() == "I am untracked\n"
1133
1134 def test_ours_only_change_carried_cleanly(self, tmp_path: pathlib.Path) -> None:
1135 """When we modify a file that target branch left untouched, the change carries.
1136
1137 'clean' branch diverged by adding a brand-new file, leaving shared.py
1138 identical to main's HEAD. Our uncommitted change to shared.py has no
1139 competition from the target and must merge cleanly.
1140 """
1141 saved = os.getcwd()
1142 try:
1143 os.chdir(tmp_path)
1144 runner.invoke(None, ["init"])
1145 finally:
1146 os.chdir(saved)
1147 (tmp_path / "shared.py").write_text("line1\nline2\nline3\n")
1148 _commit(tmp_path, "-m", "initial")
1149
1150 # Create 'clean' branch — only adds a new file, does NOT touch shared.py
1151 _branch(tmp_path, "clean")
1152 _checkout(tmp_path, "clean")
1153 (tmp_path / "extra.py").write_text("# extra\n")
1154 _commit(tmp_path, "-m", "add extra.py")
1155 _checkout(tmp_path, "main")
1156
1157 # Dirty workdir on main: modify shared.py
1158 (tmp_path / "shared.py").write_text("line1\nline2\nLINE3\n")
1159 r = _checkout(tmp_path, "-m", "clean")
1160 assert r.exit_code == 0
1161 content = (tmp_path / "shared.py").read_text()
1162 assert "LINE3" in content, "Our uncommitted change must be in merged result"
1163
1164 def test_clean_merge_json_output(self, tmp_path: pathlib.Path) -> None:
1165 """JSON output reports clean_merges and empty conflicts on success."""
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("line1\nline2\nline3\n")
1173 _commit(tmp_path, "-m", "initial")
1174
1175 _branch(tmp_path, "clean")
1176 _checkout(tmp_path, "clean")
1177 (tmp_path / "extra.py").write_text("# extra\n")
1178 _commit(tmp_path, "-m", "add extra.py")
1179 _checkout(tmp_path, "main")
1180
1181 (tmp_path / "shared.py").write_text("line1\nline2\nLINE3\n")
1182 r = _checkout(tmp_path, "-m", "--json", "clean")
1183 assert r.exit_code == 0
1184 data = json.loads(r.output)
1185 assert data["action"] == "switched"
1186 assert data["branch"] == "clean"
1187 assert isinstance(data["clean_merges"], list)
1188 assert isinstance(data["conflicts"], list)
1189 assert len(data["conflicts"]) == 0
1190
1191 def test_switched_branch_recorded(self, tmp_path: pathlib.Path) -> None:
1192 """After a clean -m checkout the current branch is the target."""
1193 repo = _make_diverged_repo(tmp_path)
1194 (repo / "shared.py").write_text("line1\nline2\nLINE3\n")
1195 _checkout(repo, "-m", "other")
1196 assert read_current_branch(repo) == "other"
1197
1198 def test_no_dirty_files_succeeds_silently(self, repo: pathlib.Path) -> None:
1199 """If the working tree is clean, -m behaves like a normal checkout."""
1200 _branch(repo, "feat")
1201 r = _checkout(repo, "-m", "feat")
1202 assert r.exit_code == 0
1203 assert read_current_branch(repo) == "feat"
1204
1205 def test_dry_run_does_not_switch_branch(self, tmp_path: pathlib.Path) -> None:
1206 """-m --dry-run must not actually switch branches."""
1207 repo = _make_diverged_repo(tmp_path)
1208 (repo / "shared.py").write_text("line1\nline2\nLINE3\n")
1209 r = _checkout(repo, "-m", "--dry-run", "other")
1210 assert r.exit_code == 0
1211 assert read_current_branch(repo) == "main"
1212
1213 def test_dry_run_json_reports_dry_run_true(self, tmp_path: pathlib.Path) -> None:
1214 repo = _make_diverged_repo(tmp_path)
1215 (repo / "shared.py").write_text("line1\nline2\nLINE3\n")
1216 r = _checkout(repo, "-m", "--dry-run", "--json", "other")
1217 assert r.exit_code == 0
1218 data = json.loads(r.output)
1219 assert data["dry_run"] is True
1220 assert data["branch"] == "other"
1221
1222
1223 class TestCheckoutMergeConflict:
1224 """Conflict scenarios — conflict markers and MERGE_STATE must appear."""
1225
1226 def test_conflicting_change_exits_1(self, tmp_path: pathlib.Path) -> None:
1227 """Same line changed on both sides → conflict → exit code 1."""
1228 repo = _make_diverged_repo(tmp_path)
1229 # Also change line2 on main (uncommitted) — same line other branch changed
1230 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1231 r = _checkout(repo, "-m", "other")
1232 assert r.exit_code == 1
1233
1234 def test_conflict_markers_written_to_file(self, tmp_path: pathlib.Path) -> None:
1235 """Conflicting file must contain diff3-style conflict markers after -m."""
1236 repo = _make_diverged_repo(tmp_path)
1237 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1238 _checkout(repo, "-m", "other")
1239 content = (repo / "shared.py").read_text()
1240 assert "<<<<<<<" in content
1241 assert "=======" in content
1242 assert ">>>>>>>" in content
1243
1244 def test_conflict_markers_contain_cohen_action_labels(self, tmp_path: pathlib.Path) -> None:
1245 """Cohen-style action labels ([modified], [inserted], [deleted]) must appear."""
1246 repo = _make_diverged_repo(tmp_path)
1247 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1248 _checkout(repo, "-m", "other")
1249 content = (repo / "shared.py").read_text()
1250 # At least one action label must be present
1251 assert any(label in content for label in ("[modified]", "[inserted]", "[deleted]"))
1252
1253 def test_merge_state_written_on_conflict(self, tmp_path: pathlib.Path) -> None:
1254 """MERGE_STATE.json must exist after a conflicting -m checkout."""
1255 repo = _make_diverged_repo(tmp_path)
1256 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1257 _checkout(repo, "-m", "other")
1258 merge_state_file = merge_state_path(repo)
1259 assert merge_state_file.exists()
1260
1261 def test_merge_state_lists_conflict_path(self, tmp_path: pathlib.Path) -> None:
1262 """MERGE_STATE.json must name the conflicting path."""
1263 repo = _make_diverged_repo(tmp_path)
1264 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1265 _checkout(repo, "-m", "other")
1266 data = json.loads((merge_state_path(repo)).read_text())
1267 assert "shared.py" in data.get("conflict_paths", [])
1268
1269 def test_conflict_json_output_contains_conflicts_list(self, tmp_path: pathlib.Path) -> None:
1270 """JSON output must list the conflict paths even on exit code 1."""
1271 repo = _make_diverged_repo(tmp_path)
1272 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1273 r = _checkout(repo, "-m", "--json", "other")
1274 data = json.loads(r.output)
1275 assert "conflicts" in data
1276 assert len(data["conflicts"]) >= 1
1277
1278 def test_branch_is_switched_despite_conflict(self, tmp_path: pathlib.Path) -> None:
1279 """Even when conflicts exist, we are on the target branch after -m."""
1280 repo = _make_diverged_repo(tmp_path)
1281 (repo / "shared.py").write_text("line1\nOURS_LINE2\nline3\n")
1282 _checkout(repo, "-m", "other")
1283 assert read_current_branch(repo) == "other"
1284
1285
1286 class TestCheckoutMergeErrors:
1287 """Error cases for -m flag."""
1288
1289 def test_merge_with_create_flag_ignored(self, repo: pathlib.Path) -> None:
1290 """``-m -b new-branch`` must NOT invoke _checkout_with_merge (target doesn't exist)."""
1291 # -m with -b falls through to regular create logic; no conflict-carry
1292 r = _checkout(repo, "-m", "-b", "new-branch")
1293 # Should succeed as a regular branch creation (no carry logic for new branches)
1294 assert r.exit_code == 0
1295
1296 def test_merge_missing_branch_errors(self, repo: pathlib.Path) -> None:
1297 """``-m nonexistent`` must exit 1 with an error message."""
1298 r = _checkout(repo, "-m", "nonexistent")
1299 assert r.exit_code == 1
1300
1301 def test_merge_already_on_branch_is_noop(self, repo: pathlib.Path) -> None:
1302 """``-m main`` when already on main must print 'Already on' and exit 0."""
1303 r = _checkout(repo, "-m", "main")
1304 assert r.exit_code == 0
1305 assert "Already on" in r.output or "already" in r.output.lower()
1306
1307 def test_merge_with_invalid_branch_name_errors(self, repo: pathlib.Path) -> None:
1308 """``-m ..invalid`` must exit 1 before attempting any merge."""
1309 r = _checkout(repo, "-m", "../bad/name")
1310 assert r.exit_code == 1
1311
1312
1313 # ──────────────────────────────────────────────────────────────────────────────
1314 # Non-conflicting carry-forward: checkout <branch> with dirty files that the
1315 # target branch does not touch should succeed without --merge or --autoshelf.
1316 #
1317 # This is the core ergonomics fix: an agent editing app.py on dev, then doing
1318 # checkout always refuses a dirty tracked file regardless of whether the target
1319 # branch shares the same committed version — see TestDirtyWorkdirBleedThrough.
1320 # Untracked files are never blocked (they are not in any snapshot).
1321 # ──────────────────────────────────────────────────────────────────────────────
1322
1323
1324 class TestSwitchWithNonConflictingChanges:
1325 @pytest.fixture()
1326 def two_branch_clean(self, tmp_path: pathlib.Path) -> pathlib.Path:
1327 """Repo with main and feat; feat adds a NEW file, leaves shared.py alone."""
1328 saved = os.getcwd()
1329 try:
1330 os.chdir(tmp_path)
1331 runner.invoke(None, ["init"])
1332 finally:
1333 os.chdir(saved)
1334 (tmp_path / "shared.py").write_text("x = 1\n")
1335 _commit(tmp_path, "-m", "initial")
1336 # Create feat branch with an extra file — shared.py is untouched
1337 _checkout(tmp_path, "-b", "feat")
1338 (tmp_path / "feat_only.py").write_text("f = 1\n")
1339 _commit(tmp_path, "-m", "feat commit")
1340 _checkout(tmp_path, "main")
1341 return tmp_path
1342
1343 def test_switch_still_blocks_on_true_conflict(
1344 self, tmp_path: pathlib.Path
1345 ) -> None:
1346 """When the target branch has a different version of a modified file, block."""
1347 saved = os.getcwd()
1348 try:
1349 os.chdir(tmp_path)
1350 runner.invoke(None, ["init"])
1351 finally:
1352 os.chdir(saved)
1353 (tmp_path / "shared.py").write_text("x = 1\n")
1354 _commit(tmp_path, "-m", "initial")
1355 # feat branch changes shared.py
1356 _checkout(tmp_path, "-b", "feat")
1357 (tmp_path / "shared.py").write_text("x = feat\n")
1358 _commit(tmp_path, "-m", "feat changes shared")
1359 _checkout(tmp_path, "main")
1360 # Now dirty shared.py locally — feat has a different version → must block
1361 (tmp_path / "shared.py").write_text("x = local\n")
1362 result = _checkout(tmp_path, "feat")
1363 assert result.exit_code != 0
1364
1365 def test_switch_new_file_carries_through(
1366 self, two_branch_clean: pathlib.Path
1367 ) -> None:
1368 """Brand-new untracked files always carry through (unchanged behaviour)."""
1369 repo = two_branch_clean
1370 (repo / "new_file.py").write_text("new = True\n")
1371 result = _checkout(repo, "feat")
1372 assert result.exit_code == 0
1373 assert (repo / "new_file.py").exists()
1374
1375
1376 # ──────────────────────────────────────────────────────────────────────────────
1377 # Agent supercharge — duration_ms and exit_code in every JSON output
1378 # ──────────────────────────────────────────────────────────────────────────────
1379
1380
1381 class TestElapsed:
1382 """Every JSON output path must include an ``duration_ms`` float."""
1383
1384 def test_switch_json_has_elapsed(self, two_branch_repo: pathlib.Path) -> None:
1385 result = _checkout(two_branch_repo, "feat", "--json")
1386 data = json.loads(result.output)
1387 assert "duration_ms" in data
1388 assert isinstance(data["duration_ms"], float)
1389
1390 def test_create_json_has_elapsed(self, repo: pathlib.Path) -> None:
1391 result = _checkout(repo, "-b", "elapsed-branch", "--json")
1392 data = json.loads(result.output)
1393 assert "duration_ms" in data
1394 assert isinstance(data["duration_ms"], float)
1395
1396 def test_already_on_json_has_elapsed(self, repo: pathlib.Path) -> None:
1397 result = _checkout(repo, "main", "--json")
1398 data = json.loads(result.output)
1399 assert "duration_ms" in data
1400 assert isinstance(data["duration_ms"], float)
1401
1402 def test_detach_json_has_elapsed(self, repo: pathlib.Path) -> None:
1403 sha = get_head_commit_id(repo, "main")
1404 assert sha is not None
1405 result = _checkout(repo, sha, "--json")
1406 data = json.loads(result.output)
1407 assert "duration_ms" in data
1408 assert isinstance(data["duration_ms"], float)
1409
1410 def test_dry_run_switch_json_has_elapsed(self, two_branch_repo: pathlib.Path) -> None:
1411 result = _checkout(two_branch_repo, "--dry-run", "feat", "--json")
1412 data = json.loads(result.output)
1413 assert "duration_ms" in data
1414
1415 def test_dry_run_create_json_has_elapsed(self, repo: pathlib.Path) -> None:
1416 result = _checkout(repo, "-b", "dry-elapsed", "--dry-run", "--json")
1417 data = json.loads(result.output)
1418 assert "duration_ms" in data
1419
1420 def test_dry_run_detach_json_has_elapsed(self, repo: pathlib.Path) -> None:
1421 sha = get_head_commit_id(repo, "main")
1422 assert sha is not None
1423 result = _checkout(repo, "--dry-run", sha, "--json")
1424 data = json.loads(result.output)
1425 assert "duration_ms" in data
1426
1427 def test_restored_json_has_elapsed(self, repo: pathlib.Path) -> None:
1428 result = _checkout(repo, "--force", "main", "--json")
1429 data = json.loads(result.output)
1430 assert "duration_ms" in data
1431
1432 def test_conflict_resolved_all_json_has_elapsed(self, repo: pathlib.Path) -> None:
1433 from muse.core.merge_engine import write_merge_state
1434
1435 ours = get_head_commit_id(repo, "main") or ""
1436 write_merge_state(
1437 repo,
1438 base_commit="",
1439 ours_commit=ours,
1440 theirs_commit=ours,
1441 conflict_paths=["a.py"],
1442 other_branch="feat",
1443 )
1444 result = _checkout(repo, "--ours", "--all", "--json")
1445 data = json.loads(result.output)
1446 assert "duration_ms" in data
1447
1448 def test_conflict_resolved_single_json_has_elapsed(self, repo: pathlib.Path) -> None:
1449 from muse.core.merge_engine import write_merge_state
1450 from muse.core.refs import get_head_commit_id as _gci
1451
1452 ours = _gci(repo, "main") or ""
1453 write_merge_state(
1454 repo,
1455 base_commit="",
1456 ours_commit=ours,
1457 theirs_commit=ours,
1458 conflict_paths=["a.py"],
1459 other_branch="feat",
1460 )
1461 result = _checkout(repo, "--ours", "a.py", "--json")
1462 data = json.loads(result.output)
1463 assert "duration_ms" in data
1464
1465
1466 class TestExitCode:
1467 """Every successful JSON output path must include ``exit_code: 0``."""
1468
1469 def test_switch_json_exit_code_0(self, two_branch_repo: pathlib.Path) -> None:
1470 result = _checkout(two_branch_repo, "feat", "--json")
1471 data = json.loads(result.output)
1472 assert data["exit_code"] == 0
1473
1474 def test_create_json_exit_code_0(self, repo: pathlib.Path) -> None:
1475 result = _checkout(repo, "-b", "ec-branch", "--json")
1476 data = json.loads(result.output)
1477 assert data["exit_code"] == 0
1478
1479 def test_already_on_json_exit_code_0(self, repo: pathlib.Path) -> None:
1480 result = _checkout(repo, "main", "--json")
1481 data = json.loads(result.output)
1482 assert data["exit_code"] == 0
1483
1484 def test_detach_json_exit_code_0(self, repo: pathlib.Path) -> None:
1485 sha = get_head_commit_id(repo, "main")
1486 assert sha is not None
1487 result = _checkout(repo, sha, "--json")
1488 data = json.loads(result.output)
1489 assert data["exit_code"] == 0
1490
1491 def test_dry_run_switch_json_exit_code_0(self, two_branch_repo: pathlib.Path) -> None:
1492 result = _checkout(two_branch_repo, "--dry-run", "feat", "--json")
1493 data = json.loads(result.output)
1494 assert data["exit_code"] == 0
1495
1496 def test_dry_run_create_json_exit_code_0(self, repo: pathlib.Path) -> None:
1497 result = _checkout(repo, "-b", "dry-ec", "--dry-run", "--json")
1498 data = json.loads(result.output)
1499 assert data["exit_code"] == 0
1500
1501 def test_dry_run_detach_json_exit_code_0(self, repo: pathlib.Path) -> None:
1502 sha = get_head_commit_id(repo, "main")
1503 assert sha is not None
1504 result = _checkout(repo, "--dry-run", sha, "--json")
1505 data = json.loads(result.output)
1506 assert data["exit_code"] == 0
1507
1508 def test_restored_json_exit_code_0(self, repo: pathlib.Path) -> None:
1509 result = _checkout(repo, "--force", "main", "--json")
1510 data = json.loads(result.output)
1511 assert data["exit_code"] == 0
1512
1513 def test_conflict_resolved_all_json_exit_code_0(self, repo: pathlib.Path) -> None:
1514 from muse.core.merge_engine import write_merge_state
1515
1516 ours = get_head_commit_id(repo, "main") or ""
1517 write_merge_state(
1518 repo,
1519 base_commit="",
1520 ours_commit=ours,
1521 theirs_commit=ours,
1522 conflict_paths=["a.py"],
1523 other_branch="feat",
1524 )
1525 result = _checkout(repo, "--ours", "--all", "--json")
1526 data = json.loads(result.output)
1527 assert data["exit_code"] == 0
1528
1529 def test_conflict_resolved_single_json_exit_code_0(self, repo: pathlib.Path) -> None:
1530 from muse.core.merge_engine import write_merge_state
1531 from muse.core.refs import get_head_commit_id as _gci
1532
1533 ours = _gci(repo, "main") or ""
1534 write_merge_state(
1535 repo,
1536 base_commit="",
1537 ours_commit=ours,
1538 theirs_commit=ours,
1539 conflict_paths=["a.py"],
1540 other_branch="feat",
1541 )
1542 result = _checkout(repo, "--ours", "a.py", "--json")
1543 data = json.loads(result.output)
1544 assert data["exit_code"] == 0
1545
1546
1547 class TestJsonSchemaComplete:
1548 """``duration_ms`` and ``exit_code`` must be in ``REQUIRED_KEYS``."""
1549
1550 REQUIRED_KEYS = {
1551 "action", "branch", "commit_id", "from_branch", "dry_run",
1552 "duration_ms", "exit_code",
1553 }
1554
1555 def test_switch_has_complete_schema(self, two_branch_repo: pathlib.Path) -> None:
1556 result = _checkout(two_branch_repo, "feat", "--json")
1557 data = json.loads(result.output)
1558 missing = self.REQUIRED_KEYS - set(data)
1559 assert not missing, f"Missing keys in 'switched' JSON: {missing}"
1560
1561 def test_create_has_complete_schema(self, repo: pathlib.Path) -> None:
1562 result = _checkout(repo, "-b", "schema-branch", "--json")
1563 data = json.loads(result.output)
1564 missing = self.REQUIRED_KEYS - set(data)
1565 assert not missing, f"Missing keys in 'created' JSON: {missing}"
1566
1567 def test_detach_has_complete_schema(self, repo: pathlib.Path) -> None:
1568 sha = get_head_commit_id(repo, "main")
1569 assert sha is not None
1570 result = _checkout(repo, sha, "--json")
1571 data = json.loads(result.output)
1572 missing = self.REQUIRED_KEYS - set(data)
1573 assert not missing, f"Missing keys in 'detached' JSON: {missing}"
1574
1575 def test_already_on_has_complete_schema(self, repo: pathlib.Path) -> None:
1576 result = _checkout(repo, "main", "--json")
1577 data = json.loads(result.output)
1578 missing = self.REQUIRED_KEYS - set(data)
1579 assert not missing, f"Missing keys in 'already_on' JSON: {missing}"
1580
1581
1582 # ──────────────────────────────────────────────────────────────────────────────
1583 # checkout -b --intent / --resumable
1584 # ──────────────────────────────────────────────────────────────────────────────
1585
1586
1587 class TestCheckoutCreateWithMeta:
1588 """``muse checkout -b <name> --intent <text> --resumable`` stores metadata."""
1589
1590 # ── Parser ────────────────────────────────────────────────────────────────
1591
1592 def _parse(self, *args: str) -> "argparse.Namespace":
1593 import argparse
1594 from muse.cli.commands.checkout import register
1595 p = argparse.ArgumentParser()
1596 sub = p.add_subparsers()
1597 register(sub)
1598 return p.parse_args(["checkout", *args])
1599
1600 def test_intent_flag_parsed(self) -> None:
1601 ns = self._parse("-b", "task/x", "--intent", "do the thing")
1602 assert ns.intent == "do the thing"
1603
1604 def test_resumable_flag_parsed(self) -> None:
1605 ns = self._parse("-b", "task/x", "--resumable")
1606 assert ns.resumable is True
1607
1608 def test_intent_default_none(self) -> None:
1609 ns = self._parse("main")
1610 assert ns.intent is None
1611
1612 def test_resumable_default_false(self) -> None:
1613 ns = self._parse("main")
1614 assert ns.resumable is False
1615
1616 def test_intent_without_create_is_error(self, repo: pathlib.Path) -> None:
1617 """--intent without -b should be rejected."""
1618 result = _checkout(repo, "main", "--intent", "oops")
1619 assert result.exit_code != 0
1620
1621 def test_resumable_without_create_is_error(self, repo: pathlib.Path) -> None:
1622 """--resumable without -b should be rejected."""
1623 result = _checkout(repo, "main", "--resumable")
1624 assert result.exit_code != 0
1625
1626 # ── Integration: intent stored ─────────────────────────────────────────
1627
1628 def test_intent_stored_in_branch_meta(self, repo: pathlib.Path) -> None:
1629 result = _checkout(repo, "-b", "task/work", "--intent", "implement auth")
1630 assert result.exit_code == 0
1631 meta = read_branch_meta(repo, "task/work")
1632 assert meta.get("intent") == "implement auth"
1633
1634 def test_resumable_stored_in_branch_meta(self, repo: pathlib.Path) -> None:
1635 result = _checkout(repo, "-b", "task/work", "--resumable")
1636 assert result.exit_code == 0
1637 meta = read_branch_meta(repo, "task/work")
1638 assert meta.get("resumable") is True
1639
1640 def test_intent_and_resumable_together(self, repo: pathlib.Path) -> None:
1641 result = _checkout(
1642 repo, "-b", "task/work", "--intent", "add feature", "--resumable"
1643 )
1644 assert result.exit_code == 0
1645 meta = read_branch_meta(repo, "task/work")
1646 assert meta.get("intent") == "add feature"
1647 assert meta.get("resumable") is True
1648
1649 def test_branch_switched_after_create_with_meta(self, repo: pathlib.Path) -> None:
1650 _checkout(repo, "-b", "task/work", "--intent", "x", "--resumable")
1651 assert read_current_branch(repo) == "task/work"
1652
1653 def test_no_metadata_when_flags_absent(self, repo: pathlib.Path) -> None:
1654 _checkout(repo, "-b", "task/plain")
1655 meta = read_branch_meta(repo, "task/plain")
1656 assert meta.get("intent") is None
1657 assert not meta.get("resumable")
1658
1659 def test_intent_only_no_resumable_set(self, repo: pathlib.Path) -> None:
1660 _checkout(repo, "-b", "task/work", "--intent", "just intent")
1661 meta = read_branch_meta(repo, "task/work")
1662 assert meta.get("intent") == "just intent"
1663 assert not meta.get("resumable")
1664
1665 def test_resumable_only_no_intent_set(self, repo: pathlib.Path) -> None:
1666 _checkout(repo, "-b", "task/work", "--resumable")
1667 meta = read_branch_meta(repo, "task/work")
1668 assert meta.get("resumable") is True
1669 assert meta.get("intent") is None
1670
1671 # ── JSON output still correct ──────────────────────────────────────────
1672
1673 def test_json_action_is_created(self, repo: pathlib.Path) -> None:
1674 result = _checkout(
1675 repo, "-b", "task/work", "--intent", "x", "--resumable", "--json"
1676 )
1677 assert result.exit_code == 0
1678 data = json.loads(result.output)
1679 assert data["action"] == "created"
1680
1681 def test_json_branch_name_correct(self, repo: pathlib.Path) -> None:
1682 result = _checkout(
1683 repo, "-b", "task/work", "--intent", "x", "--json"
1684 )
1685 data = json.loads(result.output)
1686 assert data["branch"] == "task/work"
1687
1688 # ── branch --json listing reflects metadata ────────────────────────────
1689
1690 def test_branch_list_json_shows_intent(self, repo: pathlib.Path) -> None:
1691 _checkout(repo, "-b", "task/work", "--intent", "my intent")
1692 result = _invoke(repo, ["branch", "--json"])
1693 branches = json.loads(result.output)
1694 entry = next(b for b in branches if b["name"] == "task/work")
1695 assert entry.get("intent") == "my intent"
1696
1697 def test_branch_list_json_shows_resumable(self, repo: pathlib.Path) -> None:
1698 _checkout(repo, "-b", "task/work", "--resumable")
1699 result = _invoke(repo, ["branch", "--json"])
1700 branches = json.loads(result.output)
1701 entry = next(b for b in branches if b["name"] == "task/work")
1702 assert entry.get("resumable") is True
1703
1704 def test_resumable_filter_finds_branch(self, repo: pathlib.Path) -> None:
1705 _checkout(repo, "-b", "task/work", "--resumable")
1706 _checkout(repo, "main")
1707 _checkout(repo, "-b", "task/plain")
1708 result = _invoke(repo, ["branch", "--resumable", "--json"])
1709 names = [b["name"] for b in json.loads(result.output)]
1710 assert "task/work" in names
1711 assert "task/plain" not in names
1712
1713 # ── Security: ANSI in intent ───────────────────────────────────────────
1714
1715 def test_ansi_in_intent_sanitized_in_text_output(self, repo: pathlib.Path) -> None:
1716 malicious = "\x1b[31mmalicious\x1b[0m"
1717 result = _checkout(repo, "-b", "task/work", "--intent", malicious)
1718 assert result.exit_code == 0
1719 assert "\x1b" not in result.output
1720
1721
1722 # ---------------------------------------------------------------------------
1723 # Flag registration tests
1724 # ---------------------------------------------------------------------------
1725
1726 import argparse as _argparse
1727 from muse.cli.commands.checkout import register as _register_checkout
1728 from muse.core.paths import merge_state_path
1729
1730
1731 def _parse_checkout(*args: str) -> _argparse.Namespace:
1732 """Build an argument parser via register() and parse args."""
1733 root_p = _argparse.ArgumentParser()
1734 subs = root_p.add_subparsers(dest="cmd")
1735 _register_checkout(subs)
1736 return root_p.parse_args(["checkout", *args])
1737
1738
1739 class TestRegisterFlags:
1740 def test_default_json_out_is_false(self) -> None:
1741 ns = _parse_checkout("dev")
1742 assert ns.json_out is False
1743
1744 def test_json_flag_sets_json_out(self) -> None:
1745 ns = _parse_checkout("dev", "--json")
1746 assert ns.json_out is True
1747
1748 def test_j_shorthand_sets_json_out(self) -> None:
1749 ns = _parse_checkout("dev", "-j")
1750 assert ns.json_out is True
1751
1752 def test_create_branch_flag(self) -> None:
1753 ns = _parse_checkout("-b", "task/foo")
1754 assert ns.create is True
1755
1756 def test_force_flag(self) -> None:
1757 ns = _parse_checkout("dev", "--force")
1758 assert ns.force is True
1759
1760 def test_f_shorthand_for_force(self) -> None:
1761 ns = _parse_checkout("dev", "-f")
1762 assert ns.force is True
1763
1764 def test_dry_run_flag(self) -> None:
1765 ns = _parse_checkout("dev", "--dry-run")
1766 assert ns.dry_run is True
1767
1768 def test_n_shorthand_for_dry_run(self) -> None:
1769 ns = _parse_checkout("dev", "-n")
1770 assert ns.dry_run is True
1771
1772
1773 # ──────────────────────────────────────────────────────────────────────────────
1774 # Regression — autoshelf must NOT bleed committed task-branch changes back
1775 # ──────────────────────────────────────────────────────────────────────────────
1776
1777
1778 @pytest.fixture()
1779 def task_branch_repo(tmp_path: pathlib.Path) -> pathlib.Path:
1780 """Repo that reproduces the autoshelf phantom-modification bug.
1781
1782 Layout
1783 ------
1784 main
1785 committed.py = "base\n"
1786 dirty.py = "base\n"
1787
1788 task (branched from main)
1789 committed.py = "task-committed\n" ← committed on task branch
1790 dirty.py = "dirty-edit\n" ← NOT committed (working-tree only)
1791
1792 This is the exact shape that triggered the bug: after
1793 ``muse checkout main --autoshelf``, committed.py was appearing as
1794 "modified" on main even though no one edited it there.
1795 """
1796 saved = os.getcwd()
1797 try:
1798 os.chdir(tmp_path)
1799 runner.invoke(None, ["init"])
1800 finally:
1801 os.chdir(saved)
1802
1803 (tmp_path / "committed.py").write_text("base\n")
1804 (tmp_path / "dirty.py").write_text("base\n")
1805 _commit(tmp_path, "-m", "initial")
1806
1807 _checkout(tmp_path, "-b", "task")
1808 (tmp_path / "committed.py").write_text("task-committed\n")
1809 _commit(tmp_path, "-m", "task: update committed.py")
1810
1811 # Leave dirty.py modified but NOT committed — this is the legitimate dirt.
1812 (tmp_path / "dirty.py").write_text("dirty-edit\n")
1813
1814 return tmp_path
1815
1816
1817 class TestAutoshelfDirtyIsolation:
1818 """autoshelf must only restore files that were actually dirty (unstaged/uncommitted).
1819
1820 Regression: the old implementation saved the full working-tree snapshot
1821 and applied it verbatim on the target branch. Committed-on-source changes
1822 bled into the target working tree as phantom modifications, causing merge
1823 to refuse with "your local changes would be overwritten".
1824
1825 The invariant: after ``checkout <target> --autoshelf``, only files that
1826 were genuinely uncommitted on the source branch should appear as modified
1827 on the target. Files that were committed on the source (but not yet on the
1828 target) are the merge's job — not the shelf's.
1829 """
1830
1831 def test_committed_source_file_not_in_working_tree_after_autoshelf(
1832 self,
1833 task_branch_repo: pathlib.Path,
1834 ) -> None:
1835 """committed.py was committed on task — it must NOT appear modified on main."""
1836 result = _checkout(task_branch_repo, "main", "--autoshelf")
1837 assert result.exit_code == 0, result.stderr
1838 assert read_current_branch(task_branch_repo) == "main"
1839
1840 content = (task_branch_repo / "committed.py").read_text()
1841 assert content == "base\n", (
1842 "committed.py must reflect main HEAD after autoshelf — "
1843 f"got {content!r}, expected 'base\\n'. "
1844 "The task-branch committed version must NOT be restored by the shelf."
1845 )
1846
1847 def test_dirty_file_is_restored_after_autoshelf(
1848 self,
1849 task_branch_repo: pathlib.Path,
1850 ) -> None:
1851 """dirty.py was uncommitted on task — it MUST be restored on main."""
1852 _checkout(task_branch_repo, "main", "--autoshelf")
1853
1854 content = (task_branch_repo / "dirty.py").read_text()
1855 assert content == "dirty-edit\n", (
1856 "dirty.py (uncommitted working-tree change) must survive the autoshelf "
1857 f"round-trip — got {content!r}, expected 'dirty-edit\\n'."
1858 )
1859
1860 def test_working_tree_clean_except_dirty_file_after_autoshelf(
1861 self,
1862 task_branch_repo: pathlib.Path,
1863 ) -> None:
1864 """After autoshelf to main, only dirty.py should be modified — nothing else."""
1865 _checkout(task_branch_repo, "main", "--autoshelf")
1866
1867 result = _invoke(task_branch_repo, ["status", "--json"])
1868 assert result.exit_code == 0, result.stderr
1869 data = json.loads(result.output)
1870
1871 modified = set(data.get("modified", []))
1872 assert modified == {"dirty.py"}, (
1873 f"Only dirty.py should be modified after autoshelf — got {modified}. "
1874 "committed.py is a phantom: it was committed on task, belongs to the "
1875 "merge, and must not bleed into the working tree via the shelf."
1876 )
File History 3 commits
sha256:f6cd81bc71702f5c1c6890bd39aaba994fe58c75f019d7c03934724fa2739bb4 fix: carry dev changes harmony dropped in merge — detached … Sonnet 4.6 minor 5 days ago
sha256:45e1291ec44e0e86fe353b0b55306b2689a7f6ffa39bafb4bd2782b5be1c9cb8 fix: checkout from detached HEAD state raises ValueError Sonnet 4.6 minor 6 days ago