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.
- scc_cli/__init__.py +15 -0
- scc_cli/audit/__init__.py +37 -0
- scc_cli/audit/parser.py +191 -0
- scc_cli/audit/reader.py +180 -0
- scc_cli/auth.py +145 -0
- scc_cli/claude_adapter.py +485 -0
- scc_cli/cli.py +259 -0
- scc_cli/cli_admin.py +683 -0
- scc_cli/cli_audit.py +245 -0
- scc_cli/cli_common.py +166 -0
- scc_cli/cli_config.py +527 -0
- scc_cli/cli_exceptions.py +705 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/cli_init.py +272 -0
- scc_cli/cli_launch.py +1400 -0
- scc_cli/cli_org.py +1433 -0
- scc_cli/cli_support.py +322 -0
- scc_cli/cli_team.py +858 -0
- scc_cli/cli_worktree.py +865 -0
- scc_cli/config.py +583 -0
- scc_cli/console.py +562 -0
- scc_cli/constants.py +79 -0
- scc_cli/contexts.py +377 -0
- scc_cli/deprecation.py +54 -0
- scc_cli/deps.py +189 -0
- scc_cli/docker/__init__.py +127 -0
- scc_cli/docker/core.py +466 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +603 -0
- scc_cli/doctor/__init__.py +99 -0
- scc_cli/doctor/checks.py +1082 -0
- scc_cli/doctor/render.py +346 -0
- scc_cli/doctor/types.py +66 -0
- scc_cli/errors.py +288 -0
- scc_cli/evaluation/__init__.py +27 -0
- scc_cli/evaluation/apply_exceptions.py +207 -0
- scc_cli/evaluation/evaluate.py +97 -0
- scc_cli/evaluation/models.py +80 -0
- scc_cli/exit_codes.py +55 -0
- scc_cli/git.py +1405 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +96 -0
- scc_cli/kinds.py +62 -0
- scc_cli/marketplace/__init__.py +123 -0
- scc_cli/marketplace/compute.py +377 -0
- scc_cli/marketplace/constants.py +87 -0
- scc_cli/marketplace/managed.py +135 -0
- scc_cli/marketplace/materialize.py +723 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +238 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +502 -0
- scc_cli/marketplace/sync.py +257 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +688 -0
- scc_cli/marketplace/trust.py +244 -0
- scc_cli/models/__init__.py +41 -0
- scc_cli/models/exceptions.py +273 -0
- scc_cli/models/plugin_audit.py +434 -0
- scc_cli/org_templates.py +269 -0
- scc_cli/output_mode.py +167 -0
- scc_cli/panels.py +113 -0
- scc_cli/platform.py +350 -0
- scc_cli/profiles.py +1034 -0
- scc_cli/remote.py +443 -0
- scc_cli/schemas/__init__.py +1 -0
- scc_cli/schemas/org-v1.schema.json +456 -0
- scc_cli/schemas/team-config.v1.schema.json +163 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +582 -0
- scc_cli/source_resolver.py +470 -0
- scc_cli/stats.py +378 -0
- scc_cli/stores/__init__.py +13 -0
- scc_cli/stores/exception_store.py +251 -0
- scc_cli/subprocess_utils.py +88 -0
- scc_cli/teams.py +339 -0
- scc_cli/templates/__init__.py +2 -0
- scc_cli/templates/org/__init__.py +0 -0
- scc_cli/templates/org/minimal.json +19 -0
- scc_cli/templates/org/reference.json +74 -0
- scc_cli/templates/org/strict.json +38 -0
- scc_cli/templates/org/teams.json +42 -0
- scc_cli/templates/statusline.sh +75 -0
- scc_cli/theme.py +348 -0
- scc_cli/ui/__init__.py +124 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +395 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +669 -0
- scc_cli/ui/dashboard/loaders.py +369 -0
- scc_cli/ui/dashboard/models.py +184 -0
- scc_cli/ui/dashboard/orchestrator.py +337 -0
- scc_cli/ui/formatters.py +443 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +521 -0
- scc_cli/ui/list_screen.py +431 -0
- scc_cli/ui/picker.py +700 -0
- scc_cli/ui/prompts.py +200 -0
- scc_cli/ui/wizard.py +490 -0
- scc_cli/update.py +680 -0
- scc_cli/utils/__init__.py +39 -0
- scc_cli/utils/fixit.py +264 -0
- scc_cli/utils/fuzzy.py +124 -0
- scc_cli/utils/locks.py +101 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.4.0.dist-info/METADATA +369 -0
- scc_cli-1.4.0.dist-info/RECORD +112 -0
- scc_cli-1.4.0.dist-info/WHEEL +4 -0
- scc_cli-1.4.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|