scc-cli 1.5.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of scc-cli might be problematic. Click here for more details.

Files changed (153) hide show
  1. scc_cli/__init__.py +15 -0
  2. scc_cli/audit/__init__.py +37 -0
  3. scc_cli/audit/parser.py +191 -0
  4. scc_cli/audit/reader.py +180 -0
  5. scc_cli/auth.py +145 -0
  6. scc_cli/claude_adapter.py +485 -0
  7. scc_cli/cli.py +311 -0
  8. scc_cli/cli_common.py +190 -0
  9. scc_cli/cli_helpers.py +244 -0
  10. scc_cli/commands/__init__.py +20 -0
  11. scc_cli/commands/admin.py +708 -0
  12. scc_cli/commands/audit.py +246 -0
  13. scc_cli/commands/config.py +528 -0
  14. scc_cli/commands/exceptions.py +696 -0
  15. scc_cli/commands/init.py +272 -0
  16. scc_cli/commands/launch/__init__.py +73 -0
  17. scc_cli/commands/launch/app.py +1247 -0
  18. scc_cli/commands/launch/render.py +309 -0
  19. scc_cli/commands/launch/sandbox.py +135 -0
  20. scc_cli/commands/launch/workspace.py +339 -0
  21. scc_cli/commands/org/__init__.py +49 -0
  22. scc_cli/commands/org/_builders.py +264 -0
  23. scc_cli/commands/org/app.py +41 -0
  24. scc_cli/commands/org/import_cmd.py +267 -0
  25. scc_cli/commands/org/init_cmd.py +269 -0
  26. scc_cli/commands/org/schema_cmd.py +76 -0
  27. scc_cli/commands/org/status_cmd.py +157 -0
  28. scc_cli/commands/org/update_cmd.py +330 -0
  29. scc_cli/commands/org/validate_cmd.py +138 -0
  30. scc_cli/commands/support.py +323 -0
  31. scc_cli/commands/team.py +910 -0
  32. scc_cli/commands/worktree/__init__.py +72 -0
  33. scc_cli/commands/worktree/_helpers.py +57 -0
  34. scc_cli/commands/worktree/app.py +170 -0
  35. scc_cli/commands/worktree/container_commands.py +385 -0
  36. scc_cli/commands/worktree/context_commands.py +61 -0
  37. scc_cli/commands/worktree/session_commands.py +128 -0
  38. scc_cli/commands/worktree/worktree_commands.py +734 -0
  39. scc_cli/config.py +647 -0
  40. scc_cli/confirm.py +20 -0
  41. scc_cli/console.py +562 -0
  42. scc_cli/contexts.py +394 -0
  43. scc_cli/core/__init__.py +68 -0
  44. scc_cli/core/constants.py +101 -0
  45. scc_cli/core/errors.py +297 -0
  46. scc_cli/core/exit_codes.py +91 -0
  47. scc_cli/core/workspace.py +57 -0
  48. scc_cli/deprecation.py +54 -0
  49. scc_cli/deps.py +189 -0
  50. scc_cli/docker/__init__.py +127 -0
  51. scc_cli/docker/core.py +467 -0
  52. scc_cli/docker/credentials.py +726 -0
  53. scc_cli/docker/launch.py +595 -0
  54. scc_cli/doctor/__init__.py +105 -0
  55. scc_cli/doctor/checks/__init__.py +166 -0
  56. scc_cli/doctor/checks/cache.py +314 -0
  57. scc_cli/doctor/checks/config.py +107 -0
  58. scc_cli/doctor/checks/environment.py +182 -0
  59. scc_cli/doctor/checks/json_helpers.py +157 -0
  60. scc_cli/doctor/checks/organization.py +264 -0
  61. scc_cli/doctor/checks/worktree.py +278 -0
  62. scc_cli/doctor/render.py +365 -0
  63. scc_cli/doctor/types.py +66 -0
  64. scc_cli/evaluation/__init__.py +27 -0
  65. scc_cli/evaluation/apply_exceptions.py +207 -0
  66. scc_cli/evaluation/evaluate.py +97 -0
  67. scc_cli/evaluation/models.py +80 -0
  68. scc_cli/git.py +84 -0
  69. scc_cli/json_command.py +166 -0
  70. scc_cli/json_output.py +159 -0
  71. scc_cli/kinds.py +65 -0
  72. scc_cli/marketplace/__init__.py +123 -0
  73. scc_cli/marketplace/adapter.py +74 -0
  74. scc_cli/marketplace/compute.py +377 -0
  75. scc_cli/marketplace/constants.py +87 -0
  76. scc_cli/marketplace/managed.py +135 -0
  77. scc_cli/marketplace/materialize.py +846 -0
  78. scc_cli/marketplace/normalize.py +548 -0
  79. scc_cli/marketplace/render.py +281 -0
  80. scc_cli/marketplace/resolve.py +459 -0
  81. scc_cli/marketplace/schema.py +506 -0
  82. scc_cli/marketplace/sync.py +279 -0
  83. scc_cli/marketplace/team_cache.py +195 -0
  84. scc_cli/marketplace/team_fetch.py +689 -0
  85. scc_cli/marketplace/trust.py +244 -0
  86. scc_cli/models/__init__.py +41 -0
  87. scc_cli/models/exceptions.py +273 -0
  88. scc_cli/models/plugin_audit.py +434 -0
  89. scc_cli/org_templates.py +269 -0
  90. scc_cli/output_mode.py +167 -0
  91. scc_cli/panels.py +113 -0
  92. scc_cli/platform.py +350 -0
  93. scc_cli/profiles.py +960 -0
  94. scc_cli/remote.py +443 -0
  95. scc_cli/schemas/__init__.py +1 -0
  96. scc_cli/schemas/org-v1.schema.json +456 -0
  97. scc_cli/schemas/team-config.v1.schema.json +163 -0
  98. scc_cli/services/__init__.py +1 -0
  99. scc_cli/services/git/__init__.py +79 -0
  100. scc_cli/services/git/branch.py +151 -0
  101. scc_cli/services/git/core.py +216 -0
  102. scc_cli/services/git/hooks.py +108 -0
  103. scc_cli/services/git/worktree.py +444 -0
  104. scc_cli/services/workspace/__init__.py +36 -0
  105. scc_cli/services/workspace/resolver.py +223 -0
  106. scc_cli/services/workspace/suspicious.py +200 -0
  107. scc_cli/sessions.py +425 -0
  108. scc_cli/setup.py +589 -0
  109. scc_cli/source_resolver.py +470 -0
  110. scc_cli/stats.py +378 -0
  111. scc_cli/stores/__init__.py +13 -0
  112. scc_cli/stores/exception_store.py +251 -0
  113. scc_cli/subprocess_utils.py +88 -0
  114. scc_cli/teams.py +383 -0
  115. scc_cli/templates/__init__.py +2 -0
  116. scc_cli/templates/org/__init__.py +0 -0
  117. scc_cli/templates/org/minimal.json +19 -0
  118. scc_cli/templates/org/reference.json +74 -0
  119. scc_cli/templates/org/strict.json +38 -0
  120. scc_cli/templates/org/teams.json +42 -0
  121. scc_cli/templates/statusline.sh +75 -0
  122. scc_cli/theme.py +348 -0
  123. scc_cli/ui/__init__.py +154 -0
  124. scc_cli/ui/branding.py +68 -0
  125. scc_cli/ui/chrome.py +401 -0
  126. scc_cli/ui/dashboard/__init__.py +62 -0
  127. scc_cli/ui/dashboard/_dashboard.py +794 -0
  128. scc_cli/ui/dashboard/loaders.py +452 -0
  129. scc_cli/ui/dashboard/models.py +185 -0
  130. scc_cli/ui/dashboard/orchestrator.py +735 -0
  131. scc_cli/ui/formatters.py +444 -0
  132. scc_cli/ui/gate.py +350 -0
  133. scc_cli/ui/git_interactive.py +869 -0
  134. scc_cli/ui/git_render.py +176 -0
  135. scc_cli/ui/help.py +157 -0
  136. scc_cli/ui/keys.py +615 -0
  137. scc_cli/ui/list_screen.py +437 -0
  138. scc_cli/ui/picker.py +763 -0
  139. scc_cli/ui/prompts.py +201 -0
  140. scc_cli/ui/quick_resume.py +116 -0
  141. scc_cli/ui/wizard.py +576 -0
  142. scc_cli/update.py +680 -0
  143. scc_cli/utils/__init__.py +39 -0
  144. scc_cli/utils/fixit.py +264 -0
  145. scc_cli/utils/fuzzy.py +124 -0
  146. scc_cli/utils/locks.py +114 -0
  147. scc_cli/utils/ttl.py +376 -0
  148. scc_cli/validate.py +455 -0
  149. scc_cli-1.5.3.dist-info/METADATA +401 -0
  150. scc_cli-1.5.3.dist-info/RECORD +153 -0
  151. scc_cli-1.5.3.dist-info/WHEEL +4 -0
  152. scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
  153. scc_cli-1.5.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,734 @@
