scc-cli 1.4.0__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 (112) 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 +259 -0
  8. scc_cli/cli_admin.py +683 -0
  9. scc_cli/cli_audit.py +245 -0
  10. scc_cli/cli_common.py +166 -0
  11. scc_cli/cli_config.py +527 -0
  12. scc_cli/cli_exceptions.py +705 -0
  13. scc_cli/cli_helpers.py +244 -0
  14. scc_cli/cli_init.py +272 -0
  15. scc_cli/cli_launch.py +1400 -0
  16. scc_cli/cli_org.py +1433 -0
  17. scc_cli/cli_support.py +322 -0
  18. scc_cli/cli_team.py +858 -0
  19. scc_cli/cli_worktree.py +865 -0
  20. scc_cli/config.py +583 -0
  21. scc_cli/console.py +562 -0
  22. scc_cli/constants.py +79 -0
  23. scc_cli/contexts.py +377 -0
  24. scc_cli/deprecation.py +54 -0
  25. scc_cli/deps.py +189 -0
  26. scc_cli/docker/__init__.py +127 -0
  27. scc_cli/docker/core.py +466 -0
  28. scc_cli/docker/credentials.py +726 -0
  29. scc_cli/docker/launch.py +603 -0
  30. scc_cli/doctor/__init__.py +99 -0
  31. scc_cli/doctor/checks.py +1082 -0
  32. scc_cli/doctor/render.py +346 -0
  33. scc_cli/doctor/types.py +66 -0
  34. scc_cli/errors.py +288 -0
  35. scc_cli/evaluation/__init__.py +27 -0
  36. scc_cli/evaluation/apply_exceptions.py +207 -0
  37. scc_cli/evaluation/evaluate.py +97 -0
  38. scc_cli/evaluation/models.py +80 -0
  39. scc_cli/exit_codes.py +55 -0
  40. scc_cli/git.py +1405 -0
  41. scc_cli/json_command.py +166 -0
  42. scc_cli/json_output.py +96 -0
  43. scc_cli/kinds.py +62 -0
  44. scc_cli/marketplace/__init__.py +123 -0
  45. scc_cli/marketplace/compute.py +377 -0
  46. scc_cli/marketplace/constants.py +87 -0
  47. scc_cli/marketplace/managed.py +135 -0
  48. scc_cli/marketplace/materialize.py +723 -0
  49. scc_cli/marketplace/normalize.py +548 -0
  50. scc_cli/marketplace/render.py +238 -0
  51. scc_cli/marketplace/resolve.py +459 -0
  52. scc_cli/marketplace/schema.py +502 -0
  53. scc_cli/marketplace/sync.py +257 -0
  54. scc_cli/marketplace/team_cache.py +195 -0
  55. scc_cli/marketplace/team_fetch.py +688 -0
  56. scc_cli/marketplace/trust.py +244 -0
  57. scc_cli/models/__init__.py +41 -0
  58. scc_cli/models/exceptions.py +273 -0
  59. scc_cli/models/plugin_audit.py +434 -0
  60. scc_cli/org_templates.py +269 -0
  61. scc_cli/output_mode.py +167 -0
  62. scc_cli/panels.py +113 -0
  63. scc_cli/platform.py +350 -0
  64. scc_cli/profiles.py +1034 -0
  65. scc_cli/remote.py +443 -0
  66. scc_cli/schemas/__init__.py +1 -0
  67. scc_cli/schemas/org-v1.schema.json +456 -0
  68. scc_cli/schemas/team-config.v1.schema.json +163 -0
  69. scc_cli/sessions.py +425 -0
  70. scc_cli/setup.py +582 -0
  71. scc_cli/source_resolver.py +470 -0
  72. scc_cli/stats.py +378 -0
  73. scc_cli/stores/__init__.py +13 -0
  74. scc_cli/stores/exception_store.py +251 -0
  75. scc_cli/subprocess_utils.py +88 -0
  76. scc_cli/teams.py +339 -0
  77. scc_cli/templates/__init__.py +2 -0
  78. scc_cli/templates/org/__init__.py +0 -0
  79. scc_cli/templates/org/minimal.json +19 -0
  80. scc_cli/templates/org/reference.json +74 -0
  81. scc_cli/templates/org/strict.json +38 -0
  82. scc_cli/templates/org/teams.json +42 -0
  83. scc_cli/templates/statusline.sh +75 -0
  84. scc_cli/theme.py +348 -0
  85. scc_cli/ui/__init__.py +124 -0
  86. scc_cli/ui/branding.py +68 -0
  87. scc_cli/ui/chrome.py +395 -0
  88. scc_cli/ui/dashboard/__init__.py +62 -0
  89. scc_cli/ui/dashboard/_dashboard.py +669 -0
  90. scc_cli/ui/dashboard/loaders.py +369 -0
  91. scc_cli/ui/dashboard/models.py +184 -0
  92. scc_cli/ui/dashboard/orchestrator.py +337 -0
  93. scc_cli/ui/formatters.py +443 -0
  94. scc_cli/ui/gate.py +350 -0
  95. scc_cli/ui/help.py +157 -0
  96. scc_cli/ui/keys.py +521 -0
  97. scc_cli/ui/list_screen.py +431 -0
  98. scc_cli/ui/picker.py +700 -0
  99. scc_cli/ui/prompts.py +200 -0
  100. scc_cli/ui/wizard.py +490 -0
  101. scc_cli/update.py +680 -0
  102. scc_cli/utils/__init__.py +39 -0
  103. scc_cli/utils/fixit.py +264 -0
  104. scc_cli/utils/fuzzy.py +124 -0
  105. scc_cli/utils/locks.py +101 -0
  106. scc_cli/utils/ttl.py +376 -0
  107. scc_cli/validate.py +455 -0
  108. scc_cli-1.4.0.dist-info/METADATA +369 -0
  109. scc_cli-1.4.0.dist-info/RECORD +112 -0
  110. scc_cli-1.4.0.dist-info/WHEEL +4 -0
  111. scc_cli-1.4.0.dist-info/entry_points.txt +2 -0
  112. scc_cli-1.4.0.dist-info/licenses/LICENSE +21 -0
