1use crate::cli::output::Output;
2use crate::config::Settings;
3use crate::errors::{CascadeError, Result};
4use crate::git::find_repository_root;
5use dialoguer::{theme::ColorfulTheme, Confirm};
6use std::env;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use tracing::debug;
11
12#[derive(Debug, Clone, PartialEq)]
14pub enum RepositoryType {
15 Bitbucket,
16 GitHub,
17 GitLab,
18 AzureDevOps,
19 Unknown,
20}
21
22#[derive(Debug, Clone, PartialEq)]
24pub enum BranchType {
25 Main, Feature, Unknown,
28}
29
30#[derive(Debug, Clone)]
32pub struct InstallOptions {
33 pub check_prerequisites: bool,
34 pub feature_branches_only: bool,
35 pub confirm: bool,
36 pub force: bool,
37}
38
39impl Default for InstallOptions {
40 fn default() -> Self {
41 Self {
42 check_prerequisites: true,
43 feature_branches_only: true,
44 confirm: true,
45 force: false,
46 }
47 }
48}
49
50pub struct HooksManager {
52 repo_path: PathBuf,
53 repo_id: String,
54}
55
56#[derive(Debug, Clone)]
58pub enum HookType {
59 PostCommit,
61 PrePush,
63 CommitMsg,
65 PreCommit,
67 PrepareCommitMsg,
69}
70
71impl HookType {
72 fn filename(&self) -> String {
73 let base_name = match self {
74 HookType::PostCommit => "post-commit",
75 HookType::PrePush => "pre-push",
76 HookType::CommitMsg => "commit-msg",
77 HookType::PreCommit => "pre-commit",
78 HookType::PrepareCommitMsg => "prepare-commit-msg",
79 };
80 format!(
81 "{}{}",
82 base_name,
83 crate::utils::platform::git_hook_extension()
84 )
85 }
86
87 fn description(&self) -> &'static str {
88 match self {
89 HookType::PostCommit => "Auto-add new commits to active stack",
90 HookType::PrePush => "Prevent force pushes and validate stack state",
91 HookType::CommitMsg => "Validate commit message format",
92 HookType::PreCommit => "Smart edit mode guidance for better UX",
93 HookType::PrepareCommitMsg => "Add stack context to commit messages",
94 }
95 }
96}
97
98impl HooksManager {
99 pub fn new(repo_path: &Path) -> Result<Self> {
100 let git_dir = repo_path.join(".git");
102 if !git_dir.exists() {
103 return Err(CascadeError::config(
104 "Not a Git repository. Git hooks require a valid Git repository.".to_string(),
105 ));
106 }
107
108 let repo_id = Self::generate_repo_id(repo_path)?;
110
111 Ok(Self {
112 repo_path: repo_path.to_path_buf(),
113 repo_id,
114 })
115 }
116
117 fn generate_repo_id(repo_path: &Path) -> Result<String> {
119 use std::process::Command;
120
121 let output = Command::new("git")
122 .args(["remote", "get-url", "origin"])
123 .current_dir(repo_path)
124 .output()
125 .map_err(|e| CascadeError::config(format!("Failed to get remote URL: {e}")))?;
126
127 if !output.status.success() {
128 use sha2::{Digest, Sha256};
130 let canonical_path = repo_path
131 .canonicalize()
132 .unwrap_or_else(|_| repo_path.to_path_buf());
133 let path_str = canonical_path.to_string_lossy();
134 let mut hasher = Sha256::new();
135 hasher.update(path_str.as_bytes());
136 let result = hasher.finalize();
137 let hash = format!("{result:x}");
138 return Ok(format!("local-{}", &hash[..8]));
139 }
140
141 let remote_url = String::from_utf8_lossy(&output.stdout).trim().to_string();
142
143 let safe_name = remote_url
146 .replace("https://", "")
147 .replace("http://", "")
148 .replace("git@", "")
149 .replace("ssh://", "")
150 .replace(".git", "")
151 .replace([':', '/', '\\'], "-")
152 .chars()
153 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '.' || *c == '_')
154 .collect::<String>();
155
156 Ok(safe_name)
157 }
158
159 fn get_cascade_hooks_dir(&self) -> Result<PathBuf> {
161 let home = dirs::home_dir()
162 .ok_or_else(|| CascadeError::config("Could not find home directory".to_string()))?;
163 let cascade_hooks = home.join(".cascade").join("hooks").join(&self.repo_id);
164 Ok(cascade_hooks)
165 }
166
167 fn get_cascade_config_dir(&self) -> Result<PathBuf> {
169 let home = dirs::home_dir()
170 .ok_or_else(|| CascadeError::config("Could not find home directory".to_string()))?;
171 let cascade_config = home.join(".cascade").join("config").join(&self.repo_id);
172 Ok(cascade_config)
173 }
174
175 fn save_original_hooks_path(&self) -> Result<()> {
177 use std::process::Command;
178
179 let config_dir = self.get_cascade_config_dir()?;
180 fs::create_dir_all(&config_dir)
181 .map_err(|e| CascadeError::config(format!("Failed to create config directory: {e}")))?;
182
183 let original_path_file = config_dir.join("original-hooks-path");
184
185 if original_path_file.exists() {
187 if let Ok(saved_path) = fs::read_to_string(&original_path_file) {
189 let saved_path = saved_path.trim();
190 let cascade_hooks_dir = dirs::home_dir()
191 .ok_or_else(|| {
192 CascadeError::config("Could not find home directory".to_string())
193 })?
194 .join(".cascade")
195 .join("hooks")
196 .join(&self.repo_id);
197 let cascade_hooks_path = cascade_hooks_dir.to_string_lossy().to_string();
198
199 if saved_path == cascade_hooks_path {
200 fs::write(&original_path_file, "").map_err(|e| {
202 CascadeError::config(format!("Failed to fix corrupted hooks path: {e}"))
203 })?;
204 return Ok(());
205 }
206 }
207 return Ok(());
209 }
210
211 let output = Command::new("git")
212 .args(["config", "--get", "core.hooksPath"])
213 .current_dir(&self.repo_path)
214 .output()
215 .map_err(|e| CascadeError::config(format!("Failed to check git config: {e}")))?;
216
217 let original_path = if output.status.success() {
218 let current_hooks_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
219
220 let cascade_hooks_dir = dirs::home_dir()
223 .ok_or_else(|| CascadeError::config("Could not find home directory".to_string()))?
224 .join(".cascade")
225 .join("hooks")
226 .join(&self.repo_id);
227 let cascade_hooks_path = cascade_hooks_dir.to_string_lossy().to_string();
228
229 if current_hooks_path == cascade_hooks_path {
230 String::new()
232 } else {
233 current_hooks_path
234 }
235 } else {
236 String::new()
238 };
239
240 fs::write(original_path_file, original_path).map_err(|e| {
241 CascadeError::config(format!("Failed to save original hooks path: {e}"))
242 })?;
243
244 Ok(())
245 }
246
247 fn restore_original_hooks_path(&self) -> Result<()> {
249 use std::process::Command;
250
251 let config_dir = self.get_cascade_config_dir()?;
252 let original_path_file = config_dir.join("original-hooks-path");
253
254 if !original_path_file.exists() {
255 return Ok(());
257 }
258
259 let original_path = fs::read_to_string(&original_path_file).map_err(|e| {
260 CascadeError::config(format!("Failed to read original hooks path: {e}"))
261 })?;
262
263 if original_path.is_empty() {
264 Command::new("git")
266 .args(["config", "--unset", "core.hooksPath"])
267 .current_dir(&self.repo_path)
268 .output()
269 .map_err(|e| {
270 CascadeError::config(format!("Failed to unset core.hooksPath: {e}"))
271 })?;
272 } else {
273 Command::new("git")
275 .args(["config", "core.hooksPath", &original_path])
276 .current_dir(&self.repo_path)
277 .output()
278 .map_err(|e| {
279 CascadeError::config(format!("Failed to restore core.hooksPath: {e}"))
280 })?;
281 }
282
283 fs::remove_file(original_path_file).ok();
285
286 Ok(())
287 }
288
289 #[allow(dead_code)]
291 fn get_hooks_path(repo_path: &Path) -> Result<PathBuf> {
292 use std::process::Command;
293
294 let output = Command::new("git")
296 .args(["config", "--get", "core.hooksPath"])
297 .current_dir(repo_path)
298 .output()
299 .map_err(|e| CascadeError::config(format!("Failed to check git config: {e}")))?;
300
301 let hooks_path = if output.status.success() {
302 let configured_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
303 if configured_path.is_empty() {
304 repo_path.join(".git").join("hooks")
306 } else if configured_path.starts_with('/') {
307 PathBuf::from(configured_path)
309 } else {
310 repo_path.join(configured_path)
312 }
313 } else {
314 repo_path.join(".git").join("hooks")
316 };
317
318 Ok(hooks_path)
319 }
320
321 pub fn install_all(&self) -> Result<()> {
323 self.install_with_options(&InstallOptions::default())
324 }
325
326 pub fn install_essential(&self) -> Result<()> {
328 Output::progress("Installing essential Cascade Git hooks");
329
330 let essential_hooks = vec![
331 HookType::PrePush,
332 HookType::CommitMsg,
333 HookType::PrepareCommitMsg,
334 HookType::PreCommit,
335 ];
336
337 for hook in essential_hooks {
338 self.install_hook(&hook)?;
339 }
340
341 Output::success("Essential Cascade hooks installed successfully!");
342 Output::tip("Note: Post-commit auto-add hook available with 'ca hooks install --all'");
343 Output::section("Hooks installed");
344 self.list_installed_hooks()?;
345
346 Ok(())
347 }
348
349 pub fn install_with_options(&self, options: &InstallOptions) -> Result<()> {
351 if options.check_prerequisites && !options.force {
352 self.validate_prerequisites()?;
353 }
354
355 if options.feature_branches_only && !options.force {
356 self.validate_branch_suitability()?;
357 }
358
359 if options.confirm && !options.force {
360 self.confirm_installation()?;
361 }
362
363 Output::progress("Installing all Cascade Git hooks");
364
365 let hooks = vec![
367 HookType::PostCommit,
368 HookType::PrePush,
369 HookType::CommitMsg,
370 HookType::PrepareCommitMsg,
371 HookType::PreCommit,
372 ];
373
374 for hook in hooks {
375 self.install_hook(&hook)?;
376 }
377
378 Output::success("All Cascade hooks installed successfully!");
379 Output::section("Hooks installed");
380 self.list_installed_hooks()?;
381
382 Ok(())
383 }
384
385 pub fn install_hook(&self, hook_type: &HookType) -> Result<()> {
387 self.save_original_hooks_path()?;
389
390 let cascade_hooks_dir = self.get_cascade_hooks_dir()?;
392 fs::create_dir_all(&cascade_hooks_dir).map_err(|e| {
393 CascadeError::config(format!("Failed to create cascade hooks directory: {e}"))
394 })?;
395
396 let hook_content = self.generate_chaining_hook_script(hook_type)?;
398 let hook_path = cascade_hooks_dir.join(hook_type.filename());
399
400 fs::write(&hook_path, hook_content)
402 .map_err(|e| CascadeError::config(format!("Failed to write hook file: {e}")))?;
403
404 crate::utils::platform::make_executable(&hook_path)
406 .map_err(|e| CascadeError::config(format!("Failed to make hook executable: {e}")))?;
407
408 self.set_cascade_hooks_path()?;
410
411 Output::success(format!("Installed {} hook", hook_type.filename()));
412 Ok(())
413 }
414
415 fn set_cascade_hooks_path(&self) -> Result<()> {
417 use std::process::Command;
418
419 let cascade_hooks_dir = self.get_cascade_hooks_dir()?;
420 let hooks_path_str = cascade_hooks_dir.to_string_lossy();
421
422 let output = Command::new("git")
423 .args(["config", "core.hooksPath", &hooks_path_str])
424 .current_dir(&self.repo_path)
425 .output()
426 .map_err(|e| CascadeError::config(format!("Failed to set core.hooksPath: {e}")))?;
427
428 if !output.status.success() {
429 return Err(CascadeError::config(format!(
430 "Failed to set core.hooksPath: {}",
431 String::from_utf8_lossy(&output.stderr)
432 )));
433 }
434
435 Ok(())
436 }
437
438 pub fn uninstall_all(&self) -> Result<()> {
440 Output::progress("Removing Cascade Git hooks");
441
442 self.restore_original_hooks_path()?;
444
445 let cascade_hooks_dir = self.get_cascade_hooks_dir()?;
447 if cascade_hooks_dir.exists() {
448 fs::remove_dir_all(&cascade_hooks_dir).map_err(|e| {
449 CascadeError::config(format!("Failed to remove cascade hooks directory: {e}"))
450 })?;
451 }
452
453 let git_hooks_dir = self.repo_path.join(".git").join("hooks");
458 if git_hooks_dir.exists() {
459 for hook_type in &[
460 HookType::PostCommit,
461 HookType::PrePush,
462 HookType::CommitMsg,
463 HookType::PrepareCommitMsg,
464 HookType::PreCommit,
465 ] {
466 let hook_path = git_hooks_dir.join(hook_type.filename());
467 if hook_path.exists() {
468 if let Ok(content) = fs::read_to_string(&hook_path) {
471 if content.contains("# Cascade CLI Hook Wrapper")
472 && content.contains("cascade_logic()")
473 && content.contains("# Function to run Cascade logic")
474 {
475 debug!(
476 "Removing old Cascade wrapper hook from .git/hooks: {:?}",
477 hook_path
478 );
479 fs::remove_file(&hook_path).ok(); }
481 }
482 }
483 }
484 }
485
486 let cascade_config_dir = self.get_cascade_config_dir()?;
488 if cascade_config_dir.exists() {
489 fs::remove_dir(&cascade_config_dir).ok();
491 }
492
493 Output::success("All Cascade hooks removed!");
494 Ok(())
495 }
496
497 pub fn uninstall_hook(&self, hook_type: &HookType) -> Result<()> {
499 let cascade_hooks_dir = self.get_cascade_hooks_dir()?;
500 let hook_path = cascade_hooks_dir.join(hook_type.filename());
501
502 if hook_path.exists() {
503 fs::remove_file(&hook_path)
504 .map_err(|e| CascadeError::config(format!("Failed to remove hook file: {e}")))?;
505 Output::success(format!("Removed {} hook", hook_type.filename()));
506
507 let remaining_hooks = fs::read_dir(&cascade_hooks_dir)
509 .map_err(|e| CascadeError::config(format!("Failed to read hooks directory: {e}")))?
510 .filter_map(|entry| entry.ok())
511 .filter(|entry| {
512 entry.path().is_file() && !entry.file_name().to_string_lossy().starts_with('.')
513 })
514 .count();
515
516 if remaining_hooks == 0 {
517 Output::info(
518 "No more Cascade hooks installed, restoring original hooks configuration",
519 );
520 self.restore_original_hooks_path()?;
521 fs::remove_dir(&cascade_hooks_dir).ok();
522 }
523 } else {
524 Output::info(format!("{} hook not found", hook_type.filename()));
525 }
526
527 Ok(())
528 }
529
530 pub fn list_installed_hooks(&self) -> Result<()> {
532 let hooks = vec![
533 HookType::PostCommit,
534 HookType::PrePush,
535 HookType::CommitMsg,
536 HookType::PrepareCommitMsg,
537 HookType::PreCommit,
538 ];
539
540 Output::section("Git Hooks Status");
541
542 let cascade_hooks_dir = self.get_cascade_hooks_dir()?;
544 let using_cascade_hooks = cascade_hooks_dir.exists()
545 && self.get_current_hooks_path()?
546 == Some(cascade_hooks_dir.to_string_lossy().to_string());
547
548 if using_cascade_hooks {
549 Output::success("✓ Cascade hooks are installed and active");
550 Output::info(format!(
551 " Hooks directory: {}",
552 cascade_hooks_dir.display()
553 ));
554
555 let config_dir = self.get_cascade_config_dir()?;
557 let original_path_file = config_dir.join("original-hooks-path");
558 if original_path_file.exists() {
559 let original_path = fs::read_to_string(original_path_file).unwrap_or_default();
560 if !original_path.is_empty() {
561 Output::info(format!(" Chaining to original hooks: {original_path}"));
562 } else {
563 Output::info(" Chaining to original hooks: .git/hooks");
564 }
565 }
566 println!();
567 } else {
568 Output::warning("Cascade hooks are NOT installed in this repository");
569 println!();
570 Output::sub_item("To install Cascade hooks:");
571 Output::command_example("ca hooks install # recommended: 4 essential hooks");
572 Output::command_example(
573 "ca hooks install --all # all 5 hooks + post-commit auto-add",
574 );
575 println!();
576 Output::sub_item("Both options preserve existing hooks by chaining to them");
577 println!();
578 }
579
580 for hook in hooks {
581 let cascade_hook_path = cascade_hooks_dir.join(hook.filename());
582
583 if using_cascade_hooks && cascade_hook_path.exists() {
584 Output::success(format!("{}: {} ✓", hook.filename(), hook.description()));
585 } else {
586 let default_hook_path = self
588 .repo_path
589 .join(".git")
590 .join("hooks")
591 .join(hook.filename());
592 if default_hook_path.exists() {
593 Output::warning(format!(
594 "{}: {} (In .git/hooks, not managed by Cascade)",
595 hook.filename(),
596 hook.description()
597 ));
598 } else {
599 Output::error(format!(
600 "{}: {} (Not installed)",
601 hook.filename(),
602 hook.description()
603 ));
604 }
605 }
606 }
607
608 Ok(())
609 }
610
611 fn get_current_hooks_path(&self) -> Result<Option<String>> {
613 use std::process::Command;
614
615 let output = Command::new("git")
616 .args(["config", "--get", "core.hooksPath"])
617 .current_dir(&self.repo_path)
618 .output()
619 .map_err(|e| CascadeError::config(format!("Failed to check git config: {e}")))?;
620
621 if output.status.success() {
622 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
623 if path.is_empty() {
624 Ok(None)
625 } else {
626 Ok(Some(path))
627 }
628 } else {
629 Ok(None)
630 }
631 }
632
633 pub fn generate_hook_script(&self, hook_type: &HookType) -> Result<String> {
635 let cascade_cli = env::current_exe()
636 .map_err(|e| {
637 CascadeError::config(format!("Failed to get current executable path: {e}"))
638 })?
639 .to_string_lossy()
640 .to_string();
641
642 let script = match hook_type {
643 HookType::PostCommit => self.generate_post_commit_hook(&cascade_cli),
644 HookType::PrePush => self.generate_pre_push_hook(&cascade_cli),
645 HookType::CommitMsg => self.generate_commit_msg_hook(&cascade_cli),
646 HookType::PreCommit => self.generate_pre_commit_hook(&cascade_cli),
647 HookType::PrepareCommitMsg => self.generate_prepare_commit_msg_hook(&cascade_cli),
648 };
649
650 Ok(script)
651 }
652
653 pub fn generate_chaining_hook_script(&self, hook_type: &HookType) -> Result<String> {
655 let cascade_cli = env::current_exe()
656 .map_err(|e| {
657 CascadeError::config(format!("Failed to get current executable path: {e}"))
658 })?
659 .to_string_lossy()
660 .to_string();
661
662 let config_dir = self.get_cascade_config_dir()?;
663 let hook_name = match hook_type {
664 HookType::PostCommit => "post-commit",
665 HookType::PrePush => "pre-push",
666 HookType::CommitMsg => "commit-msg",
667 HookType::PreCommit => "pre-commit",
668 HookType::PrepareCommitMsg => "prepare-commit-msg",
669 };
670
671 let cascade_logic = match hook_type {
673 HookType::PostCommit => self.generate_post_commit_hook(&cascade_cli),
674 HookType::PrePush => self.generate_pre_push_hook(&cascade_cli),
675 HookType::CommitMsg => self.generate_commit_msg_hook(&cascade_cli),
676 HookType::PreCommit => self.generate_pre_commit_hook(&cascade_cli),
677 HookType::PrepareCommitMsg => self.generate_prepare_commit_msg_hook(&cascade_cli),
678 };
679
680 #[cfg(windows)]
682 return Ok(format!(
683 "@echo off\n\
684 rem Cascade CLI Hook Wrapper - {}\n\
685 rem This hook runs Cascade logic first, then chains to original hooks\n\n\
686 rem Run Cascade logic first\n\
687 call :cascade_logic %*\n\
688 set CASCADE_RESULT=%ERRORLEVEL%\n\
689 if %CASCADE_RESULT% neq 0 exit /b %CASCADE_RESULT%\n\n\
690 rem Check for original hook\n\
691 set ORIGINAL_HOOKS_PATH=\n\
692 if exist \"{}\\original-hooks-path\" (\n\
693 set /p ORIGINAL_HOOKS_PATH=<\"{}\\original-hooks-path\"\n\
694 )\n\n\
695 if \"%ORIGINAL_HOOKS_PATH%\"==\"\" (\n\
696 rem Default location\n\
697 for /f \"tokens=*\" %%i in ('git rev-parse --git-dir 2^>nul') do set GIT_DIR=%%i\n\
698 if exist \"%GIT_DIR%\\hooks\\{}\" (\n\
699 call \"%GIT_DIR%\\hooks\\{}\" %*\n\
700 exit /b %ERRORLEVEL%\n\
701 )\n\
702 ) else (\n\
703 rem Custom hooks path\n\
704 if exist \"%ORIGINAL_HOOKS_PATH%\\{}\" (\n\
705 call \"%ORIGINAL_HOOKS_PATH%\\{}\" %*\n\
706 exit /b %ERRORLEVEL%\n\
707 )\n\
708 )\n\n\
709 exit /b 0\n\n\
710 :cascade_logic\n\
711 {}\n\
712 exit /b %ERRORLEVEL%\n",
713 hook_name,
714 config_dir.to_string_lossy(),
715 config_dir.to_string_lossy(),
716 hook_name,
717 hook_name,
718 hook_name,
719 hook_name,
720 cascade_logic
721 ));
722
723 #[cfg(not(windows))]
724 {
725 let trimmed_logic = cascade_logic
727 .trim_start_matches("#!/bin/sh\n")
728 .trim_start_matches("set -e\n");
729
730 let wrapper = format!(
731 "#!/bin/sh\n\
732 # Cascade CLI Hook Wrapper - {}\n\
733 # This hook runs Cascade logic first, then chains to original hooks\n\n\
734 set -e\n\n\
735 # Function to run Cascade logic\n\
736 cascade_logic() {{\n",
737 hook_name
738 );
739
740 let chaining_logic = format!(
741 "\n\
742 }}\n\n\
743 # Run Cascade logic first\n\
744 cascade_logic \"$@\"\n\
745 CASCADE_RESULT=$?\n\
746 if [ $CASCADE_RESULT -ne 0 ]; then\n\
747 exit $CASCADE_RESULT\n\
748 fi\n\n\
749 # Check for original hook\n\
750 ORIGINAL_HOOKS_PATH=\"\"\n\
751 if [ -f \"{}/original-hooks-path\" ]; then\n\
752 ORIGINAL_HOOKS_PATH=$(cat \"{}/original-hooks-path\" 2>/dev/null || echo \"\")\n\
753 fi\n\n\
754 if [ -z \"$ORIGINAL_HOOKS_PATH\" ]; then\n\
755 # Default location\n\
756 GIT_DIR=$(git rev-parse --git-dir 2>/dev/null || echo \".git\")\n\
757 ORIGINAL_HOOK=\"$GIT_DIR/hooks/{}\"\n\
758 else\n\
759 # Custom hooks path\n\
760 ORIGINAL_HOOK=\"$ORIGINAL_HOOKS_PATH/{}\"\n\
761 fi\n\n\
762 # Run original hook if it exists and is executable\n\
763 if [ -x \"$ORIGINAL_HOOK\" ]; then\n\
764 \"$ORIGINAL_HOOK\" \"$@\"\n\
765 exit $?\n\
766 fi\n\n\
767 exit 0\n",
768 config_dir.to_string_lossy(),
769 config_dir.to_string_lossy(),
770 hook_name,
771 hook_name
772 );
773
774 Ok(format!("{}{}{}", wrapper, trimmed_logic, chaining_logic))
775 }
776 }
777
778 fn generate_post_commit_hook(&self, cascade_cli: &str) -> String {
779 #[cfg(windows)]
780 {
781 format!(
782 "@echo off\n\
783 rem Cascade CLI Hook - Post Commit\n\
784 rem Automatically adds new commits to the active stack\n\n\
785 rem Get the commit hash and message\n\
786 for /f \"tokens=*\" %%i in ('git rev-parse HEAD') do set COMMIT_HASH=%%i\n\
787 for /f \"tokens=*\" %%i in ('git log --format=%%s -n 1 HEAD') do set COMMIT_MSG=%%i\n\n\
788 rem Find repository root and check if Cascade is initialized\n\
789 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
790 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
791 if not exist \"%REPO_ROOT%\\.cascade\" (\n\
792 echo \"Cascade not initialized, skipping stack management\"\n\
793 echo \"Run 'ca init' to start using stacked diffs\"\n\
794 exit /b 0\n\
795 )\n\n\
796 rem Check if there's an active stack\n\
797 \"{cascade_cli}\" stack list --active >nul 2>&1\n\
798 if %ERRORLEVEL% neq 0 (\n\
799 echo \"No active stack found, commit will not be added to any stack\"\n\
800 echo \"Tip: Use 'ca stack create ^<name^>' to create a stack for this commit\"\n\
801 exit /b 0\n\
802 )\n\n\
803 rem Add commit to active stack\n\
804 echo \"Adding commit to active stack...\"\n\
805 echo \"Commit: %COMMIT_MSG%\"\n\
806 \"{cascade_cli}\" stack push --commit \"%COMMIT_HASH%\" --message \"%COMMIT_MSG%\"\n\
807 if %ERRORLEVEL% equ 0 (\n\
808 echo \"Commit added to stack successfully\"\n\
809 echo \"Next: 'ca submit' to create PRs when ready\"\n\
810 ) else (\n\
811 echo \"Failed to add commit to stack\"\n\
812 echo \"Tip: You can manually add it with: ca push --commit %COMMIT_HASH%\"\n\
813 )\n"
814 )
815 }
816
817 #[cfg(not(windows))]
818 {
819 format!(
820 "#!/bin/sh\n\
821 # Cascade CLI Hook - Post Commit\n\
822 # Automatically adds new commits to the active stack\n\n\
823 set -e\n\n\
824 # Get the commit hash and message\n\
825 COMMIT_HASH=$(git rev-parse HEAD)\n\
826 COMMIT_MSG=$(git log --format=%s -n 1 HEAD)\n\n\
827 # Find repository root and check if Cascade is initialized\n\
828 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
829 if [ ! -d \"$REPO_ROOT/.cascade\" ]; then\n\
830 echo \"Cascade not initialized, skipping stack management\"\n\
831 echo \"Run 'ca init' to start using stacked diffs\"\n\
832 exit 0\n\
833 fi\n\n\
834 # Check if there's an active stack\n\
835 if ! \"{cascade_cli}\" stack list --active > /dev/null 2>&1; then\n\
836 echo \"No active stack found, commit will not be added to any stack\"\n\
837 echo \"Tip: Use 'ca stack create <name>' to create a stack for this commit\"\n\
838 exit 0\n\
839 fi\n\n\
840 # Add commit to active stack (using specific commit targeting)\n\
841 echo \"Adding commit to active stack...\"\n\
842 echo \"Commit: $COMMIT_MSG\"\n\
843 if \"{cascade_cli}\" stack push --commit \"$COMMIT_HASH\" --message \"$COMMIT_MSG\"; then\n\
844 echo \"Commit added to stack successfully\"\n\
845 echo \"Next: 'ca submit' to create PRs when ready\"\n\
846 else\n\
847 echo \"Failed to add commit to stack\"\n\
848 echo \"Tip: You can manually add it with: ca push --commit $COMMIT_HASH\"\n\
849 fi\n"
850 )
851 }
852 }
853
854 fn generate_pre_push_hook(&self, cascade_cli: &str) -> String {
855 #[cfg(windows)]
856 {
857 format!(
858 "@echo off\n\
859 rem Cascade CLI Hook - Pre Push\n\
860 rem Prevents force pushes and validates stack state\n\n\
861 rem Allow force pushes from Cascade internal commands (ca sync, ca submit, etc.)\n\
862 rem Check for marker file (Git hooks don't inherit env vars)\n\
863 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
864 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
865 if exist \"%REPO_ROOT%\\.git\\.cascade-internal-push\" (\n\
866 exit /b 0\n\
867 )\n\n\
868 rem Check for force push from user\n\
869 echo %* | findstr /C:\"--force\" /C:\"--force-with-lease\" /C:\"-f\" >nul\n\
870 if %ERRORLEVEL% equ 0 (\n\
871 echo ERROR: Force push detected\n\
872 echo Cascade CLI uses stacked diffs - force pushes can break stack integrity\n\
873 echo.\n\
874 echo Instead, try these commands:\n\
875 echo ca sync - Sync with remote changes ^(handles rebasing^)\n\
876 echo ca push - Push all unpushed commits\n\
877 echo ca submit - Submit all entries for review\n\
878 echo ca autoland - Auto-merge when approved + builds pass\n\
879 echo.\n\
880 echo If you really need to force push:\n\
881 echo git push --force-with-lease [remote] [branch]\n\
882 echo ^(But consider if this will affect other stack entries^)\n\
883 exit /b 1\n\
884 )\n\n\
885 rem Find repository root and check if Cascade is initialized\n\
886 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
887 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
888 if not exist \"%REPO_ROOT%\\.cascade\" (\n\
889 exit /b 0\n\
890 )\n\n\
891 rem Validate stack state (silent unless error)\n\
892 \"{cascade_cli}\" validate >nul 2>&1\n\
893 if %ERRORLEVEL% neq 0 (\n\
894 echo Stack validation failed - run 'ca validate' for details\n\
895 exit /b 1\n\
896 )\n"
897 )
898 }
899
900 #[cfg(not(windows))]
901 {
902 format!(
903 "#!/bin/sh\n\
904 # Cascade CLI Hook - Pre Push\n\
905 # Prevents force pushes and validates stack state\n\n\
906 set -e\n\n\
907 # Allow force pushes from Cascade internal commands (ca sync, ca submit, etc.)\n\
908 # Check for marker file (Git hooks don't inherit env vars)\n\
909 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
910 if [ -f \"$REPO_ROOT/.git/.cascade-internal-push\" ]; then\n\
911 exit 0\n\
912 fi\n\n\
913 # Check for force push from user\n\
914 if echo \"$*\" | grep -q -- \"--force\\|--force-with-lease\\|-f\"; then\n\
915 echo \"ERROR: Force push detected\"\n\
916 echo \"Cascade CLI uses stacked diffs - force pushes can break stack integrity\"\n\
917 echo \"\"\n\
918 echo \"Instead, try these commands:\"\n\
919 echo \" ca sync - Sync with remote changes (handles rebasing)\"\n\
920 echo \" ca push - Push all unpushed commits\"\n\
921 echo \" ca submit - Submit all entries for review\"\n\
922 echo \" ca autoland - Auto-merge when approved + builds pass\"\n\
923 echo \"\"\n\
924 echo \"If you really need to force push:\"\n\
925 echo \" git push --force-with-lease [remote] [branch]\"\n\
926 echo \" (But consider if this will affect other stack entries)\"\n\
927 exit 1\n\
928 fi\n\n\
929 # Find repository root and check if Cascade is initialized\n\
930 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
931 if [ ! -d \"$REPO_ROOT/.cascade\" ]; then\n\
932 exit 0\n\
933 fi\n\n\
934 # Validate stack state (silent unless error)\n\
935 if ! \"{cascade_cli}\" validate > /dev/null 2>&1; then\n\
936 echo \"Stack validation failed - run 'ca validate' for details\"\n\
937 exit 1\n\
938 fi\n"
939 )
940 }
941 }
942
943 fn generate_commit_msg_hook(&self, _cascade_cli: &str) -> String {
944 #[cfg(windows)]
945 {
946 r#"@echo off
947rem Cascade CLI Hook - Commit Message
948rem Validates commit message format
949
950set COMMIT_MSG_FILE=%1
951if "%COMMIT_MSG_FILE%"=="" (
952 echo ERROR: No commit message file provided
953 exit /b 1
954)
955
956rem Read commit message (Windows batch is limited, but this covers basic cases)
957for /f "delims=" %%i in ('type "%COMMIT_MSG_FILE%"') do set COMMIT_MSG=%%i
958
959rem Skip validation for merge commits, fixup commits, etc.
960echo %COMMIT_MSG% | findstr /B /C:"Merge" /C:"Revert" /C:"fixup!" /C:"squash!" >nul
961if %ERRORLEVEL% equ 0 exit /b 0
962
963rem Find repository root and check if Cascade is initialized
964for /f "tokens=*" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i
965if "%REPO_ROOT%"=="" set REPO_ROOT=.
966if not exist "%REPO_ROOT%\.cascade" exit /b 0
967
968rem Basic commit message validation
969echo %COMMIT_MSG% | findstr /R "^..........*" >nul
970if %ERRORLEVEL% neq 0 (
971 echo ERROR: Commit message too short (minimum 10 characters)
972 echo TIP: Write a descriptive commit message for better stack management
973 exit /b 1
974)
975
976rem Validation passed (silent success)
977exit /b 0
978"#
979 .to_string()
980 }
981
982 #[cfg(not(windows))]
983 {
984 r#"#!/bin/sh
985# Cascade CLI Hook - Commit Message
986# Validates commit message format
987
988set -e
989
990COMMIT_MSG_FILE="$1"
991COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
992
993# Skip validation for merge commits, fixup commits, etc.
994if echo "$COMMIT_MSG" | grep -E "^(Merge|Revert|fixup!|squash!)" > /dev/null; then
995 exit 0
996fi
997
998# Find repository root and check if Cascade is initialized
999REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")
1000if [ ! -d "$REPO_ROOT/.cascade" ]; then
1001 exit 0
1002fi
1003
1004# Basic commit message validation
1005if [ ${#COMMIT_MSG} -lt 10 ]; then
1006 echo "ERROR: Commit message too short (minimum 10 characters)"
1007 echo "TIP: Write a descriptive commit message for better stack management"
1008 exit 1
1009fi
1010
1011# Validation passed (silent success)
1012exit 0
1013"#
1014 .to_string()
1015 }
1016 }
1017
1018 #[allow(clippy::uninlined_format_args)]
1019 fn generate_pre_commit_hook(&self, cascade_cli: &str) -> String {
1020 #[cfg(windows)]
1021 {
1022 format!(
1023 "@echo off\n\
1024 rem Cascade CLI Hook - Pre Commit\n\
1025 rem Smart edit mode guidance for better UX\n\n\
1026 rem Check if Cascade is initialized\n\
1027 for /f \\\"tokens=*\\\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
1028 if \\\"%REPO_ROOT%\\\"==\\\"\\\" set REPO_ROOT=.\n\
1029 if not exist \\\"%REPO_ROOT%\\.cascade\\\" exit /b 0\n\n\
1030 rem Skip hook if called from ca entry amend ^(avoid infinite loop^)\n\
1031 if \\\"%CASCADE_SKIP_HOOKS%\\\"==\\\"1\\\" exit /b 0\n\n\
1032 rem Get edit status\n\
1033 for /f \\\"tokens=*\\\" %%i in ('\\\"{0}\\\" entry status --quiet 2^>nul') do set EDIT_STATUS=%%i\n\
1034 if \\\"%EDIT_STATUS%\\\"==\\\"\\\" set EDIT_STATUS=inactive\n\n\
1035 rem Check if edit status is active\n\
1036 echo %EDIT_STATUS% | findstr /b \\\"active:\\\" >nul\n\
1037 if %ERRORLEVEL% equ 0 (\n\
1038 echo You're in EDIT MODE for a stack entry\n\
1039 echo.\n\
1040 echo Choose your action:\n\
1041 echo [a] amend: Modify the current entry ^(default^)\n\
1042 echo [n] new: Create new entry on top\n\
1043 echo [c] cancel: Stop and think about it\n\
1044 echo.\n\
1045 set /p choice=\\\"Your choice (a/n/c): \\\" <CON\n\
1046 if \\\"%choice%\\\"==\\\"\\\" set choice=a\n\
1047 \n\
1048 if /i \\\"%choice%\\\"==\\\"A\\\" (\n\
1049 rem Use ca entry amend to update entry ^(ignore any -m flag^)\n\
1050 rem Changes are already staged by git commit; --restack updates dependents\n\
1051 \\\"{0}\\\" entry amend --restack\n\
1052 set amend_error=%ERRORLEVEL%\n\
1053 if %amend_error% EQU 0 (\n\
1054 echo Amend applied - skipping git commit to avoid duplicate entry.\n\
1055 echo Your commit was updated by Cascade; no further action needed.\n\
1056 exit /b 1\n\
1057 ) else (\n\
1058 exit /b %amend_error%\n\
1059 )\n\
1060 ) else if /i \\\"%choice%\\\"==\\\"N\\\" (\n\
1061 echo Creating new stack entry...\n\
1062 echo The commit will proceed and post-commit hook will add it to your stack\n\
1063 rem Let commit proceed ^(Git will use -m flag or open editor^)\n\
1064 exit /b 0\n\
1065 ) else if /i \\\"%choice%\\\"==\\\"C\\\" (\n\
1066 echo Commit cancelled\n\
1067 exit /b 1\n\
1068 ) else (\n\
1069 echo Invalid choice. Please choose A, n, or c\n\
1070 exit /b 1\n\
1071 )\n\
1072 )\n\n\
1073 rem Not in edit mode, proceed normally\n\
1074 exit /b 0\n",
1075 cascade_cli
1076 )
1077 }
1078
1079 #[cfg(not(windows))]
1080 {
1081 let status_check = format!(
1084 "EDIT_STATUS=$(\"{}\" entry status --quiet 2>/dev/null || echo \"inactive\")",
1085 cascade_cli
1086 );
1087 let amend_line = format!(" \"{}\" entry amend --restack", cascade_cli);
1090
1091 vec![
1092 "#!/bin/sh".to_string(),
1093 "# Cascade CLI Hook - Pre Commit".to_string(),
1094 "# Smart edit mode guidance for better UX".to_string(),
1095 "".to_string(),
1096 "set -e".to_string(),
1097 "".to_string(),
1098 "# Check if Cascade is initialized".to_string(),
1099 r#"REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")"#.to_string(),
1100 r#"if [ ! -d "$REPO_ROOT/.cascade" ]; then"#.to_string(),
1101 " exit 0".to_string(),
1102 "fi".to_string(),
1103 "".to_string(),
1104 "# Skip hook if called from ca entry amend (avoid infinite loop)".to_string(),
1105 r#"if [ "$CASCADE_SKIP_HOOKS" = "1" ]; then"#.to_string(),
1106 " exit 0".to_string(),
1107 "fi".to_string(),
1108 "".to_string(),
1109 "# Check if we're in edit mode".to_string(),
1110 r#"CURRENT_BRANCH=$(git branch --show-current 2>/dev/null)"#.to_string(),
1111 status_check,
1112 "".to_string(),
1113 "# If in edit mode, check if we're on a stack entry branch".to_string(),
1114 r#"if echo "$EDIT_STATUS" | grep -q "^active:"; then"#.to_string(),
1115 " # Check if current branch is a stack entry branch".to_string(),
1116 format!(r#" if ! "{}" stacks list --format=json 2>/dev/null | grep -q "\"branch_name\": \"$CURRENT_BRANCH\""; then"#, cascade_cli),
1117 r#" # Not on a stack entry branch - edit mode is for a different branch"#.to_string(),
1118 r#" # Silently proceed with normal commit"#.to_string(),
1119 " exit 0".to_string(),
1120 " fi".to_string(),
1121 " ".to_string(),
1122 " # Proper edit mode - prompt user".to_string(),
1123 r#" echo "You're in EDIT MODE for a stack entry""#.to_string(),
1124 r#" echo """#.to_string(),
1125 r#" echo "Choose your action:""#.to_string(),
1126 r#" echo " [a] amend: Modify the current entry (default)""#.to_string(),
1127 r#" echo " [n] new: Create new entry on top""#.to_string(),
1128 r#" echo " [c] cancel: Stop and think about it""#.to_string(),
1129 r#" echo """#.to_string(),
1130 " ".to_string(),
1131 " # Read user choice with default to amend".to_string(),
1132 r#" read -p "Your choice (a/n/c): " choice < /dev/tty"#.to_string(),
1133 " choice=${choice:-a}".to_string(),
1134 " ".to_string(),
1135 " ".to_string(),
1136 r#" case "$choice" in"#.to_string(),
1137 " [Aa])".to_string(),
1138 " # Use ca entry amend to properly update entry + working branch (ignore any -m flag)"
1139 .to_string(),
1140 " # Changes are already staged by 'git commit', so no --all flag needed".to_string(),
1141 amend_line.replace(" ", " "),
1142 " amend_rc=$?".to_string(),
1143 r#" if [ $amend_rc -eq 0 ]; then"#.to_string(),
1144 r#" echo "Amend applied - skipping git commit to avoid duplicate entry.""#
1145 .to_string(),
1146 r#" echo "Your commit was updated by Cascade; no further action needed.""#
1147 .to_string(),
1148 " exit 1".to_string(),
1149 " else".to_string(),
1150 " exit $amend_rc".to_string(),
1151 " fi".to_string(),
1152 " ;;".to_string(),
1153 " [Nn])".to_string(),
1154 r#" echo "Creating new stack entry...""#.to_string(),
1155 r#" echo "The commit will proceed and post-commit hook will add it to your stack""#.to_string(),
1156 " # Let the commit proceed normally (Git will use -m flag or open editor)"
1157 .to_string(),
1158 " exit 0".to_string(),
1159 " ;;".to_string(),
1160 " [Cc])".to_string(),
1161 r#" echo "Commit cancelled""#.to_string(),
1162 " exit 1".to_string(),
1163 " ;;".to_string(),
1164 " *)".to_string(),
1165 r#" echo "Invalid choice. Please choose A, n, or c""#.to_string(),
1166 " exit 1".to_string(),
1167 " ;;".to_string(),
1168 " esac".to_string(),
1169 "fi".to_string(),
1170 "".to_string(),
1171 "# Not in edit mode, proceed normally".to_string(),
1172 "exit 0".to_string(),
1173 ]
1174 .join("\n")
1175 }
1176 }
1177
1178 fn generate_prepare_commit_msg_hook(&self, cascade_cli: &str) -> String {
1179 #[cfg(windows)]
1180 {
1181 format!(
1182 "@echo off\n\
1183 rem Cascade CLI Hook - Prepare Commit Message\n\
1184 rem Adds stack context to commit messages\n\n\
1185 set COMMIT_MSG_FILE=%1\n\
1186 set COMMIT_SOURCE=%2\n\
1187 set COMMIT_SHA=%3\n\n\
1188 rem Skip if user provided message via -m flag, merge commit, etc.\n\
1189 if not \"%COMMIT_SOURCE%\"==\"\" exit /b 0\n\n\
1190 rem Find repository root and check if Cascade is initialized\n\
1191 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
1192 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
1193 if not exist \"%REPO_ROOT%\\.cascade\" exit /b 0\n\n\
1194 rem Check for active stack\n\
1195 for /f \"tokens=*\" %%i in ('\"{cascade_cli}\" stack list --active --format=name 2^>nul') do set ACTIVE_STACK=%%i\n\n\
1196 if not \"%ACTIVE_STACK%\"==\"\" (\n\
1197 rem Get current commit message\n\
1198 set /p CURRENT_MSG=<%COMMIT_MSG_FILE%\n\n\
1199 rem Skip if message already has stack context\n\
1200 echo !CURRENT_MSG! | findstr \"[stack:\" >nul\n\
1201 if %ERRORLEVEL% equ 0 exit /b 0\n\n\
1202 rem Add stack context to commit message\n\
1203 echo.\n\
1204 echo # Stack: %ACTIVE_STACK%\n\
1205 echo # This commit will be added to the active stack automatically.\n\
1206 echo # Use 'ca stack status' to see the current stack state.\n\
1207 type \"%COMMIT_MSG_FILE%\"\n\
1208 ) > \"%COMMIT_MSG_FILE%.tmp\"\n\
1209 move \"%COMMIT_MSG_FILE%.tmp\" \"%COMMIT_MSG_FILE%\"\n"
1210 )
1211 }
1212
1213 #[cfg(not(windows))]
1214 {
1215 format!(
1216 "#!/bin/sh\n\
1217 # Cascade CLI Hook - Prepare Commit Message\n\
1218 # Adds stack context to commit messages\n\n\
1219 set -e\n\n\
1220 COMMIT_MSG_FILE=\"$1\"\n\
1221 COMMIT_SOURCE=\"$2\"\n\
1222 COMMIT_SHA=\"$3\"\n\n\
1223 # Skip if user provided message via -m flag, merge commit, etc.\n\
1224 if [ \"$COMMIT_SOURCE\" != \"\" ]; then\n\
1225 exit 0\n\
1226 fi\n\n\
1227 # Find repository root and check if Cascade is initialized\n\
1228 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
1229 if [ ! -d \"$REPO_ROOT/.cascade\" ]; then\n\
1230 exit 0\n\
1231 fi\n\n\
1232 # Check for active stack\n\
1233 ACTIVE_STACK=$(\"{cascade_cli}\" stack list --active --format=name 2>/dev/null || echo \"\")\n\
1234 \n\
1235 if [ -n \"$ACTIVE_STACK\" ]; then\n\
1236 # Get current commit message\n\
1237 CURRENT_MSG=$(cat \"$COMMIT_MSG_FILE\")\n\
1238 \n\
1239 # Skip if message already has stack context\n\
1240 if echo \"$CURRENT_MSG\" | grep -q \"\\[stack:\"; then\n\
1241 exit 0\n\
1242 fi\n\
1243 \n\
1244 # Add stack context to commit message\n\
1245 echo \"\n\
1246 # Stack: $ACTIVE_STACK\n\
1247 # This commit will be added to the active stack automatically.\n\
1248 # Use 'ca stack status' to see the current stack state.\n\
1249 $CURRENT_MSG\" > \"$COMMIT_MSG_FILE\"\n\
1250 fi\n"
1251 )
1252 }
1253 }
1254
1255 pub fn detect_repository_type(&self) -> Result<RepositoryType> {
1257 let output = Command::new("git")
1258 .args(["remote", "get-url", "origin"])
1259 .current_dir(&self.repo_path)
1260 .output()
1261 .map_err(|e| CascadeError::config(format!("Failed to get remote URL: {e}")))?;
1262
1263 if !output.status.success() {
1264 return Ok(RepositoryType::Unknown);
1265 }
1266
1267 let remote_url = String::from_utf8_lossy(&output.stdout)
1268 .trim()
1269 .to_lowercase();
1270
1271 if remote_url.contains("github.com") {
1272 Ok(RepositoryType::GitHub)
1273 } else if remote_url.contains("gitlab.com") || remote_url.contains("gitlab") {
1274 Ok(RepositoryType::GitLab)
1275 } else if remote_url.contains("dev.azure.com") || remote_url.contains("visualstudio.com") {
1276 Ok(RepositoryType::AzureDevOps)
1277 } else if remote_url.contains("bitbucket") {
1278 Ok(RepositoryType::Bitbucket)
1279 } else {
1280 Ok(RepositoryType::Unknown)
1281 }
1282 }
1283
1284 pub fn detect_branch_type(&self) -> Result<BranchType> {
1286 let output = Command::new("git")
1287 .args(["branch", "--show-current"])
1288 .current_dir(&self.repo_path)
1289 .output()
1290 .map_err(|e| CascadeError::config(format!("Failed to get current branch: {e}")))?;
1291
1292 if !output.status.success() {
1293 return Ok(BranchType::Unknown);
1294 }
1295
1296 let branch_name = String::from_utf8_lossy(&output.stdout)
1297 .trim()
1298 .to_lowercase();
1299
1300 if branch_name == "main" || branch_name == "master" || branch_name == "develop" {
1301 Ok(BranchType::Main)
1302 } else if !branch_name.is_empty() {
1303 Ok(BranchType::Feature)
1304 } else {
1305 Ok(BranchType::Unknown)
1306 }
1307 }
1308
1309 pub fn validate_prerequisites(&self) -> Result<()> {
1311 Output::check_start("Checking prerequisites for Cascade hooks");
1312
1313 let repo_type = self.detect_repository_type()?;
1315 match repo_type {
1316 RepositoryType::Bitbucket => {
1317 Output::success("Bitbucket repository detected");
1318 Output::tip("Hooks will work great with 'ca submit' and 'ca autoland' for Bitbucket integration");
1319 }
1320 RepositoryType::GitHub => {
1321 Output::success("GitHub repository detected");
1322 Output::tip("Consider setting up GitHub Actions for CI/CD integration");
1323 }
1324 RepositoryType::GitLab => {
1325 Output::success("GitLab repository detected");
1326 Output::tip("GitLab CI integration works well with Cascade stacks");
1327 }
1328 RepositoryType::AzureDevOps => {
1329 Output::success("Azure DevOps repository detected");
1330 Output::tip("Azure Pipelines can be configured to work with Cascade workflows");
1331 }
1332 RepositoryType::Unknown => {
1333 Output::info(
1334 "Unknown repository type - hooks will still work for local Git operations",
1335 );
1336 }
1337 }
1338
1339 let config_dir = crate::config::get_repo_config_dir(&self.repo_path)?;
1341 let config_path = config_dir.join("config.json");
1342 if !config_path.exists() {
1343 return Err(CascadeError::config(
1344 "🚫 Cascade not initialized!\n\n\
1345 Please run 'ca init' or 'ca setup' first to configure Cascade CLI.\n\
1346 Hooks require proper Bitbucket Server configuration.\n\n\
1347 Use --force to install anyway (not recommended)."
1348 .to_string(),
1349 ));
1350 }
1351
1352 let config = Settings::load_from_file(&config_path)?;
1354
1355 if config.bitbucket.url == "https://bitbucket.example.com"
1356 || config.bitbucket.url.contains("example.com")
1357 {
1358 return Err(CascadeError::config(
1359 "🚫 Invalid Bitbucket configuration!\n\n\
1360 Your Bitbucket URL appears to be a placeholder.\n\
1361 Please run 'ca setup' to configure a real Bitbucket Server.\n\n\
1362 Use --force to install anyway (not recommended)."
1363 .to_string(),
1364 ));
1365 }
1366
1367 if config.bitbucket.project == "PROJECT" || config.bitbucket.repo == "repo" {
1368 return Err(CascadeError::config(
1369 "🚫 Incomplete Bitbucket configuration!\n\n\
1370 Your project/repository settings appear to be placeholders.\n\
1371 Please run 'ca setup' to complete configuration.\n\n\
1372 Use --force to install anyway (not recommended)."
1373 .to_string(),
1374 ));
1375 }
1376
1377 Output::success("Prerequisites validation passed");
1378 Ok(())
1379 }
1380
1381 pub fn validate_branch_suitability(&self) -> Result<()> {
1383 let branch_type = self.detect_branch_type()?;
1384
1385 match branch_type {
1386 BranchType::Main => {
1387 return Err(CascadeError::config(
1388 "🚫 Currently on main/master branch!\n\n\
1389 Cascade hooks are designed for feature branch development.\n\
1390 Working directly on main/master with stacked diffs can:\n\
1391 • Complicate the commit history\n\
1392 • Interfere with team collaboration\n\
1393 • Break CI/CD workflows\n\n\
1394 Recommended workflow:\n\
1395 1. Create a feature branch: git checkout -b feature/my-feature\n\
1396 2. Install hooks: ca hooks install\n\
1397 3. Develop with stacked commits (auto-added with hooks)\n\
1398 4. Push & submit: ca push && ca submit (all by default)\n\
1399 5. Auto-land when ready: ca autoland\n\n\
1400 Use --force to install anyway (not recommended)."
1401 .to_string(),
1402 ));
1403 }
1404 BranchType::Feature => {
1405 Output::success("Feature branch detected - suitable for stacked development");
1406 }
1407 BranchType::Unknown => {
1408 Output::warning("Unknown branch type - proceeding with caution");
1409 }
1410 }
1411
1412 Ok(())
1413 }
1414
1415 pub fn confirm_installation(&self) -> Result<()> {
1417 Output::section("Hook Installation Summary");
1418
1419 let hooks = vec![
1420 HookType::PostCommit,
1421 HookType::PrePush,
1422 HookType::CommitMsg,
1423 HookType::PrepareCommitMsg,
1424 ];
1425
1426 for hook in &hooks {
1427 Output::sub_item(format!("{}: {}", hook.filename(), hook.description()));
1428 }
1429
1430 println!();
1431 Output::section("These hooks will automatically");
1432 Output::bullet("Add commits to your active stack");
1433 Output::bullet("Validate commit messages");
1434 Output::bullet("Prevent force pushes that break stack integrity");
1435 Output::bullet("Add stack context to commit messages");
1436
1437 println!();
1438 Output::section("With hooks + new defaults, your workflow becomes");
1439 Output::sub_item("git commit → Auto-added to stack");
1440 Output::sub_item("ca push → Pushes all by default");
1441 Output::sub_item("ca submit → Submits all by default");
1442 Output::sub_item("ca autoland → Auto-merges when ready");
1443
1444 let should_install = Confirm::with_theme(&ColorfulTheme::default())
1446 .with_prompt("Install Cascade hooks?")
1447 .default(true)
1448 .interact()
1449 .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
1450
1451 if should_install {
1452 Output::success("Proceeding with installation");
1453 Ok(())
1454 } else {
1455 Err(CascadeError::config(
1456 "Installation cancelled by user".to_string(),
1457 ))
1458 }
1459 }
1460}
1461
1462pub async fn install() -> Result<()> {
1464 install_with_options(false, false, false, false).await
1465}
1466
1467pub async fn install_essential() -> Result<()> {
1468 let current_dir = env::current_dir()
1469 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1470
1471 let repo_root = find_repository_root(¤t_dir)
1472 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1473
1474 let hooks_manager = HooksManager::new(&repo_root)?;
1475 hooks_manager.install_essential()
1476}
1477
1478pub async fn install_with_options(
1479 skip_checks: bool,
1480 allow_main_branch: bool,
1481 yes: bool,
1482 force: bool,
1483) -> Result<()> {
1484 let current_dir = env::current_dir()
1485 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1486
1487 let repo_root = find_repository_root(¤t_dir)
1488 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1489
1490 let hooks_manager = HooksManager::new(&repo_root)?;
1491
1492 let options = InstallOptions {
1493 check_prerequisites: !skip_checks,
1494 feature_branches_only: !allow_main_branch,
1495 confirm: !yes,
1496 force,
1497 };
1498
1499 hooks_manager.install_with_options(&options)
1500}
1501
1502pub async fn uninstall() -> Result<()> {
1503 let current_dir = env::current_dir()
1504 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1505
1506 let repo_root = find_repository_root(¤t_dir)
1507 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1508
1509 let hooks_manager = HooksManager::new(&repo_root)?;
1510 hooks_manager.uninstall_all()
1511}
1512
1513pub async fn status() -> Result<()> {
1514 let current_dir = env::current_dir()
1515 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1516
1517 let repo_root = find_repository_root(¤t_dir)
1518 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1519
1520 let hooks_manager = HooksManager::new(&repo_root)?;
1521 hooks_manager.list_installed_hooks()
1522}
1523
1524pub async fn install_hook(hook_name: &str) -> Result<()> {
1525 install_hook_with_options(hook_name, false, false).await
1526}
1527
1528pub async fn install_hook_with_options(
1529 hook_name: &str,
1530 skip_checks: bool,
1531 force: bool,
1532) -> Result<()> {
1533 let current_dir = env::current_dir()
1534 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1535
1536 let repo_root = find_repository_root(¤t_dir)
1537 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1538
1539 let hooks_manager = HooksManager::new(&repo_root)?;
1540
1541 let hook_type = match hook_name {
1542 "post-commit" => HookType::PostCommit,
1543 "pre-push" => HookType::PrePush,
1544 "commit-msg" => HookType::CommitMsg,
1545 "pre-commit" => HookType::PreCommit,
1546 "prepare-commit-msg" => HookType::PrepareCommitMsg,
1547 _ => {
1548 return Err(CascadeError::config(format!(
1549 "Unknown hook type: {hook_name}"
1550 )))
1551 }
1552 };
1553
1554 if !skip_checks && !force {
1556 hooks_manager.validate_prerequisites()?;
1557 }
1558
1559 hooks_manager.install_hook(&hook_type)
1560}
1561
1562pub async fn uninstall_hook(hook_name: &str) -> Result<()> {
1563 let current_dir = env::current_dir()
1564 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1565
1566 let repo_root = find_repository_root(¤t_dir)
1567 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1568
1569 let hooks_manager = HooksManager::new(&repo_root)?;
1570
1571 let hook_type = match hook_name {
1572 "post-commit" => HookType::PostCommit,
1573 "pre-push" => HookType::PrePush,
1574 "commit-msg" => HookType::CommitMsg,
1575 "pre-commit" => HookType::PreCommit,
1576 "prepare-commit-msg" => HookType::PrepareCommitMsg,
1577 _ => {
1578 return Err(CascadeError::config(format!(
1579 "Unknown hook type: {hook_name}"
1580 )))
1581 }
1582 };
1583
1584 hooks_manager.uninstall_hook(&hook_type)
1585}
1586
1587#[cfg(test)]
1588mod tests {
1589 use super::*;
1590 use std::process::Command;
1591 use tempfile::TempDir;
1592
1593 fn create_test_repo() -> (TempDir, std::path::PathBuf) {
1594 let temp_dir = TempDir::new().unwrap();
1595 let repo_path = temp_dir.path().to_path_buf();
1596
1597 Command::new("git")
1599 .args(["init"])
1600 .current_dir(&repo_path)
1601 .output()
1602 .unwrap();
1603 Command::new("git")
1604 .args(["config", "user.name", "Test"])
1605 .current_dir(&repo_path)
1606 .output()
1607 .unwrap();
1608 Command::new("git")
1609 .args(["config", "user.email", "test@test.com"])
1610 .current_dir(&repo_path)
1611 .output()
1612 .unwrap();
1613
1614 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1616 Command::new("git")
1617 .args(["add", "."])
1618 .current_dir(&repo_path)
1619 .output()
1620 .unwrap();
1621 Command::new("git")
1622 .args(["commit", "-m", "Initial"])
1623 .current_dir(&repo_path)
1624 .output()
1625 .unwrap();
1626
1627 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
1629 .unwrap();
1630
1631 (temp_dir, repo_path)
1632 }
1633
1634 #[test]
1635 fn test_hooks_manager_creation() {
1636 let (_temp_dir, repo_path) = create_test_repo();
1637 let _manager = HooksManager::new(&repo_path).unwrap();
1638
1639 assert_eq!(_manager.repo_path, repo_path);
1640 assert!(!_manager.repo_id.is_empty());
1642 }
1643
1644 #[test]
1645 fn test_hooks_manager_custom_hooks_path() {
1646 let (_temp_dir, repo_path) = create_test_repo();
1647
1648 Command::new("git")
1650 .args(["config", "core.hooksPath", "custom-hooks"])
1651 .current_dir(&repo_path)
1652 .output()
1653 .unwrap();
1654
1655 let custom_hooks_dir = repo_path.join("custom-hooks");
1657 std::fs::create_dir_all(&custom_hooks_dir).unwrap();
1658
1659 let _manager = HooksManager::new(&repo_path).unwrap();
1660
1661 assert_eq!(_manager.repo_path, repo_path);
1662 assert!(!_manager.repo_id.is_empty());
1664 }
1665
1666 #[test]
1667 fn test_hook_chaining_with_existing_hooks() {
1668 let (_temp_dir, repo_path) = create_test_repo();
1669 let manager = HooksManager::new(&repo_path).unwrap();
1670
1671 let hook_type = HookType::PreCommit;
1672 let hook_path = repo_path.join(".git/hooks").join(hook_type.filename());
1673
1674 let existing_hook_content = "#!/bin/bash\n# Project pre-commit hook\n./scripts/lint.sh\n";
1676 std::fs::write(&hook_path, existing_hook_content).unwrap();
1677 crate::utils::platform::make_executable(&hook_path).unwrap();
1678
1679 let result = manager.install_hook(&hook_type);
1681 assert!(result.is_ok());
1682
1683 let original_content = std::fs::read_to_string(&hook_path).unwrap();
1685 assert!(original_content.contains("# Project pre-commit hook"));
1686 assert!(original_content.contains("./scripts/lint.sh"));
1687
1688 let cascade_hooks_dir = manager.get_cascade_hooks_dir().unwrap();
1690 let cascade_hook_path = cascade_hooks_dir.join(hook_type.filename());
1691 assert!(cascade_hook_path.exists());
1692
1693 let uninstall_result = manager.uninstall_hook(&hook_type);
1695 assert!(uninstall_result.is_ok());
1696
1697 let after_uninstall = std::fs::read_to_string(&hook_path).unwrap();
1699 assert!(after_uninstall.contains("# Project pre-commit hook"));
1700 assert!(after_uninstall.contains("./scripts/lint.sh"));
1701
1702 assert!(!cascade_hook_path.exists());
1704 }
1705
1706 #[test]
1707 fn test_hook_installation() {
1708 let (_temp_dir, repo_path) = create_test_repo();
1709 let manager = HooksManager::new(&repo_path).unwrap();
1710
1711 let hook_type = HookType::PostCommit;
1713 let result = manager.install_hook(&hook_type);
1714 assert!(result.is_ok());
1715
1716 let hook_filename = hook_type.filename();
1718 let cascade_hooks_dir = manager.get_cascade_hooks_dir().unwrap();
1719 let hook_path = cascade_hooks_dir.join(&hook_filename);
1720 assert!(hook_path.exists());
1721
1722 #[cfg(unix)]
1724 {
1725 use std::os::unix::fs::PermissionsExt;
1726 let metadata = std::fs::metadata(&hook_path).unwrap();
1727 let permissions = metadata.permissions();
1728 assert!(permissions.mode() & 0o111 != 0); }
1730
1731 #[cfg(windows)]
1732 {
1733 assert!(hook_filename.ends_with(".bat"));
1735 assert!(hook_path.exists());
1736 }
1737 }
1738
1739 #[test]
1740 fn test_hook_detection() {
1741 let (_temp_dir, repo_path) = create_test_repo();
1742 let _manager = HooksManager::new(&repo_path).unwrap();
1743
1744 let post_commit_path = repo_path
1746 .join(".git/hooks")
1747 .join(HookType::PostCommit.filename());
1748 let pre_push_path = repo_path
1749 .join(".git/hooks")
1750 .join(HookType::PrePush.filename());
1751 let commit_msg_path = repo_path
1752 .join(".git/hooks")
1753 .join(HookType::CommitMsg.filename());
1754
1755 assert!(!post_commit_path.exists());
1757 assert!(!pre_push_path.exists());
1758 assert!(!commit_msg_path.exists());
1759 }
1760
1761 #[test]
1762 fn test_hook_validation() {
1763 let (_temp_dir, repo_path) = create_test_repo();
1764 let manager = HooksManager::new(&repo_path).unwrap();
1765
1766 let validation = manager.validate_prerequisites();
1768 let _ = validation; let branch_validation = manager.validate_branch_suitability();
1774 let _ = branch_validation; }
1777
1778 #[test]
1779 fn test_hook_uninstallation() {
1780 let (_temp_dir, repo_path) = create_test_repo();
1781 let manager = HooksManager::new(&repo_path).unwrap();
1782
1783 let hook_type = HookType::PostCommit;
1785 manager.install_hook(&hook_type).unwrap();
1786
1787 let cascade_hooks_dir = manager.get_cascade_hooks_dir().unwrap();
1788 let hook_path = cascade_hooks_dir.join(hook_type.filename());
1789 assert!(hook_path.exists());
1790
1791 let result = manager.uninstall_hook(&hook_type);
1792 assert!(result.is_ok());
1793 assert!(!hook_path.exists());
1794 }
1795
1796 #[test]
1797 fn test_hook_content_generation() {
1798 let (_temp_dir, repo_path) = create_test_repo();
1799 let manager = HooksManager::new(&repo_path).unwrap();
1800
1801 let binary_name = "cascade-cli";
1803
1804 let post_commit_content = manager.generate_post_commit_hook(binary_name);
1806 #[cfg(windows)]
1807 {
1808 assert!(post_commit_content.contains("@echo off"));
1809 assert!(post_commit_content.contains("rem Cascade CLI Hook"));
1810 }
1811 #[cfg(not(windows))]
1812 {
1813 assert!(post_commit_content.contains("#!/bin/sh"));
1814 assert!(post_commit_content.contains("# Cascade CLI Hook"));
1815 }
1816 assert!(post_commit_content.contains(binary_name));
1817
1818 let pre_push_content = manager.generate_pre_push_hook(binary_name);
1820 #[cfg(windows)]
1821 {
1822 assert!(pre_push_content.contains("@echo off"));
1823 assert!(pre_push_content.contains("rem Cascade CLI Hook"));
1824 }
1825 #[cfg(not(windows))]
1826 {
1827 assert!(pre_push_content.contains("#!/bin/sh"));
1828 assert!(pre_push_content.contains("# Cascade CLI Hook"));
1829 }
1830 assert!(pre_push_content.contains(binary_name));
1831
1832 let commit_msg_content = manager.generate_commit_msg_hook(binary_name);
1834 #[cfg(windows)]
1835 {
1836 assert!(commit_msg_content.contains("@echo off"));
1837 assert!(commit_msg_content.contains("rem Cascade CLI Hook"));
1838 }
1839 #[cfg(not(windows))]
1840 {
1841 assert!(commit_msg_content.contains("#!/bin/sh"));
1842 assert!(commit_msg_content.contains("# Cascade CLI Hook"));
1843 }
1844
1845 let prepare_commit_content = manager.generate_prepare_commit_msg_hook(binary_name);
1847 #[cfg(windows)]
1848 {
1849 assert!(prepare_commit_content.contains("@echo off"));
1850 assert!(prepare_commit_content.contains("rem Cascade CLI Hook"));
1851 }
1852 #[cfg(not(windows))]
1853 {
1854 assert!(prepare_commit_content.contains("#!/bin/sh"));
1855 assert!(prepare_commit_content.contains("# Cascade CLI Hook"));
1856 }
1857 assert!(prepare_commit_content.contains(binary_name));
1858 }
1859
1860 #[test]
1861 fn test_hook_status_reporting() {
1862 let (_temp_dir, repo_path) = create_test_repo();
1863 let manager = HooksManager::new(&repo_path).unwrap();
1864
1865 let repo_type = manager.detect_repository_type().unwrap();
1867 assert!(matches!(
1869 repo_type,
1870 RepositoryType::Bitbucket | RepositoryType::Unknown
1871 ));
1872
1873 let branch_type = manager.detect_branch_type().unwrap();
1875 assert!(matches!(
1877 branch_type,
1878 BranchType::Main | BranchType::Unknown
1879 ));
1880 }
1881
1882 #[test]
1883 fn test_force_installation() {
1884 let (_temp_dir, repo_path) = create_test_repo();
1885 let manager = HooksManager::new(&repo_path).unwrap();
1886
1887 let hook_filename = HookType::PostCommit.filename();
1889 let hook_path = repo_path.join(".git/hooks").join(&hook_filename);
1890
1891 #[cfg(windows)]
1892 let existing_content = "@echo off\necho existing hook";
1893 #[cfg(not(windows))]
1894 let existing_content = "#!/bin/sh\necho 'existing hook'";
1895
1896 std::fs::write(&hook_path, existing_content).unwrap();
1897
1898 let hook_type = HookType::PostCommit;
1900 let result = manager.install_hook(&hook_type);
1901 assert!(result.is_ok());
1902
1903 let cascade_hooks_dir = manager.get_cascade_hooks_dir().unwrap();
1905 let cascade_hook_path = cascade_hooks_dir.join(&hook_filename);
1906 assert!(cascade_hook_path.exists());
1907
1908 let original_content = std::fs::read_to_string(&hook_path).unwrap();
1910 assert!(original_content.contains("existing hook"));
1911
1912 let cascade_content = std::fs::read_to_string(&cascade_hook_path).unwrap();
1914 assert!(cascade_content.contains("cascade-cli") || cascade_content.contains("ca"));
1915 }
1916}