cascade_cli/cli/commands/
hooks.rs

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