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#[derive(Debug, Clone, PartialEq)]
10pub enum RepositoryType {
11 Bitbucket,
12 GitHub,
13 GitLab,
14 AzureDevOps,
15 Unknown,
16}
17
18#[derive(Debug, Clone, PartialEq)]
20pub enum BranchType {
21 Main, Feature, Unknown,
24}
25
26#[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
46pub struct HooksManager {
48 repo_path: PathBuf,
49 hooks_dir: PathBuf,
50}
51
52#[derive(Debug, Clone)]
54pub enum HookType {
55 PostCommit,
57 PrePush,
59 CommitMsg,
61 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 pub fn install_all(&self) -> Result<()> {
108 self.install_with_options(&InstallOptions::default())
109 }
110
111 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 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 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 fs::write(&hook_path, hook_content)
161 .map_err(|e| CascadeError::config(format!("Failed to write hook file: {e}")))?;
162
163 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 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 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 let content = fs::read_to_string(&hook_path)
197 .map_err(|e| CascadeError::config(format!("Failed to read hook file: {e}")))?;
198
199 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 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 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 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 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 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 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 pub fn validate_prerequisites(&self) -> Result<()> {
691 println!("š Checking prerequisites for Cascade hooks...");
692
693 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 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 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 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 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
844pub 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(¤t_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(¤t_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(¤t_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(¤t_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 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(¤t_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 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 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 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 let hook_type = HookType::PostCommit;
1004 let result = manager.install_hook(&hook_type);
1005 assert!(result.is_ok());
1006
1007 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 #[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); }
1020
1021 #[cfg(windows)]
1022 {
1023 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 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 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 let validation = manager.validate_prerequisites();
1058 let _ = validation; let branch_validation = manager.validate_branch_suitability();
1064 let _ = branch_validation; }
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 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 let binary_name = "cascade-cli";
1092
1093 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 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 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 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 let repo_type = manager.detect_repository_type().unwrap();
1156 assert!(matches!(
1158 repo_type,
1159 RepositoryType::Bitbucket | RepositoryType::Unknown
1160 ));
1161
1162 let branch_type = manager.detect_branch_type().unwrap();
1164 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 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 let hook_type = HookType::PostCommit;
1189 let result = manager.install_hook(&hook_type);
1190 assert!(result.is_ok());
1191
1192 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 let backup_path = hook_path.with_extension("cascade-backup");
1206 assert!(backup_path.exists());
1207 }
1208}