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