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