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,444 @@
|
|
|
1
|
+
"""Worktree operations - data structures and queries.
|
|
2
|
+
|
|
3
|
+
Pure functions with no UI dependencies.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ...core.constants import WORKTREE_BRANCH_PREFIX
|
|
11
|
+
from .branch import get_default_branch, sanitize_branch_name
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class WorktreeInfo:
|
|
16
|
+
"""Information about a git worktree."""
|
|
17
|
+
|
|
18
|
+
path: str
|
|
19
|
+
branch: str
|
|
20
|
+
status: str = ""
|
|
21
|
+
is_current: bool = False
|
|
22
|
+
has_changes: bool = False
|
|
23
|
+
# Status counts (populated with --verbose)
|
|
24
|
+
staged_count: int = 0
|
|
25
|
+
modified_count: int = 0
|
|
26
|
+
untracked_count: int = 0
|
|
27
|
+
status_timed_out: bool = False # True if git status timed out
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_worktree(path: Path) -> bool:
|
|
31
|
+
"""Check if the path is a git worktree (not the main repository).
|
|
32
|
+
|
|
33
|
+
Worktrees have a `.git` file (not directory) containing a gitdir pointer.
|
|
34
|
+
"""
|
|
35
|
+
git_path = path / ".git"
|
|
36
|
+
return git_path.is_file() # Worktrees have .git as file, main repo has .git as dir
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_worktree_main_repo(worktree_path: Path) -> Path | None:
|
|
40
|
+
"""Get the main repository path for a worktree.
|
|
41
|
+
|
|
42
|
+
Parse the `.git` file to find the gitdir pointer and resolve
|
|
43
|
+
back to the main repo location.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Main repository path, or None if not a worktree or cannot determine.
|
|
47
|
+
"""
|
|
48
|
+
git_file = worktree_path / ".git"
|
|
49
|
+
|
|
50
|
+
if not git_file.is_file():
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
content = git_file.read_text().strip()
|
|
55
|
+
# Format: "gitdir: /path/to/main-repo/.git/worktrees/<name>"
|
|
56
|
+
if content.startswith("gitdir:"):
|
|
57
|
+
gitdir = content[7:].strip()
|
|
58
|
+
gitdir_path = Path(gitdir)
|
|
59
|
+
|
|
60
|
+
# Navigate from .git/worktrees/<name> up to repo root
|
|
61
|
+
# gitdir_path = /repo/.git/worktrees/feature
|
|
62
|
+
# We need /repo
|
|
63
|
+
if "worktrees" in gitdir_path.parts:
|
|
64
|
+
# Find the .git directory (parent of worktrees)
|
|
65
|
+
git_dir = gitdir_path
|
|
66
|
+
while git_dir.name != ".git" and git_dir != git_dir.parent:
|
|
67
|
+
git_dir = git_dir.parent
|
|
68
|
+
if git_dir.name == ".git":
|
|
69
|
+
return git_dir.parent
|
|
70
|
+
except (OSError, ValueError):
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_workspace_mount_path(workspace: Path) -> tuple[Path, bool]:
|
|
77
|
+
"""Determine the optimal path to mount for Docker sandbox.
|
|
78
|
+
|
|
79
|
+
For worktrees, return the common parent containing both repo and worktrees folder.
|
|
80
|
+
For regular repos, return the workspace path as-is.
|
|
81
|
+
|
|
82
|
+
This ensures git worktrees have access to the main repo's .git folder.
|
|
83
|
+
The gitdir pointer in worktrees uses absolute paths, so Docker must mount
|
|
84
|
+
the common parent to make those paths resolve correctly inside the container.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Tuple of (mount_path, is_expanded) where is_expanded=True if we expanded
|
|
88
|
+
the mount scope beyond the original workspace (for user awareness).
|
|
89
|
+
|
|
90
|
+
Note:
|
|
91
|
+
Docker sandbox uses "mirrored mounting" - the path inside the container
|
|
92
|
+
matches the host path, so absolute gitdir pointers will resolve correctly.
|
|
93
|
+
"""
|
|
94
|
+
if not is_worktree(workspace):
|
|
95
|
+
return workspace, False
|
|
96
|
+
|
|
97
|
+
main_repo = get_worktree_main_repo(workspace)
|
|
98
|
+
if main_repo is None:
|
|
99
|
+
return workspace, False
|
|
100
|
+
|
|
101
|
+
# Find common parent of worktree and main repo
|
|
102
|
+
# Worktree: /parent/repo-worktrees/feature
|
|
103
|
+
# Main repo: /parent/repo
|
|
104
|
+
# Common parent: /parent
|
|
105
|
+
|
|
106
|
+
workspace_resolved = workspace.resolve()
|
|
107
|
+
main_repo_resolved = main_repo.resolve()
|
|
108
|
+
|
|
109
|
+
worktree_parts = workspace_resolved.parts
|
|
110
|
+
repo_parts = main_repo_resolved.parts
|
|
111
|
+
|
|
112
|
+
# Find common ancestor path
|
|
113
|
+
common_parts = []
|
|
114
|
+
for w_part, r_part in zip(worktree_parts, repo_parts):
|
|
115
|
+
if w_part == r_part:
|
|
116
|
+
common_parts.append(w_part)
|
|
117
|
+
else:
|
|
118
|
+
break
|
|
119
|
+
|
|
120
|
+
if not common_parts:
|
|
121
|
+
# No common ancestor - shouldn't happen, but fall back safely
|
|
122
|
+
return workspace, False
|
|
123
|
+
|
|
124
|
+
common_parent = Path(*common_parts)
|
|
125
|
+
|
|
126
|
+
# Safety checks: don't mount system directories
|
|
127
|
+
# Use resolved paths for proper symlink handling (cross-platform)
|
|
128
|
+
try:
|
|
129
|
+
resolved_parent = common_parent.resolve()
|
|
130
|
+
except OSError:
|
|
131
|
+
# Can't resolve path - fall back to safe option
|
|
132
|
+
return workspace, False
|
|
133
|
+
|
|
134
|
+
# System directories that should NEVER be mounted as common parent
|
|
135
|
+
# Cross-platform: covers Linux, macOS, and WSL2
|
|
136
|
+
blocked_roots = {
|
|
137
|
+
# Root filesystem
|
|
138
|
+
Path("/"),
|
|
139
|
+
# User home parents (mounting all of /home or /Users is too broad)
|
|
140
|
+
Path("/home"),
|
|
141
|
+
Path("/Users"),
|
|
142
|
+
# System directories (Linux + macOS)
|
|
143
|
+
Path("/bin"),
|
|
144
|
+
Path("/boot"),
|
|
145
|
+
Path("/dev"),
|
|
146
|
+
Path("/etc"),
|
|
147
|
+
Path("/lib"),
|
|
148
|
+
Path("/lib64"),
|
|
149
|
+
Path("/opt"),
|
|
150
|
+
Path("/proc"),
|
|
151
|
+
Path("/root"),
|
|
152
|
+
Path("/run"),
|
|
153
|
+
Path("/sbin"),
|
|
154
|
+
Path("/srv"),
|
|
155
|
+
Path("/sys"),
|
|
156
|
+
Path("/usr"),
|
|
157
|
+
# Temp directories (sensitive, often contain secrets)
|
|
158
|
+
Path("/tmp"),
|
|
159
|
+
Path("/var"),
|
|
160
|
+
# macOS specific
|
|
161
|
+
Path("/System"),
|
|
162
|
+
Path("/Library"),
|
|
163
|
+
Path("/Applications"),
|
|
164
|
+
Path("/Volumes"),
|
|
165
|
+
Path("/private"),
|
|
166
|
+
# WSL2 specific
|
|
167
|
+
Path("/mnt"),
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
# Check if resolved path IS or IS UNDER a blocked root
|
|
171
|
+
for blocked in blocked_roots:
|
|
172
|
+
if resolved_parent == blocked:
|
|
173
|
+
return workspace, False
|
|
174
|
+
|
|
175
|
+
# Skip root "/" for is_relative_to check - all paths are under root!
|
|
176
|
+
# We already checked exact match above.
|
|
177
|
+
if blocked == Path("/"):
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
# Use is_relative_to for "is under" check (Python 3.9+)
|
|
181
|
+
try:
|
|
182
|
+
if resolved_parent.is_relative_to(blocked):
|
|
183
|
+
# Exception: allow paths under /home/<user>/... or /Users/<user>/...
|
|
184
|
+
# (i.e., actual user workspaces, not the parent directories themselves)
|
|
185
|
+
if blocked in (Path("/home"), Path("/Users")):
|
|
186
|
+
# /home/user/projects is OK (depth 4+)
|
|
187
|
+
# /home/user is too broad (depth 3)
|
|
188
|
+
if len(resolved_parent.parts) >= 4:
|
|
189
|
+
continue # Allow: /home/user/projects or deeper
|
|
190
|
+
|
|
191
|
+
# WSL2 exception: /mnt/<drive>/... where <drive> is single letter
|
|
192
|
+
# This specifically targets Windows filesystem mounts, NOT arbitrary
|
|
193
|
+
# Linux mount points like /mnt/nfs, /mnt/usb, /mnt/wsl, etc.
|
|
194
|
+
if blocked == Path("/mnt"):
|
|
195
|
+
parts = resolved_parent.parts
|
|
196
|
+
# Validate: /mnt/<single-letter>/<something>/<something>
|
|
197
|
+
# parts[0]="/", parts[1]="mnt", parts[2]=drive, parts[3+]=path
|
|
198
|
+
if len(parts) >= 5: # Conservative: require depth 5+
|
|
199
|
+
drive = parts[2] if len(parts) > 2 else ""
|
|
200
|
+
# WSL2 drives are single letters (c, d, e, etc.)
|
|
201
|
+
if len(drive) == 1 and drive.isalpha():
|
|
202
|
+
continue # Allow: /mnt/c/Users/dev/projects
|
|
203
|
+
|
|
204
|
+
return workspace, False
|
|
205
|
+
except (ValueError, AttributeError):
|
|
206
|
+
# is_relative_to raises ValueError if not relative
|
|
207
|
+
# AttributeError on Python < 3.9 (fallback below)
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
# Fallback depth check for edge cases not caught above
|
|
211
|
+
# Require at least 3 path components: /, parent, child
|
|
212
|
+
# This catches unusual paths not in the blocklist
|
|
213
|
+
if len(resolved_parent.parts) < 3:
|
|
214
|
+
return workspace, False
|
|
215
|
+
|
|
216
|
+
return common_parent, True
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def get_worktree_status(worktree_path: str) -> tuple[int, int, int, bool]:
|
|
220
|
+
"""Get status counts for a worktree (staged, modified, untracked, timed_out).
|
|
221
|
+
|
|
222
|
+
Parses git status --porcelain output where each line starts with:
|
|
223
|
+
- XY where X is index status, Y is worktree status
|
|
224
|
+
- X = staged changes (A, M, D, R, C)
|
|
225
|
+
- Y = unstaged changes (M, D)
|
|
226
|
+
- ?? = untracked files
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
worktree_path: Path to the worktree directory.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Tuple of (staged_count, modified_count, untracked_count, timed_out).
|
|
233
|
+
"""
|
|
234
|
+
try:
|
|
235
|
+
result = subprocess.run(
|
|
236
|
+
["git", "-C", worktree_path, "status", "--porcelain"],
|
|
237
|
+
capture_output=True,
|
|
238
|
+
text=True,
|
|
239
|
+
timeout=5,
|
|
240
|
+
)
|
|
241
|
+
if result.returncode != 0:
|
|
242
|
+
return 0, 0, 0, False
|
|
243
|
+
|
|
244
|
+
lines = [line for line in result.stdout.split("\n") if line.strip()]
|
|
245
|
+
except subprocess.TimeoutExpired:
|
|
246
|
+
return 0, 0, 0, True
|
|
247
|
+
|
|
248
|
+
staged = 0
|
|
249
|
+
modified = 0
|
|
250
|
+
untracked = 0
|
|
251
|
+
|
|
252
|
+
for line in lines:
|
|
253
|
+
if len(line) < 2:
|
|
254
|
+
continue
|
|
255
|
+
|
|
256
|
+
index_status = line[0] # X - index/staging area
|
|
257
|
+
worktree_status = line[1] # Y - working tree
|
|
258
|
+
|
|
259
|
+
if line.startswith("??"):
|
|
260
|
+
untracked += 1
|
|
261
|
+
else:
|
|
262
|
+
# Staged: any change in index (not space or ?)
|
|
263
|
+
if index_status not in (" ", "?"):
|
|
264
|
+
staged += 1
|
|
265
|
+
# Modified: any change in worktree (not space or ?)
|
|
266
|
+
if worktree_status not in (" ", "?"):
|
|
267
|
+
modified += 1
|
|
268
|
+
|
|
269
|
+
return staged, modified, untracked, False
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def get_worktrees_data(repo_path: Path) -> list[WorktreeInfo]:
|
|
273
|
+
"""Get raw worktree data from git.
|
|
274
|
+
|
|
275
|
+
This is the public API for getting worktree data.
|
|
276
|
+
Previously named _get_worktrees_data (private).
|
|
277
|
+
"""
|
|
278
|
+
try:
|
|
279
|
+
result = subprocess.run(
|
|
280
|
+
["git", "-C", str(repo_path), "worktree", "list", "--porcelain"],
|
|
281
|
+
capture_output=True,
|
|
282
|
+
text=True,
|
|
283
|
+
timeout=10,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
if result.returncode != 0:
|
|
287
|
+
return []
|
|
288
|
+
|
|
289
|
+
worktrees = []
|
|
290
|
+
current: dict[str, str] = {}
|
|
291
|
+
|
|
292
|
+
for line in result.stdout.split("\n"):
|
|
293
|
+
if line.startswith("worktree "):
|
|
294
|
+
if current:
|
|
295
|
+
worktrees.append(
|
|
296
|
+
WorktreeInfo(
|
|
297
|
+
path=current.get("path", ""),
|
|
298
|
+
branch=current.get("branch", ""),
|
|
299
|
+
status=current.get("status", ""),
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
current = {"path": line[9:], "branch": "", "status": ""}
|
|
303
|
+
elif line.startswith("branch "):
|
|
304
|
+
current["branch"] = line[7:].replace("refs/heads/", "")
|
|
305
|
+
elif line == "bare":
|
|
306
|
+
current["status"] = "bare"
|
|
307
|
+
elif line == "detached":
|
|
308
|
+
current["status"] = "detached"
|
|
309
|
+
|
|
310
|
+
if current:
|
|
311
|
+
worktrees.append(
|
|
312
|
+
WorktreeInfo(
|
|
313
|
+
path=current.get("path", ""),
|
|
314
|
+
branch=current.get("branch", ""),
|
|
315
|
+
status=current.get("status", ""),
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
return worktrees
|
|
320
|
+
|
|
321
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
322
|
+
return []
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def find_worktree_by_query(
|
|
326
|
+
repo_path: Path,
|
|
327
|
+
query: str,
|
|
328
|
+
) -> tuple[WorktreeInfo | None, list[WorktreeInfo]]:
|
|
329
|
+
"""Find a worktree by name, branch, or path using fuzzy matching.
|
|
330
|
+
|
|
331
|
+
Resolution order (prefix-aware):
|
|
332
|
+
1. Exact match on branch name (user typed full branch like 'scc/feature')
|
|
333
|
+
2. Prefixed branch match (user typed 'feature', branch is 'scc/feature')
|
|
334
|
+
3. Exact match on worktree directory name
|
|
335
|
+
4. Branch starts with query (prefix stripped for comparison)
|
|
336
|
+
5. Directory starts with query
|
|
337
|
+
6. Query contained in branch name (prefix stripped)
|
|
338
|
+
7. Query contained in directory name
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
repo_path: Path to the repository.
|
|
342
|
+
query: Search query (branch name, directory name, or partial match).
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
Tuple of (exact_match, all_matches). If exact_match is None,
|
|
346
|
+
all_matches contains partial matches for disambiguation.
|
|
347
|
+
"""
|
|
348
|
+
worktrees = get_worktrees_data(repo_path)
|
|
349
|
+
if not worktrees:
|
|
350
|
+
return None, []
|
|
351
|
+
|
|
352
|
+
query_lower = query.lower()
|
|
353
|
+
query_sanitized = sanitize_branch_name(query).lower()
|
|
354
|
+
prefix_lower = WORKTREE_BRANCH_PREFIX.lower()
|
|
355
|
+
prefixed_query = f"{prefix_lower}{query_sanitized}"
|
|
356
|
+
|
|
357
|
+
matches: list[WorktreeInfo] = []
|
|
358
|
+
|
|
359
|
+
# Priority 1: Exact match on branch name (user typed full branch name)
|
|
360
|
+
for wt in worktrees:
|
|
361
|
+
branch_lower = wt.branch.lower()
|
|
362
|
+
if branch_lower == query_lower:
|
|
363
|
+
return wt, [wt]
|
|
364
|
+
|
|
365
|
+
# Priority 2: Prefixed branch match (user typed feature name, branch is scc/feature)
|
|
366
|
+
for wt in worktrees:
|
|
367
|
+
branch_lower = wt.branch.lower()
|
|
368
|
+
if branch_lower == prefixed_query:
|
|
369
|
+
return wt, [wt]
|
|
370
|
+
|
|
371
|
+
# Priority 3: Exact match on directory name
|
|
372
|
+
for wt in worktrees:
|
|
373
|
+
dir_name = Path(wt.path).name.lower()
|
|
374
|
+
if dir_name == query_sanitized or dir_name == query_lower:
|
|
375
|
+
return wt, [wt]
|
|
376
|
+
|
|
377
|
+
# Priority 4: Branch starts with query (strip prefix for matching)
|
|
378
|
+
for wt in worktrees:
|
|
379
|
+
branch_lower = wt.branch.lower()
|
|
380
|
+
display_branch = (
|
|
381
|
+
branch_lower[len(prefix_lower) :]
|
|
382
|
+
if branch_lower.startswith(prefix_lower)
|
|
383
|
+
else branch_lower
|
|
384
|
+
)
|
|
385
|
+
if display_branch.startswith(query_sanitized):
|
|
386
|
+
matches.append(wt)
|
|
387
|
+
if len(matches) == 1:
|
|
388
|
+
return matches[0], matches
|
|
389
|
+
if matches:
|
|
390
|
+
return None, matches
|
|
391
|
+
|
|
392
|
+
# Priority 5: Directory starts with query
|
|
393
|
+
for wt in worktrees:
|
|
394
|
+
dir_name = Path(wt.path).name.lower()
|
|
395
|
+
if dir_name.startswith(query_sanitized):
|
|
396
|
+
matches.append(wt)
|
|
397
|
+
if len(matches) == 1:
|
|
398
|
+
return matches[0], matches
|
|
399
|
+
if matches:
|
|
400
|
+
return None, matches
|
|
401
|
+
|
|
402
|
+
# Priority 6: Query contained in branch name (prefix stripped)
|
|
403
|
+
for wt in worktrees:
|
|
404
|
+
branch_lower = wt.branch.lower()
|
|
405
|
+
display_branch = (
|
|
406
|
+
branch_lower[len(prefix_lower) :]
|
|
407
|
+
if branch_lower.startswith(prefix_lower)
|
|
408
|
+
else branch_lower
|
|
409
|
+
)
|
|
410
|
+
if query_sanitized in display_branch:
|
|
411
|
+
matches.append(wt)
|
|
412
|
+
if len(matches) == 1:
|
|
413
|
+
return matches[0], matches
|
|
414
|
+
if matches:
|
|
415
|
+
return None, matches
|
|
416
|
+
|
|
417
|
+
# Priority 7: Query contained in directory name
|
|
418
|
+
for wt in worktrees:
|
|
419
|
+
dir_name = Path(wt.path).name.lower()
|
|
420
|
+
if query_sanitized in dir_name:
|
|
421
|
+
matches.append(wt)
|
|
422
|
+
if len(matches) == 1:
|
|
423
|
+
return matches[0], matches
|
|
424
|
+
|
|
425
|
+
return None, matches
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def find_main_worktree(repo_path: Path) -> WorktreeInfo | None:
|
|
429
|
+
"""Find the worktree for the default/main branch.
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
repo_path: Path to the repository.
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
WorktreeInfo for the main branch worktree, or None if not found.
|
|
436
|
+
"""
|
|
437
|
+
default_branch = get_default_branch(repo_path)
|
|
438
|
+
worktrees = get_worktrees_data(repo_path)
|
|
439
|
+
|
|
440
|
+
for wt in worktrees:
|
|
441
|
+
if wt.branch == default_branch:
|
|
442
|
+
return wt
|
|
443
|
+
|
|
444
|
+
return None
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Workspace resolution services.
|
|
2
|
+
|
|
3
|
+
This package provides workspace detection and resolution for the launch command.
|
|
4
|
+
|
|
5
|
+
Exports:
|
|
6
|
+
resolve_launch_context: Main entry point for workspace resolution
|
|
7
|
+
is_suspicious_directory: Check if a path is inappropriate as workspace
|
|
8
|
+
get_suspicious_reason: Get human-readable reason for suspicious status
|
|
9
|
+
ResolverResult: Complete workspace resolution result (from core)
|
|
10
|
+
|
|
11
|
+
Example usage:
|
|
12
|
+
from scc_cli.services.workspace import resolve_launch_context
|
|
13
|
+
|
|
14
|
+
result = resolve_launch_context(Path.cwd(), workspace_arg=None)
|
|
15
|
+
if result is None:
|
|
16
|
+
# No workspace detected - need wizard or explicit path
|
|
17
|
+
...
|
|
18
|
+
elif not result.is_auto_eligible():
|
|
19
|
+
# Suspicious location - need confirmation
|
|
20
|
+
...
|
|
21
|
+
else:
|
|
22
|
+
# Good to auto-launch
|
|
23
|
+
...
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from scc_cli.core.workspace import ResolverResult
|
|
27
|
+
|
|
28
|
+
from .resolver import resolve_launch_context
|
|
29
|
+
from .suspicious import get_suspicious_reason, is_suspicious_directory
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"ResolverResult",
|
|
33
|
+
"get_suspicious_reason",
|
|
34
|
+
"is_suspicious_directory",
|
|
35
|
+
"resolve_launch_context",
|
|
36
|
+
]
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Workspace resolution service.
|
|
2
|
+
|
|
3
|
+
This module provides the main entry point for resolving workspace context
|
|
4
|
+
for the launch command. It implements the Smart Start resolution logic.
|
|
5
|
+
|
|
6
|
+
Resolution Policy (simple, explicit):
|
|
7
|
+
1. If --workspace provided: Use that path (explicit mode)
|
|
8
|
+
2. If cwd is in a git repo: Use git root (auto-detect)
|
|
9
|
+
3. If .scc.yaml found in parent walk: Use config parent dir (auto-detect)
|
|
10
|
+
4. Otherwise: Return None (requires wizard or explicit path)
|
|
11
|
+
|
|
12
|
+
Path Canonicalization:
|
|
13
|
+
All paths in the result are canonicalized via Path.resolve() to ensure:
|
|
14
|
+
- Symlinks are expanded
|
|
15
|
+
- Relative paths become absolute
|
|
16
|
+
- Consistent comparison semantics
|
|
17
|
+
|
|
18
|
+
Container Workdir (CW) Calculation:
|
|
19
|
+
- CW = str(ED) if ED is within MR, else str(WR)
|
|
20
|
+
- Uses realpath semantics for "within" check to prevent symlink escape
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from scc_cli.core.workspace import ResolverResult
|
|
28
|
+
from scc_cli.services.git.worktree import get_workspace_mount_path
|
|
29
|
+
from scc_cli.subprocess_utils import run_command
|
|
30
|
+
|
|
31
|
+
from .suspicious import is_suspicious_directory
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_path_within(child: Path, parent: Path) -> bool:
|
|
35
|
+
"""Check if child path is within parent path using resolved paths.
|
|
36
|
+
|
|
37
|
+
Both paths are resolved to handle symlinks properly. This prevents
|
|
38
|
+
symlink escape attacks where a symlink outside the mount could
|
|
39
|
+
trick the check.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
child: The potential child path.
|
|
43
|
+
parent: The potential parent path.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
True if child is equal to or under parent.
|
|
47
|
+
"""
|
|
48
|
+
try:
|
|
49
|
+
child_resolved = child.resolve()
|
|
50
|
+
parent_resolved = parent.resolve()
|
|
51
|
+
# Check if child is equal to parent or is a descendant
|
|
52
|
+
return child_resolved == parent_resolved or parent_resolved in child_resolved.parents
|
|
53
|
+
except OSError:
|
|
54
|
+
# Path resolution failed - be conservative
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _detect_git_root(cwd: Path) -> Path | None:
|
|
59
|
+
"""Detect git repository root from cwd using git rev-parse.
|
|
60
|
+
|
|
61
|
+
This handles:
|
|
62
|
+
- Regular git repos
|
|
63
|
+
- Subdirectories within repos
|
|
64
|
+
- Git worktrees
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
cwd: Current working directory.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Git repository root path, or None if not in a git repo.
|
|
71
|
+
"""
|
|
72
|
+
toplevel = run_command(
|
|
73
|
+
["git", "-C", str(cwd), "rev-parse", "--show-toplevel"],
|
|
74
|
+
timeout=5,
|
|
75
|
+
)
|
|
76
|
+
if toplevel:
|
|
77
|
+
return Path(toplevel.strip()).resolve()
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _detect_scc_config_root(cwd: Path) -> Path | None:
|
|
82
|
+
"""Find .scc.yaml by walking up from cwd.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
cwd: Current working directory.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Directory containing .scc.yaml, or None if not found.
|
|
89
|
+
"""
|
|
90
|
+
current = cwd.resolve()
|
|
91
|
+
while current != current.parent:
|
|
92
|
+
scc_config = current / ".scc.yaml"
|
|
93
|
+
if scc_config.is_file():
|
|
94
|
+
return current
|
|
95
|
+
current = current.parent
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _calculate_container_workdir(
|
|
100
|
+
entry_dir: Path,
|
|
101
|
+
mount_root: Path,
|
|
102
|
+
workspace_root: Path,
|
|
103
|
+
) -> str:
|
|
104
|
+
"""Calculate the container working directory.
|
|
105
|
+
|
|
106
|
+
The container workdir follows a simple rule:
|
|
107
|
+
- If entry_dir is within mount_root, use entry_dir as container cwd
|
|
108
|
+
- Otherwise, use workspace_root as container cwd
|
|
109
|
+
|
|
110
|
+
This preserves the user's subdirectory context when launching from
|
|
111
|
+
within a project, while falling back to workspace root when the
|
|
112
|
+
entry point is outside the mount scope.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
entry_dir: Where the user invoked from (ED).
|
|
116
|
+
mount_root: The host path mounted into the container (MR).
|
|
117
|
+
workspace_root: The workspace root (WR).
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Container working directory as absolute path string.
|
|
121
|
+
"""
|
|
122
|
+
if _is_path_within(entry_dir, mount_root):
|
|
123
|
+
return str(entry_dir.resolve())
|
|
124
|
+
return str(workspace_root.resolve())
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def resolve_launch_context(
|
|
128
|
+
cwd: Path,
|
|
129
|
+
workspace_arg: str | None,
|
|
130
|
+
*,
|
|
131
|
+
allow_suspicious: bool = False,
|
|
132
|
+
) -> ResolverResult | None:
|
|
133
|
+
"""Resolve workspace with complete context for launch.
|
|
134
|
+
|
|
135
|
+
This is the main entry point for workspace resolution. It implements
|
|
136
|
+
the Smart Start logic to determine workspace root, mount path, and
|
|
137
|
+
container working directory.
|
|
138
|
+
|
|
139
|
+
Auto-detect policy (simple, explicit):
|
|
140
|
+
1. git rev-parse --show-toplevel -> use git root
|
|
141
|
+
2. .scc.yaml parent walk -> use config dir
|
|
142
|
+
3. Anything else -> None (requires wizard or explicit path)
|
|
143
|
+
|
|
144
|
+
Suspicious handling:
|
|
145
|
+
- Auto-detected + suspicious -> is_suspicious=True (blocks auto-launch)
|
|
146
|
+
- .scc.yaml resolving WR to suspicious (e.g., HOME) -> is_suspicious=True
|
|
147
|
+
- Explicit + suspicious + allow_suspicious=False -> is_suspicious=True
|
|
148
|
+
- Explicit + suspicious + allow_suspicious=True -> proceed (user confirmed)
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
cwd: Current working directory (where user invoked from).
|
|
152
|
+
workspace_arg: Explicit workspace path from --workspace arg, or None.
|
|
153
|
+
allow_suspicious: If True, allow explicit paths to suspicious locations.
|
|
154
|
+
This is typically set via --force or after user confirmation.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
ResolverResult with all paths canonicalized, or None if:
|
|
158
|
+
- No workspace could be auto-detected AND no explicit path provided
|
|
159
|
+
- Explicit path doesn't exist
|
|
160
|
+
"""
|
|
161
|
+
entry_dir = cwd.resolve()
|
|
162
|
+
is_auto_detected = workspace_arg is None
|
|
163
|
+
|
|
164
|
+
# Determine workspace root
|
|
165
|
+
if workspace_arg is not None:
|
|
166
|
+
# Explicit --workspace provided
|
|
167
|
+
workspace_path = Path(workspace_arg).expanduser()
|
|
168
|
+
if not workspace_path.is_absolute():
|
|
169
|
+
workspace_path = (cwd / workspace_path).resolve()
|
|
170
|
+
else:
|
|
171
|
+
workspace_path = workspace_path.resolve()
|
|
172
|
+
|
|
173
|
+
if not workspace_path.exists():
|
|
174
|
+
# Explicit path doesn't exist - return None
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
workspace_root = workspace_path
|
|
178
|
+
reason = f"Explicit --workspace: {workspace_arg}"
|
|
179
|
+
else:
|
|
180
|
+
# Auto-detection: try git first, then .scc.yaml
|
|
181
|
+
git_root = _detect_git_root(cwd)
|
|
182
|
+
if git_root is not None:
|
|
183
|
+
workspace_root = git_root
|
|
184
|
+
reason = f"Git repository detected at: {git_root}"
|
|
185
|
+
else:
|
|
186
|
+
scc_config_root = _detect_scc_config_root(cwd)
|
|
187
|
+
if scc_config_root is not None:
|
|
188
|
+
workspace_root = scc_config_root
|
|
189
|
+
reason = f".scc.yaml found at: {scc_config_root}"
|
|
190
|
+
else:
|
|
191
|
+
# No auto-detection possible
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
# Check if workspace root is suspicious
|
|
195
|
+
is_suspicious = is_suspicious_directory(workspace_root)
|
|
196
|
+
|
|
197
|
+
# For explicit paths with allow_suspicious=True, clear the flag
|
|
198
|
+
# (user has confirmed they want to use this location)
|
|
199
|
+
if not is_auto_detected and allow_suspicious and is_suspicious:
|
|
200
|
+
# User explicitly confirmed - still report as suspicious but allow
|
|
201
|
+
# The caller can check is_auto_eligible() to see if it needs confirmation
|
|
202
|
+
pass
|
|
203
|
+
|
|
204
|
+
# Determine mount root (may expand for worktrees)
|
|
205
|
+
mount_root, is_mount_expanded = get_workspace_mount_path(workspace_root)
|
|
206
|
+
|
|
207
|
+
# Calculate container working directory
|
|
208
|
+
container_workdir = _calculate_container_workdir(
|
|
209
|
+
entry_dir,
|
|
210
|
+
mount_root,
|
|
211
|
+
workspace_root,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
return ResolverResult(
|
|
215
|
+
workspace_root=workspace_root,
|
|
216
|
+
entry_dir=entry_dir,
|
|
217
|
+
mount_root=mount_root,
|
|
218
|
+
container_workdir=container_workdir,
|
|
219
|
+
is_auto_detected=is_auto_detected,
|
|
220
|
+
is_suspicious=is_suspicious,
|
|
221
|
+
is_mount_expanded=is_mount_expanded,
|
|
222
|
+
reason=reason,
|
|
223
|
+
)
|