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 Skip hook during cherry-pick/rebase/merge operations\n\
1033 if exist \\\"%REPO_ROOT%\\.git\\CHERRY_PICK_HEAD\\\" exit /b 0\n\
1034 if exist \\\"%REPO_ROOT%\\.git\\REBASE_HEAD\\\" exit /b 0\n\
1035 if exist \\\"%REPO_ROOT%\\.git\\MERGE_HEAD\\\" exit /b 0\n\n\
1036 rem Get edit status\n\
1037 for /f \\\"tokens=*\\\" %%i in ('\\\"{0}\\\" entry status --quiet 2^>nul') do set EDIT_STATUS=%%i\n\
1038 if \\\"%EDIT_STATUS%\\\"==\\\"\\\" set EDIT_STATUS=inactive\n\n\
1039 rem Check if edit status is active\n\
1040 echo %EDIT_STATUS% | findstr /b \\\"active:\\\" >nul\n\
1041 if %ERRORLEVEL% equ 0 (\n\
1042 echo You're in EDIT MODE for a stack entry\n\
1043 echo.\n\
1044 echo Choose your action:\n\
1045 echo [a] amend: Modify the current entry ^(default^)\n\
1046 echo [n] new: Create new entry on top\n\
1047 echo [c] cancel: Stop and think about it\n\
1048 echo.\n\
1049 set /p choice=\\\"Your choice (a/n/c): \\\" <CON\n\
1050 if \\\"%choice%\\\"==\\\"\\\" set choice=a\n\
1051 \n\
1052 if /i \\\"%choice%\\\"==\\\"A\\\" (\n\
1053 rem Use ca entry amend to update entry ^(ignore any -m flag^)\n\
1054 rem Changes are already staged by git commit; --restack updates dependents\n\
1055 \\\"{0}\\\" entry amend --restack\n\
1056 set amend_error=%ERRORLEVEL%\n\
1057 if %amend_error% EQU 0 (\n\
1058 echo Amend applied - skipping git commit to avoid duplicate entry.\n\
1059 echo Your commit was updated by Cascade; no further action needed.\n\
1060 exit /b 1\n\
1061 ) else (\n\
1062 exit /b %amend_error%\n\
1063 )\n\
1064 ) else if /i \\\"%choice%\\\"==\\\"N\\\" (\n\
1065 echo Creating new stack entry...\n\
1066 echo The commit will proceed and post-commit hook will add it to your stack\n\
1067 rem Let commit proceed ^(Git will use -m flag or open editor^)\n\
1068 exit /b 0\n\
1069 ) else if /i \\\"%choice%\\\"==\\\"C\\\" (\n\
1070 echo Commit cancelled\n\
1071 exit /b 1\n\
1072 ) else (\n\
1073 echo Invalid choice. Please choose A, n, or c\n\
1074 exit /b 1\n\
1075 )\n\
1076 )\n\n\
1077 rem Not in edit mode, proceed normally\n\
1078 exit /b 0\n",
1079 cascade_cli
1080 )
1081 }
1082
1083 #[cfg(not(windows))]
1084 {
1085 let status_check = format!(
1088 "EDIT_STATUS=$(\"{}\" entry status --quiet 2>/dev/null || echo \"inactive\")",
1089 cascade_cli
1090 );
1091 let amend_line = format!(" \"{}\" entry amend --restack", cascade_cli);
1094
1095 vec![
1096 "#!/bin/sh".to_string(),
1097 "# Cascade CLI Hook - Pre Commit".to_string(),
1098 "# Smart edit mode guidance for better UX".to_string(),
1099 "".to_string(),
1100 "set -e".to_string(),
1101 "".to_string(),
1102 "# Check if Cascade is initialized".to_string(),
1103 r#"REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")"#.to_string(),
1104 r#"if [ ! -d "$REPO_ROOT/.cascade" ]; then"#.to_string(),
1105 " exit 0".to_string(),
1106 "fi".to_string(),
1107 "".to_string(),
1108 "# Skip hook if called from ca entry amend (avoid infinite loop)".to_string(),
1109 r#"if [ "$CASCADE_SKIP_HOOKS" = "1" ]; then"#.to_string(),
1110 " exit 0".to_string(),
1111 "fi".to_string(),
1112 "".to_string(),
1113 "# Skip hook during cherry-pick/rebase/merge operations".to_string(),
1114 r#"if [ -f "$REPO_ROOT/.git/CHERRY_PICK_HEAD" ] || [ -f "$REPO_ROOT/.git/REBASE_HEAD" ] || [ -f "$REPO_ROOT/.git/MERGE_HEAD" ]; then"#.to_string(),
1115 " exit 0".to_string(),
1116 "fi".to_string(),
1117 "".to_string(),
1118 "# Check if we're in edit mode".to_string(),
1119 r#"CURRENT_BRANCH=$(git branch --show-current 2>/dev/null)"#.to_string(),
1120 status_check,
1121 "".to_string(),
1122 "# If in edit mode, check if we're on a stack entry branch".to_string(),
1123 r#"if echo "$EDIT_STATUS" | grep -q "^active:"; then"#.to_string(),
1124 " # Check if current branch is a stack entry branch".to_string(),
1125 format!(r#" if ! "{}" stacks list --format=json 2>/dev/null | grep -q "\"branch_name\": \"$CURRENT_BRANCH\""; then"#, cascade_cli),
1126 r#" # Not on a stack entry branch - edit mode is for a different branch"#.to_string(),
1127 r#" # Silently proceed with normal commit"#.to_string(),
1128 " exit 0".to_string(),
1129 " fi".to_string(),
1130 " ".to_string(),
1131 " # Proper edit mode - prompt user".to_string(),
1132 r#" echo "You're in EDIT MODE for a stack entry""#.to_string(),
1133 r#" echo """#.to_string(),
1134 r#" echo "Choose your action:""#.to_string(),
1135 r#" echo " [a] amend: Modify the current entry (default)""#.to_string(),
1136 r#" echo " [n] new: Create new entry on top""#.to_string(),
1137 r#" echo " [c] cancel: Stop and think about it""#.to_string(),
1138 r#" echo """#.to_string(),
1139 " ".to_string(),
1140 " # Read user choice with default to amend".to_string(),
1141 r#" read -p "Your choice (a/n/c): " choice < /dev/tty"#.to_string(),
1142 " choice=${choice:-a}".to_string(),
1143 " ".to_string(),
1144 " ".to_string(),
1145 r#" case "$choice" in"#.to_string(),
1146 " [Aa])".to_string(),
1147 " # Use ca entry amend to properly update entry + working branch (ignore any -m flag)"
1148 .to_string(),
1149 " # Changes are already staged by 'git commit', so no --all flag needed".to_string(),
1150 amend_line.replace(" ", " "),
1151 " amend_rc=$?".to_string(),
1152 r#" if [ $amend_rc -eq 0 ]; then"#.to_string(),
1153 r#" echo "Amend applied - skipping git commit to avoid duplicate entry.""#
1154 .to_string(),
1155 r#" echo "Your commit was updated by Cascade; no further action needed.""#
1156 .to_string(),
1157 " exit 1".to_string(),
1158 " else".to_string(),
1159 " exit $amend_rc".to_string(),
1160 " fi".to_string(),
1161 " ;;".to_string(),
1162 " [Nn])".to_string(),
1163 r#" echo "Creating new stack entry...""#.to_string(),
1164 r#" echo "The commit will proceed and post-commit hook will add it to your stack""#.to_string(),
1165 " # Let the commit proceed normally (Git will use -m flag or open editor)"
1166 .to_string(),
1167 " exit 0".to_string(),
1168 " ;;".to_string(),
1169 " [Cc])".to_string(),
1170 r#" echo "Commit cancelled""#.to_string(),
1171 " exit 1".to_string(),
1172 " ;;".to_string(),
1173 " *)".to_string(),
1174 r#" echo "Invalid choice. Please choose A, n, or c""#.to_string(),
1175 " exit 1".to_string(),
1176 " ;;".to_string(),
1177 " esac".to_string(),
1178 "fi".to_string(),
1179 "".to_string(),
1180 "# Not in edit mode, proceed normally".to_string(),
1181 "exit 0".to_string(),
1182 ]
1183 .join("\n")
1184 }
1185 }
1186
1187 fn generate_prepare_commit_msg_hook(&self, cascade_cli: &str) -> String {
1188 #[cfg(windows)]
1189 {
1190 format!(
1191 "@echo off\n\
1192 rem Cascade CLI Hook - Prepare Commit Message\n\
1193 rem Adds stack context to commit messages\n\n\
1194 set COMMIT_MSG_FILE=%1\n\
1195 set COMMIT_SOURCE=%2\n\
1196 set COMMIT_SHA=%3\n\n\
1197 rem Skip if user provided message via -m flag, merge commit, etc.\n\
1198 if not \"%COMMIT_SOURCE%\"==\"\" exit /b 0\n\n\
1199 rem Find repository root and check if Cascade is initialized\n\
1200 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
1201 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
1202 if not exist \"%REPO_ROOT%\\.cascade\" exit /b 0\n\n\
1203 rem Check for active stack\n\
1204 for /f \"tokens=*\" %%i in ('\"{cascade_cli}\" stack list --active --format=name 2^>nul') do set ACTIVE_STACK=%%i\n\n\
1205 if not \"%ACTIVE_STACK%\"==\"\" (\n\
1206 rem Get current commit message\n\
1207 set /p CURRENT_MSG=<%COMMIT_MSG_FILE%\n\n\
1208 rem Skip if message already has stack context\n\
1209 echo !CURRENT_MSG! | findstr \"[stack:\" >nul\n\
1210 if %ERRORLEVEL% equ 0 exit /b 0\n\n\
1211 rem Add stack context to commit message\n\
1212 echo.\n\
1213 echo # Stack: %ACTIVE_STACK%\n\
1214 echo # This commit will be added to the active stack automatically.\n\
1215 echo # Use 'ca stack status' to see the current stack state.\n\
1216 type \"%COMMIT_MSG_FILE%\"\n\
1217 ) > \"%COMMIT_MSG_FILE%.tmp\"\n\
1218 move \"%COMMIT_MSG_FILE%.tmp\" \"%COMMIT_MSG_FILE%\"\n"
1219 )
1220 }
1221
1222 #[cfg(not(windows))]
1223 {
1224 format!(
1225 "#!/bin/sh\n\
1226 # Cascade CLI Hook - Prepare Commit Message\n\
1227 # Adds stack context to commit messages\n\n\
1228 set -e\n\n\
1229 COMMIT_MSG_FILE=\"$1\"\n\
1230 COMMIT_SOURCE=\"$2\"\n\
1231 COMMIT_SHA=\"$3\"\n\n\
1232 # Skip if user provided message via -m flag, merge commit, etc.\n\
1233 if [ \"$COMMIT_SOURCE\" != \"\" ]; then\n\
1234 exit 0\n\
1235 fi\n\n\
1236 # Find repository root and check if Cascade is initialized\n\
1237 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
1238 if [ ! -d \"$REPO_ROOT/.cascade\" ]; then\n\
1239 exit 0\n\
1240 fi\n\n\
1241 # Check for active stack\n\
1242 ACTIVE_STACK=$(\"{cascade_cli}\" stack list --active --format=name 2>/dev/null || echo \"\")\n\
1243 \n\
1244 if [ -n \"$ACTIVE_STACK\" ]; then\n\
1245 # Get current commit message\n\
1246 CURRENT_MSG=$(cat \"$COMMIT_MSG_FILE\")\n\
1247 \n\
1248 # Skip if message already has stack context\n\
1249 if echo \"$CURRENT_MSG\" | grep -q \"\\[stack:\"; then\n\
1250 exit 0\n\
1251 fi\n\
1252 \n\
1253 # Add stack context to commit message\n\
1254 echo \"\n\
1255 # Stack: $ACTIVE_STACK\n\
1256 # This commit will be added to the active stack automatically.\n\
1257 # Use 'ca stack status' to see the current stack state.\n\
1258 $CURRENT_MSG\" > \"$COMMIT_MSG_FILE\"\n\
1259 fi\n"
1260 )
1261 }
1262 }
1263
1264 pub fn detect_repository_type(&self) -> Result<RepositoryType> {
1266 let output = Command::new("git")
1267 .args(["remote", "get-url", "origin"])
1268 .current_dir(&self.repo_path)
1269 .output()
1270 .map_err(|e| CascadeError::config(format!("Failed to get remote URL: {e}")))?;
1271
1272 if !output.status.success() {
1273 return Ok(RepositoryType::Unknown);
1274 }
1275
1276 let remote_url = String::from_utf8_lossy(&output.stdout)
1277 .trim()
1278 .to_lowercase();
1279
1280 if remote_url.contains("github.com") {
1281 Ok(RepositoryType::GitHub)
1282 } else if remote_url.contains("gitlab.com") || remote_url.contains("gitlab") {
1283 Ok(RepositoryType::GitLab)
1284 } else if remote_url.contains("dev.azure.com") || remote_url.contains("visualstudio.com") {
1285 Ok(RepositoryType::AzureDevOps)
1286 } else if remote_url.contains("bitbucket") {
1287 Ok(RepositoryType::Bitbucket)
1288 } else {
1289 Ok(RepositoryType::Unknown)
1290 }
1291 }
1292
1293 pub fn detect_branch_type(&self) -> Result<BranchType> {
1295 let output = Command::new("git")
1296 .args(["branch", "--show-current"])
1297 .current_dir(&self.repo_path)
1298 .output()
1299 .map_err(|e| CascadeError::config(format!("Failed to get current branch: {e}")))?;
1300
1301 if !output.status.success() {
1302 return Ok(BranchType::Unknown);
1303 }
1304
1305 let branch_name = String::from_utf8_lossy(&output.stdout)
1306 .trim()
1307 .to_lowercase();
1308
1309 if branch_name == "main" || branch_name == "master" || branch_name == "develop" {
1310 Ok(BranchType::Main)
1311 } else if !branch_name.is_empty() {
1312 Ok(BranchType::Feature)
1313 } else {
1314 Ok(BranchType::Unknown)
1315 }
1316 }
1317
1318 pub fn validate_prerequisites(&self) -> Result<()> {
1320 Output::check_start("Checking prerequisites for Cascade hooks");
1321
1322 let repo_type = self.detect_repository_type()?;
1324 match repo_type {
1325 RepositoryType::Bitbucket => {
1326 Output::success("Bitbucket repository detected");
1327 Output::tip("Hooks will work great with 'ca submit' and 'ca autoland' for Bitbucket integration");
1328 }
1329 RepositoryType::GitHub => {
1330 Output::success("GitHub repository detected");
1331 Output::tip("Consider setting up GitHub Actions for CI/CD integration");
1332 }
1333 RepositoryType::GitLab => {
1334 Output::success("GitLab repository detected");
1335 Output::tip("GitLab CI integration works well with Cascade stacks");
1336 }
1337 RepositoryType::AzureDevOps => {
1338 Output::success("Azure DevOps repository detected");
1339 Output::tip("Azure Pipelines can be configured to work with Cascade workflows");
1340 }
1341 RepositoryType::Unknown => {
1342 Output::info(
1343 "Unknown repository type - hooks will still work for local Git operations",
1344 );
1345 }
1346 }
1347
1348 let config_dir = crate::config::get_repo_config_dir(&self.repo_path)?;
1350 let config_path = config_dir.join("config.json");
1351 if !config_path.exists() {
1352 return Err(CascadeError::config(
1353 "🚫 Cascade not initialized!\n\n\
1354 Please run 'ca init' or 'ca setup' first to configure Cascade CLI.\n\
1355 Hooks require proper Bitbucket Server configuration.\n\n\
1356 Use --force to install anyway (not recommended)."
1357 .to_string(),
1358 ));
1359 }
1360
1361 let config = Settings::load_from_file(&config_path)?;
1363
1364 if config.bitbucket.url == "https://bitbucket.example.com"
1365 || config.bitbucket.url.contains("example.com")
1366 {
1367 return Err(CascadeError::config(
1368 "🚫 Invalid Bitbucket configuration!\n\n\
1369 Your Bitbucket URL appears to be a placeholder.\n\
1370 Please run 'ca setup' to configure a real Bitbucket Server.\n\n\
1371 Use --force to install anyway (not recommended)."
1372 .to_string(),
1373 ));
1374 }
1375
1376 if config.bitbucket.project == "PROJECT" || config.bitbucket.repo == "repo" {
1377 return Err(CascadeError::config(
1378 "🚫 Incomplete Bitbucket configuration!\n\n\
1379 Your project/repository settings appear to be placeholders.\n\
1380 Please run 'ca setup' to complete configuration.\n\n\
1381 Use --force to install anyway (not recommended)."
1382 .to_string(),
1383 ));
1384 }
1385
1386 Output::success("Prerequisites validation passed");
1387 Ok(())
1388 }
1389
1390 pub fn validate_branch_suitability(&self) -> Result<()> {
1392 let branch_type = self.detect_branch_type()?;
1393
1394 match branch_type {
1395 BranchType::Main => {
1396 return Err(CascadeError::config(
1397 "🚫 Currently on main/master branch!\n\n\
1398 Cascade hooks are designed for feature branch development.\n\
1399 Working directly on main/master with stacked diffs can:\n\
1400 • Complicate the commit history\n\
1401 • Interfere with team collaboration\n\
1402 • Break CI/CD workflows\n\n\
1403 Recommended workflow:\n\
1404 1. Create a feature branch: git checkout -b feature/my-feature\n\
1405 2. Install hooks: ca hooks install\n\
1406 3. Develop with stacked commits (auto-added with hooks)\n\
1407 4. Push & submit: ca push && ca submit (all by default)\n\
1408 5. Auto-land when ready: ca autoland\n\n\
1409 Use --force to install anyway (not recommended)."
1410 .to_string(),
1411 ));
1412 }
1413 BranchType::Feature => {
1414 Output::success("Feature branch detected - suitable for stacked development");
1415 }
1416 BranchType::Unknown => {
1417 Output::warning("Unknown branch type - proceeding with caution");
1418 }
1419 }
1420
1421 Ok(())
1422 }
1423
1424 pub fn confirm_installation(&self) -> Result<()> {
1426 Output::section("Hook Installation Summary");
1427
1428 let hooks = vec![
1429 HookType::PostCommit,
1430 HookType::PrePush,
1431 HookType::CommitMsg,
1432 HookType::PrepareCommitMsg,
1433 ];
1434
1435 for hook in &hooks {
1436 Output::sub_item(format!("{}: {}", hook.filename(), hook.description()));
1437 }
1438
1439 println!();
1440 Output::section("These hooks will automatically");
1441 Output::bullet("Add commits to your active stack");
1442 Output::bullet("Validate commit messages");
1443 Output::bullet("Prevent force pushes that break stack integrity");
1444 Output::bullet("Add stack context to commit messages");
1445
1446 println!();
1447 Output::section("With hooks + new defaults, your workflow becomes");
1448 Output::sub_item("git commit → Auto-added to stack");
1449 Output::sub_item("ca push → Pushes all by default");
1450 Output::sub_item("ca submit → Submits all by default");
1451 Output::sub_item("ca autoland → Auto-merges when ready");
1452
1453 let should_install = Confirm::with_theme(&ColorfulTheme::default())
1455 .with_prompt("Install Cascade hooks?")
1456 .default(true)
1457 .interact()
1458 .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
1459
1460 if should_install {
1461 Output::success("Proceeding with installation");
1462 Ok(())
1463 } else {
1464 Err(CascadeError::config(
1465 "Installation cancelled by user".to_string(),
1466 ))
1467 }
1468 }
1469}
1470
1471pub async fn install() -> Result<()> {
1473 install_with_options(false, false, false, false).await
1474}
1475
1476pub async fn install_essential() -> Result<()> {
1477 let current_dir = env::current_dir()
1478 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1479
1480 let repo_root = find_repository_root(¤t_dir)
1481 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1482
1483 let hooks_manager = HooksManager::new(&repo_root)?;
1484 hooks_manager.install_essential()
1485}
1486
1487pub async fn install_with_options(
1488 skip_checks: bool,
1489 allow_main_branch: bool,
1490 yes: bool,
1491 force: bool,
1492) -> Result<()> {
1493 let current_dir = env::current_dir()
1494 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1495
1496 let repo_root = find_repository_root(¤t_dir)
1497 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1498
1499 let hooks_manager = HooksManager::new(&repo_root)?;
1500
1501 let options = InstallOptions {
1502 check_prerequisites: !skip_checks,
1503 feature_branches_only: !allow_main_branch,
1504 confirm: !yes,
1505 force,
1506 };
1507
1508 hooks_manager.install_with_options(&options)
1509}
1510
1511pub async fn uninstall() -> Result<()> {
1512 let current_dir = env::current_dir()
1513 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1514
1515 let repo_root = find_repository_root(¤t_dir)
1516 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1517
1518 let hooks_manager = HooksManager::new(&repo_root)?;
1519 hooks_manager.uninstall_all()
1520}
1521
1522pub async fn status() -> Result<()> {
1523 let current_dir = env::current_dir()
1524 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1525
1526 let repo_root = find_repository_root(¤t_dir)
1527 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1528
1529 let hooks_manager = HooksManager::new(&repo_root)?;
1530 hooks_manager.list_installed_hooks()
1531}
1532
1533pub async fn install_hook(hook_name: &str) -> Result<()> {
1534 install_hook_with_options(hook_name, false, false).await
1535}
1536
1537pub async fn install_hook_with_options(
1538 hook_name: &str,
1539 skip_checks: bool,
1540 force: bool,
1541) -> Result<()> {
1542 let current_dir = env::current_dir()
1543 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1544
1545 let repo_root = find_repository_root(¤t_dir)
1546 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1547
1548 let hooks_manager = HooksManager::new(&repo_root)?;
1549
1550 let hook_type = match hook_name {
1551 "post-commit" => HookType::PostCommit,
1552 "pre-push" => HookType::PrePush,
1553 "commit-msg" => HookType::CommitMsg,
1554 "pre-commit" => HookType::PreCommit,
1555 "prepare-commit-msg" => HookType::PrepareCommitMsg,
1556 _ => {
1557 return Err(CascadeError::config(format!(
1558 "Unknown hook type: {hook_name}"
1559 )))
1560 }
1561 };
1562
1563 if !skip_checks && !force {
1565 hooks_manager.validate_prerequisites()?;
1566 }
1567
1568 hooks_manager.install_hook(&hook_type)
1569}
1570
1571pub async fn uninstall_hook(hook_name: &str) -> Result<()> {
1572 let current_dir = env::current_dir()
1573 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1574
1575 let repo_root = find_repository_root(¤t_dir)
1576 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1577
1578 let hooks_manager = HooksManager::new(&repo_root)?;
1579
1580 let hook_type = match hook_name {
1581 "post-commit" => HookType::PostCommit,
1582 "pre-push" => HookType::PrePush,
1583 "commit-msg" => HookType::CommitMsg,
1584 "pre-commit" => HookType::PreCommit,
1585 "prepare-commit-msg" => HookType::PrepareCommitMsg,
1586 _ => {
1587 return Err(CascadeError::config(format!(
1588 "Unknown hook type: {hook_name}"
1589 )))
1590 }
1591 };
1592
1593 hooks_manager.uninstall_hook(&hook_type)
1594}
1595
1596#[cfg(test)]
1597mod tests {
1598 use super::*;
1599 use std::process::Command;
1600 use tempfile::TempDir;
1601
1602 fn create_test_repo() -> (TempDir, std::path::PathBuf) {
1603 let temp_dir = TempDir::new().unwrap();
1604 let repo_path = temp_dir.path().to_path_buf();
1605
1606 Command::new("git")
1608 .args(["init"])
1609 .current_dir(&repo_path)
1610 .output()
1611 .unwrap();
1612 Command::new("git")
1613 .args(["config", "user.name", "Test"])
1614 .current_dir(&repo_path)
1615 .output()
1616 .unwrap();
1617 Command::new("git")
1618 .args(["config", "user.email", "test@test.com"])
1619 .current_dir(&repo_path)
1620 .output()
1621 .unwrap();
1622
1623 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1625 Command::new("git")
1626 .args(["add", "."])
1627 .current_dir(&repo_path)
1628 .output()
1629 .unwrap();
1630 Command::new("git")
1631 .args(["commit", "-m", "Initial"])
1632 .current_dir(&repo_path)
1633 .output()
1634 .unwrap();
1635
1636 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
1638 .unwrap();
1639
1640 (temp_dir, repo_path)
1641 }
1642
1643 #[test]
1644 fn test_hooks_manager_creation() {
1645 let (_temp_dir, repo_path) = create_test_repo();
1646 let _manager = HooksManager::new(&repo_path).unwrap();
1647
1648 assert_eq!(_manager.repo_path, repo_path);
1649 assert!(!_manager.repo_id.is_empty());
1651 }
1652
1653 #[test]
1654 fn test_hooks_manager_custom_hooks_path() {
1655 let (_temp_dir, repo_path) = create_test_repo();
1656
1657 Command::new("git")
1659 .args(["config", "core.hooksPath", "custom-hooks"])
1660 .current_dir(&repo_path)
1661 .output()
1662 .unwrap();
1663
1664 let custom_hooks_dir = repo_path.join("custom-hooks");
1666 std::fs::create_dir_all(&custom_hooks_dir).unwrap();
1667
1668 let _manager = HooksManager::new(&repo_path).unwrap();
1669
1670 assert_eq!(_manager.repo_path, repo_path);
1671 assert!(!_manager.repo_id.is_empty());
1673 }
1674
1675 #[test]
1676 fn test_hook_chaining_with_existing_hooks() {
1677 let (_temp_dir, repo_path) = create_test_repo();
1678 let manager = HooksManager::new(&repo_path).unwrap();
1679
1680 let hook_type = HookType::PreCommit;
1681 let hook_path = repo_path.join(".git/hooks").join(hook_type.filename());
1682
1683 let existing_hook_content = "#!/bin/bash\n# Project pre-commit hook\n./scripts/lint.sh\n";
1685 std::fs::write(&hook_path, existing_hook_content).unwrap();
1686 crate::utils::platform::make_executable(&hook_path).unwrap();
1687
1688 let result = manager.install_hook(&hook_type);
1690 assert!(result.is_ok());
1691
1692 let original_content = std::fs::read_to_string(&hook_path).unwrap();
1694 assert!(original_content.contains("# Project pre-commit hook"));
1695 assert!(original_content.contains("./scripts/lint.sh"));
1696
1697 let cascade_hooks_dir = manager.get_cascade_hooks_dir().unwrap();
1699 let cascade_hook_path = cascade_hooks_dir.join(hook_type.filename());
1700 assert!(cascade_hook_path.exists());
1701
1702 let uninstall_result = manager.uninstall_hook(&hook_type);
1704 assert!(uninstall_result.is_ok());
1705
1706 let after_uninstall = std::fs::read_to_string(&hook_path).unwrap();
1708 assert!(after_uninstall.contains("# Project pre-commit hook"));
1709 assert!(after_uninstall.contains("./scripts/lint.sh"));
1710
1711 assert!(!cascade_hook_path.exists());
1713 }
1714
1715 #[test]
1716 fn test_hook_installation() {
1717 let (_temp_dir, repo_path) = create_test_repo();
1718 let manager = HooksManager::new(&repo_path).unwrap();
1719
1720 let hook_type = HookType::PostCommit;
1722 let result = manager.install_hook(&hook_type);
1723 assert!(result.is_ok());
1724
1725 let hook_filename = hook_type.filename();
1727 let cascade_hooks_dir = manager.get_cascade_hooks_dir().unwrap();
1728 let hook_path = cascade_hooks_dir.join(&hook_filename);
1729 assert!(hook_path.exists());
1730
1731 #[cfg(unix)]
1733 {
1734 use std::os::unix::fs::PermissionsExt;
1735 let metadata = std::fs::metadata(&hook_path).unwrap();
1736 let permissions = metadata.permissions();
1737 assert!(permissions.mode() & 0o111 != 0); }
1739
1740 #[cfg(windows)]
1741 {
1742 assert!(hook_filename.ends_with(".bat"));
1744 assert!(hook_path.exists());
1745 }
1746 }
1747
1748 #[test]
1749 fn test_hook_detection() {
1750 let (_temp_dir, repo_path) = create_test_repo();
1751 let _manager = HooksManager::new(&repo_path).unwrap();
1752
1753 let post_commit_path = repo_path
1755 .join(".git/hooks")
1756 .join(HookType::PostCommit.filename());
1757 let pre_push_path = repo_path
1758 .join(".git/hooks")
1759 .join(HookType::PrePush.filename());
1760 let commit_msg_path = repo_path
1761 .join(".git/hooks")
1762 .join(HookType::CommitMsg.filename());
1763
1764 assert!(!post_commit_path.exists());
1766 assert!(!pre_push_path.exists());
1767 assert!(!commit_msg_path.exists());
1768 }
1769
1770 #[test]
1771 fn test_hook_validation() {
1772 let (_temp_dir, repo_path) = create_test_repo();
1773 let manager = HooksManager::new(&repo_path).unwrap();
1774
1775 let validation = manager.validate_prerequisites();
1777 let _ = validation; let branch_validation = manager.validate_branch_suitability();
1783 let _ = branch_validation; }
1786
1787 #[test]
1788 fn test_hook_uninstallation() {
1789 let (_temp_dir, repo_path) = create_test_repo();
1790 let manager = HooksManager::new(&repo_path).unwrap();
1791
1792 let hook_type = HookType::PostCommit;
1794 manager.install_hook(&hook_type).unwrap();
1795
1796 let cascade_hooks_dir = manager.get_cascade_hooks_dir().unwrap();
1797 let hook_path = cascade_hooks_dir.join(hook_type.filename());
1798 assert!(hook_path.exists());
1799
1800 let result = manager.uninstall_hook(&hook_type);
1801 assert!(result.is_ok());
1802 assert!(!hook_path.exists());
1803 }
1804
1805 #[test]
1806 fn test_hook_content_generation() {
1807 let (_temp_dir, repo_path) = create_test_repo();
1808 let manager = HooksManager::new(&repo_path).unwrap();
1809
1810 let binary_name = "cascade-cli";
1812
1813 let post_commit_content = manager.generate_post_commit_hook(binary_name);
1815 #[cfg(windows)]
1816 {
1817 assert!(post_commit_content.contains("@echo off"));
1818 assert!(post_commit_content.contains("rem Cascade CLI Hook"));
1819 }
1820 #[cfg(not(windows))]
1821 {
1822 assert!(post_commit_content.contains("#!/bin/sh"));
1823 assert!(post_commit_content.contains("# Cascade CLI Hook"));
1824 }
1825 assert!(post_commit_content.contains(binary_name));
1826
1827 let pre_push_content = manager.generate_pre_push_hook(binary_name);
1829 #[cfg(windows)]
1830 {
1831 assert!(pre_push_content.contains("@echo off"));
1832 assert!(pre_push_content.contains("rem Cascade CLI Hook"));
1833 }
1834 #[cfg(not(windows))]
1835 {
1836 assert!(pre_push_content.contains("#!/bin/sh"));
1837 assert!(pre_push_content.contains("# Cascade CLI Hook"));
1838 }
1839 assert!(pre_push_content.contains(binary_name));
1840
1841 let commit_msg_content = manager.generate_commit_msg_hook(binary_name);
1843 #[cfg(windows)]
1844 {
1845 assert!(commit_msg_content.contains("@echo off"));
1846 assert!(commit_msg_content.contains("rem Cascade CLI Hook"));
1847 }
1848 #[cfg(not(windows))]
1849 {
1850 assert!(commit_msg_content.contains("#!/bin/sh"));
1851 assert!(commit_msg_content.contains("# Cascade CLI Hook"));
1852 }
1853
1854 let prepare_commit_content = manager.generate_prepare_commit_msg_hook(binary_name);
1856 #[cfg(windows)]
1857 {
1858 assert!(prepare_commit_content.contains("@echo off"));
1859 assert!(prepare_commit_content.contains("rem Cascade CLI Hook"));
1860 }
1861 #[cfg(not(windows))]
1862 {
1863 assert!(prepare_commit_content.contains("#!/bin/sh"));
1864 assert!(prepare_commit_content.contains("# Cascade CLI Hook"));
1865 }
1866 assert!(prepare_commit_content.contains(binary_name));
1867 }
1868
1869 #[test]
1870 fn test_hook_status_reporting() {
1871 let (_temp_dir, repo_path) = create_test_repo();
1872 let manager = HooksManager::new(&repo_path).unwrap();
1873
1874 let repo_type = manager.detect_repository_type().unwrap();
1876 assert!(matches!(
1878 repo_type,
1879 RepositoryType::Bitbucket | RepositoryType::Unknown
1880 ));
1881
1882 let branch_type = manager.detect_branch_type().unwrap();
1884 assert!(matches!(
1886 branch_type,
1887 BranchType::Main | BranchType::Unknown
1888 ));
1889 }
1890
1891 #[test]
1892 fn test_force_installation() {
1893 let (_temp_dir, repo_path) = create_test_repo();
1894 let manager = HooksManager::new(&repo_path).unwrap();
1895
1896 let hook_filename = HookType::PostCommit.filename();
1898 let hook_path = repo_path.join(".git/hooks").join(&hook_filename);
1899
1900 #[cfg(windows)]
1901 let existing_content = "@echo off\necho existing hook";
1902 #[cfg(not(windows))]
1903 let existing_content = "#!/bin/sh\necho 'existing hook'";
1904
1905 std::fs::write(&hook_path, existing_content).unwrap();
1906
1907 let hook_type = HookType::PostCommit;
1909 let result = manager.install_hook(&hook_type);
1910 assert!(result.is_ok());
1911
1912 let cascade_hooks_dir = manager.get_cascade_hooks_dir().unwrap();
1914 let cascade_hook_path = cascade_hooks_dir.join(&hook_filename);
1915 assert!(cascade_hook_path.exists());
1916
1917 let original_content = std::fs::read_to_string(&hook_path).unwrap();
1919 assert!(original_content.contains("existing hook"));
1920
1921 let cascade_content = std::fs::read_to_string(&cascade_hook_path).unwrap();
1923 assert!(cascade_content.contains("cascade-cli") || cascade_content.contains("ca"));
1924 }
1925}