cascade_cli/cli/commands/
hooks.rs

1use crate::cli::output::Output;
2use crate::config::Settings;
3use crate::errors::{CascadeError, Result};
4use crate::git::find_repository_root;
5use std::env;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10/// Git repository type detection
11#[derive(Debug, Clone, PartialEq)]
12pub enum RepositoryType {
13    Bitbucket,
14    GitHub,
15    GitLab,
16    AzureDevOps,
17    Unknown,
18}
19
20/// Branch type classification
21#[derive(Debug, Clone, PartialEq)]
22pub enum BranchType {
23    Main,    // main, master, develop
24    Feature, // feature branches
25    Unknown,
26}
27
28/// Installation options for smart hook activation
29#[derive(Debug, Clone)]
30pub struct InstallOptions {
31    pub check_prerequisites: bool,
32    pub feature_branches_only: bool,
33    pub confirm: bool,
34    pub force: bool,
35}
36
37impl Default for InstallOptions {
38    fn default() -> Self {
39        Self {
40            check_prerequisites: true,
41            feature_branches_only: true,
42            confirm: true,
43            force: false,
44        }
45    }
46}
47
48/// Git hooks integration for Cascade CLI
49pub struct HooksManager {
50    repo_path: PathBuf,
51    hooks_dir: PathBuf,
52}
53
54/// Available Git hooks that Cascade can install
55#[derive(Debug, Clone)]
56pub enum HookType {
57    /// Validates commits are added to stacks
58    PostCommit,
59    /// Prevents force pushes and validates stack state
60    PrePush,
61    /// Validates commit messages follow conventions
62    CommitMsg,
63    /// Smart edit mode guidance before commit
64    PreCommit,
65    /// Prepares commit message with stack context
66    PrepareCommitMsg,
67}
68
69impl HookType {
70    fn filename(&self) -> String {
71        let base_name = match self {
72            HookType::PostCommit => "post-commit",
73            HookType::PrePush => "pre-push",
74            HookType::CommitMsg => "commit-msg",
75            HookType::PreCommit => "pre-commit",
76            HookType::PrepareCommitMsg => "prepare-commit-msg",
77        };
78        format!(
79            "{}{}",
80            base_name,
81            crate::utils::platform::git_hook_extension()
82        )
83    }
84
85    fn description(&self) -> &'static str {
86        match self {
87            HookType::PostCommit => "Auto-add new commits to active stack",
88            HookType::PrePush => "Prevent force pushes and validate stack state",
89            HookType::CommitMsg => "Validate commit message format",
90            HookType::PreCommit => "Smart edit mode guidance for better UX",
91            HookType::PrepareCommitMsg => "Add stack context to commit messages",
92        }
93    }
94}
95
96impl HooksManager {
97    pub fn new(repo_path: &Path) -> Result<Self> {
98        let hooks_dir = Self::get_hooks_path(repo_path)?;
99
100        if !hooks_dir.exists() {
101            return Err(CascadeError::config(
102                "Git hooks directory not found. Is this a Git repository?".to_string(),
103            ));
104        }
105
106        Ok(Self {
107            repo_path: repo_path.to_path_buf(),
108            hooks_dir,
109        })
110    }
111
112    /// Get the actual hooks directory path, respecting core.hooksPath configuration
113    fn get_hooks_path(repo_path: &Path) -> Result<PathBuf> {
114        use std::process::Command;
115
116        // Try to get core.hooksPath configuration
117        let output = Command::new("git")
118            .args(["config", "--get", "core.hooksPath"])
119            .current_dir(repo_path)
120            .output()
121            .map_err(|e| CascadeError::config(format!("Failed to check git config: {e}")))?;
122
123        let hooks_path = if output.status.success() {
124            let configured_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
125            if configured_path.is_empty() {
126                // Empty value means default
127                repo_path.join(".git").join("hooks")
128            } else if configured_path.starts_with('/') {
129                // Absolute path
130                PathBuf::from(configured_path)
131            } else {
132                // Relative path - relative to repo root
133                repo_path.join(configured_path)
134            }
135        } else {
136            // No core.hooksPath configured, use default
137            repo_path.join(".git").join("hooks")
138        };
139
140        Ok(hooks_path)
141    }
142
143    /// Install all recommended Cascade hooks
144    pub fn install_all(&self) -> Result<()> {
145        self.install_with_options(&InstallOptions::default())
146    }
147
148    /// Install only essential hooks (for setup) - excludes post-commit
149    pub fn install_essential(&self) -> Result<()> {
150        Output::progress("Installing essential Cascade Git hooks");
151
152        let essential_hooks = vec![HookType::PrePush, HookType::CommitMsg, HookType::PreCommit];
153
154        for hook in essential_hooks {
155            self.install_hook(&hook)?;
156        }
157
158        Output::success("Essential Cascade hooks installed successfully!");
159        Output::tip(
160            "Note: Post-commit hook available separately with 'ca hooks install post-commit'",
161        );
162        Output::section("Hooks installed");
163        self.list_installed_hooks()?;
164
165        Ok(())
166    }
167
168    /// Install hooks with smart validation options
169    pub fn install_with_options(&self, options: &InstallOptions) -> Result<()> {
170        if options.check_prerequisites && !options.force {
171            self.validate_prerequisites()?;
172        }
173
174        if options.feature_branches_only && !options.force {
175            self.validate_branch_suitability()?;
176        }
177
178        if options.confirm && !options.force {
179            self.confirm_installation()?;
180        }
181
182        Output::progress("Installing Cascade Git hooks");
183
184        let hooks = vec![
185            HookType::PostCommit,
186            HookType::PrePush,
187            HookType::CommitMsg,
188            HookType::PrepareCommitMsg,
189        ];
190
191        for hook in hooks {
192            self.install_hook(&hook)?;
193        }
194
195        Output::success("All Cascade hooks installed successfully!");
196        Output::section("Hooks installed");
197        self.list_installed_hooks()?;
198
199        Ok(())
200    }
201
202    /// Install a specific hook
203    pub fn install_hook(&self, hook_type: &HookType) -> Result<()> {
204        let hook_path = self.hooks_dir.join(hook_type.filename());
205        let cascade_content = self.generate_hook_script(hook_type)?;
206
207        if hook_path.exists() {
208            // Check if cascade hooks are already installed
209            let existing_content = fs::read_to_string(&hook_path)
210                .map_err(|e| CascadeError::config(format!("Failed to read existing hook: {e}")))?;
211
212            if existing_content.contains("=== CASCADE CLI HOOKS START ===") {
213                Output::info(format!(
214                    "{} hook already has cascade hooks installed",
215                    hook_type.filename()
216                ));
217                return Ok(());
218            }
219
220            // Append cascade hooks to existing hook
221            self.append_to_existing_hook(&hook_path, &cascade_content)?;
222            Output::success(format!(
223                "Added cascade hooks to existing {} hook",
224                hook_type.filename()
225            ));
226        } else {
227            // No existing hook, install cascade hook directly
228            fs::write(&hook_path, cascade_content)
229                .map_err(|e| CascadeError::config(format!("Failed to write hook file: {e}")))?;
230
231            // Make executable (platform-specific)
232            crate::utils::platform::make_executable(&hook_path).map_err(|e| {
233                CascadeError::config(format!("Failed to make hook executable: {e}"))
234            })?;
235
236            Output::success(format!("Installed {} hook", hook_type.filename()));
237        }
238
239        Ok(())
240    }
241
242    /// Append cascade hooks to an existing hook file
243    fn append_to_existing_hook(&self, hook_path: &Path, cascade_content: &str) -> Result<()> {
244        let existing_content = fs::read_to_string(hook_path)
245            .map_err(|e| CascadeError::config(format!("Failed to read existing hook: {e}")))?;
246
247        // Create the cascade section to append
248        let cascade_section = format!(
249            "\n# === CASCADE CLI HOOKS START ===\n{cascade_content}\n# === CASCADE CLI HOOKS END ===\n"
250        );
251
252        // Append cascade section to existing content
253        let new_content = format!("{existing_content}{cascade_section}");
254
255        fs::write(hook_path, new_content)
256            .map_err(|e| CascadeError::config(format!("Failed to update hook file: {e}")))?;
257
258        // Ensure it's still executable
259        crate::utils::platform::make_executable(hook_path)
260            .map_err(|e| CascadeError::config(format!("Failed to make hook executable: {e}")))?;
261
262        Ok(())
263    }
264
265    /// Remove all Cascade hooks
266    pub fn uninstall_all(&self) -> Result<()> {
267        Output::progress("Removing Cascade Git hooks");
268
269        let hooks = vec![
270            HookType::PostCommit,
271            HookType::PrePush,
272            HookType::CommitMsg,
273            HookType::PrepareCommitMsg,
274        ];
275
276        for hook in hooks {
277            self.uninstall_hook(&hook)?;
278        }
279
280        Output::success("All Cascade hooks removed!");
281        Ok(())
282    }
283
284    /// Remove a specific hook
285    pub fn uninstall_hook(&self, hook_type: &HookType) -> Result<()> {
286        let hook_path = self.hooks_dir.join(hook_type.filename());
287
288        if hook_path.exists() {
289            let content = fs::read_to_string(&hook_path)
290                .map_err(|e| CascadeError::config(format!("Failed to read hook file: {e}")))?;
291
292            if content.contains("=== CASCADE CLI HOOKS START ===") {
293                // Remove only the cascade section from the hook
294                self.remove_cascade_section_from_hook(&hook_path)?;
295                Output::success(format!(
296                    "Removed cascade hooks from {} hook",
297                    hook_type.filename()
298                ));
299            } else {
300                // Check if it's a pure cascade hook (no project hooks)
301                let is_cascade_hook = if cfg!(windows) {
302                    content.contains("rem Cascade CLI Hook")
303                } else {
304                    content.contains("# Cascade CLI Hook")
305                };
306
307                if is_cascade_hook {
308                    fs::remove_file(&hook_path).map_err(|e| {
309                        CascadeError::config(format!("Failed to remove hook file: {e}"))
310                    })?;
311                    Output::info(format!("Removed {} hook", hook_type.filename()));
312                } else {
313                    Output::warning(format!(
314                        "{} hook exists but is not a Cascade hook, skipping",
315                        hook_type.filename()
316                    ));
317                }
318            }
319        } else {
320            Output::info(format!("{} hook not found", hook_type.filename()));
321        }
322
323        Ok(())
324    }
325
326    /// Remove only the cascade section from a hook file, preserving the original content
327    fn remove_cascade_section_from_hook(&self, hook_path: &Path) -> Result<()> {
328        let content = fs::read_to_string(hook_path)
329            .map_err(|e| CascadeError::config(format!("Failed to read hook file: {e}")))?;
330
331        // Find and remove the cascade section
332        let start_marker = "# === CASCADE CLI HOOKS START ===";
333        let end_marker = "# === CASCADE CLI HOOKS END ===";
334
335        if let Some(start_pos) = content.find(start_marker) {
336            if let Some(end_pos) = content.find(end_marker) {
337                let end_pos = end_pos + end_marker.len();
338
339                // Remove everything from start marker to end marker (inclusive)
340                let mut new_content = String::new();
341                new_content.push_str(&content[..start_pos]);
342                new_content.push_str(&content[end_pos..]);
343
344                // Clean up any trailing newlines that might be left
345                let new_content = new_content.trim_end().to_string();
346
347                fs::write(hook_path, new_content).map_err(|e| {
348                    CascadeError::config(format!("Failed to update hook file: {e}"))
349                })?;
350            }
351        }
352
353        Ok(())
354    }
355
356    /// List all installed hooks and their status
357    pub fn list_installed_hooks(&self) -> Result<()> {
358        let hooks = vec![
359            HookType::PostCommit,
360            HookType::PrePush,
361            HookType::CommitMsg,
362            HookType::PrepareCommitMsg,
363        ];
364
365        Output::section("Git Hooks Status");
366
367        for hook in hooks {
368            let hook_path = self.hooks_dir.join(hook.filename());
369            if hook_path.exists() {
370                let content = fs::read_to_string(&hook_path).unwrap_or_default();
371
372                // Check if cascade hooks are present (either as standalone or chained)
373                let has_cascade_hooks = content.contains("=== CASCADE CLI HOOKS START ===")
374                    || if cfg!(windows) {
375                        content.contains("rem Cascade CLI Hook")
376                    } else {
377                        content.contains("# Cascade CLI Hook")
378                    };
379
380                if has_cascade_hooks {
381                    // Check if it's chained (has both project and cascade hooks)
382                    if content.contains("=== CASCADE CLI HOOKS START ===") {
383                        Output::success(format!(
384                            "{}: {} (Chained)",
385                            hook.filename(),
386                            hook.description()
387                        ));
388                    } else {
389                        Output::success(format!("{}: {}", hook.filename(), hook.description()));
390                    }
391                } else {
392                    Output::warning(format!(
393                        "{}: {} (Custom)",
394                        hook.filename(),
395                        hook.description()
396                    ));
397                }
398            } else {
399                Output::error(format!(
400                    "{}: {} (Missing)",
401                    hook.filename(),
402                    hook.description()
403                ));
404            }
405        }
406
407        Ok(())
408    }
409
410    /// Generate hook script content
411    pub fn generate_hook_script(&self, hook_type: &HookType) -> Result<String> {
412        let cascade_cli = env::current_exe()
413            .map_err(|e| {
414                CascadeError::config(format!("Failed to get current executable path: {e}"))
415            })?
416            .to_string_lossy()
417            .to_string();
418
419        let script = match hook_type {
420            HookType::PostCommit => self.generate_post_commit_hook(&cascade_cli),
421            HookType::PrePush => self.generate_pre_push_hook(&cascade_cli),
422            HookType::CommitMsg => self.generate_commit_msg_hook(&cascade_cli),
423            HookType::PreCommit => self.generate_pre_commit_hook(&cascade_cli),
424            HookType::PrepareCommitMsg => self.generate_prepare_commit_msg_hook(&cascade_cli),
425        };
426
427        Ok(script)
428    }
429
430    fn generate_post_commit_hook(&self, cascade_cli: &str) -> String {
431        #[cfg(windows)]
432        {
433            format!(
434                "@echo off\n\
435                 rem Cascade CLI Hook - Post Commit\n\
436                 rem Automatically adds new commits to the active stack\n\n\
437                 rem Get the commit hash and message\n\
438                 for /f \"tokens=*\" %%i in ('git rev-parse HEAD') do set COMMIT_HASH=%%i\n\
439                 for /f \"tokens=*\" %%i in ('git log --format=%%s -n 1 HEAD') do set COMMIT_MSG=%%i\n\n\
440                 rem Find repository root and check if Cascade is initialized\n\
441                 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
442                 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
443                 if not exist \"%REPO_ROOT%\\.cascade\" (\n\
444                     echo ℹ️ Cascade not initialized, skipping stack management\n\
445                     echo 💡 Run 'ca init' to start using stacked diffs\n\
446                     exit /b 0\n\
447                 )\n\n\
448                 rem Check if there's an active stack\n\
449                 \"{cascade_cli}\" stack list --active >nul 2>&1\n\
450                 if %ERRORLEVEL% neq 0 (\n\
451                     echo ℹ️ No active stack found, commit will not be added to any stack\n\
452                     echo 💡 Use 'ca stack create ^<name^>' to create a stack for this commit\n\
453                     exit /b 0\n\
454                 )\n\n\
455                 rem Add commit to active stack\n\
456                 echo 🪝 Adding commit to active stack...\n\
457                 echo 📝 Commit: %COMMIT_MSG%\n\
458                 \"{cascade_cli}\" stack push --commit \"%COMMIT_HASH%\" --message \"%COMMIT_MSG%\"\n\
459                 if %ERRORLEVEL% equ 0 (\n\
460                     echo ✅ Commit added to stack successfully\n\
461                     echo 💡 Next: 'ca submit' to create PRs when ready\n\
462                 ) else (\n\
463                     echo ⚠️ Failed to add commit to stack\n\
464                     echo 💡 You can manually add it with: ca push --commit %COMMIT_HASH%\n\
465                 )\n"
466            )
467        }
468
469        #[cfg(not(windows))]
470        {
471            format!(
472                "#!/bin/sh\n\
473                 # Cascade CLI Hook - Post Commit\n\
474                 # Automatically adds new commits to the active stack\n\n\
475                 set -e\n\n\
476                 # Get the commit hash and message\n\
477                 COMMIT_HASH=$(git rev-parse HEAD)\n\
478                 COMMIT_MSG=$(git log --format=%s -n 1 HEAD)\n\n\
479                 # Find repository root and check if Cascade is initialized\n\
480                 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
481                 if [ ! -d \"$REPO_ROOT/.cascade\" ]; then\n\
482                     echo \"ℹ️ Cascade not initialized, skipping stack management\"\n\
483                     echo \"💡 Run 'ca init' to start using stacked diffs\"\n\
484                     exit 0\n\
485                 fi\n\n\
486                 # Check if there's an active stack\n\
487                 if ! \"{cascade_cli}\" stack list --active > /dev/null 2>&1; then\n\
488                     echo \"ℹ️ No active stack found, commit will not be added to any stack\"\n\
489                     echo \"💡 Use 'ca stack create <name>' to create a stack for this commit\"\n\
490                     exit 0\n\
491                 fi\n\n\
492                 # Add commit to active stack (using specific commit targeting)\n\
493                 echo \"🪝 Adding commit to active stack...\"\n\
494                 echo \"📝 Commit: $COMMIT_MSG\"\n\
495                 if \"{cascade_cli}\" stack push --commit \"$COMMIT_HASH\" --message \"$COMMIT_MSG\"; then\n\
496                     echo \"✅ Commit added to stack successfully\"\n\
497                     echo \"💡 Next: 'ca submit' to create PRs when ready\"\n\
498                 else\n\
499                     echo \"⚠️ Failed to add commit to stack\"\n\
500                     echo \"💡 You can manually add it with: ca push --commit $COMMIT_HASH\"\n\
501                 fi\n"
502            )
503        }
504    }
505
506    fn generate_pre_push_hook(&self, cascade_cli: &str) -> String {
507        #[cfg(windows)]
508        {
509            format!(
510                "@echo off\n\
511                 rem Cascade CLI Hook - Pre Push\n\
512                 rem Prevents force pushes and validates stack state\n\n\
513                 rem Check for force push\n\
514                 echo %* | findstr /C:\"--force\" /C:\"--force-with-lease\" /C:\"-f\" >nul\n\
515                 if %ERRORLEVEL% equ 0 (\n\
516                     echo ❌ Force push detected!\n\
517                     echo 🌊 Cascade CLI uses stacked diffs - force pushes can break stack integrity\n\
518                     echo.\n\
519                     echo 💡 Instead of force pushing, try these streamlined commands:\n\
520                     echo    • ca sync      - Sync with remote changes ^(handles rebasing^)\n\
521                     echo    • ca push      - Push all unpushed commits ^(new default^)\n\
522                     echo    • ca submit    - Submit all entries for review ^(new default^)\n\
523                     echo    • ca autoland  - Auto-merge when approved + builds pass\n\
524                     echo.\n\
525                     echo 🚨 If you really need to force push, run:\n\
526                     echo    git push --force-with-lease [remote] [branch]\n\
527                     echo    ^(But consider if this will affect other stack entries^)\n\
528                     exit /b 1\n\
529                 )\n\n\
530                 rem Find repository root and check if Cascade is initialized\n\
531                 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
532                 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
533                 if not exist \"%REPO_ROOT%\\.cascade\" (\n\
534                     echo ℹ️ Cascade not initialized, allowing push\n\
535                     exit /b 0\n\
536                 )\n\n\
537                 rem Validate stack state\n\
538                 echo 🪝 Validating stack state before push...\n\
539                 \"{cascade_cli}\" stack validate\n\
540                 if %ERRORLEVEL% equ 0 (\n\
541                     echo ✅ Stack validation passed\n\
542                 ) else (\n\
543                     echo ❌ Stack validation failed\n\
544                     echo 💡 Fix validation errors before pushing:\n\
545                     echo    • ca doctor       - Check overall health\n\
546                     echo    • ca status       - Check current stack status\n\
547                     echo    • ca sync         - Sync with remote and rebase if needed\n\
548                     exit /b 1\n\
549                 )\n\n\
550                 echo ✅ Pre-push validation complete\n"
551            )
552        }
553
554        #[cfg(not(windows))]
555        {
556            format!(
557                "#!/bin/sh\n\
558                 # Cascade CLI Hook - Pre Push\n\
559                 # Prevents force pushes and validates stack state\n\n\
560                 set -e\n\n\
561                 # Check for force push\n\
562                 if echo \"$*\" | grep -q -- \"--force\\|--force-with-lease\\|-f\"; then\n\
563                     echo \"❌ Force push detected!\"\n\
564                     echo \"🌊 Cascade CLI uses stacked diffs - force pushes can break stack integrity\"\n\
565                     echo \"\"\n\
566                     echo \"💡 Instead of force pushing, try these streamlined commands:\"\n\
567                     echo \"   • ca sync      - Sync with remote changes (handles rebasing)\"\n\
568                     echo \"   • ca push      - Push all unpushed commits (new default)\"\n\
569                     echo \"   • ca submit    - Submit all entries for review (new default)\"\n\
570                     echo \"   • ca autoland  - Auto-merge when approved + builds pass\"\n\
571                     echo \"\"\n\
572                     echo \"🚨 If you really need to force push, run:\"\n\
573                     echo \"   git push --force-with-lease [remote] [branch]\"\n\
574                     echo \"   (But consider if this will affect other stack entries)\"\n\
575                     exit 1\n\
576                 fi\n\n\
577                 # Find repository root and check if Cascade is initialized\n\
578                 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
579                 if [ ! -d \"$REPO_ROOT/.cascade\" ]; then\n\
580                     echo \"ℹ️ Cascade not initialized, allowing push\"\n\
581                     exit 0\n\
582                 fi\n\n\
583                 # Validate stack state\n\
584                 echo \"🪝 Validating stack state before push...\"\n\
585                 if \"{cascade_cli}\" stack validate; then\n\
586                     echo \"✅ Stack validation passed\"\n\
587                 else\n\
588                     echo \"❌ Stack validation failed\"\n\
589                     echo \"💡 Fix validation errors before pushing:\"\n\
590                     echo \"   • ca doctor       - Check overall health\"\n\
591                     echo \"   • ca status       - Check current stack status\"\n\
592                     echo \"   • ca sync         - Sync with remote and rebase if needed\"\n\
593                     exit 1\n\
594                 fi\n\n\
595                 echo \"✅ Pre-push validation complete\"\n"
596            )
597        }
598    }
599
600    fn generate_commit_msg_hook(&self, _cascade_cli: &str) -> String {
601        #[cfg(windows)]
602        {
603            r#"@echo off
604rem Cascade CLI Hook - Commit Message
605rem Validates commit message format
606
607set COMMIT_MSG_FILE=%1
608if "%COMMIT_MSG_FILE%"=="" (
609    echo ❌ No commit message file provided
610    exit /b 1
611)
612
613rem Read commit message (Windows batch is limited, but this covers basic cases)
614for /f "delims=" %%i in ('type "%COMMIT_MSG_FILE%"') do set COMMIT_MSG=%%i
615
616rem Skip validation for merge commits, fixup commits, etc.
617echo %COMMIT_MSG% | findstr /B /C:"Merge" /C:"Revert" /C:"fixup!" /C:"squash!" >nul
618if %ERRORLEVEL% equ 0 exit /b 0
619
620rem Find repository root and check if Cascade is initialized
621for /f "tokens=*" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i
622if "%REPO_ROOT%"=="" set REPO_ROOT=.
623if not exist "%REPO_ROOT%\.cascade" exit /b 0
624
625rem Basic commit message validation
626echo %COMMIT_MSG% | findstr /R "^..........*" >nul
627if %ERRORLEVEL% neq 0 (
628    echo ❌ Commit message too short (minimum 10 characters)
629    echo 💡 Write a descriptive commit message for better stack management
630    exit /b 1
631)
632
633rem Check for very long messages (approximate check in batch)
634echo %COMMIT_MSG% | findstr /R "^..................................................................................*" >nul
635if %ERRORLEVEL% equ 0 (
636    echo ⚠️ Warning: Commit message longer than 72 characters
637    echo 💡 Consider keeping the first line short for better readability
638)
639
640rem Check for conventional commit format (optional)
641echo %COMMIT_MSG% | findstr /R "^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)" >nul
642if %ERRORLEVEL% neq 0 (
643    echo 💡 Consider using conventional commit format:
644    echo    feat: add new feature
645    echo    fix: resolve bug
646    echo    docs: update documentation
647    echo    etc.
648)
649
650echo ✅ Commit message validation passed
651"#.to_string()
652        }
653
654        #[cfg(not(windows))]
655        {
656            r#"#!/bin/sh
657# Cascade CLI Hook - Commit Message
658# Validates commit message format
659
660set -e
661
662COMMIT_MSG_FILE="$1"
663COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
664
665# Skip validation for merge commits, fixup commits, etc.
666if echo "$COMMIT_MSG" | grep -E "^(Merge|Revert|fixup!|squash!)" > /dev/null; then
667    exit 0
668fi
669
670# Find repository root and check if Cascade is initialized
671REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")
672if [ ! -d "$REPO_ROOT/.cascade" ]; then
673    exit 0
674fi
675
676# Basic commit message validation
677if [ ${#COMMIT_MSG} -lt 10 ]; then
678    echo "❌ Commit message too short (minimum 10 characters)"
679    echo "💡 Write a descriptive commit message for better stack management"
680    exit 1
681fi
682
683if [ ${#COMMIT_MSG} -gt 72 ]; then
684    echo "⚠️ Warning: Commit message longer than 72 characters"
685    echo "💡 Consider keeping the first line short for better readability"
686fi
687
688# Check for conventional commit format (optional)
689if ! echo "$COMMIT_MSG" | grep -E "^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)(\(.+\))?: .+" > /dev/null; then
690    echo "💡 Consider using conventional commit format:"
691    echo "   feat: add new feature"
692    echo "   fix: resolve bug"
693    echo "   docs: update documentation"
694    echo "   etc."
695fi
696
697echo "✅ Commit message validation passed"
698"#.to_string()
699        }
700    }
701
702    #[allow(clippy::uninlined_format_args)]
703    fn generate_pre_commit_hook(&self, cascade_cli: &str) -> String {
704        #[cfg(windows)]
705        {
706            format!(
707                "@echo off\n\
708                 rem Cascade CLI Hook - Pre Commit\n\
709                 rem Smart edit mode guidance for better UX\n\n\
710                 rem Check if Cascade is initialized\n\
711                 for /f \\\"tokens=*\\\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
712                 if \\\"%REPO_ROOT%\\\"==\\\"\\\" set REPO_ROOT=.\n\
713                 if not exist \\\"%REPO_ROOT%\\.cascade\\\" exit /b 0\n\n\
714                 rem Check if we're in edit mode\n\
715                 \\\"{0}\\\" entry status --quiet >nul 2>&1\n\
716                 if %ERRORLEVEL% equ 0 (\n\
717                     echo ⚠️ You're in EDIT MODE for a stack entry!\n\
718                     echo.\n\
719                     echo Choose your action:\n\
720                     echo   🔄 [A]mend: Modify the current entry\n\
721                     echo   ➕ [N]ew:   Create new entry on top ^(current behavior^)\n\
722                     echo   ❌ [C]ancel: Stop and think about it\n\
723                     echo.\n\
724                     set /p choice=\\\"Your choice (A/n/c): \\\"\n\
725                     \n\
726                     if /i \\\"%choice%\\\"==\\\"A\\\" (\n\
727                         echo ✅ Running: git commit --amend\n\
728                         git commit --amend\n\
729                         if %ERRORLEVEL% equ 0 (\n\
730                             echo 💡 Entry updated! Run 'ca sync' to update PRs\n\
731                         )\n\
732                         exit /b %ERRORLEVEL%\n\
733                     ) else if /i \\\"%choice%\\\"==\\\"C\\\" (\n\
734                         echo ❌ Commit cancelled\n\
735                         exit /b 1\n\
736                     ) else (\n\
737                         echo ➕ Creating new stack entry...\n\
738                         rem Let the commit proceed normally\n\
739                         exit /b 0\n\
740                     )\n\
741                 )\n\n\
742                 rem Not in edit mode, proceed normally\n\
743                 exit /b 0\n",
744                cascade_cli
745            )
746        }
747
748        #[cfg(not(windows))]
749        {
750            format!(
751                "#!/bin/sh\n\
752                 # Cascade CLI Hook - Pre Commit\n\
753                 # Smart edit mode guidance for better UX\n\n\
754                 set -e\n\n\
755                 # Check if Cascade is initialized\n\
756                 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \\\".\\\")\n\
757                 if [ ! -d \\\"$REPO_ROOT/.cascade\\\" ]; then\n\
758                     exit 0\n\
759                 fi\n\n\
760                 # Check if we're in edit mode\n\
761                 if \\\"{0}\\\" entry status --quiet >/dev/null 2>&1; then\n\
762                     echo \\\"⚠️ You're in EDIT MODE for a stack entry!\\\"\n\
763                     echo \\\"\\\"\n\
764                     echo \\\"Choose your action:\\\"\n\
765                     echo \\\"  🔄 [A]mend: Modify the current entry\\\"\n\
766                     echo \\\"  ➕ [N]ew:   Create new entry on top (current behavior)\\\"\n\
767                     echo \\\"  ❌ [C]ancel: Stop and think about it\\\"\n\
768                     echo \\\"\\\"\n\
769                     \n\
770                     # Read user choice with default to 'new'\n\
771                     read -p \\\"Your choice (A/n/c): \\\" choice\n\
772                     \n\
773                     case \\\"$choice\\\" in\n\
774                         [Aa])\n\
775                             echo \\\"✅ Running: git commit --amend\\\"\n\
776                             # Temporarily disable hooks to avoid recursion\n\
777                             git -c core.hooksPath=/dev/null commit --amend\n\
778                             if [ $? -eq 0 ]; then\n\
779                                 echo \\\"💡 Entry updated! Run 'ca sync' to update PRs\\\"\n\
780                             fi\n\
781                             exit $?\n\
782                             ;;\n\
783                         [Cc])\n\
784                             echo \\\"❌ Commit cancelled\\\"\n\
785                             exit 1\n\
786                             ;;\n\
787                         *)\n\
788                             echo \\\"➕ Creating new stack entry...\\\"\n\
789                             # Let the commit proceed normally\n\
790                             exit 0\n\
791                             ;;\n\
792                     esac\n\
793                 fi\n\n\
794                 # Not in edit mode, proceed normally\n\
795                 exit 0\n",
796                cascade_cli
797            )
798        }
799    }
800
801    fn generate_prepare_commit_msg_hook(&self, cascade_cli: &str) -> String {
802        #[cfg(windows)]
803        {
804            format!(
805                "@echo off\n\
806                 rem Cascade CLI Hook - Prepare Commit Message\n\
807                 rem Adds stack context to commit messages\n\n\
808                 set COMMIT_MSG_FILE=%1\n\
809                 set COMMIT_SOURCE=%2\n\
810                 set COMMIT_SHA=%3\n\n\
811                 rem Only modify message if it's a regular commit (not merge, template, etc.)\n\
812                 if not \"%COMMIT_SOURCE%\"==\"\" if not \"%COMMIT_SOURCE%\"==\"message\" exit /b 0\n\n\
813                 rem Find repository root and check if Cascade is initialized\n\
814                 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
815                 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
816                 if not exist \"%REPO_ROOT%\\.cascade\" exit /b 0\n\n\
817                 rem Check if in edit mode first\n\
818                 for /f \"tokens=*\" %%i in ('\"{cascade_cli}\" entry status --quiet 2^>nul') do set EDIT_STATUS=%%i\n\
819                 if \"%EDIT_STATUS%\"==\"\" set EDIT_STATUS=inactive\n\n\
820                 if not \"%EDIT_STATUS%\"==\"inactive\" (\n\
821                     rem In edit mode - provide smart guidance\n\
822                     set /p CURRENT_MSG=<%COMMIT_MSG_FILE%\n\n\
823                     rem Skip if message already has edit guidance\n\
824                     echo !CURRENT_MSG! | findstr \"[EDIT MODE]\" >nul\n\
825                     if %ERRORLEVEL% equ 0 exit /b 0\n\n\
826                     rem Add edit mode guidance to commit message\n\
827                     echo.\n\
828                     echo # [EDIT MODE] You're editing a stack entry\n\
829                     echo #\n\
830                     echo # Choose your action:\n\
831                     echo #   🔄 AMEND: To modify the current entry, use:\n\
832                     echo #       git commit --amend\n\
833                     echo #\n\
834                     echo #   ➕ NEW: To create a new entry on top, use:\n\
835                     echo #       git commit    ^(this command^)\n\
836                     echo #\n\
837                     echo # 💡 After committing, run 'ca sync' to update PRs\n\
838                     echo.\n\
839                     type \"%COMMIT_MSG_FILE%\"\n\
840                 ) > \"%COMMIT_MSG_FILE%.tmp\" && (\n\
841                     move \"%COMMIT_MSG_FILE%.tmp\" \"%COMMIT_MSG_FILE%\"\n\
842                 ) else (\n\
843                     rem Regular stack mode - check for active stack\n\
844                     for /f \"tokens=*\" %%i in ('\"{cascade_cli}\" stack list --active --format=name 2^>nul') do set ACTIVE_STACK=%%i\n\n\
845                     if not \"%ACTIVE_STACK%\"==\"\" (\n\
846                         rem Get current commit message\n\
847                         set /p CURRENT_MSG=<%COMMIT_MSG_FILE%\n\n\
848                         rem Skip if message already has stack context\n\
849                         echo !CURRENT_MSG! | findstr \"[stack:\" >nul\n\
850                         if %ERRORLEVEL% equ 0 exit /b 0\n\n\
851                         rem Add stack context to commit message\n\
852                         echo.\n\
853                         echo # Stack: %ACTIVE_STACK%\n\
854                         echo # This commit will be added to the active stack automatically.\n\
855                         echo # Use 'ca stack status' to see the current stack state.\n\
856                         type \"%COMMIT_MSG_FILE%\"\n\
857                     ) > \"%COMMIT_MSG_FILE%.tmp\"\n\
858                     move \"%COMMIT_MSG_FILE%.tmp\" \"%COMMIT_MSG_FILE%\"\n\
859                 )\n"
860            )
861        }
862
863        #[cfg(not(windows))]
864        {
865            format!(
866                "#!/bin/sh\n\
867                 # Cascade CLI Hook - Prepare Commit Message\n\
868                 # Adds stack context to commit messages\n\n\
869                 set -e\n\n\
870                 COMMIT_MSG_FILE=\"$1\"\n\
871                 COMMIT_SOURCE=\"$2\"\n\
872                 COMMIT_SHA=\"$3\"\n\n\
873                 # Only modify message if it's a regular commit (not merge, template, etc.)\n\
874                 if [ \"$COMMIT_SOURCE\" != \"\" ] && [ \"$COMMIT_SOURCE\" != \"message\" ]; then\n\
875                     exit 0\n\
876                 fi\n\n\
877                 # Find repository root and check if Cascade is initialized\n\
878                 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
879                 if [ ! -d \"$REPO_ROOT/.cascade\" ]; then\n\
880                     exit 0\n\
881                 fi\n\n\
882                 # Check if in edit mode first\n\
883                 EDIT_STATUS=$(\"{cascade_cli}\" entry status --quiet 2>/dev/null || echo \"inactive\")\n\
884                 \n\
885                 if [ \"$EDIT_STATUS\" != \"inactive\" ]; then\n\
886                     # In edit mode - provide smart guidance\n\
887                     CURRENT_MSG=$(cat \"$COMMIT_MSG_FILE\")\n\
888                     \n\
889                     # Skip if message already has edit guidance\n\
890                     if echo \"$CURRENT_MSG\" | grep -q \"\\[EDIT MODE\\]\"; then\n\
891                         exit 0\n\
892                     fi\n\
893                     \n\
894                     echo \"\n\
895                 # [EDIT MODE] You're editing a stack entry\n\
896                 #\n\
897                 # Choose your action:\n\
898                 #   🔄 AMEND: To modify the current entry, use:\n\
899                 #       git commit --amend\n\
900                 #\n\
901                 #   ➕ NEW: To create a new entry on top, use:\n\
902                 #       git commit    (this command)\n\
903                 #\n\
904                 # 💡 After committing, run 'ca sync' to update PRs\n\
905                 \n\
906                 $CURRENT_MSG\" > \"$COMMIT_MSG_FILE\"\n\
907                 else\n\
908                     # Regular stack mode - check for active stack\n\
909                     ACTIVE_STACK=$(\"{cascade_cli}\" stack list --active --format=name 2>/dev/null || echo \"\")\n\
910                     \n\
911                     if [ -n \"$ACTIVE_STACK\" ]; then\n\
912                         # Get current commit message\n\
913                         CURRENT_MSG=$(cat \"$COMMIT_MSG_FILE\")\n\
914                         \n\
915                         # Skip if message already has stack context\n\
916                         if echo \"$CURRENT_MSG\" | grep -q \"\\[stack:\"; then\n\
917                             exit 0\n\
918                         fi\n\
919                         \n\
920                         # Add stack context to commit message\n\
921                         echo \"\n\
922                     # Stack: $ACTIVE_STACK\n\
923                     # This commit will be added to the active stack automatically.\n\
924                     # Use 'ca stack status' to see the current stack state.\n\
925                     $CURRENT_MSG\" > \"$COMMIT_MSG_FILE\"\n\
926                     fi\n\
927                 fi\n"
928            )
929        }
930    }
931
932    /// Detect repository type from remote URLs
933    pub fn detect_repository_type(&self) -> Result<RepositoryType> {
934        let output = Command::new("git")
935            .args(["remote", "get-url", "origin"])
936            .current_dir(&self.repo_path)
937            .output()
938            .map_err(|e| CascadeError::config(format!("Failed to get remote URL: {e}")))?;
939
940        if !output.status.success() {
941            return Ok(RepositoryType::Unknown);
942        }
943
944        let remote_url = String::from_utf8_lossy(&output.stdout)
945            .trim()
946            .to_lowercase();
947
948        if remote_url.contains("github.com") {
949            Ok(RepositoryType::GitHub)
950        } else if remote_url.contains("gitlab.com") || remote_url.contains("gitlab") {
951            Ok(RepositoryType::GitLab)
952        } else if remote_url.contains("dev.azure.com") || remote_url.contains("visualstudio.com") {
953            Ok(RepositoryType::AzureDevOps)
954        } else if remote_url.contains("bitbucket") {
955            Ok(RepositoryType::Bitbucket)
956        } else {
957            Ok(RepositoryType::Unknown)
958        }
959    }
960
961    /// Detect current branch type
962    pub fn detect_branch_type(&self) -> Result<BranchType> {
963        let output = Command::new("git")
964            .args(["branch", "--show-current"])
965            .current_dir(&self.repo_path)
966            .output()
967            .map_err(|e| CascadeError::config(format!("Failed to get current branch: {e}")))?;
968
969        if !output.status.success() {
970            return Ok(BranchType::Unknown);
971        }
972
973        let branch_name = String::from_utf8_lossy(&output.stdout)
974            .trim()
975            .to_lowercase();
976
977        if branch_name == "main" || branch_name == "master" || branch_name == "develop" {
978            Ok(BranchType::Main)
979        } else if !branch_name.is_empty() {
980            Ok(BranchType::Feature)
981        } else {
982            Ok(BranchType::Unknown)
983        }
984    }
985
986    /// Validate prerequisites for hook installation
987    pub fn validate_prerequisites(&self) -> Result<()> {
988        Output::check_start("Checking prerequisites for Cascade hooks");
989
990        // 1. Check repository type
991        let repo_type = self.detect_repository_type()?;
992        match repo_type {
993            RepositoryType::Bitbucket => {
994                Output::success("Bitbucket repository detected");
995                Output::tip("Hooks will work great with 'ca submit' and 'ca autoland' for Bitbucket integration");
996            }
997            RepositoryType::GitHub => {
998                Output::success("GitHub repository detected");
999                Output::tip("Consider setting up GitHub Actions for CI/CD integration");
1000            }
1001            RepositoryType::GitLab => {
1002                Output::success("GitLab repository detected");
1003                Output::tip("GitLab CI integration works well with Cascade stacks");
1004            }
1005            RepositoryType::AzureDevOps => {
1006                Output::success("Azure DevOps repository detected");
1007                Output::tip("Azure Pipelines can be configured to work with Cascade workflows");
1008            }
1009            RepositoryType::Unknown => {
1010                Output::info(
1011                    "Unknown repository type - hooks will still work for local Git operations",
1012                );
1013            }
1014        }
1015
1016        // 2. Check Cascade configuration
1017        let config_dir = crate::config::get_repo_config_dir(&self.repo_path)?;
1018        let config_path = config_dir.join("config.json");
1019        if !config_path.exists() {
1020            return Err(CascadeError::config(
1021                "🚫 Cascade not initialized!\n\n\
1022                Please run 'ca init' or 'ca setup' first to configure Cascade CLI.\n\
1023                Hooks require proper Bitbucket Server configuration.\n\n\
1024                Use --force to install anyway (not recommended)."
1025                    .to_string(),
1026            ));
1027        }
1028
1029        // 3. Validate Bitbucket configuration
1030        let config = Settings::load_from_file(&config_path)?;
1031
1032        if config.bitbucket.url == "https://bitbucket.example.com"
1033            || config.bitbucket.url.contains("example.com")
1034        {
1035            return Err(CascadeError::config(
1036                "🚫 Invalid Bitbucket configuration!\n\n\
1037                Your Bitbucket URL appears to be a placeholder.\n\
1038                Please run 'ca setup' to configure a real Bitbucket Server.\n\n\
1039                Use --force to install anyway (not recommended)."
1040                    .to_string(),
1041            ));
1042        }
1043
1044        if config.bitbucket.project == "PROJECT" || config.bitbucket.repo == "repo" {
1045            return Err(CascadeError::config(
1046                "🚫 Incomplete Bitbucket configuration!\n\n\
1047                Your project/repository settings appear to be placeholders.\n\
1048                Please run 'ca setup' to complete configuration.\n\n\
1049                Use --force to install anyway (not recommended)."
1050                    .to_string(),
1051            ));
1052        }
1053
1054        Output::success("Prerequisites validation passed");
1055        Ok(())
1056    }
1057
1058    /// Validate branch suitability for hooks
1059    pub fn validate_branch_suitability(&self) -> Result<()> {
1060        let branch_type = self.detect_branch_type()?;
1061
1062        match branch_type {
1063            BranchType::Main => {
1064                return Err(CascadeError::config(
1065                    "🚫 Currently on main/master branch!\n\n\
1066                    Cascade hooks are designed for feature branch development.\n\
1067                    Working directly on main/master with stacked diffs can:\n\
1068                    • Complicate the commit history\n\
1069                    • Interfere with team collaboration\n\
1070                    • Break CI/CD workflows\n\n\
1071                    💡 Recommended workflow:\n\
1072                    1. Create a feature branch: git checkout -b feature/my-feature\n\
1073                    2. Install hooks: ca hooks install\n\
1074                    3. Develop with stacked commits (auto-added with hooks)\n\
1075                    4. Push & submit: ca push && ca submit (all by default)\n\
1076                    5. Auto-land when ready: ca autoland\n\n\
1077                    Use --force to install anyway (not recommended)."
1078                        .to_string(),
1079                ));
1080            }
1081            BranchType::Feature => {
1082                Output::success("Feature branch detected - suitable for stacked development");
1083            }
1084            BranchType::Unknown => {
1085                Output::warning("Unknown branch type - proceeding with caution");
1086            }
1087        }
1088
1089        Ok(())
1090    }
1091
1092    /// Confirm installation with user
1093    pub fn confirm_installation(&self) -> Result<()> {
1094        Output::section("Hook Installation Summary");
1095
1096        let hooks = vec![
1097            HookType::PostCommit,
1098            HookType::PrePush,
1099            HookType::CommitMsg,
1100            HookType::PrepareCommitMsg,
1101        ];
1102
1103        for hook in &hooks {
1104            Output::sub_item(format!("{}: {}", hook.filename(), hook.description()));
1105        }
1106
1107        println!();
1108        Output::section("These hooks will automatically");
1109        Output::bullet("Add commits to your active stack");
1110        Output::bullet("Validate commit messages");
1111        Output::bullet("Prevent force pushes that break stack integrity");
1112        Output::bullet("Add stack context to commit messages");
1113
1114        println!();
1115        Output::section("With hooks + new defaults, your workflow becomes");
1116        Output::sub_item("git commit       → Auto-added to stack");
1117        Output::sub_item("ca push          → Pushes all by default");
1118        Output::sub_item("ca submit        → Submits all by default");
1119        Output::sub_item("ca autoland      → Auto-merges when ready");
1120
1121        use std::io::{self, Write};
1122        print!("\nInstall Cascade hooks? [Y/n]: ");
1123        io::stdout().flush().unwrap();
1124
1125        let mut input = String::new();
1126        io::stdin().read_line(&mut input).unwrap();
1127        let input = input.trim().to_lowercase();
1128
1129        if input.is_empty() || input == "y" || input == "yes" {
1130            Output::success("Proceeding with installation");
1131            Ok(())
1132        } else {
1133            Err(CascadeError::config(
1134                "Installation cancelled by user".to_string(),
1135            ))
1136        }
1137    }
1138}
1139
1140/// Run hooks management commands
1141pub async fn install() -> Result<()> {
1142    install_with_options(false, false, false, false).await
1143}
1144
1145pub async fn install_essential() -> Result<()> {
1146    let current_dir = env::current_dir()
1147        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1148
1149    let repo_root = find_repository_root(&current_dir)
1150        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1151
1152    let hooks_manager = HooksManager::new(&repo_root)?;
1153    hooks_manager.install_essential()
1154}
1155
1156pub async fn install_with_options(
1157    skip_checks: bool,
1158    allow_main_branch: bool,
1159    yes: bool,
1160    force: bool,
1161) -> Result<()> {
1162    let current_dir = env::current_dir()
1163        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1164
1165    let repo_root = find_repository_root(&current_dir)
1166        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1167
1168    let hooks_manager = HooksManager::new(&repo_root)?;
1169
1170    let options = InstallOptions {
1171        check_prerequisites: !skip_checks,
1172        feature_branches_only: !allow_main_branch,
1173        confirm: !yes,
1174        force,
1175    };
1176
1177    hooks_manager.install_with_options(&options)
1178}
1179
1180pub async fn uninstall() -> Result<()> {
1181    let current_dir = env::current_dir()
1182        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1183
1184    let repo_root = find_repository_root(&current_dir)
1185        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1186
1187    let hooks_manager = HooksManager::new(&repo_root)?;
1188    hooks_manager.uninstall_all()
1189}
1190
1191pub async fn status() -> Result<()> {
1192    let current_dir = env::current_dir()
1193        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1194
1195    let repo_root = find_repository_root(&current_dir)
1196        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1197
1198    let hooks_manager = HooksManager::new(&repo_root)?;
1199    hooks_manager.list_installed_hooks()
1200}
1201
1202pub async fn install_hook(hook_name: &str) -> Result<()> {
1203    install_hook_with_options(hook_name, false, false).await
1204}
1205
1206pub async fn install_hook_with_options(
1207    hook_name: &str,
1208    skip_checks: bool,
1209    force: bool,
1210) -> Result<()> {
1211    let current_dir = env::current_dir()
1212        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1213
1214    let repo_root = find_repository_root(&current_dir)
1215        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1216
1217    let hooks_manager = HooksManager::new(&repo_root)?;
1218
1219    let hook_type = match hook_name {
1220        "post-commit" => HookType::PostCommit,
1221        "pre-push" => HookType::PrePush,
1222        "commit-msg" => HookType::CommitMsg,
1223        "pre-commit" => HookType::PreCommit,
1224        "prepare-commit-msg" => HookType::PrepareCommitMsg,
1225        _ => {
1226            return Err(CascadeError::config(format!(
1227                "Unknown hook type: {hook_name}"
1228            )))
1229        }
1230    };
1231
1232    // Run basic validation if not skipped
1233    if !skip_checks && !force {
1234        hooks_manager.validate_prerequisites()?;
1235    }
1236
1237    hooks_manager.install_hook(&hook_type)
1238}
1239
1240pub async fn uninstall_hook(hook_name: &str) -> Result<()> {
1241    let current_dir = env::current_dir()
1242        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1243
1244    let repo_root = find_repository_root(&current_dir)
1245        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1246
1247    let hooks_manager = HooksManager::new(&repo_root)?;
1248
1249    let hook_type = match hook_name {
1250        "post-commit" => HookType::PostCommit,
1251        "pre-push" => HookType::PrePush,
1252        "commit-msg" => HookType::CommitMsg,
1253        "pre-commit" => HookType::PreCommit,
1254        "prepare-commit-msg" => HookType::PrepareCommitMsg,
1255        _ => {
1256            return Err(CascadeError::config(format!(
1257                "Unknown hook type: {hook_name}"
1258            )))
1259        }
1260    };
1261
1262    hooks_manager.uninstall_hook(&hook_type)
1263}
1264
1265#[cfg(test)]
1266mod tests {
1267    use super::*;
1268    use std::process::Command;
1269    use tempfile::TempDir;
1270
1271    fn create_test_repo() -> (TempDir, std::path::PathBuf) {
1272        let temp_dir = TempDir::new().unwrap();
1273        let repo_path = temp_dir.path().to_path_buf();
1274
1275        // Initialize git repository
1276        Command::new("git")
1277            .args(["init"])
1278            .current_dir(&repo_path)
1279            .output()
1280            .unwrap();
1281        Command::new("git")
1282            .args(["config", "user.name", "Test"])
1283            .current_dir(&repo_path)
1284            .output()
1285            .unwrap();
1286        Command::new("git")
1287            .args(["config", "user.email", "test@test.com"])
1288            .current_dir(&repo_path)
1289            .output()
1290            .unwrap();
1291
1292        // Create initial commit
1293        std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1294        Command::new("git")
1295            .args(["add", "."])
1296            .current_dir(&repo_path)
1297            .output()
1298            .unwrap();
1299        Command::new("git")
1300            .args(["commit", "-m", "Initial"])
1301            .current_dir(&repo_path)
1302            .output()
1303            .unwrap();
1304
1305        // Initialize cascade
1306        crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
1307            .unwrap();
1308
1309        (temp_dir, repo_path)
1310    }
1311
1312    #[test]
1313    fn test_hooks_manager_creation() {
1314        let (_temp_dir, repo_path) = create_test_repo();
1315        let _manager = HooksManager::new(&repo_path).unwrap();
1316
1317        assert_eq!(_manager.repo_path, repo_path);
1318        // Should use default hooks directory when no core.hooksPath is set
1319        assert_eq!(_manager.hooks_dir, repo_path.join(".git/hooks"));
1320    }
1321
1322    #[test]
1323    fn test_hooks_manager_custom_hooks_path() {
1324        let (_temp_dir, repo_path) = create_test_repo();
1325
1326        // Set custom hooks path
1327        Command::new("git")
1328            .args(["config", "core.hooksPath", "custom-hooks"])
1329            .current_dir(&repo_path)
1330            .output()
1331            .unwrap();
1332
1333        // Create the custom hooks directory
1334        let custom_hooks_dir = repo_path.join("custom-hooks");
1335        std::fs::create_dir_all(&custom_hooks_dir).unwrap();
1336
1337        let _manager = HooksManager::new(&repo_path).unwrap();
1338
1339        assert_eq!(_manager.repo_path, repo_path);
1340        // Should use custom hooks directory when core.hooksPath is set
1341        assert_eq!(_manager.hooks_dir, custom_hooks_dir);
1342    }
1343
1344    #[test]
1345    fn test_hook_chaining_with_existing_hooks() {
1346        let (_temp_dir, repo_path) = create_test_repo();
1347        let manager = HooksManager::new(&repo_path).unwrap();
1348
1349        let hook_type = HookType::PreCommit;
1350        let hook_path = repo_path.join(".git/hooks").join(hook_type.filename());
1351
1352        // Create an existing project hook
1353        let existing_hook_content = "#!/bin/bash\n# Project pre-commit hook\n./scripts/lint.sh\n";
1354        std::fs::write(&hook_path, existing_hook_content).unwrap();
1355        crate::utils::platform::make_executable(&hook_path).unwrap();
1356
1357        // Install cascade hook (should chain with existing)
1358        let result = manager.install_hook(&hook_type);
1359        assert!(result.is_ok());
1360
1361        // Read the resulting hook
1362        let final_content = std::fs::read_to_string(&hook_path).unwrap();
1363
1364        // Should contain both original and cascade content
1365        assert!(final_content.contains("# Project pre-commit hook"));
1366        assert!(final_content.contains("./scripts/lint.sh"));
1367        assert!(final_content.contains("=== CASCADE CLI HOOKS START ==="));
1368        assert!(final_content.contains("=== CASCADE CLI HOOKS END ==="));
1369
1370        // Test uninstall removes only cascade section
1371        let uninstall_result = manager.uninstall_hook(&hook_type);
1372        assert!(uninstall_result.is_ok());
1373
1374        // Read the hook after uninstall
1375        let after_uninstall = std::fs::read_to_string(&hook_path).unwrap();
1376
1377        // Should still contain original project hook
1378        assert!(after_uninstall.contains("# Project pre-commit hook"));
1379        assert!(after_uninstall.contains("./scripts/lint.sh"));
1380        // But not cascade content
1381        assert!(!after_uninstall.contains("=== CASCADE CLI HOOKS START ==="));
1382        assert!(!after_uninstall.contains("=== CASCADE CLI HOOKS END ==="));
1383    }
1384
1385    #[test]
1386    fn test_hook_installation() {
1387        let (_temp_dir, repo_path) = create_test_repo();
1388        let manager = HooksManager::new(&repo_path).unwrap();
1389
1390        // Test installing post-commit hook
1391        let hook_type = HookType::PostCommit;
1392        let result = manager.install_hook(&hook_type);
1393        assert!(result.is_ok());
1394
1395        // Verify hook file exists with platform-appropriate filename
1396        let hook_filename = hook_type.filename();
1397        let hook_path = repo_path.join(".git/hooks").join(&hook_filename);
1398        assert!(hook_path.exists());
1399
1400        // Verify hook is executable (platform-specific)
1401        #[cfg(unix)]
1402        {
1403            use std::os::unix::fs::PermissionsExt;
1404            let metadata = std::fs::metadata(&hook_path).unwrap();
1405            let permissions = metadata.permissions();
1406            assert!(permissions.mode() & 0o111 != 0); // Check executable bit
1407        }
1408
1409        #[cfg(windows)]
1410        {
1411            // On Windows, verify it has .bat extension and file exists
1412            assert!(hook_filename.ends_with(".bat"));
1413            assert!(hook_path.exists());
1414        }
1415    }
1416
1417    #[test]
1418    fn test_hook_detection() {
1419        let (_temp_dir, repo_path) = create_test_repo();
1420        let _manager = HooksManager::new(&repo_path).unwrap();
1421
1422        // Check if hook files exist with platform-appropriate filenames
1423        let post_commit_path = repo_path
1424            .join(".git/hooks")
1425            .join(HookType::PostCommit.filename());
1426        let pre_push_path = repo_path
1427            .join(".git/hooks")
1428            .join(HookType::PrePush.filename());
1429        let commit_msg_path = repo_path
1430            .join(".git/hooks")
1431            .join(HookType::CommitMsg.filename());
1432
1433        // Initially no hooks should be installed
1434        assert!(!post_commit_path.exists());
1435        assert!(!pre_push_path.exists());
1436        assert!(!commit_msg_path.exists());
1437    }
1438
1439    #[test]
1440    fn test_hook_validation() {
1441        let (_temp_dir, repo_path) = create_test_repo();
1442        let manager = HooksManager::new(&repo_path).unwrap();
1443
1444        // Test validation - may fail in CI due to missing dependencies
1445        let validation = manager.validate_prerequisites();
1446        // In CI environment, validation might fail due to missing configuration
1447        // Just ensure it doesn't panic
1448        let _ = validation; // Don't assert ok/err, just ensure no panic
1449
1450        // Test branch validation - should work regardless of environment
1451        let branch_validation = manager.validate_branch_suitability();
1452        // Branch validation should work in most cases, but be tolerant
1453        let _ = branch_validation; // Don't assert ok/err, just ensure no panic
1454    }
1455
1456    #[test]
1457    fn test_hook_uninstallation() {
1458        let (_temp_dir, repo_path) = create_test_repo();
1459        let manager = HooksManager::new(&repo_path).unwrap();
1460
1461        // Install then uninstall hook
1462        let hook_type = HookType::PostCommit;
1463        manager.install_hook(&hook_type).unwrap();
1464
1465        let hook_path = repo_path.join(".git/hooks").join(hook_type.filename());
1466        assert!(hook_path.exists());
1467
1468        let result = manager.uninstall_hook(&hook_type);
1469        assert!(result.is_ok());
1470        assert!(!hook_path.exists());
1471    }
1472
1473    #[test]
1474    fn test_hook_content_generation() {
1475        let (_temp_dir, repo_path) = create_test_repo();
1476        let manager = HooksManager::new(&repo_path).unwrap();
1477
1478        // Use a known binary name for testing
1479        let binary_name = "cascade-cli";
1480
1481        // Test post-commit hook generation
1482        let post_commit_content = manager.generate_post_commit_hook(binary_name);
1483        #[cfg(windows)]
1484        {
1485            assert!(post_commit_content.contains("@echo off"));
1486            assert!(post_commit_content.contains("rem Cascade CLI Hook"));
1487        }
1488        #[cfg(not(windows))]
1489        {
1490            assert!(post_commit_content.contains("#!/bin/sh"));
1491            assert!(post_commit_content.contains("# Cascade CLI Hook"));
1492        }
1493        assert!(post_commit_content.contains(binary_name));
1494
1495        // Test pre-push hook generation
1496        let pre_push_content = manager.generate_pre_push_hook(binary_name);
1497        #[cfg(windows)]
1498        {
1499            assert!(pre_push_content.contains("@echo off"));
1500            assert!(pre_push_content.contains("rem Cascade CLI Hook"));
1501        }
1502        #[cfg(not(windows))]
1503        {
1504            assert!(pre_push_content.contains("#!/bin/sh"));
1505            assert!(pre_push_content.contains("# Cascade CLI Hook"));
1506        }
1507        assert!(pre_push_content.contains(binary_name));
1508
1509        // Test commit-msg hook generation (doesn't use binary, just validates)
1510        let commit_msg_content = manager.generate_commit_msg_hook(binary_name);
1511        #[cfg(windows)]
1512        {
1513            assert!(commit_msg_content.contains("@echo off"));
1514            assert!(commit_msg_content.contains("rem Cascade CLI Hook"));
1515        }
1516        #[cfg(not(windows))]
1517        {
1518            assert!(commit_msg_content.contains("#!/bin/sh"));
1519            assert!(commit_msg_content.contains("# Cascade CLI Hook"));
1520        }
1521
1522        // Test prepare-commit-msg hook generation (does use binary)
1523        let prepare_commit_content = manager.generate_prepare_commit_msg_hook(binary_name);
1524        #[cfg(windows)]
1525        {
1526            assert!(prepare_commit_content.contains("@echo off"));
1527            assert!(prepare_commit_content.contains("rem Cascade CLI Hook"));
1528        }
1529        #[cfg(not(windows))]
1530        {
1531            assert!(prepare_commit_content.contains("#!/bin/sh"));
1532            assert!(prepare_commit_content.contains("# Cascade CLI Hook"));
1533        }
1534        assert!(prepare_commit_content.contains(binary_name));
1535    }
1536
1537    #[test]
1538    fn test_hook_status_reporting() {
1539        let (_temp_dir, repo_path) = create_test_repo();
1540        let manager = HooksManager::new(&repo_path).unwrap();
1541
1542        // Check repository type detection - should work with our test setup
1543        let repo_type = manager.detect_repository_type().unwrap();
1544        // In CI environment, this might be Unknown if remote detection fails
1545        assert!(matches!(
1546            repo_type,
1547            RepositoryType::Bitbucket | RepositoryType::Unknown
1548        ));
1549
1550        // Check branch type detection
1551        let branch_type = manager.detect_branch_type().unwrap();
1552        // Should be on main/master branch, but allow for different default branch names
1553        assert!(matches!(
1554            branch_type,
1555            BranchType::Main | BranchType::Unknown
1556        ));
1557    }
1558
1559    #[test]
1560    fn test_force_installation() {
1561        let (_temp_dir, repo_path) = create_test_repo();
1562        let manager = HooksManager::new(&repo_path).unwrap();
1563
1564        // Create a fake existing hook with platform-appropriate content
1565        let hook_filename = HookType::PostCommit.filename();
1566        let hook_path = repo_path.join(".git/hooks").join(&hook_filename);
1567
1568        #[cfg(windows)]
1569        let existing_content = "@echo off\necho existing hook";
1570        #[cfg(not(windows))]
1571        let existing_content = "#!/bin/sh\necho 'existing hook'";
1572
1573        std::fs::write(&hook_path, existing_content).unwrap();
1574
1575        // Install hook (should chain with existing)
1576        let hook_type = HookType::PostCommit;
1577        let result = manager.install_hook(&hook_type);
1578        assert!(result.is_ok());
1579
1580        // Verify both old and new content exist
1581        let content = std::fs::read_to_string(&hook_path).unwrap();
1582        #[cfg(windows)]
1583        {
1584            assert!(content.contains("rem Cascade CLI Hook"));
1585        }
1586        #[cfg(not(windows))]
1587        {
1588            assert!(content.contains("# Cascade CLI Hook"));
1589        }
1590        // Original content should still be there
1591        assert!(content.contains("existing hook"));
1592        // Chaining markers should be present
1593        assert!(content.contains("=== CASCADE CLI HOOKS START ==="));
1594        assert!(content.contains("=== CASCADE CLI HOOKS END ==="));
1595    }
1596}