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