cascade_cli/cli/commands/
hooks.rs

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