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