1
+ """Worktree commands for git worktree management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ import typer
10
+ from rich.status import Status
11
+
12
+ from ... import config, deps, docker, git
13
+ from ...cli_common import console, err_console, handle_errors
14
+ from ...confirm import Confirm
15
+ from ...core.constants import WORKTREE_BRANCH_PREFIX
16
+ from ...core.errors import NotAGitRepoError, WorkspaceNotFoundError
17
+ from ...core.exit_codes import EXIT_CANCELLED
18
+ from ...json_command import json_command
19
+ from ...kinds import Kind
20
+ from ...output_mode import is_json_mode
21
+ from ...panels import create_success_panel, create_warning_panel
22
+ from ...theme import Indicators, Spinners
23
+ from ...ui.gate import InteractivityContext
24
+ from ...ui.picker import TeamSwitchRequested, pick_worktree
25
+ from ._helpers import build_worktree_list_data
26
+
27
+ if TYPE_CHECKING:
28
+ pass
29
+
30
+
31
+ @handle_errors
32
+ def worktree_create_cmd(
33
+ workspace: str = typer.Argument(..., help="Path to the main repository"),
34
+ name: str = typer.Argument(..., help="Name for the worktree/feature"),
35
+ base_branch: str | None = typer.Option(
36
+ None, "-b", "--base", help="Base branch (default: current)"
37
+ ),
38
+ start_claude: bool = typer.Option(
39
+ True, "--start/--no-start", help="Start Claude after creating"
40
+ ),
41
+ install_deps: bool = typer.Option(
42
+ False, "--install-deps", help="Install dependencies after creating worktree"
43
+ ),
44
+ ) -> None:
45
+ """Create a new worktree for parallel development."""
46
+ from ...cli_helpers import is_interactive
47
+
48
+ workspace_path = Path(workspace).expanduser().resolve()
49
+
50
+ if not workspace_path.exists():
51
+ raise WorkspaceNotFoundError(path=str(workspace_path))
52
+
53
+ # Handle non-git repo: offer to initialize in interactive mode
54
+ if not git.is_git_repo(workspace_path):
55
+ if is_interactive():
56
+ err_console.print(f"[yellow]'{workspace_path}' is not a git repository.[/yellow]")
57
+ if Confirm.ask("[cyan]Initialize git repository here?[/cyan]", default=True):
58
+ if git.init_repo(workspace_path):
59
+ err_console.print("[green]+ Git repository initialized[/green]")
60
+ else:
61
+ err_console.print("[red]Failed to initialize git repository[/red]")
62
+ raise typer.Exit(1)
63
+ else:
64
+ err_console.print("[dim]Skipped git initialization.[/dim]")
65
+ raise typer.Exit(0)
66
+ else:
67
+ raise NotAGitRepoError(path=str(workspace_path))
68
+
69
+ # Handle repo with no commits: offer to create initial commit
70
+ if not git.has_commits(workspace_path):
71
+ if is_interactive():
72
+ err_console.print(
73
+ "[yellow]Repository has no commits. Worktrees require at least one commit.[/yellow]"
74
+ )
75
+ if Confirm.ask("[cyan]Create an empty initial commit?[/cyan]", default=True):
76
+ success, error_msg = git.create_empty_initial_commit(workspace_path)
77
+ if success:
78
+ err_console.print("[green]+ Initial commit created[/green]")
79
+ else:
80
+ err_console.print(f"[red]Failed to create commit:[/red] {error_msg}")
81
+ err_console.print(
82
+ "[dim]Fix the issue above and try again, or create a commit manually.[/dim]"
83
+ )
84
+ raise typer.Exit(1)
85
+ else:
86
+ err_console.print(
87
+ "[dim]Skipped initial commit. Create one to enable worktrees:[/dim]"
88
+ )
89
+ err_console.print(" [cyan]git commit --allow-empty -m 'Initial commit'[/cyan]")
90
+ raise typer.Exit(0)
91
+ else:
92
+ err_console.print(
93
+ create_warning_panel(
94
+ "No Commits",
95
+ "Repository has no commits. Worktrees require at least one commit.",
96
+ "Run: git commit --allow-empty -m 'Initial commit'",
97
+ )
98
+ )
99
+ raise typer.Exit(1)
100
+
101
+ worktree_path = git.create_worktree(workspace_path, name, base_branch)
102
+
103
+ console.print(
104
+ create_success_panel(
105
+ "Worktree Created",
106
+ {
107
+ "Path": str(worktree_path),
108
+ "Branch": f"{WORKTREE_BRANCH_PREFIX}{name}",
109
+ "Base": base_branch or "current branch",
110
+ },
111
+ )
112
+ )
113
+
114
+ # Install dependencies if requested
115
+ if install_deps:
116
+ with Status(
117
+ "[cyan]Installing dependencies...[/cyan]", console=console, spinner=Spinners.SETUP
118
+ ):
119
+ success = deps.auto_install_dependencies(worktree_path)
120
+ if success:
121
+ console.print(f"[green]{Indicators.get('PASS')} Dependencies installed[/green]")
122
+ else:
123
+ console.print("[yellow]! Could not detect package manager or install failed[/yellow]")
124
+
125
+ if start_claude:
126
+ console.print()
127
+ if Confirm.ask("[cyan]Start Claude Code in this worktree?[/cyan]", default=True):
128
+ docker.check_docker_available()
129
+ # For worktrees, mount the common parent (contains .git/worktrees/)
130
+ # but set CWD to the worktree path
131
+ mount_path, _ = git.get_workspace_mount_path(worktree_path)
132
+ docker_cmd, _ = docker.get_or_create_container(
133
+ workspace=mount_path,
134
+ branch=f"{WORKTREE_BRANCH_PREFIX}{name}",
135
+ )
136
+ # Load org config for safety-net policy injection
137
+ org_config = config.load_cached_org_config()
138
+ # Pass container_workdir explicitly for correct CWD in worktree
139
+ docker.run(docker_cmd, org_config=org_config, container_workdir=worktree_path)
140
+
141
+
142
+ @json_command(Kind.WORKTREE_LIST)
143
+ @handle_errors
144
+ def worktree_list_cmd(
145
+ workspace: str = typer.Argument(".", help="Path to the repository"),
146
+ interactive: bool = typer.Option(
147
+ False, "-i", "--interactive", help="Interactive mode: select a worktree to work with"
148
+ ),
149
+ verbose: bool = typer.Option(
150
+ False, "--verbose", "-v", help="Show git status (staged/modified/untracked)"
151
+ ),
152
+ json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
153
+ pretty: bool = typer.Option(False, "--pretty", help="Pretty-print JSON (implies --json)"),
154
+ ) -> dict[str, Any] | None:
155
+ """List all worktrees for a repository.
156
+
157
+ With -i/--interactive, select a worktree and print its path
158
+ (useful for piping: cd $(scc worktree list -i))
159
+
160
+ With -v/--verbose, show git status for each worktree:
161
+ +N = staged changes, !N = modified files, ?N = untracked files
162
+ """
163
+ workspace_path = Path(workspace).expanduser().resolve()
164
+
165
+ if not workspace_path.exists():
166
+ raise WorkspaceNotFoundError(path=str(workspace_path))
167
+
168
+ worktree_list = git.list_worktrees(workspace_path, verbose=verbose)
169
+
170
+ # Convert WorktreeInfo dataclasses to dicts for JSON serialization
171
+ worktree_dicts = [asdict(wt) for wt in worktree_list]
172
+ data = build_worktree_list_data(worktree_dicts, str(workspace_path))
173
+
174
+ if is_json_mode():
175
+ return data
176
+
177
+ if not worktree_list:
178
+ console.print(
179
+ create_warning_panel(
180
+ "No Worktrees",
181
+ "No worktrees found for this repository.",
182
+ "Create one with: scc worktree create <repo> <name>",
183
+ )
184
+ )
185
+ return None
186
+
187
+ # Interactive mode: use worktree picker
188
+ if interactive:
189
+ try:
190
+ selected = pick_worktree(
191
+ worktree_list,
192
+ title="Select Worktree",
193
+ subtitle=f"{len(worktree_list)} worktrees in {workspace_path.name}",
194
+ )
195
+ if selected:
196
+ # Print just the path for scripting: cd $(scc worktree list -i)
197
+ print(selected.path) # noqa: T201
198
+ except TeamSwitchRequested:
199
+ console.print("[dim]Use 'scc team switch' to change teams[/dim]")
200
+ return None
201
+
202
+ # Use the beautiful worktree rendering from git.py
203
+ git.render_worktrees(worktree_list, console)
204
+
205
+ return data
206
+
207
+
208
+ @handle_errors
209
+ def worktree_switch_cmd(
210
+ target: str = typer.Argument(
211
+ None,
212
+ help="Target: worktree name, '-' (previous via $OLDPWD), '^' (main branch)",
213
+ ),
214
+ workspace: str = typer.Option(".", "-w", "--workspace", help="Path to the repository"),
215
+ ) -> None:
216
+ """Switch to a worktree. Prints path for shell integration.
217
+
218
+ Shortcuts:
219
+ - : Previous directory (uses shell $OLDPWD)
220
+ ^ : Main/default branch worktree
221
+ <name> : Fuzzy match worktree by branch or directory name
222
+
223
+ Shell integration (add to ~/.bashrc or ~/.zshrc):
224
+ wt() { cd "$(scc worktree switch "$@")" || return 1; }
225
+
226
+ Examples:
227
+ scc worktree switch feature-auth # Switch to feature-auth worktree
228
+ scc worktree switch - # Switch to previous directory
229
+ scc worktree switch ^ # Switch to main branch worktree
230
+ scc worktree switch # Interactive picker
231
+ """
232
+ import os
233
+
234
+ workspace_path = Path(workspace).expanduser().resolve()
235
+
236
+ if not workspace_path.exists():
237
+ raise WorkspaceNotFoundError(path=str(workspace_path))
238
+
239
+ if not git.is_git_repo(workspace_path):
240
+ raise NotAGitRepoError(path=str(workspace_path))
241
+
242
+ # No target: interactive picker
243
+ if target is None:
244
+ worktree_list = git.list_worktrees(workspace_path)
245
+ if not worktree_list:
246
+ err_console.print(
247
+ create_warning_panel(
248
+ "No Worktrees",
249
+ "No worktrees found for this repository.",
250
+ "Create one with: scc worktree create <repo> <name>",
251
+ ),
252
+ )
253
+ raise typer.Exit(1)
254
+
255
+ try:
256
+ selected = pick_worktree(
257
+ worktree_list,
258
+ title="Select Worktree",
259
+ subtitle=f"{len(worktree_list)} worktrees",
260
+ )
261
+ if selected:
262
+ print(selected.path) # noqa: T201
263
+ else:
264
+ raise typer.Exit(EXIT_CANCELLED)
265
+ except TeamSwitchRequested:
266
+ err_console.print("[dim]Use 'scc team switch' to change teams[/dim]")
267
+ raise typer.Exit(1)
268
+ return
269
+
270
+ # Handle special shortcuts
271
+ if target == "-":
272
+ # Previous directory via shell's OLDPWD
273
+ oldpwd = os.environ.get("OLDPWD")
274
+ if not oldpwd:
275
+ err_console.print(
276
+ create_warning_panel(
277
+ "No Previous Directory",
278
+ "Shell $OLDPWD is not set.",
279
+ "This typically means you haven't changed directories yet.",
280
+ ),
281
+ )
282
+ raise typer.Exit(1)
283
+ print(oldpwd) # noqa: T201
284
+ return
285
+
286
+ if target == "^":
287
+ # Main/default branch worktree
288
+ main_wt = git.find_main_worktree(workspace_path)
289
+ if not main_wt:
290
+ default_branch = git.get_default_branch(workspace_path)
291
+ err_console.print(
292
+ create_warning_panel(
293
+ "No Main Worktree",
294
+ f"No worktree found for default branch '{default_branch}'.",
295
+ "The main branch may not have a separate worktree.",
296
+ ),
297
+ )
298
+ raise typer.Exit(1)
299
+ print(main_wt.path) # noqa: T201
300
+ return
301
+
302
+ # Fuzzy match worktree
303
+ exact_match, matches = git.find_worktree_by_query(workspace_path, target)
304
+
305
+ if exact_match:
306
+ print(exact_match.path) # noqa: T201
307
+ return
308
+
309
+ if not matches:
310
+ # Skip branch check for special targets (handled earlier: -, ^, @)
311
+ if target not in ("^", "-", "@") and not target.startswith("@{"):
312
+ # Check if EXACT branch exists without worktree
313
+ branches = git.list_branches_without_worktrees(workspace_path)
314
+ if target in branches: # Exact match only - no substring matching
315
+ ctx = InteractivityContext.create()
316
+ if ctx.allows_prompt():
317
+ if Confirm.ask(
318
+ f"[cyan]No worktree for '{target}'. Create one?[/cyan]",
319
+ default=False, # Explicit > implicit
320
+ ):
321
+ worktree_path = git.create_worktree(
322
+ workspace_path,
323
+ name=target,
324
+ base_branch=target,
325
+ )
326
+ print(worktree_path) # noqa: T201
327
+ return
328
+ else:
329
+ # User declined - use EXIT_CANCELLED so shell wrappers don't cd
330
+ err_console.print("[dim]Cancelled.[/dim]")
331
+ raise typer.Exit(EXIT_CANCELLED)
332
+ else:
333
+ # Non-interactive: hint at explicit command
334
+ err_console.print(
335
+ create_warning_panel(
336
+ "Branch Exists, No Worktree",
337
+ f"Branch '{target}' exists but has no worktree.",
338
+ f"Use: scc worktree create <repo> {target} --base {target}",
339
+ ),
340
+ )
341
+ raise typer.Exit(1)
342
+
343
+ # Original "not found" error with select --branches hint
344
+ err_console.print(
345
+ create_warning_panel(
346
+ "Worktree Not Found",
347
+ f"No worktree matches '{target}'.",
348
+ "Tip: Use 'scc worktree select --branches' to pick from remote branches.",
349
+ ),
350
+ )
351
+ raise typer.Exit(1)
352
+
353
+ # Multiple matches: show picker or list
354
+ ctx = InteractivityContext.create()
355
+ if ctx.allows_prompt():
356
+ try:
357
+ selected = pick_worktree(
358
+ matches,
359
+ title="Multiple Matches",
360
+ subtitle=f"'{target}' matches {len(matches)} worktrees",
361
+ initial_filter=target,
362
+ )
363
+ if selected:
364
+ print(selected.path) # noqa: T201
365
+ else:
366
+ raise typer.Exit(EXIT_CANCELLED)
367
+ except TeamSwitchRequested:
368
+ raise typer.Exit(EXIT_CANCELLED)
369
+ else:
370
+ # Non-interactive: print ranked matches with explicit selection commands
371
+ match_lines = []
372
+ for i, wt in enumerate(matches):
373
+ display_branch = git.get_display_branch(wt.branch)
374
+ dir_name = Path(wt.path).name
375
+ if i == 0:
376
+ # Highlight top match (would be auto-selected interactively)
377
+ match_lines.append(
378
+ f" 1. [bold]{display_branch}[/] -> {dir_name} [dim]<- best match[/]"
379
+ )
380
+ else:
381
+ match_lines.append(f" {i + 1}. {display_branch} -> {dir_name}")
382
+
383
+ # Get the top match for the suggested command
384
+ top_match_dir = Path(matches[0].path).name
385
+
386
+ err_console.print(
387
+ create_warning_panel(
388
+ "Ambiguous Match",
389
+ f"'{target}' matches {len(matches)} worktrees (ranked by relevance):",
390
+ "\n".join(match_lines)
391
+ + f"\n\n[dim]Use explicit directory name: scc worktree switch {top_match_dir}[/]",
392
+ ),
393
+ )
394
+ raise typer.Exit(1)
395
+
396
+
397
+ @handle_errors
398
+ def worktree_select_cmd(
399
+ workspace: str = typer.Argument(".", help="Path to the repository"),
400
+ branches: bool = typer.Option(
401
+ False, "-b", "--branches", help="Include branches without worktrees"
402
+ ),
403
+ ) -> None:
404
+ """Interactive worktree selector. Prints path to stdout.
405
+
406
+ Select a worktree from an interactive list. The selected path is printed
407
+ to stdout for shell integration.
408
+
409
+ With --branches, also shows remote branches that don't have worktrees.
410
+ Selecting a branch prompts to create a new worktree.
411
+
412
+ Shell integration (add to ~/.bashrc or ~/.zshrc):
413
+ wt() { cd "$(scc worktree select "$@")" || return 1; }
414
+
415
+ Examples:
416
+ scc worktree select # Pick from worktrees
417
+ scc worktree select --branches # Include branches for quick creation
418
+ """
419
+ workspace_path = Path(workspace).expanduser().resolve()
420
+
421
+ if not workspace_path.exists():
422
+ raise WorkspaceNotFoundError(path=str(workspace_path))
423
+
424
+ if not git.is_git_repo(workspace_path):
425
+ raise NotAGitRepoError(path=str(workspace_path))
426
+
427
+ worktree_list = git.list_worktrees(workspace_path)
428
+
429
+ # Build combined list if including branches
430
+ from ...git import WorktreeInfo
431
+
432
+ items: list[WorktreeInfo] = list(worktree_list)
433
+ branch_items: list[str] = []
434
+
435
+ if branches:
436
+ branch_items = git.list_branches_without_worktrees(workspace_path)
437
+ # Create placeholder WorktreeInfo for branches (with empty path)
438
+ for branch in branch_items:
439
+ items.append(
440
+ WorktreeInfo(
441
+ path="", # Empty path indicates this is a branch, not worktree
442
+ branch=branch,
443
+ status="branch", # Mark as branch-only
444
+ )
445
+ )
446
+
447
+ if not items:
448
+ err_console.print(
449
+ create_warning_panel(
450
+ "No Worktrees or Branches",
451
+ "No worktrees found and no remote branches available.",
452
+ "Create a worktree with: scc worktree create <repo> <name>",
453
+ ),
454
+ )
455
+ raise typer.Exit(1)
456
+
457
+ try:
458
+ selected = pick_worktree(
459
+ items,
460
+ title="Select Worktree",
461
+ subtitle=f"{len(worktree_list)} worktrees"
462
+ + (f", {len(branch_items)} branches" if branch_items else ""),
463
+ )
464
+
465
+ if not selected:
466
+ raise typer.Exit(EXIT_CANCELLED)
467
+
468
+ # If selected item is a worktree (has path), print it
469
+ if selected.path:
470
+ print(selected.path) # noqa: T201
471
+ return
472
+
473
+ # Selected a branch without worktree - offer to create
474
+ if Confirm.ask(
475
+ f"[cyan]Create worktree for branch '{selected.branch}'?[/cyan]",
476
+ default=True,
477
+ console=console,
478
+ ):
479
+ with Status(
480
+ "[cyan]Creating worktree...[/cyan]",
481
+ console=console,
482
+ spinner=Spinners.SETUP,
483
+ ):
484
+ worktree_path = git.create_worktree(
485
+ workspace_path,
486
+ selected.branch,
487
+ base_branch=selected.branch,
488
+ )
489
+ err_console.print(
490
+ create_success_panel(
491
+ "Worktree Created",
492
+ {"Branch": selected.branch, "Path": str(worktree_path)},
493
+ )
494
+ )
495
+ print(worktree_path) # noqa: T201
496
+ else:
497
+ raise typer.Exit(EXIT_CANCELLED)
498
+
499
+ except TeamSwitchRequested:
500
+ err_console.print("[dim]Use 'scc team switch' to change teams[/dim]")
501
+ raise typer.Exit(EXIT_CANCELLED)
502
+
503
+
504
+ @handle_errors
505
+ def worktree_enter_cmd(
506
+ target: str = typer.Argument(
507
+ None,
508
+ help="Target: worktree name, '-' (previous), '^' (main branch)",
509
+ ),
510
+ workspace: str = typer.Option(".", "-w", "--workspace", help="Path to the repository"),
511
+ ) -> None:
512
+ """Enter a worktree in a new subshell.
513
+
514
+ Unlike 'switch', this command opens a new shell in the worktree directory.
515
+ No shell configuration is required - just type 'exit' to return.
516
+
517
+ The $SCC_WORKTREE environment variable is set to the worktree name.
518
+
519
+ Shortcuts:
520
+ - : Previous directory (uses shell $OLDPWD)
521
+ ^ : Main/default branch worktree
522
+ <name> : Fuzzy match worktree by branch or directory name
523
+
524
+ Examples:
525
+ scc worktree enter feature-auth # Enter feature-auth in new shell
526
+ scc worktree enter # Interactive picker
527
+ scc worktree enter ^ # Enter main branch worktree
528
+ """
529
+ import os
530
+ import subprocess
531
+
532
+ workspace_path = Path(workspace).expanduser().resolve()
533
+
534
+ if not workspace_path.exists():
535
+ raise WorkspaceNotFoundError(path=str(workspace_path))
536
+
537
+ if not git.is_git_repo(workspace_path):
538
+ raise NotAGitRepoError(path=str(workspace_path))
539
+
540
+ # Resolve target to worktree path
541
+ worktree_path: Path | None = None
542
+ worktree_name: str = ""
543
+
544
+ if target is None:
545
+ # No target: interactive picker
546
+ worktree_list = git.list_worktrees(workspace_path)
547
+ if not worktree_list:
548
+ err_console.print(
549
+ create_warning_panel(
550
+ "No Worktrees",
551
+ "No worktrees found for this repository.",
552
+ "Create one with: scc worktree create <repo> <name>",
553
+ ),
554
+ )
555
+ raise typer.Exit(1)
556
+
557
+ try:
558
+ selected = pick_worktree(
559
+ worktree_list,
560
+ title="Enter Worktree",
561
+ subtitle="Select a worktree to enter",
562
+ )
563
+ if selected:
564
+ worktree_path = Path(selected.path)
565
+ worktree_name = selected.branch or Path(selected.path).name
566
+ else:
567
+ raise typer.Exit(EXIT_CANCELLED)
568
+ except TeamSwitchRequested:
569
+ err_console.print("[dim]Use 'scc team switch' to change teams[/dim]")
570
+ raise typer.Exit(1)
571
+ elif target == "-":
572
+ # Previous directory
573
+ oldpwd = os.environ.get("OLDPWD")
574
+ if not oldpwd:
575
+ err_console.print(
576
+ create_warning_panel(
577
+ "No Previous Directory",
578
+ "Shell $OLDPWD is not set.",
579
+ "This typically means you haven't changed directories yet.",
580
+ ),
581
+ )
582
+ raise typer.Exit(1)
583
+ worktree_path = Path(oldpwd)
584
+ worktree_name = worktree_path.name
585
+ elif target == "^":
586
+ # Main branch worktree
587
+ main_branch = git.get_default_branch(workspace_path)
588
+ worktree_list = git.list_worktrees(workspace_path)
589
+ for wt in worktree_list:
590
+ if wt.branch == main_branch or wt.branch in {"main", "master"}:
591
+ worktree_path = Path(wt.path)
592
+ worktree_name = wt.branch or worktree_path.name
593
+ break
594
+ if not worktree_path:
595
+ err_console.print(
596
+ create_warning_panel(
597
+ "Main Branch Not Found",
598
+ f"No worktree found for main branch ({main_branch}).",
599
+ "The main worktree may be in a different location.",
600
+ ),
601
+ )
602
+ raise typer.Exit(1)
603
+ else:
604
+ # Fuzzy match target
605
+ matched, _matches = git.find_worktree_by_query(workspace_path, target)
606
+ if matched:
607
+ worktree_path = Path(matched.path)
608
+ worktree_name = matched.branch or Path(matched.path).name
609
+ else:
610
+ err_console.print(
611
+ create_warning_panel(
612
+ "Worktree Not Found",
613
+ f"No worktree matching '{target}'.",
614
+ "Run 'scc worktree list' to see available worktrees.",
615
+ ),
616
+ )
617
+ raise typer.Exit(1)
618
+
619
+ # Verify worktree path exists
620
+ if not worktree_path or not worktree_path.exists():
621
+ err_console.print(
622
+ create_warning_panel(
623
+ "Worktree Missing",
624
+ f"Worktree path does not exist: {worktree_path}",
625
+ "The worktree may have been removed. Run 'scc worktree prune'.",
626
+ ),
627
+ )
628
+ raise typer.Exit(1)
629
+
630
+ # Print entry message to stderr (stdout stays clean)
631
+ err_console.print(f"[cyan]Entering worktree:[/cyan] {worktree_path}")
632
+ err_console.print("[dim]Type 'exit' to return.[/dim]")
633
+ err_console.print()
634
+
635
+ # Set up environment with SCC_WORKTREE variable
636
+ env = os.environ.copy()
637
+ env["SCC_WORKTREE"] = worktree_name
638
+
639
+ # Get user's shell (default to /bin/bash on Unix, cmd.exe on Windows)
640
+ import platform
641
+
642
+ if platform.system() == "Windows":
643
+ shell = os.environ.get("COMSPEC", "cmd.exe")
644
+ else:
645
+ shell = os.environ.get("SHELL", "/bin/bash")
646
+
647
+ # Run subshell in worktree directory
648
+ try:
649
+ subprocess.run([shell], cwd=str(worktree_path), env=env)
650
+ except FileNotFoundError:
651
+ err_console.print(f"[red]Shell not found: {shell}[/red]")
652
+ raise typer.Exit(1)
653
+
654
+ # After subshell exits, print a message
655
+ err_console.print()
656
+ err_console.print("[dim]Exited worktree subshell[/dim]")
657
+
658
+
659
+ @handle_errors
660
+ def worktree_remove_cmd(
661
+ workspace: str = typer.Argument(..., help="Path to the main repository"),
662
+ name: str = typer.Argument(..., help="Name of the worktree to remove"),
663
+ force: bool = typer.Option(
664
+ False, "-f", "--force", help="Force removal even with uncommitted changes"
665
+ ),
666
+ yes: bool = typer.Option(False, "-y", "--yes", help="Skip all confirmation prompts"),
667
+ dry_run: bool = typer.Option(
668
+ False, "--dry-run", help="Show what would be removed without removing"
669
+ ),
670
+ ) -> None:
671
+ """Remove a worktree.
672
+
673
+ By default, prompts for confirmation if there are uncommitted changes and
674
+ asks whether to delete the associated branch.
675
+
676
+ Use --yes to skip prompts (auto-confirms all actions).
677
+ Use --dry-run to preview what would be removed.
678
+ Use --force to remove even with uncommitted changes (still prompts unless --yes).
679
+ """
680
+ workspace_path = Path(workspace).expanduser().resolve()
681
+
682
+ if not workspace_path.exists():
683
+ raise WorkspaceNotFoundError(path=str(workspace_path))
684
+
685
+ # cleanup_worktree handles all output including success panels
686
+ git.cleanup_worktree(workspace_path, name, force, console, skip_confirm=yes, dry_run=dry_run)
687
+
688
+
689
+ @handle_errors
690
+ def worktree_prune_cmd(
691
+ workspace: str = typer.Argument(".", help="Path to the repository"),
692
+ dry_run: bool = typer.Option(
693
+ False, "--dry-run", "-n", help="Show what would be pruned without pruning"
694
+ ),
695
+ ) -> None:
696
+ """Remove stale worktree entries from git.
697
+
698
+ Prunes worktree references for directories that no longer exist.
699
+ Use --dry-run to preview what would be removed.
700
+ """
701
+ workspace_path = Path(workspace).expanduser().resolve()
702
+
703
+ if not git.is_git_repo(workspace_path):
704
+ raise NotAGitRepoError(path=str(workspace_path))
705
+
706
+ cmd = ["git", "-C", str(workspace_path), "worktree", "prune"]
707
+ if dry_run:
708
+ cmd.append("--dry-run")
709
+ cmd.append("--verbose") # Show what would be pruned
710
+
711
+ from ...subprocess_utils import run_command
712
+
713
+ output = run_command(cmd, timeout=30)
714
+
715
+ if output and output.strip():
716
+ # Parse output to count pruned entries (lines containing "Removing")
717
+ lines = output.strip().splitlines()
718
+ prune_count = sum(1 for line in lines if "Removing" in line or "removing" in line)
719
+
720
+ if dry_run:
721
+ err_console.print(
722
+ f"[yellow]Would prune {prune_count} stale worktree "
723
+ f"{'entry' if prune_count == 1 else 'entries'}:[/yellow]"
724
+ )
725
+ else:
726
+ err_console.print(
727
+ f"[green]Pruned {prune_count} stale worktree "
728
+ f"{'entry' if prune_count == 1 else 'entries'}.[/green]"
729
+ )
730
+ # Show the details
731
+ for line in lines:
732
+ err_console.print(f" [dim]{line}[/dim]")
733
+ else:
734
+ err_console.print("[green]No stale worktree entries found.[/green]")