cascade_cli/cli/commands/
hooks.rs

1use crate::config::Settings;
2use crate::errors::{CascadeError, Result};
3use crate::git::find_repository_root;
4use std::env;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9/// Git repository type detection
10#[derive(Debug, Clone, PartialEq)]
11pub enum RepositoryType {
12    Bitbucket,
13    GitHub,
14    GitLab,
15    AzureDevOps,
16    Unknown,
17}
18
19/// Branch type classification
20#[derive(Debug, Clone, PartialEq)]
21pub enum BranchType {
22    Main,    // main, master, develop
23    Feature, // feature branches
24    Unknown,
25}
26
27/// Installation options for smart hook activation
28#[derive(Debug, Clone)]
29pub struct InstallOptions {
30    pub check_prerequisites: bool,
31    pub feature_branches_only: bool,
32    pub confirm: bool,
33    pub force: bool,
34}
35
36impl Default for InstallOptions {
37    fn default() -> Self {
38        Self {
39            check_prerequisites: true,
40            feature_branches_only: true,
41            confirm: true,
42            force: false,
43        }
44    }
45}
46
47/// Git hooks integration for Cascade CLI
48pub struct HooksManager {
49    repo_path: PathBuf,
50    hooks_dir: PathBuf,
51}
52
53/// Available Git hooks that Cascade can install
54#[derive(Debug, Clone)]
55pub enum HookType {
56    /// Validates commits are added to stacks
57    PostCommit,
58    /// Prevents force pushes and validates stack state
59    PrePush,
60    /// Validates commit messages follow conventions
61    CommitMsg,
62    /// Prepares commit message with stack context
63    PrepareCommitMsg,
64}
65
66impl HookType {
67    fn filename(&self) -> String {
68        let base_name = match self {
69            HookType::PostCommit => "post-commit",
70            HookType::PrePush => "pre-push",
71            HookType::CommitMsg => "commit-msg",
72            HookType::PrepareCommitMsg => "prepare-commit-msg",
73        };
74        format!(
75            "{}{}",
76            base_name,
77            crate::utils::platform::git_hook_extension()
78        )
79    }
80
81    fn description(&self) -> &'static str {
82        match self {
83            HookType::PostCommit => "Auto-add new commits to active stack",
84            HookType::PrePush => "Prevent force pushes and validate stack state",
85            HookType::CommitMsg => "Validate commit message format",
86            HookType::PrepareCommitMsg => "Add stack context to commit messages",
87        }
88    }
89}
90
91impl HooksManager {
92    pub fn new(repo_path: &Path) -> Result<Self> {
93        let hooks_dir = repo_path.join(".git").join("hooks");
94
95        if !hooks_dir.exists() {
96            return Err(CascadeError::config(
97                "Git hooks directory not found. Is this a Git repository?".to_string(),
98            ));
99        }
100
101        Ok(Self {
102            repo_path: repo_path.to_path_buf(),
103            hooks_dir,
104        })
105    }
106
107    /// Install all recommended Cascade hooks
108    pub fn install_all(&self) -> Result<()> {
109        self.install_with_options(&InstallOptions::default())
110    }
111
112    /// Install only essential hooks (for setup) - excludes post-commit
113    pub fn install_essential(&self) -> Result<()> {
114        println!("šŸŖ Installing essential Cascade Git hooks...");
115
116        let essential_hooks = vec![
117            HookType::PrePush,
118            HookType::CommitMsg,
119            HookType::PrepareCommitMsg,
120        ];
121
122        for hook in essential_hooks {
123            self.install_hook(&hook)?;
124        }
125
126        println!("āœ… Essential Cascade hooks installed successfully!");
127        println!(
128            "šŸ’” Note: Post-commit hook available separately with 'ca hooks install post-commit'"
129        );
130        println!("\nšŸ’” Hooks installed:");
131        self.list_installed_hooks()?;
132
133        Ok(())
134    }
135
136    /// Install hooks with smart validation options
137    pub fn install_with_options(&self, options: &InstallOptions) -> Result<()> {
138        if options.check_prerequisites && !options.force {
139            self.validate_prerequisites()?;
140        }
141
142        if options.feature_branches_only && !options.force {
143            self.validate_branch_suitability()?;
144        }
145
146        if options.confirm && !options.force {
147            self.confirm_installation()?;
148        }
149
150        println!("šŸŖ Installing Cascade Git hooks...");
151
152        let hooks = vec![
153            HookType::PostCommit,
154            HookType::PrePush,
155            HookType::CommitMsg,
156            HookType::PrepareCommitMsg,
157        ];
158
159        for hook in hooks {
160            self.install_hook(&hook)?;
161        }
162
163        println!("āœ… All Cascade hooks installed successfully!");
164        println!("\nšŸ’” Hooks installed:");
165        self.list_installed_hooks()?;
166
167        Ok(())
168    }
169
170    /// Install a specific hook
171    pub fn install_hook(&self, hook_type: &HookType) -> Result<()> {
172        let hook_path = self.hooks_dir.join(hook_type.filename());
173        let hook_content = self.generate_hook_script(hook_type)?;
174
175        // Backup existing hook if it exists
176        if hook_path.exists() {
177            let backup_path = hook_path.with_extension("cascade-backup");
178            fs::copy(&hook_path, &backup_path).map_err(|e| {
179                CascadeError::config(format!("Failed to backup existing hook: {e}"))
180            })?;
181            println!("šŸ“¦ Backed up existing {} hook", hook_type.filename());
182        }
183
184        // Write new hook
185        fs::write(&hook_path, hook_content)
186            .map_err(|e| CascadeError::config(format!("Failed to write hook file: {e}")))?;
187
188        // Make executable (platform-specific)
189        crate::utils::platform::make_executable(&hook_path)
190            .map_err(|e| CascadeError::config(format!("Failed to make hook executable: {e}")))?;
191
192        println!("āœ… Installed {} hook", hook_type.filename());
193        Ok(())
194    }
195
196    /// Remove all Cascade hooks
197    pub fn uninstall_all(&self) -> Result<()> {
198        println!("šŸ—‘ļø Removing Cascade Git hooks...");
199
200        let hooks = vec![
201            HookType::PostCommit,
202            HookType::PrePush,
203            HookType::CommitMsg,
204            HookType::PrepareCommitMsg,
205        ];
206
207        for hook in hooks {
208            self.uninstall_hook(&hook)?;
209        }
210
211        println!("āœ… All Cascade hooks removed!");
212        Ok(())
213    }
214
215    /// Remove a specific hook
216    pub fn uninstall_hook(&self, hook_type: &HookType) -> Result<()> {
217        let hook_path = self.hooks_dir.join(hook_type.filename());
218
219        if hook_path.exists() {
220            // Check if it's a Cascade hook
221            let content = fs::read_to_string(&hook_path)
222                .map_err(|e| CascadeError::config(format!("Failed to read hook file: {e}")))?;
223
224            // Check for platform-appropriate hook marker
225            let is_cascade_hook = if cfg!(windows) {
226                content.contains("rem Cascade CLI Hook")
227            } else {
228                content.contains("# Cascade CLI Hook")
229            };
230
231            if is_cascade_hook {
232                fs::remove_file(&hook_path).map_err(|e| {
233                    CascadeError::config(format!("Failed to remove hook file: {e}"))
234                })?;
235
236                // Restore backup if it exists
237                let backup_path = hook_path.with_extension("cascade-backup");
238                if backup_path.exists() {
239                    fs::rename(&backup_path, &hook_path).map_err(|e| {
240                        CascadeError::config(format!("Failed to restore backup: {e}"))
241                    })?;
242                    println!("šŸ“¦ Restored original {} hook", hook_type.filename());
243                } else {
244                    println!("šŸ—‘ļø Removed {} hook", hook_type.filename());
245                }
246            } else {
247                println!(
248                    "āš ļø {} hook exists but is not a Cascade hook, skipping",
249                    hook_type.filename()
250                );
251            }
252        } else {
253            println!("ā„¹ļø {} hook not found", hook_type.filename());
254        }
255
256        Ok(())
257    }
258
259    /// List all installed hooks and their status
260    pub fn list_installed_hooks(&self) -> Result<()> {
261        let hooks = vec![
262            HookType::PostCommit,
263            HookType::PrePush,
264            HookType::CommitMsg,
265            HookType::PrepareCommitMsg,
266        ];
267
268        println!("\nšŸ“‹ Git Hooks Status:");
269        println!("ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”");
270        println!("│ Hook                │ Status   │ Description                     │");
271        println!("ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤");
272
273        for hook in hooks {
274            let hook_path = self.hooks_dir.join(hook.filename());
275            let status = if hook_path.exists() {
276                let content = fs::read_to_string(&hook_path).unwrap_or_default();
277                // Check for platform-appropriate hook marker
278                let is_cascade_hook = if cfg!(windows) {
279                    content.contains("rem Cascade CLI Hook")
280                } else {
281                    content.contains("# Cascade CLI Hook")
282                };
283
284                if is_cascade_hook {
285                    "āœ… Cascade"
286                } else {
287                    "āš ļø Custom "
288                }
289            } else {
290                "āŒ Missing"
291            };
292
293            println!(
294                "│ {:19} │ {:8} │ {:31} │",
295                hook.filename(),
296                status,
297                hook.description()
298            );
299        }
300        println!("ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜");
301
302        Ok(())
303    }
304
305    /// Generate hook script content
306    pub fn generate_hook_script(&self, hook_type: &HookType) -> Result<String> {
307        let cascade_cli = env::current_exe()
308            .map_err(|e| {
309                CascadeError::config(format!("Failed to get current executable path: {e}"))
310            })?
311            .to_string_lossy()
312            .to_string();
313
314        let script = match hook_type {
315            HookType::PostCommit => self.generate_post_commit_hook(&cascade_cli),
316            HookType::PrePush => self.generate_pre_push_hook(&cascade_cli),
317            HookType::CommitMsg => self.generate_commit_msg_hook(&cascade_cli),
318            HookType::PrepareCommitMsg => self.generate_prepare_commit_msg_hook(&cascade_cli),
319        };
320
321        Ok(script)
322    }
323
324    fn generate_post_commit_hook(&self, cascade_cli: &str) -> String {
325        #[cfg(windows)]
326        {
327            format!(
328                "@echo off\n\
329                 rem Cascade CLI Hook - Post Commit\n\
330                 rem Automatically adds new commits to the active stack\n\n\
331                 rem Get the commit hash and message\n\
332                 for /f \"tokens=*\" %%i in ('git rev-parse HEAD') do set COMMIT_HASH=%%i\n\
333                 for /f \"tokens=*\" %%i in ('git log --format=%%s -n 1 HEAD') do set COMMIT_MSG=%%i\n\n\
334                 rem Find repository root and check if Cascade is initialized\n\
335                 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
336                 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
337                 if not exist \"%REPO_ROOT%\\.cascade\" (\n\
338                     echo ā„¹ļø Cascade not initialized, skipping stack management\n\
339                     echo šŸ’” Run 'ca init' to start using stacked diffs\n\
340                     exit /b 0\n\
341                 )\n\n\
342                 rem Check if there's an active stack\n\
343                 \"{cascade_cli}\" stack list --active >nul 2>&1\n\
344                 if %ERRORLEVEL% neq 0 (\n\
345                     echo ā„¹ļø No active stack found, commit will not be added to any stack\n\
346                     echo šŸ’” Use 'ca stack create ^<name^>' to create a stack for this commit\n\
347                     exit /b 0\n\
348                 )\n\n\
349                 rem Add commit to active stack\n\
350                 echo šŸŖ Adding commit to active stack...\n\
351                 echo šŸ“ Commit: %COMMIT_MSG%\n\
352                 \"{cascade_cli}\" stack push --commit \"%COMMIT_HASH%\" --message \"%COMMIT_MSG%\"\n\
353                 if %ERRORLEVEL% equ 0 (\n\
354                     echo āœ… Commit added to stack successfully\n\
355                     echo šŸ’” Next: 'ca submit' to create PRs when ready\n\
356                 ) else (\n\
357                     echo āš ļø Failed to add commit to stack\n\
358                     echo šŸ’” You can manually add it with: ca push --commit %COMMIT_HASH%\n\
359                 )\n"
360            )
361        }
362
363        #[cfg(not(windows))]
364        {
365            format!(
366                "#!/bin/sh\n\
367                 # Cascade CLI Hook - Post Commit\n\
368                 # Automatically adds new commits to the active stack\n\n\
369                 set -e\n\n\
370                 # Get the commit hash and message\n\
371                 COMMIT_HASH=$(git rev-parse HEAD)\n\
372                 COMMIT_MSG=$(git log --format=%s -n 1 HEAD)\n\n\
373                 # Find repository root and check if Cascade is initialized\n\
374                 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
375                 if [ ! -d \"$REPO_ROOT/.cascade\" ]; then\n\
376                     echo \"ā„¹ļø Cascade not initialized, skipping stack management\"\n\
377                     echo \"šŸ’” Run 'ca init' to start using stacked diffs\"\n\
378                     exit 0\n\
379                 fi\n\n\
380                 # Check if there's an active stack\n\
381                 if ! \"{cascade_cli}\" stack list --active > /dev/null 2>&1; then\n\
382                     echo \"ā„¹ļø No active stack found, commit will not be added to any stack\"\n\
383                     echo \"šŸ’” Use 'ca stack create <name>' to create a stack for this commit\"\n\
384                     exit 0\n\
385                 fi\n\n\
386                 # Add commit to active stack (using specific commit targeting)\n\
387                 echo \"šŸŖ Adding commit to active stack...\"\n\
388                 echo \"šŸ“ Commit: $COMMIT_MSG\"\n\
389                 if \"{cascade_cli}\" stack push --commit \"$COMMIT_HASH\" --message \"$COMMIT_MSG\"; then\n\
390                     echo \"āœ… Commit added to stack successfully\"\n\
391                     echo \"šŸ’” Next: 'ca submit' to create PRs when ready\"\n\
392                 else\n\
393                     echo \"āš ļø Failed to add commit to stack\"\n\
394                     echo \"šŸ’” You can manually add it with: ca push --commit $COMMIT_HASH\"\n\
395                 fi\n"
396            )
397        }
398    }
399
400    fn generate_pre_push_hook(&self, cascade_cli: &str) -> String {
401        #[cfg(windows)]
402        {
403            format!(
404                "@echo off\n\
405                 rem Cascade CLI Hook - Pre Push\n\
406                 rem Prevents force pushes and validates stack state\n\n\
407                 rem Check for force push\n\
408                 echo %* | findstr /C:\"--force\" /C:\"--force-with-lease\" /C:\"-f\" >nul\n\
409                 if %ERRORLEVEL% equ 0 (\n\
410                     echo āŒ Force push detected!\n\
411                     echo 🌊 Cascade CLI uses stacked diffs - force pushes can break stack integrity\n\
412                     echo.\n\
413                     echo šŸ’” Instead of force pushing, try these streamlined commands:\n\
414                     echo    • ca sync      - Sync with remote changes ^(handles rebasing^)\n\
415                     echo    • ca push      - Push all unpushed commits ^(new default^)\n\
416                     echo    • ca submit    - Submit all entries for review ^(new default^)\n\
417                     echo    • ca autoland  - Auto-merge when approved + builds pass\n\
418                     echo.\n\
419                     echo 🚨 If you really need to force push, run:\n\
420                     echo    git push --force-with-lease [remote] [branch]\n\
421                     echo    ^(But consider if this will affect other stack entries^)\n\
422                     exit /b 1\n\
423                 )\n\n\
424                 rem Find repository root and check if Cascade is initialized\n\
425                 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
426                 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
427                 if not exist \"%REPO_ROOT%\\.cascade\" (\n\
428                     echo ā„¹ļø Cascade not initialized, allowing push\n\
429                     exit /b 0\n\
430                 )\n\n\
431                 rem Validate stack state\n\
432                 echo šŸŖ Validating stack state before push...\n\
433                 \"{cascade_cli}\" stack validate\n\
434                 if %ERRORLEVEL% equ 0 (\n\
435                     echo āœ… Stack validation passed\n\
436                 ) else (\n\
437                     echo āŒ Stack validation failed\n\
438                     echo šŸ’” Fix validation errors before pushing:\n\
439                     echo    • ca doctor       - Check overall health\n\
440                     echo    • ca status       - Check current stack status\n\
441                     echo    • ca sync         - Sync with remote and rebase if needed\n\
442                     exit /b 1\n\
443                 )\n\n\
444                 echo āœ… Pre-push validation complete\n"
445            )
446        }
447
448        #[cfg(not(windows))]
449        {
450            format!(
451                "#!/bin/sh\n\
452                 # Cascade CLI Hook - Pre Push\n\
453                 # Prevents force pushes and validates stack state\n\n\
454                 set -e\n\n\
455                 # Check for force push\n\
456                 if echo \"$*\" | grep -q -- \"--force\\|--force-with-lease\\|-f\"; then\n\
457                     echo \"āŒ Force push detected!\"\n\
458                     echo \"🌊 Cascade CLI uses stacked diffs - force pushes can break stack integrity\"\n\
459                     echo \"\"\n\
460                     echo \"šŸ’” Instead of force pushing, try these streamlined commands:\"\n\
461                     echo \"   • ca sync      - Sync with remote changes (handles rebasing)\"\n\
462                     echo \"   • ca push      - Push all unpushed commits (new default)\"\n\
463                     echo \"   • ca submit    - Submit all entries for review (new default)\"\n\
464                     echo \"   • ca autoland  - Auto-merge when approved + builds pass\"\n\
465                     echo \"\"\n\
466                     echo \"🚨 If you really need to force push, run:\"\n\
467                     echo \"   git push --force-with-lease [remote] [branch]\"\n\
468                     echo \"   (But consider if this will affect other stack entries)\"\n\
469                     exit 1\n\
470                 fi\n\n\
471                 # Find repository root and check if Cascade is initialized\n\
472                 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
473                 if [ ! -d \"$REPO_ROOT/.cascade\" ]; then\n\
474                     echo \"ā„¹ļø Cascade not initialized, allowing push\"\n\
475                     exit 0\n\
476                 fi\n\n\
477                 # Validate stack state\n\
478                 echo \"šŸŖ Validating stack state before push...\"\n\
479                 if \"{cascade_cli}\" stack validate; then\n\
480                     echo \"āœ… Stack validation passed\"\n\
481                 else\n\
482                     echo \"āŒ Stack validation failed\"\n\
483                     echo \"šŸ’” Fix validation errors before pushing:\"\n\
484                     echo \"   • ca doctor       - Check overall health\"\n\
485                     echo \"   • ca status       - Check current stack status\"\n\
486                     echo \"   • ca sync         - Sync with remote and rebase if needed\"\n\
487                     exit 1\n\
488                 fi\n\n\
489                 echo \"āœ… Pre-push validation complete\"\n"
490            )
491        }
492    }
493
494    fn generate_commit_msg_hook(&self, _cascade_cli: &str) -> String {
495        #[cfg(windows)]
496        {
497            r#"@echo off
498rem Cascade CLI Hook - Commit Message
499rem Validates commit message format
500
501set COMMIT_MSG_FILE=%1
502if "%COMMIT_MSG_FILE%"=="" (
503    echo āŒ No commit message file provided
504    exit /b 1
505)
506
507rem Read commit message (Windows batch is limited, but this covers basic cases)
508for /f "delims=" %%i in ('type "%COMMIT_MSG_FILE%"') do set COMMIT_MSG=%%i
509
510rem Skip validation for merge commits, fixup commits, etc.
511echo %COMMIT_MSG% | findstr /B /C:"Merge" /C:"Revert" /C:"fixup!" /C:"squash!" >nul
512if %ERRORLEVEL% equ 0 exit /b 0
513
514rem Find repository root and check if Cascade is initialized
515for /f "tokens=*" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i
516if "%REPO_ROOT%"=="" set REPO_ROOT=.
517if not exist "%REPO_ROOT%\.cascade" exit /b 0
518
519rem Basic commit message validation
520echo %COMMIT_MSG% | findstr /R "^..........*" >nul
521if %ERRORLEVEL% neq 0 (
522    echo āŒ Commit message too short (minimum 10 characters)
523    echo šŸ’” Write a descriptive commit message for better stack management
524    exit /b 1
525)
526
527rem Check for very long messages (approximate check in batch)
528echo %COMMIT_MSG% | findstr /R "^..................................................................................*" >nul
529if %ERRORLEVEL% equ 0 (
530    echo āš ļø Warning: Commit message longer than 72 characters
531    echo šŸ’” Consider keeping the first line short for better readability
532)
533
534rem Check for conventional commit format (optional)
535echo %COMMIT_MSG% | findstr /R "^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)" >nul
536if %ERRORLEVEL% neq 0 (
537    echo šŸ’” Consider using conventional commit format:
538    echo    feat: add new feature
539    echo    fix: resolve bug
540    echo    docs: update documentation
541    echo    etc.
542)
543
544echo āœ… Commit message validation passed
545"#.to_string()
546        }
547
548        #[cfg(not(windows))]
549        {
550            r#"#!/bin/sh
551# Cascade CLI Hook - Commit Message
552# Validates commit message format
553
554set -e
555
556COMMIT_MSG_FILE="$1"
557COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
558
559# Skip validation for merge commits, fixup commits, etc.
560if echo "$COMMIT_MSG" | grep -E "^(Merge|Revert|fixup!|squash!)" > /dev/null; then
561    exit 0
562fi
563
564# Find repository root and check if Cascade is initialized
565REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")
566if [ ! -d "$REPO_ROOT/.cascade" ]; then
567    exit 0
568fi
569
570# Basic commit message validation
571if [ ${#COMMIT_MSG} -lt 10 ]; then
572    echo "āŒ Commit message too short (minimum 10 characters)"
573    echo "šŸ’” Write a descriptive commit message for better stack management"
574    exit 1
575fi
576
577if [ ${#COMMIT_MSG} -gt 72 ]; then
578    echo "āš ļø Warning: Commit message longer than 72 characters"
579    echo "šŸ’” Consider keeping the first line short for better readability"
580fi
581
582# Check for conventional commit format (optional)
583if ! echo "$COMMIT_MSG" | grep -E "^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)(\(.+\))?: .+" > /dev/null; then
584    echo "šŸ’” Consider using conventional commit format:"
585    echo "   feat: add new feature"
586    echo "   fix: resolve bug"
587    echo "   docs: update documentation"
588    echo "   etc."
589fi
590
591echo "āœ… Commit message validation passed"
592"#.to_string()
593        }
594    }
595
596    fn generate_prepare_commit_msg_hook(&self, cascade_cli: &str) -> String {
597        #[cfg(windows)]
598        {
599            format!(
600                "@echo off\n\
601                 rem Cascade CLI Hook - Prepare Commit Message\n\
602                 rem Adds stack context to commit messages\n\n\
603                 set COMMIT_MSG_FILE=%1\n\
604                 set COMMIT_SOURCE=%2\n\
605                 set COMMIT_SHA=%3\n\n\
606                 rem Only modify message if it's a regular commit (not merge, template, etc.)\n\
607                 if not \"%COMMIT_SOURCE%\"==\"\" if not \"%COMMIT_SOURCE%\"==\"message\" exit /b 0\n\n\
608                 rem Find repository root and check if Cascade is initialized\n\
609                 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
610                 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
611                 if not exist \"%REPO_ROOT%\\.cascade\" exit /b 0\n\n\
612                 rem Get active stack info\n\
613                 for /f \"tokens=*\" %%i in ('\"{cascade_cli}\" stack list --active --format=name 2^>nul') do set ACTIVE_STACK=%%i\n\n\
614                 if not \"%ACTIVE_STACK%\"==\"\" (\n\
615                     rem Get current commit message\n\
616                     set /p CURRENT_MSG=<%COMMIT_MSG_FILE%\n\n\
617                     rem Skip if message already has stack context\n\
618                     echo !CURRENT_MSG! | findstr \"[stack:\" >nul\n\
619                     if %ERRORLEVEL% equ 0 exit /b 0\n\n\
620                     rem Add stack context to commit message\n\
621                     echo.\n\
622                     echo # Stack: %ACTIVE_STACK%\n\
623                     echo # This commit will be added to the active stack automatically.\n\
624                     echo # Use 'ca stack status' to see the current stack state.\n\
625                     type \"%COMMIT_MSG_FILE%\"\n\
626                 ) > \"%COMMIT_MSG_FILE%.tmp\"\n\
627                 move \"%COMMIT_MSG_FILE%.tmp\" \"%COMMIT_MSG_FILE%\"\n"
628            )
629        }
630
631        #[cfg(not(windows))]
632        {
633            format!(
634                "#!/bin/sh\n\
635                 # Cascade CLI Hook - Prepare Commit Message\n\
636                 # Adds stack context to commit messages\n\n\
637                 set -e\n\n\
638                 COMMIT_MSG_FILE=\"$1\"\n\
639                 COMMIT_SOURCE=\"$2\"\n\
640                 COMMIT_SHA=\"$3\"\n\n\
641                 # Only modify message if it's a regular commit (not merge, template, etc.)\n\
642                 if [ \"$COMMIT_SOURCE\" != \"\" ] && [ \"$COMMIT_SOURCE\" != \"message\" ]; then\n\
643                     exit 0\n\
644                 fi\n\n\
645                 # Find repository root and check if Cascade is initialized\n\
646                 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
647                 if [ ! -d \"$REPO_ROOT/.cascade\" ]; then\n\
648                     exit 0\n\
649                 fi\n\n\
650                 # Get active stack info\n\
651                 ACTIVE_STACK=$(\"{cascade_cli}\" stack list --active --format=name 2>/dev/null || echo \"\")\n\n\
652                 if [ -n \"$ACTIVE_STACK\" ]; then\n\
653                     # Get current commit message\n\
654                     CURRENT_MSG=$(cat \"$COMMIT_MSG_FILE\")\n\
655                     \n\
656                     # Skip if message already has stack context\n\
657                     if echo \"$CURRENT_MSG\" | grep -q \"\\[stack:\"; then\n\
658                         exit 0\n\
659                     fi\n\
660                     \n\
661                     # Add stack context to commit message\n\
662                     echo \"\n\
663                 # Stack: $ACTIVE_STACK\n\
664                 # This commit will be added to the active stack automatically.\n\
665                 # Use 'ca stack status' to see the current stack state.\n\
666                 $CURRENT_MSG\" > \"$COMMIT_MSG_FILE\"\n\
667                 fi\n"
668            )
669        }
670    }
671
672    /// Detect repository type from remote URLs
673    pub fn detect_repository_type(&self) -> Result<RepositoryType> {
674        let output = Command::new("git")
675            .args(["remote", "get-url", "origin"])
676            .current_dir(&self.repo_path)
677            .output()
678            .map_err(|e| CascadeError::config(format!("Failed to get remote URL: {e}")))?;
679
680        if !output.status.success() {
681            return Ok(RepositoryType::Unknown);
682        }
683
684        let remote_url = String::from_utf8_lossy(&output.stdout)
685            .trim()
686            .to_lowercase();
687
688        if remote_url.contains("github.com") {
689            Ok(RepositoryType::GitHub)
690        } else if remote_url.contains("gitlab.com") || remote_url.contains("gitlab") {
691            Ok(RepositoryType::GitLab)
692        } else if remote_url.contains("dev.azure.com") || remote_url.contains("visualstudio.com") {
693            Ok(RepositoryType::AzureDevOps)
694        } else if remote_url.contains("bitbucket") {
695            Ok(RepositoryType::Bitbucket)
696        } else {
697            Ok(RepositoryType::Unknown)
698        }
699    }
700
701    /// Detect current branch type
702    pub fn detect_branch_type(&self) -> Result<BranchType> {
703        let output = Command::new("git")
704            .args(["branch", "--show-current"])
705            .current_dir(&self.repo_path)
706            .output()
707            .map_err(|e| CascadeError::config(format!("Failed to get current branch: {e}")))?;
708
709        if !output.status.success() {
710            return Ok(BranchType::Unknown);
711        }
712
713        let branch_name = String::from_utf8_lossy(&output.stdout)
714            .trim()
715            .to_lowercase();
716
717        if branch_name == "main" || branch_name == "master" || branch_name == "develop" {
718            Ok(BranchType::Main)
719        } else if !branch_name.is_empty() {
720            Ok(BranchType::Feature)
721        } else {
722            Ok(BranchType::Unknown)
723        }
724    }
725
726    /// Validate prerequisites for hook installation
727    pub fn validate_prerequisites(&self) -> Result<()> {
728        println!("šŸ” Checking prerequisites for Cascade hooks...");
729
730        // 1. Check repository type
731        let repo_type = self.detect_repository_type()?;
732        match repo_type {
733            RepositoryType::Bitbucket => {
734                println!("āœ… Bitbucket repository detected");
735                println!("šŸ’” Hooks will work great with 'ca submit' and 'ca autoland' for Bitbucket integration");
736            }
737            RepositoryType::GitHub => {
738                println!("āœ… GitHub repository detected");
739                println!("šŸ’” Consider setting up GitHub Actions for CI/CD integration");
740            }
741            RepositoryType::GitLab => {
742                println!("āœ… GitLab repository detected");
743                println!("šŸ’” GitLab CI integration works well with Cascade stacks");
744            }
745            RepositoryType::AzureDevOps => {
746                println!("āœ… Azure DevOps repository detected");
747                println!("šŸ’” Azure Pipelines can be configured to work with Cascade workflows");
748            }
749            RepositoryType::Unknown => {
750                println!(
751                    "ā„¹ļø Unknown repository type - hooks will still work for local Git operations"
752                );
753            }
754        }
755
756        // 2. Check Cascade configuration
757        let config_dir = crate::config::get_repo_config_dir(&self.repo_path)?;
758        let config_path = config_dir.join("config.json");
759        if !config_path.exists() {
760            return Err(CascadeError::config(
761                "🚫 Cascade not initialized!\n\n\
762                Please run 'ca init' or 'ca setup' first to configure Cascade CLI.\n\
763                Hooks require proper Bitbucket Server configuration.\n\n\
764                Use --force to install anyway (not recommended)."
765                    .to_string(),
766            ));
767        }
768
769        // 3. Validate Bitbucket configuration
770        let config = Settings::load_from_file(&config_path)?;
771
772        if config.bitbucket.url == "https://bitbucket.example.com"
773            || config.bitbucket.url.contains("example.com")
774        {
775            return Err(CascadeError::config(
776                "🚫 Invalid Bitbucket configuration!\n\n\
777                Your Bitbucket URL appears to be a placeholder.\n\
778                Please run 'ca setup' to configure a real Bitbucket Server.\n\n\
779                Use --force to install anyway (not recommended)."
780                    .to_string(),
781            ));
782        }
783
784        if config.bitbucket.project == "PROJECT" || config.bitbucket.repo == "repo" {
785            return Err(CascadeError::config(
786                "🚫 Incomplete Bitbucket configuration!\n\n\
787                Your project/repository settings appear to be placeholders.\n\
788                Please run 'ca setup' to complete configuration.\n\n\
789                Use --force to install anyway (not recommended)."
790                    .to_string(),
791            ));
792        }
793
794        println!("āœ… Prerequisites validation passed");
795        Ok(())
796    }
797
798    /// Validate branch suitability for hooks
799    pub fn validate_branch_suitability(&self) -> Result<()> {
800        let branch_type = self.detect_branch_type()?;
801
802        match branch_type {
803            BranchType::Main => {
804                return Err(CascadeError::config(
805                    "🚫 Currently on main/master branch!\n\n\
806                    Cascade hooks are designed for feature branch development.\n\
807                    Working directly on main/master with stacked diffs can:\n\
808                    • Complicate the commit history\n\
809                    • Interfere with team collaboration\n\
810                    • Break CI/CD workflows\n\n\
811                    šŸ’” Recommended workflow:\n\
812                    1. Create a feature branch: git checkout -b feature/my-feature\n\
813                    2. Install hooks: ca hooks install\n\
814                    3. Develop with stacked commits (auto-added with hooks)\n\
815                    4. Push & submit: ca push && ca submit (all by default)\n\
816                    5. Auto-land when ready: ca autoland\n\n\
817                    Use --force to install anyway (not recommended)."
818                        .to_string(),
819                ));
820            }
821            BranchType::Feature => {
822                println!("āœ… Feature branch detected - suitable for stacked development");
823            }
824            BranchType::Unknown => {
825                println!("āš ļø Unknown branch type - proceeding with caution");
826            }
827        }
828
829        Ok(())
830    }
831
832    /// Confirm installation with user
833    pub fn confirm_installation(&self) -> Result<()> {
834        println!("\nšŸ“‹ Hook Installation Summary:");
835        println!("ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”");
836        println!("│ Hook                │ Description                     │");
837        println!("ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¼ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤");
838
839        let hooks = vec![
840            HookType::PostCommit,
841            HookType::PrePush,
842            HookType::CommitMsg,
843            HookType::PrepareCommitMsg,
844        ];
845
846        for hook in &hooks {
847            println!("│ {:19} │ {:31} │", hook.filename(), hook.description());
848        }
849        println!("ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”“ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜");
850
851        println!("\nšŸ”„ These hooks will automatically:");
852        println!("• Add commits to your active stack");
853        println!("• Validate commit messages");
854        println!("• Prevent force pushes that break stack integrity");
855        println!("• Add stack context to commit messages");
856
857        println!("\n✨ With hooks + new defaults, your workflow becomes:");
858        println!("  git commit       → Auto-added to stack");
859        println!("  ca push          → Pushes all by default");
860        println!("  ca submit        → Submits all by default");
861        println!("  ca autoland      → Auto-merges when ready");
862
863        use std::io::{self, Write};
864        print!("\nā“ Install Cascade hooks? [Y/n]: ");
865        io::stdout().flush().unwrap();
866
867        let mut input = String::new();
868        io::stdin().read_line(&mut input).unwrap();
869        let input = input.trim().to_lowercase();
870
871        if input.is_empty() || input == "y" || input == "yes" {
872            println!("āœ… Proceeding with installation");
873            Ok(())
874        } else {
875            Err(CascadeError::config(
876                "Installation cancelled by user".to_string(),
877            ))
878        }
879    }
880}
881
882/// Run hooks management commands
883pub async fn install() -> Result<()> {
884    install_with_options(false, false, false, false).await
885}
886
887pub async fn install_essential() -> Result<()> {
888    let current_dir = env::current_dir()
889        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
890
891    let repo_root = find_repository_root(&current_dir)
892        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
893
894    let hooks_manager = HooksManager::new(&repo_root)?;
895    hooks_manager.install_essential()
896}
897
898pub async fn install_with_options(
899    skip_checks: bool,
900    allow_main_branch: bool,
901    yes: bool,
902    force: bool,
903) -> Result<()> {
904    let current_dir = env::current_dir()
905        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
906
907    let repo_root = find_repository_root(&current_dir)
908        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
909
910    let hooks_manager = HooksManager::new(&repo_root)?;
911
912    let options = InstallOptions {
913        check_prerequisites: !skip_checks,
914        feature_branches_only: !allow_main_branch,
915        confirm: !yes,
916        force,
917    };
918
919    hooks_manager.install_with_options(&options)
920}
921
922pub async fn uninstall() -> Result<()> {
923    let current_dir = env::current_dir()
924        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
925
926    let repo_root = find_repository_root(&current_dir)
927        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
928
929    let hooks_manager = HooksManager::new(&repo_root)?;
930    hooks_manager.uninstall_all()
931}
932
933pub async fn status() -> Result<()> {
934    let current_dir = env::current_dir()
935        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
936
937    let repo_root = find_repository_root(&current_dir)
938        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
939
940    let hooks_manager = HooksManager::new(&repo_root)?;
941    hooks_manager.list_installed_hooks()
942}
943
944pub async fn install_hook(hook_name: &str) -> Result<()> {
945    install_hook_with_options(hook_name, false, false).await
946}
947
948pub async fn install_hook_with_options(
949    hook_name: &str,
950    skip_checks: bool,
951    force: bool,
952) -> Result<()> {
953    let current_dir = env::current_dir()
954        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
955
956    let repo_root = find_repository_root(&current_dir)
957        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
958
959    let hooks_manager = HooksManager::new(&repo_root)?;
960
961    let hook_type = match hook_name {
962        "post-commit" => HookType::PostCommit,
963        "pre-push" => HookType::PrePush,
964        "commit-msg" => HookType::CommitMsg,
965        "prepare-commit-msg" => HookType::PrepareCommitMsg,
966        _ => {
967            return Err(CascadeError::config(format!(
968                "Unknown hook type: {hook_name}"
969            )))
970        }
971    };
972
973    // Run basic validation if not skipped
974    if !skip_checks && !force {
975        hooks_manager.validate_prerequisites()?;
976    }
977
978    hooks_manager.install_hook(&hook_type)
979}
980
981pub async fn uninstall_hook(hook_name: &str) -> Result<()> {
982    let current_dir = env::current_dir()
983        .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
984
985    let repo_root = find_repository_root(&current_dir)
986        .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
987
988    let hooks_manager = HooksManager::new(&repo_root)?;
989
990    let hook_type = match hook_name {
991        "post-commit" => HookType::PostCommit,
992        "pre-push" => HookType::PrePush,
993        "commit-msg" => HookType::CommitMsg,
994        "prepare-commit-msg" => HookType::PrepareCommitMsg,
995        _ => {
996            return Err(CascadeError::config(format!(
997                "Unknown hook type: {hook_name}"
998            )))
999        }
1000    };
1001
1002    hooks_manager.uninstall_hook(&hook_type)
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007    use super::*;
1008    use std::process::Command;
1009    use tempfile::TempDir;
1010
1011    fn create_test_repo() -> (TempDir, std::path::PathBuf) {
1012        let temp_dir = TempDir::new().unwrap();
1013        let repo_path = temp_dir.path().to_path_buf();
1014
1015        // Initialize git repository
1016        Command::new("git")
1017            .args(["init"])
1018            .current_dir(&repo_path)
1019            .output()
1020            .unwrap();
1021        Command::new("git")
1022            .args(["config", "user.name", "Test"])
1023            .current_dir(&repo_path)
1024            .output()
1025            .unwrap();
1026        Command::new("git")
1027            .args(["config", "user.email", "test@test.com"])
1028            .current_dir(&repo_path)
1029            .output()
1030            .unwrap();
1031
1032        // Create initial commit
1033        std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1034        Command::new("git")
1035            .args(["add", "."])
1036            .current_dir(&repo_path)
1037            .output()
1038            .unwrap();
1039        Command::new("git")
1040            .args(["commit", "-m", "Initial"])
1041            .current_dir(&repo_path)
1042            .output()
1043            .unwrap();
1044
1045        // Initialize cascade
1046        crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
1047            .unwrap();
1048
1049        (temp_dir, repo_path)
1050    }
1051
1052    #[test]
1053    fn test_hooks_manager_creation() {
1054        let (_temp_dir, repo_path) = create_test_repo();
1055        let _manager = HooksManager::new(&repo_path).unwrap();
1056
1057        assert_eq!(_manager.repo_path, repo_path);
1058        assert_eq!(_manager.hooks_dir, repo_path.join(".git/hooks"));
1059    }
1060
1061    #[test]
1062    fn test_hook_installation() {
1063        let (_temp_dir, repo_path) = create_test_repo();
1064        let manager = HooksManager::new(&repo_path).unwrap();
1065
1066        // Test installing post-commit hook
1067        let hook_type = HookType::PostCommit;
1068        let result = manager.install_hook(&hook_type);
1069        assert!(result.is_ok());
1070
1071        // Verify hook file exists with platform-appropriate filename
1072        let hook_filename = hook_type.filename();
1073        let hook_path = repo_path.join(".git/hooks").join(&hook_filename);
1074        assert!(hook_path.exists());
1075
1076        // Verify hook is executable (platform-specific)
1077        #[cfg(unix)]
1078        {
1079            use std::os::unix::fs::PermissionsExt;
1080            let metadata = std::fs::metadata(&hook_path).unwrap();
1081            let permissions = metadata.permissions();
1082            assert!(permissions.mode() & 0o111 != 0); // Check executable bit
1083        }
1084
1085        #[cfg(windows)]
1086        {
1087            // On Windows, verify it has .bat extension and file exists
1088            assert!(hook_filename.ends_with(".bat"));
1089            assert!(hook_path.exists());
1090        }
1091    }
1092
1093    #[test]
1094    fn test_hook_detection() {
1095        let (_temp_dir, repo_path) = create_test_repo();
1096        let _manager = HooksManager::new(&repo_path).unwrap();
1097
1098        // Check if hook files exist with platform-appropriate filenames
1099        let post_commit_path = repo_path
1100            .join(".git/hooks")
1101            .join(HookType::PostCommit.filename());
1102        let pre_push_path = repo_path
1103            .join(".git/hooks")
1104            .join(HookType::PrePush.filename());
1105        let commit_msg_path = repo_path
1106            .join(".git/hooks")
1107            .join(HookType::CommitMsg.filename());
1108
1109        // Initially no hooks should be installed
1110        assert!(!post_commit_path.exists());
1111        assert!(!pre_push_path.exists());
1112        assert!(!commit_msg_path.exists());
1113    }
1114
1115    #[test]
1116    fn test_hook_validation() {
1117        let (_temp_dir, repo_path) = create_test_repo();
1118        let manager = HooksManager::new(&repo_path).unwrap();
1119
1120        // Test validation - may fail in CI due to missing dependencies
1121        let validation = manager.validate_prerequisites();
1122        // In CI environment, validation might fail due to missing configuration
1123        // Just ensure it doesn't panic
1124        let _ = validation; // Don't assert ok/err, just ensure no panic
1125
1126        // Test branch validation - should work regardless of environment
1127        let branch_validation = manager.validate_branch_suitability();
1128        // Branch validation should work in most cases, but be tolerant
1129        let _ = branch_validation; // Don't assert ok/err, just ensure no panic
1130    }
1131
1132    #[test]
1133    fn test_hook_uninstallation() {
1134        let (_temp_dir, repo_path) = create_test_repo();
1135        let manager = HooksManager::new(&repo_path).unwrap();
1136
1137        // Install then uninstall hook
1138        let hook_type = HookType::PostCommit;
1139        manager.install_hook(&hook_type).unwrap();
1140
1141        let hook_path = repo_path.join(".git/hooks").join(hook_type.filename());
1142        assert!(hook_path.exists());
1143
1144        let result = manager.uninstall_hook(&hook_type);
1145        assert!(result.is_ok());
1146        assert!(!hook_path.exists());
1147    }
1148
1149    #[test]
1150    fn test_hook_content_generation() {
1151        let (_temp_dir, repo_path) = create_test_repo();
1152        let manager = HooksManager::new(&repo_path).unwrap();
1153
1154        // Use a known binary name for testing
1155        let binary_name = "cascade-cli";
1156
1157        // Test post-commit hook generation
1158        let post_commit_content = manager.generate_post_commit_hook(binary_name);
1159        #[cfg(windows)]
1160        {
1161            assert!(post_commit_content.contains("@echo off"));
1162            assert!(post_commit_content.contains("rem Cascade CLI Hook"));
1163        }
1164        #[cfg(not(windows))]
1165        {
1166            assert!(post_commit_content.contains("#!/bin/sh"));
1167            assert!(post_commit_content.contains("# Cascade CLI Hook"));
1168        }
1169        assert!(post_commit_content.contains(binary_name));
1170
1171        // Test pre-push hook generation
1172        let pre_push_content = manager.generate_pre_push_hook(binary_name);
1173        #[cfg(windows)]
1174        {
1175            assert!(pre_push_content.contains("@echo off"));
1176            assert!(pre_push_content.contains("rem Cascade CLI Hook"));
1177        }
1178        #[cfg(not(windows))]
1179        {
1180            assert!(pre_push_content.contains("#!/bin/sh"));
1181            assert!(pre_push_content.contains("# Cascade CLI Hook"));
1182        }
1183        assert!(pre_push_content.contains(binary_name));
1184
1185        // Test commit-msg hook generation (doesn't use binary, just validates)
1186        let commit_msg_content = manager.generate_commit_msg_hook(binary_name);
1187        #[cfg(windows)]
1188        {
1189            assert!(commit_msg_content.contains("@echo off"));
1190            assert!(commit_msg_content.contains("rem Cascade CLI Hook"));
1191        }
1192        #[cfg(not(windows))]
1193        {
1194            assert!(commit_msg_content.contains("#!/bin/sh"));
1195            assert!(commit_msg_content.contains("# Cascade CLI Hook"));
1196        }
1197
1198        // Test prepare-commit-msg hook generation (does use binary)
1199        let prepare_commit_content = manager.generate_prepare_commit_msg_hook(binary_name);
1200        #[cfg(windows)]
1201        {
1202            assert!(prepare_commit_content.contains("@echo off"));
1203            assert!(prepare_commit_content.contains("rem Cascade CLI Hook"));
1204        }
1205        #[cfg(not(windows))]
1206        {
1207            assert!(prepare_commit_content.contains("#!/bin/sh"));
1208            assert!(prepare_commit_content.contains("# Cascade CLI Hook"));
1209        }
1210        assert!(prepare_commit_content.contains(binary_name));
1211    }
1212
1213    #[test]
1214    fn test_hook_status_reporting() {
1215        let (_temp_dir, repo_path) = create_test_repo();
1216        let manager = HooksManager::new(&repo_path).unwrap();
1217
1218        // Check repository type detection - should work with our test setup
1219        let repo_type = manager.detect_repository_type().unwrap();
1220        // In CI environment, this might be Unknown if remote detection fails
1221        assert!(matches!(
1222            repo_type,
1223            RepositoryType::Bitbucket | RepositoryType::Unknown
1224        ));
1225
1226        // Check branch type detection
1227        let branch_type = manager.detect_branch_type().unwrap();
1228        // Should be on main/master branch, but allow for different default branch names
1229        assert!(matches!(
1230            branch_type,
1231            BranchType::Main | BranchType::Unknown
1232        ));
1233    }
1234
1235    #[test]
1236    fn test_force_installation() {
1237        let (_temp_dir, repo_path) = create_test_repo();
1238        let manager = HooksManager::new(&repo_path).unwrap();
1239
1240        // Create a fake existing hook with platform-appropriate content
1241        let hook_filename = HookType::PostCommit.filename();
1242        let hook_path = repo_path.join(".git/hooks").join(&hook_filename);
1243
1244        #[cfg(windows)]
1245        let existing_content = "@echo off\necho existing hook";
1246        #[cfg(not(windows))]
1247        let existing_content = "#!/bin/sh\necho 'existing hook'";
1248
1249        std::fs::write(&hook_path, existing_content).unwrap();
1250
1251        // Install hook (should backup and replace existing)
1252        let hook_type = HookType::PostCommit;
1253        let result = manager.install_hook(&hook_type);
1254        assert!(result.is_ok());
1255
1256        // Verify new content replaced old content
1257        let content = std::fs::read_to_string(&hook_path).unwrap();
1258        #[cfg(windows)]
1259        {
1260            assert!(content.contains("rem Cascade CLI Hook"));
1261        }
1262        #[cfg(not(windows))]
1263        {
1264            assert!(content.contains("# Cascade CLI Hook"));
1265        }
1266        assert!(!content.contains("existing hook"));
1267
1268        // Verify backup was created
1269        let backup_path = hook_path.with_extension("cascade-backup");
1270        assert!(backup_path.exists());
1271    }
1272}