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