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