Skip to main content

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 dialoguer::{theme::ColorfulTheme, Confirm};
6use std::env;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use tracing::debug;
11
12/// Git repository type detection
13#[derive(Debug, Clone, PartialEq)]
14pub enum RepositoryType {
15    Bitbucket,
16    GitHub,
17    GitLab,
18    AzureDevOps,
19    Unknown,
20}
21
22/// Branch type classification
23#[derive(Debug, Clone, PartialEq)]
24pub enum BranchType {
25    Main,    // main, master, develop
26    Feature, // feature branches
27    Unknown,
28}
29
30/// Installation options for smart hook activation
31#[derive(Debug, Clone)]
32pub struct InstallOptions {
33    pub check_prerequisites: bool,
34    pub feature_branches_only: bool,
35    pub confirm: bool,
36    pub force: bool,
37}
38
39impl Default for InstallOptions {
40    fn default() -> Self {
41        Self {
42            check_prerequisites: true,
43            feature_branches_only: true,
44            confirm: true,
45            force: false,
46        }
47    }
48}
49
50/// Git hooks integration for Cascade CLI
51pub struct HooksManager {
52    repo_path: PathBuf,
53    repo_id: String,
54}
55
56/// Available Git hooks that Cascade can install
57#[derive(Debug, Clone)]
58pub enum HookType {
59    /// Validates commits are added to stacks
60    PostCommit,
61    /// Prevents force pushes and validates stack state
62    PrePush,
63    /// Validates commit messages follow conventions
64    CommitMsg,
65    /// Smart edit mode guidance before commit
66    PreCommit,
67    /// Prepares commit message with stack context
68    PrepareCommitMsg,
69}
70
71impl HookType {
72    fn filename(&self) -> String {
73        let base_name = match self {
74            HookType::PostCommit => "post-commit",
75            HookType::PrePush => "pre-push",
76            HookType::CommitMsg => "commit-msg",
77            HookType::PreCommit => "pre-commit",
78            HookType::PrepareCommitMsg => "prepare-commit-msg",
79        };
80        format!(
81            "{}{}",
82            base_name,
83            crate::utils::platform::git_hook_extension()
84        )
85    }
86
87    fn description(&self) -> &'static str {
88        match self {
89            HookType::PostCommit => "Auto-add new commits to active stack",
90            HookType::PrePush => "Prevent force pushes and validate stack state",
91            HookType::CommitMsg => "Validate commit message format",
92            HookType::PreCommit => "Smart edit mode guidance for better UX",
93            HookType::PrepareCommitMsg => "Add stack context to commit messages",
94        }
95    }
96}
97
98impl HooksManager {
99    pub fn new(repo_path: &Path) -> Result<Self> {
100        // Verify this is a git repository
101        let git_dir = repo_path.join(".git");
102        if !git_dir.exists() {
103            return Err(CascadeError::config(
104                "Not a Git repository. Git hooks require a valid Git repository.".to_string(),
105            ));
106        }
107
108        // Generate a unique repo ID based on remote URL
109        let repo_id = Self::generate_repo_id(repo_path)?;
110
111        Ok(Self {
112            repo_path: repo_path.to_path_buf(),
113            repo_id,
114        })
115    }
116
117    /// Generate a unique repository identifier based on remote URL
118    fn generate_repo_id(repo_path: &Path) -> Result<String> {
119        use std::process::Command;
120
121        let output = Command::new("git")
122            .args(["remote", "get-url", "origin"])
123            .current_dir(repo_path)
124            .output()
125            .map_err(|e| CascadeError::config(format!("Failed to get remote URL: {e}")))?;
126
127        if !output.status.success() {
128            // Fallback to absolute path hash if no remote
129            use sha2::{Digest, Sha256};
130            let canonical_path = repo_path
131                .canonicalize()
132                .unwrap_or_else(|_| repo_path.to_path_buf());
133            let path_str = canonical_path.to_string_lossy();
134            let mut hasher = Sha256::new();
135            hasher.update(path_str.as_bytes());
136            let result = hasher.finalize();
137            let hash = format!("{result:x}");
138            return Ok(format!("local-{}", &hash[..8]));
139        }
140
141        let remote_url = String::from_utf8_lossy(&output.stdout).trim().to_string();
142
143        // Convert URL to safe directory name
144        // e.g., https://github.com/user/repo.git -> github.com-user-repo
145        let safe_name = remote_url
146            .replace("https://", "")
147            .replace("http://", "")
148            .replace("git@", "")
149            .replace("ssh://", "")
150            .replace(".git", "")
151            .replace([':', '/', '\\'], "-")
152            .chars()
153            .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '.' || *c == '_')
154            .collect::<String>();
155
156        Ok(safe_name)
157    }
158
159    /// Get the Cascade-specific hooks directory for this repo
160    fn get_cascade_hooks_dir(&self) -> Result<PathBuf> {
161        let home = dirs::home_dir()
162            .ok_or_else(|| CascadeError::config("Could not find home directory".to_string()))?;
163        let cascade_hooks = home.join(".cascade").join("hooks").join(&self.repo_id);
164        Ok(cascade_hooks)
165    }
166
167    /// Get the Cascade config directory for this repo
168    fn get_cascade_config_dir(&self) -> Result<PathBuf> {
169        let home = dirs::home_dir()
170            .ok_or_else(|| CascadeError::config("Could not find home directory".to_string()))?;
171        let cascade_config = home.join(".cascade").join("config").join(&self.repo_id);
172        Ok(cascade_config)
173    }
174
175    /// Save the current core.hooksPath value for later restoration
176    fn save_original_hooks_path(&self) -> Result<()> {
177        use std::process::Command;
178
179        let config_dir = self.get_cascade_config_dir()?;
180        fs::create_dir_all(&config_dir)
181            .map_err(|e| CascadeError::config(format!("Failed to create config directory: {e}")))?;
182
183        let original_path_file = config_dir.join("original-hooks-path");
184
185        // Check if file exists and if it's corrupted (pointing to Cascade's own directory)
186        if original_path_file.exists() {
187            // Self-healing: verify the saved path isn't Cascade's own directory
188            if let Ok(saved_path) = fs::read_to_string(&original_path_file) {
189                let saved_path = saved_path.trim();
190                let cascade_hooks_dir = dirs::home_dir()
191                    .ok_or_else(|| {
192                        CascadeError::config("Could not find home directory".to_string())
193                    })?
194                    .join(".cascade")
195                    .join("hooks")
196                    .join(&self.repo_id);
197                let cascade_hooks_path = cascade_hooks_dir.to_string_lossy().to_string();
198
199                if saved_path == cascade_hooks_path {
200                    // CORRUPTED: File contains Cascade's own path - fix it!
201                    fs::write(&original_path_file, "").map_err(|e| {
202                        CascadeError::config(format!("Failed to fix corrupted hooks path: {e}"))
203                    })?;
204                    return Ok(());
205                }
206            }
207            // File exists and is valid - don't overwrite
208            return Ok(());
209        }
210
211        let output = Command::new("git")
212            .args(["config", "--get", "core.hooksPath"])
213            .current_dir(&self.repo_path)
214            .output()
215            .map_err(|e| CascadeError::config(format!("Failed to check git config: {e}")))?;
216
217        let original_path = if output.status.success() {
218            let current_hooks_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
219
220            // Don't save Cascade's own hooks directory as "original"
221            // This prevents infinite loops in hook chaining
222            let cascade_hooks_dir = dirs::home_dir()
223                .ok_or_else(|| CascadeError::config("Could not find home directory".to_string()))?
224                .join(".cascade")
225                .join("hooks")
226                .join(&self.repo_id);
227            let cascade_hooks_path = cascade_hooks_dir.to_string_lossy().to_string();
228
229            if current_hooks_path == cascade_hooks_path {
230                // Already pointing to Cascade hooks - no original to save
231                String::new()
232            } else {
233                current_hooks_path
234            }
235        } else {
236            // Empty string means it wasn't set
237            String::new()
238        };
239
240        fs::write(original_path_file, original_path).map_err(|e| {
241            CascadeError::config(format!("Failed to save original hooks path: {e}"))
242        })?;
243
244        Ok(())
245    }
246
247    /// Restore the original core.hooksPath value
248    fn restore_original_hooks_path(&self) -> Result<()> {
249        use std::process::Command;
250
251        let config_dir = self.get_cascade_config_dir()?;
252        let original_path_file = config_dir.join("original-hooks-path");
253
254        if !original_path_file.exists() {
255            // Nothing to restore
256            return Ok(());
257        }
258
259        let original_path = fs::read_to_string(&original_path_file).map_err(|e| {
260            CascadeError::config(format!("Failed to read original hooks path: {e}"))
261        })?;
262
263        if original_path.is_empty() {
264            // It wasn't set originally, so unset it
265            Command::new("git")
266                .args(["config", "--unset", "core.hooksPath"])
267                .current_dir(&self.repo_path)
268                .output()
269                .map_err(|e| {
270                    CascadeError::config(format!("Failed to unset core.hooksPath: {e}"))
271                })?;
272        } else {
273            // Restore the original value
274            Command::new("git")
275                .args(["config", "core.hooksPath", &original_path])
276                .current_dir(&self.repo_path)
277                .output()
278                .map_err(|e| {
279                    CascadeError::config(format!("Failed to restore core.hooksPath: {e}"))
280                })?;
281        }
282
283        // Clean up the saved file
284        fs::remove_file(original_path_file).ok();
285
286        Ok(())
287    }
288
289    /// Get the actual hooks directory path, respecting core.hooksPath configuration
290    #[allow(dead_code)]
291    fn get_hooks_path(repo_path: &Path) -> Result<PathBuf> {
292        use std::process::Command;
293
294        // Try to get core.hooksPath configuration
295        let output = Command::new("git")
296            .args(["config", "--get", "core.hooksPath"])
297            .current_dir(repo_path)
298            .output()
299            .map_err(|e| CascadeError::config(format!("Failed to check git config: {e}")))?;
300
301        let default_hooks_dir = || -> PathBuf {
302            let repo = git2::Repository::discover(repo_path).ok();
303            repo.map(|r| r.commondir().join("hooks"))
304                .unwrap_or_else(|| repo_path.join(".git").join("hooks"))
305        };
306
307        let hooks_path = if output.status.success() {
308            let configured_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
309            if configured_path.is_empty() {
310                // Empty value means default
311                default_hooks_dir()
312            } else if configured_path.starts_with('/') {
313                // Absolute path
314                PathBuf::from(configured_path)
315            } else {
316                // Relative path - relative to repo root
317                repo_path.join(configured_path)
318            }
319        } else {
320            // No core.hooksPath configured, use default
321            default_hooks_dir()
322        };
323
324        Ok(hooks_path)
325    }
326
327    /// Install all recommended Cascade hooks
328    pub fn install_all(&self) -> Result<()> {
329        self.install_with_options(&InstallOptions::default())
330    }
331
332    /// Install only essential hooks (for setup) - excludes post-commit
333    pub fn install_essential(&self) -> Result<()> {
334        Output::progress("Installing essential Cascade Git hooks");
335
336        let essential_hooks = vec![
337            HookType::PrePush,
338            HookType::CommitMsg,
339            HookType::PrepareCommitMsg,
340            HookType::PreCommit,
341        ];
342
343        for hook in essential_hooks {
344            self.install_hook(&hook)?;
345        }
346
347        Output::success("Essential Cascade hooks installed successfully!");
348        Output::tip("Note: Post-commit auto-add hook available with 'ca hooks install --all'");
349        Output::section("Hooks installed");
350        self.list_installed_hooks()?;
351
352        Ok(())
353    }
354
355    /// Install hooks with smart validation options
356    pub fn install_with_options(&self, options: &InstallOptions) -> Result<()> {
357        if options.check_prerequisites && !options.force {
358            self.validate_prerequisites()?;
359        }
360
361        if options.feature_branches_only && !options.force {
362            self.validate_branch_suitability()?;
363        }
364
365        if options.confirm && !options.force {
366            self.confirm_installation()?;
367        }
368
369        Output::progress("Installing all Cascade Git hooks");
370
371        // Install ALL hooks (all 5 HookType variants)
372        let hooks = vec![
373            HookType::PostCommit,
374            HookType::PrePush,
375            HookType::CommitMsg,
376            HookType::PrepareCommitMsg,
377            HookType::PreCommit,
378        ];
379
380        for hook in hooks {
381            self.install_hook(&hook)?;
382        }
383
384        Output::success("All Cascade hooks installed successfully!");
385        Output::section("Hooks installed");
386        self.list_installed_hooks()?;
387
388        Ok(())
389    }
390
391    /// Install a specific hook
392    pub fn install_hook(&self, hook_type: &HookType) -> Result<()> {
393        // Ensure we've saved the original hooks path first
394        self.save_original_hooks_path()?;
395
396        // Create cascade hooks directory
397        let cascade_hooks_dir = self.get_cascade_hooks_dir()?;
398        fs::create_dir_all(&cascade_hooks_dir).map_err(|e| {
399            CascadeError::config(format!("Failed to create cascade hooks directory: {e}"))
400        })?;
401
402        // Generate hook that chains to original
403        let hook_content = self.generate_chaining_hook_script(hook_type)?;
404        let hook_path = cascade_hooks_dir.join(hook_type.filename());
405
406        // Write the hook
407        fs::write(&hook_path, hook_content)
408            .map_err(|e| CascadeError::config(format!("Failed to write hook file: {e}")))?;
409
410        // Make executable (platform-specific)
411        crate::utils::platform::make_executable(&hook_path)
412            .map_err(|e| CascadeError::config(format!("Failed to make hook executable: {e}")))?;
413
414        // Set core.hooksPath to our cascade directory
415        self.set_cascade_hooks_path()?;
416
417        Output::success(format!("Installed {} hook", hook_type.filename()));
418        Ok(())
419    }
420
421    /// Set git's core.hooksPath to our cascade hooks directory
422    fn set_cascade_hooks_path(&self) -> Result<()> {
423        use std::process::Command;
424
425        let cascade_hooks_dir = self.get_cascade_hooks_dir()?;
426        let hooks_path_str = cascade_hooks_dir.to_string_lossy();
427
428        let output = Command::new("git")
429            .args(["config", "core.hooksPath", &hooks_path_str])
430            .current_dir(&self.repo_path)
431            .output()
432            .map_err(|e| CascadeError::config(format!("Failed to set core.hooksPath: {e}")))?;
433
434        if !output.status.success() {
435            return Err(CascadeError::config(format!(
436                "Failed to set core.hooksPath: {}",
437                String::from_utf8_lossy(&output.stderr)
438            )));
439        }
440
441        Ok(())
442    }
443
444    /// Remove all Cascade hooks
445    pub fn uninstall_all(&self) -> Result<()> {
446        Output::progress("Removing Cascade Git hooks");
447
448        // Restore original core.hooksPath
449        self.restore_original_hooks_path()?;
450
451        // Clean up cascade hooks directory
452        let cascade_hooks_dir = self.get_cascade_hooks_dir()?;
453        if cascade_hooks_dir.exists() {
454            fs::remove_dir_all(&cascade_hooks_dir).map_err(|e| {
455                CascadeError::config(format!("Failed to remove cascade hooks directory: {e}"))
456            })?;
457        }
458
459        // Also clean up any old Cascade hooks in .git/hooks/ (from before core.hooksPath)
460        // These might have been left behind from earlier versions
461        // IMPORTANT: Only remove hooks that have our EXACT wrapper pattern to avoid
462        // deleting user's custom hooks that might mention "Cascade"
463        let git_hooks_dir = {
464            let repo = git2::Repository::discover(&self.repo_path).ok();
465            repo.map(|r| r.commondir().join("hooks"))
466                .unwrap_or_else(|| self.repo_path.join(".git").join("hooks"))
467        };
468        if git_hooks_dir.exists() {
469            for hook_type in &[
470                HookType::PostCommit,
471                HookType::PrePush,
472                HookType::CommitMsg,
473                HookType::PrepareCommitMsg,
474                HookType::PreCommit,
475            ] {
476                let hook_path = git_hooks_dir.join(hook_type.filename());
477                if hook_path.exists() {
478                    // Check if it's a Cascade WRAPPER hook by looking for our exact wrapper signature
479                    // This is different from user hooks that might just mention Cascade
480                    if let Ok(content) = fs::read_to_string(&hook_path) {
481                        if content.contains("# Cascade CLI Hook Wrapper")
482                            && content.contains("cascade_logic()")
483                            && content.contains("# Function to run Cascade logic")
484                        {
485                            debug!(
486                                "Removing old Cascade wrapper hook from .git/hooks: {:?}",
487                                hook_path
488                            );
489                            fs::remove_file(&hook_path).ok(); // Ignore errors
490                        }
491                    }
492                }
493            }
494        }
495
496        // Clean up config directory if empty
497        let cascade_config_dir = self.get_cascade_config_dir()?;
498        if cascade_config_dir.exists() {
499            // Try to remove, but ignore if not empty
500            fs::remove_dir(&cascade_config_dir).ok();
501        }
502
503        Output::success("All Cascade hooks removed!");
504        Ok(())
505    }
506
507    /// Remove a specific hook
508    pub fn uninstall_hook(&self, hook_type: &HookType) -> Result<()> {
509        let cascade_hooks_dir = self.get_cascade_hooks_dir()?;
510        let hook_path = cascade_hooks_dir.join(hook_type.filename());
511
512        if hook_path.exists() {
513            fs::remove_file(&hook_path)
514                .map_err(|e| CascadeError::config(format!("Failed to remove hook file: {e}")))?;
515            Output::success(format!("Removed {} hook", hook_type.filename()));
516
517            // If no more hooks in cascade directory, restore original hooks path
518            let remaining_hooks = fs::read_dir(&cascade_hooks_dir)
519                .map_err(|e| CascadeError::config(format!("Failed to read hooks directory: {e}")))?
520                .filter_map(|entry| entry.ok())
521                .filter(|entry| {
522                    entry.path().is_file() && !entry.file_name().to_string_lossy().starts_with('.')
523                })
524                .count();
525
526            if remaining_hooks == 0 {
527                Output::info(
528                    "No more Cascade hooks installed, restoring original hooks configuration",
529                );
530                self.restore_original_hooks_path()?;
531                fs::remove_dir(&cascade_hooks_dir).ok();
532            }
533        } else {
534            Output::info(format!("{} hook not found", hook_type.filename()));
535        }
536
537        Ok(())
538    }
539
540    /// List all installed hooks and their status
541    pub fn list_installed_hooks(&self) -> Result<()> {
542        let hooks = vec![
543            HookType::PostCommit,
544            HookType::PrePush,
545            HookType::CommitMsg,
546            HookType::PrepareCommitMsg,
547            HookType::PreCommit,
548        ];
549
550        Output::section("Git Hooks Status");
551
552        // Check if we're using cascade hooks directory
553        let cascade_hooks_dir = self.get_cascade_hooks_dir()?;
554        let using_cascade_hooks = cascade_hooks_dir.exists()
555            && self.get_current_hooks_path()?
556                == Some(cascade_hooks_dir.to_string_lossy().to_string());
557
558        if using_cascade_hooks {
559            Output::success("✓ Cascade hooks are installed and active");
560            Output::info(format!(
561                "  Hooks directory: {}",
562                cascade_hooks_dir.display()
563            ));
564
565            // Check what original hooks path was saved
566            let config_dir = self.get_cascade_config_dir()?;
567            let original_path_file = config_dir.join("original-hooks-path");
568            if original_path_file.exists() {
569                let original_path = fs::read_to_string(original_path_file).unwrap_or_default();
570                if !original_path.is_empty() {
571                    Output::info(format!("  Chaining to original hooks: {original_path}"));
572                } else {
573                    Output::info("  Chaining to original hooks: .git/hooks");
574                }
575            }
576            println!();
577        } else {
578            Output::warning("Cascade hooks are NOT installed in this repository");
579            println!();
580            Output::sub_item("To install Cascade hooks:");
581            Output::command_example("ca hooks install            # recommended: 4 essential hooks");
582            Output::command_example(
583                "ca hooks install --all      # all 5 hooks + post-commit auto-add",
584            );
585            println!();
586            Output::sub_item("Both options preserve existing hooks by chaining to them");
587            println!();
588        }
589
590        for hook in hooks {
591            let cascade_hook_path = cascade_hooks_dir.join(hook.filename());
592
593            if using_cascade_hooks && cascade_hook_path.exists() {
594                Output::success(format!("{}: {} ✓", hook.filename(), hook.description()));
595            } else {
596                // Check default location
597                let default_hook_path = {
598                    let repo = git2::Repository::discover(&self.repo_path).ok();
599                    repo.map(|r| r.commondir().join("hooks"))
600                        .unwrap_or_else(|| self.repo_path.join(".git").join("hooks"))
601                }
602                .join(hook.filename());
603                if default_hook_path.exists() {
604                    Output::warning(format!(
605                        "{}: {} (In .git/hooks, not managed by Cascade)",
606                        hook.filename(),
607                        hook.description()
608                    ));
609                } else {
610                    Output::error(format!(
611                        "{}: {} (Not installed)",
612                        hook.filename(),
613                        hook.description()
614                    ));
615                }
616            }
617        }
618
619        Ok(())
620    }
621
622    /// Get the current core.hooksPath value
623    fn get_current_hooks_path(&self) -> Result<Option<String>> {
624        use std::process::Command;
625
626        let output = Command::new("git")
627            .args(["config", "--get", "core.hooksPath"])
628            .current_dir(&self.repo_path)
629            .output()
630            .map_err(|e| CascadeError::config(format!("Failed to check git config: {e}")))?;
631
632        if output.status.success() {
633            let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
634            if path.is_empty() {
635                Ok(None)
636            } else {
637                Ok(Some(path))
638            }
639        } else {
640            Ok(None)
641        }
642    }
643
644    /// Generate hook script content
645    pub fn generate_hook_script(&self, hook_type: &HookType) -> Result<String> {
646        let cascade_cli = env::current_exe()
647            .map_err(|e| {
648                CascadeError::config(format!("Failed to get current executable path: {e}"))
649            })?
650            .to_string_lossy()
651            .to_string();
652
653        let script = match hook_type {
654            HookType::PostCommit => self.generate_post_commit_hook(&cascade_cli),
655            HookType::PrePush => self.generate_pre_push_hook(&cascade_cli),
656            HookType::CommitMsg => self.generate_commit_msg_hook(&cascade_cli),
657            HookType::PreCommit => self.generate_pre_commit_hook(&cascade_cli),
658            HookType::PrepareCommitMsg => self.generate_prepare_commit_msg_hook(&cascade_cli),
659        };
660
661        Ok(script)
662    }
663
664    /// Generate hook script that chains to original hooks
665    pub fn generate_chaining_hook_script(&self, hook_type: &HookType) -> Result<String> {
666        let cascade_cli = env::current_exe()
667            .map_err(|e| {
668                CascadeError::config(format!("Failed to get current executable path: {e}"))
669            })?
670            .to_string_lossy()
671            .to_string();
672
673        let config_dir = self.get_cascade_config_dir()?;
674        let hook_name = match hook_type {
675            HookType::PostCommit => "post-commit",
676            HookType::PrePush => "pre-push",
677            HookType::CommitMsg => "commit-msg",
678            HookType::PreCommit => "pre-commit",
679            HookType::PrepareCommitMsg => "prepare-commit-msg",
680        };
681
682        // Generate the cascade-specific hook logic
683        let cascade_logic = match hook_type {
684            HookType::PostCommit => self.generate_post_commit_hook(&cascade_cli),
685            HookType::PrePush => self.generate_pre_push_hook(&cascade_cli),
686            HookType::CommitMsg => self.generate_commit_msg_hook(&cascade_cli),
687            HookType::PreCommit => self.generate_pre_commit_hook(&cascade_cli),
688            HookType::PrepareCommitMsg => self.generate_prepare_commit_msg_hook(&cascade_cli),
689        };
690
691        // Create wrapper that chains to original
692        #[cfg(windows)]
693        return Ok(format!(
694                "@echo off\n\
695                 rem Cascade CLI Hook Wrapper - {}\n\
696                 rem This hook runs Cascade logic first, then chains to original hooks\n\n\
697                 rem Run Cascade logic first\n\
698                 call :cascade_logic %*\n\
699                 set CASCADE_RESULT=%ERRORLEVEL%\n\
700                 if %CASCADE_RESULT% neq 0 exit /b %CASCADE_RESULT%\n\n\
701                 rem Check for original hook\n\
702                 set ORIGINAL_HOOKS_PATH=\n\
703                 if exist \"{}\\original-hooks-path\" (\n\
704                     set /p ORIGINAL_HOOKS_PATH=<\"{}\\original-hooks-path\"\n\
705                 )\n\n\
706                 if \"%ORIGINAL_HOOKS_PATH%\"==\"\" (\n\
707                     rem Default location\n\
708                     for /f \"tokens=*\" %%i in ('git rev-parse --git-dir 2^>nul') do set GIT_DIR=%%i\n\
709                     if exist \"%GIT_DIR%\\hooks\\{}\" (\n\
710                         call \"%GIT_DIR%\\hooks\\{}\" %*\n\
711                         exit /b %ERRORLEVEL%\n\
712                     )\n\
713                 ) else (\n\
714                     rem Custom hooks path\n\
715                     if exist \"%ORIGINAL_HOOKS_PATH%\\{}\" (\n\
716                         call \"%ORIGINAL_HOOKS_PATH%\\{}\" %*\n\
717                         exit /b %ERRORLEVEL%\n\
718                     )\n\
719                 )\n\n\
720                 exit /b 0\n\n\
721                 :cascade_logic\n\
722                 {}\n\
723                 exit /b %ERRORLEVEL%\n",
724                hook_name,
725                config_dir.to_string_lossy(),
726                config_dir.to_string_lossy(),
727                hook_name,
728                hook_name,
729                hook_name,
730                hook_name,
731                cascade_logic
732            ));
733
734        #[cfg(not(windows))]
735        {
736            // Build the wrapper using string concatenation to avoid double-escaping issues
737            let trimmed_logic = cascade_logic
738                .trim_start_matches("#!/bin/sh\n")
739                .trim_start_matches("set -e\n");
740
741            let wrapper = format!(
742                "#!/bin/sh\n\
743                 # Cascade CLI Hook Wrapper - {}\n\
744                 # This hook runs Cascade logic first, then chains to original hooks\n\n\
745                 set -e\n\n\
746                 # Function to run Cascade logic\n\
747                 cascade_logic() {{\n",
748                hook_name
749            );
750
751            let chaining_logic = format!(
752                "\n\
753                 }}\n\n\
754                 # Run Cascade logic first\n\
755                 cascade_logic \"$@\"\n\
756                 CASCADE_RESULT=$?\n\
757                 if [ $CASCADE_RESULT -ne 0 ]; then\n\
758                     exit $CASCADE_RESULT\n\
759                 fi\n\n\
760                 # Check for original hook\n\
761                 ORIGINAL_HOOKS_PATH=\"\"\n\
762                 if [ -f \"{}/original-hooks-path\" ]; then\n\
763                     ORIGINAL_HOOKS_PATH=$(cat \"{}/original-hooks-path\" 2>/dev/null || echo \"\")\n\
764                 fi\n\n\
765                 if [ -z \"$ORIGINAL_HOOKS_PATH\" ]; then\n\
766                     # Default location\n\
767                     GIT_DIR=$(git rev-parse --git-dir 2>/dev/null || echo \".git\")\n\
768                     ORIGINAL_HOOK=\"$GIT_DIR/hooks/{}\"\n\
769                 else\n\
770                     # Custom hooks path\n\
771                     ORIGINAL_HOOK=\"$ORIGINAL_HOOKS_PATH/{}\"\n\
772                 fi\n\n\
773                 # Run original hook if it exists and is executable\n\
774                 if [ -x \"$ORIGINAL_HOOK\" ]; then\n\
775                     \"$ORIGINAL_HOOK\" \"$@\"\n\
776                     exit $?\n\
777                 fi\n\n\
778                 exit 0\n",
779                config_dir.to_string_lossy(),
780                config_dir.to_string_lossy(),
781                hook_name,
782                hook_name
783            );
784
785            Ok(format!("{}{}{}", wrapper, trimmed_logic, chaining_logic))
786        }
787    }
788
789    fn generate_post_commit_hook(&self, cascade_cli: &str) -> String {
790        #[cfg(windows)]
791        {
792            format!(
793                "@echo off\n\
794                 rem Cascade CLI Hook - Post Commit\n\
795                 rem Automatically adds new commits to the active stack\n\n\
796                 rem Get the commit hash and message\n\
797                 for /f \"tokens=*\" %%i in ('git rev-parse HEAD') do set COMMIT_HASH=%%i\n\
798                 for /f \"tokens=*\" %%i in ('git log --format=%%s -n 1 HEAD') do set COMMIT_MSG=%%i\n\n\
799                 rem Find repository root and check if Cascade is initialized\n\
800                 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
801                 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
802                 if not exist \"%REPO_ROOT%\\.cascade\" (\n\
803                     echo \"Cascade not initialized, skipping stack management\"\n\
804                     echo \"Run 'ca init' to start using stacked diffs\"\n\
805                     exit /b 0\n\
806                 )\n\n\
807                 rem Check if there's an active stack\n\
808                 \"{cascade_cli}\" stack list --active >nul 2>&1\n\
809                 if %ERRORLEVEL% neq 0 (\n\
810                     echo \"No active stack found, commit will not be added to any stack\"\n\
811                     echo \"Tip: Use 'ca stack create ^<name^>' to create a stack for this commit\"\n\
812                     exit /b 0\n\
813                 )\n\n\
814                 rem Add commit to active stack\n\
815                 echo \"Adding commit to active stack...\"\n\
816                 echo \"Commit: %COMMIT_MSG%\"\n\
817                 \"{cascade_cli}\" stack push --commit \"%COMMIT_HASH%\" --message \"%COMMIT_MSG%\"\n\
818                 if %ERRORLEVEL% equ 0 (\n\
819                     echo \"Commit added to stack successfully\"\n\
820                     echo \"Next: 'ca submit' to create PRs when ready\"\n\
821                 ) else (\n\
822                     echo \"Failed to add commit to stack\"\n\
823                     echo \"Tip: You can manually add it with: ca push --commit %COMMIT_HASH%\"\n\
824                 )\n"
825            )
826        }
827
828        #[cfg(not(windows))]
829        {
830            format!(
831                "#!/bin/sh\n\
832                 # Cascade CLI Hook - Post Commit\n\
833                 # Automatically adds new commits to the active stack\n\n\
834                 set -e\n\n\
835                 # Get the commit hash and message\n\
836                 COMMIT_HASH=$(git rev-parse HEAD)\n\
837                 COMMIT_MSG=$(git log --format=%s -n 1 HEAD)\n\n\
838                 # Find repository root and check if Cascade is initialized\n\
839                 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
840                 if [ ! -d \"$REPO_ROOT/.cascade\" ]; then\n\
841                     echo \"Cascade not initialized, skipping stack management\"\n\
842                     echo \"Run 'ca init' to start using stacked diffs\"\n\
843                     exit 0\n\
844                 fi\n\n\
845                 # Check if there's an active stack\n\
846                 if ! \"{cascade_cli}\" stack list --active > /dev/null 2>&1; then\n\
847                     echo \"No active stack found, commit will not be added to any stack\"\n\
848                     echo \"Tip: Use 'ca stack create <name>' to create a stack for this commit\"\n\
849                     exit 0\n\
850                 fi\n\n\
851                 # Add commit to active stack (using specific commit targeting)\n\
852                 echo \"Adding commit to active stack...\"\n\
853                 echo \"Commit: $COMMIT_MSG\"\n\
854                 if \"{cascade_cli}\" stack push --commit \"$COMMIT_HASH\" --message \"$COMMIT_MSG\"; then\n\
855                     echo \"Commit added to stack successfully\"\n\
856                     echo \"Next: 'ca submit' to create PRs when ready\"\n\
857                 else\n\
858                     echo \"Failed to add commit to stack\"\n\
859                     echo \"Tip: You can manually add it with: ca push --commit $COMMIT_HASH\"\n\
860                 fi\n"
861            )
862        }
863    }
864
865    fn generate_pre_push_hook(&self, cascade_cli: &str) -> String {
866        #[cfg(windows)]
867        {
868            format!(
869                "@echo off\n\
870                 rem Cascade CLI Hook - Pre Push\n\
871                 rem Prevents force pushes and validates stack state\n\n\
872                 rem Allow force pushes from Cascade internal commands (ca sync, ca submit, etc.)\n\
873                 rem Check for marker file (Git hooks don't inherit env vars)\n\
874                 for /f \"tokens=*\" %%i in ('git rev-parse --git-dir 2^>nul') do set GIT_DIR=%%i\n\
875                 if \"%GIT_DIR%\"==\"\" set GIT_DIR=.git\n\
876                 if exist \"%GIT_DIR%\\.cascade-internal-push\" (\n\
877                     exit /b 0\n\
878                 )\n\n\
879                 rem Check for force push from user\n\
880                 echo %* | findstr /C:\"--force\" /C:\"--force-with-lease\" /C:\"-f\" >nul\n\
881                 if %ERRORLEVEL% equ 0 (\n\
882                     echo ERROR: Force push detected\n\
883                     echo Cascade CLI uses stacked diffs - force pushes can break stack integrity\n\
884                     echo.\n\
885                     echo Instead, try these commands:\n\
886                     echo   ca sync      - Sync with remote changes ^(handles rebasing^)\n\
887                     echo   ca push      - Push all unpushed commits\n\
888                     echo   ca submit    - Submit all entries for review\n\
889                     echo   ca autoland  - Auto-merge when approved + builds pass\n\
890                     echo.\n\
891                     echo If you really need to force push:\n\
892                     echo   git push --force-with-lease [remote] [branch]\n\
893                     echo   ^(But consider if this will affect other stack entries^)\n\
894                     exit /b 1\n\
895                 )\n\n\
896                 rem Find repository root and check if Cascade is initialized\n\
897                 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
898                 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
899                 if not exist \"%REPO_ROOT%\\.cascade\" (\n\
900                     exit /b 0\n\
901                 )\n\n\
902                 rem Validate stack state (silent unless error)\n\
903                 \"{cascade_cli}\" validate >nul 2>&1\n\
904                 if %ERRORLEVEL% neq 0 (\n\
905                     echo Stack validation failed - run 'ca validate' for details\n\
906                     exit /b 1\n\
907                 )\n"
908            )
909        }
910
911        #[cfg(not(windows))]
912        {
913            format!(
914                "#!/bin/sh\n\
915                 # Cascade CLI Hook - Pre Push\n\
916                 # Prevents force pushes and validates stack state\n\n\
917                 set -e\n\n\
918                 # Allow force pushes from Cascade internal commands (ca sync, ca submit, etc.)\n\
919                 # Check for marker file (Git hooks don't inherit env vars)\n\
920                 GIT_DIR=$(git rev-parse --git-dir 2>/dev/null || echo \".git\")\n\
921                 if [ -f \"$GIT_DIR/.cascade-internal-push\" ]; then\n\
922                     exit 0\n\
923                 fi\n\n\
924                 # Check for force push from user\n\
925                 if echo \"$*\" | grep -q -- \"--force\\|--force-with-lease\\|-f\"; then\n\
926                     echo \"ERROR: Force push detected\"\n\
927                     echo \"Cascade CLI uses stacked diffs - force pushes can break stack integrity\"\n\
928                     echo \"\"\n\
929                     echo \"Instead, try these commands:\"\n\
930                     echo \"  ca sync      - Sync with remote changes (handles rebasing)\"\n\
931                     echo \"  ca push      - Push all unpushed commits\"\n\
932                     echo \"  ca submit    - Submit all entries for review\"\n\
933                     echo \"  ca autoland  - Auto-merge when approved + builds pass\"\n\
934                     echo \"\"\n\
935                     echo \"If you really need to force push:\"\n\
936                     echo \"  git push --force-with-lease [remote] [branch]\"\n\
937                     echo \"  (But consider if this will affect other stack entries)\"\n\
938                     exit 1\n\
939                 fi\n\n\
940                 # Find repository root and check if Cascade is initialized\n\
941                 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
942                 if [ ! -d \"$REPO_ROOT/.cascade\" ]; then\n\
943                     exit 0\n\
944                 fi\n\n\
945                 # Validate stack state (silent unless error)\n\
946                 if ! \"{cascade_cli}\" validate > /dev/null 2>&1; then\n\
947                     echo \"Stack validation failed - run 'ca validate' for details\"\n\
948                     exit 1\n\
949                     fi\n"
950            )
951        }
952    }
953
954    fn generate_commit_msg_hook(&self, _cascade_cli: &str) -> String {
955        #[cfg(windows)]
956        {
957            r#"@echo off
958rem Cascade CLI Hook - Commit Message
959rem Validates commit message format
960
961set COMMIT_MSG_FILE=%1
962if "%COMMIT_MSG_FILE%"=="" (
963    echo ERROR: No commit message file provided
964    exit /b 1
965)
966
967rem Read commit message (Windows batch is limited, but this covers basic cases)
968for /f "delims=" %%i in ('type "%COMMIT_MSG_FILE%"') do set COMMIT_MSG=%%i
969
970rem Skip validation for merge commits, fixup commits, etc.
971echo %COMMIT_MSG% | findstr /B /C:"Merge" /C:"Revert" /C:"fixup!" /C:"squash!" >nul
972if %ERRORLEVEL% equ 0 exit /b 0
973
974rem Find repository root and check if Cascade is initialized
975for /f "tokens=*" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i
976if "%REPO_ROOT%"=="" set REPO_ROOT=.
977if not exist "%REPO_ROOT%\.cascade" exit /b 0
978
979rem Basic commit message validation
980echo %COMMIT_MSG% | findstr /R "^..........*" >nul
981if %ERRORLEVEL% neq 0 (
982    echo ERROR: Commit message too short (minimum 10 characters)
983    echo TIP: Write a descriptive commit message for better stack management
984    exit /b 1
985)
986
987rem Validation passed (silent success)
988exit /b 0
989"#
990            .to_string()
991        }
992
993        #[cfg(not(windows))]
994        {
995            r#"#!/bin/sh
996# Cascade CLI Hook - Commit Message
997# Validates commit message format
998
999set -e
1000
1001COMMIT_MSG_FILE="$1"
1002COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
1003
1004# Skip validation for merge commits, fixup commits, etc.
1005if echo "$COMMIT_MSG" | grep -E "^(Merge|Revert|fixup!|squash!)" > /dev/null; then
1006    exit 0
1007fi
1008
1009# Find repository root and check if Cascade is initialized
1010REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")
1011if [ ! -d "$REPO_ROOT/.cascade" ]; then
1012    exit 0
1013fi
1014
1015# Basic commit message validation
1016if [ ${#COMMIT_MSG} -lt 10 ]; then
1017    echo "ERROR: Commit message too short (minimum 10 characters)"
1018    echo "TIP: Write a descriptive commit message for better stack management"
1019    exit 1
1020fi
1021
1022# Validation passed (silent success)
1023exit 0
1024"#
1025            .to_string()
1026        }
1027    }
1028
1029    #[allow(clippy::uninlined_format_args)]
1030    fn generate_pre_commit_hook(&self, cascade_cli: &str) -> String {
1031        #[cfg(windows)]
1032        {
1033            format!(
1034                "@echo off\n\
1035                 rem Cascade CLI Hook - Pre Commit\n\
1036                 rem Smart edit mode guidance for better UX\n\n\
1037                 rem Check if Cascade is initialized\n\
1038                 for /f \\\"tokens=*\\\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
1039                 if \\\"%REPO_ROOT%\\\"==\\\"\\\" set REPO_ROOT=.\n\
1040                 if not exist \\\"%REPO_ROOT%\\.cascade\\\" exit /b 0\n\n\
1041                 rem Skip hook if called from ca entry amend ^(avoid infinite loop^)\n\
1042                 if \\\"%CASCADE_SKIP_HOOKS%\\\"==\\\"1\\\" exit /b 0\n\n\
1043                 rem Skip hook during cherry-pick/rebase/merge operations\n\
1044                 for /f \\\"tokens=*\\\" %%i in ('git rev-parse --git-dir 2^>nul') do set GIT_DIR=%%i\n\
1045                 if \\\"%GIT_DIR%\\\"==\\\"\\\" set GIT_DIR=.git\n\
1046                 if exist \\\"%GIT_DIR%\\CHERRY_PICK_HEAD\\\" exit /b 0\n\
1047                 if exist \\\"%GIT_DIR%\\REBASE_HEAD\\\" exit /b 0\n\
1048                 if exist \\\"%GIT_DIR%\\MERGE_HEAD\\\" exit /b 0\n\n\
1049                 rem Get edit status\n\
1050                 for /f \\\"tokens=*\\\" %%i in ('\\\"{0}\\\" entry status --quiet 2^>nul') do set EDIT_STATUS=%%i\n\
1051                 if \\\"%EDIT_STATUS%\\\"==\\\"\\\" set EDIT_STATUS=inactive\n\n\
1052                 rem Check if edit status is active\n\
1053                 echo %EDIT_STATUS% | findstr /b \\\"active:\\\" >nul\n\
1054                 if %ERRORLEVEL% equ 0 (\n\
1055                     echo You're in EDIT MODE for a stack entry\n\
1056                     echo.\n\
1057                     echo Choose your action:\n\
1058                     echo   [a] amend: Modify the current entry ^(default^)\n\
1059                     echo   [n] new:   Create new entry on top\n\
1060                     echo   [c] cancel: Stop and think about it\n\
1061                     echo.\n\
1062                     set /p choice=\\\"Your choice (a/n/c): \\\" <CON\n\
1063                     if \\\"%choice%\\\"==\\\"\\\" set choice=a\n\
1064                     \n\
1065                    if /i \\\"%choice%\\\"==\\\"A\\\" (\n\
1066                        rem Use ca entry amend to update entry ^(ignore any -m flag^)\n\
1067                        rem Changes are already staged by git commit; --restack updates dependents\n\
1068                        \\\"{0}\\\" entry amend --restack\n\
1069                        set amend_error=%ERRORLEVEL%\n\
1070                        if %amend_error% EQU 0 (\n\
1071                            echo Amend applied - skipping git commit to avoid duplicate entry.\n\
1072                            echo Your commit was updated by Cascade; no further action needed.\n\
1073                            exit /b 1\n\
1074                        ) else (\n\
1075                            exit /b %amend_error%\n\
1076                        )\n\
1077                    ) else if /i \\\"%choice%\\\"==\\\"N\\\" (\n\
1078                         echo Creating new stack entry...\n\
1079                         echo The commit will proceed and post-commit hook will add it to your stack\n\
1080                         rem Let commit proceed ^(Git will use -m flag or open editor^)\n\
1081                         exit /b 0\n\
1082                     ) else if /i \\\"%choice%\\\"==\\\"C\\\" (\n\
1083                         echo Commit cancelled\n\
1084                         exit /b 1\n\
1085                     ) else (\n\
1086                         echo Invalid choice. Please choose A, n, or c\n\
1087                         exit /b 1\n\
1088                     )\n\
1089                 )\n\n\
1090                 rem Not in edit mode, proceed normally\n\
1091                 exit /b 0\n",
1092                cascade_cli
1093            )
1094        }
1095
1096        #[cfg(not(windows))]
1097        {
1098            // Use string building to avoid escaping issues with format! macros
1099            // Check the OUTPUT of entry status, not just exit code
1100            let status_check = format!(
1101                "EDIT_STATUS=$(\"{}\" entry status --quiet 2>/dev/null || echo \"inactive\")",
1102                cascade_cli
1103            );
1104            // When called from pre-commit hook during 'git commit', changes are already staged
1105            // So we DON'T use --all flag, just amend with what's staged
1106            let amend_line = format!("           \"{}\" entry amend --restack", cascade_cli);
1107
1108            vec![
1109                "#!/bin/sh".to_string(),
1110                "# Cascade CLI Hook - Pre Commit".to_string(),
1111                "# Smart edit mode guidance for better UX".to_string(),
1112                "".to_string(),
1113                "set -e".to_string(),
1114                "".to_string(),
1115                "# Check if Cascade is initialized".to_string(),
1116                r#"REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")"#.to_string(),
1117                r#"if [ ! -d "$REPO_ROOT/.cascade" ]; then"#.to_string(),
1118                "    exit 0".to_string(),
1119                "fi".to_string(),
1120                "".to_string(),
1121                "# Skip hook if called from ca entry amend (avoid infinite loop)".to_string(),
1122                r#"if [ "$CASCADE_SKIP_HOOKS" = "1" ]; then"#.to_string(),
1123                "    exit 0".to_string(),
1124                "fi".to_string(),
1125                "".to_string(),
1126                "# Skip hook during cherry-pick/rebase/merge operations".to_string(),
1127                r#"GIT_DIR=$(git rev-parse --git-dir 2>/dev/null || echo ".git")"#.to_string(),
1128                r#"if [ -f "$GIT_DIR/CHERRY_PICK_HEAD" ] || [ -f "$GIT_DIR/REBASE_HEAD" ] || [ -f "$GIT_DIR/MERGE_HEAD" ]; then"#.to_string(),
1129                "    exit 0".to_string(),
1130                "fi".to_string(),
1131                "".to_string(),
1132                "# Check if we're in edit mode".to_string(),
1133                r#"CURRENT_BRANCH=$(git branch --show-current 2>/dev/null)"#.to_string(),
1134                status_check,
1135                "".to_string(),
1136                "# If in edit mode, check if we're on a stack entry branch".to_string(),
1137                r#"if echo "$EDIT_STATUS" | grep -q "^active:"; then"#.to_string(),
1138                "        # Check if current branch is a stack entry branch".to_string(),
1139                format!(r#"        if ! "{}" stacks list --format=json 2>/dev/null | grep -q "\"branch_name\": \"$CURRENT_BRANCH\""; then"#, cascade_cli),
1140                r#"                # Not on a stack entry branch - edit mode is for a different branch"#.to_string(),
1141                r#"                # Silently proceed with normal commit"#.to_string(),
1142                "                exit 0".to_string(),
1143                "        fi".to_string(),
1144                "        ".to_string(),
1145                "        # Proper edit mode - prompt user".to_string(),
1146                r#"        echo "You're in EDIT MODE for a stack entry""#.to_string(),
1147                r#"        echo """#.to_string(),
1148                r#"        echo "Choose your action:""#.to_string(),
1149                r#"        echo "  [a] amend: Modify the current entry (default)""#.to_string(),
1150                r#"        echo "  [n] new:   Create new entry on top""#.to_string(),
1151                r#"        echo "  [c] cancel: Stop and think about it""#.to_string(),
1152                r#"        echo """#.to_string(),
1153                "        ".to_string(),
1154                "        # Read user choice with default to amend".to_string(),
1155                r#"        read -p "Your choice (a/n/c): " choice < /dev/tty"#.to_string(),
1156                "        choice=${choice:-a}".to_string(),
1157                "        ".to_string(),
1158                "        ".to_string(),
1159                r#"        case "$choice" in"#.to_string(),
1160                "            [Aa])".to_string(),
1161                "                # Use ca entry amend to properly update entry + working branch (ignore any -m flag)"
1162                    .to_string(),
1163                "                # Changes are already staged by 'git commit', so no --all flag needed".to_string(),
1164                amend_line.replace("           ", "                "),
1165                "                amend_rc=$?".to_string(),
1166                r#"                if [ $amend_rc -eq 0 ]; then"#.to_string(),
1167                r#"                    echo "Amend applied - skipping git commit to avoid duplicate entry.""#
1168                    .to_string(),
1169                r#"                    echo "Your commit was updated by Cascade; no further action needed.""#
1170                    .to_string(),
1171                "                    exit 1".to_string(),
1172                "                else".to_string(),
1173                "                    exit $amend_rc".to_string(),
1174                "                fi".to_string(),
1175                "                ;;".to_string(),
1176                "            [Nn])".to_string(),
1177                r#"                echo "Creating new stack entry...""#.to_string(),
1178                r#"                echo "The commit will proceed and post-commit hook will add it to your stack""#.to_string(),
1179                "                # Let the commit proceed normally (Git will use -m flag or open editor)"
1180                    .to_string(),
1181                "                exit 0".to_string(),
1182                "                ;;".to_string(),
1183                "            [Cc])".to_string(),
1184                r#"                echo "Commit cancelled""#.to_string(),
1185                "                exit 1".to_string(),
1186                "                ;;".to_string(),
1187                "            *)".to_string(),
1188                r#"                echo "Invalid choice. Please choose A, n, or c""#.to_string(),
1189                "                exit 1".to_string(),
1190                "                ;;".to_string(),
1191                "        esac".to_string(),
1192                "fi".to_string(),
1193                "".to_string(),
1194                "# Not in edit mode, proceed normally".to_string(),
1195                "exit 0".to_string(),
1196            ]
1197            .join("\n")
1198        }
1199    }
1200
1201    fn generate_prepare_commit_msg_hook(&self, cascade_cli: &str) -> String {
1202        #[cfg(windows)]
1203        {
1204            format!(
1205                "@echo off\n\
1206                 rem Cascade CLI Hook - Prepare Commit Message\n\
1207                 rem Adds stack context to commit messages\n\n\
1208                 set COMMIT_MSG_FILE=%1\n\
1209                 set COMMIT_SOURCE=%2\n\
1210                 set COMMIT_SHA=%3\n\n\
1211                 rem Skip if user provided message via -m flag, merge commit, etc.\n\
1212                 if not \"%COMMIT_SOURCE%\"==\"\" exit /b 0\n\n\
1213                 rem Find repository root and check if Cascade is initialized\n\
1214                 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
1215                 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
1216                 if not exist \"%REPO_ROOT%\\.cascade\" exit /b 0\n\n\
1217                 rem Check for active stack\n\
1218                 for /f \"tokens=*\" %%i in ('\"{cascade_cli}\" stack list --active --format=name 2^>nul') do set ACTIVE_STACK=%%i\n\n\
1219                 if not \"%ACTIVE_STACK%\"==\"\" (\n\
1220                     rem Get current commit message\n\
1221                     set /p CURRENT_MSG=<%COMMIT_MSG_FILE%\n\n\
1222                     rem Skip if message already has stack context\n\
1223                     echo !CURRENT_MSG! | findstr \"[stack:\" >nul\n\
1224                     if %ERRORLEVEL% equ 0 exit /b 0\n\n\
1225                     rem Add stack context to commit message\n\
1226                     echo.\n\
1227                     echo # Stack: %ACTIVE_STACK%\n\
1228                     echo # This commit will be added to the active stack automatically.\n\
1229                     echo # Use 'ca stack status' to see the current stack state.\n\
1230                     type \"%COMMIT_MSG_FILE%\"\n\
1231                 ) > \"%COMMIT_MSG_FILE%.tmp\"\n\
1232                 move \"%COMMIT_MSG_FILE%.tmp\" \"%COMMIT_MSG_FILE%\"\n"
1233            )
1234        }
1235
1236        #[cfg(not(windows))]
1237        {
1238            format!(
1239                "#!/bin/sh\n\
1240                 # Cascade CLI Hook - Prepare Commit Message\n\
1241                 # Adds stack context to commit messages\n\n\
1242                 set -e\n\n\
1243                 COMMIT_MSG_FILE=\"$1\"\n\
1244                 COMMIT_SOURCE=\"$2\"\n\
1245                 COMMIT_SHA=\"$3\"\n\n\
1246                 # Skip if user provided message via -m flag, merge commit, etc.\n\
1247                 if [ \"$COMMIT_SOURCE\" != \"\" ]; then\n\
1248                     exit 0\n\
1249                 fi\n\n\
1250                 # Find repository root and check if Cascade is initialized\n\
1251                 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
1252                 if [ ! -d \"$REPO_ROOT/.cascade\" ]; then\n\
1253                     exit 0\n\
1254                 fi\n\n\
1255                 # Check for active stack\n\
1256                 ACTIVE_STACK=$(\"{cascade_cli}\" stack list --active --format=name 2>/dev/null || echo \"\")\n\
1257                 \n\
1258                 if [ -n \"$ACTIVE_STACK\" ]; then\n\
1259                     # Get current commit message\n\
1260                     CURRENT_MSG=$(cat \"$COMMIT_MSG_FILE\")\n\
1261                     \n\
1262                     # Skip if message already has stack context\n\
1263                     if echo \"$CURRENT_MSG\" | grep -q \"\\[stack:\"; then\n\
1264                         exit 0\n\
1265                     fi\n\
1266                     \n\
1267                     # Add stack context to commit message\n\
1268                     echo \"\n\
1269                 # Stack: $ACTIVE_STACK\n\
1270                 # This commit will be added to the active stack automatically.\n\
1271                 # Use 'ca stack status' to see the current stack state.\n\
1272                 $CURRENT_MSG\" > \"$COMMIT_MSG_FILE\"\n\
1273                 fi\n"
1274            )
1275        }
1276    }
1277
1278    /// Detect repository type from remote URLs
1279    pub fn detect_repository_type(&self) -> Result<RepositoryType> {
1280        let output = Command::new("git")
1281            .args(["remote", "get-url", "origin"])
1282            .current_dir(&self.repo_path)
1283            .output()
1284            .map_err(|e| CascadeError::config(format!("Failed to get remote URL: {e}")))?;
1285
1286        if !output.status.success() {
1287            return Ok(RepositoryType::Unknown);
1288        }
1289
1290        let remote_url = String::from_utf8_lossy(&output.stdout)
1291            .trim()
1292            .to_lowercase();
1293
1294        if remote_url.contains("github.com") {
1295            Ok(RepositoryType::GitHub)
1296        } else if remote_url.contains("gitlab.com") || remote_url.contains("gitlab") {
1297            Ok(RepositoryType::GitLab)
1298        } else if remote_url.contains("dev.azure.com") || remote_url.contains("visualstudio.com") {
1299            Ok(RepositoryType::AzureDevOps)
1300        } else if remote_url.contains("bitbucket") {
1301            Ok(RepositoryType::Bitbucket)
1302        } else {
1303            Ok(RepositoryType::Unknown)
1304        }
1305    }
1306
1307    /// Detect current branch type
1308    pub fn detect_branch_type(&self) -> Result<BranchType> {
1309        let output = Command::new("git")
1310            .args(["branch", "--show-current"])
1311            .current_dir(&self.repo_path)
1312            .output()
1313            .map_err(|e| CascadeError::config(format!("Failed to get current branch: {e}")))?;
1314
1315        if !output.status.success() {
1316            return Ok(BranchType::Unknown);
1317        }
1318
1319        let branch_name = String::from_utf8_lossy(&output.stdout)
1320            .trim()
1321            .to_lowercase();
1322
1323        if branch_name == "main" || branch_name == "master" || branch_name == "develop" {
1324            Ok(BranchType::Main)
1325        } else if !branch_name.is_empty() {
1326            Ok(BranchType::Feature)
1327        } else {
1328            Ok(BranchType::Unknown)
1329        }
1330    }
1331
1332    /// Validate prerequisites for hook installation
1333    pub fn validate_prerequisites(&self) -> Result<()> {
1334        Output::check_start("Checking prerequisites for Cascade hooks");
1335
1336        // 1. Check repository type
1337        let repo_type = self.detect_repository_type()?;
1338        match repo_type {
1339            RepositoryType::Bitbucket => {
1340                Output::success("Bitbucket repository detected");
1341                Output::tip("Hooks will work great with 'ca submit' and 'ca autoland' for Bitbucket integration");
1342            }
1343            RepositoryType::GitHub => {
1344                Output::success("GitHub repository detected");
1345                Output::tip("Consider setting up GitHub Actions for CI/CD integration");
1346            }
1347            RepositoryType::GitLab => {
1348                Output::success("GitLab repository detected");
1349                Output::tip("GitLab CI integration works well with Cascade stacks");
1350            }
1351            RepositoryType::AzureDevOps => {
1352                Output::success("Azure DevOps repository detected");
1353                Output::tip("Azure Pipelines can be configured to work with Cascade workflows");
1354            }
1355            RepositoryType::Unknown => {
1356                Output::info(
1357                    "Unknown repository type - hooks will still work for local Git operations",
1358                );
1359            }
1360        }
1361
1362        // 2. Check Cascade configuration
1363        let config_dir = crate::config::get_repo_config_dir(&self.repo_path)?;
1364        let config_path = config_dir.join("config.json");
1365        if !config_path.exists() {
1366            return Err(CascadeError::config(
1367                "🚫 Cascade not initialized!\n\n\
1368                Please run 'ca init' or 'ca setup' first to configure Cascade CLI.\n\
1369                Hooks require proper Bitbucket Server configuration.\n\n\
1370                Use --force to install anyway (not recommended)."
1371                    .to_string(),
1372            ));
1373        }
1374
1375        // 3. Validate Bitbucket configuration
1376        let config = Settings::load_from_file(&config_path)?;
1377
1378        if config.bitbucket.url == "https://bitbucket.example.com"
1379            || config.bitbucket.url.contains("example.com")
1380        {
1381            return Err(CascadeError::config(
1382                "🚫 Invalid Bitbucket configuration!\n\n\
1383                Your Bitbucket URL appears to be a placeholder.\n\
1384                Please run 'ca setup' to configure a real Bitbucket Server.\n\n\
1385                Use --force to install anyway (not recommended)."
1386                    .to_string(),
1387            ));
1388        }
1389
1390        if config.bitbucket.project == "PROJECT" || config.bitbucket.repo == "repo" {
1391            return Err(CascadeError::config(
1392                "🚫 Incomplete Bitbucket configuration!\n\n\
1393                Your project/repository settings appear to be placeholders.\n\
1394                Please run 'ca setup' to complete configuration.\n\n\
1395                Use --force to install anyway (not recommended)."
1396                    .to_string(),
1397            ));
1398        }
1399
1400        Output::success("Prerequisites validation passed");
1401        Ok(())
1402    }
1403
1404    /// Validate branch suitability for hooks
1405    pub fn validate_branch_suitability(&self) -> Result<()> {
1406        let branch_type = self.detect_branch_type()?;
1407
1408        match branch_type {
1409            BranchType::Main => {
1410                return Err(CascadeError::config(
1411                    "🚫 Currently on main/master branch!\n\n\
1412                    Cascade hooks are designed for feature branch development.\n\
1413                    Working directly on main/master with stacked diffs can:\n\
1414                    • Complicate the commit history\n\
1415                    • Interfere with team collaboration\n\
1416                    • Break CI/CD workflows\n\n\
1417                    Recommended workflow:\n\
1418                    1. Create a feature branch: git checkout -b feature/my-feature\n\
1419                    2. Install hooks: ca hooks install\n\
1420                    3. Develop with stacked commits (auto-added with hooks)\n\
1421                    4. Push & submit: ca push && ca submit (all by default)\n\
1422                    5. Auto-land when ready: ca autoland\n\n\
1423                    Use --force to install anyway (not recommended)."
1424                        .to_string(),
1425                ));
1426            }
1427            BranchType::Feature => {
1428                Output::success("Feature branch detected - suitable for stacked development");
1429            }
1430            BranchType::Unknown => {
1431                Output::warning("Unknown branch type - proceeding with caution");
1432            }
1433        }
1434
1435        Ok(())
1436    }
1437
1438    /// Confirm installation with user
1439    pub fn confirm_installation(&self) -> Result<()> {
1440        Output::section("Hook Installation Summary");
1441
1442        let hooks = vec![
1443            HookType::PostCommit,
1444            HookType::PrePush,
1445            HookType::CommitMsg,
1446            HookType::PrepareCommitMsg,
1447        ];
1448
1449        for hook in &hooks {
1450            Output::sub_item(format!("{}: {}", hook.filename(), hook.description()));
1451        }
1452
1453        println!();
1454        Output::section("These hooks will automatically");
1455        Output::bullet("Add commits to your active stack");
1456        Output::bullet("Validate commit messages");
1457        Output::bullet("Prevent force pushes that break stack integrity");
1458        Output::bullet("Add stack context to commit messages");
1459
1460        println!();
1461        Output::section("With hooks + new defaults, your workflow becomes");
1462        Output::sub_item("git commit       → Auto-added to stack");
1463        Output::sub_item("ca push          → Pushes all by default");
1464        Output::sub_item("ca submit        → Submits all by default");
1465        Output::sub_item("ca autoland      → Auto-merges when ready");
1466
1467        // Interactive confirmation to proceed with installation
1468        let should_install = Confirm::with_theme(&ColorfulTheme::default())
1469            .with_prompt("Install Cascade hooks?")
1470            .default(true)
1471            .interact()
1472            .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
1473
1474        if should_install {
1475            Output::success("Proceeding with installation");
1476            Ok(())
1477        } else {
1478            Err(CascadeError::config(
1479                "Installation cancelled by user".to_string(),
1480            ))
1481        }
1482    }
1483}
1484
1485/// Run hooks management commands
1486pub async fn install() -> Result<()> {
1487    install_with_options(false, false, false, false).await
1488}
1489
1490pub async fn install_essential() -> Result<()> {
1491    let current_dir = env::current_dir()
1492        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1493
1494    let repo_root = find_repository_root(&current_dir)
1495        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1496
1497    let hooks_manager = HooksManager::new(&repo_root)?;
1498    hooks_manager.install_essential()
1499}
1500
1501pub async fn install_with_options(
1502    skip_checks: bool,
1503    allow_main_branch: bool,
1504    yes: bool,
1505    force: bool,
1506) -> Result<()> {
1507    let current_dir = env::current_dir()
1508        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1509
1510    let repo_root = find_repository_root(&current_dir)
1511        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1512
1513    let hooks_manager = HooksManager::new(&repo_root)?;
1514
1515    let options = InstallOptions {
1516        check_prerequisites: !skip_checks,
1517        feature_branches_only: !allow_main_branch,
1518        confirm: !yes,
1519        force,
1520    };
1521
1522    hooks_manager.install_with_options(&options)
1523}
1524
1525pub async fn uninstall() -> Result<()> {
1526    let current_dir = env::current_dir()
1527        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1528
1529    let repo_root = find_repository_root(&current_dir)
1530        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1531
1532    let hooks_manager = HooksManager::new(&repo_root)?;
1533    hooks_manager.uninstall_all()
1534}
1535
1536pub async fn status() -> Result<()> {
1537    let current_dir = env::current_dir()
1538        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1539
1540    let repo_root = find_repository_root(&current_dir)
1541        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1542
1543    let hooks_manager = HooksManager::new(&repo_root)?;
1544    hooks_manager.list_installed_hooks()
1545}
1546
1547pub async fn install_hook(hook_name: &str) -> Result<()> {
1548    install_hook_with_options(hook_name, false, false).await
1549}
1550
1551pub async fn install_hook_with_options(
1552    hook_name: &str,
1553    skip_checks: bool,
1554    force: bool,
1555) -> Result<()> {
1556    let current_dir = env::current_dir()
1557        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1558
1559    let repo_root = find_repository_root(&current_dir)
1560        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1561
1562    let hooks_manager = HooksManager::new(&repo_root)?;
1563
1564    let hook_type = match hook_name {
1565        "post-commit" => HookType::PostCommit,
1566        "pre-push" => HookType::PrePush,
1567        "commit-msg" => HookType::CommitMsg,
1568        "pre-commit" => HookType::PreCommit,
1569        "prepare-commit-msg" => HookType::PrepareCommitMsg,
1570        _ => {
1571            return Err(CascadeError::config(format!(
1572                "Unknown hook type: {hook_name}"
1573            )))
1574        }
1575    };
1576
1577    // Run basic validation if not skipped
1578    if !skip_checks && !force {
1579        hooks_manager.validate_prerequisites()?;
1580    }
1581
1582    hooks_manager.install_hook(&hook_type)
1583}
1584
1585pub async fn uninstall_hook(hook_name: &str) -> Result<()> {
1586    let current_dir = env::current_dir()
1587        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1588
1589    let repo_root = find_repository_root(&current_dir)
1590        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1591
1592    let hooks_manager = HooksManager::new(&repo_root)?;
1593
1594    let hook_type = match hook_name {
1595        "post-commit" => HookType::PostCommit,
1596        "pre-push" => HookType::PrePush,
1597        "commit-msg" => HookType::CommitMsg,
1598        "pre-commit" => HookType::PreCommit,
1599        "prepare-commit-msg" => HookType::PrepareCommitMsg,
1600        _ => {
1601            return Err(CascadeError::config(format!(
1602                "Unknown hook type: {hook_name}"
1603            )))
1604        }
1605    };
1606
1607    hooks_manager.uninstall_hook(&hook_type)
1608}
1609
1610#[cfg(test)]
1611mod tests {
1612    use super::*;
1613    use std::process::Command;
1614    use tempfile::TempDir;
1615
1616    fn create_test_repo() -> (TempDir, std::path::PathBuf) {
1617        let temp_dir = TempDir::new().unwrap();
1618        let repo_path = temp_dir.path().to_path_buf();
1619
1620        // Initialize git repository
1621        Command::new("git")
1622            .args(["init"])
1623            .current_dir(&repo_path)
1624            .output()
1625            .unwrap();
1626        Command::new("git")
1627            .args(["config", "user.name", "Test"])
1628            .current_dir(&repo_path)
1629            .output()
1630            .unwrap();
1631        Command::new("git")
1632            .args(["config", "user.email", "test@test.com"])
1633            .current_dir(&repo_path)
1634            .output()
1635            .unwrap();
1636
1637        // Create initial commit
1638        std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1639        Command::new("git")
1640            .args(["add", "."])
1641            .current_dir(&repo_path)
1642            .output()
1643            .unwrap();
1644        Command::new("git")
1645            .args(["commit", "-m", "Initial"])
1646            .current_dir(&repo_path)
1647            .output()
1648            .unwrap();
1649
1650        // Initialize cascade
1651        crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
1652            .unwrap();
1653
1654        (temp_dir, repo_path)
1655    }
1656
1657    #[test]
1658    fn test_hooks_manager_creation() {
1659        let (_temp_dir, repo_path) = create_test_repo();
1660        let _manager = HooksManager::new(&repo_path).unwrap();
1661
1662        assert_eq!(_manager.repo_path, repo_path);
1663        // Should create a HooksManager successfully
1664        assert!(!_manager.repo_id.is_empty());
1665    }
1666
1667    #[test]
1668    fn test_hooks_manager_custom_hooks_path() {
1669        let (_temp_dir, repo_path) = create_test_repo();
1670
1671        // Set custom hooks path
1672        Command::new("git")
1673            .args(["config", "core.hooksPath", "custom-hooks"])
1674            .current_dir(&repo_path)
1675            .output()
1676            .unwrap();
1677
1678        // Create the custom hooks directory
1679        let custom_hooks_dir = repo_path.join("custom-hooks");
1680        std::fs::create_dir_all(&custom_hooks_dir).unwrap();
1681
1682        let _manager = HooksManager::new(&repo_path).unwrap();
1683
1684        assert_eq!(_manager.repo_path, repo_path);
1685        // Should create a HooksManager successfully
1686        assert!(!_manager.repo_id.is_empty());
1687    }
1688
1689    #[test]
1690    fn test_hook_chaining_with_existing_hooks() {
1691        let (_temp_dir, repo_path) = create_test_repo();
1692        let manager = HooksManager::new(&repo_path).unwrap();
1693
1694        let hook_type = HookType::PreCommit;
1695        let hook_path = repo_path.join(".git/hooks").join(hook_type.filename());
1696
1697        // Create an existing project hook
1698        let existing_hook_content = "#!/bin/bash\n# Project pre-commit hook\n./scripts/lint.sh\n";
1699        std::fs::write(&hook_path, existing_hook_content).unwrap();
1700        crate::utils::platform::make_executable(&hook_path).unwrap();
1701
1702        // Install cascade hook (uses core.hooksPath, doesn't modify original)
1703        let result = manager.install_hook(&hook_type);
1704        assert!(result.is_ok());
1705
1706        // Original hook should remain unchanged
1707        let original_content = std::fs::read_to_string(&hook_path).unwrap();
1708        assert!(original_content.contains("# Project pre-commit hook"));
1709        assert!(original_content.contains("./scripts/lint.sh"));
1710
1711        // Cascade hook should exist in cascade directory
1712        let cascade_hooks_dir = manager.get_cascade_hooks_dir().unwrap();
1713        let cascade_hook_path = cascade_hooks_dir.join(hook_type.filename());
1714        assert!(cascade_hook_path.exists());
1715
1716        // Test uninstall removes cascade hooks but leaves original
1717        let uninstall_result = manager.uninstall_hook(&hook_type);
1718        assert!(uninstall_result.is_ok());
1719
1720        // Original hook should still exist and be unchanged
1721        let after_uninstall = std::fs::read_to_string(&hook_path).unwrap();
1722        assert!(after_uninstall.contains("# Project pre-commit hook"));
1723        assert!(after_uninstall.contains("./scripts/lint.sh"));
1724
1725        // Cascade hook should be removed
1726        assert!(!cascade_hook_path.exists());
1727    }
1728
1729    #[test]
1730    fn test_hook_installation() {
1731        let (_temp_dir, repo_path) = create_test_repo();
1732        let manager = HooksManager::new(&repo_path).unwrap();
1733
1734        // Test installing post-commit hook
1735        let hook_type = HookType::PostCommit;
1736        let result = manager.install_hook(&hook_type);
1737        assert!(result.is_ok());
1738
1739        // Verify hook file exists in cascade hooks directory
1740        let hook_filename = hook_type.filename();
1741        let cascade_hooks_dir = manager.get_cascade_hooks_dir().unwrap();
1742        let hook_path = cascade_hooks_dir.join(&hook_filename);
1743        assert!(hook_path.exists());
1744
1745        // Verify hook is executable (platform-specific)
1746        #[cfg(unix)]
1747        {
1748            use std::os::unix::fs::PermissionsExt;
1749            let metadata = std::fs::metadata(&hook_path).unwrap();
1750            let permissions = metadata.permissions();
1751            assert!(permissions.mode() & 0o111 != 0); // Check executable bit
1752        }
1753
1754        #[cfg(windows)]
1755        {
1756            // On Windows, verify it has .bat extension and file exists
1757            assert!(hook_filename.ends_with(".bat"));
1758            assert!(hook_path.exists());
1759        }
1760    }
1761
1762    #[test]
1763    fn test_hook_detection() {
1764        let (_temp_dir, repo_path) = create_test_repo();
1765        let _manager = HooksManager::new(&repo_path).unwrap();
1766
1767        // Check if hook files exist with platform-appropriate filenames
1768        let post_commit_path = repo_path
1769            .join(".git/hooks")
1770            .join(HookType::PostCommit.filename());
1771        let pre_push_path = repo_path
1772            .join(".git/hooks")
1773            .join(HookType::PrePush.filename());
1774        let commit_msg_path = repo_path
1775            .join(".git/hooks")
1776            .join(HookType::CommitMsg.filename());
1777
1778        // Initially no hooks should be installed
1779        assert!(!post_commit_path.exists());
1780        assert!(!pre_push_path.exists());
1781        assert!(!commit_msg_path.exists());
1782    }
1783
1784    #[test]
1785    fn test_hook_validation() {
1786        let (_temp_dir, repo_path) = create_test_repo();
1787        let manager = HooksManager::new(&repo_path).unwrap();
1788
1789        // Test validation - may fail in CI due to missing dependencies
1790        let validation = manager.validate_prerequisites();
1791        // In CI environment, validation might fail due to missing configuration
1792        // Just ensure it doesn't panic
1793        let _ = validation; // Don't assert ok/err, just ensure no panic
1794
1795        // Test branch validation - should work regardless of environment
1796        let branch_validation = manager.validate_branch_suitability();
1797        // Branch validation should work in most cases, but be tolerant
1798        let _ = branch_validation; // Don't assert ok/err, just ensure no panic
1799    }
1800
1801    #[test]
1802    fn test_hook_uninstallation() {
1803        let (_temp_dir, repo_path) = create_test_repo();
1804        let manager = HooksManager::new(&repo_path).unwrap();
1805
1806        // Install then uninstall hook
1807        let hook_type = HookType::PostCommit;
1808        manager.install_hook(&hook_type).unwrap();
1809
1810        let cascade_hooks_dir = manager.get_cascade_hooks_dir().unwrap();
1811        let hook_path = cascade_hooks_dir.join(hook_type.filename());
1812        assert!(hook_path.exists());
1813
1814        let result = manager.uninstall_hook(&hook_type);
1815        assert!(result.is_ok());
1816        assert!(!hook_path.exists());
1817    }
1818
1819    #[test]
1820    fn test_hook_content_generation() {
1821        let (_temp_dir, repo_path) = create_test_repo();
1822        let manager = HooksManager::new(&repo_path).unwrap();
1823
1824        // Use a known binary name for testing
1825        let binary_name = "cascade-cli";
1826
1827        // Test post-commit hook generation
1828        let post_commit_content = manager.generate_post_commit_hook(binary_name);
1829        #[cfg(windows)]
1830        {
1831            assert!(post_commit_content.contains("@echo off"));
1832            assert!(post_commit_content.contains("rem Cascade CLI Hook"));
1833        }
1834        #[cfg(not(windows))]
1835        {
1836            assert!(post_commit_content.contains("#!/bin/sh"));
1837            assert!(post_commit_content.contains("# Cascade CLI Hook"));
1838        }
1839        assert!(post_commit_content.contains(binary_name));
1840
1841        // Test pre-push hook generation
1842        let pre_push_content = manager.generate_pre_push_hook(binary_name);
1843        #[cfg(windows)]
1844        {
1845            assert!(pre_push_content.contains("@echo off"));
1846            assert!(pre_push_content.contains("rem Cascade CLI Hook"));
1847        }
1848        #[cfg(not(windows))]
1849        {
1850            assert!(pre_push_content.contains("#!/bin/sh"));
1851            assert!(pre_push_content.contains("# Cascade CLI Hook"));
1852        }
1853        assert!(pre_push_content.contains(binary_name));
1854
1855        // Test commit-msg hook generation (doesn't use binary, just validates)
1856        let commit_msg_content = manager.generate_commit_msg_hook(binary_name);
1857        #[cfg(windows)]
1858        {
1859            assert!(commit_msg_content.contains("@echo off"));
1860            assert!(commit_msg_content.contains("rem Cascade CLI Hook"));
1861        }
1862        #[cfg(not(windows))]
1863        {
1864            assert!(commit_msg_content.contains("#!/bin/sh"));
1865            assert!(commit_msg_content.contains("# Cascade CLI Hook"));
1866        }
1867
1868        // Test prepare-commit-msg hook generation (does use binary)
1869        let prepare_commit_content = manager.generate_prepare_commit_msg_hook(binary_name);
1870        #[cfg(windows)]
1871        {
1872            assert!(prepare_commit_content.contains("@echo off"));
1873            assert!(prepare_commit_content.contains("rem Cascade CLI Hook"));
1874        }
1875        #[cfg(not(windows))]
1876        {
1877            assert!(prepare_commit_content.contains("#!/bin/sh"));
1878            assert!(prepare_commit_content.contains("# Cascade CLI Hook"));
1879        }
1880        assert!(prepare_commit_content.contains(binary_name));
1881    }
1882
1883    #[test]
1884    fn test_hook_status_reporting() {
1885        let (_temp_dir, repo_path) = create_test_repo();
1886        let manager = HooksManager::new(&repo_path).unwrap();
1887
1888        // Check repository type detection - should work with our test setup
1889        let repo_type = manager.detect_repository_type().unwrap();
1890        // In CI environment, this might be Unknown if remote detection fails
1891        assert!(matches!(
1892            repo_type,
1893            RepositoryType::Bitbucket | RepositoryType::Unknown
1894        ));
1895
1896        // Check branch type detection
1897        let branch_type = manager.detect_branch_type().unwrap();
1898        // Should be on main/master branch, but allow for different default branch names
1899        assert!(matches!(
1900            branch_type,
1901            BranchType::Main | BranchType::Unknown
1902        ));
1903    }
1904
1905    #[test]
1906    fn test_force_installation() {
1907        let (_temp_dir, repo_path) = create_test_repo();
1908        let manager = HooksManager::new(&repo_path).unwrap();
1909
1910        // Create a fake existing hook with platform-appropriate content
1911        let hook_filename = HookType::PostCommit.filename();
1912        let hook_path = repo_path.join(".git/hooks").join(&hook_filename);
1913
1914        #[cfg(windows)]
1915        let existing_content = "@echo off\necho existing hook";
1916        #[cfg(not(windows))]
1917        let existing_content = "#!/bin/sh\necho 'existing hook'";
1918
1919        std::fs::write(&hook_path, existing_content).unwrap();
1920
1921        // Install cascade hook (uses core.hooksPath, doesn't modify original)
1922        let hook_type = HookType::PostCommit;
1923        let result = manager.install_hook(&hook_type);
1924        assert!(result.is_ok());
1925
1926        // Verify cascade hook exists in cascade directory
1927        let cascade_hooks_dir = manager.get_cascade_hooks_dir().unwrap();
1928        let cascade_hook_path = cascade_hooks_dir.join(&hook_filename);
1929        assert!(cascade_hook_path.exists());
1930
1931        // Original hook should remain unchanged
1932        let original_content = std::fs::read_to_string(&hook_path).unwrap();
1933        assert!(original_content.contains("existing hook"));
1934
1935        // Cascade hook should contain cascade logic
1936        let cascade_content = std::fs::read_to_string(&cascade_hook_path).unwrap();
1937        assert!(cascade_content.contains("cascade-cli") || cascade_content.contains("ca"));
1938    }
1939}