gabriel / muse public
test_cmd_worktree_hardening.py python
2,540 lines 109.5 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Comprehensive hardening tests for ``muse worktree``.
2
3 Covers:
4 - Unit: _load_meta symlink/size guards, _safe_delete_path, _WORKTREES_META_DIR constant,
5 get_worktree_status, prune_worktrees dry_run
6 - Security: symlink meta file, oversized meta, path-in-JSON pointing to .muse/,
7 path-in-JSON pointing to symlink, ANSI injection, error routing to stderr
8 - JSON schema: all subcommands (add, list, status, remove, prune)
9 - Integration: -b/--create-branch, --path override, prune dry-run, status subcommand
10 - E2E: text output paths still correct, worktree lifecycle
11 - Stress: 50 worktrees, concurrent list reads
12 """
13 from __future__ import annotations
14
15 import json
16 import pathlib
17 import re
18 import threading
19 from typing import TypedDict
20
21 import pytest
22
23 from tests.cli_test_helper import CliRunner, InvokeResult
24
25 runner = CliRunner()
26
27 _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
28
29
30 # ---------------------------------------------------------------------------
31 # Repo helpers
32 # ---------------------------------------------------------------------------
33
34
35 def _make_repo(tmp_path: pathlib.Path, branch: str = "main") -> pathlib.Path:
36 """Create a minimal Muse repo with a named branch."""
37 repo = tmp_path / "myproject"
38 muse = repo / ".muse"
39 for d in ("objects", "commits", "snapshots", "refs/heads"):
40 (muse / d).mkdir(parents=True, exist_ok=True)
41 (muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo"}))
42 (muse / "HEAD").write_text(f"ref: refs/heads/{branch}\n")
43 (muse / "refs" / "heads" / branch).write_text("0" * 64)
44 return repo
45
46
47 def _add_branch(repo: pathlib.Path, branch: str) -> None:
48 ref = repo / ".muse" / "refs" / "heads" / branch
49 ref.parent.mkdir(parents=True, exist_ok=True)
50 ref.write_text("0" * 64)
51
52
53 def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult:
54 return runner.invoke(None, args, env={"MUSE_REPO_ROOT": str(repo)})
55
56
57 def _json_blob(output: str) -> str:
58 for line in output.splitlines():
59 stripped = line.strip()
60 if stripped.startswith(("{", "[")):
61 return stripped
62 return output.strip()
63
64
65 # ---------------------------------------------------------------------------
66 # Typed schema helpers
67 # ---------------------------------------------------------------------------
68
69
70 class _AddJson(TypedDict):
71 name: str
72 branch: str
73 path: str
74 head_commit: str | None
75
76
77 class _ListEntryJson(TypedDict):
78 name: str
79 branch: str
80 path: str
81 head_commit: str | None
82 is_main: bool
83
84
85 class _StatusJson(TypedDict):
86 name: str
87 branch: str
88 path: str
89 head_commit: str | None
90 present: bool
91 is_main: bool
92
93
94 class _RemoveJson(TypedDict):
95 name: str
96 status: str
97
98
99 class _PruneJson(TypedDict):
100 pruned: list[str]
101 count: int
102 dry_run: bool
103
104
105 def _parse_add(output: str) -> _AddJson:
106 raw = json.loads(_json_blob(output))
107 assert isinstance(raw, dict)
108 hc = raw.get("head_commit")
109 return _AddJson(
110 name=str(raw["name"]),
111 branch=str(raw["branch"]),
112 path=str(raw["path"]),
113 head_commit=str(hc) if hc is not None else None,
114 )
115
116
117 def _parse_list(output: str) -> list[_ListEntryJson]:
118 raw = json.loads(_json_blob(output))
119 assert isinstance(raw, list)
120 result: list[_ListEntryJson] = []
121 for item in raw:
122 assert isinstance(item, dict)
123 hc = item["head_commit"]
124 assert hc is None or isinstance(hc, str)
125 result.append(_ListEntryJson(
126 name=str(item["name"]),
127 branch=str(item["branch"]),
128 path=str(item["path"]),
129 head_commit=hc,
130 is_main=bool(item["is_main"]),
131 ))
132 return result
133
134
135 def _parse_status(output: str) -> _StatusJson:
136 raw = json.loads(_json_blob(output))
137 assert isinstance(raw, dict)
138 hc = raw["head_commit"]
139 assert hc is None or isinstance(hc, str)
140 return _StatusJson(
141 name=str(raw["name"]),
142 branch=str(raw["branch"]),
143 path=str(raw["path"]),
144 head_commit=hc,
145 present=bool(raw["present"]),
146 is_main=bool(raw["is_main"]),
147 )
148
149
150 def _parse_remove(output: str) -> _RemoveJson:
151 raw = json.loads(_json_blob(output))
152 assert isinstance(raw, dict)
153 return _RemoveJson(name=str(raw["name"]), status=str(raw["status"]))
154
155
156 def _parse_prune(output: str) -> _PruneJson:
157 raw = json.loads(_json_blob(output))
158 assert isinstance(raw, dict)
159 pruned_val = raw["pruned"]
160 assert isinstance(pruned_val, list)
161 count_val = raw["count"]
162 assert isinstance(count_val, int)
163 dry_run_val = raw["dry_run"]
164 assert isinstance(dry_run_val, bool)
165 return _PruneJson(
166 pruned=[str(x) for x in pruned_val],
167 count=count_val,
168 dry_run=dry_run_val,
169 )
170
171
172 # ---------------------------------------------------------------------------
173 # Unit — _load_meta security guards
174 # ---------------------------------------------------------------------------
175
176
177 class TestLoadMetaSecurity:
178 def test_symlink_meta_file_rejected(self, tmp_path: pathlib.Path) -> None:
179 from muse.core.worktree import _load_meta, _worktree_meta_path
180
181 repo = _make_repo(tmp_path)
182 name = "my-wt"
183 # Create a symlink where the meta file should be.
184 meta_path = _worktree_meta_path(repo, name)
185 meta_path.parent.mkdir(parents=True, exist_ok=True)
186 real_file = tmp_path / "real_meta.json"
187 real_file.write_text(json.dumps({"name": name, "branch": "main", "path": str(tmp_path / "wt")}))
188 meta_path.symlink_to(real_file)
189 result = _load_meta(repo, name)
190 assert result is None
191
192 def test_oversized_meta_file_rejected(self, tmp_path: pathlib.Path) -> None:
193 from muse.core.worktree import _MAX_META_BYTES, _load_meta, _worktree_meta_path
194
195 repo = _make_repo(tmp_path)
196 name = "big-wt"
197 meta_path = _worktree_meta_path(repo, name)
198 meta_path.parent.mkdir(parents=True, exist_ok=True)
199 meta_path.write_text("x" * (_MAX_META_BYTES + 1))
200 result = _load_meta(repo, name)
201 assert result is None
202
203 def test_corrupt_json_returns_none(self, tmp_path: pathlib.Path) -> None:
204 from muse.core.worktree import _load_meta, _worktree_meta_path
205
206 repo = _make_repo(tmp_path)
207 name = "bad-wt"
208 meta_path = _worktree_meta_path(repo, name)
209 meta_path.parent.mkdir(parents=True, exist_ok=True)
210 meta_path.write_text("not valid json !!!{{{")
211 result = _load_meta(repo, name)
212 assert result is None
213
214 def test_missing_key_returns_none(self, tmp_path: pathlib.Path) -> None:
215 from muse.core.worktree import _load_meta, _worktree_meta_path
216
217 repo = _make_repo(tmp_path)
218 name = "missing-key-wt"
219 meta_path = _worktree_meta_path(repo, name)
220 meta_path.parent.mkdir(parents=True, exist_ok=True)
221 # Missing 'path' key.
222 meta_path.write_text(json.dumps({"name": name, "branch": "main"}))
223 result = _load_meta(repo, name)
224 assert result is None
225
226 def test_valid_meta_round_trips(self, tmp_path: pathlib.Path) -> None:
227 from muse.core.worktree import _load_meta, _save_meta, WorktreeRecord
228
229 repo = _make_repo(tmp_path)
230 (repo / ".muse" / "worktrees").mkdir(parents=True, exist_ok=True)
231 record: WorktreeRecord = {"name": "wt1", "branch": "dev", "path": str(tmp_path / "wt1")}
232 _save_meta(repo, record)
233 loaded = _load_meta(repo, "wt1")
234 assert loaded is not None
235 assert loaded["name"] == "wt1"
236 assert loaded["branch"] == "dev"
237
238
239 # ---------------------------------------------------------------------------
240 # Unit — _safe_delete_path
241 # ---------------------------------------------------------------------------
242
243
244 class TestSafeDeletePath:
245 def test_normal_directory_deleted(self, tmp_path: pathlib.Path) -> None:
246 from muse.core.worktree import _safe_delete_path
247
248 repo = _make_repo(tmp_path)
249 target = tmp_path / "target-dir"
250 target.mkdir()
251 (target / "file.txt").write_text("content")
252 result = _safe_delete_path(repo, target)
253 assert result is True
254 assert not target.exists()
255
256 def test_nonexistent_path_returns_true(self, tmp_path: pathlib.Path) -> None:
257 from muse.core.worktree import _safe_delete_path
258
259 repo = _make_repo(tmp_path)
260 result = _safe_delete_path(repo, tmp_path / "no-such-dir")
261 assert result is True
262
263 def test_symlink_refused(self, tmp_path: pathlib.Path) -> None:
264 from muse.core.worktree import _safe_delete_path
265
266 repo = _make_repo(tmp_path)
267 real_dir = tmp_path / "real-dir"
268 real_dir.mkdir()
269 symlink = tmp_path / "link-to-dir"
270 symlink.symlink_to(real_dir)
271 result = _safe_delete_path(repo, symlink)
272 assert result is False
273 assert real_dir.exists() # real directory untouched
274
275 def test_path_inside_muse_refused(self, tmp_path: pathlib.Path) -> None:
276 from muse.core.worktree import _safe_delete_path
277
278 repo = _make_repo(tmp_path)
279 inside_muse = repo / ".muse" / "objects"
280 result = _safe_delete_path(repo, inside_muse)
281 assert result is False
282 assert inside_muse.exists()
283
284
285 # ---------------------------------------------------------------------------
286 # Unit — _WORKTREES_META_DIR constant
287 # ---------------------------------------------------------------------------
288
289
290 class TestWorktreesMetaDir:
291 def test_constant_used_in_worktrees_dir(self, tmp_path: pathlib.Path) -> None:
292 from muse.core.worktree import _WORKTREES_META_DIR, _worktrees_dir
293
294 repo = _make_repo(tmp_path)
295 derived = _worktrees_dir(repo)
296 expected = repo / _WORKTREES_META_DIR
297 assert derived == expected
298
299
300 # ---------------------------------------------------------------------------
301 # Unit — get_worktree_status
302 # ---------------------------------------------------------------------------
303
304
305 class TestGetWorktreeStatus:
306 def test_main_worktree_status(self, tmp_path: pathlib.Path) -> None:
307 from muse.core.worktree import get_worktree_status
308
309 repo = _make_repo(tmp_path)
310 status = get_worktree_status(repo, "main")
311 assert status["is_main"] is True
312 assert status["name"] == "(main)"
313 assert status["branch"] == "main"
314 assert status["present"] is True
315
316 def test_main_alias_works(self, tmp_path: pathlib.Path) -> None:
317 from muse.core.worktree import get_worktree_status
318
319 repo = _make_repo(tmp_path)
320 s1 = get_worktree_status(repo, "main")
321 s2 = get_worktree_status(repo, "(main)")
322 assert s1["name"] == s2["name"]
323
324 def test_linked_worktree_present(self, tmp_path: pathlib.Path) -> None:
325 from muse.core.worktree import add_worktree, get_worktree_status
326
327 repo = _make_repo(tmp_path)
328 _add_branch(repo, "dev")
329 add_worktree(repo, "mydev", "dev")
330 status = get_worktree_status(repo, "mydev")
331 assert status["name"] == "mydev"
332 assert status["branch"] == "dev"
333 assert status["present"] is True
334 assert status["is_main"] is False
335
336 def test_linked_worktree_absent_after_delete(self, tmp_path: pathlib.Path) -> None:
337 from muse.core.worktree import add_worktree, get_worktree_status
338 import shutil
339
340 repo = _make_repo(tmp_path)
341 _add_branch(repo, "dev")
342 wt_path = add_worktree(repo, "mydev", "dev")
343 # Manually delete the worktree directory (simulating external removal).
344 shutil.rmtree(wt_path)
345 status = get_worktree_status(repo, "mydev")
346 assert status["present"] is False
347
348 def test_nonexistent_worktree_raises(self, tmp_path: pathlib.Path) -> None:
349 from muse.core.worktree import get_worktree_status
350
351 repo = _make_repo(tmp_path)
352 with pytest.raises(ValueError, match="does not exist"):
353 get_worktree_status(repo, "ghost")
354
355
356 # ---------------------------------------------------------------------------
357 # Unit — prune_worktrees dry_run
358 # ---------------------------------------------------------------------------
359
360
361 class TestPruneDryRun:
362 def test_dry_run_does_not_delete(self, tmp_path: pathlib.Path) -> None:
363 from muse.core.worktree import add_worktree, prune_worktrees
364 import shutil
365
366 repo = _make_repo(tmp_path)
367 _add_branch(repo, "dev")
368 wt_path = add_worktree(repo, "mydev", "dev")
369 shutil.rmtree(wt_path)
370 meta = repo / ".muse" / "worktrees" / "mydev.json"
371 assert meta.exists()
372
373 pruned = prune_worktrees(repo, dry_run=True)
374 assert "mydev" in pruned
375 # Meta file must still exist — dry_run=True.
376 assert meta.exists()
377
378 def test_dry_run_false_deletes(self, tmp_path: pathlib.Path) -> None:
379 from muse.core.worktree import add_worktree, prune_worktrees
380 import shutil
381
382 repo = _make_repo(tmp_path)
383 _add_branch(repo, "dev")
384 wt_path = add_worktree(repo, "mydev", "dev")
385 shutil.rmtree(wt_path)
386 meta = repo / ".muse" / "worktrees" / "mydev.json"
387
388 pruned = prune_worktrees(repo, dry_run=False)
389 assert "mydev" in pruned
390 assert not meta.exists()
391
392 def test_dry_run_returns_empty_when_all_present(self, tmp_path: pathlib.Path) -> None:
393 from muse.core.worktree import add_worktree, prune_worktrees
394
395 repo = _make_repo(tmp_path)
396 _add_branch(repo, "dev")
397 add_worktree(repo, "mydev", "dev")
398 pruned = prune_worktrees(repo, dry_run=True)
399 assert pruned == []
400
401
402 # ---------------------------------------------------------------------------
403 # Security — tampered path in meta.json
404 # ---------------------------------------------------------------------------
405
406
407 class TestTamperedMetaPath:
408 def test_path_inside_muse_refused_on_remove(self, tmp_path: pathlib.Path) -> None:
409 """A tampered path pointing inside .muse/ must be refused by remove_worktree."""
410 from muse.core.worktree import _worktree_meta_path, add_worktree, remove_worktree
411
412 repo = _make_repo(tmp_path)
413 _add_branch(repo, "dev")
414 add_worktree(repo, "mydev", "dev")
415
416 # Tamper: set path to the .muse/objects directory.
417 meta_path = _worktree_meta_path(repo, "mydev")
418 danger = str(repo / ".muse" / "objects")
419 meta_path.write_text(json.dumps({"name": "mydev", "branch": "dev", "path": danger}))
420
421 with pytest.raises(ValueError, match="Refusing"):
422 remove_worktree(repo, "mydev")
423
424 # The objects directory must still exist.
425 assert (repo / ".muse" / "objects").exists()
426
427 def test_symlink_wt_path_refused_on_remove(self, tmp_path: pathlib.Path) -> None:
428 """A worktree path that resolves to a symlink must be refused by remove_worktree."""
429 from muse.core.worktree import _worktree_meta_path, add_worktree, remove_worktree
430
431 repo = _make_repo(tmp_path)
432 _add_branch(repo, "dev")
433 add_worktree(repo, "mydev", "dev")
434
435 # Replace the worktree directory with a symlink to an innocent directory.
436 from muse.core.worktree import _worktree_dir
437 wt_dir = _worktree_dir(repo, "mydev")
438 import shutil
439 shutil.rmtree(wt_dir)
440 innocent = tmp_path / "innocent-dir"
441 innocent.mkdir()
442 wt_dir.symlink_to(innocent)
443
444 with pytest.raises(ValueError, match="Refusing|symlink"):
445 remove_worktree(repo, "mydev")
446
447 assert innocent.exists()
448
449
450 # ---------------------------------------------------------------------------
451 # Security — error routing
452 # ---------------------------------------------------------------------------
453
454
455 class TestErrorRouting:
456 def test_add_nonexistent_branch_goes_to_stderr(self, tmp_path: pathlib.Path) -> None:
457 repo = _make_repo(tmp_path)
458 result = _invoke(repo, ["worktree", "add", "mywt", "no-such-branch"])
459 assert result.exit_code != 0
460 assert "❌" in (result.stderr or result.output)
461
462 def test_add_duplicate_name_goes_to_stderr(self, tmp_path: pathlib.Path) -> None:
463 repo = _make_repo(tmp_path)
464 _add_branch(repo, "dev")
465 _invoke(repo, ["worktree", "add", "mywt", "dev"])
466 result = _invoke(repo, ["worktree", "add", "mywt", "dev"])
467 assert result.exit_code != 0
468 assert "❌" in (result.stderr or result.output)
469
470 def test_remove_nonexistent_goes_to_stderr(self, tmp_path: pathlib.Path) -> None:
471 repo = _make_repo(tmp_path)
472 result = _invoke(repo, ["worktree", "remove", "ghost"])
473 assert result.exit_code != 0
474 assert "❌" in (result.stderr or result.output)
475
476 def test_status_nonexistent_goes_to_stderr(self, tmp_path: pathlib.Path) -> None:
477 repo = _make_repo(tmp_path)
478 result = _invoke(repo, ["worktree", "status", "ghost"])
479 assert result.exit_code != 0
480 assert "❌" in (result.stderr or result.output)
481
482 def test_create_branch_already_exists_goes_to_stderr(self, tmp_path: pathlib.Path) -> None:
483 repo = _make_repo(tmp_path)
484 _add_branch(repo, "dev")
485 result = _invoke(repo, ["worktree", "add", "mywt", "main", "-b", "dev"])
486 assert result.exit_code != 0
487 assert "❌" in (result.stderr or result.output)
488
489
490 # ---------------------------------------------------------------------------
491 # Security — ANSI sanitization
492 # ---------------------------------------------------------------------------
493
494
495 class TestAnsiSanitization:
496 def test_ansi_in_name_does_not_leak(self, tmp_path: pathlib.Path) -> None:
497 repo = _make_repo(tmp_path)
498 ansi_name = "\x1b[31mmywt\x1b[0m"
499 result = _invoke(repo, ["worktree", "add", ansi_name, "main"])
500 assert _ANSI_RE.search(result.output) is None
501
502 def test_ansi_in_branch_does_not_leak(self, tmp_path: pathlib.Path) -> None:
503 repo = _make_repo(tmp_path)
504 ansi_branch = "\x1b[31mmain\x1b[0m"
505 result = _invoke(repo, ["worktree", "add", "mywt", ansi_branch])
506 assert _ANSI_RE.search(result.output) is None
507
508
509 # ---------------------------------------------------------------------------
510 # JSON schema — add
511 # ---------------------------------------------------------------------------
512
513
514 class TestJsonSchemaAdd:
515 def test_add_json_schema(self, tmp_path: pathlib.Path) -> None:
516 repo = _make_repo(tmp_path)
517 _add_branch(repo, "dev")
518 result = _invoke(repo, ["worktree", "add", "mydev", "dev", "--json"])
519 assert result.exit_code == 0
520 parsed = _parse_add(result.output)
521 assert parsed["name"] == "mydev"
522 assert parsed["branch"] == "dev"
523 assert "mydev" in parsed["path"] or "myproject" in parsed["path"]
524
525 def test_add_json_with_create_branch(self, tmp_path: pathlib.Path) -> None:
526 repo = _make_repo(tmp_path)
527 result = _invoke(repo, ["worktree", "add", "mywt", "main", "-b", "feat/new", "--json"])
528 assert result.exit_code == 0
529 parsed = _parse_add(result.output)
530 assert parsed["branch"] == "feat/new"
531 assert parsed["name"] == "mywt"
532
533 def test_add_json_path_is_string(self, tmp_path: pathlib.Path) -> None:
534 repo = _make_repo(tmp_path)
535 _add_branch(repo, "dev")
536 result = _invoke(repo, ["worktree", "add", "dev-wt", "dev", "--json"])
537 assert result.exit_code == 0
538 parsed = _parse_add(result.output)
539 assert isinstance(parsed["path"], str)
540 assert len(parsed["path"]) > 0
541
542
543 # ---------------------------------------------------------------------------
544 # JSON schema — list
545 # ---------------------------------------------------------------------------
546
547
548 class TestJsonSchemaList:
549 def test_list_json_includes_main(self, tmp_path: pathlib.Path) -> None:
550 repo = _make_repo(tmp_path)
551 result = _invoke(repo, ["worktree", "list", "--json"])
552 assert result.exit_code == 0
553 entries = _parse_list(result.output)
554 assert any(e["is_main"] for e in entries)
555
556 def test_list_json_linked_worktree(self, tmp_path: pathlib.Path) -> None:
557 repo = _make_repo(tmp_path)
558 _add_branch(repo, "dev")
559 _invoke(repo, ["worktree", "add", "mydev", "dev"])
560 result = _invoke(repo, ["worktree", "list", "--json"])
561 assert result.exit_code == 0
562 entries = _parse_list(result.output)
563 names = [e["name"] for e in entries]
564 assert "mydev" in names
565
566 def test_list_json_entry_schema(self, tmp_path: pathlib.Path) -> None:
567 repo = _make_repo(tmp_path)
568 _add_branch(repo, "dev")
569 _invoke(repo, ["worktree", "add", "mydev", "dev"])
570 result = _invoke(repo, ["worktree", "list", "--json"])
571 entries = _parse_list(result.output)
572 for entry in entries:
573 assert isinstance(entry["name"], str)
574 assert isinstance(entry["branch"], str)
575 assert isinstance(entry["path"], str)
576 assert entry["head_commit"] is None or isinstance(entry["head_commit"], str)
577 assert isinstance(entry["is_main"], bool)
578
579 def test_list_json_empty_repo(self, tmp_path: pathlib.Path) -> None:
580 repo = _make_repo(tmp_path)
581 result = _invoke(repo, ["worktree", "list", "--json"])
582 assert result.exit_code == 0
583 entries = _parse_list(result.output)
584 assert len(entries) == 1 # always includes main
585
586
587 # ---------------------------------------------------------------------------
588 # JSON schema — status
589 # ---------------------------------------------------------------------------
590
591
592 class TestJsonSchemaStatus:
593 def test_status_json_main(self, tmp_path: pathlib.Path) -> None:
594 repo = _make_repo(tmp_path)
595 result = _invoke(repo, ["worktree", "status", "main", "--json"])
596 assert result.exit_code == 0
597 parsed = _parse_status(result.output)
598 assert parsed["is_main"] is True
599 assert parsed["present"] is True
600 assert parsed["branch"] == "main"
601
602 def test_status_json_linked_present(self, tmp_path: pathlib.Path) -> None:
603 repo = _make_repo(tmp_path)
604 _add_branch(repo, "dev")
605 _invoke(repo, ["worktree", "add", "mydev", "dev"])
606 result = _invoke(repo, ["worktree", "status", "mydev", "--json"])
607 assert result.exit_code == 0
608 parsed = _parse_status(result.output)
609 assert parsed["name"] == "mydev"
610 assert parsed["branch"] == "dev"
611 assert parsed["present"] is True
612 assert parsed["is_main"] is False
613
614 def test_status_json_linked_absent(self, tmp_path: pathlib.Path) -> None:
615 import shutil
616 repo = _make_repo(tmp_path)
617 _add_branch(repo, "dev")
618 _invoke(repo, ["worktree", "add", "mydev", "dev"])
619 # Delete the worktree directory externally.
620 from muse.core.worktree import _worktree_dir
621 wt_path = _worktree_dir(repo, "mydev")
622 shutil.rmtree(wt_path)
623 result = _invoke(repo, ["worktree", "status", "mydev", "--json"])
624 assert result.exit_code == 0
625 parsed = _parse_status(result.output)
626 assert parsed["present"] is False
627
628 def test_status_json_nonexistent_fails(self, tmp_path: pathlib.Path) -> None:
629 repo = _make_repo(tmp_path)
630 result = _invoke(repo, ["worktree", "status", "ghost", "--json"])
631 assert result.exit_code != 0
632
633 def test_status_json_all_fields_present(self, tmp_path: pathlib.Path) -> None:
634 repo = _make_repo(tmp_path)
635 result = _invoke(repo, ["worktree", "status", "main", "--json"])
636 parsed = _parse_status(result.output)
637 assert "name" in parsed
638 assert "branch" in parsed
639 assert "path" in parsed
640 assert "head_commit" in parsed
641 assert "present" in parsed
642 assert "is_main" in parsed
643
644
645 # ---------------------------------------------------------------------------
646 # JSON schema — remove
647 # ---------------------------------------------------------------------------
648
649
650 class TestJsonSchemaRemove:
651 def test_remove_json_schema(self, tmp_path: pathlib.Path) -> None:
652 repo = _make_repo(tmp_path)
653 _add_branch(repo, "dev")
654 _invoke(repo, ["worktree", "add", "mydev", "dev"])
655 result = _invoke(repo, ["worktree", "remove", "mydev", "--json"])
656 assert result.exit_code == 0
657 parsed = _parse_remove(result.output)
658 assert parsed["name"] == "mydev"
659 assert parsed["status"] == "removed"
660
661 def test_remove_json_nonexistent_fails(self, tmp_path: pathlib.Path) -> None:
662 repo = _make_repo(tmp_path)
663 result = _invoke(repo, ["worktree", "remove", "ghost", "--json"])
664 assert result.exit_code != 0
665
666 def test_remove_cleans_up_directory(self, tmp_path: pathlib.Path) -> None:
667 repo = _make_repo(tmp_path)
668 _add_branch(repo, "dev")
669 _invoke(repo, ["worktree", "add", "mydev", "dev"])
670 from muse.core.worktree import _worktree_dir
671 wt_path = _worktree_dir(repo, "mydev")
672 assert wt_path.exists()
673 _invoke(repo, ["worktree", "remove", "mydev"])
674 assert not wt_path.exists()
675
676
677 # ---------------------------------------------------------------------------
678 # JSON schema — prune
679 # ---------------------------------------------------------------------------
680
681
682 class TestJsonSchemaPrune:
683 def test_prune_json_nothing_to_prune(self, tmp_path: pathlib.Path) -> None:
684 repo = _make_repo(tmp_path)
685 result = _invoke(repo, ["worktree", "prune", "--json"])
686 assert result.exit_code == 0
687 parsed = _parse_prune(result.output)
688 assert parsed["pruned"] == []
689 assert parsed["count"] == 0
690 assert parsed["dry_run"] is False
691
692 def test_prune_json_stale_worktree(self, tmp_path: pathlib.Path) -> None:
693 import shutil
694 repo = _make_repo(tmp_path)
695 _add_branch(repo, "dev")
696 _invoke(repo, ["worktree", "add", "mydev", "dev"])
697 from muse.core.worktree import _worktree_dir
698 shutil.rmtree(_worktree_dir(repo, "mydev"))
699 result = _invoke(repo, ["worktree", "prune", "--json"])
700 assert result.exit_code == 0
701 parsed = _parse_prune(result.output)
702 assert "mydev" in parsed["pruned"]
703 assert parsed["count"] == 1
704 assert parsed["dry_run"] is False
705
706 def test_prune_json_dry_run(self, tmp_path: pathlib.Path) -> None:
707 import shutil
708 repo = _make_repo(tmp_path)
709 _add_branch(repo, "dev")
710 _invoke(repo, ["worktree", "add", "mydev", "dev"])
711 from muse.core.worktree import _worktree_dir
712 shutil.rmtree(_worktree_dir(repo, "mydev"))
713 # Meta file still present.
714 meta = repo / ".muse" / "worktrees" / "mydev.json"
715 assert meta.exists()
716 result = _invoke(repo, ["worktree", "prune", "--dry-run", "--json"])
717 assert result.exit_code == 0
718 parsed = _parse_prune(result.output)
719 assert "mydev" in parsed["pruned"]
720 assert parsed["dry_run"] is True
721 # Meta must not be deleted in dry-run.
722 assert meta.exists()
723
724 def test_prune_json_count_matches_pruned_list(self, tmp_path: pathlib.Path) -> None:
725 import shutil
726 repo = _make_repo(tmp_path)
727 for i in range(3):
728 branch = f"feat{i}"
729 _add_branch(repo, branch)
730 _invoke(repo, ["worktree", "add", f"wt{i}", branch])
731 from muse.core.worktree import _worktree_dir
732 shutil.rmtree(_worktree_dir(repo, f"wt{i}"))
733 result = _invoke(repo, ["worktree", "prune", "--json"])
734 parsed = _parse_prune(result.output)
735 assert parsed["count"] == len(parsed["pruned"])
736 assert parsed["count"] == 3
737
738
739 # ---------------------------------------------------------------------------
740 # Integration — -b/--create-branch
741 # ---------------------------------------------------------------------------
742
743
744 class TestCreateBranch:
745 def test_create_branch_makes_new_ref(self, tmp_path: pathlib.Path) -> None:
746 repo = _make_repo(tmp_path)
747 result = _invoke(repo, ["worktree", "add", "mywt", "main", "-b", "feat/new", "--json"])
748 assert result.exit_code == 0
749 new_ref = repo / ".muse" / "refs" / "heads" / "feat" / "new"
750 assert new_ref.exists()
751
752 def test_create_branch_checked_out_in_worktree(self, tmp_path: pathlib.Path) -> None:
753 repo = _make_repo(tmp_path)
754 result = _invoke(repo, ["worktree", "add", "mywt", "main", "-b", "feat/new", "--json"])
755 assert result.exit_code == 0
756 parsed = _parse_add(result.output)
757 assert parsed["branch"] == "feat/new"
758
759 def test_create_branch_existing_name_fails(self, tmp_path: pathlib.Path) -> None:
760 repo = _make_repo(tmp_path)
761 # 'main' already exists.
762 result = _invoke(repo, ["worktree", "add", "mywt", "main", "-b", "main"])
763 assert result.exit_code != 0
764
765 def test_create_branch_no_commits_on_start_fails(self, tmp_path: pathlib.Path) -> None:
766 repo = _make_repo(tmp_path)
767 # Create a branch that has no commits.
768 empty_branch_ref = repo / ".muse" / "refs" / "heads" / "empty"
769 # Don't write anything to it — it doesn't exist as a ref.
770 # The start point HEAD should be 'main' which has '0'*64 as a dummy ref,
771 # get_head_commit_id reads the file content; let's make it return None.
772 empty_branch_ref.write_text("") # empty file → no commit
773 result = _invoke(repo, ["worktree", "add", "mywt", "empty", "-b", "feat/from-empty"])
774 # get_head_commit_id for a branch with empty ref content returns None → error
775 assert result.exit_code != 0
776
777
778 # ---------------------------------------------------------------------------
779 # Integration — --path override
780 # ---------------------------------------------------------------------------
781
782
783 class TestCustomPath:
784 def test_custom_path_used(self, tmp_path: pathlib.Path) -> None:
785 repo = _make_repo(tmp_path)
786 _add_branch(repo, "dev")
787 custom = tmp_path / "custom-wt-dir"
788 result = _invoke(repo, ["worktree", "add", "mydev", "dev", "--path", str(custom), "--json"])
789 assert result.exit_code == 0
790 parsed = _parse_add(result.output)
791 assert pathlib.Path(parsed["path"]).resolve() == custom.resolve()
792 assert custom.exists()
793
794 def test_custom_path_shows_in_list(self, tmp_path: pathlib.Path) -> None:
795 repo = _make_repo(tmp_path)
796 _add_branch(repo, "dev")
797 custom = tmp_path / "custom-wt-dir"
798 _invoke(repo, ["worktree", "add", "mydev", "dev", "--path", str(custom)])
799 result = _invoke(repo, ["worktree", "list", "--json"])
800 entries = _parse_list(result.output)
801 paths = [e["path"] for e in entries]
802 assert any("custom-wt-dir" in p for p in paths)
803
804
805 # ---------------------------------------------------------------------------
806 # E2E — text output (non-JSON) still correct
807 # ---------------------------------------------------------------------------
808
809
810 class TestE2EText:
811 def test_add_text_output(self, tmp_path: pathlib.Path) -> None:
812 repo = _make_repo(tmp_path)
813 _add_branch(repo, "dev")
814 result = _invoke(repo, ["worktree", "add", "mydev", "dev"])
815 assert result.exit_code == 0
816 assert "✅" in result.output or "created" in result.output.lower()
817
818 def test_list_text_output(self, tmp_path: pathlib.Path) -> None:
819 repo = _make_repo(tmp_path)
820 result = _invoke(repo, ["worktree", "list"])
821 assert result.exit_code == 0
822 assert "main" in result.output
823
824 def test_status_text_output(self, tmp_path: pathlib.Path) -> None:
825 repo = _make_repo(tmp_path)
826 _add_branch(repo, "dev")
827 _invoke(repo, ["worktree", "add", "mydev", "dev"])
828 result = _invoke(repo, ["worktree", "status", "mydev"])
829 assert result.exit_code == 0
830 assert "mydev" in result.output
831 assert "dev" in result.output
832
833 def test_remove_text_output(self, tmp_path: pathlib.Path) -> None:
834 repo = _make_repo(tmp_path)
835 _add_branch(repo, "dev")
836 _invoke(repo, ["worktree", "add", "mydev", "dev"])
837 result = _invoke(repo, ["worktree", "remove", "mydev"])
838 assert result.exit_code == 0
839 assert "✅" in result.output or "removed" in result.output.lower()
840
841 def test_prune_text_nothing(self, tmp_path: pathlib.Path) -> None:
842 repo = _make_repo(tmp_path)
843 result = _invoke(repo, ["worktree", "prune"])
844 assert result.exit_code == 0
845 assert "Nothing" in result.output
846
847 def test_prune_text_dry_run(self, tmp_path: pathlib.Path) -> None:
848 import shutil
849 repo = _make_repo(tmp_path)
850 _add_branch(repo, "dev")
851 _invoke(repo, ["worktree", "add", "mydev", "dev"])
852 from muse.core.worktree import _worktree_dir
853 shutil.rmtree(_worktree_dir(repo, "mydev"))
854 result = _invoke(repo, ["worktree", "prune", "--dry-run"])
855 assert result.exit_code == 0
856 assert "dry-run" in result.output or "would prune" in result.output.lower()
857
858 def test_status_text_main(self, tmp_path: pathlib.Path) -> None:
859 repo = _make_repo(tmp_path)
860 result = _invoke(repo, ["worktree", "status", "main"])
861 assert result.exit_code == 0
862 assert "present" in result.output.lower()
863
864
865 # ---------------------------------------------------------------------------
866 # Stress
867 # ---------------------------------------------------------------------------
868
869
870 class TestStress:
871 def test_50_worktrees_add_list_remove(self, tmp_path: pathlib.Path) -> None:
872 """Create 50 worktrees, verify list, then remove all."""
873 repo = _make_repo(tmp_path)
874 names: list[str] = []
875 for i in range(50):
876 branch = f"branch{i}"
877 _add_branch(repo, branch)
878 name = f"wt{i}"
879 r = _invoke(repo, ["worktree", "add", name, branch, "--json"])
880 assert r.exit_code == 0, f"Failed to add wt{i}: {r.output}"
881 names.append(name)
882
883 r_list = _invoke(repo, ["worktree", "list", "--json"])
884 assert r_list.exit_code == 0
885 entries = _parse_list(r_list.output)
886 listed_names = {e["name"] for e in entries}
887 for name in names:
888 assert name in listed_names
889
890 for name in names:
891 r_rm = _invoke(repo, ["worktree", "remove", name, "--json"])
892 assert r_rm.exit_code == 0
893
894 def test_concurrent_list_reads_safe(self, tmp_path: pathlib.Path) -> None:
895 """Concurrent reads of worktree list must not crash."""
896 repo = _make_repo(tmp_path)
897 for i in range(5):
898 branch = f"br{i}"
899 _add_branch(repo, branch)
900 from muse.core.worktree import add_worktree
901 add_worktree(repo, f"wt{i}", branch)
902
903 errors: list[str] = []
904
905 def _read() -> None:
906 from muse.core.worktree import list_worktrees
907 try:
908 wts = list_worktrees(repo)
909 assert len(wts) >= 1
910 except Exception as exc:
911 errors.append(str(exc))
912
913 threads = [threading.Thread(target=_read) for _ in range(30)]
914 for t in threads:
915 t.start()
916 for t in threads:
917 t.join()
918
919 assert not errors, f"Concurrent list failures: {errors}"
920
921 def test_prune_50_stale_worktrees(self, tmp_path: pathlib.Path) -> None:
922 """prune_worktrees with 50 stale entries all reported correctly."""
923 import shutil
924 repo = _make_repo(tmp_path)
925 for i in range(50):
926 branch = f"br{i}"
927 _add_branch(repo, branch)
928 from muse.core.worktree import add_worktree, _worktree_dir
929 add_worktree(repo, f"wt{i}", branch)
930 shutil.rmtree(_worktree_dir(repo, f"wt{i}"))
931
932 result = _invoke(repo, ["worktree", "prune", "--json"])
933 assert result.exit_code == 0
934 parsed = _parse_prune(result.output)
935 assert parsed["count"] == 50
936 assert len(parsed["pruned"]) == 50
937
938
939 # ---------------------------------------------------------------------------
940 # Extended — muse worktree add
941 # ---------------------------------------------------------------------------
942
943 class TestWorktreeAddExtended:
944 def test_add_j_alias(self, tmp_path: pathlib.Path) -> None:
945 repo = _make_repo(tmp_path)
946 _add_branch(repo, "dev")
947 result = _invoke(repo, ["worktree", "add", "mydev", "dev", "-j"])
948 assert result.exit_code == 0
949 d = _parse_add(result.output)
950 assert d["name"] == "mydev"
951
952 def test_add_json_schema_has_head_commit(self, tmp_path: pathlib.Path) -> None:
953 repo = _make_repo(tmp_path)
954 _add_branch(repo, "dev")
955 result = _invoke(repo, ["worktree", "add", "mydev", "dev", "--json"])
956 assert result.exit_code == 0
957 d = _parse_add(result.output)
958 assert "head_commit" in d
959
960 def test_add_json_head_commit_is_sha256_or_null(self, tmp_path: pathlib.Path) -> None:
961 repo = _make_repo(tmp_path)
962 _add_branch(repo, "dev")
963 result = _invoke(repo, ["worktree", "add", "mydev", "dev", "--json"])
964 d = _parse_add(result.output)
965 hc = d["head_commit"]
966 assert hc is None or (isinstance(hc, str) and len(hc) == 64)
967
968 def test_add_json_schema_complete(self, tmp_path: pathlib.Path) -> None:
969 _REQUIRED = {"name", "branch", "path", "head_commit"}
970 repo = _make_repo(tmp_path)
971 _add_branch(repo, "dev")
972 result = _invoke(repo, ["worktree", "add", "mydev", "dev", "--json"])
973 assert _REQUIRED <= _parse_add(result.output).keys()
974
975 def test_add_worktree_dir_created(self, tmp_path: pathlib.Path) -> None:
976 repo = _make_repo(tmp_path)
977 _add_branch(repo, "dev")
978 result = _invoke(repo, ["worktree", "add", "mydev", "dev", "--json"])
979 wt_path = pathlib.Path(_parse_add(result.output)["path"])
980 assert wt_path.exists() and wt_path.is_dir()
981
982 def test_add_appears_in_list(self, tmp_path: pathlib.Path) -> None:
983 repo = _make_repo(tmp_path)
984 _add_branch(repo, "dev")
985 _invoke(repo, ["worktree", "add", "mydev", "dev"])
986 entries = _parse_list(_invoke(repo, ["worktree", "list", "--json"]).output)
987 assert any(e["name"] == "mydev" for e in entries)
988
989 def test_add_duplicate_name_fails(self, tmp_path: pathlib.Path) -> None:
990 repo = _make_repo(tmp_path)
991 _add_branch(repo, "dev")
992 _invoke(repo, ["worktree", "add", "mydev", "dev"])
993 result = _invoke(repo, ["worktree", "add", "mydev", "dev"])
994 assert result.exit_code != 0
995
996 def test_add_nonexistent_branch_fails(self, tmp_path: pathlib.Path) -> None:
997 repo = _make_repo(tmp_path)
998 result = _invoke(repo, ["worktree", "add", "mywt", "no-such-branch"])
999 assert result.exit_code != 0
1000
1001 def test_add_json_branch_matches_arg(self, tmp_path: pathlib.Path) -> None:
1002 repo = _make_repo(tmp_path)
1003 _add_branch(repo, "dev")
1004 result = _invoke(repo, ["worktree", "add", "mydev", "dev", "--json"])
1005 assert _parse_add(result.output)["branch"] == "dev"
1006
1007 def test_add_json_name_matches_arg(self, tmp_path: pathlib.Path) -> None:
1008 repo = _make_repo(tmp_path)
1009 _add_branch(repo, "dev")
1010 result = _invoke(repo, ["worktree", "add", "mydev", "dev", "--json"])
1011 assert _parse_add(result.output)["name"] == "mydev"
1012
1013 def test_add_text_shows_checkmark(self, tmp_path: pathlib.Path) -> None:
1014 repo = _make_repo(tmp_path)
1015 _add_branch(repo, "dev")
1016 result = _invoke(repo, ["worktree", "add", "mydev", "dev"])
1017 assert "✅" in result.output or "created" in result.output.lower()
1018
1019 def test_add_text_shows_branch(self, tmp_path: pathlib.Path) -> None:
1020 repo = _make_repo(tmp_path)
1021 _add_branch(repo, "dev")
1022 result = _invoke(repo, ["worktree", "add", "mydev", "dev"])
1023 assert "dev" in result.output
1024
1025 def test_add_text_shows_path(self, tmp_path: pathlib.Path) -> None:
1026 repo = _make_repo(tmp_path)
1027 _add_branch(repo, "dev")
1028 result = _invoke(repo, ["worktree", "add", "mydev", "dev"])
1029 assert "mydev" in result.output
1030
1031 def test_add_default_is_text(self, tmp_path: pathlib.Path) -> None:
1032 repo = _make_repo(tmp_path)
1033 _add_branch(repo, "dev")
1034 result = _invoke(repo, ["worktree", "add", "mydev", "dev"])
1035 try:
1036 json.loads(result.output)
1037 assert False, "default should be text"
1038 except (json.JSONDecodeError, ValueError):
1039 pass
1040
1041 def test_add_outside_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
1042 result = _invoke(tmp_path, ["worktree", "add", "mywt", "main"])
1043 assert result.exit_code == 2
1044
1045 def test_add_description_in_help(self, tmp_path: pathlib.Path) -> None:
1046 result = _invoke(tmp_path, ["worktree", "add", "--help"])
1047 assert result.exit_code == 0
1048 assert "Agent quickstart" in result.output or "head_commit" in result.output
1049
1050 def test_add_create_branch_with_j(self, tmp_path: pathlib.Path) -> None:
1051 repo = _make_repo(tmp_path)
1052 result = _invoke(repo, ["worktree", "add", "mywt", "main", "-b", "feat/new", "-j"])
1053 assert result.exit_code == 0
1054 d = _parse_add(result.output)
1055 assert d["branch"] == "feat/new"
1056 assert "head_commit" in d
1057
1058 def test_add_path_is_absolute(self, tmp_path: pathlib.Path) -> None:
1059 repo = _make_repo(tmp_path)
1060 _add_branch(repo, "dev")
1061 result = _invoke(repo, ["worktree", "add", "mydev", "dev", "--json"])
1062 path = pathlib.Path(_parse_add(result.output)["path"])
1063 assert path.is_absolute()
1064
1065 def test_add_multiple_worktrees(self, tmp_path: pathlib.Path) -> None:
1066 repo = _make_repo(tmp_path)
1067 for i in range(5):
1068 _add_branch(repo, f"feat{i}")
1069 r = _invoke(repo, ["worktree", "add", f"wt{i}", f"feat{i}", "--json"])
1070 assert r.exit_code == 0, f"wt{i} failed: {r.output}"
1071 entries = _parse_list(_invoke(repo, ["worktree", "list", "--json"]).output)
1072 names = [e["name"] for e in entries]
1073 for i in range(5):
1074 assert f"wt{i}" in names
1075
1076 def test_add_worktree_has_muse_pointer(self, tmp_path: pathlib.Path) -> None:
1077 """Each worktree directory must contain a .muse pointer file."""
1078 repo = _make_repo(tmp_path)
1079 _add_branch(repo, "dev")
1080 result = _invoke(repo, ["worktree", "add", "mydev", "dev", "--json"])
1081 wt_path = pathlib.Path(_parse_add(result.output)["path"])
1082 assert (wt_path / ".muse").exists()
1083
1084 def test_add_invalid_name_exits_1(self, tmp_path: pathlib.Path) -> None:
1085 repo = _make_repo(tmp_path)
1086 result = _invoke(repo, ["worktree", "add", "bad name!", "main"])
1087 assert result.exit_code != 0
1088
1089 def test_add_invalid_create_branch_name_exits_1(self, tmp_path: pathlib.Path) -> None:
1090 repo = _make_repo(tmp_path)
1091 result = _invoke(repo, ["worktree", "add", "mywt", "main", "-b", "bad name!"])
1092 assert result.exit_code != 0
1093
1094
1095 # ---------------------------------------------------------------------------
1096 # Security — muse worktree add
1097 # ---------------------------------------------------------------------------
1098
1099 class TestWorktreeAddSecurity:
1100 def test_ansi_in_name_sanitized_text(self, tmp_path: pathlib.Path) -> None:
1101 """ANSI in name must not appear raw in text output."""
1102 repo = _make_repo(tmp_path)
1103 _add_branch(repo, "dev")
1104 # Invalid name (contains space/special) will be rejected — just verify no crash
1105 result = _invoke(repo, ["worktree", "add", "\x1b[31mwt\x1b[0m", "dev"])
1106 assert "\x1b[31m" not in result.output
1107
1108 def test_ansi_in_branch_sanitized_text(self, tmp_path: pathlib.Path) -> None:
1109 repo = _make_repo(tmp_path)
1110 result = _invoke(repo, ["worktree", "add", "mywt", "\x1b[31mmain\x1b[0m"])
1111 assert "\x1b[31m" not in result.output
1112
1113 def test_path_traversal_name_rejected(self, tmp_path: pathlib.Path) -> None:
1114 """Names with path separators must be rejected."""
1115 repo = _make_repo(tmp_path)
1116 result = _invoke(repo, ["worktree", "add", "../evil", "main"])
1117 assert result.exit_code != 0
1118
1119 def test_null_byte_in_name_rejected(self, tmp_path: pathlib.Path) -> None:
1120 repo = _make_repo(tmp_path)
1121 result = _invoke(repo, ["worktree", "add", "bad\x00name", "main"])
1122 assert result.exit_code != 0
1123
1124 def test_existing_directory_prevented(self, tmp_path: pathlib.Path) -> None:
1125 """add must refuse if the target directory already exists."""
1126 repo = _make_repo(tmp_path)
1127 _add_branch(repo, "dev")
1128 # Pre-create the target directory
1129 from muse.core.worktree import _worktree_dir
1130 wt_path = _worktree_dir(repo, "mydev")
1131 wt_path.mkdir(parents=True)
1132 result = _invoke(repo, ["worktree", "add", "mydev", "dev"])
1133 assert result.exit_code != 0
1134
1135 def test_error_to_stderr(self, tmp_path: pathlib.Path) -> None:
1136 """Error messages for missing branch must go to stderr."""
1137 repo = _make_repo(tmp_path)
1138 result = _invoke(repo, ["worktree", "add", "mywt", "no-such-branch"])
1139 assert result.exit_code != 0
1140 assert "no-such-branch" in (result.stderr or result.output)
1141
1142 def test_create_branch_duplicate_rejected(self, tmp_path: pathlib.Path) -> None:
1143 repo = _make_repo(tmp_path)
1144 result = _invoke(repo, ["worktree", "add", "mywt", "main", "-b", "main"])
1145 assert result.exit_code != 0
1146
1147 def test_json_output_is_valid_json(self, tmp_path: pathlib.Path) -> None:
1148 repo = _make_repo(tmp_path)
1149 _add_branch(repo, "dev")
1150 result = _invoke(repo, ["worktree", "add", "mydev", "dev", "--json"])
1151 assert result.exit_code == 0
1152 d = json.loads(result.output.strip().splitlines()[0])
1153 assert isinstance(d, dict)
1154
1155
1156 # ---------------------------------------------------------------------------
1157 # Stress — muse worktree add
1158 # ---------------------------------------------------------------------------
1159
1160 class TestWorktreeAddStress:
1161 def test_add_20_worktrees_sequential(self, tmp_path: pathlib.Path) -> None:
1162 """Add 20 worktrees sequentially; all must succeed and appear in list."""
1163 repo = _make_repo(tmp_path)
1164 failures: list[str] = []
1165 for i in range(20):
1166 _add_branch(repo, f"feat{i}")
1167 r = _invoke(repo, ["worktree", "add", f"wt{i}", f"feat{i}", "--json"])
1168 if r.exit_code != 0:
1169 failures.append(f"wt{i}: {r.output.strip()[:60]}")
1170 assert not failures, f"Add failures: {failures}"
1171 entries = _parse_list(_invoke(repo, ["worktree", "list", "--json"]).output)
1172 assert len(entries) == 21 # 20 linked + 1 main
1173
1174 def test_add_performance(self, tmp_path: pathlib.Path) -> None:
1175 """Adding a worktree must complete in < 2 s."""
1176 import time
1177 repo = _make_repo(tmp_path)
1178 _add_branch(repo, "dev")
1179 start = time.perf_counter()
1180 result = _invoke(repo, ["worktree", "add", "mydev", "dev", "--json"])
1181 elapsed = time.perf_counter() - start
1182 assert result.exit_code == 0
1183 assert elapsed < 2.0, f"add took {elapsed:.2f}s"
1184
1185 def test_add_with_create_branch_20_times(self, tmp_path: pathlib.Path) -> None:
1186 """Create 20 worktrees each with a fresh -b branch."""
1187 repo = _make_repo(tmp_path)
1188 failures: list[str] = []
1189 for i in range(20):
1190 r = _invoke(repo, ["worktree", "add", f"wt{i}", "main", "-b", f"feat/task-{i}", "--json"])
1191 if r.exit_code != 0:
1192 failures.append(f"wt{i}: {r.output.strip()[:60]}")
1193 assert not failures
1194 entries = _parse_list(_invoke(repo, ["worktree", "list", "--json"]).output)
1195 assert len(entries) == 21
1196
1197
1198 # ===========================================================================
1199 # muse worktree list — Extended / Security / Stress
1200 # ===========================================================================
1201
1202
1203 class TestWorktreeListExtended:
1204 """-j alias, text output, ordering, schema completeness, edge cases."""
1205
1206 def test_list_j_alias_json(self, tmp_path: pathlib.Path) -> None:
1207 """-j is accepted and produces the same JSON as --json."""
1208 repo = _make_repo(tmp_path)
1209 r1 = _invoke(repo, ["worktree", "list", "--json"])
1210 r2 = _invoke(repo, ["worktree", "list", "-j"])
1211 assert r1.exit_code == 0
1212 assert r2.exit_code == 0
1213 assert json.loads(_json_blob(r1.output)) == json.loads(_json_blob(r2.output))
1214
1215 def test_list_default_is_text(self, tmp_path: pathlib.Path) -> None:
1216 """Without --json the output is human-readable text, not JSON."""
1217 repo = _make_repo(tmp_path)
1218 result = _invoke(repo, ["worktree", "list"])
1219 assert result.exit_code == 0
1220 output = result.output.strip()
1221 assert not output.startswith("[")
1222 assert not output.startswith("{")
1223
1224 def test_list_text_has_header(self, tmp_path: pathlib.Path) -> None:
1225 """Text output includes a column header."""
1226 repo = _make_repo(tmp_path)
1227 result = _invoke(repo, ["worktree", "list"])
1228 assert "name" in result.output
1229 assert "branch" in result.output
1230
1231 def test_list_text_empty_repo_prints_main(self, tmp_path: pathlib.Path) -> None:
1232 """Repo with no linked worktrees still shows main in text mode."""
1233 repo = _make_repo(tmp_path)
1234 result = _invoke(repo, ["worktree", "list"])
1235 assert result.exit_code == 0
1236 assert "(main)" in result.output
1237
1238 def test_list_main_always_first_in_json(self, tmp_path: pathlib.Path) -> None:
1239 """Main worktree is always the first element in JSON output."""
1240 repo = _make_repo(tmp_path)
1241 _add_branch(repo, "feat/x")
1242 _invoke(repo, ["worktree", "add", "feat-x", "feat/x"])
1243 entries = _parse_list(_invoke(repo, ["worktree", "list", "-j"]).output)
1244 assert entries[0]["is_main"] is True
1245
1246 def test_list_linked_worktrees_sorted(self, tmp_path: pathlib.Path) -> None:
1247 """Linked worktrees appear in lexicographic order after main."""
1248 repo = _make_repo(tmp_path)
1249 for name in ("zzz", "aaa", "mmm"):
1250 _add_branch(repo, name)
1251 _invoke(repo, ["worktree", "add", name, name])
1252 entries = _parse_list(_invoke(repo, ["worktree", "list", "-j"]).output)
1253 linked = [e["name"] for e in entries if not e["is_main"]]
1254 assert linked == sorted(linked)
1255
1256 def test_list_json_path_is_absolute(self, tmp_path: pathlib.Path) -> None:
1257 """Every path in JSON output is absolute."""
1258 repo = _make_repo(tmp_path)
1259 _add_branch(repo, "dev")
1260 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1261 entries = _parse_list(_invoke(repo, ["worktree", "list", "-j"]).output)
1262 for entry in entries:
1263 assert pathlib.Path(entry["path"]).is_absolute()
1264
1265 def test_list_json_all_fields_present(self, tmp_path: pathlib.Path) -> None:
1266 """Every JSON entry has name, branch, path, head_commit, is_main."""
1267 repo = _make_repo(tmp_path)
1268 _add_branch(repo, "dev")
1269 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1270 raw = json.loads(_json_blob(_invoke(repo, ["worktree", "list", "-j"]).output))
1271 for entry in raw:
1272 for field in ("name", "branch", "path", "head_commit", "is_main"):
1273 assert field in entry, f"field '{field}' missing from {entry}"
1274
1275 def test_list_json_only_one_is_main(self, tmp_path: pathlib.Path) -> None:
1276 """Exactly one entry has is_main=true."""
1277 repo = _make_repo(tmp_path)
1278 for name in ("a", "b", "c"):
1279 _add_branch(repo, name)
1280 _invoke(repo, ["worktree", "add", name, name])
1281 entries = _parse_list(_invoke(repo, ["worktree", "list", "-j"]).output)
1282 assert sum(1 for e in entries if e["is_main"]) == 1
1283
1284 def test_list_json_linked_is_main_false(self, tmp_path: pathlib.Path) -> None:
1285 """All linked worktrees have is_main=false."""
1286 repo = _make_repo(tmp_path)
1287 _add_branch(repo, "dev")
1288 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1289 entries = _parse_list(_invoke(repo, ["worktree", "list", "-j"]).output)
1290 linked = [e for e in entries if not e["is_main"]]
1291 assert all(e["is_main"] is False for e in linked)
1292
1293 def test_list_outside_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
1294 """Running outside a repo exits with code 2."""
1295 result = runner.invoke(None, ["worktree", "list"], env={"MUSE_REPO_ROOT": str(tmp_path)})
1296 assert result.exit_code == 2
1297
1298 def test_list_json_output_is_array(self, tmp_path: pathlib.Path) -> None:
1299 """JSON output must be a JSON array, not an object."""
1300 repo = _make_repo(tmp_path)
1301 result = _invoke(repo, ["worktree", "list", "-j"])
1302 raw = json.loads(_json_blob(result.output))
1303 assert isinstance(raw, list)
1304
1305 def test_list_json_count_matches_worktrees(self, tmp_path: pathlib.Path) -> None:
1306 """JSON array length equals 1 (main) + number of linked worktrees."""
1307 repo = _make_repo(tmp_path)
1308 n = 5
1309 for i in range(n):
1310 _add_branch(repo, f"br{i}")
1311 _invoke(repo, ["worktree", "add", f"wt{i}", f"br{i}"])
1312 entries = _parse_list(_invoke(repo, ["worktree", "list", "-j"]).output)
1313 assert len(entries) == n + 1
1314
1315 def test_list_help_has_description(self, tmp_path: pathlib.Path) -> None:
1316 """--help output includes the rich description text."""
1317 result = _invoke(tmp_path, ["worktree", "list", "--help"])
1318 assert result.exit_code == 0
1319 assert "Agent quickstart" in result.output or "JSON output schema" in result.output
1320
1321 def test_list_text_shows_worktree_name(self, tmp_path: pathlib.Path) -> None:
1322 """Text output includes linked worktree name."""
1323 repo = _make_repo(tmp_path)
1324 _add_branch(repo, "dev")
1325 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1326 result = _invoke(repo, ["worktree", "list"])
1327 assert "mydev" in result.output
1328
1329 def test_list_text_marks_main_with_star(self, tmp_path: pathlib.Path) -> None:
1330 """Main worktree row starts with '*' in text output."""
1331 repo = _make_repo(tmp_path)
1332 result = _invoke(repo, ["worktree", "list"])
1333 lines = result.output.splitlines()
1334 assert any(l.startswith("* ") for l in lines)
1335
1336 def test_list_after_remove_not_shown(self, tmp_path: pathlib.Path) -> None:
1337 """A removed worktree no longer appears in list."""
1338 repo = _make_repo(tmp_path)
1339 _add_branch(repo, "dev")
1340 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1341 _invoke(repo, ["worktree", "remove", "mydev"])
1342 entries = _parse_list(_invoke(repo, ["worktree", "list", "-j"]).output)
1343 assert all(e["name"] != "mydev" for e in entries)
1344
1345 def test_list_json_branch_matches_added_branch(self, tmp_path: pathlib.Path) -> None:
1346 """Branch field in list JSON matches the branch used at add time."""
1347 repo = _make_repo(tmp_path)
1348 _add_branch(repo, "feat/x")
1349 _invoke(repo, ["worktree", "add", "feat-x", "feat/x"])
1350 entries = _parse_list(_invoke(repo, ["worktree", "list", "-j"]).output)
1351 feat = next(e for e in entries if e["name"] == "feat-x")
1352 assert feat["branch"] == "feat/x"
1353
1354
1355 class TestWorktreeListSecurity:
1356 """ANSI sanitization, path integrity, error routing."""
1357
1358 def test_list_json_ansi_in_branch_sanitized(self, tmp_path: pathlib.Path) -> None:
1359 """ANSI codes injected into stored branch name are stripped from JSON."""
1360 repo = _make_repo(tmp_path)
1361 poisoned = "dev\x1b[31mred\x1b[0m"
1362 # Write the metadata directly so we bypass name validation.
1363 meta_dir = repo / ".muse" / "worktrees"
1364 meta_dir.mkdir(parents=True, exist_ok=True)
1365 import json as _json
1366 (meta_dir / "mywt.json").write_text(_json.dumps({"name": "mywt", "branch": poisoned, "path": str(repo / "mywt")}))
1367 # Create the worktree path so it is "present".
1368 (repo / "mywt").mkdir()
1369 result = _invoke(repo, ["worktree", "list", "-j"])
1370 assert result.exit_code == 0
1371 raw = _json.loads(_json_blob(result.output))
1372 wt = next((e for e in raw if e["name"] == "mywt"), None)
1373 assert wt is not None
1374 assert "\x1b" not in wt["branch"]
1375
1376 def test_list_text_ansi_in_branch_sanitized(self, tmp_path: pathlib.Path) -> None:
1377 """ANSI codes injected into stored branch are stripped from text output."""
1378 repo = _make_repo(tmp_path)
1379 poisoned = "dev\x1b[31mred\x1b[0m"
1380 meta_dir = repo / ".muse" / "worktrees"
1381 meta_dir.mkdir(parents=True, exist_ok=True)
1382 import json as _json
1383 (meta_dir / "mywt.json").write_text(_json.dumps({"name": "mywt", "branch": poisoned, "path": str(repo / "mywt")}))
1384 (repo / "mywt").mkdir()
1385 result = _invoke(repo, ["worktree", "list"])
1386 assert "\x1b" not in result.output
1387
1388 def test_list_error_to_stderr_not_stdout(self, tmp_path: pathlib.Path) -> None:
1389 """Error output goes to stderr, not stdout."""
1390 result = runner.invoke(None, ["worktree", "list"], env={"MUSE_REPO_ROOT": str(tmp_path)})
1391 assert result.exit_code != 0
1392 assert result.stdout == "" or not result.stdout.strip().startswith("[")
1393
1394 def test_list_json_path_never_empty(self, tmp_path: pathlib.Path) -> None:
1395 """path field in JSON is always a non-empty string."""
1396 repo = _make_repo(tmp_path)
1397 _add_branch(repo, "dev")
1398 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1399 entries = _parse_list(_invoke(repo, ["worktree", "list", "-j"]).output)
1400 for entry in entries:
1401 assert entry["path"]
1402
1403 def test_list_json_is_valid_json(self, tmp_path: pathlib.Path) -> None:
1404 """Output is well-formed JSON (no trailing commas, correct types)."""
1405 repo = _make_repo(tmp_path)
1406 _add_branch(repo, "dev")
1407 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1408 result = _invoke(repo, ["worktree", "list", "-j"])
1409 parsed = json.loads(_json_blob(result.output))
1410 assert isinstance(parsed, list)
1411 for entry in parsed:
1412 assert isinstance(entry["is_main"], bool)
1413 assert isinstance(entry["name"], str)
1414
1415 def test_list_json_ansi_in_name_sanitized(self, tmp_path: pathlib.Path) -> None:
1416 """ANSI codes injected into stored worktree name are stripped from JSON."""
1417 repo = _make_repo(tmp_path)
1418 poisoned_name = "mywt"
1419 branch = "dev"
1420 meta_dir = repo / ".muse" / "worktrees"
1421 meta_dir.mkdir(parents=True, exist_ok=True)
1422 import json as _json
1423 (meta_dir / f"{poisoned_name}.json").write_text(
1424 _json.dumps({"name": poisoned_name, "branch": branch, "path": str(repo / poisoned_name)})
1425 )
1426 (repo / poisoned_name).mkdir()
1427 # Patch the name field as returned by list_worktrees via WorktreeInfo
1428 # by writing a meta file with a name that would contain ANSI if stored.
1429 # Since name comes from filename stem (already safe), verify the
1430 # sanitize_display path is exercised by injecting directly into the
1431 # raw output path — branch covers this adequately (tested above).
1432 result = _invoke(repo, ["worktree", "list", "-j"])
1433 assert "\x1b" not in result.output
1434
1435
1436 class TestWorktreeListStress:
1437 """Performance and scale tests for worktree list."""
1438
1439 def test_list_20_worktrees(self, tmp_path: pathlib.Path) -> None:
1440 """List with 20 linked worktrees returns all 21 entries (main + 20)."""
1441 repo = _make_repo(tmp_path)
1442 for i in range(20):
1443 _add_branch(repo, f"br{i}")
1444 _invoke(repo, ["worktree", "add", f"wt{i}", f"br{i}"])
1445 entries = _parse_list(_invoke(repo, ["worktree", "list", "-j"]).output)
1446 assert len(entries) == 21
1447
1448 def test_list_performance(self, tmp_path: pathlib.Path) -> None:
1449 """Listing 15 worktrees completes within 2 seconds."""
1450 import time
1451 repo = _make_repo(tmp_path)
1452 for i in range(15):
1453 _add_branch(repo, f"br{i}")
1454 _invoke(repo, ["worktree", "add", f"wt{i}", f"br{i}"])
1455 t0 = time.monotonic()
1456 result = _invoke(repo, ["worktree", "list", "-j"])
1457 elapsed = time.monotonic() - t0
1458 assert result.exit_code == 0
1459 assert elapsed < 2.0, f"list took {elapsed:.2f}s"
1460
1461 def test_list_concurrent_reads(self, tmp_path: pathlib.Path) -> None:
1462 """Concurrent list invocations via threads all return consistent counts."""
1463 repo = _make_repo(tmp_path)
1464 for i in range(10):
1465 _add_branch(repo, f"br{i}")
1466 _invoke(repo, ["worktree", "add", f"wt{i}", f"br{i}"])
1467 results: list[int] = []
1468 errors: list[str] = []
1469 lock = threading.Lock()
1470
1471 def _run() -> None:
1472 r = _invoke(repo, ["worktree", "list", "-j"])
1473 with lock:
1474 if r.exit_code != 0:
1475 errors.append(r.output)
1476 return
1477 try:
1478 entries = json.loads(_json_blob(r.output))
1479 results.append(len(entries))
1480 except (json.JSONDecodeError, ValueError) as exc:
1481 errors.append(f"parse error: {exc!r} output={r.output!r}")
1482
1483 threads = [threading.Thread(target=_run) for _ in range(8)]
1484 for t in threads:
1485 t.start()
1486 for t in threads:
1487 t.join()
1488 assert not errors, f"Concurrent list errors: {errors}"
1489 assert all(n == 11 for n in results), f"Inconsistent counts: {results}"
1490
1491
1492 # ===========================================================================
1493 # muse worktree status — Extended / Security / Stress
1494 # ===========================================================================
1495
1496
1497 class TestWorktreeStatusExtended:
1498 """-j alias, text output, main aliases, all fields, edge cases."""
1499
1500 def test_status_j_alias(self, tmp_path: pathlib.Path) -> None:
1501 """-j produces the same JSON as --json."""
1502 repo = _make_repo(tmp_path)
1503 r1 = _invoke(repo, ["worktree", "status", "main", "--json"])
1504 r2 = _invoke(repo, ["worktree", "status", "main", "-j"])
1505 assert r1.exit_code == 0 and r2.exit_code == 0
1506 assert json.loads(_json_blob(r1.output)) == json.loads(_json_blob(r2.output))
1507
1508 def test_status_main_alias_main(self, tmp_path: pathlib.Path) -> None:
1509 """'main' resolves to the main worktree."""
1510 repo = _make_repo(tmp_path)
1511 result = _invoke(repo, ["worktree", "status", "main", "-j"])
1512 assert result.exit_code == 0
1513 parsed = _parse_status(result.output)
1514 assert parsed["is_main"] is True
1515
1516 def test_status_main_alias_paren_main(self, tmp_path: pathlib.Path) -> None:
1517 """'(main)' resolves to the main worktree."""
1518 repo = _make_repo(tmp_path)
1519 result = _invoke(repo, ["worktree", "status", "(main)", "-j"])
1520 assert result.exit_code == 0
1521 parsed = _parse_status(result.output)
1522 assert parsed["is_main"] is True
1523
1524 def test_status_linked_present(self, tmp_path: pathlib.Path) -> None:
1525 """present=true when the worktree directory exists."""
1526 repo = _make_repo(tmp_path)
1527 _add_branch(repo, "dev")
1528 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1529 parsed = _parse_status(_invoke(repo, ["worktree", "status", "mydev", "-j"]).output)
1530 assert parsed["present"] is True
1531
1532 def test_status_linked_absent(self, tmp_path: pathlib.Path) -> None:
1533 """present=false when the worktree directory has been deleted."""
1534 import shutil
1535 from muse.core.worktree import _worktree_dir
1536 repo = _make_repo(tmp_path)
1537 _add_branch(repo, "dev")
1538 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1539 shutil.rmtree(_worktree_dir(repo, "mydev"))
1540 parsed = _parse_status(_invoke(repo, ["worktree", "status", "mydev", "-j"]).output)
1541 assert parsed["present"] is False
1542
1543 def test_status_nonexistent_exits_1(self, tmp_path: pathlib.Path) -> None:
1544 """Querying a nonexistent worktree exits with code 1."""
1545 repo = _make_repo(tmp_path)
1546 result = _invoke(repo, ["worktree", "status", "ghost", "-j"])
1547 assert result.exit_code == 1
1548
1549 def test_status_outside_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
1550 """Running outside a repo exits with code 2."""
1551 result = runner.invoke(None, ["worktree", "status", "main"], env={"MUSE_REPO_ROOT": str(tmp_path)})
1552 assert result.exit_code == 2
1553
1554 def test_status_json_all_fields_present(self, tmp_path: pathlib.Path) -> None:
1555 """JSON output contains all six required fields."""
1556 repo = _make_repo(tmp_path)
1557 raw = json.loads(_json_blob(_invoke(repo, ["worktree", "status", "main", "-j"]).output))
1558 for field in ("name", "branch", "path", "head_commit", "present", "is_main"):
1559 assert field in raw, f"field '{field}' missing"
1560
1561 def test_status_json_path_is_absolute(self, tmp_path: pathlib.Path) -> None:
1562 """path field in JSON is absolute."""
1563 repo = _make_repo(tmp_path)
1564 parsed = _parse_status(_invoke(repo, ["worktree", "status", "main", "-j"]).output)
1565 assert pathlib.Path(parsed["path"]).is_absolute()
1566
1567 def test_status_json_branch_matches(self, tmp_path: pathlib.Path) -> None:
1568 """branch field matches the branch the worktree was created with."""
1569 repo = _make_repo(tmp_path)
1570 _add_branch(repo, "feat/x")
1571 _invoke(repo, ["worktree", "add", "feat-x", "feat/x"])
1572 parsed = _parse_status(_invoke(repo, ["worktree", "status", "feat-x", "-j"]).output)
1573 assert parsed["branch"] == "feat/x"
1574
1575 def test_status_json_is_main_false_for_linked(self, tmp_path: pathlib.Path) -> None:
1576 """is_main=false for a linked worktree."""
1577 repo = _make_repo(tmp_path)
1578 _add_branch(repo, "dev")
1579 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1580 parsed = _parse_status(_invoke(repo, ["worktree", "status", "mydev", "-j"]).output)
1581 assert parsed["is_main"] is False
1582
1583 def test_status_default_is_text(self, tmp_path: pathlib.Path) -> None:
1584 """Without --json output is human-readable text, not JSON."""
1585 repo = _make_repo(tmp_path)
1586 result = _invoke(repo, ["worktree", "status", "main"])
1587 assert result.exit_code == 0
1588 output = result.output.strip()
1589 assert not output.startswith("{")
1590
1591 def test_status_text_shows_branch(self, tmp_path: pathlib.Path) -> None:
1592 """Text output includes the branch name."""
1593 repo = _make_repo(tmp_path)
1594 result = _invoke(repo, ["worktree", "status", "main"])
1595 assert "main" in result.output
1596
1597 def test_status_text_shows_present(self, tmp_path: pathlib.Path) -> None:
1598 """Text output indicates the worktree is present."""
1599 repo = _make_repo(tmp_path)
1600 result = _invoke(repo, ["worktree", "status", "main"])
1601 assert "present" in result.output
1602
1603 def test_status_text_main_flag(self, tmp_path: pathlib.Path) -> None:
1604 """Text output marks the main worktree with '[main]'."""
1605 repo = _make_repo(tmp_path)
1606 result = _invoke(repo, ["worktree", "status", "main"])
1607 assert "[main]" in result.output
1608
1609 def test_status_help_has_description(self, tmp_path: pathlib.Path) -> None:
1610 """--help includes the rich description."""
1611 result = _invoke(tmp_path, ["worktree", "status", "--help"])
1612 assert result.exit_code == 0
1613 assert "Agent quickstart" in result.output or "JSON output schema" in result.output
1614
1615 def test_status_head_commit_null_for_empty_branch(self, tmp_path: pathlib.Path) -> None:
1616 """head_commit is null when no commits exist on the branch."""
1617 repo = _make_repo(tmp_path)
1618 parsed = _parse_status(_invoke(repo, ["worktree", "status", "main", "-j"]).output)
1619 # The test repo uses "0"*64 as the ref — get_head_commit_id may return
1620 # that or None depending on whether the commit object exists.
1621 assert parsed["head_commit"] is None or isinstance(parsed["head_commit"], str)
1622
1623 def test_status_json_name_matches_requested(self, tmp_path: pathlib.Path) -> None:
1624 """name field in JSON matches the queried worktree name."""
1625 repo = _make_repo(tmp_path)
1626 _add_branch(repo, "dev")
1627 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1628 parsed = _parse_status(_invoke(repo, ["worktree", "status", "mydev", "-j"]).output)
1629 assert parsed["name"] == "mydev"
1630
1631 def test_status_json_is_valid_json(self, tmp_path: pathlib.Path) -> None:
1632 """Output is well-formed JSON."""
1633 repo = _make_repo(tmp_path)
1634 result = _invoke(repo, ["worktree", "status", "main", "-j"])
1635 parsed = json.loads(_json_blob(result.output))
1636 assert isinstance(parsed, dict)
1637 assert isinstance(parsed["is_main"], bool)
1638 assert isinstance(parsed["present"], bool)
1639
1640
1641 class TestWorktreeStatusSecurity:
1642 """ANSI sanitization and error routing for worktree status."""
1643
1644 def test_status_json_ansi_in_branch_sanitized(self, tmp_path: pathlib.Path) -> None:
1645 """ANSI codes in stored branch are stripped from JSON output."""
1646 repo = _make_repo(tmp_path)
1647 import json as _json
1648 poisoned = "dev\x1b[31mred\x1b[0m"
1649 meta_dir = repo / ".muse" / "worktrees"
1650 meta_dir.mkdir(parents=True, exist_ok=True)
1651 (meta_dir / "mywt.json").write_text(_json.dumps({"name": "mywt", "branch": poisoned, "path": str(repo / "mywt")}))
1652 (repo / "mywt").mkdir()
1653 result = _invoke(repo, ["worktree", "status", "mywt", "-j"])
1654 assert result.exit_code == 0
1655 raw = _json.loads(_json_blob(result.output))
1656 assert "\x1b" not in raw["branch"]
1657
1658 def test_status_text_ansi_in_branch_sanitized(self, tmp_path: pathlib.Path) -> None:
1659 """ANSI codes in stored branch are stripped from text output."""
1660 repo = _make_repo(tmp_path)
1661 import json as _json
1662 poisoned = "dev\x1b[31mred\x1b[0m"
1663 meta_dir = repo / ".muse" / "worktrees"
1664 meta_dir.mkdir(parents=True, exist_ok=True)
1665 (meta_dir / "mywt.json").write_text(_json.dumps({"name": "mywt", "branch": poisoned, "path": str(repo / "mywt")}))
1666 (repo / "mywt").mkdir()
1667 result = _invoke(repo, ["worktree", "status", "mywt"])
1668 assert "\x1b" not in result.output
1669
1670 def test_status_error_to_stderr(self, tmp_path: pathlib.Path) -> None:
1671 """Error output goes to stderr, not stdout."""
1672 repo = _make_repo(tmp_path)
1673 result = _invoke(repo, ["worktree", "status", "ghost"])
1674 assert result.exit_code != 0
1675 assert result.stdout.strip() == "" or not result.stdout.strip().startswith("{")
1676
1677 def test_status_json_path_never_empty(self, tmp_path: pathlib.Path) -> None:
1678 """path is always a non-empty string."""
1679 repo = _make_repo(tmp_path)
1680 parsed = _parse_status(_invoke(repo, ["worktree", "status", "main", "-j"]).output)
1681 assert parsed["path"]
1682
1683 def test_status_json_ansi_in_path_sanitized(self, tmp_path: pathlib.Path) -> None:
1684 """ANSI codes in stored path are stripped from JSON output."""
1685 repo = _make_repo(tmp_path)
1686 import json as _json
1687 meta_dir = repo / ".muse" / "worktrees"
1688 meta_dir.mkdir(parents=True, exist_ok=True)
1689 poisoned_path = str(repo / "mywt") + "\x1b[31m"
1690 (meta_dir / "mywt.json").write_text(_json.dumps({"name": "mywt", "branch": "dev", "path": poisoned_path}))
1691 (repo / "mywt").mkdir()
1692 result = _invoke(repo, ["worktree", "status", "mywt", "-j"])
1693 assert result.exit_code == 0
1694 raw = _json.loads(_json_blob(result.output))
1695 assert "\x1b" not in raw["path"]
1696
1697 def test_status_invalid_name_exits_1(self, tmp_path: pathlib.Path) -> None:
1698 """An invalid worktree name (path traversal attempt) exits with 1."""
1699 repo = _make_repo(tmp_path)
1700 result = _invoke(repo, ["worktree", "status", "../../../etc/passwd"])
1701 assert result.exit_code == 1
1702
1703
1704 class TestWorktreeStatusStress:
1705 """Performance and scale tests for worktree status."""
1706
1707 def test_status_10_sequential(self, tmp_path: pathlib.Path) -> None:
1708 """Querying status of 10 worktrees sequentially all succeed."""
1709 repo = _make_repo(tmp_path)
1710 for i in range(10):
1711 _add_branch(repo, f"br{i}")
1712 _invoke(repo, ["worktree", "add", f"wt{i}", f"br{i}"])
1713 for i in range(10):
1714 result = _invoke(repo, ["worktree", "status", f"wt{i}", "-j"])
1715 assert result.exit_code == 0
1716 parsed = _parse_status(result.output)
1717 assert parsed["name"] == f"wt{i}"
1718
1719 def test_status_performance(self, tmp_path: pathlib.Path) -> None:
1720 """10 sequential status queries complete within 2 seconds."""
1721 import time
1722 repo = _make_repo(tmp_path)
1723 for i in range(10):
1724 _add_branch(repo, f"br{i}")
1725 _invoke(repo, ["worktree", "add", f"wt{i}", f"br{i}"])
1726 t0 = time.monotonic()
1727 for i in range(10):
1728 _invoke(repo, ["worktree", "status", f"wt{i}", "-j"])
1729 elapsed = time.monotonic() - t0
1730 assert elapsed < 2.0, f"10 status queries took {elapsed:.2f}s"
1731
1732 def test_status_concurrent_reads(self, tmp_path: pathlib.Path) -> None:
1733 """Concurrent status queries against the same worktree all succeed."""
1734 repo = _make_repo(tmp_path)
1735 _add_branch(repo, "dev")
1736 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1737 errors: list[str] = []
1738 lock = threading.Lock()
1739
1740 def _run() -> None:
1741 r = _invoke(repo, ["worktree", "status", "mydev", "-j"])
1742 if r.exit_code != 0:
1743 with lock:
1744 errors.append(r.output)
1745
1746 threads = [threading.Thread(target=_run) for _ in range(8)]
1747 for t in threads:
1748 t.start()
1749 for t in threads:
1750 t.join()
1751 assert not errors, f"Concurrent status errors: {errors}"
1752
1753
1754 # ===========================================================================
1755 # muse worktree remove — Extended / Security / Stress
1756 # ===========================================================================
1757
1758
1759 class TestWorktreeRemoveExtended:
1760 """-j alias, text output, lifecycle, metadata cleanup, edge cases."""
1761
1762 def test_remove_j_alias(self, tmp_path: pathlib.Path) -> None:
1763 """-j produces the same JSON as --json."""
1764 repo = _make_repo(tmp_path)
1765 _add_branch(repo, "dev")
1766 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1767 result = _invoke(repo, ["worktree", "remove", "mydev", "-j"])
1768 assert result.exit_code == 0
1769 parsed = _parse_remove(result.output)
1770 assert parsed["name"] == "mydev"
1771 assert parsed["status"] == "removed"
1772
1773 def test_remove_default_is_text(self, tmp_path: pathlib.Path) -> None:
1774 """Without --json output is human-readable text."""
1775 repo = _make_repo(tmp_path)
1776 _add_branch(repo, "dev")
1777 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1778 result = _invoke(repo, ["worktree", "remove", "mydev"])
1779 assert result.exit_code == 0
1780 assert not result.output.strip().startswith("{")
1781 assert "mydev" in result.output
1782
1783 def test_remove_json_name_field(self, tmp_path: pathlib.Path) -> None:
1784 """JSON name field matches the removed worktree."""
1785 repo = _make_repo(tmp_path)
1786 _add_branch(repo, "feat/x")
1787 _invoke(repo, ["worktree", "add", "feat-x", "feat/x"])
1788 parsed = _parse_remove(_invoke(repo, ["worktree", "remove", "feat-x", "-j"]).output)
1789 assert parsed["name"] == "feat-x"
1790
1791 def test_remove_json_status_field(self, tmp_path: pathlib.Path) -> None:
1792 """JSON status field is always 'removed'."""
1793 repo = _make_repo(tmp_path)
1794 _add_branch(repo, "dev")
1795 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1796 parsed = _parse_remove(_invoke(repo, ["worktree", "remove", "mydev", "-j"]).output)
1797 assert parsed["status"] == "removed"
1798
1799 def test_remove_cleans_working_dir(self, tmp_path: pathlib.Path) -> None:
1800 """Worktree working directory is deleted after removal."""
1801 from muse.core.worktree import _worktree_dir
1802 repo = _make_repo(tmp_path)
1803 _add_branch(repo, "dev")
1804 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1805 wt_path = _worktree_dir(repo, "mydev")
1806 assert wt_path.exists()
1807 _invoke(repo, ["worktree", "remove", "mydev"])
1808 assert not wt_path.exists()
1809
1810 def test_remove_cleans_metadata(self, tmp_path: pathlib.Path) -> None:
1811 """Worktree metadata file is deleted after removal."""
1812 repo = _make_repo(tmp_path)
1813 _add_branch(repo, "dev")
1814 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1815 meta = repo / ".muse" / "worktrees" / "mydev.json"
1816 assert meta.exists()
1817 _invoke(repo, ["worktree", "remove", "mydev"])
1818 assert not meta.exists()
1819
1820 def test_remove_not_in_list_after(self, tmp_path: pathlib.Path) -> None:
1821 """Removed worktree does not appear in subsequent list."""
1822 repo = _make_repo(tmp_path)
1823 _add_branch(repo, "dev")
1824 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1825 _invoke(repo, ["worktree", "remove", "mydev"])
1826 entries = _parse_list(_invoke(repo, ["worktree", "list", "-j"]).output)
1827 assert all(e["name"] != "mydev" for e in entries)
1828
1829 def test_remove_nonexistent_exits_1(self, tmp_path: pathlib.Path) -> None:
1830 """Removing a nonexistent worktree exits with code 1."""
1831 repo = _make_repo(tmp_path)
1832 result = _invoke(repo, ["worktree", "remove", "ghost"])
1833 assert result.exit_code == 1
1834
1835 def test_remove_outside_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
1836 """Running outside a repo exits with code 2."""
1837 result = runner.invoke(None, ["worktree", "remove", "mydev"], env={"MUSE_REPO_ROOT": str(tmp_path)})
1838 assert result.exit_code == 2
1839
1840 def test_remove_force_flag_accepted(self, tmp_path: pathlib.Path) -> None:
1841 """--force flag is accepted without error."""
1842 repo = _make_repo(tmp_path)
1843 _add_branch(repo, "dev")
1844 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1845 result = _invoke(repo, ["worktree", "remove", "mydev", "--force", "-j"])
1846 assert result.exit_code == 0
1847 assert _parse_remove(result.output)["status"] == "removed"
1848
1849 def test_remove_json_all_fields(self, tmp_path: pathlib.Path) -> None:
1850 """JSON output contains exactly name and status fields."""
1851 repo = _make_repo(tmp_path)
1852 _add_branch(repo, "dev")
1853 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1854 raw = json.loads(_json_blob(_invoke(repo, ["worktree", "remove", "mydev", "-j"]).output))
1855 assert set(raw.keys()) == {"name", "status"}
1856
1857 def test_remove_help_has_description(self, tmp_path: pathlib.Path) -> None:
1858 """--help output includes the rich description."""
1859 result = _invoke(tmp_path, ["worktree", "remove", "--help"])
1860 assert result.exit_code == 0
1861 assert "Agent quickstart" in result.output or "JSON output schema" in result.output
1862
1863 def test_remove_idempotent_second_call_fails(self, tmp_path: pathlib.Path) -> None:
1864 """Removing the same worktree twice: second call exits 1."""
1865 repo = _make_repo(tmp_path)
1866 _add_branch(repo, "dev")
1867 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1868 _invoke(repo, ["worktree", "remove", "mydev"])
1869 result = _invoke(repo, ["worktree", "remove", "mydev"])
1870 assert result.exit_code == 1
1871
1872 def test_remove_multiple_sequential(self, tmp_path: pathlib.Path) -> None:
1873 """Multiple worktrees can be removed one after another."""
1874 repo = _make_repo(tmp_path)
1875 for name in ("a", "b", "c"):
1876 _add_branch(repo, name)
1877 _invoke(repo, ["worktree", "add", name, name])
1878 for name in ("a", "b", "c"):
1879 r = _invoke(repo, ["worktree", "remove", name, "-j"])
1880 assert r.exit_code == 0
1881 entries = _parse_list(_invoke(repo, ["worktree", "list", "-j"]).output)
1882 assert len(entries) == 1 # only main remains
1883
1884 def test_remove_leaves_branch_intact(self, tmp_path: pathlib.Path) -> None:
1885 """The branch ref still exists after the worktree is removed."""
1886 repo = _make_repo(tmp_path)
1887 _add_branch(repo, "dev")
1888 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1889 _invoke(repo, ["worktree", "remove", "mydev"])
1890 branch_ref = repo / ".muse" / "refs" / "heads" / "dev"
1891 assert branch_ref.exists()
1892
1893
1894 class TestWorktreeRemoveSecurity:
1895 """Safety guards, ANSI sanitization, and error routing."""
1896
1897 def test_remove_refuses_symlink_path(self, tmp_path: pathlib.Path) -> None:
1898 """Removal is refused when the worktree path is a symlink."""
1899 from muse.core.worktree import _worktree_meta_path
1900 import json as _json
1901 repo = _make_repo(tmp_path)
1902 _add_branch(repo, "dev")
1903 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1904 # Replace the worktree dir with a symlink to a safe directory.
1905 from muse.core.worktree import _worktree_dir
1906 wt_dir = _worktree_dir(repo, "mydev")
1907 safe_target = tmp_path / "safe"
1908 safe_target.mkdir()
1909 import shutil
1910 shutil.rmtree(wt_dir)
1911 wt_dir.symlink_to(safe_target)
1912 result = _invoke(repo, ["worktree", "remove", "mydev"])
1913 assert result.exit_code == 1
1914
1915 def test_remove_refuses_muse_overlap(self, tmp_path: pathlib.Path) -> None:
1916 """Removal is refused when the stored path resolves inside .muse/."""
1917 import json as _json
1918 repo = _make_repo(tmp_path)
1919 _add_branch(repo, "dev")
1920 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1921 # Create a real directory inside .muse/ so _safe_delete_path can
1922 # evaluate it (non-existent paths are a no-op, not a refusal).
1923 evil_dir = repo / ".muse" / "evil"
1924 evil_dir.mkdir(parents=True, exist_ok=True)
1925 # Tamper the metadata to point inside .muse/.
1926 meta = repo / ".muse" / "worktrees" / "mydev.json"
1927 data = _json.loads(meta.read_text())
1928 data["path"] = str(evil_dir)
1929 meta.write_text(_json.dumps(data))
1930 result = _invoke(repo, ["worktree", "remove", "mydev"])
1931 assert result.exit_code == 1
1932
1933 def test_remove_json_ansi_in_name_sanitized(self, tmp_path: pathlib.Path) -> None:
1934 """ANSI codes in the name argument are stripped from JSON output."""
1935 repo = _make_repo(tmp_path)
1936 _add_branch(repo, "dev")
1937 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1938 # The name goes through validate_branch_name at core level, but
1939 # sanitize_display is applied at CLI output level. Verify no ANSI leaks.
1940 result = _invoke(repo, ["worktree", "remove", "mydev", "-j"])
1941 assert result.exit_code == 0
1942 assert "\x1b" not in result.output
1943
1944 def test_remove_error_to_stderr(self, tmp_path: pathlib.Path) -> None:
1945 """Error output does not contain valid JSON (errors go to stderr)."""
1946 repo = _make_repo(tmp_path)
1947 result = _invoke(repo, ["worktree", "remove", "ghost"])
1948 assert result.exit_code != 0
1949 assert not result.stdout.strip().startswith("{")
1950
1951 def test_remove_invalid_name_exits_1(self, tmp_path: pathlib.Path) -> None:
1952 """Path-traversal name is rejected before any filesystem operation."""
1953 repo = _make_repo(tmp_path)
1954 result = _invoke(repo, ["worktree", "remove", "../../../etc/passwd"])
1955 assert result.exit_code == 1
1956
1957 def test_remove_json_is_valid_json(self, tmp_path: pathlib.Path) -> None:
1958 """JSON output is well-formed."""
1959 repo = _make_repo(tmp_path)
1960 _add_branch(repo, "dev")
1961 _invoke(repo, ["worktree", "add", "mydev", "dev"])
1962 result = _invoke(repo, ["worktree", "remove", "mydev", "-j"])
1963 parsed = json.loads(_json_blob(result.output))
1964 assert isinstance(parsed, dict)
1965 assert isinstance(parsed["name"], str)
1966 assert isinstance(parsed["status"], str)
1967
1968
1969 class TestWorktreeRemoveStress:
1970 """Performance and scale tests for worktree remove."""
1971
1972 def test_remove_20_sequential(self, tmp_path: pathlib.Path) -> None:
1973 """20 worktrees can be created and removed sequentially."""
1974 repo = _make_repo(tmp_path)
1975 for i in range(20):
1976 _add_branch(repo, f"br{i}")
1977 _invoke(repo, ["worktree", "add", f"wt{i}", f"br{i}"])
1978 failures = []
1979 for i in range(20):
1980 r = _invoke(repo, ["worktree", "remove", f"wt{i}", "-j"])
1981 if r.exit_code != 0:
1982 failures.append(f"wt{i}: {r.output.strip()[:60]}")
1983 assert not failures
1984 entries = _parse_list(_invoke(repo, ["worktree", "list", "-j"]).output)
1985 assert len(entries) == 1 # only main
1986
1987 def test_remove_performance(self, tmp_path: pathlib.Path) -> None:
1988 """Removing 10 worktrees sequentially completes within 2 seconds."""
1989 import time
1990 repo = _make_repo(tmp_path)
1991 for i in range(10):
1992 _add_branch(repo, f"br{i}")
1993 _invoke(repo, ["worktree", "add", f"wt{i}", f"br{i}"])
1994 t0 = time.monotonic()
1995 for i in range(10):
1996 _invoke(repo, ["worktree", "remove", f"wt{i}"])
1997 elapsed = time.monotonic() - t0
1998 assert elapsed < 2.0, f"10 removes took {elapsed:.2f}s"
1999
2000 def test_remove_add_remove_cycle(self, tmp_path: pathlib.Path) -> None:
2001 """A worktree can be re-added with the same name after removal."""
2002 repo = _make_repo(tmp_path)
2003 _add_branch(repo, "dev")
2004 for _ in range(5):
2005 r_add = _invoke(repo, ["worktree", "add", "mydev", "dev"])
2006 assert r_add.exit_code == 0
2007 r_rm = _invoke(repo, ["worktree", "remove", "mydev"])
2008 assert r_rm.exit_code == 0
2009
2010
2011 # ===========================================================================
2012 # muse worktree prune — Extended / Security / Stress
2013 # ===========================================================================
2014
2015
2016 class TestWorktreesPruneExtended:
2017 """-j alias, dry-run, text output, schema, lifecycle edge cases."""
2018
2019 def test_prune_j_alias(self, tmp_path: pathlib.Path) -> None:
2020 """-j produces the same JSON as --json."""
2021 repo = _make_repo(tmp_path)
2022 r1 = _invoke(repo, ["worktree", "prune", "--json"])
2023 r2 = _invoke(repo, ["worktree", "prune", "-j"])
2024 assert r1.exit_code == 0 and r2.exit_code == 0
2025 assert json.loads(_json_blob(r1.output)) == json.loads(_json_blob(r2.output))
2026
2027 def test_prune_empty_repo_exits_0(self, tmp_path: pathlib.Path) -> None:
2028 """Prune on a clean repo exits 0 with empty pruned list."""
2029 repo = _make_repo(tmp_path)
2030 result = _invoke(repo, ["worktree", "prune", "-j"])
2031 assert result.exit_code == 0
2032 parsed = _parse_prune(result.output)
2033 assert parsed["pruned"] == []
2034 assert parsed["count"] == 0
2035
2036 def test_prune_stale_in_json_pruned_list(self, tmp_path: pathlib.Path) -> None:
2037 """Stale worktree name appears in JSON pruned list."""
2038 import shutil
2039 from muse.core.worktree import _worktree_dir
2040 repo = _make_repo(tmp_path)
2041 _add_branch(repo, "dev")
2042 _invoke(repo, ["worktree", "add", "mydev", "dev"])
2043 shutil.rmtree(_worktree_dir(repo, "mydev"))
2044 parsed = _parse_prune(_invoke(repo, ["worktree", "prune", "-j"]).output)
2045 assert "mydev" in parsed["pruned"]
2046 assert parsed["count"] == 1
2047 assert parsed["dry_run"] is False
2048
2049 def test_prune_removes_metadata(self, tmp_path: pathlib.Path) -> None:
2050 """Prune deletes the metadata file for a stale worktree."""
2051 import shutil
2052 from muse.core.worktree import _worktree_dir
2053 repo = _make_repo(tmp_path)
2054 _add_branch(repo, "dev")
2055 _invoke(repo, ["worktree", "add", "mydev", "dev"])
2056 shutil.rmtree(_worktree_dir(repo, "mydev"))
2057 meta = repo / ".muse" / "worktrees" / "mydev.json"
2058 assert meta.exists()
2059 _invoke(repo, ["worktree", "prune"])
2060 assert not meta.exists()
2061
2062 def test_prune_dry_run_preserves_metadata(self, tmp_path: pathlib.Path) -> None:
2063 """--dry-run does not delete any metadata."""
2064 import shutil
2065 from muse.core.worktree import _worktree_dir
2066 repo = _make_repo(tmp_path)
2067 _add_branch(repo, "dev")
2068 _invoke(repo, ["worktree", "add", "mydev", "dev"])
2069 shutil.rmtree(_worktree_dir(repo, "mydev"))
2070 meta = repo / ".muse" / "worktrees" / "mydev.json"
2071 _invoke(repo, ["worktree", "prune", "--dry-run"])
2072 assert meta.exists()
2073
2074 def test_prune_dry_run_json_dry_run_true(self, tmp_path: pathlib.Path) -> None:
2075 """JSON dry_run field is true when --dry-run is passed."""
2076 import shutil
2077 from muse.core.worktree import _worktree_dir
2078 repo = _make_repo(tmp_path)
2079 _add_branch(repo, "dev")
2080 _invoke(repo, ["worktree", "add", "mydev", "dev"])
2081 shutil.rmtree(_worktree_dir(repo, "mydev"))
2082 parsed = _parse_prune(_invoke(repo, ["worktree", "prune", "--dry-run", "-j"]).output)
2083 assert parsed["dry_run"] is True
2084 assert "mydev" in parsed["pruned"]
2085
2086 def test_prune_active_worktree_not_pruned(self, tmp_path: pathlib.Path) -> None:
2087 """Active (present) worktrees are not pruned."""
2088 repo = _make_repo(tmp_path)
2089 _add_branch(repo, "dev")
2090 _invoke(repo, ["worktree", "add", "mydev", "dev"])
2091 parsed = _parse_prune(_invoke(repo, ["worktree", "prune", "-j"]).output)
2092 assert "mydev" not in parsed["pruned"]
2093 assert parsed["count"] == 0
2094
2095 def test_prune_default_is_text(self, tmp_path: pathlib.Path) -> None:
2096 """Without --json the output is human-readable text."""
2097 repo = _make_repo(tmp_path)
2098 result = _invoke(repo, ["worktree", "prune"])
2099 assert result.exit_code == 0
2100 assert not result.output.strip().startswith("{")
2101
2102 def test_prune_text_nothing_to_prune(self, tmp_path: pathlib.Path) -> None:
2103 """Text output says 'Nothing to prune' when clean."""
2104 repo = _make_repo(tmp_path)
2105 result = _invoke(repo, ["worktree", "prune"])
2106 assert "Nothing to prune" in result.output
2107
2108 def test_prune_text_shows_stale_names(self, tmp_path: pathlib.Path) -> None:
2109 """Text output includes names of pruned worktrees."""
2110 import shutil
2111 from muse.core.worktree import _worktree_dir
2112 repo = _make_repo(tmp_path)
2113 _add_branch(repo, "dev")
2114 _invoke(repo, ["worktree", "add", "mydev", "dev"])
2115 shutil.rmtree(_worktree_dir(repo, "mydev"))
2116 result = _invoke(repo, ["worktree", "prune"])
2117 assert "mydev" in result.output
2118
2119 def test_prune_count_matches_pruned_list(self, tmp_path: pathlib.Path) -> None:
2120 """count field always equals len(pruned)."""
2121 import shutil
2122 from muse.core.worktree import _worktree_dir
2123 repo = _make_repo(tmp_path)
2124 for i in range(4):
2125 _add_branch(repo, f"br{i}")
2126 _invoke(repo, ["worktree", "add", f"wt{i}", f"br{i}"])
2127 shutil.rmtree(_worktree_dir(repo, f"wt{i}"))
2128 parsed = _parse_prune(_invoke(repo, ["worktree", "prune", "-j"]).output)
2129 assert parsed["count"] == len(parsed["pruned"]) == 4
2130
2131 def test_prune_outside_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
2132 """Running outside a repo exits with code 2."""
2133 result = runner.invoke(None, ["worktree", "prune"], env={"MUSE_REPO_ROOT": str(tmp_path)})
2134 assert result.exit_code == 2
2135
2136 def test_prune_help_has_description(self, tmp_path: pathlib.Path) -> None:
2137 """--help includes the rich description."""
2138 result = _invoke(tmp_path, ["worktree", "prune", "--help"])
2139 assert result.exit_code == 0
2140 assert "Agent quickstart" in result.output or "JSON output schema" in result.output
2141
2142 def test_prune_leaves_branch_refs_intact(self, tmp_path: pathlib.Path) -> None:
2143 """Branch refs are never deleted by prune."""
2144 import shutil
2145 from muse.core.worktree import _worktree_dir
2146 repo = _make_repo(tmp_path)
2147 _add_branch(repo, "dev")
2148 _invoke(repo, ["worktree", "add", "mydev", "dev"])
2149 shutil.rmtree(_worktree_dir(repo, "mydev"))
2150 _invoke(repo, ["worktree", "prune"])
2151 assert (repo / ".muse" / "refs" / "heads" / "dev").exists()
2152
2153 def test_prune_after_prune_nothing_left(self, tmp_path: pathlib.Path) -> None:
2154 """Running prune twice: second call reports nothing to prune."""
2155 import shutil
2156 from muse.core.worktree import _worktree_dir
2157 repo = _make_repo(tmp_path)
2158 _add_branch(repo, "dev")
2159 _invoke(repo, ["worktree", "add", "mydev", "dev"])
2160 shutil.rmtree(_worktree_dir(repo, "mydev"))
2161 _invoke(repo, ["worktree", "prune"])
2162 parsed = _parse_prune(_invoke(repo, ["worktree", "prune", "-j"]).output)
2163 assert parsed["count"] == 0
2164
2165 def test_prune_json_is_array_for_pruned(self, tmp_path: pathlib.Path) -> None:
2166 """pruned field is always a JSON array."""
2167 repo = _make_repo(tmp_path)
2168 raw = json.loads(_json_blob(_invoke(repo, ["worktree", "prune", "-j"]).output))
2169 assert isinstance(raw["pruned"], list)
2170
2171 def test_prune_json_all_fields(self, tmp_path: pathlib.Path) -> None:
2172 """JSON output contains exactly pruned, count, dry_run."""
2173 repo = _make_repo(tmp_path)
2174 raw = json.loads(_json_blob(_invoke(repo, ["worktree", "prune", "-j"]).output))
2175 assert set(raw.keys()) == {"pruned", "count", "dry_run"}
2176
2177
2178 class TestWorktreePruneSecurity:
2179 """ANSI sanitization, corrupt metadata handling."""
2180
2181 def test_prune_json_ansi_in_name_sanitized(self, tmp_path: pathlib.Path) -> None:
2182 """ANSI codes in worktree names are stripped from JSON pruned list."""
2183 import json as _json
2184 repo = _make_repo(tmp_path)
2185 # Write a meta file whose stem contains no ANSI (filesystem prevents it),
2186 # but whose stored branch field has ANSI — prune goes by filename stem
2187 # for the name. Verify the output contains no escape sequences.
2188 meta_dir = repo / ".muse" / "worktrees"
2189 meta_dir.mkdir(parents=True, exist_ok=True)
2190 (meta_dir / "mywt.json").write_text(_json.dumps({"name": "mywt", "branch": "dev", "path": str(repo / "mywt")}))
2191 # mywt dir does not exist → stale → will be pruned
2192 result = _invoke(repo, ["worktree", "prune", "-j"])
2193 assert result.exit_code == 0
2194 assert "\x1b" not in result.output
2195
2196 def test_prune_text_ansi_in_name_sanitized(self, tmp_path: pathlib.Path) -> None:
2197 """ANSI codes in worktree names are stripped from text output."""
2198 import json as _json
2199 repo = _make_repo(tmp_path)
2200 meta_dir = repo / ".muse" / "worktrees"
2201 meta_dir.mkdir(parents=True, exist_ok=True)
2202 (meta_dir / "mywt.json").write_text(_json.dumps({"name": "mywt", "branch": "dev", "path": str(repo / "mywt")}))
2203 result = _invoke(repo, ["worktree", "prune"])
2204 assert "\x1b" not in result.output
2205
2206 def test_prune_corrupt_meta_pruned_safely(self, tmp_path: pathlib.Path) -> None:
2207 """A corrupt (unparseable) metadata file is treated as stale and pruned."""
2208 repo = _make_repo(tmp_path)
2209 meta_dir = repo / ".muse" / "worktrees"
2210 meta_dir.mkdir(parents=True, exist_ok=True)
2211 (meta_dir / "corrupt.json").write_text("NOT VALID JSON {{{")
2212 result = _invoke(repo, ["worktree", "prune", "-j"])
2213 assert result.exit_code == 0
2214 parsed = _parse_prune(result.output)
2215 assert "corrupt" in parsed["pruned"]
2216
2217 def test_prune_json_is_valid_json(self, tmp_path: pathlib.Path) -> None:
2218 """Output is well-formed JSON."""
2219 repo = _make_repo(tmp_path)
2220 result = _invoke(repo, ["worktree", "prune", "-j"])
2221 parsed = json.loads(_json_blob(result.output))
2222 assert isinstance(parsed["pruned"], list)
2223 assert isinstance(parsed["count"], int)
2224 assert isinstance(parsed["dry_run"], bool)
2225
2226 def test_prune_dry_run_no_side_effects(self, tmp_path: pathlib.Path) -> None:
2227 """--dry-run produces no filesystem side effects at all."""
2228 import shutil
2229 from muse.core.worktree import _worktree_dir
2230 repo = _make_repo(tmp_path)
2231 for i in range(3):
2232 _add_branch(repo, f"br{i}")
2233 _invoke(repo, ["worktree", "add", f"wt{i}", f"br{i}"])
2234 shutil.rmtree(_worktree_dir(repo, f"wt{i}"))
2235 metas_before = sorted(p.name for p in (repo / ".muse" / "worktrees").glob("*.json"))
2236 _invoke(repo, ["worktree", "prune", "--dry-run"])
2237 metas_after = sorted(p.name for p in (repo / ".muse" / "worktrees").glob("*.json"))
2238 assert metas_before == metas_after
2239
2240
2241 class TestWorktreePruneStress:
2242 """Performance and scale tests for worktree prune."""
2243
2244 def test_prune_20_stale(self, tmp_path: pathlib.Path) -> None:
2245 """Pruning 20 stale entries reports count=20."""
2246 import shutil
2247 from muse.core.worktree import _worktree_dir
2248 repo = _make_repo(tmp_path)
2249 for i in range(20):
2250 _add_branch(repo, f"br{i}")
2251 _invoke(repo, ["worktree", "add", f"wt{i}", f"br{i}"])
2252 shutil.rmtree(_worktree_dir(repo, f"wt{i}"))
2253 parsed = _parse_prune(_invoke(repo, ["worktree", "prune", "-j"]).output)
2254 assert parsed["count"] == 20
2255 assert len(parsed["pruned"]) == 20
2256
2257 def test_prune_performance(self, tmp_path: pathlib.Path) -> None:
2258 """Pruning 15 stale worktrees completes within 2 seconds."""
2259 import shutil
2260 import time
2261 from muse.core.worktree import _worktree_dir
2262 repo = _make_repo(tmp_path)
2263 for i in range(15):
2264 _add_branch(repo, f"br{i}")
2265 _invoke(repo, ["worktree", "add", f"wt{i}", f"br{i}"])
2266 shutil.rmtree(_worktree_dir(repo, f"wt{i}"))
2267 t0 = time.monotonic()
2268 result = _invoke(repo, ["worktree", "prune", "-j"])
2269 elapsed = time.monotonic() - t0
2270 assert result.exit_code == 0
2271 assert elapsed < 2.0, f"prune took {elapsed:.2f}s"
2272
2273 def test_prune_mixed_active_and_stale(self, tmp_path: pathlib.Path) -> None:
2274 """Only stale worktrees are pruned; active ones are preserved."""
2275 import shutil
2276 from muse.core.worktree import _worktree_dir
2277 repo = _make_repo(tmp_path)
2278 for i in range(10):
2279 _add_branch(repo, f"br{i}")
2280 _invoke(repo, ["worktree", "add", f"wt{i}", f"br{i}"])
2281 # Remove every other worktree dir to make it stale.
2282 stale = [f"wt{i}" for i in range(0, 10, 2)]
2283 active = [f"wt{i}" for i in range(1, 10, 2)]
2284 for name in stale:
2285 shutil.rmtree(_worktree_dir(repo, name))
2286 parsed = _parse_prune(_invoke(repo, ["worktree", "prune", "-j"]).output)
2287 assert parsed["count"] == 5
2288 for name in stale:
2289 assert name in parsed["pruned"]
2290 for name in active:
2291 assert name not in parsed["pruned"]
2292 # Active worktrees still in list.
2293 entries = _parse_list(_invoke(repo, ["worktree", "list", "-j"]).output)
2294 listed_names = {e["name"] for e in entries}
2295 for name in active:
2296 assert name in listed_names
2297
2298
2299 # ===========================================================================
2300 # muse worktree repair — Extended / Security / Stress
2301 # ===========================================================================
2302
2303
2304 class TestWorktreeRepairExtended:
2305 """-j alias, text output, pointer writes, idempotency, edge cases."""
2306
2307 def test_repair_j_alias(self, tmp_path: pathlib.Path) -> None:
2308 """-j produces the same JSON as --json."""
2309 repo = _make_repo(tmp_path)
2310 r1 = _invoke(repo, ["worktree", "repair", "--json"])
2311 r2 = _invoke(repo, ["worktree", "repair", "-j"])
2312 assert r1.exit_code == 0 and r2.exit_code == 0
2313 assert json.loads(_json_blob(r1.output)) == json.loads(_json_blob(r2.output))
2314
2315 def test_repair_empty_repo_exits_0(self, tmp_path: pathlib.Path) -> None:
2316 """Repair on a repo with no linked worktrees exits 0."""
2317 repo = _make_repo(tmp_path)
2318 result = _invoke(repo, ["worktree", "repair", "-j"])
2319 assert result.exit_code == 0
2320
2321 def test_repair_json_repaired_is_list(self, tmp_path: pathlib.Path) -> None:
2322 """JSON repaired field is always a list."""
2323 repo = _make_repo(tmp_path)
2324 raw = json.loads(_json_blob(_invoke(repo, ["worktree", "repair", "-j"]).output))
2325 assert isinstance(raw["repaired"], list)
2326
2327 def test_repair_json_only_repaired_field(self, tmp_path: pathlib.Path) -> None:
2328 """JSON output contains exactly the repaired field."""
2329 repo = _make_repo(tmp_path)
2330 raw = json.loads(_json_blob(_invoke(repo, ["worktree", "repair", "-j"]).output))
2331 assert set(raw.keys()) == {"repaired"}
2332
2333 def test_repair_writes_pointer_file(self, tmp_path: pathlib.Path) -> None:
2334 """Repair writes the .muse pointer file in the worktree directory."""
2335 repo = _make_repo(tmp_path)
2336 _add_branch(repo, "dev")
2337 _invoke(repo, ["worktree", "add", "mydev", "dev"])
2338 from muse.core.worktree import _worktree_dir
2339 pointer = _worktree_dir(repo, "mydev") / ".muse"
2340 pointer.unlink(missing_ok=True)
2341 assert not pointer.exists()
2342 result = _invoke(repo, ["worktree", "repair"])
2343 assert result.exit_code == 0
2344 assert pointer.exists()
2345
2346 def test_repair_pointer_contains_store_path(self, tmp_path: pathlib.Path) -> None:
2347 """The pointer file content references the main .muse store."""
2348 repo = _make_repo(tmp_path)
2349 _add_branch(repo, "dev")
2350 _invoke(repo, ["worktree", "add", "mydev", "dev"])
2351 from muse.core.worktree import _worktree_dir
2352 pointer = _worktree_dir(repo, "mydev") / ".muse"
2353 pointer.unlink(missing_ok=True)
2354 _invoke(repo, ["worktree", "repair"])
2355 content = pointer.read_text()
2356 assert "musestore:" in content
2357 assert str(repo / ".muse") in content or ".muse" in content
2358
2359 def test_repair_idempotent(self, tmp_path: pathlib.Path) -> None:
2360 """Running repair twice does not raise an error."""
2361 repo = _make_repo(tmp_path)
2362 _add_branch(repo, "dev")
2363 _invoke(repo, ["worktree", "add", "mydev", "dev"])
2364 r1 = _invoke(repo, ["worktree", "repair", "-j"])
2365 r2 = _invoke(repo, ["worktree", "repair", "-j"])
2366 assert r1.exit_code == 0
2367 assert r2.exit_code == 0
2368
2369 def test_repair_includes_worktree_name_in_json(self, tmp_path: pathlib.Path) -> None:
2370 """JSON repaired list contains the linked worktree name."""
2371 repo = _make_repo(tmp_path)
2372 _add_branch(repo, "dev")
2373 _invoke(repo, ["worktree", "add", "mydev", "dev"])
2374 from muse.core.worktree import _worktree_dir
2375 (_worktree_dir(repo, "mydev") / ".muse").unlink(missing_ok=True)
2376 raw = json.loads(_json_blob(_invoke(repo, ["worktree", "repair", "-j"]).output))
2377 assert "mydev" in raw["repaired"]
2378
2379 def test_repair_default_is_text(self, tmp_path: pathlib.Path) -> None:
2380 """Without --json the output is human-readable text."""
2381 repo = _make_repo(tmp_path)
2382 result = _invoke(repo, ["worktree", "repair"])
2383 assert result.exit_code == 0
2384 assert not result.output.strip().startswith("{")
2385
2386 def test_repair_text_no_worktrees_message(self, tmp_path: pathlib.Path) -> None:
2387 """Text output says 'All worktrees already have pointer files' when clean."""
2388 repo = _make_repo(tmp_path)
2389 result = _invoke(repo, ["worktree", "repair"])
2390 assert "already have pointer files" in result.output
2391
2392 def test_repair_text_shows_repaired_name(self, tmp_path: pathlib.Path) -> None:
2393 """Text output shows the name of each repaired worktree."""
2394 repo = _make_repo(tmp_path)
2395 _add_branch(repo, "dev")
2396 _invoke(repo, ["worktree", "add", "mydev", "dev"])
2397 from muse.core.worktree import _worktree_dir
2398 (_worktree_dir(repo, "mydev") / ".muse").unlink(missing_ok=True)
2399 result = _invoke(repo, ["worktree", "repair"])
2400 assert "mydev" in result.output
2401
2402 def test_repair_outside_repo_exits_2(self, tmp_path: pathlib.Path) -> None:
2403 """Running outside a repo exits with code 2."""
2404 result = runner.invoke(None, ["worktree", "repair"], env={"MUSE_REPO_ROOT": str(tmp_path)})
2405 assert result.exit_code == 2
2406
2407 def test_repair_help_has_description(self, tmp_path: pathlib.Path) -> None:
2408 """--help includes the rich description."""
2409 result = _invoke(tmp_path, ["worktree", "repair", "--help"])
2410 assert result.exit_code == 0
2411 assert "Agent quickstart" in result.output or "JSON output schema" in result.output
2412
2413 def test_repair_skips_absent_worktree_dirs(self, tmp_path: pathlib.Path) -> None:
2414 """Worktrees whose directories are absent are skipped (not in repaired list)."""
2415 import shutil
2416 from muse.core.worktree import _worktree_dir
2417 repo = _make_repo(tmp_path)
2418 _add_branch(repo, "dev")
2419 _invoke(repo, ["worktree", "add", "mydev", "dev"])
2420 shutil.rmtree(_worktree_dir(repo, "mydev"))
2421 raw = json.loads(_json_blob(_invoke(repo, ["worktree", "repair", "-j"]).output))
2422 assert "mydev" not in raw["repaired"]
2423
2424 def test_repair_multiple_worktrees(self, tmp_path: pathlib.Path) -> None:
2425 """Repair fixes pointer files for all present linked worktrees."""
2426 repo = _make_repo(tmp_path)
2427 for name in ("a", "b", "c"):
2428 _add_branch(repo, name)
2429 _invoke(repo, ["worktree", "add", name, name])
2430 from muse.core.worktree import _worktree_dir
2431 for name in ("a", "b", "c"):
2432 (_worktree_dir(repo, name) / ".muse").unlink(missing_ok=True)
2433 raw = json.loads(_json_blob(_invoke(repo, ["worktree", "repair", "-j"]).output))
2434 for name in ("a", "b", "c"):
2435 assert name in raw["repaired"]
2436 assert len(raw["repaired"]) >= 3
2437
2438 def test_repair_is_valid_json(self, tmp_path: pathlib.Path) -> None:
2439 """JSON output is well-formed."""
2440 repo = _make_repo(tmp_path)
2441 result = _invoke(repo, ["worktree", "repair", "-j"])
2442 parsed = json.loads(_json_blob(result.output))
2443 assert isinstance(parsed, dict)
2444 assert isinstance(parsed["repaired"], list)
2445
2446
2447 class TestWorktreeRepairSecurity:
2448 """ANSI sanitization and output integrity."""
2449
2450 def test_repair_json_no_ansi_in_output(self, tmp_path: pathlib.Path) -> None:
2451 """JSON output contains no ANSI escape sequences."""
2452 repo = _make_repo(tmp_path)
2453 _add_branch(repo, "dev")
2454 _invoke(repo, ["worktree", "add", "mydev", "dev"])
2455 result = _invoke(repo, ["worktree", "repair", "-j"])
2456 assert "\x1b" not in result.output
2457
2458 def test_repair_text_no_ansi_in_output(self, tmp_path: pathlib.Path) -> None:
2459 """Text output contains no ANSI escape sequences."""
2460 repo = _make_repo(tmp_path)
2461 _add_branch(repo, "dev")
2462 _invoke(repo, ["worktree", "add", "mydev", "dev"])
2463 result = _invoke(repo, ["worktree", "repair"])
2464 assert "\x1b" not in result.output
2465
2466 def test_repair_does_not_write_outside_worktree_dirs(self, tmp_path: pathlib.Path) -> None:
2467 """Repair only writes inside registered worktree directories."""
2468 repo = _make_repo(tmp_path)
2469 _add_branch(repo, "dev")
2470 _invoke(repo, ["worktree", "add", "mydev", "dev"])
2471 from muse.core.worktree import _worktree_dir
2472 wt_dir = _worktree_dir(repo, "mydev")
2473 _invoke(repo, ["worktree", "repair"])
2474 # The only .muse file written should be inside the worktree directory.
2475 pointer = wt_dir / ".muse"
2476 assert pointer.exists()
2477 # The main repo .muse dir must remain a directory (not overwritten).
2478 assert (repo / ".muse").is_dir()
2479
2480 def test_repair_pointer_not_symlink(self, tmp_path: pathlib.Path) -> None:
2481 """The written pointer file is a regular file, not a symlink."""
2482 repo = _make_repo(tmp_path)
2483 _add_branch(repo, "dev")
2484 _invoke(repo, ["worktree", "add", "mydev", "dev"])
2485 from muse.core.worktree import _worktree_dir
2486 pointer = _worktree_dir(repo, "mydev") / ".muse"
2487 pointer.unlink(missing_ok=True)
2488 _invoke(repo, ["worktree", "repair"])
2489 assert pointer.exists()
2490 assert not pointer.is_symlink()
2491
2492 def test_repair_json_repaired_names_are_strings(self, tmp_path: pathlib.Path) -> None:
2493 """Every element of JSON repaired list is a string."""
2494 repo = _make_repo(tmp_path)
2495 _add_branch(repo, "dev")
2496 _invoke(repo, ["worktree", "add", "mydev", "dev"])
2497 raw = json.loads(_json_blob(_invoke(repo, ["worktree", "repair", "-j"]).output))
2498 for name in raw["repaired"]:
2499 assert isinstance(name, str)
2500
2501
2502 class TestWorktreeRepairStress:
2503 """Performance and scale tests for worktree repair."""
2504
2505 def test_repair_15_worktrees(self, tmp_path: pathlib.Path) -> None:
2506 """Repair correctly fixes pointer files for 15 linked worktrees."""
2507 repo = _make_repo(tmp_path)
2508 for i in range(15):
2509 _add_branch(repo, f"br{i}")
2510 _invoke(repo, ["worktree", "add", f"wt{i}", f"br{i}"])
2511 from muse.core.worktree import _worktree_dir
2512 for i in range(15):
2513 (_worktree_dir(repo, f"wt{i}") / ".muse").unlink(missing_ok=True)
2514 raw = json.loads(_json_blob(_invoke(repo, ["worktree", "repair", "-j"]).output))
2515 assert len(raw["repaired"]) >= 15
2516
2517 def test_repair_performance(self, tmp_path: pathlib.Path) -> None:
2518 """Repairing 10 worktrees completes within 2 seconds."""
2519 import time
2520 repo = _make_repo(tmp_path)
2521 for i in range(10):
2522 _add_branch(repo, f"br{i}")
2523 _invoke(repo, ["worktree", "add", f"wt{i}", f"br{i}"])
2524 from muse.core.worktree import _worktree_dir
2525 for i in range(10):
2526 (_worktree_dir(repo, f"wt{i}") / ".muse").unlink(missing_ok=True)
2527 t0 = time.monotonic()
2528 result = _invoke(repo, ["worktree", "repair", "-j"])
2529 elapsed = time.monotonic() - t0
2530 assert result.exit_code == 0
2531 assert elapsed < 2.0, f"repair took {elapsed:.2f}s"
2532
2533 def test_repair_idempotent_10_times(self, tmp_path: pathlib.Path) -> None:
2534 """Running repair 10 times in a row never fails."""
2535 repo = _make_repo(tmp_path)
2536 _add_branch(repo, "dev")
2537 _invoke(repo, ["worktree", "add", "mydev", "dev"])
2538 for _ in range(10):
2539 r = _invoke(repo, ["worktree", "repair", "-j"])
2540 assert r.exit_code == 0
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago