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 if content.contains("# Cascade CLI Hook") {
200 fs::remove_file(&hook_path).map_err(|e| {
201 CascadeError::config(format!("Failed to remove hook file: {e}"))
202 })?;
203
204 let backup_path = hook_path.with_extension("cascade-backup");
206 if backup_path.exists() {
207 fs::rename(&backup_path, &hook_path).map_err(|e| {
208 CascadeError::config(format!("Failed to restore backup: {e}"))
209 })?;
210 println!("š¦ Restored original {} hook", hook_type.filename());
211 } else {
212 println!("šļø Removed {} hook", hook_type.filename());
213 }
214 } else {
215 println!(
216 "ā ļø {} hook exists but is not a Cascade hook, skipping",
217 hook_type.filename()
218 );
219 }
220 } else {
221 println!("ā¹ļø {} hook not found", hook_type.filename());
222 }
223
224 Ok(())
225 }
226
227 pub fn list_installed_hooks(&self) -> Result<()> {
229 let hooks = vec![
230 HookType::PostCommit,
231 HookType::PrePush,
232 HookType::CommitMsg,
233 HookType::PrepareCommitMsg,
234 ];
235
236 println!("\nš Git Hooks Status:");
237 println!("āāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
238 println!("ā Hook ā Status ā Description ā");
239 println!("āāāāāāāāāāāāāāāāāāāāāāā¼āāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā¤");
240
241 for hook in hooks {
242 let hook_path = self.hooks_dir.join(hook.filename());
243 let status = if hook_path.exists() {
244 let content = fs::read_to_string(&hook_path).unwrap_or_default();
245 if content.contains("# Cascade CLI Hook") {
246 "ā
Cascade"
247 } else {
248 "ā ļø Custom "
249 }
250 } else {
251 "ā Missing"
252 };
253
254 println!(
255 "ā {:19} ā {:8} ā {:31} ā",
256 hook.filename(),
257 status,
258 hook.description()
259 );
260 }
261 println!("āāāāāāāāāāāāāāāāāāāāāāā“āāāāāāāāāāā“āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
262
263 Ok(())
264 }
265
266 pub fn generate_hook_script(&self, hook_type: &HookType) -> Result<String> {
268 let cascade_cli = env::current_exe()
269 .map_err(|e| {
270 CascadeError::config(format!("Failed to get current executable path: {e}"))
271 })?
272 .to_string_lossy()
273 .to_string();
274
275 let script = match hook_type {
276 HookType::PostCommit => self.generate_post_commit_hook(&cascade_cli),
277 HookType::PrePush => self.generate_pre_push_hook(&cascade_cli),
278 HookType::CommitMsg => self.generate_commit_msg_hook(&cascade_cli),
279 HookType::PrepareCommitMsg => self.generate_prepare_commit_msg_hook(&cascade_cli),
280 };
281
282 Ok(script)
283 }
284
285 fn generate_post_commit_hook(&self, cascade_cli: &str) -> String {
286 #[cfg(windows)]
287 {
288 format!(
289 "@echo off\n\
290 rem Cascade CLI Hook - Post Commit\n\
291 rem Automatically adds new commits to the active stack\n\n\
292 rem Get the commit hash and message\n\
293 for /f \"tokens=*\" %%i in ('git rev-parse HEAD') do set COMMIT_HASH=%%i\n\
294 for /f \"tokens=*\" %%i in ('git log --format=%%s -n 1 HEAD') do set COMMIT_MSG=%%i\n\n\
295 rem Check if Cascade is initialized\n\
296 if not exist \".cascade\" (\n\
297 echo ā¹ļø Cascade not initialized, skipping stack management\n\
298 echo š” Run 'cc init' to start using stacked diffs\n\
299 exit /b 0\n\
300 )\n\n\
301 rem Check if there's an active stack\n\
302 \"{cascade_cli}\" stack list --active >nul 2>&1\n\
303 if %ERRORLEVEL% neq 0 (\n\
304 echo ā¹ļø No active stack found, commit will not be added to any stack\n\
305 echo š” Use 'cc stack create ^<name^>' to create a stack for this commit\n\
306 exit /b 0\n\
307 )\n\n\
308 rem Add commit to active stack\n\
309 echo šŖ Adding commit to active stack...\n\
310 echo š Commit: %COMMIT_MSG%\n\
311 \"{cascade_cli}\" stack push --commit \"%COMMIT_HASH%\" --message \"%COMMIT_MSG%\"\n\
312 if %ERRORLEVEL% equ 0 (\n\
313 echo ā
Commit added to stack successfully\n\
314 echo š” Next: 'cc submit' to create PRs when ready\n\
315 ) else (\n\
316 echo ā ļø Failed to add commit to stack\n\
317 echo š” You can manually add it with: cc push --commit %COMMIT_HASH%\n\
318 )\n"
319 )
320 }
321
322 #[cfg(not(windows))]
323 {
324 format!(
325 "#!/bin/sh\n\
326 # Cascade CLI Hook - Post Commit\n\
327 # Automatically adds new commits to the active stack\n\n\
328 set -e\n\n\
329 # Get the commit hash and message\n\
330 COMMIT_HASH=$(git rev-parse HEAD)\n\
331 COMMIT_MSG=$(git log --format=%s -n 1 HEAD)\n\n\
332 # Check if Cascade is initialized\n\
333 if [ ! -d \".cascade\" ]; then\n\
334 echo \"ā¹ļø Cascade not initialized, skipping stack management\"\n\
335 echo \"š” Run 'cc init' to start using stacked diffs\"\n\
336 exit 0\n\
337 fi\n\n\
338 # Check if there's an active stack\n\
339 if ! \"{cascade_cli}\" stack list --active > /dev/null 2>&1; then\n\
340 echo \"ā¹ļø No active stack found, commit will not be added to any stack\"\n\
341 echo \"š” Use 'cc stack create <name>' to create a stack for this commit\"\n\
342 exit 0\n\
343 fi\n\n\
344 # Add commit to active stack (using specific commit targeting)\n\
345 echo \"šŖ Adding commit to active stack...\"\n\
346 echo \"š Commit: $COMMIT_MSG\"\n\
347 if \"{cascade_cli}\" stack push --commit \"$COMMIT_HASH\" --message \"$COMMIT_MSG\"; then\n\
348 echo \"ā
Commit added to stack successfully\"\n\
349 echo \"š” Next: 'cc submit' to create PRs when ready\"\n\
350 else\n\
351 echo \"ā ļø Failed to add commit to stack\"\n\
352 echo \"š” You can manually add it with: cc push --commit $COMMIT_HASH\"\n\
353 fi\n"
354 )
355 }
356 }
357
358 fn generate_pre_push_hook(&self, cascade_cli: &str) -> String {
359 #[cfg(windows)]
360 {
361 format!(
362 "@echo off\n\
363 rem Cascade CLI Hook - Pre Push\n\
364 rem Prevents force pushes and validates stack state\n\n\
365 rem Check for force push\n\
366 echo %* | findstr /C:\"--force\" /C:\"--force-with-lease\" /C:\"-f\" >nul\n\
367 if %ERRORLEVEL% equ 0 (\n\
368 echo ā Force push detected!\n\
369 echo š Cascade CLI uses stacked diffs - force pushes can break stack integrity\n\
370 echo.\n\
371 echo š” Instead of force pushing, try these streamlined commands:\n\
372 echo ⢠cc sync - Sync with remote changes ^(handles rebasing^)\n\
373 echo ⢠cc push - Push all unpushed commits ^(new default^)\n\
374 echo ⢠cc submit - Submit all entries for review ^(new default^)\n\
375 echo ⢠cc autoland - Auto-merge when approved + builds pass\n\
376 echo.\n\
377 echo šØ If you really need to force push, run:\n\
378 echo git push --force-with-lease [remote] [branch]\n\
379 echo ^(But consider if this will affect other stack entries^)\n\
380 exit /b 1\n\
381 )\n\n\
382 rem Check if Cascade is initialized\n\
383 if not exist \".cascade\" (\n\
384 echo ā¹ļø Cascade not initialized, allowing push\n\
385 exit /b 0\n\
386 )\n\n\
387 rem Validate stack state\n\
388 echo šŖ Validating stack state before push...\n\
389 \"{cascade_cli}\" stack validate\n\
390 if %ERRORLEVEL% equ 0 (\n\
391 echo ā
Stack validation passed\n\
392 ) else (\n\
393 echo ā Stack validation failed\n\
394 echo š” Fix validation errors before pushing:\n\
395 echo ⢠cc doctor - Check overall health\n\
396 echo ⢠cc status - Check current stack status\n\
397 echo ⢠cc sync - Sync with remote and rebase if needed\n\
398 exit /b 1\n\
399 )\n\n\
400 echo ā
Pre-push validation complete\n"
401 )
402 }
403
404 #[cfg(not(windows))]
405 {
406 format!(
407 "#!/bin/sh\n\
408 # Cascade CLI Hook - Pre Push\n\
409 # Prevents force pushes and validates stack state\n\n\
410 set -e\n\n\
411 # Check for force push\n\
412 if echo \"$*\" | grep -q -- \"--force\\|--force-with-lease\\|-f\"; then\n\
413 echo \"ā Force push detected!\"\n\
414 echo \"š Cascade CLI uses stacked diffs - force pushes can break stack integrity\"\n\
415 echo \"\"\n\
416 echo \"š” Instead of force pushing, try these streamlined commands:\"\n\
417 echo \" ⢠cc sync - Sync with remote changes (handles rebasing)\"\n\
418 echo \" ⢠cc push - Push all unpushed commits (new default)\"\n\
419 echo \" ⢠cc submit - Submit all entries for review (new default)\"\n\
420 echo \" ⢠cc autoland - Auto-merge when approved + builds pass\"\n\
421 echo \"\"\n\
422 echo \"šØ If you really need to force push, run:\"\n\
423 echo \" git push --force-with-lease [remote] [branch]\"\n\
424 echo \" (But consider if this will affect other stack entries)\"\n\
425 exit 1\n\
426 fi\n\n\
427 # Check if Cascade is initialized\n\
428 if [ ! -d \".cascade\" ]; then\n\
429 echo \"ā¹ļø Cascade not initialized, allowing push\"\n\
430 exit 0\n\
431 fi\n\n\
432 # Validate stack state\n\
433 echo \"šŖ Validating stack state before push...\"\n\
434 if \"{cascade_cli}\" stack validate; then\n\
435 echo \"ā
Stack validation passed\"\n\
436 else\n\
437 echo \"ā Stack validation failed\"\n\
438 echo \"š” Fix validation errors before pushing:\"\n\
439 echo \" ⢠cc doctor - Check overall health\"\n\
440 echo \" ⢠cc status - Check current stack status\"\n\
441 echo \" ⢠cc sync - Sync with remote and rebase if needed\"\n\
442 exit 1\n\
443 fi\n\n\
444 echo \"ā
Pre-push validation complete\"\n"
445 )
446 }
447 }
448
449 fn generate_commit_msg_hook(&self, _cascade_cli: &str) -> String {
450 #[cfg(windows)]
451 {
452 r#"@echo off
453rem Cascade CLI Hook - Commit Message
454rem Validates commit message format
455
456set COMMIT_MSG_FILE=%1
457if "%COMMIT_MSG_FILE%"=="" (
458 echo ā No commit message file provided
459 exit /b 1
460)
461
462rem Read commit message (Windows batch is limited, but this covers basic cases)
463for /f "delims=" %%i in ('type "%COMMIT_MSG_FILE%"') do set COMMIT_MSG=%%i
464
465rem Skip validation for merge commits, fixup commits, etc.
466echo %COMMIT_MSG% | findstr /B /C:"Merge" /C:"Revert" /C:"fixup!" /C:"squash!" >nul
467if %ERRORLEVEL% equ 0 exit /b 0
468
469rem Check if Cascade is initialized
470if not exist ".cascade" exit /b 0
471
472rem Basic commit message validation
473echo %COMMIT_MSG% | findstr /R "^..........*" >nul
474if %ERRORLEVEL% neq 0 (
475 echo ā Commit message too short (minimum 10 characters)
476 echo š” Write a descriptive commit message for better stack management
477 exit /b 1
478)
479
480rem Check for very long messages (approximate check in batch)
481echo %COMMIT_MSG% | findstr /R "^..................................................................................*" >nul
482if %ERRORLEVEL% equ 0 (
483 echo ā ļø Warning: Commit message longer than 72 characters
484 echo š” Consider keeping the first line short for better readability
485)
486
487rem Check for conventional commit format (optional)
488echo %COMMIT_MSG% | findstr /R "^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)" >nul
489if %ERRORLEVEL% neq 0 (
490 echo š” Consider using conventional commit format:
491 echo feat: add new feature
492 echo fix: resolve bug
493 echo docs: update documentation
494 echo etc.
495)
496
497echo ā
Commit message validation passed
498"#.to_string()
499 }
500
501 #[cfg(not(windows))]
502 {
503 r#"#!/bin/sh
504# Cascade CLI Hook - Commit Message
505# Validates commit message format
506
507set -e
508
509COMMIT_MSG_FILE="$1"
510COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
511
512# Skip validation for merge commits, fixup commits, etc.
513if echo "$COMMIT_MSG" | grep -E "^(Merge|Revert|fixup!|squash!)" > /dev/null; then
514 exit 0
515fi
516
517# Check if Cascade is initialized
518if [ ! -d ".cascade" ]; then
519 exit 0
520fi
521
522# Basic commit message validation
523if [ ${#COMMIT_MSG} -lt 10 ]; then
524 echo "ā Commit message too short (minimum 10 characters)"
525 echo "š” Write a descriptive commit message for better stack management"
526 exit 1
527fi
528
529if [ ${#COMMIT_MSG} -gt 72 ]; then
530 echo "ā ļø Warning: Commit message longer than 72 characters"
531 echo "š” Consider keeping the first line short for better readability"
532fi
533
534# Check for conventional commit format (optional)
535if ! echo "$COMMIT_MSG" | grep -E "^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)(\(.+\))?: .+" > /dev/null; then
536 echo "š” Consider using conventional commit format:"
537 echo " feat: add new feature"
538 echo " fix: resolve bug"
539 echo " docs: update documentation"
540 echo " etc."
541fi
542
543echo "ā
Commit message validation passed"
544"#.to_string()
545 }
546 }
547
548 fn generate_prepare_commit_msg_hook(&self, cascade_cli: &str) -> String {
549 #[cfg(windows)]
550 {
551 format!(
552 "@echo off\n\
553 rem Cascade CLI Hook - Prepare Commit Message\n\
554 rem Adds stack context to commit messages\n\n\
555 set COMMIT_MSG_FILE=%1\n\
556 set COMMIT_SOURCE=%2\n\
557 set COMMIT_SHA=%3\n\n\
558 rem Only modify message if it's a regular commit (not merge, template, etc.)\n\
559 if not \"%COMMIT_SOURCE%\"==\"\" if not \"%COMMIT_SOURCE%\"==\"message\" exit /b 0\n\n\
560 rem Check if Cascade is initialized\n\
561 if not exist \".cascade\" exit /b 0\n\n\
562 rem Get active stack info\n\
563 for /f \"tokens=*\" %%i in ('\"{cascade_cli}\" stack list --active --format=name 2^>nul') do set ACTIVE_STACK=%%i\n\n\
564 if not \"%ACTIVE_STACK%\"==\"\" (\n\
565 rem Get current commit message\n\
566 set /p CURRENT_MSG=<%COMMIT_MSG_FILE%\n\n\
567 rem Skip if message already has stack context\n\
568 echo !CURRENT_MSG! | findstr \"[stack:\" >nul\n\
569 if %ERRORLEVEL% equ 0 exit /b 0\n\n\
570 rem Add stack context to commit message\n\
571 echo.\n\
572 echo # Stack: %ACTIVE_STACK%\n\
573 echo # This commit will be added to the active stack automatically.\n\
574 echo # Use 'cc stack status' to see the current stack state.\n\
575 type \"%COMMIT_MSG_FILE%\"\n\
576 ) > \"%COMMIT_MSG_FILE%.tmp\"\n\
577 move \"%COMMIT_MSG_FILE%.tmp\" \"%COMMIT_MSG_FILE%\"\n"
578 )
579 }
580
581 #[cfg(not(windows))]
582 {
583 format!(
584 "#!/bin/sh\n\
585 # Cascade CLI Hook - Prepare Commit Message\n\
586 # Adds stack context to commit messages\n\n\
587 set -e\n\n\
588 COMMIT_MSG_FILE=\"$1\"\n\
589 COMMIT_SOURCE=\"$2\"\n\
590 COMMIT_SHA=\"$3\"\n\n\
591 # Only modify message if it's a regular commit (not merge, template, etc.)\n\
592 if [ \"$COMMIT_SOURCE\" != \"\" ] && [ \"$COMMIT_SOURCE\" != \"message\" ]; then\n\
593 exit 0\n\
594 fi\n\n\
595 # Check if Cascade is initialized\n\
596 if [ ! -d \".cascade\" ]; then\n\
597 exit 0\n\
598 fi\n\n\
599 # Get active stack info\n\
600 ACTIVE_STACK=$(\"{cascade_cli}\" stack list --active --format=name 2>/dev/null || echo \"\")\n\n\
601 if [ -n \"$ACTIVE_STACK\" ]; then\n\
602 # Get current commit message\n\
603 CURRENT_MSG=$(cat \"$COMMIT_MSG_FILE\")\n\
604 \n\
605 # Skip if message already has stack context\n\
606 if echo \"$CURRENT_MSG\" | grep -q \"\\[stack:\"; then\n\
607 exit 0\n\
608 fi\n\
609 \n\
610 # Add stack context to commit message\n\
611 echo \"\n\
612 # Stack: $ACTIVE_STACK\n\
613 # This commit will be added to the active stack automatically.\n\
614 # Use 'cc stack status' to see the current stack state.\n\
615 $CURRENT_MSG\" > \"$COMMIT_MSG_FILE\"\n\
616 fi\n"
617 )
618 }
619 }
620
621 pub fn detect_repository_type(&self) -> Result<RepositoryType> {
623 let output = Command::new("git")
624 .args(["remote", "get-url", "origin"])
625 .current_dir(&self.repo_path)
626 .output()
627 .map_err(|e| CascadeError::config(format!("Failed to get remote URL: {e}")))?;
628
629 if !output.status.success() {
630 return Ok(RepositoryType::Unknown);
631 }
632
633 let remote_url = String::from_utf8_lossy(&output.stdout)
634 .trim()
635 .to_lowercase();
636
637 if remote_url.contains("github.com") {
638 Ok(RepositoryType::GitHub)
639 } else if remote_url.contains("gitlab.com") || remote_url.contains("gitlab") {
640 Ok(RepositoryType::GitLab)
641 } else if remote_url.contains("dev.azure.com") || remote_url.contains("visualstudio.com") {
642 Ok(RepositoryType::AzureDevOps)
643 } else if remote_url.contains("bitbucket") {
644 Ok(RepositoryType::Bitbucket)
645 } else {
646 Ok(RepositoryType::Unknown)
647 }
648 }
649
650 pub fn detect_branch_type(&self) -> Result<BranchType> {
652 let output = Command::new("git")
653 .args(["branch", "--show-current"])
654 .current_dir(&self.repo_path)
655 .output()
656 .map_err(|e| CascadeError::config(format!("Failed to get current branch: {e}")))?;
657
658 if !output.status.success() {
659 return Ok(BranchType::Unknown);
660 }
661
662 let branch_name = String::from_utf8_lossy(&output.stdout)
663 .trim()
664 .to_lowercase();
665
666 if branch_name == "main" || branch_name == "master" || branch_name == "develop" {
667 Ok(BranchType::Main)
668 } else if !branch_name.is_empty() {
669 Ok(BranchType::Feature)
670 } else {
671 Ok(BranchType::Unknown)
672 }
673 }
674
675 pub fn validate_prerequisites(&self) -> Result<()> {
677 println!("š Checking prerequisites for Cascade hooks...");
678
679 let repo_type = self.detect_repository_type()?;
681 match repo_type {
682 RepositoryType::Bitbucket => {
683 println!("ā
Bitbucket repository detected");
684 println!("š” Hooks will work great with 'cc submit' and 'cc autoland' for Bitbucket integration");
685 }
686 RepositoryType::GitHub => {
687 println!("ā
GitHub repository detected");
688 println!("š” Consider setting up GitHub Actions for CI/CD integration");
689 }
690 RepositoryType::GitLab => {
691 println!("ā
GitLab repository detected");
692 println!("š” GitLab CI integration works well with Cascade stacks");
693 }
694 RepositoryType::AzureDevOps => {
695 println!("ā
Azure DevOps repository detected");
696 println!("š” Azure Pipelines can be configured to work with Cascade workflows");
697 }
698 RepositoryType::Unknown => {
699 println!(
700 "ā¹ļø Unknown repository type - hooks will still work for local Git operations"
701 );
702 }
703 }
704
705 let config_path = self.repo_path.join(".cascade").join("config.json");
707 if !config_path.exists() {
708 return Err(CascadeError::config(
709 "š« Cascade not initialized!\n\n\
710 Please run 'cc init' or 'cc setup' first to configure Cascade CLI.\n\
711 Hooks require proper Bitbucket Server configuration.\n\n\
712 Use --force to install anyway (not recommended)."
713 .to_string(),
714 ));
715 }
716
717 let config = Settings::load_from_file(&config_path)?;
719
720 if config.bitbucket.url == "https://bitbucket.example.com"
721 || config.bitbucket.url.contains("example.com")
722 {
723 return Err(CascadeError::config(
724 "š« Invalid Bitbucket configuration!\n\n\
725 Your Bitbucket URL appears to be a placeholder.\n\
726 Please run 'cc setup' to configure a real Bitbucket Server.\n\n\
727 Use --force to install anyway (not recommended)."
728 .to_string(),
729 ));
730 }
731
732 if config.bitbucket.project == "PROJECT" || config.bitbucket.repo == "repo" {
733 return Err(CascadeError::config(
734 "š« Incomplete Bitbucket configuration!\n\n\
735 Your project/repository settings appear to be placeholders.\n\
736 Please run 'cc setup' to complete configuration.\n\n\
737 Use --force to install anyway (not recommended)."
738 .to_string(),
739 ));
740 }
741
742 println!("ā
Prerequisites validation passed");
743 Ok(())
744 }
745
746 pub fn validate_branch_suitability(&self) -> Result<()> {
748 let branch_type = self.detect_branch_type()?;
749
750 match branch_type {
751 BranchType::Main => {
752 return Err(CascadeError::config(
753 "š« Currently on main/master branch!\n\n\
754 Cascade hooks are designed for feature branch development.\n\
755 Working directly on main/master with stacked diffs can:\n\
756 ⢠Complicate the commit history\n\
757 ⢠Interfere with team collaboration\n\
758 ⢠Break CI/CD workflows\n\n\
759 š” Recommended workflow:\n\
760 1. Create a feature branch: git checkout -b feature/my-feature\n\
761 2. Install hooks: cc hooks install\n\
762 3. Develop with stacked commits (auto-added with hooks)\n\
763 4. Push & submit: cc push && cc submit (all by default)\n\
764 5. Auto-land when ready: cc autoland\n\n\
765 Use --force to install anyway (not recommended)."
766 .to_string(),
767 ));
768 }
769 BranchType::Feature => {
770 println!("ā
Feature branch detected - suitable for stacked development");
771 }
772 BranchType::Unknown => {
773 println!("ā ļø Unknown branch type - proceeding with caution");
774 }
775 }
776
777 Ok(())
778 }
779
780 pub fn confirm_installation(&self) -> Result<()> {
782 println!("\nš Hook Installation Summary:");
783 println!("āāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
784 println!("ā Hook ā Description ā");
785 println!("āāāāāāāāāāāāāāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā¤");
786
787 let hooks = vec![
788 HookType::PostCommit,
789 HookType::PrePush,
790 HookType::CommitMsg,
791 HookType::PrepareCommitMsg,
792 ];
793
794 for hook in &hooks {
795 println!("ā {:19} ā {:31} ā", hook.filename(), hook.description());
796 }
797 println!("āāāāāāāāāāāāāāāāāāāāāāā“āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
798
799 println!("\nš These hooks will automatically:");
800 println!("⢠Add commits to your active stack");
801 println!("⢠Validate commit messages");
802 println!("⢠Prevent force pushes that break stack integrity");
803 println!("⢠Add stack context to commit messages");
804
805 println!("\n⨠With hooks + new defaults, your workflow becomes:");
806 println!(" git commit ā Auto-added to stack");
807 println!(" cc push ā Pushes all by default");
808 println!(" cc submit ā Submits all by default");
809 println!(" cc autoland ā Auto-merges when ready");
810
811 use std::io::{self, Write};
812 print!("\nā Install Cascade hooks? [Y/n]: ");
813 io::stdout().flush().unwrap();
814
815 let mut input = String::new();
816 io::stdin().read_line(&mut input).unwrap();
817 let input = input.trim().to_lowercase();
818
819 if input.is_empty() || input == "y" || input == "yes" {
820 println!("ā
Proceeding with installation");
821 Ok(())
822 } else {
823 Err(CascadeError::config(
824 "Installation cancelled by user".to_string(),
825 ))
826 }
827 }
828}
829
830pub async fn install() -> Result<()> {
832 install_with_options(false, false, false, false).await
833}
834
835pub async fn install_with_options(
836 skip_checks: bool,
837 allow_main_branch: bool,
838 yes: bool,
839 force: bool,
840) -> Result<()> {
841 let current_dir = env::current_dir()
842 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
843
844 let hooks_manager = HooksManager::new(¤t_dir)?;
845
846 let options = InstallOptions {
847 check_prerequisites: !skip_checks,
848 feature_branches_only: !allow_main_branch,
849 confirm: !yes,
850 force,
851 };
852
853 hooks_manager.install_with_options(&options)
854}
855
856pub async fn uninstall() -> Result<()> {
857 let current_dir = env::current_dir()
858 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
859
860 let hooks_manager = HooksManager::new(¤t_dir)?;
861 hooks_manager.uninstall_all()
862}
863
864pub async fn status() -> Result<()> {
865 let current_dir = env::current_dir()
866 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
867
868 let hooks_manager = HooksManager::new(¤t_dir)?;
869 hooks_manager.list_installed_hooks()
870}
871
872pub async fn install_hook(hook_name: &str) -> Result<()> {
873 install_hook_with_options(hook_name, false, false).await
874}
875
876pub async fn install_hook_with_options(
877 hook_name: &str,
878 skip_checks: bool,
879 force: bool,
880) -> Result<()> {
881 let current_dir = env::current_dir()
882 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
883
884 let hooks_manager = HooksManager::new(¤t_dir)?;
885
886 let hook_type = match hook_name {
887 "post-commit" => HookType::PostCommit,
888 "pre-push" => HookType::PrePush,
889 "commit-msg" => HookType::CommitMsg,
890 "prepare-commit-msg" => HookType::PrepareCommitMsg,
891 _ => {
892 return Err(CascadeError::config(format!(
893 "Unknown hook type: {hook_name}"
894 )))
895 }
896 };
897
898 if !skip_checks && !force {
900 hooks_manager.validate_prerequisites()?;
901 }
902
903 hooks_manager.install_hook(&hook_type)
904}
905
906pub async fn uninstall_hook(hook_name: &str) -> Result<()> {
907 let current_dir = env::current_dir()
908 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
909
910 let hooks_manager = HooksManager::new(¤t_dir)?;
911
912 let hook_type = match hook_name {
913 "post-commit" => HookType::PostCommit,
914 "pre-push" => HookType::PrePush,
915 "commit-msg" => HookType::CommitMsg,
916 "prepare-commit-msg" => HookType::PrepareCommitMsg,
917 _ => {
918 return Err(CascadeError::config(format!(
919 "Unknown hook type: {hook_name}"
920 )))
921 }
922 };
923
924 hooks_manager.uninstall_hook(&hook_type)
925}
926
927#[cfg(test)]
928mod tests {
929 use super::*;
930 use std::process::Command;
931 use tempfile::TempDir;
932
933 fn create_test_repo() -> (TempDir, std::path::PathBuf) {
934 let temp_dir = TempDir::new().unwrap();
935 let repo_path = temp_dir.path().to_path_buf();
936
937 Command::new("git")
939 .args(["init"])
940 .current_dir(&repo_path)
941 .output()
942 .unwrap();
943 Command::new("git")
944 .args(["config", "user.name", "Test"])
945 .current_dir(&repo_path)
946 .output()
947 .unwrap();
948 Command::new("git")
949 .args(["config", "user.email", "test@test.com"])
950 .current_dir(&repo_path)
951 .output()
952 .unwrap();
953
954 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
956 Command::new("git")
957 .args(["add", "."])
958 .current_dir(&repo_path)
959 .output()
960 .unwrap();
961 Command::new("git")
962 .args(["commit", "-m", "Initial"])
963 .current_dir(&repo_path)
964 .output()
965 .unwrap();
966
967 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
969 .unwrap();
970
971 (temp_dir, repo_path)
972 }
973
974 #[test]
975 fn test_hooks_manager_creation() {
976 let (_temp_dir, repo_path) = create_test_repo();
977 let _manager = HooksManager::new(&repo_path).unwrap();
978
979 assert_eq!(_manager.repo_path, repo_path);
980 assert_eq!(_manager.hooks_dir, repo_path.join(".git/hooks"));
981 }
982
983 #[test]
984 fn test_hook_installation() {
985 let (_temp_dir, repo_path) = create_test_repo();
986 let manager = HooksManager::new(&repo_path).unwrap();
987
988 let hook_type = HookType::PostCommit;
990 let result = manager.install_hook(&hook_type);
991 assert!(result.is_ok());
992
993 let hook_path = repo_path.join(".git/hooks/post-commit");
995 assert!(hook_path.exists());
996
997 #[cfg(unix)]
999 {
1000 use std::os::unix::fs::PermissionsExt;
1001 let metadata = std::fs::metadata(&hook_path).unwrap();
1002 let permissions = metadata.permissions();
1003 assert!(permissions.mode() & 0o111 != 0); }
1005 }
1006
1007 #[test]
1008 fn test_hook_detection() {
1009 let (_temp_dir, repo_path) = create_test_repo();
1010 let _manager = HooksManager::new(&repo_path).unwrap();
1011
1012 let post_commit_path = repo_path.join(".git/hooks/post-commit");
1014 let pre_push_path = repo_path.join(".git/hooks/pre-push");
1015 let commit_msg_path = repo_path.join(".git/hooks/commit-msg");
1016
1017 assert!(!post_commit_path.exists());
1019 assert!(!pre_push_path.exists());
1020 assert!(!commit_msg_path.exists());
1021 }
1022
1023 #[test]
1024 fn test_hook_validation() {
1025 let (_temp_dir, repo_path) = create_test_repo();
1026 let manager = HooksManager::new(&repo_path).unwrap();
1027
1028 let validation = manager.validate_prerequisites();
1030 let _ = validation; let branch_validation = manager.validate_branch_suitability();
1036 let _ = branch_validation; }
1039
1040 #[test]
1041 fn test_hook_uninstallation() {
1042 let (_temp_dir, repo_path) = create_test_repo();
1043 let manager = HooksManager::new(&repo_path).unwrap();
1044
1045 let hook_type = HookType::PostCommit;
1047 manager.install_hook(&hook_type).unwrap();
1048
1049 let hook_path = repo_path.join(".git/hooks/post-commit");
1050 assert!(hook_path.exists());
1051
1052 let result = manager.uninstall_hook(&hook_type);
1053 assert!(result.is_ok());
1054 assert!(!hook_path.exists());
1055 }
1056
1057 #[test]
1058 fn test_hook_content_generation() {
1059 let (_temp_dir, repo_path) = create_test_repo();
1060 let manager = HooksManager::new(&repo_path).unwrap();
1061
1062 let binary_name = "cascade-cli";
1064
1065 let post_commit_content = manager.generate_post_commit_hook(binary_name);
1067 assert!(post_commit_content.contains("#!/bin/sh"));
1068 assert!(post_commit_content.contains(binary_name));
1069
1070 let pre_push_content = manager.generate_pre_push_hook(binary_name);
1072 assert!(pre_push_content.contains("#!/bin/sh"));
1073 assert!(pre_push_content.contains(binary_name));
1074
1075 let commit_msg_content = manager.generate_commit_msg_hook(binary_name);
1077 assert!(commit_msg_content.contains("#!/bin/sh"));
1078 assert!(commit_msg_content.contains("Cascade CLI Hook")); let prepare_commit_content = manager.generate_prepare_commit_msg_hook(binary_name);
1082 assert!(prepare_commit_content.contains("#!/bin/sh"));
1083 assert!(prepare_commit_content.contains(binary_name));
1084 }
1085
1086 #[test]
1087 fn test_hook_status_reporting() {
1088 let (_temp_dir, repo_path) = create_test_repo();
1089 let manager = HooksManager::new(&repo_path).unwrap();
1090
1091 let repo_type = manager.detect_repository_type().unwrap();
1093 assert!(matches!(
1095 repo_type,
1096 RepositoryType::Bitbucket | RepositoryType::Unknown
1097 ));
1098
1099 let branch_type = manager.detect_branch_type().unwrap();
1101 assert!(matches!(
1103 branch_type,
1104 BranchType::Main | BranchType::Unknown
1105 ));
1106 }
1107
1108 #[test]
1109 fn test_force_installation() {
1110 let (_temp_dir, repo_path) = create_test_repo();
1111 let manager = HooksManager::new(&repo_path).unwrap();
1112
1113 let hook_path = repo_path.join(".git/hooks/post-commit");
1115 std::fs::write(&hook_path, "#!/bin/sh\necho 'existing hook'").unwrap();
1116
1117 let hook_type = HookType::PostCommit;
1119 let result = manager.install_hook(&hook_type);
1120 assert!(result.is_ok());
1121
1122 let content = std::fs::read_to_string(&hook_path).unwrap();
1124 assert!(content.contains("Cascade CLI Hook"));
1125 assert!(!content.contains("existing hook"));
1126
1127 let backup_path = hook_path.with_extension("cascade-backup");
1129 assert!(backup_path.exists());
1130 }
1131}