ai-codeindex 0.7.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.
- ai_codeindex-0.7.0.dist-info/METADATA +966 -0
- ai_codeindex-0.7.0.dist-info/RECORD +41 -0
- ai_codeindex-0.7.0.dist-info/WHEEL +4 -0
- ai_codeindex-0.7.0.dist-info/entry_points.txt +2 -0
- ai_codeindex-0.7.0.dist-info/licenses/LICENSE +21 -0
- codeindex/README_AI.md +767 -0
- codeindex/__init__.py +11 -0
- codeindex/adaptive_config.py +83 -0
- codeindex/adaptive_selector.py +171 -0
- codeindex/ai_helper.py +48 -0
- codeindex/cli.py +40 -0
- codeindex/cli_common.py +10 -0
- codeindex/cli_config.py +97 -0
- codeindex/cli_docs.py +66 -0
- codeindex/cli_hooks.py +765 -0
- codeindex/cli_scan.py +562 -0
- codeindex/cli_symbols.py +295 -0
- codeindex/cli_tech_debt.py +238 -0
- codeindex/config.py +479 -0
- codeindex/directory_tree.py +229 -0
- codeindex/docstring_processor.py +342 -0
- codeindex/errors.py +62 -0
- codeindex/extractors/__init__.py +9 -0
- codeindex/extractors/thinkphp.py +132 -0
- codeindex/file_classifier.py +148 -0
- codeindex/framework_detect.py +323 -0
- codeindex/hierarchical.py +428 -0
- codeindex/incremental.py +278 -0
- codeindex/invoker.py +260 -0
- codeindex/parallel.py +155 -0
- codeindex/parser.py +740 -0
- codeindex/route_extractor.py +98 -0
- codeindex/route_registry.py +77 -0
- codeindex/scanner.py +167 -0
- codeindex/semantic_extractor.py +408 -0
- codeindex/smart_writer.py +737 -0
- codeindex/symbol_index.py +199 -0
- codeindex/symbol_scorer.py +283 -0
- codeindex/tech_debt.py +619 -0
- codeindex/tech_debt_formatters.py +234 -0
- codeindex/writer.py +164 -0
codeindex/cli_hooks.py
ADDED
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
"""Git Hooks management module for codeindex.
|
|
2
|
+
|
|
3
|
+
Epic 6, P3.1: Automate Git Hooks installation and management.
|
|
4
|
+
|
|
5
|
+
This module provides:
|
|
6
|
+
- HookManager: Manage Git hooks installation/uninstall
|
|
7
|
+
- Hook script generation with templates
|
|
8
|
+
- Backup and restore existing hooks
|
|
9
|
+
- Detect and merge with existing hooks
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import shutil
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
|
|
20
|
+
from .cli_common import console
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class HookStatus(Enum):
|
|
24
|
+
"""Status of a Git hook."""
|
|
25
|
+
|
|
26
|
+
NOT_INSTALLED = "not_installed"
|
|
27
|
+
INSTALLED = "installed" # codeindex-managed
|
|
28
|
+
CUSTOM = "custom" # User's custom hook
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class HookManager:
|
|
32
|
+
"""Manage Git hooks for codeindex."""
|
|
33
|
+
|
|
34
|
+
CODEINDEX_MARKER = "# codeindex-managed hook"
|
|
35
|
+
SUPPORTED_HOOKS = ["pre-commit", "post-commit", "pre-push"]
|
|
36
|
+
|
|
37
|
+
def __init__(self, repo_path: Optional[Path] = None):
|
|
38
|
+
"""
|
|
39
|
+
Initialize HookManager.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
repo_path: Path to Git repository. If None, uses current directory.
|
|
43
|
+
"""
|
|
44
|
+
if repo_path is None:
|
|
45
|
+
repo_path = self._find_git_repo()
|
|
46
|
+
|
|
47
|
+
self.repo_path = Path(repo_path)
|
|
48
|
+
self.hooks_dir = self.repo_path / ".git" / "hooks"
|
|
49
|
+
|
|
50
|
+
if not (self.repo_path / ".git").exists():
|
|
51
|
+
raise ValueError(f"Not a git repository: {repo_path}")
|
|
52
|
+
|
|
53
|
+
# Create hooks directory if it doesn't exist
|
|
54
|
+
self.hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
|
|
56
|
+
def _find_git_repo(self) -> Path:
|
|
57
|
+
"""Find Git repository from current directory."""
|
|
58
|
+
current = Path.cwd()
|
|
59
|
+
|
|
60
|
+
while current != current.parent:
|
|
61
|
+
if (current / ".git").exists():
|
|
62
|
+
return current
|
|
63
|
+
current = current.parent
|
|
64
|
+
|
|
65
|
+
raise ValueError("Not in a git repository")
|
|
66
|
+
|
|
67
|
+
def get_hook_status(self, hook_name: str) -> HookStatus:
|
|
68
|
+
"""
|
|
69
|
+
Get status of a hook.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
hook_name: Name of hook (e.g., "pre-commit")
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
HookStatus indicating current status
|
|
76
|
+
"""
|
|
77
|
+
hook_path = self.hooks_dir / hook_name
|
|
78
|
+
|
|
79
|
+
if not hook_path.exists():
|
|
80
|
+
return HookStatus.NOT_INSTALLED
|
|
81
|
+
|
|
82
|
+
content = hook_path.read_text()
|
|
83
|
+
|
|
84
|
+
if self.CODEINDEX_MARKER in content:
|
|
85
|
+
return HookStatus.INSTALLED
|
|
86
|
+
else:
|
|
87
|
+
return HookStatus.CUSTOM
|
|
88
|
+
|
|
89
|
+
def install_hook(
|
|
90
|
+
self, hook_name: str, backup: bool = True, force: bool = False
|
|
91
|
+
) -> bool:
|
|
92
|
+
"""
|
|
93
|
+
Install codeindex hook.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
hook_name: Name of hook to install
|
|
97
|
+
backup: Whether to backup existing hook
|
|
98
|
+
force: Overwrite existing codeindex hook
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
True if successful, False otherwise
|
|
102
|
+
"""
|
|
103
|
+
if hook_name not in self.SUPPORTED_HOOKS:
|
|
104
|
+
raise ValueError(f"Unsupported hook: {hook_name}")
|
|
105
|
+
|
|
106
|
+
hook_path = self.hooks_dir / hook_name
|
|
107
|
+
status = self.get_hook_status(hook_name)
|
|
108
|
+
|
|
109
|
+
# Backup existing hook if requested
|
|
110
|
+
if status == HookStatus.CUSTOM and backup:
|
|
111
|
+
backup_existing_hook(hook_path)
|
|
112
|
+
|
|
113
|
+
# Don't overwrite codeindex hook unless force=True
|
|
114
|
+
if status == HookStatus.INSTALLED and not force:
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
# Generate and write hook script
|
|
118
|
+
script = generate_hook_script(hook_name)
|
|
119
|
+
hook_path.write_text(script)
|
|
120
|
+
hook_path.chmod(0o755) # Make executable
|
|
121
|
+
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
def uninstall_hook(
|
|
125
|
+
self, hook_name: str, restore_backup: bool = True
|
|
126
|
+
) -> bool:
|
|
127
|
+
"""
|
|
128
|
+
Uninstall codeindex hook.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
hook_name: Name of hook to uninstall
|
|
132
|
+
restore_backup: Whether to restore backup if exists
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
True if successful, False otherwise
|
|
136
|
+
"""
|
|
137
|
+
hook_path = self.hooks_dir / hook_name
|
|
138
|
+
status = self.get_hook_status(hook_name)
|
|
139
|
+
|
|
140
|
+
# Only uninstall codeindex-managed hooks
|
|
141
|
+
if status != HookStatus.INSTALLED:
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
# Remove hook
|
|
145
|
+
hook_path.unlink()
|
|
146
|
+
|
|
147
|
+
# Restore backup if requested and exists
|
|
148
|
+
if restore_backup:
|
|
149
|
+
backup_path = self.hooks_dir / f"{hook_name}.backup"
|
|
150
|
+
if backup_path.exists():
|
|
151
|
+
shutil.copy(backup_path, hook_path)
|
|
152
|
+
backup_path.unlink()
|
|
153
|
+
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
def list_all_hooks(self) -> dict[str, HookStatus]:
|
|
157
|
+
"""
|
|
158
|
+
List status of all supported hooks.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Dictionary mapping hook name to status
|
|
162
|
+
"""
|
|
163
|
+
statuses = {}
|
|
164
|
+
for hook_name in self.SUPPORTED_HOOKS:
|
|
165
|
+
statuses[hook_name] = self.get_hook_status(hook_name)
|
|
166
|
+
return statuses
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def generate_hook_script(
|
|
170
|
+
hook_name: str, config: Optional[dict] = None
|
|
171
|
+
) -> str:
|
|
172
|
+
"""
|
|
173
|
+
Generate hook script content.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
hook_name: Name of hook (e.g., "pre-commit")
|
|
177
|
+
config: Optional configuration for customization
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Hook script as string
|
|
181
|
+
"""
|
|
182
|
+
config = config or {}
|
|
183
|
+
|
|
184
|
+
if hook_name == "pre-commit":
|
|
185
|
+
return _generate_pre_commit_script(config)
|
|
186
|
+
elif hook_name == "post-commit":
|
|
187
|
+
return _generate_post_commit_script(config)
|
|
188
|
+
elif hook_name == "pre-push":
|
|
189
|
+
return _generate_pre_push_script(config)
|
|
190
|
+
else:
|
|
191
|
+
raise ValueError(f"Unsupported hook: {hook_name}")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _generate_pre_commit_script(config: dict) -> str:
|
|
195
|
+
"""Generate pre-commit hook script."""
|
|
196
|
+
lint_enabled = config.get("lint_enabled", True)
|
|
197
|
+
|
|
198
|
+
script = """#!/bin/zsh
|
|
199
|
+
# codeindex-managed hook
|
|
200
|
+
# Pre-commit hook for codeindex
|
|
201
|
+
# L1: Lint check (ruff)
|
|
202
|
+
# L2: Forbid debug code (print/breakpoint)
|
|
203
|
+
|
|
204
|
+
set -e
|
|
205
|
+
|
|
206
|
+
# Colors
|
|
207
|
+
RED='\\033[0;31m'
|
|
208
|
+
GREEN='\\033[0;32m'
|
|
209
|
+
YELLOW='\\033[0;33m'
|
|
210
|
+
NC='\\033[0m' # No Color
|
|
211
|
+
|
|
212
|
+
# Try to activate virtual environment if exists
|
|
213
|
+
REPO_ROOT=$(git rev-parse --show-toplevel)
|
|
214
|
+
if [ -f "$REPO_ROOT/.venv/bin/activate" ]; then
|
|
215
|
+
source "$REPO_ROOT/.venv/bin/activate"
|
|
216
|
+
elif [ -f "$REPO_ROOT/venv/bin/activate" ]; then
|
|
217
|
+
source "$REPO_ROOT/venv/bin/activate"
|
|
218
|
+
fi
|
|
219
|
+
|
|
220
|
+
echo "🔍 Running pre-commit checks..."
|
|
221
|
+
|
|
222
|
+
# Get staged Python files
|
|
223
|
+
STAGED_PY_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\\.py$' || true)
|
|
224
|
+
|
|
225
|
+
if [ -z "$STAGED_PY_FILES" ]; then
|
|
226
|
+
echo "${GREEN}✓ No Python files to check${NC}"
|
|
227
|
+
exit 0
|
|
228
|
+
fi
|
|
229
|
+
|
|
230
|
+
echo " Checking files: $(echo $STAGED_PY_FILES | wc -w | tr -d ' ') Python files"
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
if lint_enabled:
|
|
234
|
+
script += """
|
|
235
|
+
# ============================================
|
|
236
|
+
# L1: Ruff lint check
|
|
237
|
+
# ============================================
|
|
238
|
+
echo "\\n${YELLOW}[L1] Running ruff lint...${NC}"
|
|
239
|
+
|
|
240
|
+
# Try venv ruff first, then system ruff
|
|
241
|
+
RUFF_CMD=""
|
|
242
|
+
if [ -f "$REPO_ROOT/.venv/bin/ruff" ]; then
|
|
243
|
+
RUFF_CMD="$REPO_ROOT/.venv/bin/ruff"
|
|
244
|
+
elif command -v ruff &> /dev/null; then
|
|
245
|
+
RUFF_CMD="ruff"
|
|
246
|
+
else
|
|
247
|
+
echo "${RED}✗ ruff not found. Install with: pip install ruff${NC}"
|
|
248
|
+
exit 1
|
|
249
|
+
fi
|
|
250
|
+
|
|
251
|
+
# Check only staged files
|
|
252
|
+
STAGED_FILES_ARRAY=()
|
|
253
|
+
while IFS= read -r file; do
|
|
254
|
+
if [ -f "$file" ]; then
|
|
255
|
+
STAGED_FILES_ARRAY+=("$file")
|
|
256
|
+
fi
|
|
257
|
+
done < <(git diff --cached --name-only --diff-filter=ACM | grep '\\.py$' || true)
|
|
258
|
+
|
|
259
|
+
if [ ${#STAGED_FILES_ARRAY[@]} -eq 0 ]; then
|
|
260
|
+
echo "${GREEN}✓ No files to lint${NC}"
|
|
261
|
+
else
|
|
262
|
+
if ! $RUFF_CMD check "${STAGED_FILES_ARRAY[@]}"; then
|
|
263
|
+
echo "\\n${RED}✗ Lint errors found. Fix them before committing.${NC}"
|
|
264
|
+
echo " Run: ruff check --fix src/"
|
|
265
|
+
exit 1
|
|
266
|
+
fi
|
|
267
|
+
echo "${GREEN}✓ Lint check passed${NC}"
|
|
268
|
+
fi
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
script += """
|
|
272
|
+
# ============================================
|
|
273
|
+
# L2: Forbid debug code
|
|
274
|
+
# ============================================
|
|
275
|
+
echo "\\n${YELLOW}[L2] Checking for debug code...${NC}"
|
|
276
|
+
|
|
277
|
+
DEBUG_PATTERNS=(
|
|
278
|
+
'print\\s*\\(' # print() statements
|
|
279
|
+
'breakpoint\\s*\\(' # breakpoint() calls
|
|
280
|
+
'pdb\\.set_trace\\s*\\(' # pdb debugger
|
|
281
|
+
'import\\s+pdb' # pdb import
|
|
282
|
+
'from\\s+pdb\\s+import' # from pdb import
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
FOUND_DEBUG=0
|
|
286
|
+
for file in $STAGED_PY_FILES; do
|
|
287
|
+
# Skip CLI files and modules that use print() for legitimate output
|
|
288
|
+
if [[ "$file" == *"/cli"* ]] || [[ "$file" == *"/cli_"* ]] || \\
|
|
289
|
+
[[ "$file" == *"hierarchical.py"* ]] || \\
|
|
290
|
+
[[ "$file" == *"directory_tree.py"* ]] || \\
|
|
291
|
+
[[ "$file" == *"adaptive_selector.py"* ]]; then
|
|
292
|
+
continue
|
|
293
|
+
fi
|
|
294
|
+
|
|
295
|
+
# Get only staged content (not working directory)
|
|
296
|
+
STAGED_CONTENT=$(git show ":$file" 2>/dev/null || true)
|
|
297
|
+
|
|
298
|
+
if [ -z "$STAGED_CONTENT" ]; then
|
|
299
|
+
continue
|
|
300
|
+
fi
|
|
301
|
+
|
|
302
|
+
for pattern in $DEBUG_PATTERNS; do
|
|
303
|
+
# Find matches, excluding console.print() and docstring examples
|
|
304
|
+
MATCHES=$(echo "$STAGED_CONTENT" | \\
|
|
305
|
+
grep -n -E "$pattern" | \\
|
|
306
|
+
grep -v "console\\.print" | \\
|
|
307
|
+
grep -v "^[[:space:]]*>>>" || true)
|
|
308
|
+
if [ -n "$MATCHES" ]; then
|
|
309
|
+
if [ $FOUND_DEBUG -eq 0 ]; then
|
|
310
|
+
echo "${RED}✗ Debug code found:${NC}"
|
|
311
|
+
FOUND_DEBUG=1
|
|
312
|
+
fi
|
|
313
|
+
echo " ${file}:"
|
|
314
|
+
echo "$MATCHES" | while read line; do
|
|
315
|
+
echo " $line"
|
|
316
|
+
done
|
|
317
|
+
fi
|
|
318
|
+
done
|
|
319
|
+
done
|
|
320
|
+
|
|
321
|
+
if [ $FOUND_DEBUG -eq 1 ]; then
|
|
322
|
+
echo "\\n${RED}✗ Remove debug code before committing.${NC}"
|
|
323
|
+
echo " Tip: Use logging module instead of print()"
|
|
324
|
+
exit 1
|
|
325
|
+
fi
|
|
326
|
+
echo "${GREEN}✓ No debug code found${NC}"
|
|
327
|
+
|
|
328
|
+
# ============================================
|
|
329
|
+
# All checks passed
|
|
330
|
+
# ============================================
|
|
331
|
+
echo "\\n${GREEN}✓ All pre-commit checks passed!${NC}\\n"
|
|
332
|
+
exit 0
|
|
333
|
+
"""
|
|
334
|
+
|
|
335
|
+
return script
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _generate_post_commit_script(config: dict) -> str: # noqa: E501
|
|
339
|
+
"""Generate post-commit hook script."""
|
|
340
|
+
auto_update = config.get("auto_update", True)
|
|
341
|
+
|
|
342
|
+
if not auto_update:
|
|
343
|
+
return """#!/bin/zsh
|
|
344
|
+
# codeindex-managed hook
|
|
345
|
+
# Post-commit hook (disabled)
|
|
346
|
+
exit 0
|
|
347
|
+
"""
|
|
348
|
+
|
|
349
|
+
return """#!/bin/zsh
|
|
350
|
+
# codeindex-managed hook
|
|
351
|
+
# Post-commit hook for codeindex
|
|
352
|
+
# Smart incremental update based on change analysis
|
|
353
|
+
|
|
354
|
+
set -e
|
|
355
|
+
|
|
356
|
+
# Colors
|
|
357
|
+
RED='\\033[0;31m'
|
|
358
|
+
GREEN='\\033[0;32m'
|
|
359
|
+
YELLOW='\\033[0;33m'
|
|
360
|
+
CYAN='\\033[0;36m'
|
|
361
|
+
NC='\\033[0m'
|
|
362
|
+
|
|
363
|
+
# Avoid infinite loop: skip if last commit only contains README_AI.md
|
|
364
|
+
LAST_COMMIT_FILES=$(git diff-tree --no-commit-id --name-only -r HEAD)
|
|
365
|
+
NON_DOC_FILES=$(echo "$LAST_COMMIT_FILES" | \\
|
|
366
|
+
grep -v "README_AI.md" | grep -v "PROJECT_INDEX.md" || true)
|
|
367
|
+
if [ -z "$NON_DOC_FILES" ]; then
|
|
368
|
+
exit 0 # Only doc files changed, skip to avoid loop
|
|
369
|
+
fi
|
|
370
|
+
|
|
371
|
+
# Try to activate virtual environment
|
|
372
|
+
REPO_ROOT=$(git rev-parse --show-toplevel)
|
|
373
|
+
if [ -f "$REPO_ROOT/.venv/bin/activate" ]; then
|
|
374
|
+
source "$REPO_ROOT/.venv/bin/activate"
|
|
375
|
+
elif [ -f "$REPO_ROOT/venv/bin/activate" ]; then
|
|
376
|
+
source "$REPO_ROOT/venv/bin/activate"
|
|
377
|
+
fi
|
|
378
|
+
|
|
379
|
+
echo "\\n${CYAN}📝 Post-commit: Analyzing changes...${NC}"
|
|
380
|
+
|
|
381
|
+
# Check if codeindex is available
|
|
382
|
+
if ! command -v codeindex &> /dev/null; then
|
|
383
|
+
echo "${YELLOW}⚠ codeindex not found, skipping auto-update${NC}"
|
|
384
|
+
exit 0
|
|
385
|
+
fi
|
|
386
|
+
|
|
387
|
+
# Get change analysis as JSON
|
|
388
|
+
ANALYSIS=$(codeindex affected --json 2>/dev/null || echo '{"level": "skip"}')
|
|
389
|
+
|
|
390
|
+
# Extract level from JSON
|
|
391
|
+
LEVEL=$(echo "$ANALYSIS" | python3 -c \\
|
|
392
|
+
"import sys, json; print(json.load(sys.stdin).get('level', 'skip'))" \\
|
|
393
|
+
2>/dev/null || echo "skip")
|
|
394
|
+
|
|
395
|
+
if [ "$LEVEL" = "skip" ]; then
|
|
396
|
+
echo "${GREEN}✓ Changes below threshold, skipping update${NC}"
|
|
397
|
+
exit 0
|
|
398
|
+
fi
|
|
399
|
+
|
|
400
|
+
echo " Update level: ${YELLOW}${LEVEL}${NC}"
|
|
401
|
+
|
|
402
|
+
# Get affected directories
|
|
403
|
+
AFFECTED_DIRS=$(echo "$ANALYSIS" | python3 -c "
|
|
404
|
+
import sys, json
|
|
405
|
+
data = json.load(sys.stdin)
|
|
406
|
+
for d in data.get('affected_dirs', []):
|
|
407
|
+
print(d)
|
|
408
|
+
" 2>/dev/null || true)
|
|
409
|
+
|
|
410
|
+
if [ -z "$AFFECTED_DIRS" ]; then
|
|
411
|
+
echo "${GREEN}✓ No directories need updating${NC}"
|
|
412
|
+
exit 0
|
|
413
|
+
fi
|
|
414
|
+
|
|
415
|
+
DIR_COUNT=$(echo "$AFFECTED_DIRS" | wc -l | tr -d ' ')
|
|
416
|
+
echo " Found ${DIR_COUNT} directory(ies) to check"
|
|
417
|
+
|
|
418
|
+
echo "\\n${GREEN}✓ Post-commit hook completed${NC}\\n"
|
|
419
|
+
exit 0
|
|
420
|
+
"""
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _generate_pre_push_script(config: dict) -> str:
|
|
424
|
+
"""Generate pre-push hook script."""
|
|
425
|
+
return """#!/bin/zsh
|
|
426
|
+
# codeindex-managed hook
|
|
427
|
+
# Pre-push hook for codeindex
|
|
428
|
+
|
|
429
|
+
echo "🚀 Running pre-push checks..."
|
|
430
|
+
|
|
431
|
+
# Add your pre-push checks here
|
|
432
|
+
# Example: run tests before push
|
|
433
|
+
|
|
434
|
+
echo "✓ Pre-push checks passed"
|
|
435
|
+
exit 0
|
|
436
|
+
"""
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def backup_existing_hook(hook_path: Path) -> Path:
|
|
440
|
+
"""
|
|
441
|
+
Backup existing hook file.
|
|
442
|
+
|
|
443
|
+
Args:
|
|
444
|
+
hook_path: Path to hook file
|
|
445
|
+
|
|
446
|
+
Returns:
|
|
447
|
+
Path to backup file
|
|
448
|
+
"""
|
|
449
|
+
backup_path = hook_path.parent / f"{hook_path.name}.backup"
|
|
450
|
+
|
|
451
|
+
# If backup already exists, use timestamped name
|
|
452
|
+
if backup_path.exists():
|
|
453
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
454
|
+
backup_path = hook_path.parent / f"{hook_path.name}.backup.{timestamp}"
|
|
455
|
+
|
|
456
|
+
shutil.copy(hook_path, backup_path)
|
|
457
|
+
return backup_path
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def detect_existing_hooks(hooks_dir: Path) -> list[str]:
|
|
461
|
+
"""
|
|
462
|
+
Detect existing hooks in hooks directory.
|
|
463
|
+
|
|
464
|
+
Args:
|
|
465
|
+
hooks_dir: Path to .git/hooks directory
|
|
466
|
+
|
|
467
|
+
Returns:
|
|
468
|
+
List of hook names that exist (excluding .sample files)
|
|
469
|
+
"""
|
|
470
|
+
existing = []
|
|
471
|
+
|
|
472
|
+
if not hooks_dir.exists():
|
|
473
|
+
return existing
|
|
474
|
+
|
|
475
|
+
for file in hooks_dir.iterdir():
|
|
476
|
+
# Skip .sample files and backup files
|
|
477
|
+
if file.suffix in [".sample", ".backup"]:
|
|
478
|
+
continue
|
|
479
|
+
|
|
480
|
+
# Skip if file name contains .backup (timestamped backups)
|
|
481
|
+
if ".backup" in file.name:
|
|
482
|
+
continue
|
|
483
|
+
|
|
484
|
+
# Skip if it's a directory
|
|
485
|
+
if file.is_dir():
|
|
486
|
+
continue
|
|
487
|
+
|
|
488
|
+
# It's a hook file
|
|
489
|
+
existing.append(file.name)
|
|
490
|
+
|
|
491
|
+
return existing
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def install_hook(hook_name: str, repo_path: Optional[Path] = None) -> bool:
|
|
495
|
+
"""
|
|
496
|
+
Install a specific hook.
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
hook_name: Name of hook to install
|
|
500
|
+
repo_path: Path to repository
|
|
501
|
+
|
|
502
|
+
Returns:
|
|
503
|
+
True if successful
|
|
504
|
+
"""
|
|
505
|
+
manager = HookManager(repo_path)
|
|
506
|
+
return manager.install_hook(hook_name, backup=True)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def uninstall_hook(hook_name: str, repo_path: Optional[Path] = None) -> bool:
|
|
510
|
+
"""
|
|
511
|
+
Uninstall a specific hook.
|
|
512
|
+
|
|
513
|
+
Args:
|
|
514
|
+
hook_name: Name of hook to uninstall
|
|
515
|
+
repo_path: Path to repository
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
True if successful
|
|
519
|
+
"""
|
|
520
|
+
manager = HookManager(repo_path)
|
|
521
|
+
return manager.uninstall_hook(hook_name, restore_backup=True)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
# ============================================================================
|
|
525
|
+
# CLI Commands
|
|
526
|
+
# ============================================================================
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
@click.group()
|
|
530
|
+
def hooks():
|
|
531
|
+
"""Manage Git hooks for codeindex."""
|
|
532
|
+
pass
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
@hooks.command()
|
|
536
|
+
@click.option(
|
|
537
|
+
"--all",
|
|
538
|
+
"install_all",
|
|
539
|
+
is_flag=True,
|
|
540
|
+
help="Install all supported hooks",
|
|
541
|
+
)
|
|
542
|
+
@click.option(
|
|
543
|
+
"--force",
|
|
544
|
+
is_flag=True,
|
|
545
|
+
help="Overwrite existing codeindex hooks",
|
|
546
|
+
)
|
|
547
|
+
@click.argument("hook_name", required=False)
|
|
548
|
+
def install(hook_name: Optional[str], install_all: bool, force: bool):
|
|
549
|
+
"""Install Git hooks for codeindex.
|
|
550
|
+
|
|
551
|
+
Examples:
|
|
552
|
+
codeindex hooks install pre-commit
|
|
553
|
+
codeindex hooks install --all
|
|
554
|
+
codeindex hooks install --all --force
|
|
555
|
+
"""
|
|
556
|
+
try:
|
|
557
|
+
manager = HookManager()
|
|
558
|
+
|
|
559
|
+
hooks_to_install = []
|
|
560
|
+
if install_all:
|
|
561
|
+
hooks_to_install = manager.SUPPORTED_HOOKS
|
|
562
|
+
elif hook_name:
|
|
563
|
+
if hook_name not in manager.SUPPORTED_HOOKS:
|
|
564
|
+
console.print(
|
|
565
|
+
f"[red]✗[/red] Unsupported hook: {hook_name}",
|
|
566
|
+
style="red",
|
|
567
|
+
)
|
|
568
|
+
console.print(
|
|
569
|
+
f" Supported hooks: {', '.join(manager.SUPPORTED_HOOKS)}"
|
|
570
|
+
)
|
|
571
|
+
raise click.Abort()
|
|
572
|
+
hooks_to_install = [hook_name]
|
|
573
|
+
else:
|
|
574
|
+
console.print(
|
|
575
|
+
"[yellow]Usage:[/yellow] codeindex hooks install <hook-name> or --all"
|
|
576
|
+
)
|
|
577
|
+
raise click.Abort()
|
|
578
|
+
|
|
579
|
+
console.print("\n[bold]Installing Git Hooks[/bold]\n")
|
|
580
|
+
|
|
581
|
+
installed_count = 0
|
|
582
|
+
skipped_count = 0
|
|
583
|
+
backed_up = []
|
|
584
|
+
|
|
585
|
+
for hook in hooks_to_install:
|
|
586
|
+
status = manager.get_hook_status(hook)
|
|
587
|
+
|
|
588
|
+
if status == HookStatus.CUSTOM:
|
|
589
|
+
backup_path = manager.hooks_dir / f"{hook}.backup"
|
|
590
|
+
backed_up.append(f"{hook} → {backup_path.name}")
|
|
591
|
+
|
|
592
|
+
result = manager.install_hook(hook, backup=True, force=force)
|
|
593
|
+
|
|
594
|
+
if result:
|
|
595
|
+
if status == HookStatus.INSTALLED and not force:
|
|
596
|
+
console.print(f" [dim]→ {hook}: already installed (skipped)[/dim]")
|
|
597
|
+
skipped_count += 1
|
|
598
|
+
else:
|
|
599
|
+
console.print(f" [green]✓[/green] {hook}: installed")
|
|
600
|
+
installed_count += 1
|
|
601
|
+
else:
|
|
602
|
+
console.print(f" [red]✗[/red] {hook}: failed")
|
|
603
|
+
|
|
604
|
+
console.print()
|
|
605
|
+
|
|
606
|
+
if backed_up:
|
|
607
|
+
console.print("[yellow]Backups created:[/yellow]")
|
|
608
|
+
for backup in backed_up:
|
|
609
|
+
console.print(f" {backup}")
|
|
610
|
+
console.print()
|
|
611
|
+
|
|
612
|
+
if installed_count > 0:
|
|
613
|
+
console.print(
|
|
614
|
+
f"[green]✓[/green] Successfully installed {installed_count} hook(s)\n"
|
|
615
|
+
)
|
|
616
|
+
if skipped_count > 0:
|
|
617
|
+
console.print(
|
|
618
|
+
f"[dim]→ Skipped {skipped_count} already installed hook(s)[/dim]\n"
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
except ValueError as e:
|
|
622
|
+
console.print(f"[red]✗[/red] Error: {e}", style="red")
|
|
623
|
+
raise click.Abort()
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
@hooks.command()
|
|
627
|
+
@click.option(
|
|
628
|
+
"--all",
|
|
629
|
+
"uninstall_all",
|
|
630
|
+
is_flag=True,
|
|
631
|
+
help="Uninstall all codeindex hooks",
|
|
632
|
+
)
|
|
633
|
+
@click.option(
|
|
634
|
+
"--keep-backup",
|
|
635
|
+
is_flag=True,
|
|
636
|
+
help="Don't restore backup when uninstalling",
|
|
637
|
+
)
|
|
638
|
+
@click.argument("hook_name", required=False)
|
|
639
|
+
def uninstall(hook_name: Optional[str], uninstall_all: bool, keep_backup: bool):
|
|
640
|
+
"""Uninstall codeindex Git hooks.
|
|
641
|
+
|
|
642
|
+
Examples:
|
|
643
|
+
codeindex hooks uninstall pre-commit
|
|
644
|
+
codeindex hooks uninstall --all
|
|
645
|
+
codeindex hooks uninstall --all --keep-backup
|
|
646
|
+
"""
|
|
647
|
+
try:
|
|
648
|
+
manager = HookManager()
|
|
649
|
+
|
|
650
|
+
hooks_to_uninstall = []
|
|
651
|
+
if uninstall_all:
|
|
652
|
+
# Only uninstall codeindex-managed hooks
|
|
653
|
+
statuses = manager.list_all_hooks()
|
|
654
|
+
hooks_to_uninstall = [
|
|
655
|
+
name
|
|
656
|
+
for name, status in statuses.items()
|
|
657
|
+
if status == HookStatus.INSTALLED
|
|
658
|
+
]
|
|
659
|
+
elif hook_name:
|
|
660
|
+
hooks_to_uninstall = [hook_name]
|
|
661
|
+
else:
|
|
662
|
+
console.print(
|
|
663
|
+
"[yellow]Usage:[/yellow] codeindex hooks uninstall <hook-name> or --all"
|
|
664
|
+
)
|
|
665
|
+
raise click.Abort()
|
|
666
|
+
|
|
667
|
+
if not hooks_to_uninstall:
|
|
668
|
+
console.print("[yellow]→[/yellow] No codeindex hooks to uninstall\n")
|
|
669
|
+
return
|
|
670
|
+
|
|
671
|
+
console.print("\n[bold]Uninstalling Git Hooks[/bold]\n")
|
|
672
|
+
|
|
673
|
+
uninstalled_count = 0
|
|
674
|
+
restored = []
|
|
675
|
+
|
|
676
|
+
for hook in hooks_to_uninstall:
|
|
677
|
+
status = manager.get_hook_status(hook)
|
|
678
|
+
|
|
679
|
+
if status != HookStatus.INSTALLED:
|
|
680
|
+
console.print(f" [dim]→ {hook}: not installed (skipped)[/dim]")
|
|
681
|
+
continue
|
|
682
|
+
|
|
683
|
+
backup_path = manager.hooks_dir / f"{hook}.backup"
|
|
684
|
+
has_backup = backup_path.exists()
|
|
685
|
+
|
|
686
|
+
result = manager.uninstall_hook(hook, restore_backup=not keep_backup)
|
|
687
|
+
|
|
688
|
+
if result:
|
|
689
|
+
console.print(f" [green]✓[/green] {hook}: uninstalled")
|
|
690
|
+
uninstalled_count += 1
|
|
691
|
+
|
|
692
|
+
if has_backup and not keep_backup:
|
|
693
|
+
restored.append(f"{hook} ← {backup_path.name}")
|
|
694
|
+
|
|
695
|
+
console.print()
|
|
696
|
+
|
|
697
|
+
if restored:
|
|
698
|
+
console.print("[green]Backups restored:[/green]")
|
|
699
|
+
for restore in restored:
|
|
700
|
+
console.print(f" {restore}")
|
|
701
|
+
console.print()
|
|
702
|
+
|
|
703
|
+
console.print(
|
|
704
|
+
f"[green]✓[/green] Successfully uninstalled {uninstalled_count} hook(s)\n"
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
except ValueError as e:
|
|
708
|
+
console.print(f"[red]✗[/red] Error: {e}", style="red")
|
|
709
|
+
raise click.Abort()
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
@hooks.command()
|
|
713
|
+
def status():
|
|
714
|
+
"""Show status of Git hooks."""
|
|
715
|
+
try:
|
|
716
|
+
manager = HookManager()
|
|
717
|
+
statuses = manager.list_all_hooks()
|
|
718
|
+
|
|
719
|
+
console.print("\n[bold]Git Hooks Status[/bold]\n")
|
|
720
|
+
|
|
721
|
+
# Status indicators
|
|
722
|
+
status_icons = {
|
|
723
|
+
HookStatus.INSTALLED: "[green]✓[/green]",
|
|
724
|
+
HookStatus.CUSTOM: "[yellow]⚠[/yellow]",
|
|
725
|
+
HookStatus.NOT_INSTALLED: "[dim]○[/dim]",
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
status_labels = {
|
|
729
|
+
HookStatus.INSTALLED: "[green]installed[/green]",
|
|
730
|
+
HookStatus.CUSTOM: "[yellow]custom[/yellow]",
|
|
731
|
+
HookStatus.NOT_INSTALLED: "[dim]not installed[/dim]",
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
for hook_name in manager.SUPPORTED_HOOKS:
|
|
735
|
+
status = statuses[hook_name]
|
|
736
|
+
icon = status_icons[status]
|
|
737
|
+
label = status_labels[status]
|
|
738
|
+
|
|
739
|
+
console.print(f" {icon} {hook_name}: {label}")
|
|
740
|
+
|
|
741
|
+
# Show backup info if exists
|
|
742
|
+
if status in [HookStatus.INSTALLED, HookStatus.CUSTOM]:
|
|
743
|
+
backup_path = manager.hooks_dir / f"{hook_name}.backup"
|
|
744
|
+
if backup_path.exists():
|
|
745
|
+
console.print(f" [dim]└─ backup: {backup_path.name}[/dim]")
|
|
746
|
+
|
|
747
|
+
console.print()
|
|
748
|
+
|
|
749
|
+
# Summary
|
|
750
|
+
installed = sum(1 for s in statuses.values() if s == HookStatus.INSTALLED)
|
|
751
|
+
custom = sum(1 for s in statuses.values() if s == HookStatus.CUSTOM)
|
|
752
|
+
|
|
753
|
+
if installed > 0:
|
|
754
|
+
console.print(f"[green]→[/green] {installed} codeindex hook(s) installed")
|
|
755
|
+
if custom > 0:
|
|
756
|
+
console.print(
|
|
757
|
+
f"[yellow]→[/yellow] {custom} custom hook(s) detected\n"
|
|
758
|
+
f" [dim]Use 'codeindex hooks install --force' to overwrite[/dim]"
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
console.print()
|
|
762
|
+
|
|
763
|
+
except ValueError as e:
|
|
764
|
+
console.print(f"[red]✗[/red] Error: {e}", style="red")
|
|
765
|
+
raise click.Abort()
|