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