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