scc_cli/git.py ADDED
@@ -0,0 +1,1405 @@
1
+ """
2
+ Git operations including worktree management and safety checks.
3
+
4
+ UI Philosophy:
5
+ - Consistent visual language with semantic colors
6
+ - Responsive layouts (80-120+ columns)
7
+ - Clear hierarchy: errors > warnings > info > success
8
+ - Interactive flows with visual "speed bumps" for dangerous ops
9
+ """
10
+
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from rich import box
19
+ from rich.console import Console
20
+ from rich.prompt import Confirm, Prompt
21
+ from rich.table import Table
22
+ from rich.text import Text
23
+ from rich.tree import Tree
24
+
25
+ from .constants import WORKTREE_BRANCH_PREFIX
26
+ from .errors import (
27
+ CloneError,
28
+ GitNotFoundError,
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 .subprocess_utils import run_command, run_command_bool, run_command_lines
40
+ from .theme import Indicators, Spinners
41
+ from .utils.locks import file_lock, lock_path
42
+
43
+ # ═══════════════════════════════════════════════════════════════════════════════
44
+ # Constants
45
+ # ═══════════════════════════════════════════════════════════════════════════════
46
+
47
+ PROTECTED_BRANCHES = ("main", "master", "develop", "production", "staging")
48
+ BRANCH_PREFIX = WORKTREE_BRANCH_PREFIX # Imported from constants.py
49
+ SCC_HOOK_MARKER = "# SCC-MANAGED-HOOK" # Identifies hooks we can safely update
50
+
51
+
52
+ # ═══════════════════════════════════════════════════════════════════════════════
53
+ # Data Classes
54
+ # ═══════════════════════════════════════════════════════════════════════════════
55
+
56
+
57
+ @dataclass
58
+ class WorktreeInfo:
59
+ """Information about a git worktree."""
60
+
61
+ path: str
62
+ branch: str
63
+ status: str = ""
64
+ is_current: bool = False
65
+ has_changes: bool = False
66
+
67
+
68
+ # ═══════════════════════════════════════════════════════════════════════════════
69
+ # Git Detection & Basic Operations
70
+ # ═══════════════════════════════════════════════════════════════════════════════
71
+
72
+
73
+ def check_git_available() -> None:
74
+ """Check if Git is installed and available.
75
+
76
+ Raises:
77
+ GitNotFoundError: Git is not installed or not in PATH
78
+ """
79
+ if shutil.which("git") is None:
80
+ raise GitNotFoundError()
81
+
82
+
83
+ def check_git_installed() -> bool:
84
+ """Check if Git is installed (boolean for doctor command)."""
85
+ return shutil.which("git") is not None
86
+
87
+
88
+ def get_git_version() -> str | None:
89
+ """Get Git version string for display."""
90
+ # Returns something like "git version 2.40.0"
91
+ return run_command(["git", "--version"], timeout=5)
92
+
93
+
94
+ def is_git_repo(path: Path) -> bool:
95
+ """Check if path is inside a git repository."""
96
+ return run_command_bool(["git", "-C", str(path), "rev-parse", "--git-dir"], timeout=5)
97
+
98
+
99
+ def detect_workspace_root(start_dir: Path) -> tuple[Path | None, Path]:
100
+ """Detect the workspace root from a starting directory.
101
+
102
+ This function implements smart workspace detection for use cases where
103
+ the user runs `scc start` from a subdirectory or git worktree.
104
+
105
+ Resolution order:
106
+ 1) git rev-parse --show-toplevel (works for subdirs + worktrees)
107
+ 2) Parent-walk for .scc.yaml (repo root config marker)
108
+ 3) Parent-walk for .git (directory OR file - worktree-safe)
109
+ 4) None (no workspace detected)
110
+
111
+ Args:
112
+ start_dir: The directory to start detection from (usually cwd).
113
+
114
+ Returns:
115
+ Tuple of (root, start_cwd) where:
116
+ - root: The detected workspace root, or None if not found
117
+ - start_cwd: The original start_dir (preserved for container cwd)
118
+ """
119
+ start_dir = start_dir.resolve()
120
+
121
+ # Priority 1: Use git rev-parse --show-toplevel (handles subdirs + worktrees)
122
+ if check_git_installed():
123
+ toplevel = run_command(
124
+ ["git", "-C", str(start_dir), "rev-parse", "--show-toplevel"],
125
+ timeout=5,
126
+ )
127
+ if toplevel:
128
+ return (Path(toplevel.strip()), start_dir)
129
+
130
+ # Priority 2: Parent-walk for .scc.yaml (SCC project marker)
131
+ current = start_dir
132
+ while current != current.parent:
133
+ scc_config = current / ".scc.yaml"
134
+ if scc_config.is_file():
135
+ return (current, start_dir)
136
+ current = current.parent
137
+
138
+ # Priority 3: Parent-walk for .git (directory OR file - worktree-safe)
139
+ current = start_dir
140
+ while current != current.parent:
141
+ git_marker = current / ".git"
142
+ if git_marker.exists(): # Works for both directory and file
143
+ return (current, start_dir)
144
+ current = current.parent
145
+
146
+ # No workspace detected
147
+ return (None, start_dir)
148
+
149
+
150
+ def is_protected_branch(branch: str) -> bool:
151
+ """Check if branch is protected.
152
+
153
+ Protected branches are: main, master, develop, production, staging.
154
+ """
155
+ return branch in PROTECTED_BRANCHES
156
+
157
+
158
+ def is_scc_hook(hook_path: Path) -> bool:
159
+ """Check if hook file is managed by SCC (has SCC marker).
160
+
161
+ Returns:
162
+ True if hook exists and contains SCC_HOOK_MARKER, False otherwise.
163
+ """
164
+ if not hook_path.exists():
165
+ return False
166
+ try:
167
+ content = hook_path.read_text()
168
+ return SCC_HOOK_MARKER in content
169
+ except (OSError, PermissionError):
170
+ return False
171
+
172
+
173
+ def install_pre_push_hook(repo_path: Path) -> tuple[bool, str]:
174
+ """Install repo-local pre-push hook with strict rules.
175
+
176
+ Installation conditions:
177
+ 1. User said yes in `scc setup` (hooks.enabled=true in config)
178
+ 2. Repo is recognized (has .git directory)
179
+
180
+ Never:
181
+ - Modify global git config
182
+ - Overwrite existing non-SCC hooks
183
+
184
+ Args:
185
+ repo_path: Path to the git repository root
186
+
187
+ Returns:
188
+ Tuple of (success, message) describing the outcome
189
+ """
190
+ from .config import load_user_config
191
+
192
+ # Condition 1: Check if hooks are enabled in user config
193
+ config = load_user_config()
194
+ if not config.get("hooks", {}).get("enabled", False):
195
+ return (False, "Hooks not enabled in config")
196
+
197
+ # Condition 2: Check if repo is recognized (has .git directory)
198
+ git_dir = repo_path / ".git"
199
+ if not git_dir.exists():
200
+ return (False, "Not a git repository")
201
+
202
+ # Determine hooks directory (repo-local, NOT global)
203
+ hooks_dir = git_dir / "hooks"
204
+ hooks_dir.mkdir(parents=True, exist_ok=True)
205
+ hook_path = hooks_dir / "pre-push"
206
+
207
+ # Check for existing hook
208
+ if hook_path.exists():
209
+ if is_scc_hook(hook_path):
210
+ # Safe to update our own hook
211
+ _write_scc_hook(hook_path)
212
+ return (True, "Updated existing SCC hook")
213
+ else:
214
+ # DON'T overwrite user's hook
215
+ return (
216
+ False,
217
+ f"Will not overwrite existing user hook at {hook_path}. "
218
+ f"To manually add SCC protection, add '{SCC_HOOK_MARKER}' marker to your hook.",
219
+ )
220
+
221
+ # No existing hook - safe to create
222
+ _write_scc_hook(hook_path)
223
+ return (True, "Installed new SCC hook")
224
+
225
+
226
+ def _write_scc_hook(hook_path: Path) -> None:
227
+ """Write SCC pre-push hook content.
228
+
229
+ The hook blocks pushes to protected branches (main, master, develop, production, staging).
230
+ """
231
+ hook_content = f"""#!/bin/bash
232
+ {SCC_HOOK_MARKER}
233
+ # SCC pre-push hook - blocks pushes to protected branches
234
+ # This hook is managed by SCC. You can safely delete it to remove protection.
235
+
236
+ branch=$(git rev-parse --abbrev-ref HEAD)
237
+ protected_branches="main master develop production staging"
238
+
239
+ for protected in $protected_branches; do
240
+ if [ "$branch" = "$protected" ]; then
241
+ echo ""
242
+ echo "❌ Direct push to '$branch' blocked by SCC"
243
+ echo ""
244
+ echo "Create a feature branch first:"
245
+ echo " git checkout -b feature/your-feature"
246
+ echo " git push -u origin feature/your-feature"
247
+ echo ""
248
+ exit 1
249
+ fi
250
+ done
251
+
252
+ exit 0
253
+ """
254
+ hook_path.write_text(hook_content)
255
+ hook_path.chmod(0o755)
256
+
257
+
258
+ def is_worktree(path: Path) -> bool:
259
+ """Check if the path is a git worktree (not the main repository).
260
+
261
+ Worktrees have a `.git` file (not directory) containing a gitdir pointer.
262
+ """
263
+ git_path = path / ".git"
264
+ return git_path.is_file() # Worktrees have .git as file, main repo has .git as dir
265
+
266
+
267
+ def get_worktree_main_repo(worktree_path: Path) -> Path | None:
268
+ """Get the main repository path for a worktree.
269
+
270
+ Parse the `.git` file to find the gitdir pointer and resolve
271
+ back to the main repo location.
272
+
273
+ Returns:
274
+ Main repository path, or None if not a worktree or cannot determine.
275
+ """
276
+ git_file = worktree_path / ".git"
277
+
278
+ if not git_file.is_file():
279
+ return None
280
+
281
+ try:
282
+ content = git_file.read_text().strip()
283
+ # Format: "gitdir: /path/to/main-repo/.git/worktrees/<name>"
284
+ if content.startswith("gitdir:"):
285
+ gitdir = content[7:].strip()
286
+ gitdir_path = Path(gitdir)
287
+
288
+ # Navigate from .git/worktrees/<name> up to repo root
289
+ # gitdir_path = /repo/.git/worktrees/feature
290
+ # We need /repo
291
+ if "worktrees" in gitdir_path.parts:
292
+ # Find the .git directory (parent of worktrees)
293
+ git_dir = gitdir_path
294
+ while git_dir.name != ".git" and git_dir != git_dir.parent:
295
+ git_dir = git_dir.parent
296
+ if git_dir.name == ".git":
297
+ return git_dir.parent
298
+ except (OSError, ValueError):
299
+ pass
300
+
301
+ return None
302
+
303
+
304
+ def get_workspace_mount_path(workspace: Path) -> tuple[Path, bool]:
305
+ """Determine the optimal path to mount for Docker sandbox.
306
+
307
+ For worktrees, return the common parent containing both repo and worktrees folder.
308
+ For regular repos, return the workspace path as-is.
309
+
310
+ This ensures git worktrees have access to the main repo's .git folder.
311
+ The gitdir pointer in worktrees uses absolute paths, so Docker must mount
312
+ the common parent to make those paths resolve correctly inside the container.
313
+
314
+ Returns:
315
+ Tuple of (mount_path, is_expanded) where is_expanded=True if we expanded
316
+ the mount scope beyond the original workspace (for user awareness).
317
+
318
+ Note:
319
+ Docker sandbox uses "mirrored mounting" - the path inside the container
320
+ matches the host path, so absolute gitdir pointers will resolve correctly.
321
+ """
322
+ if not is_worktree(workspace):
323
+ return workspace, False
324
+
325
+ main_repo = get_worktree_main_repo(workspace)
326
+ if main_repo is None:
327
+ return workspace, False
328
+
329
+ # Find common parent of worktree and main repo
330
+ # Worktree: /parent/repo-worktrees/feature
331
+ # Main repo: /parent/repo
332
+ # Common parent: /parent
333
+
334
+ workspace_resolved = workspace.resolve()
335
+ main_repo_resolved = main_repo.resolve()
336
+
337
+ worktree_parts = workspace_resolved.parts
338
+ repo_parts = main_repo_resolved.parts
339
+
340
+ # Find common ancestor path
341
+ common_parts = []
342
+ for w_part, r_part in zip(worktree_parts, repo_parts):
343
+ if w_part == r_part:
344
+ common_parts.append(w_part)
345
+ else:
346
+ break
347
+
348
+ if not common_parts:
349
+ # No common ancestor - shouldn't happen, but fall back safely
350
+ return workspace, False
351
+
352
+ common_parent = Path(*common_parts)
353
+
354
+ # Safety checks: don't mount system directories
355
+ # Use resolved paths for proper symlink handling (cross-platform)
356
+ try:
357
+ resolved_parent = common_parent.resolve()
358
+ except OSError:
359
+ # Can't resolve path - fall back to safe option
360
+ return workspace, False
361
+
362
+ # System directories that should NEVER be mounted as common parent
363
+ # Cross-platform: covers Linux, macOS, and WSL2
364
+ blocked_roots = {
365
+ # Root filesystem
366
+ Path("/"),
367
+ # User home parents (mounting all of /home or /Users is too broad)
368
+ Path("/home"),
369
+ Path("/Users"),
370
+ # System directories (Linux + macOS)
371
+ Path("/bin"),
372
+ Path("/boot"),
373
+ Path("/dev"),
374
+ Path("/etc"),
375
+ Path("/lib"),
376
+ Path("/lib64"),
377
+ Path("/opt"),
378
+ Path("/proc"),
379
+ Path("/root"),
380
+ Path("/run"),
381
+ Path("/sbin"),
382
+ Path("/srv"),
383
+ Path("/sys"),
384
+ Path("/usr"),
385
+ # Temp directories (sensitive, often contain secrets)
386
+ Path("/tmp"),
387
+ Path("/var"),
388
+ # macOS specific
389
+ Path("/System"),
390
+ Path("/Library"),
391
+ Path("/Applications"),
392
+ Path("/Volumes"),
393
+ Path("/private"),
394
+ # WSL2 specific
395
+ Path("/mnt"),
396
+ }
397
+
398
+ # Check if resolved path IS or IS UNDER a blocked root
399
+ for blocked in blocked_roots:
400
+ if resolved_parent == blocked:
401
+ return workspace, False
402
+
403
+ # Skip root "/" for is_relative_to check - all paths are under root!
404
+ # We already checked exact match above.
405
+ if blocked == Path("/"):
406
+ continue
407
+
408
+ # Use is_relative_to for "is under" check (Python 3.9+)
409
+ try:
410
+ if resolved_parent.is_relative_to(blocked):
411
+ # Exception: allow paths under /home/<user>/... or /Users/<user>/...
412
+ # (i.e., actual user workspaces, not the parent directories themselves)
413
+ if blocked in (Path("/home"), Path("/Users")):
414
+ # /home/user/projects is OK (depth 4+)
415
+ # /home/user is too broad (depth 3)
416
+ if len(resolved_parent.parts) >= 4:
417
+ continue # Allow: /home/user/projects or deeper
418
+
419
+ # WSL2 exception: /mnt/<drive>/... where <drive> is single letter
420
+ # This specifically targets Windows filesystem mounts, NOT arbitrary
421
+ # Linux mount points like /mnt/nfs, /mnt/usb, /mnt/wsl, etc.
422
+ if blocked == Path("/mnt"):
423
+ parts = resolved_parent.parts
424
+ # Validate: /mnt/<single-letter>/<something>/<something>
425
+ # parts[0]="/", parts[1]="mnt", parts[2]=drive, parts[3+]=path
426
+ if len(parts) >= 5: # Conservative: require depth 5+
427
+ drive = parts[2] if len(parts) > 2 else ""
428
+ # WSL2 drives are single letters (c, d, e, etc.)
429
+ if len(drive) == 1 and drive.isalpha():
430
+ continue # Allow: /mnt/c/Users/dev/projects
431
+
432
+ return workspace, False
433
+ except (ValueError, AttributeError):
434
+ # is_relative_to raises ValueError if not relative
435
+ # AttributeError on Python < 3.9 (fallback below)
436
+ pass
437
+
438
+ # Fallback depth check for edge cases not caught above
439
+ # Require at least 3 path components: /, parent, child
440
+ # This catches unusual paths not in the blocklist
441
+ if len(resolved_parent.parts) < 3:
442
+ return workspace, False
443
+
444
+ return common_parent, True
445
+
446
+
447
+ def get_current_branch(path: Path) -> str | None:
448
+ """Get the current branch name."""
449
+ return run_command(["git", "-C", str(path), "branch", "--show-current"], timeout=5)
450
+
451
+
452
+ def get_default_branch(path: Path) -> str:
453
+ """Get the default branch (main or master)."""
454
+ # Try to get from remote HEAD
455
+ output = run_command(
456
+ ["git", "-C", str(path), "symbolic-ref", "refs/remotes/origin/HEAD"],
457
+ timeout=5,
458
+ )
459
+ if output:
460
+ return output.split("/")[-1]
461
+
462
+ # Fallback: check if main or master exists
463
+ for branch in ["main", "master"]:
464
+ if run_command_bool(
465
+ ["git", "-C", str(path), "rev-parse", "--verify", branch],
466
+ timeout=5,
467
+ ):
468
+ return branch
469
+
470
+ return "main"
471
+
472
+
473
+ def sanitize_branch_name(name: str) -> str:
474
+ """Sanitize a name for use as a branch name."""
475
+ # Convert to lowercase, replace spaces with hyphens
476
+ safe = name.lower().replace(" ", "-")
477
+ # Remove invalid characters
478
+ safe = re.sub(r"[^a-z0-9-]", "", safe)
479
+ # Remove multiple hyphens
480
+ safe = re.sub(r"-+", "-", safe)
481
+ # Remove leading/trailing hyphens
482
+ safe = safe.strip("-")
483
+ return safe
484
+
485
+
486
+ def get_uncommitted_files(path: Path) -> list[str]:
487
+ """Get list of uncommitted files in a repository."""
488
+ lines = run_command_lines(
489
+ ["git", "-C", str(path), "status", "--porcelain"],
490
+ timeout=5,
491
+ )
492
+ # Each line is "XY filename" where XY is 2-char status code
493
+ return [line[3:] for line in lines if len(line) > 3]
494
+
495
+
496
+ # ═══════════════════════════════════════════════════════════════════════════════
497
+ # Branch Safety - Interactive UI
498
+ # ═══════════════════════════════════════════════════════════════════════════════
499
+
500
+
501
+ def check_branch_safety(path: Path, console: Console) -> bool:
502
+ """Check if current branch is safe for Claude Code work.
503
+
504
+ Display a visual "speed bump" for protected branches with
505
+ interactive options to create a feature branch or continue.
506
+
507
+ Args:
508
+ path: Path to the git repository.
509
+ console: Rich console for output.
510
+
511
+ Returns:
512
+ True if safe to proceed, False if user cancelled.
513
+ """
514
+ if not is_git_repo(path):
515
+ return True
516
+
517
+ current = get_current_branch(path)
518
+
519
+ if current in PROTECTED_BRANCHES:
520
+ console.print()
521
+
522
+ # Visual speed bump - warning panel
523
+ warning = create_warning_panel(
524
+ "Protected Branch",
525
+ f"You are on branch '{current}'\n\n"
526
+ "For safety, Claude Code work should happen on a feature branch.\n"
527
+ "Direct pushes to protected branches are blocked by git hooks.",
528
+ "Create a feature branch for isolated, safe development",
529
+ )
530
+ console.print(warning)
531
+ console.print()
532
+
533
+ # Interactive options table
534
+ options_table = Table(
535
+ box=box.SIMPLE,
536
+ show_header=False,
537
+ padding=(0, 2),
538
+ expand=False,
539
+ )
540
+ options_table.add_column("Option", style="yellow", width=10)
541
+ options_table.add_column("Action", style="white")
542
+ options_table.add_column("Description", style="dim")
543
+
544
+ options_table.add_row("[1]", "Create branch", "New feature branch (recommended)")
545
+ options_table.add_row("[2]", "Continue", "Stay on protected branch (pushes blocked)")
546
+ options_table.add_row("[3]", "Cancel", "Exit without starting")
547
+
548
+ console.print(options_table)
549
+ console.print()
550
+
551
+ choice = Prompt.ask(
552
+ "[cyan]Select option[/cyan]",
553
+ choices=["1", "2", "3", "create", "continue", "cancel"],
554
+ default="1",
555
+ )
556
+
557
+ if choice in ["1", "create"]:
558
+ console.print()
559
+ name = Prompt.ask("[cyan]Feature name[/cyan]")
560
+ safe_name = sanitize_branch_name(name)
561
+ branch_name = f"{BRANCH_PREFIX}{safe_name}"
562
+
563
+ with console.status(
564
+ f"[cyan]Creating branch {branch_name}...[/cyan]", spinner=Spinners.SETUP
565
+ ):
566
+ try:
567
+ subprocess.run(
568
+ ["git", "-C", str(path), "checkout", "-b", branch_name],
569
+ check=True,
570
+ capture_output=True,
571
+ timeout=10,
572
+ )
573
+ except subprocess.CalledProcessError:
574
+ console.print()
575
+ console.print(
576
+ create_error_panel(
577
+ "Branch Creation Failed",
578
+ f"Could not create branch '{branch_name}'",
579
+ "Check if the branch already exists or if there are uncommitted changes",
580
+ )
581
+ )
582
+ return False
583
+
584
+ console.print()
585
+ console.print(
586
+ create_success_panel(
587
+ "Branch Created",
588
+ {
589
+ "Branch": branch_name,
590
+ "Base": current,
591
+ },
592
+ )
593
+ )
594
+ return True
595
+
596
+ elif choice in ["2", "continue"]:
597
+ console.print()
598
+ console.print(
599
+ "[dim]→ Continuing on protected branch. "
600
+ "Push attempts will be blocked by git hooks.[/dim]"
601
+ )
602
+ return True
603
+
604
+ else:
605
+ return False
606
+
607
+ return True
608
+
609
+
610
+ # ═══════════════════════════════════════════════════════════════════════════════
611
+ # Worktree Operations - Beautiful UI
612
+ # ═══════════════════════════════════════════════════════════════════════════════
613
+
614
+
615
+ def create_worktree(
616
+ repo_path: Path,
617
+ name: str,
618
+ base_branch: str | None = None,
619
+ console: Console | None = None,
620
+ ) -> Path:
621
+ """Create a new git worktree with visual progress feedback.
622
+
623
+ Args:
624
+ repo_path: Path to the main repository.
625
+ name: Feature name for the worktree.
626
+ base_branch: Branch to base the worktree on (default: main/master).
627
+ console: Rich console for output.
628
+
629
+ Returns:
630
+ Path to the created worktree.
631
+
632
+ Raises:
633
+ NotAGitRepoError: Path is not a git repository.
634
+ WorktreeExistsError: Worktree already exists.
635
+ WorktreeCreationError: Failed to create worktree.
636
+ """
637
+ if console is None:
638
+ console = Console()
639
+
640
+ # Validate repository
641
+ if not is_git_repo(repo_path):
642
+ raise NotAGitRepoError(path=str(repo_path))
643
+
644
+ safe_name = sanitize_branch_name(name)
645
+ branch_name = f"{BRANCH_PREFIX}{safe_name}"
646
+
647
+ # Determine worktree location
648
+ worktree_base = repo_path.parent / f"{repo_path.name}-worktrees"
649
+ worktree_path = worktree_base / safe_name
650
+
651
+ lock_file = lock_path("worktree", repo_path)
652
+ with file_lock(lock_file):
653
+ # Check if already exists
654
+ if worktree_path.exists():
655
+ raise WorktreeExistsError(path=str(worktree_path))
656
+
657
+ # Determine base branch
658
+ if not base_branch:
659
+ base_branch = get_default_branch(repo_path)
660
+
661
+ console.print()
662
+ console.print(
663
+ create_info_panel(
664
+ "Creating Worktree", f"Feature: {safe_name}", f"Location: {worktree_path}"
665
+ )
666
+ )
667
+ console.print()
668
+
669
+ worktree_created = False
670
+
671
+ def _install_deps() -> None:
672
+ success = install_dependencies(worktree_path, console)
673
+ if not success:
674
+ raise WorktreeCreationError(
675
+ name=safe_name,
676
+ user_message="Dependency install failed for the new worktree",
677
+ suggested_action="Install dependencies manually and retry if needed",
678
+ )
679
+
680
+ # Multi-step progress
681
+ steps: list[tuple[str, Callable[[], None]]] = [
682
+ ("Fetching latest changes", lambda: _fetch_branch(repo_path, base_branch)),
683
+ (
684
+ "Creating worktree",
685
+ lambda: _create_worktree_dir(
686
+ repo_path, worktree_path, branch_name, base_branch, worktree_base
687
+ ),
688
+ ),
689
+ ("Installing dependencies", _install_deps),
690
+ ]
691
+
692
+ try:
693
+ for step_name, step_func in steps:
694
+ with console.status(f"[cyan]{step_name}...[/cyan]", spinner=Spinners.SETUP):
695
+ try:
696
+ step_func()
697
+ except subprocess.CalledProcessError as e:
698
+ raise WorktreeCreationError(
699
+ name=safe_name,
700
+ command=" ".join(e.cmd) if hasattr(e, "cmd") else None,
701
+ stderr=e.stderr.decode() if e.stderr else None,
702
+ )
703
+ console.print(f" [green]{Indicators.get('PASS')}[/green] {step_name}")
704
+ if step_name == "Creating worktree":
705
+ worktree_created = True
706
+ except KeyboardInterrupt:
707
+ if worktree_created or worktree_path.exists():
708
+ _cleanup_partial_worktree(repo_path, worktree_path)
709
+ raise
710
+ except WorktreeCreationError:
711
+ if worktree_created or worktree_path.exists():
712
+ _cleanup_partial_worktree(repo_path, worktree_path)
713
+ raise
714
+
715
+ console.print()
716
+ console.print(
717
+ create_success_panel(
718
+ "Worktree Ready",
719
+ {
720
+ "Path": str(worktree_path),
721
+ "Branch": branch_name,
722
+ "Base": base_branch,
723
+ "Next": f"cd {worktree_path}",
724
+ },
725
+ )
726
+ )
727
+
728
+ return worktree_path
729
+
730
+
731
+ def _fetch_branch(repo_path: Path, branch: str) -> None:
732
+ """Fetch a branch from origin.
733
+
734
+ Raises:
735
+ WorktreeCreationError: If fetch fails (network error, branch not found, etc.)
736
+ """
737
+ result = subprocess.run(
738
+ ["git", "-C", str(repo_path), "fetch", "origin", branch],
739
+ capture_output=True,
740
+ text=True,
741
+ timeout=30,
742
+ )
743
+ if result.returncode != 0:
744
+ error_msg = result.stderr.strip() if result.stderr else "Unknown fetch error"
745
+ lower = error_msg.lower()
746
+ user_message = f"Failed to fetch branch '{branch}'"
747
+ suggested_action = "Check the branch name and your network connection"
748
+
749
+ if "couldn't find remote ref" in lower or "remote ref" in lower and "not found" in lower:
750
+ user_message = f"Branch '{branch}' not found on origin"
751
+ suggested_action = "Check the branch name or fetch remote branches"
752
+ elif "could not resolve host" in lower or "failed to connect" in lower:
753
+ user_message = "Network error while fetching from origin"
754
+ suggested_action = "Check your network or VPN connection"
755
+ elif "permission denied" in lower or "authentication" in lower:
756
+ user_message = "Authentication error while fetching from origin"
757
+ suggested_action = "Check your git credentials and remote access"
758
+
759
+ raise WorktreeCreationError(
760
+ name=branch,
761
+ user_message=user_message,
762
+ suggested_action=suggested_action,
763
+ command=f"git -C {repo_path} fetch origin {branch}",
764
+ stderr=error_msg,
765
+ )
766
+
767
+
768
+ def _cleanup_partial_worktree(repo_path: Path, worktree_path: Path) -> None:
769
+ """Best-effort cleanup for partially created worktrees."""
770
+ try:
771
+ subprocess.run(
772
+ [
773
+ "git",
774
+ "-C",
775
+ str(repo_path),
776
+ "worktree",
777
+ "remove",
778
+ "--force",
779
+ str(worktree_path),
780
+ ],
781
+ capture_output=True,
782
+ timeout=30,
783
+ )
784
+ except (subprocess.SubprocessError, FileNotFoundError):
785
+ pass
786
+
787
+ shutil.rmtree(worktree_path, ignore_errors=True)
788
+
789
+ try:
790
+ subprocess.run(
791
+ ["git", "-C", str(repo_path), "worktree", "prune"],
792
+ capture_output=True,
793
+ timeout=30,
794
+ )
795
+ except (subprocess.SubprocessError, FileNotFoundError):
796
+ pass
797
+
798
+
799
+ def _create_worktree_dir(
800
+ repo_path: Path,
801
+ worktree_path: Path,
802
+ branch_name: str,
803
+ base_branch: str,
804
+ worktree_base: Path,
805
+ ) -> None:
806
+ """Create the worktree directory."""
807
+ worktree_base.mkdir(parents=True, exist_ok=True)
808
+
809
+ try:
810
+ subprocess.run(
811
+ [
812
+ "git",
813
+ "-C",
814
+ str(repo_path),
815
+ "worktree",
816
+ "add",
817
+ "-b",
818
+ branch_name,
819
+ str(worktree_path),
820
+ f"origin/{base_branch}",
821
+ ],
822
+ check=True,
823
+ capture_output=True,
824
+ timeout=30,
825
+ )
826
+ except subprocess.CalledProcessError:
827
+ # Try without origin/ prefix
828
+ subprocess.run(
829
+ [
830
+ "git",
831
+ "-C",
832
+ str(repo_path),
833
+ "worktree",
834
+ "add",
835
+ "-b",
836
+ branch_name,
837
+ str(worktree_path),
838
+ base_branch,
839
+ ],
840
+ check=True,
841
+ capture_output=True,
842
+ timeout=30,
843
+ )
844
+
845
+
846
+ def list_worktrees(repo_path: Path, console: Console | None = None) -> list[WorktreeInfo]:
847
+ """List all worktrees for a repository with beautiful table display.
848
+
849
+ Args:
850
+ repo_path: Path to the repository.
851
+ console: Rich console for output (if None, return data only).
852
+
853
+ Returns:
854
+ List of WorktreeInfo objects.
855
+ """
856
+ worktrees = _get_worktrees_data(repo_path)
857
+
858
+ if console is not None:
859
+ _render_worktrees_table(worktrees, console)
860
+
861
+ return worktrees
862
+
863
+
864
+ def render_worktrees(worktrees: list[WorktreeInfo], console: Console) -> None:
865
+ """Render worktrees with beautiful formatting.
866
+
867
+ Public interface used by cli.py for consistent styling across the application.
868
+ """
869
+ _render_worktrees_table(worktrees, console)
870
+
871
+
872
+ def _get_worktrees_data(repo_path: Path) -> list[WorktreeInfo]:
873
+ """Get raw worktree data from git."""
874
+ try:
875
+ result = subprocess.run(
876
+ ["git", "-C", str(repo_path), "worktree", "list", "--porcelain"],
877
+ capture_output=True,
878
+ text=True,
879
+ timeout=10,
880
+ )
881
+
882
+ if result.returncode != 0:
883
+ return []
884
+
885
+ worktrees = []
886
+ current: dict[str, str] = {}
887
+
888
+ for line in result.stdout.split("\n"):
889
+ if line.startswith("worktree "):
890
+ if current:
891
+ worktrees.append(
892
+ WorktreeInfo(
893
+ path=current.get("path", ""),
894
+ branch=current.get("branch", ""),
895
+ status=current.get("status", ""),
896
+ )
897
+ )
898
+ current = {"path": line[9:], "branch": "", "status": ""}
899
+ elif line.startswith("branch "):
900
+ current["branch"] = line[7:].replace("refs/heads/", "")
901
+ elif line == "bare":
902
+ current["status"] = "bare"
903
+ elif line == "detached":
904
+ current["status"] = "detached"
905
+
906
+ if current:
907
+ worktrees.append(
908
+ WorktreeInfo(
909
+ path=current.get("path", ""),
910
+ branch=current.get("branch", ""),
911
+ status=current.get("status", ""),
912
+ )
913
+ )
914
+
915
+ return worktrees
916
+
917
+ except (subprocess.TimeoutExpired, FileNotFoundError):
918
+ return []
919
+
920
+
921
+ def _render_worktrees_table(worktrees: list[WorktreeInfo], console: Console) -> None:
922
+ """Render worktrees in a responsive table."""
923
+ if not worktrees:
924
+ console.print()
925
+ console.print(
926
+ create_warning_panel(
927
+ "No Worktrees",
928
+ "No git worktrees found for this repository.",
929
+ "Create one with: scc worktree <repo> <feature-name>",
930
+ )
931
+ )
932
+ return
933
+
934
+ console.print()
935
+
936
+ # Responsive: check terminal width
937
+ width = console.width
938
+ wide_mode = width >= 110
939
+
940
+ # Create table with adaptive columns
941
+ table = Table(
942
+ title="[bold cyan]Git Worktrees[/bold cyan]",
943
+ box=box.ROUNDED,
944
+ header_style="bold cyan",
945
+ show_lines=False,
946
+ expand=True,
947
+ padding=(0, 1),
948
+ )
949
+
950
+ table.add_column("#", style="dim", width=3, justify="right")
951
+ table.add_column("Branch", style="cyan", no_wrap=True)
952
+
953
+ if wide_mode:
954
+ table.add_column("Path", style="dim", overflow="ellipsis", ratio=2)
955
+ table.add_column("Status", style="dim", no_wrap=True, width=12)
956
+ else:
957
+ table.add_column("Path", style="dim", overflow="ellipsis", max_width=40)
958
+
959
+ for idx, wt in enumerate(worktrees, 1):
960
+ # Style the branch name
961
+ is_detached = not wt.branch
962
+ is_protected = wt.branch in PROTECTED_BRANCHES if wt.branch else False
963
+ branch_value = wt.branch or "detached"
964
+
965
+ if is_protected or is_detached:
966
+ branch_display = Text(branch_value, style="yellow")
967
+ else:
968
+ branch_display = Text(branch_value, style="cyan")
969
+
970
+ # Determine status
971
+ status = wt.status or ("detached" if is_detached else "active")
972
+ if is_protected:
973
+ status = "protected"
974
+
975
+ status_style = {
976
+ "active": "green",
977
+ "protected": "yellow",
978
+ "detached": "yellow",
979
+ "bare": "dim",
980
+ }.get(status, "dim")
981
+
982
+ if wide_mode:
983
+ table.add_row(
984
+ str(idx),
985
+ branch_display,
986
+ wt.path,
987
+ Text(status, style=status_style),
988
+ )
989
+ else:
990
+ table.add_row(
991
+ str(idx),
992
+ branch_display,
993
+ wt.path,
994
+ )
995
+
996
+ console.print(table)
997
+ console.print()
998
+
999
+
1000
+ def cleanup_worktree(
1001
+ repo_path: Path,
1002
+ name: str,
1003
+ force: bool,
1004
+ console: Console,
1005
+ *,
1006
+ skip_confirm: bool = False,
1007
+ dry_run: bool = False,
1008
+ ) -> bool:
1009
+ """Clean up a worktree with safety checks and visual feedback.
1010
+
1011
+ Show uncommitted changes before deletion to prevent accidental data loss.
1012
+
1013
+ Args:
1014
+ repo_path: Path to the main repository.
1015
+ name: Name of the worktree to remove.
1016
+ force: If True, remove even if worktree has uncommitted changes.
1017
+ console: Rich console for output.
1018
+ skip_confirm: If True, skip interactive confirmations (--yes flag).
1019
+ dry_run: If True, show what would be removed but don't actually remove.
1020
+
1021
+ Returns:
1022
+ True if worktree was removed (or would be in dry-run mode), False otherwise.
1023
+ """
1024
+ safe_name = sanitize_branch_name(name)
1025
+ branch_name = f"{BRANCH_PREFIX}{safe_name}"
1026
+ worktree_base = repo_path.parent / f"{repo_path.name}-worktrees"
1027
+ worktree_path = worktree_base / safe_name
1028
+
1029
+ if not worktree_path.exists():
1030
+ console.print()
1031
+ console.print(
1032
+ create_warning_panel(
1033
+ "Worktree Not Found",
1034
+ f"No worktree found at: {worktree_path}",
1035
+ "Use 'scc worktrees <repo>' to list available worktrees",
1036
+ )
1037
+ )
1038
+ return False
1039
+
1040
+ console.print()
1041
+ if dry_run:
1042
+ console.print(
1043
+ create_info_panel(
1044
+ "Dry Run: Cleanup Worktree",
1045
+ f"Worktree: {safe_name}",
1046
+ f"Path: {worktree_path}",
1047
+ )
1048
+ )
1049
+ else:
1050
+ console.print(
1051
+ create_info_panel(
1052
+ "Cleanup Worktree", f"Worktree: {safe_name}", f"Path: {worktree_path}"
1053
+ )
1054
+ )
1055
+ console.print()
1056
+
1057
+ # Check for uncommitted changes - show evidence
1058
+ if not force:
1059
+ uncommitted = get_uncommitted_files(worktree_path)
1060
+
1061
+ if uncommitted:
1062
+ # Build a tree of files that will be lost
1063
+ tree = Tree(f"[red bold]Uncommitted Changes ({len(uncommitted)})[/red bold]")
1064
+
1065
+ for f in uncommitted[:10]: # Show max 10
1066
+ tree.add(Text(f, style="dim"))
1067
+
1068
+ if len(uncommitted) > 10:
1069
+ tree.add(Text(f"…and {len(uncommitted) - 10} more", style="dim italic"))
1070
+
1071
+ console.print(tree)
1072
+ console.print()
1073
+ console.print("[red bold]These changes will be permanently lost.[/red bold]")
1074
+ console.print()
1075
+
1076
+ # Skip confirmation prompt if --yes was provided
1077
+ if not skip_confirm:
1078
+ if not Confirm.ask("[yellow]Delete worktree anyway?[/yellow]", default=False):
1079
+ console.print("[dim]Cleanup cancelled.[/dim]")
1080
+ return False
1081
+
1082
+ # Dry run: show what would be removed without actually removing
1083
+ if dry_run:
1084
+ console.print(" [cyan]Would remove:[/cyan]")
1085
+ console.print(f" • Worktree: {worktree_path}")
1086
+ console.print(f" • Branch: {branch_name} [dim](if confirmed)[/dim]")
1087
+ console.print()
1088
+ console.print("[dim]Dry run complete. No changes made.[/dim]")
1089
+ return True
1090
+
1091
+ # Remove worktree
1092
+ with console.status("[cyan]Removing worktree...[/cyan]", spinner=Spinners.DEFAULT):
1093
+ try:
1094
+ force_flag = ["--force"] if force else []
1095
+ subprocess.run(
1096
+ ["git", "-C", str(repo_path), "worktree", "remove", str(worktree_path)]
1097
+ + force_flag,
1098
+ check=True,
1099
+ capture_output=True,
1100
+ timeout=30,
1101
+ )
1102
+ except subprocess.CalledProcessError:
1103
+ # Fallback: manual removal
1104
+ shutil.rmtree(worktree_path, ignore_errors=True)
1105
+ subprocess.run(
1106
+ ["git", "-C", str(repo_path), "worktree", "prune"],
1107
+ capture_output=True,
1108
+ timeout=10,
1109
+ )
1110
+
1111
+ console.print(f" [green]{Indicators.get('PASS')}[/green] Worktree removed")
1112
+
1113
+ # Ask about branch deletion (auto-delete if --yes was provided)
1114
+ console.print()
1115
+ branch_deleted = False
1116
+ should_delete_branch = skip_confirm or Confirm.ask(
1117
+ f"[cyan]Also delete branch '{branch_name}'?[/cyan]", default=False
1118
+ )
1119
+ if should_delete_branch:
1120
+ with console.status("[cyan]Deleting branch...[/cyan]", spinner=Spinners.DEFAULT):
1121
+ subprocess.run(
1122
+ ["git", "-C", str(repo_path), "branch", "-D", branch_name],
1123
+ capture_output=True,
1124
+ timeout=10,
1125
+ )
1126
+ console.print(f" [green]{Indicators.get('PASS')}[/green] Branch deleted")
1127
+ branch_deleted = True
1128
+
1129
+ console.print()
1130
+ console.print(
1131
+ create_success_panel(
1132
+ "Cleanup Complete",
1133
+ {
1134
+ "Removed": str(worktree_path),
1135
+ "Branch": "deleted" if branch_deleted else "kept",
1136
+ },
1137
+ )
1138
+ )
1139
+
1140
+ return True
1141
+
1142
+
1143
+ # ═══════════════════════════════════════════════════════════════════════════════
1144
+ # Dependency Installation
1145
+ # ═══════════════════════════════════════════════════════════════════════════════
1146
+
1147
+
1148
+ def _run_install_cmd(
1149
+ cmd: list[str],
1150
+ path: Path,
1151
+ console: Console | None,
1152
+ timeout: int = 300,
1153
+ ) -> bool:
1154
+ """Run an install command and warn on failure. Returns True if successful."""
1155
+ try:
1156
+ result = subprocess.run(cmd, cwd=path, capture_output=True, text=True, timeout=timeout)
1157
+ if result.returncode != 0 and console:
1158
+ error_detail = result.stderr.strip() if result.stderr else ""
1159
+ message = f"'{' '.join(cmd)}' failed with exit code {result.returncode}"
1160
+ if error_detail:
1161
+ message += f": {error_detail[:100]}" # Truncate long errors
1162
+ console.print(
1163
+ create_warning_panel(
1164
+ "Dependency Install Warning",
1165
+ message,
1166
+ "You may need to install dependencies manually",
1167
+ )
1168
+ )
1169
+ return False
1170
+ return True
1171
+ except subprocess.TimeoutExpired:
1172
+ if console:
1173
+ console.print(
1174
+ create_warning_panel(
1175
+ "Dependency Install Timeout",
1176
+ f"'{' '.join(cmd)}' timed out after {timeout}s",
1177
+ "You may need to install dependencies manually",
1178
+ )
1179
+ )
1180
+ return False
1181
+
1182
+
1183
+ def install_dependencies(path: Path, console: Console | None = None) -> bool:
1184
+ """Detect and install project dependencies.
1185
+
1186
+ Support Node.js (npm/yarn/pnpm/bun), Python (pip/poetry/uv), and
1187
+ Java (Maven/Gradle). Warn user if any install fails rather than
1188
+ silently ignoring.
1189
+
1190
+ Args:
1191
+ path: Path to the project directory.
1192
+ console: Rich console for output (optional).
1193
+ """
1194
+ success = True
1195
+
1196
+ # Node.js
1197
+ if (path / "package.json").exists():
1198
+ if (path / "pnpm-lock.yaml").exists():
1199
+ cmd = ["pnpm", "install"]
1200
+ elif (path / "bun.lockb").exists():
1201
+ cmd = ["bun", "install"]
1202
+ elif (path / "yarn.lock").exists():
1203
+ cmd = ["yarn", "install"]
1204
+ else:
1205
+ cmd = ["npm", "install"]
1206
+
1207
+ success = _run_install_cmd(cmd, path, console, timeout=300) and success
1208
+
1209
+ # Python
1210
+ if (path / "pyproject.toml").exists():
1211
+ if shutil.which("poetry"):
1212
+ success = (
1213
+ _run_install_cmd(["poetry", "install"], path, console, timeout=300) and success
1214
+ )
1215
+ elif shutil.which("uv"):
1216
+ success = (
1217
+ _run_install_cmd(["uv", "pip", "install", "-e", "."], path, console, timeout=300)
1218
+ and success
1219
+ )
1220
+ elif (path / "requirements.txt").exists():
1221
+ success = (
1222
+ _run_install_cmd(
1223
+ ["pip", "install", "-r", "requirements.txt"],
1224
+ path,
1225
+ console,
1226
+ timeout=300,
1227
+ )
1228
+ and success
1229
+ )
1230
+
1231
+ # Java/Maven
1232
+ if (path / "pom.xml").exists():
1233
+ success = (
1234
+ _run_install_cmd(["mvn", "dependency:resolve"], path, console, timeout=600) and success
1235
+ )
1236
+
1237
+ # Java/Gradle
1238
+ if (path / "build.gradle").exists() or (path / "build.gradle.kts").exists():
1239
+ gradle_cmd = "./gradlew" if (path / "gradlew").exists() else "gradle"
1240
+ success = (
1241
+ _run_install_cmd([gradle_cmd, "dependencies"], path, console, timeout=600) and success
1242
+ )
1243
+
1244
+ return success
1245
+
1246
+
1247
+ # ═══════════════════════════════════════════════════════════════════════════════
1248
+ # Repository Cloning
1249
+ # ═══════════════════════════════════════════════════════════════════════════════
1250
+
1251
+
1252
+ def clone_repo(url: str, base_path: str, console: Console | None = None) -> str:
1253
+ """Clone a repository with progress feedback.
1254
+
1255
+ Args:
1256
+ url: Repository URL (HTTPS or SSH).
1257
+ base_path: Base directory for cloning.
1258
+ console: Rich console for output.
1259
+
1260
+ Returns:
1261
+ Path to the cloned repository.
1262
+
1263
+ Raises:
1264
+ CloneError: Failed to clone repository.
1265
+ """
1266
+ if console is None:
1267
+ console = Console()
1268
+
1269
+ base = Path(base_path).expanduser()
1270
+ base.mkdir(parents=True, exist_ok=True)
1271
+
1272
+ # Extract repo name from URL
1273
+ name = url.rstrip("/").split("/")[-1]
1274
+ if name.endswith(".git"):
1275
+ name = name[:-4]
1276
+
1277
+ target = base / name
1278
+
1279
+ if target.exists():
1280
+ # Already cloned
1281
+ console.print(f"[dim]Repository already exists at {target}[/dim]")
1282
+ return str(target)
1283
+
1284
+ console.print()
1285
+ console.print(create_info_panel("Cloning Repository", url, f"Target: {target}"))
1286
+ console.print()
1287
+
1288
+ with console.status("[cyan]Cloning...[/cyan]", spinner=Spinners.NETWORK):
1289
+ try:
1290
+ subprocess.run(
1291
+ ["git", "clone", url, str(target)],
1292
+ check=True,
1293
+ capture_output=True,
1294
+ timeout=300,
1295
+ )
1296
+ except subprocess.CalledProcessError as e:
1297
+ raise CloneError(
1298
+ url=url,
1299
+ command=f"git clone {url}",
1300
+ stderr=e.stderr.decode() if e.stderr else None,
1301
+ )
1302
+
1303
+ console.print(f" [green]{Indicators.get('PASS')}[/green] Repository cloned")
1304
+ console.print()
1305
+ console.print(
1306
+ create_success_panel(
1307
+ "Clone Complete",
1308
+ {
1309
+ "Repository": name,
1310
+ "Path": str(target),
1311
+ },
1312
+ )
1313
+ )
1314
+
1315
+ return str(target)
1316
+
1317
+
1318
+ # ═══════════════════════════════════════════════════════════════════════════════
1319
+ # Git Hooks Installation
1320
+ # ═══════════════════════════════════════════════════════════════════════════════
1321
+
1322
+
1323
+ def install_hooks(console: Console) -> None:
1324
+ """Install global git hooks for branch protection.
1325
+
1326
+ Configure the global core.hooksPath and install a pre-push hook
1327
+ that prevents direct pushes to protected branches.
1328
+
1329
+ Args:
1330
+ console: Rich console for output.
1331
+ """
1332
+
1333
+ hooks_dir = Path.home() / ".config" / "git" / "hooks"
1334
+ hooks_dir.mkdir(parents=True, exist_ok=True)
1335
+
1336
+ pre_push_content = """#!/bin/bash
1337
+ # SCC - Pre-push hook
1338
+ # Prevents direct pushes to protected branches
1339
+
1340
+ PROTECTED_BRANCHES="main master develop production staging"
1341
+
1342
+ current_branch=$(git symbolic-ref HEAD 2>/dev/null | sed -e 's,.*/\\(.*\\),\\1,')
1343
+
1344
+ for protected in $PROTECTED_BRANCHES; do
1345
+ if [ "$current_branch" = "$protected" ]; then
1346
+ echo ""
1347
+ echo "⛔ BLOCKED: Direct push to '$protected' is not allowed"
1348
+ echo ""
1349
+ echo "Please push to a feature branch instead:"
1350
+ echo " git checkout -b claude/<feature-name>"
1351
+ echo " git push -u origin claude/<feature-name>"
1352
+ echo ""
1353
+ exit 1
1354
+ fi
1355
+ done
1356
+
1357
+ while read local_ref local_sha remote_ref remote_sha; do
1358
+ remote_branch=$(echo "$remote_ref" | sed -e 's,.*/\\(.*\\),\\1,')
1359
+
1360
+ for protected in $PROTECTED_BRANCHES; do
1361
+ if [ "$remote_branch" = "$protected" ]; then
1362
+ echo ""
1363
+ echo "⛔ BLOCKED: Push to protected branch '$protected'"
1364
+ echo ""
1365
+ exit 1
1366
+ fi
1367
+ done
1368
+ done
1369
+
1370
+ exit 0
1371
+ """
1372
+
1373
+ pre_push_path = hooks_dir / "pre-push"
1374
+
1375
+ console.print()
1376
+ console.print(
1377
+ create_info_panel(
1378
+ "Installing Git Hooks",
1379
+ "Branch protection hooks will be installed globally",
1380
+ f"Location: {hooks_dir}",
1381
+ )
1382
+ )
1383
+ console.print()
1384
+
1385
+ with console.status("[cyan]Installing hooks...[/cyan]", spinner=Spinners.SETUP):
1386
+ pre_push_path.write_text(pre_push_content)
1387
+ pre_push_path.chmod(0o755)
1388
+
1389
+ # Configure git to use global hooks
1390
+ subprocess.run(
1391
+ ["git", "config", "--global", "core.hooksPath", str(hooks_dir)],
1392
+ capture_output=True,
1393
+ )
1394
+
1395
+ console.print(f" [green]{Indicators.get('PASS')}[/green] Pre-push hook installed")
1396
+ console.print()
1397
+ console.print(
1398
+ create_success_panel(
1399
+ "Hooks Installed",
1400
+ {
1401
+ "Location": str(hooks_dir),
1402
+ "Protected branches": "main, master, develop, production, staging",
1403
+ },
1404
+ )
1405
+ )