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 Get edit status\n\
1033                 for /f \\\"tokens=*\\\" %%i in ('\\\"{0}\\\" entry status --quiet 2^>nul') do set EDIT_STATUS=%%i\n\
1034                 if \\\"%EDIT_STATUS%\\\"==\\\"\\\" set EDIT_STATUS=inactive\n\n\
1035                 rem Check if edit status is active\n\
1036                 echo %EDIT_STATUS% | findstr /b \\\"active:\\\" >nul\n\
1037                 if %ERRORLEVEL% equ 0 (\n\
1038                     echo You're in EDIT MODE for a stack entry\n\
1039                     echo.\n\
1040                     echo Choose your action:\n\
1041                     echo   [a] amend: Modify the current entry ^(default^)\n\
1042                     echo   [n] new:   Create new entry on top\n\
1043                     echo   [c] cancel: Stop and think about it\n\
1044                     echo.\n\
1045                     set /p choice=\\\"Your choice (a/n/c): \\\" <CON\n\
1046                     if \\\"%choice%\\\"==\\\"\\\" set choice=a\n\
1047                     \n\
1048                    if /i \\\"%choice%\\\"==\\\"A\\\" (\n\
1049                        rem Use ca entry amend to update entry ^(ignore any -m flag^)\n\
1050                        rem Changes are already staged by git commit; --restack updates dependents\n\
1051                        \\\"{0}\\\" entry amend --restack\n\
1052                        set amend_error=%ERRORLEVEL%\n\
1053                        if %amend_error% EQU 0 (\n\
1054                            echo Amend applied - skipping git commit to avoid duplicate entry.\n\
1055                            echo Your commit was updated by Cascade; no further action needed.\n\
1056                            exit /b 1\n\
1057                        ) else (\n\
1058                            exit /b %amend_error%\n\
1059                        )\n\
1060                    ) else if /i \\\"%choice%\\\"==\\\"N\\\" (\n\
1061                         echo Creating new stack entry...\n\
1062                         echo The commit will proceed and post-commit hook will add it to your stack\n\
1063                         rem Let commit proceed ^(Git will use -m flag or open editor^)\n\
1064                         exit /b 0\n\
1065                     ) else if /i \\\"%choice%\\\"==\\\"C\\\" (\n\
1066                         echo Commit cancelled\n\
1067                         exit /b 1\n\
1068                     ) else (\n\
1069                         echo Invalid choice. Please choose A, n, or c\n\
1070                         exit /b 1\n\
1071                     )\n\
1072                 )\n\n\
1073                 rem Not in edit mode, proceed normally\n\
1074                 exit /b 0\n",
1075                cascade_cli
1076            )
1077        }
1078
1079        #[cfg(not(windows))]
1080        {
1081            // Use string building to avoid escaping issues with format! macros
1082            // Check the OUTPUT of entry status, not just exit code
1083            let status_check = format!(
1084                "EDIT_STATUS=$(\"{}\" entry status --quiet 2>/dev/null || echo \"inactive\")",
1085                cascade_cli
1086            );
1087            // When called from pre-commit hook during 'git commit', changes are already staged
1088            // So we DON'T use --all flag, just amend with what's staged
1089            let amend_line = format!("           \"{}\" entry amend --restack", cascade_cli);
1090
1091            vec![
1092                "#!/bin/sh".to_string(),
1093                "# Cascade CLI Hook - Pre Commit".to_string(),
1094                "# Smart edit mode guidance for better UX".to_string(),
1095                "".to_string(),
1096                "set -e".to_string(),
1097                "".to_string(),
1098                "# Check if Cascade is initialized".to_string(),
1099                r#"REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")"#.to_string(),
1100                r#"if [ ! -d "$REPO_ROOT/.cascade" ]; then"#.to_string(),
1101                "    exit 0".to_string(),
1102                "fi".to_string(),
1103                "".to_string(),
1104                "# Skip hook if called from ca entry amend (avoid infinite loop)".to_string(),
1105                r#"if [ "$CASCADE_SKIP_HOOKS" = "1" ]; then"#.to_string(),
1106                "    exit 0".to_string(),
1107                "fi".to_string(),
1108                "".to_string(),
1109                "# Check if we're in edit mode".to_string(),
1110                r#"CURRENT_BRANCH=$(git branch --show-current 2>/dev/null)"#.to_string(),
1111                status_check,
1112                "".to_string(),
1113                "# If in edit mode, check if we're on a stack entry branch".to_string(),
1114                r#"if echo "$EDIT_STATUS" | grep -q "^active:"; then"#.to_string(),
1115                "        # Check if current branch is a stack entry branch".to_string(),
1116                format!(r#"        if ! "{}" stacks list --format=json 2>/dev/null | grep -q "\"branch_name\": \"$CURRENT_BRANCH\""; then"#, cascade_cli),
1117                r#"                # Not on a stack entry branch - edit mode is for a different branch"#.to_string(),
1118                r#"                # Silently proceed with normal commit"#.to_string(),
1119                "                exit 0".to_string(),
1120                "        fi".to_string(),
1121                "        ".to_string(),
1122                "        # Proper edit mode - prompt user".to_string(),
1123                r#"        echo "You're in EDIT MODE for a stack entry""#.to_string(),
1124                r#"        echo """#.to_string(),
1125                r#"        echo "Choose your action:""#.to_string(),
1126                r#"        echo "  [a] amend: Modify the current entry (default)""#.to_string(),
1127                r#"        echo "  [n] new:   Create new entry on top""#.to_string(),
1128                r#"        echo "  [c] cancel: Stop and think about it""#.to_string(),
1129                r#"        echo """#.to_string(),
1130                "        ".to_string(),
1131                "        # Read user choice with default to amend".to_string(),
1132                r#"        read -p "Your choice (a/n/c): " choice < /dev/tty"#.to_string(),
1133                "        choice=${choice:-a}".to_string(),
1134                "        ".to_string(),
1135                "        ".to_string(),
1136                r#"        case "$choice" in"#.to_string(),
1137                "            [Aa])".to_string(),
1138                "                # Use ca entry amend to properly update entry + working branch (ignore any -m flag)"
1139                    .to_string(),
1140                "                # Changes are already staged by 'git commit', so no --all flag needed".to_string(),
1141                amend_line.replace("           ", "                "),
1142                "                amend_rc=$?".to_string(),
1143                r#"                if [ $amend_rc -eq 0 ]; then"#.to_string(),
1144                r#"                    echo "Amend applied - skipping git commit to avoid duplicate entry.""#
1145                    .to_string(),
1146                r#"                    echo "Your commit was updated by Cascade; no further action needed.""#
1147                    .to_string(),
1148                "                    exit 1".to_string(),
1149                "                else".to_string(),
1150                "                    exit $amend_rc".to_string(),
1151                "                fi".to_string(),
1152                "                ;;".to_string(),
1153                "            [Nn])".to_string(),
1154                r#"                echo "Creating new stack entry...""#.to_string(),
1155                r#"                echo "The commit will proceed and post-commit hook will add it to your stack""#.to_string(),
1156                "                # Let the commit proceed normally (Git will use -m flag or open editor)"
1157                    .to_string(),
1158                "                exit 0".to_string(),
1159                "                ;;".to_string(),
1160                "            [Cc])".to_string(),
1161                r#"                echo "Commit cancelled""#.to_string(),
1162                "                exit 1".to_string(),
1163                "                ;;".to_string(),
1164                "            *)".to_string(),
1165                r#"                echo "Invalid choice. Please choose A, n, or c""#.to_string(),
1166                "                exit 1".to_string(),
1167                "                ;;".to_string(),
1168                "        esac".to_string(),
1169                "fi".to_string(),
1170                "".to_string(),
1171                "# Not in edit mode, proceed normally".to_string(),
1172                "exit 0".to_string(),
1173            ]
1174            .join("\n")
1175        }
1176    }
1177
1178    fn generate_prepare_commit_msg_hook(&self, cascade_cli: &str) -> String {
1179        #[cfg(windows)]
1180        {
1181            format!(
1182                "@echo off\n\
1183                 rem Cascade CLI Hook - Prepare Commit Message\n\
1184                 rem Adds stack context to commit messages\n\n\
1185                 set COMMIT_MSG_FILE=%1\n\
1186                 set COMMIT_SOURCE=%2\n\
1187                 set COMMIT_SHA=%3\n\n\
1188                 rem Skip if user provided message via -m flag, merge commit, etc.\n\
1189                 if not \"%COMMIT_SOURCE%\"==\"\" exit /b 0\n\n\
1190                 rem Find repository root and check if Cascade is initialized\n\
1191                 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
1192                 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
1193                 if not exist \"%REPO_ROOT%\\.cascade\" exit /b 0\n\n\
1194                 rem Check for active stack\n\
1195                 for /f \"tokens=*\" %%i in ('\"{cascade_cli}\" stack list --active --format=name 2^>nul') do set ACTIVE_STACK=%%i\n\n\
1196                 if not \"%ACTIVE_STACK%\"==\"\" (\n\
1197                     rem Get current commit message\n\
1198                     set /p CURRENT_MSG=<%COMMIT_MSG_FILE%\n\n\
1199                     rem Skip if message already has stack context\n\
1200                     echo !CURRENT_MSG! | findstr \"[stack:\" >nul\n\
1201                     if %ERRORLEVEL% equ 0 exit /b 0\n\n\
1202                     rem Add stack context to commit message\n\
1203                     echo.\n\
1204                     echo # Stack: %ACTIVE_STACK%\n\
1205                     echo # This commit will be added to the active stack automatically.\n\
1206                     echo # Use 'ca stack status' to see the current stack state.\n\
1207                     type \"%COMMIT_MSG_FILE%\"\n\
1208                 ) > \"%COMMIT_MSG_FILE%.tmp\"\n\
1209                 move \"%COMMIT_MSG_FILE%.tmp\" \"%COMMIT_MSG_FILE%\"\n"
1210            )
1211        }
1212
1213        #[cfg(not(windows))]
1214        {
1215            format!(
1216                "#!/bin/sh\n\
1217                 # Cascade CLI Hook - Prepare Commit Message\n\
1218                 # Adds stack context to commit messages\n\n\
1219                 set -e\n\n\
1220                 COMMIT_MSG_FILE=\"$1\"\n\
1221                 COMMIT_SOURCE=\"$2\"\n\
1222                 COMMIT_SHA=\"$3\"\n\n\
1223                 # Skip if user provided message via -m flag, merge commit, etc.\n\
1224                 if [ \"$COMMIT_SOURCE\" != \"\" ]; then\n\
1225                     exit 0\n\
1226                 fi\n\n\
1227                 # Find repository root and check if Cascade is initialized\n\
1228                 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
1229                 if [ ! -d \"$REPO_ROOT/.cascade\" ]; then\n\
1230                     exit 0\n\
1231                 fi\n\n\
1232                 # Check for active stack\n\
1233                 ACTIVE_STACK=$(\"{cascade_cli}\" stack list --active --format=name 2>/dev/null || echo \"\")\n\
1234                 \n\
1235                 if [ -n \"$ACTIVE_STACK\" ]; then\n\
1236                     # Get current commit message\n\
1237                     CURRENT_MSG=$(cat \"$COMMIT_MSG_FILE\")\n\
1238                     \n\
1239                     # Skip if message already has stack context\n\
1240                     if echo \"$CURRENT_MSG\" | grep -q \"\\[stack:\"; then\n\
1241                         exit 0\n\
1242                     fi\n\
1243                     \n\
1244                     # Add stack context to commit message\n\
1245                     echo \"\n\
1246                 # Stack: $ACTIVE_STACK\n\
1247                 # This commit will be added to the active stack automatically.\n\
1248                 # Use 'ca stack status' to see the current stack state.\n\
1249                 $CURRENT_MSG\" > \"$COMMIT_MSG_FILE\"\n\
1250                 fi\n"
1251            )
1252        }
1253    }
1254
1255    /// Detect repository type from remote URLs
1256    pub fn detect_repository_type(&self) -> Result<RepositoryType> {
1257        let output = Command::new("git")
1258            .args(["remote", "get-url", "origin"])
1259            .current_dir(&self.repo_path)
1260            .output()
1261            .map_err(|e| CascadeError::config(format!("Failed to get remote URL: {e}")))?;
1262
1263        if !output.status.success() {
1264            return Ok(RepositoryType::Unknown);
1265        }
1266
1267        let remote_url = String::from_utf8_lossy(&output.stdout)
1268            .trim()
1269            .to_lowercase();
1270
1271        if remote_url.contains("github.com") {
1272            Ok(RepositoryType::GitHub)
1273        } else if remote_url.contains("gitlab.com") || remote_url.contains("gitlab") {
1274            Ok(RepositoryType::GitLab)
1275        } else if remote_url.contains("dev.azure.com") || remote_url.contains("visualstudio.com") {
1276            Ok(RepositoryType::AzureDevOps)
1277        } else if remote_url.contains("bitbucket") {
1278            Ok(RepositoryType::Bitbucket)
1279        } else {
1280            Ok(RepositoryType::Unknown)
1281        }
1282    }
1283
1284    /// Detect current branch type
1285    pub fn detect_branch_type(&self) -> Result<BranchType> {
1286        let output = Command::new("git")
1287            .args(["branch", "--show-current"])
1288            .current_dir(&self.repo_path)
1289            .output()
1290            .map_err(|e| CascadeError::config(format!("Failed to get current branch: {e}")))?;
1291
1292        if !output.status.success() {
1293            return Ok(BranchType::Unknown);
1294        }
1295
1296        let branch_name = String::from_utf8_lossy(&output.stdout)
1297            .trim()
1298            .to_lowercase();
1299
1300        if branch_name == "main" || branch_name == "master" || branch_name == "develop" {
1301            Ok(BranchType::Main)
1302        } else if !branch_name.is_empty() {
1303            Ok(BranchType::Feature)
1304        } else {
1305            Ok(BranchType::Unknown)
1306        }
1307    }
1308
1309    /// Validate prerequisites for hook installation
1310    pub fn validate_prerequisites(&self) -> Result<()> {
1311        Output::check_start("Checking prerequisites for Cascade hooks");
1312
1313        // 1. Check repository type
1314        let repo_type = self.detect_repository_type()?;
1315        match repo_type {
1316            RepositoryType::Bitbucket => {
1317                Output::success("Bitbucket repository detected");
1318                Output::tip("Hooks will work great with 'ca submit' and 'ca autoland' for Bitbucket integration");
1319            }
1320            RepositoryType::GitHub => {
1321                Output::success("GitHub repository detected");
1322                Output::tip("Consider setting up GitHub Actions for CI/CD integration");
1323            }
1324            RepositoryType::GitLab => {
1325                Output::success("GitLab repository detected");
1326                Output::tip("GitLab CI integration works well with Cascade stacks");
1327            }
1328            RepositoryType::AzureDevOps => {
1329                Output::success("Azure DevOps repository detected");
1330                Output::tip("Azure Pipelines can be configured to work with Cascade workflows");
1331            }
1332            RepositoryType::Unknown => {
1333                Output::info(
1334                    "Unknown repository type - hooks will still work for local Git operations",
1335                );
1336            }
1337        }
1338
1339        // 2. Check Cascade configuration
1340        let config_dir = crate::config::get_repo_config_dir(&self.repo_path)?;
1341        let config_path = config_dir.join("config.json");
1342        if !config_path.exists() {
1343            return Err(CascadeError::config(
1344                "🚫 Cascade not initialized!\n\n\
1345                Please run 'ca init' or 'ca setup' first to configure Cascade CLI.\n\
1346                Hooks require proper Bitbucket Server configuration.\n\n\
1347                Use --force to install anyway (not recommended)."
1348                    .to_string(),
1349            ));
1350        }
1351
1352        // 3. Validate Bitbucket configuration
1353        let config = Settings::load_from_file(&config_path)?;
1354
1355        if config.bitbucket.url == "https://bitbucket.example.com"
1356            || config.bitbucket.url.contains("example.com")
1357        {
1358            return Err(CascadeError::config(
1359                "🚫 Invalid Bitbucket configuration!\n\n\
1360                Your Bitbucket URL appears to be a placeholder.\n\
1361                Please run 'ca setup' to configure a real Bitbucket Server.\n\n\
1362                Use --force to install anyway (not recommended)."
1363                    .to_string(),
1364            ));
1365        }
1366
1367        if config.bitbucket.project == "PROJECT" || config.bitbucket.repo == "repo" {
1368            return Err(CascadeError::config(
1369                "🚫 Incomplete Bitbucket configuration!\n\n\
1370                Your project/repository settings appear to be placeholders.\n\
1371                Please run 'ca setup' to complete configuration.\n\n\
1372                Use --force to install anyway (not recommended)."
1373                    .to_string(),
1374            ));
1375        }
1376
1377        Output::success("Prerequisites validation passed");
1378        Ok(())
1379    }
1380
1381    /// Validate branch suitability for hooks
1382    pub fn validate_branch_suitability(&self) -> Result<()> {
1383        let branch_type = self.detect_branch_type()?;
1384
1385        match branch_type {
1386            BranchType::Main => {
1387                return Err(CascadeError::config(
1388                    "🚫 Currently on main/master branch!\n\n\
1389                    Cascade hooks are designed for feature branch development.\n\
1390                    Working directly on main/master with stacked diffs can:\n\
1391                    • Complicate the commit history\n\
1392                    • Interfere with team collaboration\n\
1393                    • Break CI/CD workflows\n\n\
1394                    Recommended workflow:\n\
1395                    1. Create a feature branch: git checkout -b feature/my-feature\n\
1396                    2. Install hooks: ca hooks install\n\
1397                    3. Develop with stacked commits (auto-added with hooks)\n\
1398                    4. Push & submit: ca push && ca submit (all by default)\n\
1399                    5. Auto-land when ready: ca autoland\n\n\
1400                    Use --force to install anyway (not recommended)."
1401                        .to_string(),
1402                ));
1403            }
1404            BranchType::Feature => {
1405                Output::success("Feature branch detected - suitable for stacked development");
1406            }
1407            BranchType::Unknown => {
1408                Output::warning("Unknown branch type - proceeding with caution");
1409            }
1410        }
1411
1412        Ok(())
1413    }
1414
1415    /// Confirm installation with user
1416    pub fn confirm_installation(&self) -> Result<()> {
1417        Output::section("Hook Installation Summary");
1418
1419        let hooks = vec![
1420            HookType::PostCommit,
1421            HookType::PrePush,
1422            HookType::CommitMsg,
1423            HookType::PrepareCommitMsg,
1424        ];
1425
1426        for hook in &hooks {
1427            Output::sub_item(format!("{}: {}", hook.filename(), hook.description()));
1428        }
1429
1430        println!();
1431        Output::section("These hooks will automatically");
1432        Output::bullet("Add commits to your active stack");
1433        Output::bullet("Validate commit messages");
1434        Output::bullet("Prevent force pushes that break stack integrity");
1435        Output::bullet("Add stack context to commit messages");
1436
1437        println!();
1438        Output::section("With hooks + new defaults, your workflow becomes");
1439        Output::sub_item("git commit       → Auto-added to stack");
1440        Output::sub_item("ca push          → Pushes all by default");
1441        Output::sub_item("ca submit        → Submits all by default");
1442        Output::sub_item("ca autoland      → Auto-merges when ready");
1443
1444        // Interactive confirmation to proceed with installation
1445        let should_install = Confirm::with_theme(&ColorfulTheme::default())
1446            .with_prompt("Install Cascade hooks?")
1447            .default(true)
1448            .interact()
1449            .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
1450
1451        if should_install {
1452            Output::success("Proceeding with installation");
1453            Ok(())
1454        } else {
1455            Err(CascadeError::config(
1456                "Installation cancelled by user".to_string(),
1457            ))
1458        }
1459    }
1460}
1461
1462/// Run hooks management commands
1463pub async fn install() -> Result<()> {
1464    install_with_options(false, false, false, false).await
1465}
1466
1467pub async fn install_essential() -> Result<()> {
1468    let current_dir = env::current_dir()
1469        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1470
1471    let repo_root = find_repository_root(&current_dir)
1472        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1473
1474    let hooks_manager = HooksManager::new(&repo_root)?;
1475    hooks_manager.install_essential()
1476}
1477
1478pub async fn install_with_options(
1479    skip_checks: bool,
1480    allow_main_branch: bool,
1481    yes: bool,
1482    force: bool,
1483) -> Result<()> {
1484    let current_dir = env::current_dir()
1485        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1486
1487    let repo_root = find_repository_root(&current_dir)
1488        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1489
1490    let hooks_manager = HooksManager::new(&repo_root)?;
1491
1492    let options = InstallOptions {
1493        check_prerequisites: !skip_checks,
1494        feature_branches_only: !allow_main_branch,
1495        confirm: !yes,
1496        force,
1497    };
1498
1499    hooks_manager.install_with_options(&options)
1500}
1501
1502pub async fn uninstall() -> Result<()> {
1503    let current_dir = env::current_dir()
1504        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1505
1506    let repo_root = find_repository_root(&current_dir)
1507        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1508
1509    let hooks_manager = HooksManager::new(&repo_root)?;
1510    hooks_manager.uninstall_all()
1511}
1512
1513pub async fn status() -> Result<()> {
1514    let current_dir = env::current_dir()
1515        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1516
1517    let repo_root = find_repository_root(&current_dir)
1518        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1519
1520    let hooks_manager = HooksManager::new(&repo_root)?;
1521    hooks_manager.list_installed_hooks()
1522}
1523
1524pub async fn install_hook(hook_name: &str) -> Result<()> {
1525    install_hook_with_options(hook_name, false, false).await
1526}
1527
1528pub async fn install_hook_with_options(
1529    hook_name: &str,
1530    skip_checks: bool,
1531    force: bool,
1532) -> Result<()> {
1533    let current_dir = env::current_dir()
1534        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1535
1536    let repo_root = find_repository_root(&current_dir)
1537        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1538
1539    let hooks_manager = HooksManager::new(&repo_root)?;
1540
1541    let hook_type = match hook_name {
1542        "post-commit" => HookType::PostCommit,
1543        "pre-push" => HookType::PrePush,
1544        "commit-msg" => HookType::CommitMsg,
1545        "pre-commit" => HookType::PreCommit,
1546        "prepare-commit-msg" => HookType::PrepareCommitMsg,
1547        _ => {
1548            return Err(CascadeError::config(format!(
1549                "Unknown hook type: {hook_name}"
1550            )))
1551        }
1552    };
1553
1554    // Run basic validation if not skipped
1555    if !skip_checks && !force {
1556        hooks_manager.validate_prerequisites()?;
1557    }
1558
1559    hooks_manager.install_hook(&hook_type)
1560}
1561
1562pub async fn uninstall_hook(hook_name: &str) -> Result<()> {
1563    let current_dir = env::current_dir()
1564        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1565
1566    let repo_root = find_repository_root(&current_dir)
1567        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1568
1569    let hooks_manager = HooksManager::new(&repo_root)?;
1570
1571    let hook_type = match hook_name {
1572        "post-commit" => HookType::PostCommit,
1573        "pre-push" => HookType::PrePush,
1574        "commit-msg" => HookType::CommitMsg,
1575        "pre-commit" => HookType::PreCommit,
1576        "prepare-commit-msg" => HookType::PrepareCommitMsg,
1577        _ => {
1578            return Err(CascadeError::config(format!(
1579                "Unknown hook type: {hook_name}"
1580            )))
1581        }
1582    };
1583
1584    hooks_manager.uninstall_hook(&hook_type)
1585}
1586
1587#[cfg(test)]
1588mod tests {
1589    use super::*;
1590    use std::process::Command;
1591    use tempfile::TempDir;
1592
1593    fn create_test_repo() -> (TempDir, std::path::PathBuf) {
1594        let temp_dir = TempDir::new().unwrap();
1595        let repo_path = temp_dir.path().to_path_buf();
1596
1597        // Initialize git repository
1598        Command::new("git")
1599            .args(["init"])
1600            .current_dir(&repo_path)
1601            .output()
1602            .unwrap();
1603        Command::new("git")
1604            .args(["config", "user.name", "Test"])
1605            .current_dir(&repo_path)
1606            .output()
1607            .unwrap();
1608        Command::new("git")
1609            .args(["config", "user.email", "test@test.com"])
1610            .current_dir(&repo_path)
1611            .output()
1612            .unwrap();
1613
1614        // Create initial commit
1615        std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1616        Command::new("git")
1617            .args(["add", "."])
1618            .current_dir(&repo_path)
1619            .output()
1620            .unwrap();
1621        Command::new("git")
1622            .args(["commit", "-m", "Initial"])
1623            .current_dir(&repo_path)
1624            .output()
1625            .unwrap();
1626
1627        // Initialize cascade
1628        crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
1629            .unwrap();
1630
1631        (temp_dir, repo_path)
1632    }
1633
1634    #[test]
1635    fn test_hooks_manager_creation() {
1636        let (_temp_dir, repo_path) = create_test_repo();
1637        let _manager = HooksManager::new(&repo_path).unwrap();
1638
1639        assert_eq!(_manager.repo_path, repo_path);
1640        // Should create a HooksManager successfully
1641        assert!(!_manager.repo_id.is_empty());
1642    }
1643
1644    #[test]
1645    fn test_hooks_manager_custom_hooks_path() {
1646        let (_temp_dir, repo_path) = create_test_repo();
1647
1648        // Set custom hooks path
1649        Command::new("git")
1650            .args(["config", "core.hooksPath", "custom-hooks"])
1651            .current_dir(&repo_path)
1652            .output()
1653            .unwrap();
1654
1655        // Create the custom hooks directory
1656        let custom_hooks_dir = repo_path.join("custom-hooks");
1657        std::fs::create_dir_all(&custom_hooks_dir).unwrap();
1658
1659        let _manager = HooksManager::new(&repo_path).unwrap();
1660
1661        assert_eq!(_manager.repo_path, repo_path);
1662        // Should create a HooksManager successfully
1663        assert!(!_manager.repo_id.is_empty());
1664    }
1665
1666    #[test]
1667    fn test_hook_chaining_with_existing_hooks() {
1668        let (_temp_dir, repo_path) = create_test_repo();
1669        let manager = HooksManager::new(&repo_path).unwrap();
1670
1671        let hook_type = HookType::PreCommit;
1672        let hook_path = repo_path.join(".git/hooks").join(hook_type.filename());
1673
1674        // Create an existing project hook
1675        let existing_hook_content = "#!/bin/bash\n# Project pre-commit hook\n./scripts/lint.sh\n";
1676        std::fs::write(&hook_path, existing_hook_content).unwrap();
1677        crate::utils::platform::make_executable(&hook_path).unwrap();
1678
1679        // Install cascade hook (uses core.hooksPath, doesn't modify original)
1680        let result = manager.install_hook(&hook_type);
1681        assert!(result.is_ok());
1682
1683        // Original hook should remain unchanged
1684        let original_content = std::fs::read_to_string(&hook_path).unwrap();
1685        assert!(original_content.contains("# Project pre-commit hook"));
1686        assert!(original_content.contains("./scripts/lint.sh"));
1687
1688        // Cascade hook should exist in cascade directory
1689        let cascade_hooks_dir = manager.get_cascade_hooks_dir().unwrap();
1690        let cascade_hook_path = cascade_hooks_dir.join(hook_type.filename());
1691        assert!(cascade_hook_path.exists());
1692
1693        // Test uninstall removes cascade hooks but leaves original
1694        let uninstall_result = manager.uninstall_hook(&hook_type);
1695        assert!(uninstall_result.is_ok());
1696
1697        // Original hook should still exist and be unchanged
1698        let after_uninstall = std::fs::read_to_string(&hook_path).unwrap();
1699        assert!(after_uninstall.contains("# Project pre-commit hook"));
1700        assert!(after_uninstall.contains("./scripts/lint.sh"));
1701
1702        // Cascade hook should be removed
1703        assert!(!cascade_hook_path.exists());
1704    }
1705
1706    #[test]
1707    fn test_hook_installation() {
1708        let (_temp_dir, repo_path) = create_test_repo();
1709        let manager = HooksManager::new(&repo_path).unwrap();
1710
1711        // Test installing post-commit hook
1712        let hook_type = HookType::PostCommit;
1713        let result = manager.install_hook(&hook_type);
1714        assert!(result.is_ok());
1715
1716        // Verify hook file exists in cascade hooks directory
1717        let hook_filename = hook_type.filename();
1718        let cascade_hooks_dir = manager.get_cascade_hooks_dir().unwrap();
1719        let hook_path = cascade_hooks_dir.join(&hook_filename);
1720        assert!(hook_path.exists());
1721
1722        // Verify hook is executable (platform-specific)
1723        #[cfg(unix)]
1724        {
1725            use std::os::unix::fs::PermissionsExt;
1726            let metadata = std::fs::metadata(&hook_path).unwrap();
1727            let permissions = metadata.permissions();
1728            assert!(permissions.mode() & 0o111 != 0); // Check executable bit
1729        }
1730
1731        #[cfg(windows)]
1732        {
1733            // On Windows, verify it has .bat extension and file exists
1734            assert!(hook_filename.ends_with(".bat"));
1735            assert!(hook_path.exists());
1736        }
1737    }
1738
1739    #[test]
1740    fn test_hook_detection() {
1741        let (_temp_dir, repo_path) = create_test_repo();
1742        let _manager = HooksManager::new(&repo_path).unwrap();
1743
1744        // Check if hook files exist with platform-appropriate filenames
1745        let post_commit_path = repo_path
1746            .join(".git/hooks")
1747            .join(HookType::PostCommit.filename());
1748        let pre_push_path = repo_path
1749            .join(".git/hooks")
1750            .join(HookType::PrePush.filename());
1751        let commit_msg_path = repo_path
1752            .join(".git/hooks")
1753            .join(HookType::CommitMsg.filename());
1754
1755        // Initially no hooks should be installed
1756        assert!(!post_commit_path.exists());
1757        assert!(!pre_push_path.exists());
1758        assert!(!commit_msg_path.exists());
1759    }
1760
1761    #[test]
1762    fn test_hook_validation() {
1763        let (_temp_dir, repo_path) = create_test_repo();
1764        let manager = HooksManager::new(&repo_path).unwrap();
1765
1766        // Test validation - may fail in CI due to missing dependencies
1767        let validation = manager.validate_prerequisites();
1768        // In CI environment, validation might fail due to missing configuration
1769        // Just ensure it doesn't panic
1770        let _ = validation; // Don't assert ok/err, just ensure no panic
1771
1772        // Test branch validation - should work regardless of environment
1773        let branch_validation = manager.validate_branch_suitability();
1774        // Branch validation should work in most cases, but be tolerant
1775        let _ = branch_validation; // Don't assert ok/err, just ensure no panic
1776    }
1777
1778    #[test]
1779    fn test_hook_uninstallation() {
1780        let (_temp_dir, repo_path) = create_test_repo();
1781        let manager = HooksManager::new(&repo_path).unwrap();
1782
1783        // Install then uninstall hook
1784        let hook_type = HookType::PostCommit;
1785        manager.install_hook(&hook_type).unwrap();
1786
1787        let cascade_hooks_dir = manager.get_cascade_hooks_dir().unwrap();
1788        let hook_path = cascade_hooks_dir.join(hook_type.filename());
1789        assert!(hook_path.exists());
1790
1791        let result = manager.uninstall_hook(&hook_type);
1792        assert!(result.is_ok());
1793        assert!(!hook_path.exists());
1794    }
1795
1796    #[test]
1797    fn test_hook_content_generation() {
1798        let (_temp_dir, repo_path) = create_test_repo();
1799        let manager = HooksManager::new(&repo_path).unwrap();
1800
1801        // Use a known binary name for testing
1802        let binary_name = "cascade-cli";
1803
1804        // Test post-commit hook generation
1805        let post_commit_content = manager.generate_post_commit_hook(binary_name);
1806        #[cfg(windows)]
1807        {
1808            assert!(post_commit_content.contains("@echo off"));
1809            assert!(post_commit_content.contains("rem Cascade CLI Hook"));
1810        }
1811        #[cfg(not(windows))]
1812        {
1813            assert!(post_commit_content.contains("#!/bin/sh"));
1814            assert!(post_commit_content.contains("# Cascade CLI Hook"));
1815        }
1816        assert!(post_commit_content.contains(binary_name));
1817
1818        // Test pre-push hook generation
1819        let pre_push_content = manager.generate_pre_push_hook(binary_name);
1820        #[cfg(windows)]
1821        {
1822            assert!(pre_push_content.contains("@echo off"));
1823            assert!(pre_push_content.contains("rem Cascade CLI Hook"));
1824        }
1825        #[cfg(not(windows))]
1826        {
1827            assert!(pre_push_content.contains("#!/bin/sh"));
1828            assert!(pre_push_content.contains("# Cascade CLI Hook"));
1829        }
1830        assert!(pre_push_content.contains(binary_name));
1831
1832        // Test commit-msg hook generation (doesn't use binary, just validates)
1833        let commit_msg_content = manager.generate_commit_msg_hook(binary_name);
1834        #[cfg(windows)]
1835        {
1836            assert!(commit_msg_content.contains("@echo off"));
1837            assert!(commit_msg_content.contains("rem Cascade CLI Hook"));
1838        }
1839        #[cfg(not(windows))]
1840        {
1841            assert!(commit_msg_content.contains("#!/bin/sh"));
1842            assert!(commit_msg_content.contains("# Cascade CLI Hook"));
1843        }
1844
1845        // Test prepare-commit-msg hook generation (does use binary)
1846        let prepare_commit_content = manager.generate_prepare_commit_msg_hook(binary_name);
1847        #[cfg(windows)]
1848        {
1849            assert!(prepare_commit_content.contains("@echo off"));
1850            assert!(prepare_commit_content.contains("rem Cascade CLI Hook"));
1851        }
1852        #[cfg(not(windows))]
1853        {
1854            assert!(prepare_commit_content.contains("#!/bin/sh"));
1855            assert!(prepare_commit_content.contains("# Cascade CLI Hook"));
1856        }
1857        assert!(prepare_commit_content.contains(binary_name));
1858    }
1859
1860    #[test]
1861    fn test_hook_status_reporting() {
1862        let (_temp_dir, repo_path) = create_test_repo();
1863        let manager = HooksManager::new(&repo_path).unwrap();
1864
1865        // Check repository type detection - should work with our test setup
1866        let repo_type = manager.detect_repository_type().unwrap();
1867        // In CI environment, this might be Unknown if remote detection fails
1868        assert!(matches!(
1869            repo_type,
1870            RepositoryType::Bitbucket | RepositoryType::Unknown
1871        ));
1872
1873        // Check branch type detection
1874        let branch_type = manager.detect_branch_type().unwrap();
1875        // Should be on main/master branch, but allow for different default branch names
1876        assert!(matches!(
1877            branch_type,
1878            BranchType::Main | BranchType::Unknown
1879        ));
1880    }
1881
1882    #[test]
1883    fn test_force_installation() {
1884        let (_temp_dir, repo_path) = create_test_repo();
1885        let manager = HooksManager::new(&repo_path).unwrap();
1886
1887        // Create a fake existing hook with platform-appropriate content
1888        let hook_filename = HookType::PostCommit.filename();
1889        let hook_path = repo_path.join(".git/hooks").join(&hook_filename);
1890
1891        #[cfg(windows)]
1892        let existing_content = "@echo off\necho existing hook";
1893        #[cfg(not(windows))]
1894        let existing_content = "#!/bin/sh\necho 'existing hook'";
1895
1896        std::fs::write(&hook_path, existing_content).unwrap();
1897
1898        // Install cascade hook (uses core.hooksPath, doesn't modify original)
1899        let hook_type = HookType::PostCommit;
1900        let result = manager.install_hook(&hook_type);
1901        assert!(result.is_ok());
1902
1903        // Verify cascade hook exists in cascade directory
1904        let cascade_hooks_dir = manager.get_cascade_hooks_dir().unwrap();
1905        let cascade_hook_path = cascade_hooks_dir.join(&hook_filename);
1906        assert!(cascade_hook_path.exists());
1907
1908        // Original hook should remain unchanged
1909        let original_content = std::fs::read_to_string(&hook_path).unwrap();
1910        assert!(original_content.contains("existing hook"));
1911
1912        // Cascade hook should contain cascade logic
1913        let cascade_content = std::fs::read_to_string(&cascade_hook_path).unwrap();
1914        assert!(cascade_content.contains("cascade-cli") || cascade_content.contains("ca"));
1915    }
1916}