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,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
+ )