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