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.
- scc_cli/__init__.py +15 -0
- scc_cli/audit/__init__.py +37 -0
- scc_cli/audit/parser.py +191 -0
- scc_cli/audit/reader.py +180 -0
- scc_cli/auth.py +145 -0
- scc_cli/claude_adapter.py +485 -0
- scc_cli/cli.py +311 -0
- scc_cli/cli_common.py +190 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/commands/__init__.py +20 -0
- scc_cli/commands/admin.py +708 -0
- scc_cli/commands/audit.py +246 -0
- scc_cli/commands/config.py +528 -0
- scc_cli/commands/exceptions.py +696 -0
- scc_cli/commands/init.py +272 -0
- scc_cli/commands/launch/__init__.py +73 -0
- scc_cli/commands/launch/app.py +1247 -0
- scc_cli/commands/launch/render.py +309 -0
- scc_cli/commands/launch/sandbox.py +135 -0
- scc_cli/commands/launch/workspace.py +339 -0
- scc_cli/commands/org/__init__.py +49 -0
- scc_cli/commands/org/_builders.py +264 -0
- scc_cli/commands/org/app.py +41 -0
- scc_cli/commands/org/import_cmd.py +267 -0
- scc_cli/commands/org/init_cmd.py +269 -0
- scc_cli/commands/org/schema_cmd.py +76 -0
- scc_cli/commands/org/status_cmd.py +157 -0
- scc_cli/commands/org/update_cmd.py +330 -0
- scc_cli/commands/org/validate_cmd.py +138 -0
- scc_cli/commands/support.py +323 -0
- scc_cli/commands/team.py +910 -0
- scc_cli/commands/worktree/__init__.py +72 -0
- scc_cli/commands/worktree/_helpers.py +57 -0
- scc_cli/commands/worktree/app.py +170 -0
- scc_cli/commands/worktree/container_commands.py +385 -0
- scc_cli/commands/worktree/context_commands.py +61 -0
- scc_cli/commands/worktree/session_commands.py +128 -0
- scc_cli/commands/worktree/worktree_commands.py +734 -0
- scc_cli/config.py +647 -0
- scc_cli/confirm.py +20 -0
- scc_cli/console.py +562 -0
- scc_cli/contexts.py +394 -0
- scc_cli/core/__init__.py +68 -0
- scc_cli/core/constants.py +101 -0
- scc_cli/core/errors.py +297 -0
- scc_cli/core/exit_codes.py +91 -0
- scc_cli/core/workspace.py +57 -0
- scc_cli/deprecation.py +54 -0
- scc_cli/deps.py +189 -0
- scc_cli/docker/__init__.py +127 -0
- scc_cli/docker/core.py +467 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +595 -0
- scc_cli/doctor/__init__.py +105 -0
- scc_cli/doctor/checks/__init__.py +166 -0
- scc_cli/doctor/checks/cache.py +314 -0
- scc_cli/doctor/checks/config.py +107 -0
- scc_cli/doctor/checks/environment.py +182 -0
- scc_cli/doctor/checks/json_helpers.py +157 -0
- scc_cli/doctor/checks/organization.py +264 -0
- scc_cli/doctor/checks/worktree.py +278 -0
- scc_cli/doctor/render.py +365 -0
- scc_cli/doctor/types.py +66 -0
- scc_cli/evaluation/__init__.py +27 -0
- scc_cli/evaluation/apply_exceptions.py +207 -0
- scc_cli/evaluation/evaluate.py +97 -0
- scc_cli/evaluation/models.py +80 -0
- scc_cli/git.py +84 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +159 -0
- scc_cli/kinds.py +65 -0
- scc_cli/marketplace/__init__.py +123 -0
- scc_cli/marketplace/adapter.py +74 -0
- scc_cli/marketplace/compute.py +377 -0
- scc_cli/marketplace/constants.py +87 -0
- scc_cli/marketplace/managed.py +135 -0
- scc_cli/marketplace/materialize.py +846 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +281 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +506 -0
- scc_cli/marketplace/sync.py +279 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +689 -0
- scc_cli/marketplace/trust.py +244 -0
- scc_cli/models/__init__.py +41 -0
- scc_cli/models/exceptions.py +273 -0
- scc_cli/models/plugin_audit.py +434 -0
- scc_cli/org_templates.py +269 -0
- scc_cli/output_mode.py +167 -0
- scc_cli/panels.py +113 -0
- scc_cli/platform.py +350 -0
- scc_cli/profiles.py +960 -0
- scc_cli/remote.py +443 -0
- scc_cli/schemas/__init__.py +1 -0
- scc_cli/schemas/org-v1.schema.json +456 -0
- scc_cli/schemas/team-config.v1.schema.json +163 -0
- scc_cli/services/__init__.py +1 -0
- scc_cli/services/git/__init__.py +79 -0
- scc_cli/services/git/branch.py +151 -0
- scc_cli/services/git/core.py +216 -0
- scc_cli/services/git/hooks.py +108 -0
- scc_cli/services/git/worktree.py +444 -0
- scc_cli/services/workspace/__init__.py +36 -0
- scc_cli/services/workspace/resolver.py +223 -0
- scc_cli/services/workspace/suspicious.py +200 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +589 -0
- scc_cli/source_resolver.py +470 -0
- scc_cli/stats.py +378 -0
- scc_cli/stores/__init__.py +13 -0
- scc_cli/stores/exception_store.py +251 -0
- scc_cli/subprocess_utils.py +88 -0
- scc_cli/teams.py +383 -0
- scc_cli/templates/__init__.py +2 -0
- scc_cli/templates/org/__init__.py +0 -0
- scc_cli/templates/org/minimal.json +19 -0
- scc_cli/templates/org/reference.json +74 -0
- scc_cli/templates/org/strict.json +38 -0
- scc_cli/templates/org/teams.json +42 -0
- scc_cli/templates/statusline.sh +75 -0
- scc_cli/theme.py +348 -0
- scc_cli/ui/__init__.py +154 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +401 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +794 -0
- scc_cli/ui/dashboard/loaders.py +452 -0
- scc_cli/ui/dashboard/models.py +185 -0
- scc_cli/ui/dashboard/orchestrator.py +735 -0
- scc_cli/ui/formatters.py +444 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/git_interactive.py +869 -0
- scc_cli/ui/git_render.py +176 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +615 -0
- scc_cli/ui/list_screen.py +437 -0
- scc_cli/ui/picker.py +763 -0
- scc_cli/ui/prompts.py +201 -0
- scc_cli/ui/quick_resume.py +116 -0
- scc_cli/ui/wizard.py +576 -0
- scc_cli/update.py +680 -0
- scc_cli/utils/__init__.py +39 -0
- scc_cli/utils/fixit.py +264 -0
- scc_cli/utils/fuzzy.py +124 -0
- scc_cli/utils/locks.py +114 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.5.3.dist-info/METADATA +401 -0
- scc_cli-1.5.3.dist-info/RECORD +153 -0
- scc_cli-1.5.3.dist-info/WHEEL +4 -0
- scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
- 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]")
|