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