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