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.
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()