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,869 @@
1
+ """Git interactive UI functions - user-facing workflows with console output.
2
+
3
+ These functions combine domain logic with Rich console output for
4
+ interactive user workflows. They use:
5
+ - services/git/ for data operations
6
+ - ui/git_render.py for pure rendering
7
+ - panels, theme for consistent styling
8
+
9
+ Extracted from git.py to achieve "no Rich imports in git.py" criterion.
10
+ """
11
+
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ from collections.abc import Callable
16
+ from pathlib import Path
17
+
18
+ from rich import box
19
+ from rich.console import Console
20
+ from rich.prompt import Prompt
21
+ from rich.table import Table
22
+ from rich.text import Text
23
+ from rich.tree import Tree
24
+
25
+ from ..confirm import Confirm
26
+ from ..core.constants import WORKTREE_BRANCH_PREFIX
27
+ from ..core.errors import (
28
+ CloneError,
29
+ NotAGitRepoError,
30
+ WorktreeCreationError,
31
+ WorktreeExistsError,
32
+ )
33
+ from ..panels import (
34
+ create_error_panel,
35
+ create_info_panel,
36
+ create_success_panel,
37
+ create_warning_panel,
38
+ )
39
+ from ..services.git.branch import (
40
+ PROTECTED_BRANCHES,
41
+ get_current_branch,
42
+ get_default_branch,
43
+ get_uncommitted_files,
44
+ sanitize_branch_name,
45
+ )
46
+ from ..services.git.core import has_remote, is_git_repo
47
+ from ..services.git.worktree import (
48
+ WorktreeInfo,
49
+ get_worktree_status,
50
+ get_worktrees_data,
51
+ )
52
+ from ..theme import Indicators, Spinners
53
+ from ..utils.locks import file_lock, lock_path
54
+ from .git_render import render_worktrees_table
55
+
56
+ # ═══════════════════════════════════════════════════════════════════════════════
57
+ # Branch Safety - Interactive UI
58
+ # ═══════════════════════════════════════════════════════════════════════════════
59
+
60
+
61
+ def check_branch_safety(path: Path, console: Console) -> bool:
62
+ """Check if current branch is safe for Claude Code work.
63
+
64
+ Display a visual "speed bump" for protected branches with
65
+ interactive options to create a feature branch or continue.
66
+
67
+ Args:
68
+ path: Path to the git repository.
69
+ console: Rich console for output.
70
+
71
+ Returns:
72
+ True if safe to proceed, False if user cancelled.
73
+ """
74
+ if not is_git_repo(path):
75
+ return True
76
+
77
+ current = get_current_branch(path)
78
+
79
+ if current in PROTECTED_BRANCHES:
80
+ console.print()
81
+
82
+ # Visual speed bump - warning panel
83
+ warning = create_warning_panel(
84
+ "Protected Branch",
85
+ f"You are on branch '{current}'\n\n"
86
+ "For safety, Claude Code work should happen on a feature branch.\n"
87
+ "Direct pushes to protected branches are blocked by git hooks.",
88
+ "Create a feature branch for isolated, safe development",
89
+ )
90
+ console.print(warning)
91
+ console.print()
92
+
93
+ # Interactive options table
94
+ options_table = Table(
95
+ box=box.SIMPLE,
96
+ show_header=False,
97
+ padding=(0, 2),
98
+ expand=False,
99
+ )
100
+ options_table.add_column("Option", style="yellow", width=10)
101
+ options_table.add_column("Action", style="white")
102
+ options_table.add_column("Description", style="dim")
103
+
104
+ options_table.add_row("[1]", "Create branch", "New feature branch (recommended)")
105
+ options_table.add_row("[2]", "Continue", "Stay on protected branch (pushes blocked)")
106
+ options_table.add_row("[3]", "Cancel", "Exit without starting")
107
+
108
+ console.print(options_table)
109
+ console.print()
110
+
111
+ choice = Prompt.ask(
112
+ "[cyan]Select option[/cyan]",
113
+ choices=["1", "2", "3", "create", "continue", "cancel"],
114
+ default="1",
115
+ )
116
+
117
+ if choice in ["1", "create"]:
118
+ console.print()
119
+ name = Prompt.ask("[cyan]Feature name[/cyan]")
120
+ safe_name = sanitize_branch_name(name)
121
+ branch_name = f"{WORKTREE_BRANCH_PREFIX}{safe_name}"
122
+
123
+ with console.status(
124
+ f"[cyan]Creating branch {branch_name}...[/cyan]", spinner=Spinners.SETUP
125
+ ):
126
+ try:
127
+ subprocess.run(
128
+ ["git", "-C", str(path), "checkout", "-b", branch_name],
129
+ check=True,
130
+ capture_output=True,
131
+ timeout=10,
132
+ )
133
+ except subprocess.CalledProcessError:
134
+ console.print()
135
+ console.print(
136
+ create_error_panel(
137
+ "Branch Creation Failed",
138
+ f"Could not create branch '{branch_name}'",
139
+ "Check if the branch already exists or if there are uncommitted changes",
140
+ )
141
+ )
142
+ return False
143
+
144
+ console.print()
145
+ console.print(
146
+ create_success_panel(
147
+ "Branch Created",
148
+ {
149
+ "Branch": branch_name,
150
+ "Base": current,
151
+ },
152
+ )
153
+ )
154
+ return True
155
+
156
+ elif choice in ["2", "continue"]:
157
+ console.print()
158
+ console.print(
159
+ "[dim]→ Continuing on protected branch. "
160
+ "Push attempts will be blocked by git hooks.[/dim]"
161
+ )
162
+ return True
163
+
164
+ else:
165
+ return False
166
+
167
+ return True
168
+
169
+
170
+ # ═══════════════════════════════════════════════════════════════════════════════
171
+ # Worktree Operations - Beautiful UI
172
+ # ═══════════════════════════════════════════════════════════════════════════════
173
+
174
+
175
+ def create_worktree(
176
+ repo_path: Path,
177
+ name: str,
178
+ base_branch: str | None = None,
179
+ console: Console | None = None,
180
+ ) -> Path:
181
+ """Create a new git worktree with visual progress feedback.
182
+
183
+ Args:
184
+ repo_path: Path to the main repository.
185
+ name: Feature name for the worktree.
186
+ base_branch: Branch to base the worktree on (default: main/master).
187
+ console: Rich console for output.
188
+
189
+ Returns:
190
+ Path to the created worktree.
191
+
192
+ Raises:
193
+ NotAGitRepoError: Path is not a git repository.
194
+ WorktreeExistsError: Worktree already exists.
195
+ WorktreeCreationError: Failed to create worktree.
196
+ """
197
+ if console is None:
198
+ console = Console()
199
+
200
+ # Validate repository
201
+ if not is_git_repo(repo_path):
202
+ raise NotAGitRepoError(path=str(repo_path))
203
+
204
+ safe_name = sanitize_branch_name(name)
205
+ if not safe_name:
206
+ raise ValueError(f"Invalid worktree name: {name!r}")
207
+
208
+ branch_name = f"{WORKTREE_BRANCH_PREFIX}{safe_name}"
209
+
210
+ # Determine worktree location
211
+ worktree_base = repo_path.parent / f"{repo_path.name}-worktrees"
212
+ worktree_path = worktree_base / safe_name
213
+
214
+ lock_file = lock_path("worktree", repo_path)
215
+ with file_lock(lock_file):
216
+ if worktree_path.exists():
217
+ raise WorktreeExistsError(path=str(worktree_path))
218
+
219
+ # Determine base branch
220
+ if not base_branch:
221
+ base_branch = get_default_branch(repo_path)
222
+
223
+ console.print()
224
+ console.print(
225
+ create_info_panel(
226
+ "Creating Worktree", f"Feature: {safe_name}", f"Location: {worktree_path}"
227
+ )
228
+ )
229
+ console.print()
230
+
231
+ worktree_created = False
232
+
233
+ def _install_deps() -> None:
234
+ success = install_dependencies(worktree_path, console)
235
+ if not success:
236
+ raise WorktreeCreationError(
237
+ name=safe_name,
238
+ user_message="Dependency install failed for the new worktree",
239
+ suggested_action="Install dependencies manually and retry if needed",
240
+ )
241
+
242
+ # Multi-step progress - conditionally include fetch if remote exists
243
+ steps: list[tuple[str, Callable[[], None]]] = []
244
+
245
+ # Only fetch if the repository has a remote origin
246
+ if has_remote(repo_path):
247
+ steps.append(("Fetching latest changes", lambda: _fetch_branch(repo_path, base_branch)))
248
+
249
+ steps.extend(
250
+ [
251
+ (
252
+ "Creating worktree",
253
+ lambda: _create_worktree_dir(
254
+ repo_path, worktree_path, branch_name, base_branch, worktree_base
255
+ ),
256
+ ),
257
+ ("Installing dependencies", _install_deps),
258
+ ]
259
+ )
260
+
261
+ try:
262
+ for step_name, step_func in steps:
263
+ with console.status(f"[cyan]{step_name}...[/cyan]", spinner=Spinners.SETUP):
264
+ try:
265
+ step_func()
266
+ except subprocess.CalledProcessError as e:
267
+ raise WorktreeCreationError(
268
+ name=safe_name,
269
+ command=" ".join(e.cmd) if hasattr(e, "cmd") else None,
270
+ stderr=e.stderr.decode() if e.stderr else None,
271
+ )
272
+ console.print(f" [green]{Indicators.get('PASS')}[/green] {step_name}")
273
+ if step_name == "Creating worktree":
274
+ worktree_created = True
275
+ except KeyboardInterrupt:
276
+ if worktree_created or worktree_path.exists():
277
+ _cleanup_partial_worktree(repo_path, worktree_path)
278
+ raise
279
+ except WorktreeCreationError:
280
+ if worktree_created or worktree_path.exists():
281
+ _cleanup_partial_worktree(repo_path, worktree_path)
282
+ raise
283
+
284
+ console.print()
285
+ console.print(
286
+ create_success_panel(
287
+ "Worktree Ready",
288
+ {
289
+ "Path": str(worktree_path),
290
+ "Branch": branch_name,
291
+ "Base": base_branch,
292
+ "Next": f"cd {worktree_path}",
293
+ },
294
+ )
295
+ )
296
+
297
+ return worktree_path
298
+
299
+
300
+ def _fetch_branch(repo_path: Path, branch: str) -> None:
301
+ """Fetch a branch from origin.
302
+
303
+ Raises:
304
+ WorktreeCreationError: If fetch fails (network error, branch not found, etc.)
305
+ """
306
+ result = subprocess.run(
307
+ ["git", "-C", str(repo_path), "fetch", "origin", branch],
308
+ capture_output=True,
309
+ text=True,
310
+ timeout=30,
311
+ )
312
+ if result.returncode != 0:
313
+ error_msg = result.stderr.strip() if result.stderr else "Unknown fetch error"
314
+ lower = error_msg.lower()
315
+ user_message = f"Failed to fetch branch '{branch}'"
316
+ suggested_action = "Check the branch name and your network connection"
317
+
318
+ if "couldn't find remote ref" in lower or "remote ref" in lower and "not found" in lower:
319
+ user_message = f"Branch '{branch}' not found on origin"
320
+ suggested_action = "Check the branch name or fetch remote branches"
321
+ elif "could not resolve host" in lower or "failed to connect" in lower:
322
+ user_message = "Network error while fetching from origin"
323
+ suggested_action = "Check your network or VPN connection"
324
+ elif "permission denied" in lower or "authentication" in lower:
325
+ user_message = "Authentication error while fetching from origin"
326
+ suggested_action = "Check your git credentials and remote access"
327
+
328
+ raise WorktreeCreationError(
329
+ name=branch,
330
+ user_message=user_message,
331
+ suggested_action=suggested_action,
332
+ command=f"git -C {repo_path} fetch origin {branch}",
333
+ stderr=error_msg,
334
+ )
335
+
336
+
337
+ def _cleanup_partial_worktree(repo_path: Path, worktree_path: Path) -> None:
338
+ """Best-effort cleanup for partially created worktrees."""
339
+ try:
340
+ subprocess.run(
341
+ [
342
+ "git",
343
+ "-C",
344
+ str(repo_path),
345
+ "worktree",
346
+ "remove",
347
+ "--force",
348
+ str(worktree_path),
349
+ ],
350
+ capture_output=True,
351
+ timeout=30,
352
+ )
353
+ except (subprocess.SubprocessError, FileNotFoundError):
354
+ pass
355
+
356
+ shutil.rmtree(worktree_path, ignore_errors=True)
357
+
358
+ try:
359
+ subprocess.run(
360
+ ["git", "-C", str(repo_path), "worktree", "prune"],
361
+ capture_output=True,
362
+ timeout=30,
363
+ )
364
+ except (subprocess.SubprocessError, FileNotFoundError):
365
+ pass
366
+
367
+
368
+ def _create_worktree_dir(
369
+ repo_path: Path,
370
+ worktree_path: Path,
371
+ branch_name: str,
372
+ base_branch: str,
373
+ worktree_base: Path,
374
+ ) -> None:
375
+ """Create the worktree directory."""
376
+ worktree_base.mkdir(parents=True, exist_ok=True)
377
+
378
+ try:
379
+ subprocess.run(
380
+ [
381
+ "git",
382
+ "-C",
383
+ str(repo_path),
384
+ "worktree",
385
+ "add",
386
+ "-b",
387
+ branch_name,
388
+ str(worktree_path),
389
+ f"origin/{base_branch}",
390
+ ],
391
+ check=True,
392
+ capture_output=True,
393
+ timeout=30,
394
+ )
395
+ except subprocess.CalledProcessError:
396
+ # Try without origin/ prefix
397
+ subprocess.run(
398
+ [
399
+ "git",
400
+ "-C",
401
+ str(repo_path),
402
+ "worktree",
403
+ "add",
404
+ "-b",
405
+ branch_name,
406
+ str(worktree_path),
407
+ base_branch,
408
+ ],
409
+ check=True,
410
+ capture_output=True,
411
+ timeout=30,
412
+ )
413
+
414
+
415
+ def list_worktrees(
416
+ repo_path: Path,
417
+ console: Console | None = None,
418
+ *,
419
+ verbose: bool = False,
420
+ ) -> list[WorktreeInfo]:
421
+ """List all worktrees for a repository with beautiful table display.
422
+
423
+ Args:
424
+ repo_path: Path to the repository.
425
+ console: Rich console for output (if None, return data only).
426
+ verbose: If True, fetch git status for each worktree (slower).
427
+
428
+ Returns:
429
+ List of WorktreeInfo objects.
430
+ """
431
+ worktrees = get_worktrees_data(repo_path)
432
+
433
+ # Detect current worktree
434
+ cwd = os.getcwd()
435
+ for wt in worktrees:
436
+ if os.path.realpath(wt.path) == os.path.realpath(cwd):
437
+ wt.is_current = True
438
+ break
439
+
440
+ # Fetch status if verbose
441
+ if verbose:
442
+ for wt in worktrees:
443
+ staged, modified, untracked, timed_out = get_worktree_status(wt.path)
444
+ wt.staged_count = staged
445
+ wt.modified_count = modified
446
+ wt.untracked_count = untracked
447
+ wt.status_timed_out = timed_out
448
+ wt.has_changes = (staged + modified + untracked) > 0
449
+
450
+ if console is not None:
451
+ render_worktrees_table(worktrees, console, verbose=verbose)
452
+
453
+ # Summary if any timed out (only when verbose and console provided)
454
+ if verbose:
455
+ timeout_count = sum(1 for wt in worktrees if wt.status_timed_out)
456
+ if timeout_count > 0:
457
+ console.print(
458
+ f"[dim]Note: {timeout_count} worktree(s) timed out computing status.[/dim]",
459
+ )
460
+
461
+ return worktrees
462
+
463
+
464
+ def cleanup_worktree(
465
+ repo_path: Path,
466
+ name: str,
467
+ force: bool,
468
+ console: Console,
469
+ *,
470
+ skip_confirm: bool = False,
471
+ dry_run: bool = False,
472
+ ) -> bool:
473
+ """Clean up a worktree with safety checks and visual feedback.
474
+
475
+ Show uncommitted changes before deletion to prevent accidental data loss.
476
+
477
+ Args:
478
+ repo_path: Path to the main repository.
479
+ name: Name of the worktree to remove.
480
+ force: If True, remove even if worktree has uncommitted changes.
481
+ console: Rich console for output.
482
+ skip_confirm: If True, skip interactive confirmations (--yes flag).
483
+ dry_run: If True, show what would be removed but don't actually remove.
484
+
485
+ Returns:
486
+ True if worktree was removed (or would be in dry-run mode), False otherwise.
487
+ """
488
+ safe_name = sanitize_branch_name(name)
489
+ branch_name = f"{WORKTREE_BRANCH_PREFIX}{safe_name}"
490
+ worktree_base = repo_path.parent / f"{repo_path.name}-worktrees"
491
+ worktree_path = worktree_base / safe_name
492
+
493
+ if not worktree_path.exists():
494
+ console.print()
495
+ console.print(
496
+ create_warning_panel(
497
+ "Worktree Not Found",
498
+ f"No worktree found at: {worktree_path}",
499
+ "Use 'scc worktrees <repo>' to list available worktrees",
500
+ )
501
+ )
502
+ return False
503
+
504
+ console.print()
505
+ if dry_run:
506
+ console.print(
507
+ create_info_panel(
508
+ "Dry Run: Cleanup Worktree",
509
+ f"Worktree: {safe_name}",
510
+ f"Path: {worktree_path}",
511
+ )
512
+ )
513
+ else:
514
+ console.print(
515
+ create_info_panel(
516
+ "Cleanup Worktree", f"Worktree: {safe_name}", f"Path: {worktree_path}"
517
+ )
518
+ )
519
+ console.print()
520
+
521
+ # Check for uncommitted changes - show evidence
522
+ if not force:
523
+ uncommitted = get_uncommitted_files(worktree_path)
524
+
525
+ if uncommitted:
526
+ # Build a tree of files that will be lost
527
+ tree = Tree(f"[red bold]Uncommitted Changes ({len(uncommitted)})[/red bold]")
528
+
529
+ for f in uncommitted[:10]: # Show max 10
530
+ tree.add(Text(f, style="dim"))
531
+
532
+ if len(uncommitted) > 10:
533
+ tree.add(Text(f"...and {len(uncommitted) - 10} more", style="dim italic"))
534
+
535
+ console.print(tree)
536
+ console.print()
537
+ console.print("[red bold]These changes will be permanently lost.[/red bold]")
538
+ console.print()
539
+
540
+ # Skip confirmation prompt if --yes was provided
541
+ if not skip_confirm:
542
+ if not Confirm.ask("[yellow]Delete worktree anyway?[/yellow]", default=False):
543
+ console.print("[dim]Cleanup cancelled.[/dim]")
544
+ return False
545
+
546
+ # Dry run: show what would be removed without actually removing
547
+ if dry_run:
548
+ console.print(" [cyan]Would remove:[/cyan]")
549
+ console.print(f" - Worktree: {worktree_path}")
550
+ console.print(f" - Branch: {branch_name} [dim](if confirmed)[/dim]")
551
+ console.print()
552
+ console.print("[dim]Dry run complete. No changes made.[/dim]")
553
+ return True
554
+
555
+ # Remove worktree
556
+ with console.status("[cyan]Removing worktree...[/cyan]", spinner=Spinners.DEFAULT):
557
+ try:
558
+ force_flag = ["--force"] if force else []
559
+ subprocess.run(
560
+ ["git", "-C", str(repo_path), "worktree", "remove", str(worktree_path)]
561
+ + force_flag,
562
+ check=True,
563
+ capture_output=True,
564
+ timeout=30,
565
+ )
566
+ except subprocess.CalledProcessError:
567
+ # Fallback: manual removal
568
+ shutil.rmtree(worktree_path, ignore_errors=True)
569
+ subprocess.run(
570
+ ["git", "-C", str(repo_path), "worktree", "prune"],
571
+ capture_output=True,
572
+ timeout=10,
573
+ )
574
+
575
+ console.print(f" [green]{Indicators.get('PASS')}[/green] Worktree removed")
576
+
577
+ # Ask about branch deletion (auto-delete if --yes was provided)
578
+ console.print()
579
+ branch_deleted = False
580
+ should_delete_branch = skip_confirm or Confirm.ask(
581
+ f"[cyan]Also delete branch '{branch_name}'?[/cyan]", default=False
582
+ )
583
+ if should_delete_branch:
584
+ with console.status("[cyan]Deleting branch...[/cyan]", spinner=Spinners.DEFAULT):
585
+ subprocess.run(
586
+ ["git", "-C", str(repo_path), "branch", "-D", branch_name],
587
+ capture_output=True,
588
+ timeout=10,
589
+ )
590
+ console.print(f" [green]{Indicators.get('PASS')}[/green] Branch deleted")
591
+ branch_deleted = True
592
+
593
+ console.print()
594
+ console.print(
595
+ create_success_panel(
596
+ "Cleanup Complete",
597
+ {
598
+ "Removed": str(worktree_path),
599
+ "Branch": "deleted" if branch_deleted else "kept",
600
+ },
601
+ )
602
+ )
603
+
604
+ return True
605
+
606
+
607
+ # ═══════════════════════════════════════════════════════════════════════════════
608
+ # Dependency Installation
609
+ # ═══════════════════════════════════════════════════════════════════════════════
610
+
611
+
612
+ def _run_install_cmd(
613
+ cmd: list[str],
614
+ path: Path,
615
+ console: Console | None,
616
+ timeout: int = 300,
617
+ ) -> bool:
618
+ """Run an install command and warn on failure. Returns True if successful."""
619
+ try:
620
+ result = subprocess.run(cmd, cwd=path, capture_output=True, text=True, timeout=timeout)
621
+ if result.returncode != 0 and console:
622
+ error_detail = result.stderr.strip() if result.stderr else ""
623
+ message = f"'{' '.join(cmd)}' failed with exit code {result.returncode}"
624
+ if error_detail:
625
+ message += f": {error_detail[:100]}" # Truncate long errors
626
+ console.print(
627
+ create_warning_panel(
628
+ "Dependency Install Warning",
629
+ message,
630
+ "You may need to install dependencies manually",
631
+ )
632
+ )
633
+ return False
634
+ return True
635
+ except subprocess.TimeoutExpired:
636
+ if console:
637
+ console.print(
638
+ create_warning_panel(
639
+ "Dependency Install Timeout",
640
+ f"'{' '.join(cmd)}' timed out after {timeout}s",
641
+ "You may need to install dependencies manually",
642
+ )
643
+ )
644
+ return False
645
+
646
+
647
+ def install_dependencies(path: Path, console: Console | None = None) -> bool:
648
+ """Detect and install project dependencies.
649
+
650
+ Support Node.js (npm/yarn/pnpm/bun), Python (pip/poetry/uv), and
651
+ Java (Maven/Gradle). Warn user if any install fails rather than
652
+ silently ignoring.
653
+
654
+ Args:
655
+ path: Path to the project directory.
656
+ console: Rich console for output (optional).
657
+ """
658
+ success = True
659
+
660
+ # Node.js
661
+ if (path / "package.json").exists():
662
+ if (path / "pnpm-lock.yaml").exists():
663
+ cmd = ["pnpm", "install"]
664
+ elif (path / "bun.lockb").exists():
665
+ cmd = ["bun", "install"]
666
+ elif (path / "yarn.lock").exists():
667
+ cmd = ["yarn", "install"]
668
+ else:
669
+ cmd = ["npm", "install"]
670
+
671
+ success = _run_install_cmd(cmd, path, console, timeout=300) and success
672
+
673
+ # Python
674
+ if (path / "pyproject.toml").exists():
675
+ if shutil.which("poetry"):
676
+ success = (
677
+ _run_install_cmd(["poetry", "install"], path, console, timeout=300) and success
678
+ )
679
+ elif shutil.which("uv"):
680
+ success = (
681
+ _run_install_cmd(["uv", "pip", "install", "-e", "."], path, console, timeout=300)
682
+ and success
683
+ )
684
+ elif (path / "requirements.txt").exists():
685
+ success = (
686
+ _run_install_cmd(
687
+ ["pip", "install", "-r", "requirements.txt"],
688
+ path,
689
+ console,
690
+ timeout=300,
691
+ )
692
+ and success
693
+ )
694
+
695
+ # Java/Maven
696
+ if (path / "pom.xml").exists():
697
+ success = (
698
+ _run_install_cmd(["mvn", "dependency:resolve"], path, console, timeout=600) and success
699
+ )
700
+
701
+ # Java/Gradle
702
+ if (path / "build.gradle").exists() or (path / "build.gradle.kts").exists():
703
+ gradle_cmd = "./gradlew" if (path / "gradlew").exists() else "gradle"
704
+ success = (
705
+ _run_install_cmd([gradle_cmd, "dependencies"], path, console, timeout=600) and success
706
+ )
707
+
708
+ return success
709
+
710
+
711
+ # ═══════════════════════════════════════════════════════════════════════════════
712
+ # Repository Cloning
713
+ # ═══════════════════════════════════════════════════════════════════════════════
714
+
715
+
716
+ def clone_repo(url: str, base_path: str, console: Console | None = None) -> str:
717
+ """Clone a repository with progress feedback.
718
+
719
+ Args:
720
+ url: Repository URL (HTTPS or SSH).
721
+ base_path: Base directory for cloning.
722
+ console: Rich console for output.
723
+
724
+ Returns:
725
+ Path to the cloned repository.
726
+
727
+ Raises:
728
+ CloneError: Failed to clone repository.
729
+ """
730
+ if console is None:
731
+ console = Console()
732
+
733
+ base = Path(base_path).expanduser()
734
+ base.mkdir(parents=True, exist_ok=True)
735
+
736
+ # Extract repo name from URL
737
+ name = url.rstrip("/").split("/")[-1]
738
+ if name.endswith(".git"):
739
+ name = name[:-4]
740
+
741
+ target = base / name
742
+
743
+ if target.exists():
744
+ # Already cloned
745
+ console.print(f"[dim]Repository already exists at {target}[/dim]")
746
+ return str(target)
747
+
748
+ console.print()
749
+ console.print(create_info_panel("Cloning Repository", url, f"Target: {target}"))
750
+ console.print()
751
+
752
+ with console.status("[cyan]Cloning...[/cyan]", spinner=Spinners.NETWORK):
753
+ try:
754
+ subprocess.run(
755
+ ["git", "clone", url, str(target)],
756
+ check=True,
757
+ capture_output=True,
758
+ timeout=300,
759
+ )
760
+ except subprocess.CalledProcessError as e:
761
+ raise CloneError(
762
+ url=url,
763
+ command=f"git clone {url}",
764
+ stderr=e.stderr.decode() if e.stderr else None,
765
+ )
766
+
767
+ console.print(f" [green]{Indicators.get('PASS')}[/green] Repository cloned")
768
+ console.print()
769
+ console.print(
770
+ create_success_panel(
771
+ "Clone Complete",
772
+ {
773
+ "Repository": name,
774
+ "Path": str(target),
775
+ },
776
+ )
777
+ )
778
+
779
+ return str(target)
780
+
781
+
782
+ # ═══════════════════════════════════════════════════════════════════════════════
783
+ # Git Hooks Installation
784
+ # ═══════════════════════════════════════════════════════════════════════════════
785
+
786
+
787
+ def install_hooks(console: Console) -> None:
788
+ """Install global git hooks for branch protection.
789
+
790
+ Configure the global core.hooksPath and install a pre-push hook
791
+ that prevents direct pushes to protected branches.
792
+
793
+ Args:
794
+ console: Rich console for output.
795
+ """
796
+
797
+ hooks_dir = Path.home() / ".config" / "git" / "hooks"
798
+ hooks_dir.mkdir(parents=True, exist_ok=True)
799
+
800
+ pre_push_content = """#!/bin/bash
801
+ # SCC - Pre-push hook
802
+ # Prevents direct pushes to protected branches
803
+
804
+ PROTECTED_BRANCHES="main master develop production staging"
805
+
806
+ current_branch=$(git symbolic-ref HEAD 2>/dev/null | sed -e 's,.*/\\(.*\\),\\1,')
807
+
808
+ for protected in $PROTECTED_BRANCHES; do
809
+ if [ "$current_branch" = "$protected" ]; then
810
+ echo ""
811
+ echo "BLOCKED: Direct push to '$protected' is not allowed"
812
+ echo ""
813
+ echo "Please push to a feature branch instead:"
814
+ echo " git checkout -b scc/<feature-name>"
815
+ echo " git push -u origin scc/<feature-name>"
816
+ echo ""
817
+ exit 1
818
+ fi
819
+ done
820
+
821
+ while read local_ref local_sha remote_ref remote_sha; do
822
+ remote_branch=$(echo "$remote_ref" | sed -e 's,.*/\\(.*\\),\\1,')
823
+
824
+ for protected in $PROTECTED_BRANCHES; do
825
+ if [ "$remote_branch" = "$protected" ]; then
826
+ echo ""
827
+ echo "BLOCKED: Push to protected branch '$protected'"
828
+ echo ""
829
+ exit 1
830
+ fi
831
+ done
832
+ done
833
+
834
+ exit 0
835
+ """
836
+
837
+ pre_push_path = hooks_dir / "pre-push"
838
+
839
+ console.print()
840
+ console.print(
841
+ create_info_panel(
842
+ "Installing Git Hooks",
843
+ "Branch protection hooks will be installed globally",
844
+ f"Location: {hooks_dir}",
845
+ )
846
+ )
847
+ console.print()
848
+
849
+ with console.status("[cyan]Installing hooks...[/cyan]", spinner=Spinners.SETUP):
850
+ pre_push_path.write_text(pre_push_content)
851
+ pre_push_path.chmod(0o755)
852
+
853
+ # Configure git to use global hooks
854
+ subprocess.run(
855
+ ["git", "config", "--global", "core.hooksPath", str(hooks_dir)],
856
+ capture_output=True,
857
+ )
858
+
859
+ console.print(f" [green]{Indicators.get('PASS')}[/green] Pre-push hook installed")
860
+ console.print()
861
+ console.print(
862
+ create_success_panel(
863
+ "Hooks Installed",
864
+ {
865
+ "Location": str(hooks_dir),
866
+ "Protected branches": "main, master, develop, production, staging",
867
+ },
868
+ )
869
+ )