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 💡 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 💡 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 \"💡 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 \"💡 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                 if \"%CASCADE_INTERNAL_PUSH%\"==\"1\" (\n\
863                     exit /b 0\n\
864                 )\n\n\
865                 rem Check for force push from user\n\
866                 echo %* | findstr /C:\"--force\" /C:\"--force-with-lease\" /C:\"-f\" >nul\n\
867                 if %ERRORLEVEL% equ 0 (\n\
868                     echo ERROR: Force push detected\n\
869                     echo Cascade CLI uses stacked diffs - force pushes can break stack integrity\n\
870                     echo.\n\
871                     echo Instead, try these commands:\n\
872                     echo   ca sync      - Sync with remote changes ^(handles rebasing^)\n\
873                     echo   ca push      - Push all unpushed commits\n\
874                     echo   ca submit    - Submit all entries for review\n\
875                     echo   ca autoland  - Auto-merge when approved + builds pass\n\
876                     echo.\n\
877                     echo If you really need to force push:\n\
878                     echo   git push --force-with-lease [remote] [branch]\n\
879                     echo   ^(But consider if this will affect other stack entries^)\n\
880                     exit /b 1\n\
881                 )\n\n\
882                 rem Find repository root and check if Cascade is initialized\n\
883                 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
884                 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
885                 if not exist \"%REPO_ROOT%\\.cascade\" (\n\
886                     exit /b 0\n\
887                 )\n\n\
888                 rem Validate stack state (silent unless error)\n\
889                 \"{cascade_cli}\" validate >nul 2>&1\n\
890                 if %ERRORLEVEL% neq 0 (\n\
891                     echo Stack validation failed - run 'ca validate' for details\n\
892                     exit /b 1\n\
893                 )\n"
894            )
895        }
896
897        #[cfg(not(windows))]
898        {
899            format!(
900                "#!/bin/sh\n\
901                 # Cascade CLI Hook - Pre Push\n\
902                 # Prevents force pushes and validates stack state\n\n\
903                 set -e\n\n\
904                 # Allow force pushes from Cascade internal commands (ca sync, ca submit, etc.)\n\
905                 if [ \"$CASCADE_INTERNAL_PUSH\" = \"1\" ]; then\n\
906                     exit 0\n\
907                 fi\n\n\
908                 # Check for force push from user\n\
909                 if echo \"$*\" | grep -q -- \"--force\\|--force-with-lease\\|-f\"; then\n\
910                     echo \"ERROR: Force push detected\"\n\
911                     echo \"Cascade CLI uses stacked diffs - force pushes can break stack integrity\"\n\
912                     echo \"\"\n\
913                     echo \"Instead, try these commands:\"\n\
914                     echo \"  ca sync      - Sync with remote changes (handles rebasing)\"\n\
915                     echo \"  ca push      - Push all unpushed commits\"\n\
916                     echo \"  ca submit    - Submit all entries for review\"\n\
917                     echo \"  ca autoland  - Auto-merge when approved + builds pass\"\n\
918                     echo \"\"\n\
919                     echo \"If you really need to force push:\"\n\
920                     echo \"  git push --force-with-lease [remote] [branch]\"\n\
921                     echo \"  (But consider if this will affect other stack entries)\"\n\
922                     exit 1\n\
923                 fi\n\n\
924                 # Find repository root and check if Cascade is initialized\n\
925                 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
926                 if [ ! -d \"$REPO_ROOT/.cascade\" ]; then\n\
927                     exit 0\n\
928                 fi\n\n\
929                 # Validate stack state (silent unless error)\n\
930                 if ! \"{cascade_cli}\" validate > /dev/null 2>&1; then\n\
931                     echo \"Stack validation failed - run 'ca validate' for details\"\n\
932                     exit 1\n\
933                     fi\n"
934            )
935        }
936    }
937
938    fn generate_commit_msg_hook(&self, _cascade_cli: &str) -> String {
939        #[cfg(windows)]
940        {
941            r#"@echo off
942rem Cascade CLI Hook - Commit Message
943rem Validates commit message format
944
945set COMMIT_MSG_FILE=%1
946if "%COMMIT_MSG_FILE%"=="" (
947    echo ❌ No commit message file provided
948    exit /b 1
949)
950
951rem Read commit message (Windows batch is limited, but this covers basic cases)
952for /f "delims=" %%i in ('type "%COMMIT_MSG_FILE%"') do set COMMIT_MSG=%%i
953
954rem Skip validation for merge commits, fixup commits, etc.
955echo %COMMIT_MSG% | findstr /B /C:"Merge" /C:"Revert" /C:"fixup!" /C:"squash!" >nul
956if %ERRORLEVEL% equ 0 exit /b 0
957
958rem Find repository root and check if Cascade is initialized
959for /f "tokens=*" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i
960if "%REPO_ROOT%"=="" set REPO_ROOT=.
961if not exist "%REPO_ROOT%\.cascade" exit /b 0
962
963rem Basic commit message validation
964echo %COMMIT_MSG% | findstr /R "^..........*" >nul
965if %ERRORLEVEL% neq 0 (
966    echo ERROR: Commit message too short (minimum 10 characters)
967    echo TIP: Write a descriptive commit message for better stack management
968    exit /b 1
969)
970
971rem Validation passed (silent success)
972exit /b 0
973"#
974            .to_string()
975        }
976
977        #[cfg(not(windows))]
978        {
979            r#"#!/bin/sh
980# Cascade CLI Hook - Commit Message
981# Validates commit message format
982
983set -e
984
985COMMIT_MSG_FILE="$1"
986COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
987
988# Skip validation for merge commits, fixup commits, etc.
989if echo "$COMMIT_MSG" | grep -E "^(Merge|Revert|fixup!|squash!)" > /dev/null; then
990    exit 0
991fi
992
993# Find repository root and check if Cascade is initialized
994REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")
995if [ ! -d "$REPO_ROOT/.cascade" ]; then
996    exit 0
997fi
998
999# Basic commit message validation
1000if [ ${#COMMIT_MSG} -lt 10 ]; then
1001    echo "ERROR: Commit message too short (minimum 10 characters)"
1002    echo "TIP: Write a descriptive commit message for better stack management"
1003    exit 1
1004fi
1005
1006# Validation passed (silent success)
1007exit 0
1008"#
1009            .to_string()
1010        }
1011    }
1012
1013    #[allow(clippy::uninlined_format_args)]
1014    fn generate_pre_commit_hook(&self, cascade_cli: &str) -> String {
1015        #[cfg(windows)]
1016        {
1017            format!(
1018                "@echo off\n\
1019                 rem Cascade CLI Hook - Pre Commit\n\
1020                 rem Smart edit mode guidance for better UX\n\n\
1021                 rem Check if Cascade is initialized\n\
1022                 for /f \\\"tokens=*\\\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
1023                 if \\\"%REPO_ROOT%\\\"==\\\"\\\" set REPO_ROOT=.\n\
1024                 if not exist \\\"%REPO_ROOT%\\.cascade\\\" exit /b 0\n\n\
1025                 rem Check if we're on an entry branch\n\
1026                 for /f \\\"tokens=*\\\" %%i in ('git branch --show-current 2^>nul') do set CURRENT_BRANCH=%%i\n\
1027                 echo %CURRENT_BRANCH% | findstr /r \\\".*-entry-[0-9][0-9]*$\\\" >nul\n\
1028                 if %ERRORLEVEL% neq 0 exit /b 0\n\n\
1029                 rem Get edit status\n\
1030                 for /f \\\"tokens=*\\\" %%i in ('\\\"{0}\\\" entry status --quiet 2^>nul') do set EDIT_STATUS=%%i\n\
1031                 if \\\"%EDIT_STATUS%\\\"==\\\"\\\" set EDIT_STATUS=inactive\n\n\
1032                 rem Check if edit status is active\n\
1033                 echo %EDIT_STATUS% | findstr /b \\\"active:\\\" >nul\n\
1034                 if %ERRORLEVEL% equ 0 (\n\
1035                     echo WARNING: You're in EDIT MODE for a stack entry\n\
1036                     echo.\n\
1037                     echo Choose your action:\n\
1038                     echo   [A] Amend: Modify the current entry ^(default^)\n\
1039                     echo   [N] New:   Create new entry on top\n\
1040                     echo   [C] Cancel: Stop and think about it\n\
1041                     echo.\n\
1042                     set /p choice=\\\"Your choice (A/n/c): \\\"\n\
1043                     if \\\"%choice%\\\"==\\\"\\\" set choice=A\n\
1044                     \n\
1045                     if /i \\\"%choice%\\\"==\\\"A\\\" (\n\
1046                         echo Amending current entry...\n\
1047                         git add -A\n\
1048                         \\\"{0}\\\" entry amend --all\n\
1049                         exit /b %ERRORLEVEL%\n\
1050                     ) else if /i \\\"%choice%\\\"==\\\"N\\\" (\n\
1051                         echo Creating new stack entry...\n\
1052                         exit /b 0\n\
1053                     ) else if /i \\\"%choice%\\\"==\\\"C\\\" (\n\
1054                         echo Commit cancelled\n\
1055                         exit /b 1\n\
1056                     ) else (\n\
1057                         echo Invalid choice. Please choose A, n, or c\n\
1058                         exit /b 1\n\
1059                     )\n\
1060                 ) else (\n\
1061                     rem On entry branch but NOT in edit mode\n\
1062                     echo.\n\
1063                     echo WARNING: You're on a stack entry branch\n\
1064                     echo.\n\
1065                     echo Current branch: %CURRENT_BRANCH%\n\
1066                     echo.\n\
1067                     echo ERROR: Cannot commit directly to entry branches\n\
1068                     echo.\n\
1069                     echo Did you mean to:\n\
1070                     echo   - {0} entry checkout ^<N^>  ^(enter proper edit mode^)\n\
1071                     echo   - git checkout ^<working-branch^>  ^(switch to working branch^)\n\
1072                     echo   - {0} stack list  ^(see your stacks^)\n\
1073                     echo.\n\
1074                     exit /b 1\n\
1075                 )\n\n\
1076                 rem Not on entry branch, proceed normally\n\
1077                 exit /b 0\n",
1078                cascade_cli
1079            )
1080        }
1081
1082        #[cfg(not(windows))]
1083        {
1084            // Use string building to avoid escaping issues with format! macros
1085            // Check the OUTPUT of entry status, not just exit code
1086            let status_check = format!(
1087                "EDIT_STATUS=$(\"{}\" entry status --quiet 2>/dev/null || echo \"inactive\")",
1088                cascade_cli
1089            );
1090            let amend_line = format!("           \"{}\" entry amend --all", cascade_cli);
1091
1092            vec![
1093                "#!/bin/sh".to_string(),
1094                "# Cascade CLI Hook - Pre Commit".to_string(),
1095                "# Smart edit mode guidance for better UX".to_string(),
1096                "".to_string(),
1097                "set -e".to_string(),
1098                "".to_string(),
1099                "# Check if Cascade is initialized".to_string(),
1100                r#"REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")"#.to_string(),
1101                r#"if [ ! -d "$REPO_ROOT/.cascade" ]; then"#.to_string(),
1102                "    exit 0".to_string(),
1103                "fi".to_string(),
1104                "".to_string(),
1105                "# Check if we're on an entry branch (even without edit mode set)".to_string(),
1106                r#"CURRENT_BRANCH=$(git branch --show-current 2>/dev/null)"#.to_string(),
1107                r#"IS_ENTRY_BRANCH=$(echo "$CURRENT_BRANCH" | grep -qE '\-entry\-[0-9]+$' && echo "yes" || echo "no")"#.to_string(),
1108                "".to_string(),
1109                "# If on entry branch, check if edit mode is properly set".to_string(),
1110                r#"if [ "$IS_ENTRY_BRANCH" = "yes" ]; then"#.to_string(),
1111                "    ".to_string(),
1112                status_check,
1113                "    ".to_string(),
1114                "    # Check if edit mode is active".to_string(),
1115                r#"    if echo "$EDIT_STATUS" | grep -q "^active:"; then"#.to_string(),
1116                "        # Proper edit mode - show options".to_string(),
1117                r#"        echo "WARNING: You're in EDIT MODE for a stack entry""#.to_string(),
1118                r#"        echo """#.to_string(),
1119                "        ".to_string(),
1120                "        # Check if running interactively (stdin is a terminal)".to_string(),
1121                "        if [ -t 0 ]; then".to_string(),
1122                "            # Interactive mode - prompt user".to_string(),
1123                r#"            echo "Choose your action:""#.to_string(),
1124                r#"            echo "  [A] Amend: Modify the current entry (default)""#.to_string(),
1125                r#"            echo "  [N] New:   Create new entry on top""#.to_string(),
1126                r#"            echo "  [C] Cancel: Stop and think about it""#.to_string(),
1127                r#"            echo """#.to_string(),
1128                "            ".to_string(),
1129                "            # Read user choice with default to amend".to_string(),
1130                r#"            read -p "Your choice (A/n/c): " choice"#.to_string(),
1131                "            choice=${choice:-A}".to_string(),
1132                "        else".to_string(),
1133                "            # Non-interactive (e.g., git commit -m) - block and provide guidance".to_string(),
1134                r#"            echo """#.to_string(),
1135                r#"            echo "ERROR: Cannot use 'git commit -m' while in edit mode""#.to_string(),
1136                r#"            echo """#.to_string(),
1137                r#"            echo "Choose one of these instead:""#.to_string(),
1138                r#"            echo "  - git commit              (interactive: choose Amend/New)""#.to_string(),
1139                format!("            echo \"  - {} entry amend -m 'msg'  (amend with message)\"", cascade_cli),
1140                format!("            echo \"  - {} entry amend          (amend, edit message in editor)\"", cascade_cli),
1141                r#"            echo "  - git checkout <branch>   (exit edit mode first)""#.to_string(),
1142                r#"            echo """#.to_string(),
1143                "            exit 1".to_string(),
1144                "        fi".to_string(),
1145                "        ".to_string(),
1146                "        ".to_string(),
1147                r#"        case "$choice" in"#.to_string(),
1148                "            [Aa])".to_string(),
1149                r#"                echo "Amending current entry...""#.to_string(),
1150                "                # Stage all changes first (like git commit -a)".to_string(),
1151                "                git add -A".to_string(),
1152                "                # Use ca entry amend to properly update entry + working branch"
1153                    .to_string(),
1154                amend_line.replace("           ", "                "),
1155                "                exit $?".to_string(),
1156                "                ;;".to_string(),
1157                "            [Nn])".to_string(),
1158                r#"                echo "Creating new stack entry...""#.to_string(),
1159                "                # Let the commit proceed normally (will create new commit)".to_string(),
1160                "                exit 0".to_string(),
1161                "                ;;".to_string(),
1162                "            [Cc])".to_string(),
1163                r#"                echo "Commit cancelled""#.to_string(),
1164                "                exit 1".to_string(),
1165                "                ;;".to_string(),
1166                "            *)".to_string(),
1167                r#"                echo "Invalid choice. Please choose A, n, or c""#.to_string(),
1168                "                exit 1".to_string(),
1169                "                ;;".to_string(),
1170                "        esac".to_string(),
1171                "    else".to_string(),
1172                "        # On entry branch but NOT in edit mode - someone bypassed ca entry checkout!".to_string(),
1173                r#"        echo """#.to_string(),
1174                r#"        echo "WARNING: You're on a stack entry branch""#.to_string(),
1175                r#"        echo """#.to_string(),
1176                r#"        echo "Current branch: $CURRENT_BRANCH""#.to_string(),
1177                r#"        echo """#.to_string(),
1178                r#"        echo "ERROR: Cannot commit directly to entry branches""#.to_string(),
1179                r#"        echo """#.to_string(),
1180                r#"        echo "Did you mean to:""#.to_string(),
1181                format!("        echo \"  - {} entry checkout <N>  (enter proper edit mode)\"", cascade_cli),
1182                r#"        echo "  - git checkout <working-branch>  (switch to working branch)""#.to_string(),
1183                format!("        echo \"  - {} stack list            (see your stacks)\"", cascade_cli),
1184                r#"        echo """#.to_string(),
1185                "        exit 1".to_string(),
1186                "    fi".to_string(),
1187                "fi".to_string(),
1188                "".to_string(),
1189                "# Not on entry branch, proceed normally".to_string(),
1190                "exit 0".to_string(),
1191            ]
1192            .join("\n")
1193        }
1194    }
1195
1196    fn generate_prepare_commit_msg_hook(&self, cascade_cli: &str) -> String {
1197        #[cfg(windows)]
1198        {
1199            format!(
1200                "@echo off\n\
1201                 rem Cascade CLI Hook - Prepare Commit Message\n\
1202                 rem Adds stack context to commit messages\n\n\
1203                 set COMMIT_MSG_FILE=%1\n\
1204                 set COMMIT_SOURCE=%2\n\
1205                 set COMMIT_SHA=%3\n\n\
1206                 rem Only modify message if it's a regular commit (not merge, template, etc.)\n\
1207                 if not \"%COMMIT_SOURCE%\"==\"\" if not \"%COMMIT_SOURCE%\"==\"message\" exit /b 0\n\n\
1208                 rem Find repository root and check if Cascade is initialized\n\
1209                 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
1210                 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
1211                 if not exist \"%REPO_ROOT%\\.cascade\" exit /b 0\n\n\
1212                 rem Check if in edit mode first\n\
1213                 for /f \"tokens=*\" %%i in ('\"{cascade_cli}\" entry status --quiet 2^>nul') do set EDIT_STATUS=%%i\n\
1214                 if \"%EDIT_STATUS%\"==\"\" set EDIT_STATUS=inactive\n\n\
1215                 if not \"%EDIT_STATUS%\"==\"inactive\" (\n\
1216                     rem In edit mode - provide smart guidance\n\
1217                     set /p CURRENT_MSG=<%COMMIT_MSG_FILE%\n\n\
1218                     rem Skip if message already has edit guidance\n\
1219                     echo !CURRENT_MSG! | findstr \"[EDIT MODE]\" >nul\n\
1220                     if %ERRORLEVEL% equ 0 exit /b 0\n\n\
1221                     rem Add edit mode guidance to commit message\n\
1222                     echo.\n\
1223                     echo # [EDIT MODE] You're editing a stack entry\n\
1224                     echo #\n\
1225                     echo # Choose your action:\n\
1226                     echo #   🔄 AMEND: To modify the current entry, use:\n\
1227                     echo #       git commit --amend\n\
1228                     echo #\n\
1229                     echo #   ➕ NEW: To create a new entry on top, use:\n\
1230                     echo #       git commit    ^(this command^)\n\
1231                     echo #\n\
1232                     echo # 💡 After committing, run 'ca sync' to update PRs\n\
1233                     echo.\n\
1234                     type \"%COMMIT_MSG_FILE%\"\n\
1235                 ) > \"%COMMIT_MSG_FILE%.tmp\" && (\n\
1236                     move \"%COMMIT_MSG_FILE%.tmp\" \"%COMMIT_MSG_FILE%\"\n\
1237                 ) else (\n\
1238                     rem Regular stack mode - check for active stack\n\
1239                     for /f \"tokens=*\" %%i in ('\"{cascade_cli}\" stack list --active --format=name 2^>nul') do set ACTIVE_STACK=%%i\n\n\
1240                     if not \"%ACTIVE_STACK%\"==\"\" (\n\
1241                         rem Get current commit message\n\
1242                         set /p CURRENT_MSG=<%COMMIT_MSG_FILE%\n\n\
1243                         rem Skip if message already has stack context\n\
1244                         echo !CURRENT_MSG! | findstr \"[stack:\" >nul\n\
1245                         if %ERRORLEVEL% equ 0 exit /b 0\n\n\
1246                         rem Add stack context to commit message\n\
1247                         echo.\n\
1248                         echo # Stack: %ACTIVE_STACK%\n\
1249                         echo # This commit will be added to the active stack automatically.\n\
1250                         echo # Use 'ca stack status' to see the current stack state.\n\
1251                         type \"%COMMIT_MSG_FILE%\"\n\
1252                     ) > \"%COMMIT_MSG_FILE%.tmp\"\n\
1253                     move \"%COMMIT_MSG_FILE%.tmp\" \"%COMMIT_MSG_FILE%\"\n\
1254                 )\n"
1255            )
1256        }
1257
1258        #[cfg(not(windows))]
1259        {
1260            format!(
1261                "#!/bin/sh\n\
1262                 # Cascade CLI Hook - Prepare Commit Message\n\
1263                 # Adds stack context to commit messages\n\n\
1264                 set -e\n\n\
1265                 COMMIT_MSG_FILE=\"$1\"\n\
1266                 COMMIT_SOURCE=\"$2\"\n\
1267                 COMMIT_SHA=\"$3\"\n\n\
1268                 # Only modify message if it's a regular commit (not merge, template, etc.)\n\
1269                 if [ \"$COMMIT_SOURCE\" != \"\" ] && [ \"$COMMIT_SOURCE\" != \"message\" ]; then\n\
1270                     exit 0\n\
1271                 fi\n\n\
1272                 # Find repository root and check if Cascade is initialized\n\
1273                 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
1274                 if [ ! -d \"$REPO_ROOT/.cascade\" ]; then\n\
1275                     exit 0\n\
1276                 fi\n\n\
1277                 # Check if in edit mode first\n\
1278                 EDIT_STATUS=$(\"{cascade_cli}\" entry status --quiet 2>/dev/null || echo \"inactive\")\n\
1279                 \n\
1280                 if [ \"$EDIT_STATUS\" != \"inactive\" ]; then\n\
1281                     # In edit mode - provide smart guidance\n\
1282                     CURRENT_MSG=$(cat \"$COMMIT_MSG_FILE\")\n\
1283                     \n\
1284                     # Skip if message already has edit guidance\n\
1285                     if echo \"$CURRENT_MSG\" | grep -q \"\\[EDIT MODE\\]\"; then\n\
1286                         exit 0\n\
1287                     fi\n\
1288                     \n\
1289                     echo \"\n\
1290                 # [EDIT MODE] You're editing a stack entry\n\
1291                 #\n\
1292                 # Choose your action:\n\
1293                 #   🔄 AMEND: To modify the current entry, use:\n\
1294                 #       git commit --amend\n\
1295                 #\n\
1296                 #   ➕ NEW: To create a new entry on top, use:\n\
1297                 #       git commit    (this command)\n\
1298                 #\n\
1299                 # 💡 After committing, run 'ca sync' to update PRs\n\
1300                 \n\
1301                 $CURRENT_MSG\" > \"$COMMIT_MSG_FILE\"\n\
1302                 else\n\
1303                     # Regular stack mode - check for active stack\n\
1304                     ACTIVE_STACK=$(\"{cascade_cli}\" stack list --active --format=name 2>/dev/null || echo \"\")\n\
1305                     \n\
1306                     if [ -n \"$ACTIVE_STACK\" ]; then\n\
1307                         # Get current commit message\n\
1308                         CURRENT_MSG=$(cat \"$COMMIT_MSG_FILE\")\n\
1309                         \n\
1310                         # Skip if message already has stack context\n\
1311                         if echo \"$CURRENT_MSG\" | grep -q \"\\[stack:\"; then\n\
1312                             exit 0\n\
1313                         fi\n\
1314                         \n\
1315                         # Add stack context to commit message\n\
1316                         echo \"\n\
1317                     # Stack: $ACTIVE_STACK\n\
1318                     # This commit will be added to the active stack automatically.\n\
1319                     # Use 'ca stack status' to see the current stack state.\n\
1320                     $CURRENT_MSG\" > \"$COMMIT_MSG_FILE\"\n\
1321                     fi\n\
1322                 fi\n"
1323            )
1324        }
1325    }
1326
1327    /// Detect repository type from remote URLs
1328    pub fn detect_repository_type(&self) -> Result<RepositoryType> {
1329        let output = Command::new("git")
1330            .args(["remote", "get-url", "origin"])
1331            .current_dir(&self.repo_path)
1332            .output()
1333            .map_err(|e| CascadeError::config(format!("Failed to get remote URL: {e}")))?;
1334
1335        if !output.status.success() {
1336            return Ok(RepositoryType::Unknown);
1337        }
1338
1339        let remote_url = String::from_utf8_lossy(&output.stdout)
1340            .trim()
1341            .to_lowercase();
1342
1343        if remote_url.contains("github.com") {
1344            Ok(RepositoryType::GitHub)
1345        } else if remote_url.contains("gitlab.com") || remote_url.contains("gitlab") {
1346            Ok(RepositoryType::GitLab)
1347        } else if remote_url.contains("dev.azure.com") || remote_url.contains("visualstudio.com") {
1348            Ok(RepositoryType::AzureDevOps)
1349        } else if remote_url.contains("bitbucket") {
1350            Ok(RepositoryType::Bitbucket)
1351        } else {
1352            Ok(RepositoryType::Unknown)
1353        }
1354    }
1355
1356    /// Detect current branch type
1357    pub fn detect_branch_type(&self) -> Result<BranchType> {
1358        let output = Command::new("git")
1359            .args(["branch", "--show-current"])
1360            .current_dir(&self.repo_path)
1361            .output()
1362            .map_err(|e| CascadeError::config(format!("Failed to get current branch: {e}")))?;
1363
1364        if !output.status.success() {
1365            return Ok(BranchType::Unknown);
1366        }
1367
1368        let branch_name = String::from_utf8_lossy(&output.stdout)
1369            .trim()
1370            .to_lowercase();
1371
1372        if branch_name == "main" || branch_name == "master" || branch_name == "develop" {
1373            Ok(BranchType::Main)
1374        } else if !branch_name.is_empty() {
1375            Ok(BranchType::Feature)
1376        } else {
1377            Ok(BranchType::Unknown)
1378        }
1379    }
1380
1381    /// Validate prerequisites for hook installation
1382    pub fn validate_prerequisites(&self) -> Result<()> {
1383        Output::check_start("Checking prerequisites for Cascade hooks");
1384
1385        // 1. Check repository type
1386        let repo_type = self.detect_repository_type()?;
1387        match repo_type {
1388            RepositoryType::Bitbucket => {
1389                Output::success("Bitbucket repository detected");
1390                Output::tip("Hooks will work great with 'ca submit' and 'ca autoland' for Bitbucket integration");
1391            }
1392            RepositoryType::GitHub => {
1393                Output::success("GitHub repository detected");
1394                Output::tip("Consider setting up GitHub Actions for CI/CD integration");
1395            }
1396            RepositoryType::GitLab => {
1397                Output::success("GitLab repository detected");
1398                Output::tip("GitLab CI integration works well with Cascade stacks");
1399            }
1400            RepositoryType::AzureDevOps => {
1401                Output::success("Azure DevOps repository detected");
1402                Output::tip("Azure Pipelines can be configured to work with Cascade workflows");
1403            }
1404            RepositoryType::Unknown => {
1405                Output::info(
1406                    "Unknown repository type - hooks will still work for local Git operations",
1407                );
1408            }
1409        }
1410
1411        // 2. Check Cascade configuration
1412        let config_dir = crate::config::get_repo_config_dir(&self.repo_path)?;
1413        let config_path = config_dir.join("config.json");
1414        if !config_path.exists() {
1415            return Err(CascadeError::config(
1416                "🚫 Cascade not initialized!\n\n\
1417                Please run 'ca init' or 'ca setup' first to configure Cascade CLI.\n\
1418                Hooks require proper Bitbucket Server configuration.\n\n\
1419                Use --force to install anyway (not recommended)."
1420                    .to_string(),
1421            ));
1422        }
1423
1424        // 3. Validate Bitbucket configuration
1425        let config = Settings::load_from_file(&config_path)?;
1426
1427        if config.bitbucket.url == "https://bitbucket.example.com"
1428            || config.bitbucket.url.contains("example.com")
1429        {
1430            return Err(CascadeError::config(
1431                "🚫 Invalid Bitbucket configuration!\n\n\
1432                Your Bitbucket URL appears to be a placeholder.\n\
1433                Please run 'ca setup' to configure a real Bitbucket Server.\n\n\
1434                Use --force to install anyway (not recommended)."
1435                    .to_string(),
1436            ));
1437        }
1438
1439        if config.bitbucket.project == "PROJECT" || config.bitbucket.repo == "repo" {
1440            return Err(CascadeError::config(
1441                "🚫 Incomplete Bitbucket configuration!\n\n\
1442                Your project/repository settings appear to be placeholders.\n\
1443                Please run 'ca setup' to complete configuration.\n\n\
1444                Use --force to install anyway (not recommended)."
1445                    .to_string(),
1446            ));
1447        }
1448
1449        Output::success("Prerequisites validation passed");
1450        Ok(())
1451    }
1452
1453    /// Validate branch suitability for hooks
1454    pub fn validate_branch_suitability(&self) -> Result<()> {
1455        let branch_type = self.detect_branch_type()?;
1456
1457        match branch_type {
1458            BranchType::Main => {
1459                return Err(CascadeError::config(
1460                    "🚫 Currently on main/master branch!\n\n\
1461                    Cascade hooks are designed for feature branch development.\n\
1462                    Working directly on main/master with stacked diffs can:\n\
1463                    • Complicate the commit history\n\
1464                    • Interfere with team collaboration\n\
1465                    • Break CI/CD workflows\n\n\
1466                    💡 Recommended workflow:\n\
1467                    1. Create a feature branch: git checkout -b feature/my-feature\n\
1468                    2. Install hooks: ca hooks install\n\
1469                    3. Develop with stacked commits (auto-added with hooks)\n\
1470                    4. Push & submit: ca push && ca submit (all by default)\n\
1471                    5. Auto-land when ready: ca autoland\n\n\
1472                    Use --force to install anyway (not recommended)."
1473                        .to_string(),
1474                ));
1475            }
1476            BranchType::Feature => {
1477                Output::success("Feature branch detected - suitable for stacked development");
1478            }
1479            BranchType::Unknown => {
1480                Output::warning("Unknown branch type - proceeding with caution");
1481            }
1482        }
1483
1484        Ok(())
1485    }
1486
1487    /// Confirm installation with user
1488    pub fn confirm_installation(&self) -> Result<()> {
1489        Output::section("Hook Installation Summary");
1490
1491        let hooks = vec![
1492            HookType::PostCommit,
1493            HookType::PrePush,
1494            HookType::CommitMsg,
1495            HookType::PrepareCommitMsg,
1496        ];
1497
1498        for hook in &hooks {
1499            Output::sub_item(format!("{}: {}", hook.filename(), hook.description()));
1500        }
1501
1502        println!();
1503        Output::section("These hooks will automatically");
1504        Output::bullet("Add commits to your active stack");
1505        Output::bullet("Validate commit messages");
1506        Output::bullet("Prevent force pushes that break stack integrity");
1507        Output::bullet("Add stack context to commit messages");
1508
1509        println!();
1510        Output::section("With hooks + new defaults, your workflow becomes");
1511        Output::sub_item("git commit       → Auto-added to stack");
1512        Output::sub_item("ca push          → Pushes all by default");
1513        Output::sub_item("ca submit        → Submits all by default");
1514        Output::sub_item("ca autoland      → Auto-merges when ready");
1515
1516        // Interactive confirmation to proceed with installation
1517        let should_install = Confirm::with_theme(&ColorfulTheme::default())
1518            .with_prompt("Install Cascade hooks?")
1519            .default(true)
1520            .interact()
1521            .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
1522
1523        if should_install {
1524            Output::success("Proceeding with installation");
1525            Ok(())
1526        } else {
1527            Err(CascadeError::config(
1528                "Installation cancelled by user".to_string(),
1529            ))
1530        }
1531    }
1532}
1533
1534/// Run hooks management commands
1535pub async fn install() -> Result<()> {
1536    install_with_options(false, false, false, false).await
1537}
1538
1539pub async fn install_essential() -> Result<()> {
1540    let current_dir = env::current_dir()
1541        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1542
1543    let repo_root = find_repository_root(&current_dir)
1544        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1545
1546    let hooks_manager = HooksManager::new(&repo_root)?;
1547    hooks_manager.install_essential()
1548}
1549
1550pub async fn install_with_options(
1551    skip_checks: bool,
1552    allow_main_branch: bool,
1553    yes: bool,
1554    force: bool,
1555) -> Result<()> {
1556    let current_dir = env::current_dir()
1557        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1558
1559    let repo_root = find_repository_root(&current_dir)
1560        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1561
1562    let hooks_manager = HooksManager::new(&repo_root)?;
1563
1564    let options = InstallOptions {
1565        check_prerequisites: !skip_checks,
1566        feature_branches_only: !allow_main_branch,
1567        confirm: !yes,
1568        force,
1569    };
1570
1571    hooks_manager.install_with_options(&options)
1572}
1573
1574pub async fn uninstall() -> Result<()> {
1575    let current_dir = env::current_dir()
1576        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1577
1578    let repo_root = find_repository_root(&current_dir)
1579        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1580
1581    let hooks_manager = HooksManager::new(&repo_root)?;
1582    hooks_manager.uninstall_all()
1583}
1584
1585pub async fn status() -> Result<()> {
1586    let current_dir = env::current_dir()
1587        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1588
1589    let repo_root = find_repository_root(&current_dir)
1590        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1591
1592    let hooks_manager = HooksManager::new(&repo_root)?;
1593    hooks_manager.list_installed_hooks()
1594}
1595
1596pub async fn install_hook(hook_name: &str) -> Result<()> {
1597    install_hook_with_options(hook_name, false, false).await
1598}
1599
1600pub async fn install_hook_with_options(
1601    hook_name: &str,
1602    skip_checks: bool,
1603    force: bool,
1604) -> Result<()> {
1605    let current_dir = env::current_dir()
1606        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1607
1608    let repo_root = find_repository_root(&current_dir)
1609        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1610
1611    let hooks_manager = HooksManager::new(&repo_root)?;
1612
1613    let hook_type = match hook_name {
1614        "post-commit" => HookType::PostCommit,
1615        "pre-push" => HookType::PrePush,
1616        "commit-msg" => HookType::CommitMsg,
1617        "pre-commit" => HookType::PreCommit,
1618        "prepare-commit-msg" => HookType::PrepareCommitMsg,
1619        _ => {
1620            return Err(CascadeError::config(format!(
1621                "Unknown hook type: {hook_name}"
1622            )))
1623        }
1624    };
1625
1626    // Run basic validation if not skipped
1627    if !skip_checks && !force {
1628        hooks_manager.validate_prerequisites()?;
1629    }
1630
1631    hooks_manager.install_hook(&hook_type)
1632}
1633
1634pub async fn uninstall_hook(hook_name: &str) -> Result<()> {
1635    let current_dir = env::current_dir()
1636        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1637
1638    let repo_root = find_repository_root(&current_dir)
1639        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1640
1641    let hooks_manager = HooksManager::new(&repo_root)?;
1642
1643    let hook_type = match hook_name {
1644        "post-commit" => HookType::PostCommit,
1645        "pre-push" => HookType::PrePush,
1646        "commit-msg" => HookType::CommitMsg,
1647        "pre-commit" => HookType::PreCommit,
1648        "prepare-commit-msg" => HookType::PrepareCommitMsg,
1649        _ => {
1650            return Err(CascadeError::config(format!(
1651                "Unknown hook type: {hook_name}"
1652            )))
1653        }
1654    };
1655
1656    hooks_manager.uninstall_hook(&hook_type)
1657}
1658
1659#[cfg(test)]
1660mod tests {
1661    use super::*;
1662    use std::process::Command;
1663    use tempfile::TempDir;
1664
1665    fn create_test_repo() -> (TempDir, std::path::PathBuf) {
1666        let temp_dir = TempDir::new().unwrap();
1667        let repo_path = temp_dir.path().to_path_buf();
1668
1669        // Initialize git repository
1670        Command::new("git")
1671            .args(["init"])
1672            .current_dir(&repo_path)
1673            .output()
1674            .unwrap();
1675        Command::new("git")
1676            .args(["config", "user.name", "Test"])
1677            .current_dir(&repo_path)
1678            .output()
1679            .unwrap();
1680        Command::new("git")
1681            .args(["config", "user.email", "test@test.com"])
1682            .current_dir(&repo_path)
1683            .output()
1684            .unwrap();
1685
1686        // Create initial commit
1687        std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1688        Command::new("git")
1689            .args(["add", "."])
1690            .current_dir(&repo_path)
1691            .output()
1692            .unwrap();
1693        Command::new("git")
1694            .args(["commit", "-m", "Initial"])
1695            .current_dir(&repo_path)
1696            .output()
1697            .unwrap();
1698
1699        // Initialize cascade
1700        crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
1701            .unwrap();
1702
1703        (temp_dir, repo_path)
1704    }
1705
1706    #[test]
1707    fn test_hooks_manager_creation() {
1708        let (_temp_dir, repo_path) = create_test_repo();
1709        let _manager = HooksManager::new(&repo_path).unwrap();
1710
1711        assert_eq!(_manager.repo_path, repo_path);
1712        // Should create a HooksManager successfully
1713        assert!(!_manager.repo_id.is_empty());
1714    }
1715
1716    #[test]
1717    fn test_hooks_manager_custom_hooks_path() {
1718        let (_temp_dir, repo_path) = create_test_repo();
1719
1720        // Set custom hooks path
1721        Command::new("git")
1722            .args(["config", "core.hooksPath", "custom-hooks"])
1723            .current_dir(&repo_path)
1724            .output()
1725            .unwrap();
1726
1727        // Create the custom hooks directory
1728        let custom_hooks_dir = repo_path.join("custom-hooks");
1729        std::fs::create_dir_all(&custom_hooks_dir).unwrap();
1730
1731        let _manager = HooksManager::new(&repo_path).unwrap();
1732
1733        assert_eq!(_manager.repo_path, repo_path);
1734        // Should create a HooksManager successfully
1735        assert!(!_manager.repo_id.is_empty());
1736    }
1737
1738    #[test]
1739    fn test_hook_chaining_with_existing_hooks() {
1740        let (_temp_dir, repo_path) = create_test_repo();
1741        let manager = HooksManager::new(&repo_path).unwrap();
1742
1743        let hook_type = HookType::PreCommit;
1744        let hook_path = repo_path.join(".git/hooks").join(hook_type.filename());
1745
1746        // Create an existing project hook
1747        let existing_hook_content = "#!/bin/bash\n# Project pre-commit hook\n./scripts/lint.sh\n";
1748        std::fs::write(&hook_path, existing_hook_content).unwrap();
1749        crate::utils::platform::make_executable(&hook_path).unwrap();
1750
1751        // Install cascade hook (uses core.hooksPath, doesn't modify original)
1752        let result = manager.install_hook(&hook_type);
1753        assert!(result.is_ok());
1754
1755        // Original hook should remain unchanged
1756        let original_content = std::fs::read_to_string(&hook_path).unwrap();
1757        assert!(original_content.contains("# Project pre-commit hook"));
1758        assert!(original_content.contains("./scripts/lint.sh"));
1759
1760        // Cascade hook should exist in cascade directory
1761        let cascade_hooks_dir = manager.get_cascade_hooks_dir().unwrap();
1762        let cascade_hook_path = cascade_hooks_dir.join(hook_type.filename());
1763        assert!(cascade_hook_path.exists());
1764
1765        // Test uninstall removes cascade hooks but leaves original
1766        let uninstall_result = manager.uninstall_hook(&hook_type);
1767        assert!(uninstall_result.is_ok());
1768
1769        // Original hook should still exist and be unchanged
1770        let after_uninstall = std::fs::read_to_string(&hook_path).unwrap();
1771        assert!(after_uninstall.contains("# Project pre-commit hook"));
1772        assert!(after_uninstall.contains("./scripts/lint.sh"));
1773
1774        // Cascade hook should be removed
1775        assert!(!cascade_hook_path.exists());
1776    }
1777
1778    #[test]
1779    fn test_hook_installation() {
1780        let (_temp_dir, repo_path) = create_test_repo();
1781        let manager = HooksManager::new(&repo_path).unwrap();
1782
1783        // Test installing post-commit hook
1784        let hook_type = HookType::PostCommit;
1785        let result = manager.install_hook(&hook_type);
1786        assert!(result.is_ok());
1787
1788        // Verify hook file exists in cascade hooks directory
1789        let hook_filename = hook_type.filename();
1790        let cascade_hooks_dir = manager.get_cascade_hooks_dir().unwrap();
1791        let hook_path = cascade_hooks_dir.join(&hook_filename);
1792        assert!(hook_path.exists());
1793
1794        // Verify hook is executable (platform-specific)
1795        #[cfg(unix)]
1796        {
1797            use std::os::unix::fs::PermissionsExt;
1798            let metadata = std::fs::metadata(&hook_path).unwrap();
1799            let permissions = metadata.permissions();
1800            assert!(permissions.mode() & 0o111 != 0); // Check executable bit
1801        }
1802
1803        #[cfg(windows)]
1804        {
1805            // On Windows, verify it has .bat extension and file exists
1806            assert!(hook_filename.ends_with(".bat"));
1807            assert!(hook_path.exists());
1808        }
1809    }
1810
1811    #[test]
1812    fn test_hook_detection() {
1813        let (_temp_dir, repo_path) = create_test_repo();
1814        let _manager = HooksManager::new(&repo_path).unwrap();
1815
1816        // Check if hook files exist with platform-appropriate filenames
1817        let post_commit_path = repo_path
1818            .join(".git/hooks")
1819            .join(HookType::PostCommit.filename());
1820        let pre_push_path = repo_path
1821            .join(".git/hooks")
1822            .join(HookType::PrePush.filename());
1823        let commit_msg_path = repo_path
1824            .join(".git/hooks")
1825            .join(HookType::CommitMsg.filename());
1826
1827        // Initially no hooks should be installed
1828        assert!(!post_commit_path.exists());
1829        assert!(!pre_push_path.exists());
1830        assert!(!commit_msg_path.exists());
1831    }
1832
1833    #[test]
1834    fn test_hook_validation() {
1835        let (_temp_dir, repo_path) = create_test_repo();
1836        let manager = HooksManager::new(&repo_path).unwrap();
1837
1838        // Test validation - may fail in CI due to missing dependencies
1839        let validation = manager.validate_prerequisites();
1840        // In CI environment, validation might fail due to missing configuration
1841        // Just ensure it doesn't panic
1842        let _ = validation; // Don't assert ok/err, just ensure no panic
1843
1844        // Test branch validation - should work regardless of environment
1845        let branch_validation = manager.validate_branch_suitability();
1846        // Branch validation should work in most cases, but be tolerant
1847        let _ = branch_validation; // Don't assert ok/err, just ensure no panic
1848    }
1849
1850    #[test]
1851    fn test_hook_uninstallation() {
1852        let (_temp_dir, repo_path) = create_test_repo();
1853        let manager = HooksManager::new(&repo_path).unwrap();
1854
1855        // Install then uninstall hook
1856        let hook_type = HookType::PostCommit;
1857        manager.install_hook(&hook_type).unwrap();
1858
1859        let cascade_hooks_dir = manager.get_cascade_hooks_dir().unwrap();
1860        let hook_path = cascade_hooks_dir.join(hook_type.filename());
1861        assert!(hook_path.exists());
1862
1863        let result = manager.uninstall_hook(&hook_type);
1864        assert!(result.is_ok());
1865        assert!(!hook_path.exists());
1866    }
1867
1868    #[test]
1869    fn test_hook_content_generation() {
1870        let (_temp_dir, repo_path) = create_test_repo();
1871        let manager = HooksManager::new(&repo_path).unwrap();
1872
1873        // Use a known binary name for testing
1874        let binary_name = "cascade-cli";
1875
1876        // Test post-commit hook generation
1877        let post_commit_content = manager.generate_post_commit_hook(binary_name);
1878        #[cfg(windows)]
1879        {
1880            assert!(post_commit_content.contains("@echo off"));
1881            assert!(post_commit_content.contains("rem Cascade CLI Hook"));
1882        }
1883        #[cfg(not(windows))]
1884        {
1885            assert!(post_commit_content.contains("#!/bin/sh"));
1886            assert!(post_commit_content.contains("# Cascade CLI Hook"));
1887        }
1888        assert!(post_commit_content.contains(binary_name));
1889
1890        // Test pre-push hook generation
1891        let pre_push_content = manager.generate_pre_push_hook(binary_name);
1892        #[cfg(windows)]
1893        {
1894            assert!(pre_push_content.contains("@echo off"));
1895            assert!(pre_push_content.contains("rem Cascade CLI Hook"));
1896        }
1897        #[cfg(not(windows))]
1898        {
1899            assert!(pre_push_content.contains("#!/bin/sh"));
1900            assert!(pre_push_content.contains("# Cascade CLI Hook"));
1901        }
1902        assert!(pre_push_content.contains(binary_name));
1903
1904        // Test commit-msg hook generation (doesn't use binary, just validates)
1905        let commit_msg_content = manager.generate_commit_msg_hook(binary_name);
1906        #[cfg(windows)]
1907        {
1908            assert!(commit_msg_content.contains("@echo off"));
1909            assert!(commit_msg_content.contains("rem Cascade CLI Hook"));
1910        }
1911        #[cfg(not(windows))]
1912        {
1913            assert!(commit_msg_content.contains("#!/bin/sh"));
1914            assert!(commit_msg_content.contains("# Cascade CLI Hook"));
1915        }
1916
1917        // Test prepare-commit-msg hook generation (does use binary)
1918        let prepare_commit_content = manager.generate_prepare_commit_msg_hook(binary_name);
1919        #[cfg(windows)]
1920        {
1921            assert!(prepare_commit_content.contains("@echo off"));
1922            assert!(prepare_commit_content.contains("rem Cascade CLI Hook"));
1923        }
1924        #[cfg(not(windows))]
1925        {
1926            assert!(prepare_commit_content.contains("#!/bin/sh"));
1927            assert!(prepare_commit_content.contains("# Cascade CLI Hook"));
1928        }
1929        assert!(prepare_commit_content.contains(binary_name));
1930    }
1931
1932    #[test]
1933    fn test_hook_status_reporting() {
1934        let (_temp_dir, repo_path) = create_test_repo();
1935        let manager = HooksManager::new(&repo_path).unwrap();
1936
1937        // Check repository type detection - should work with our test setup
1938        let repo_type = manager.detect_repository_type().unwrap();
1939        // In CI environment, this might be Unknown if remote detection fails
1940        assert!(matches!(
1941            repo_type,
1942            RepositoryType::Bitbucket | RepositoryType::Unknown
1943        ));
1944
1945        // Check branch type detection
1946        let branch_type = manager.detect_branch_type().unwrap();
1947        // Should be on main/master branch, but allow for different default branch names
1948        assert!(matches!(
1949            branch_type,
1950            BranchType::Main | BranchType::Unknown
1951        ));
1952    }
1953
1954    #[test]
1955    fn test_force_installation() {
1956        let (_temp_dir, repo_path) = create_test_repo();
1957        let manager = HooksManager::new(&repo_path).unwrap();
1958
1959        // Create a fake existing hook with platform-appropriate content
1960        let hook_filename = HookType::PostCommit.filename();
1961        let hook_path = repo_path.join(".git/hooks").join(&hook_filename);
1962
1963        #[cfg(windows)]
1964        let existing_content = "@echo off\necho existing hook";
1965        #[cfg(not(windows))]
1966        let existing_content = "#!/bin/sh\necho 'existing hook'";
1967
1968        std::fs::write(&hook_path, existing_content).unwrap();
1969
1970        // Install cascade hook (uses core.hooksPath, doesn't modify original)
1971        let hook_type = HookType::PostCommit;
1972        let result = manager.install_hook(&hook_type);
1973        assert!(result.is_ok());
1974
1975        // Verify cascade hook exists in cascade directory
1976        let cascade_hooks_dir = manager.get_cascade_hooks_dir().unwrap();
1977        let cascade_hook_path = cascade_hooks_dir.join(&hook_filename);
1978        assert!(cascade_hook_path.exists());
1979
1980        // Original hook should remain unchanged
1981        let original_content = std::fs::read_to_string(&hook_path).unwrap();
1982        assert!(original_content.contains("existing hook"));
1983
1984        // Cascade hook should contain cascade logic
1985        let cascade_content = std::fs::read_to_string(&cascade_hook_path).unwrap();
1986        assert!(cascade_content.contains("cascade-cli") || cascade_content.contains("ca"));
1987    }
1988}