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