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