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 💡 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 💡 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 \"💡 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 \"💡 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 Check for force push\n\
862 echo %* | findstr /C:\"--force\" /C:\"--force-with-lease\" /C:\"-f\" >nul\n\
863 if %ERRORLEVEL% equ 0 (\n\
864 echo ERROR: Force push detected\n\
865 echo Cascade CLI uses stacked diffs - force pushes can break stack integrity\n\
866 echo.\n\
867 echo Instead, try these commands:\n\
868 echo ca sync - Sync with remote changes ^(handles rebasing^)\n\
869 echo ca push - Push all unpushed commits\n\
870 echo ca submit - Submit all entries for review\n\
871 echo ca autoland - Auto-merge when approved + builds pass\n\
872 echo.\n\
873 echo If you really need to force push:\n\
874 echo git push --force-with-lease [remote] [branch]\n\
875 echo ^(But consider if this will affect other stack entries^)\n\
876 exit /b 1\n\
877 )\n\n\
878 rem Find repository root and check if Cascade is initialized\n\
879 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
880 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
881 if not exist \"%REPO_ROOT%\\.cascade\" (\n\
882 exit /b 0\n\
883 )\n\n\
884 rem Validate stack state (silent unless error)\n\
885 \"{cascade_cli}\" validate >nul 2>&1\n\
886 if %ERRORLEVEL% neq 0 (\n\
887 echo Stack validation failed - run 'ca validate' for details\n\
888 exit /b 1\n\
889 )\n"
890 )
891 }
892
893 #[cfg(not(windows))]
894 {
895 format!(
896 "#!/bin/sh\n\
897 # Cascade CLI Hook - Pre Push\n\
898 # Prevents force pushes and validates stack state\n\n\
899 set -e\n\n\
900 # Check for force push\n\
901 if echo \"$*\" | grep -q -- \"--force\\|--force-with-lease\\|-f\"; then\n\
902 echo \"ERROR: Force push detected\"\n\
903 echo \"Cascade CLI uses stacked diffs - force pushes can break stack integrity\"\n\
904 echo \"\"\n\
905 echo \"Instead, try these commands:\"\n\
906 echo \" ca sync - Sync with remote changes (handles rebasing)\"\n\
907 echo \" ca push - Push all unpushed commits\"\n\
908 echo \" ca submit - Submit all entries for review\"\n\
909 echo \" ca autoland - Auto-merge when approved + builds pass\"\n\
910 echo \"\"\n\
911 echo \"If you really need to force push:\"\n\
912 echo \" git push --force-with-lease [remote] [branch]\"\n\
913 echo \" (But consider if this will affect other stack entries)\"\n\
914 exit 1\n\
915 fi\n\n\
916 # Find repository root and check if Cascade is initialized\n\
917 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
918 if [ ! -d \"$REPO_ROOT/.cascade\" ]; then\n\
919 exit 0\n\
920 fi\n\n\
921 # Validate stack state (silent unless error)\n\
922 if ! \"{cascade_cli}\" validate > /dev/null 2>&1; then\n\
923 echo \"Stack validation failed - run 'ca validate' for details\"\n\
924 exit 1\n\
925 fi\n"
926 )
927 }
928 }
929
930 fn generate_commit_msg_hook(&self, _cascade_cli: &str) -> String {
931 #[cfg(windows)]
932 {
933 r#"@echo off
934rem Cascade CLI Hook - Commit Message
935rem Validates commit message format
936
937set COMMIT_MSG_FILE=%1
938if "%COMMIT_MSG_FILE%"=="" (
939 echo ❌ No commit message file provided
940 exit /b 1
941)
942
943rem Read commit message (Windows batch is limited, but this covers basic cases)
944for /f "delims=" %%i in ('type "%COMMIT_MSG_FILE%"') do set COMMIT_MSG=%%i
945
946rem Skip validation for merge commits, fixup commits, etc.
947echo %COMMIT_MSG% | findstr /B /C:"Merge" /C:"Revert" /C:"fixup!" /C:"squash!" >nul
948if %ERRORLEVEL% equ 0 exit /b 0
949
950rem Find repository root and check if Cascade is initialized
951for /f "tokens=*" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i
952if "%REPO_ROOT%"=="" set REPO_ROOT=.
953if not exist "%REPO_ROOT%\.cascade" exit /b 0
954
955rem Basic commit message validation
956echo %COMMIT_MSG% | findstr /R "^..........*" >nul
957if %ERRORLEVEL% neq 0 (
958 echo ❌ Commit message too short (minimum 10 characters)
959 echo 💡 Write a descriptive commit message for better stack management
960 exit /b 1
961)
962
963rem Validation passed (silent success)
964exit /b 0
965"#
966 .to_string()
967 }
968
969 #[cfg(not(windows))]
970 {
971 r#"#!/bin/sh
972# Cascade CLI Hook - Commit Message
973# Validates commit message format
974
975set -e
976
977COMMIT_MSG_FILE="$1"
978COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
979
980# Skip validation for merge commits, fixup commits, etc.
981if echo "$COMMIT_MSG" | grep -E "^(Merge|Revert|fixup!|squash!)" > /dev/null; then
982 exit 0
983fi
984
985# Find repository root and check if Cascade is initialized
986REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")
987if [ ! -d "$REPO_ROOT/.cascade" ]; then
988 exit 0
989fi
990
991# Basic commit message validation
992if [ ${#COMMIT_MSG} -lt 10 ]; then
993 echo "❌ Commit message too short (minimum 10 characters)"
994 echo "💡 Write a descriptive commit message for better stack management"
995 exit 1
996fi
997
998# Validation passed (silent success)
999exit 0
1000"#
1001 .to_string()
1002 }
1003 }
1004
1005 #[allow(clippy::uninlined_format_args)]
1006 fn generate_pre_commit_hook(&self, cascade_cli: &str) -> String {
1007 #[cfg(windows)]
1008 {
1009 format!(
1010 "@echo off\n\
1011 rem Cascade CLI Hook - Pre Commit\n\
1012 rem Smart edit mode guidance for better UX\n\n\
1013 rem Check if Cascade is initialized\n\
1014 for /f \\\"tokens=*\\\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
1015 if \\\"%REPO_ROOT%\\\"==\\\"\\\" set REPO_ROOT=.\n\
1016 if not exist \\\"%REPO_ROOT%\\.cascade\\\" exit /b 0\n\n\
1017 rem Check if we're on an entry branch\n\
1018 for /f \\\"tokens=*\\\" %%i in ('git branch --show-current 2^>nul') do set CURRENT_BRANCH=%%i\n\
1019 echo %CURRENT_BRANCH% | findstr /r \\\".*-entry-[0-9][0-9]*$\\\" >nul\n\
1020 if %ERRORLEVEL% neq 0 exit /b 0\n\n\
1021 rem Get edit status\n\
1022 for /f \\\"tokens=*\\\" %%i in ('\\\"{0}\\\" entry status --quiet 2^>nul') do set EDIT_STATUS=%%i\n\
1023 if \\\"%EDIT_STATUS%\\\"==\\\"\\\" set EDIT_STATUS=inactive\n\n\
1024 rem Check if edit status is active\n\
1025 echo %EDIT_STATUS% | findstr /b \\\"active:\\\" >nul\n\
1026 if %ERRORLEVEL% equ 0 (\n\
1027 echo WARNING: You're in EDIT MODE for a stack entry\n\
1028 echo.\n\
1029 echo Choose your action:\n\
1030 echo [A] Amend: Modify the current entry ^(default^)\n\
1031 echo [N] New: Create new entry on top\n\
1032 echo [C] Cancel: Stop and think about it\n\
1033 echo.\n\
1034 set /p choice=\\\"Your choice (A/n/c): \\\"\n\
1035 if \\\"%choice%\\\"==\\\"\\\" set choice=A\n\
1036 \n\
1037 if /i \\\"%choice%\\\"==\\\"A\\\" (\n\
1038 echo Amending current entry...\n\
1039 git add -A\n\
1040 \\\"{0}\\\" entry amend --all\n\
1041 exit /b %ERRORLEVEL%\n\
1042 ) else if /i \\\"%choice%\\\"==\\\"N\\\" (\n\
1043 echo Creating new stack entry...\n\
1044 exit /b 0\n\
1045 ) else if /i \\\"%choice%\\\"==\\\"C\\\" (\n\
1046 echo Commit cancelled\n\
1047 exit /b 1\n\
1048 ) else (\n\
1049 echo Invalid choice. Please choose A, n, or c\n\
1050 exit /b 1\n\
1051 )\n\
1052 ) else (\n\
1053 rem On entry branch but NOT in edit mode\n\
1054 echo.\n\
1055 echo WARNING: You're on a stack entry branch\n\
1056 echo.\n\
1057 echo Current branch: %CURRENT_BRANCH%\n\
1058 echo.\n\
1059 echo ERROR: Cannot commit directly to entry branches\n\
1060 echo.\n\
1061 echo Did you mean to:\n\
1062 echo - {0} entry checkout ^<N^> ^(enter proper edit mode^)\n\
1063 echo - git checkout ^<working-branch^> ^(switch to working branch^)\n\
1064 echo - {0} stack list ^(see your stacks^)\n\
1065 echo.\n\
1066 exit /b 1\n\
1067 )\n\n\
1068 rem Not on entry branch, proceed normally\n\
1069 exit /b 0\n",
1070 cascade_cli
1071 )
1072 }
1073
1074 #[cfg(not(windows))]
1075 {
1076 let status_check = format!(
1079 "EDIT_STATUS=$(\"{}\" entry status --quiet 2>/dev/null || echo \"inactive\")",
1080 cascade_cli
1081 );
1082 let amend_line = format!(" \"{}\" entry amend --all", cascade_cli);
1083
1084 vec![
1085 "#!/bin/sh".to_string(),
1086 "# Cascade CLI Hook - Pre Commit".to_string(),
1087 "# Smart edit mode guidance for better UX".to_string(),
1088 "".to_string(),
1089 "set -e".to_string(),
1090 "".to_string(),
1091 "# Check if Cascade is initialized".to_string(),
1092 r#"REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")"#.to_string(),
1093 r#"if [ ! -d "$REPO_ROOT/.cascade" ]; then"#.to_string(),
1094 " exit 0".to_string(),
1095 "fi".to_string(),
1096 "".to_string(),
1097 "# Check if we're on an entry branch (even without edit mode set)".to_string(),
1098 r#"CURRENT_BRANCH=$(git branch --show-current 2>/dev/null)"#.to_string(),
1099 r#"IS_ENTRY_BRANCH=$(echo "$CURRENT_BRANCH" | grep -qE '\-entry\-[0-9]+$' && echo "yes" || echo "no")"#.to_string(),
1100 "".to_string(),
1101 "# If on entry branch, check if edit mode is properly set".to_string(),
1102 r#"if [ "$IS_ENTRY_BRANCH" = "yes" ]; then"#.to_string(),
1103 " ".to_string(),
1104 status_check,
1105 " ".to_string(),
1106 " # Check if edit mode is active".to_string(),
1107 r#" if echo "$EDIT_STATUS" | grep -q "^active:"; then"#.to_string(),
1108 " # Proper edit mode - show options".to_string(),
1109 r#" echo "WARNING: You're in EDIT MODE for a stack entry""#.to_string(),
1110 r#" echo """#.to_string(),
1111 " ".to_string(),
1112 " # Check if running interactively (stdin is a terminal)".to_string(),
1113 " if [ -t 0 ]; then".to_string(),
1114 " # Interactive mode - prompt user".to_string(),
1115 r#" echo "Choose your action:""#.to_string(),
1116 r#" echo " [A] Amend: Modify the current entry (default)""#.to_string(),
1117 r#" echo " [N] New: Create new entry on top""#.to_string(),
1118 r#" echo " [C] Cancel: Stop and think about it""#.to_string(),
1119 r#" echo """#.to_string(),
1120 " ".to_string(),
1121 " # Read user choice with default to amend".to_string(),
1122 r#" read -p "Your choice (A/n/c): " choice"#.to_string(),
1123 " choice=${choice:-A}".to_string(),
1124 " else".to_string(),
1125 " # Non-interactive (e.g., git commit -m) - block and provide guidance".to_string(),
1126 r#" echo """#.to_string(),
1127 r#" echo "ERROR: Cannot use 'git commit -m' while in edit mode""#.to_string(),
1128 r#" echo """#.to_string(),
1129 r#" echo "Choose one of these instead:""#.to_string(),
1130 r#" echo " - git commit (interactive: choose Amend/New)""#.to_string(),
1131 format!(" echo \" - {} entry amend -m 'msg' (amend with message)\"", cascade_cli),
1132 format!(" echo \" - {} entry amend (amend, edit message in editor)\"", cascade_cli),
1133 r#" echo " - git checkout <branch> (exit edit mode first)""#.to_string(),
1134 r#" echo """#.to_string(),
1135 " exit 1".to_string(),
1136 " fi".to_string(),
1137 " ".to_string(),
1138 " ".to_string(),
1139 r#" case "$choice" in"#.to_string(),
1140 " [Aa])".to_string(),
1141 r#" echo "Amending current entry...""#.to_string(),
1142 " # Stage all changes first (like git commit -a)".to_string(),
1143 " git add -A".to_string(),
1144 " # Use ca entry amend to properly update entry + working branch"
1145 .to_string(),
1146 amend_line.replace(" ", " "),
1147 " exit $?".to_string(),
1148 " ;;".to_string(),
1149 " [Nn])".to_string(),
1150 r#" echo "Creating new stack entry...""#.to_string(),
1151 " # Let the commit proceed normally (will create new commit)".to_string(),
1152 " exit 0".to_string(),
1153 " ;;".to_string(),
1154 " [Cc])".to_string(),
1155 r#" echo "Commit cancelled""#.to_string(),
1156 " exit 1".to_string(),
1157 " ;;".to_string(),
1158 " *)".to_string(),
1159 r#" echo "Invalid choice. Please choose A, n, or c""#.to_string(),
1160 " exit 1".to_string(),
1161 " ;;".to_string(),
1162 " esac".to_string(),
1163 " else".to_string(),
1164 " # On entry branch but NOT in edit mode - someone bypassed ca entry checkout!".to_string(),
1165 r#" echo """#.to_string(),
1166 r#" echo "WARNING: You're on a stack entry branch""#.to_string(),
1167 r#" echo """#.to_string(),
1168 r#" echo "Current branch: $CURRENT_BRANCH""#.to_string(),
1169 r#" echo """#.to_string(),
1170 r#" echo "ERROR: Cannot commit directly to entry branches""#.to_string(),
1171 r#" echo """#.to_string(),
1172 r#" echo "Did you mean to:""#.to_string(),
1173 format!(" echo \" - {} entry checkout <N> (enter proper edit mode)\"", cascade_cli),
1174 r#" echo " - git checkout <working-branch> (switch to working branch)""#.to_string(),
1175 format!(" echo \" - {} stack list (see your stacks)\"", cascade_cli),
1176 r#" echo """#.to_string(),
1177 " exit 1".to_string(),
1178 " fi".to_string(),
1179 "fi".to_string(),
1180 "".to_string(),
1181 "# Not on entry branch, proceed normally".to_string(),
1182 "exit 0".to_string(),
1183 ]
1184 .join("\n")
1185 }
1186 }
1187
1188 fn generate_prepare_commit_msg_hook(&self, cascade_cli: &str) -> String {
1189 #[cfg(windows)]
1190 {
1191 format!(
1192 "@echo off\n\
1193 rem Cascade CLI Hook - Prepare Commit Message\n\
1194 rem Adds stack context to commit messages\n\n\
1195 set COMMIT_MSG_FILE=%1\n\
1196 set COMMIT_SOURCE=%2\n\
1197 set COMMIT_SHA=%3\n\n\
1198 rem Only modify message if it's a regular commit (not merge, template, etc.)\n\
1199 if not \"%COMMIT_SOURCE%\"==\"\" if not \"%COMMIT_SOURCE%\"==\"message\" exit /b 0\n\n\
1200 rem Find repository root and check if Cascade is initialized\n\
1201 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
1202 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
1203 if not exist \"%REPO_ROOT%\\.cascade\" exit /b 0\n\n\
1204 rem Check if in edit mode first\n\
1205 for /f \"tokens=*\" %%i in ('\"{cascade_cli}\" entry status --quiet 2^>nul') do set EDIT_STATUS=%%i\n\
1206 if \"%EDIT_STATUS%\"==\"\" set EDIT_STATUS=inactive\n\n\
1207 if not \"%EDIT_STATUS%\"==\"inactive\" (\n\
1208 rem In edit mode - provide smart guidance\n\
1209 set /p CURRENT_MSG=<%COMMIT_MSG_FILE%\n\n\
1210 rem Skip if message already has edit guidance\n\
1211 echo !CURRENT_MSG! | findstr \"[EDIT MODE]\" >nul\n\
1212 if %ERRORLEVEL% equ 0 exit /b 0\n\n\
1213 rem Add edit mode guidance to commit message\n\
1214 echo.\n\
1215 echo # [EDIT MODE] You're editing a stack entry\n\
1216 echo #\n\
1217 echo # Choose your action:\n\
1218 echo # 🔄 AMEND: To modify the current entry, use:\n\
1219 echo # git commit --amend\n\
1220 echo #\n\
1221 echo # ➕ NEW: To create a new entry on top, use:\n\
1222 echo # git commit ^(this command^)\n\
1223 echo #\n\
1224 echo # 💡 After committing, run 'ca sync' to update PRs\n\
1225 echo.\n\
1226 type \"%COMMIT_MSG_FILE%\"\n\
1227 ) > \"%COMMIT_MSG_FILE%.tmp\" && (\n\
1228 move \"%COMMIT_MSG_FILE%.tmp\" \"%COMMIT_MSG_FILE%\"\n\
1229 ) else (\n\
1230 rem Regular stack mode - check for active stack\n\
1231 for /f \"tokens=*\" %%i in ('\"{cascade_cli}\" stack list --active --format=name 2^>nul') do set ACTIVE_STACK=%%i\n\n\
1232 if not \"%ACTIVE_STACK%\"==\"\" (\n\
1233 rem Get current commit message\n\
1234 set /p CURRENT_MSG=<%COMMIT_MSG_FILE%\n\n\
1235 rem Skip if message already has stack context\n\
1236 echo !CURRENT_MSG! | findstr \"[stack:\" >nul\n\
1237 if %ERRORLEVEL% equ 0 exit /b 0\n\n\
1238 rem Add stack context to commit message\n\
1239 echo.\n\
1240 echo # Stack: %ACTIVE_STACK%\n\
1241 echo # This commit will be added to the active stack automatically.\n\
1242 echo # Use 'ca stack status' to see the current stack state.\n\
1243 type \"%COMMIT_MSG_FILE%\"\n\
1244 ) > \"%COMMIT_MSG_FILE%.tmp\"\n\
1245 move \"%COMMIT_MSG_FILE%.tmp\" \"%COMMIT_MSG_FILE%\"\n\
1246 )\n"
1247 )
1248 }
1249
1250 #[cfg(not(windows))]
1251 {
1252 format!(
1253 "#!/bin/sh\n\
1254 # Cascade CLI Hook - Prepare Commit Message\n\
1255 # Adds stack context to commit messages\n\n\
1256 set -e\n\n\
1257 COMMIT_MSG_FILE=\"$1\"\n\
1258 COMMIT_SOURCE=\"$2\"\n\
1259 COMMIT_SHA=\"$3\"\n\n\
1260 # Only modify message if it's a regular commit (not merge, template, etc.)\n\
1261 if [ \"$COMMIT_SOURCE\" != \"\" ] && [ \"$COMMIT_SOURCE\" != \"message\" ]; then\n\
1262 exit 0\n\
1263 fi\n\n\
1264 # Find repository root and check if Cascade is initialized\n\
1265 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
1266 if [ ! -d \"$REPO_ROOT/.cascade\" ]; then\n\
1267 exit 0\n\
1268 fi\n\n\
1269 # Check if in edit mode first\n\
1270 EDIT_STATUS=$(\"{cascade_cli}\" entry status --quiet 2>/dev/null || echo \"inactive\")\n\
1271 \n\
1272 if [ \"$EDIT_STATUS\" != \"inactive\" ]; then\n\
1273 # In edit mode - provide smart guidance\n\
1274 CURRENT_MSG=$(cat \"$COMMIT_MSG_FILE\")\n\
1275 \n\
1276 # Skip if message already has edit guidance\n\
1277 if echo \"$CURRENT_MSG\" | grep -q \"\\[EDIT MODE\\]\"; then\n\
1278 exit 0\n\
1279 fi\n\
1280 \n\
1281 echo \"\n\
1282 # [EDIT MODE] You're editing a stack entry\n\
1283 #\n\
1284 # Choose your action:\n\
1285 # 🔄 AMEND: To modify the current entry, use:\n\
1286 # git commit --amend\n\
1287 #\n\
1288 # ➕ NEW: To create a new entry on top, use:\n\
1289 # git commit (this command)\n\
1290 #\n\
1291 # 💡 After committing, run 'ca sync' to update PRs\n\
1292 \n\
1293 $CURRENT_MSG\" > \"$COMMIT_MSG_FILE\"\n\
1294 else\n\
1295 # Regular stack mode - check for active stack\n\
1296 ACTIVE_STACK=$(\"{cascade_cli}\" stack list --active --format=name 2>/dev/null || echo \"\")\n\
1297 \n\
1298 if [ -n \"$ACTIVE_STACK\" ]; then\n\
1299 # Get current commit message\n\
1300 CURRENT_MSG=$(cat \"$COMMIT_MSG_FILE\")\n\
1301 \n\
1302 # Skip if message already has stack context\n\
1303 if echo \"$CURRENT_MSG\" | grep -q \"\\[stack:\"; then\n\
1304 exit 0\n\
1305 fi\n\
1306 \n\
1307 # Add stack context to commit message\n\
1308 echo \"\n\
1309 # Stack: $ACTIVE_STACK\n\
1310 # This commit will be added to the active stack automatically.\n\
1311 # Use 'ca stack status' to see the current stack state.\n\
1312 $CURRENT_MSG\" > \"$COMMIT_MSG_FILE\"\n\
1313 fi\n\
1314 fi\n"
1315 )
1316 }
1317 }
1318
1319 pub fn detect_repository_type(&self) -> Result<RepositoryType> {
1321 let output = Command::new("git")
1322 .args(["remote", "get-url", "origin"])
1323 .current_dir(&self.repo_path)
1324 .output()
1325 .map_err(|e| CascadeError::config(format!("Failed to get remote URL: {e}")))?;
1326
1327 if !output.status.success() {
1328 return Ok(RepositoryType::Unknown);
1329 }
1330
1331 let remote_url = String::from_utf8_lossy(&output.stdout)
1332 .trim()
1333 .to_lowercase();
1334
1335 if remote_url.contains("github.com") {
1336 Ok(RepositoryType::GitHub)
1337 } else if remote_url.contains("gitlab.com") || remote_url.contains("gitlab") {
1338 Ok(RepositoryType::GitLab)
1339 } else if remote_url.contains("dev.azure.com") || remote_url.contains("visualstudio.com") {
1340 Ok(RepositoryType::AzureDevOps)
1341 } else if remote_url.contains("bitbucket") {
1342 Ok(RepositoryType::Bitbucket)
1343 } else {
1344 Ok(RepositoryType::Unknown)
1345 }
1346 }
1347
1348 pub fn detect_branch_type(&self) -> Result<BranchType> {
1350 let output = Command::new("git")
1351 .args(["branch", "--show-current"])
1352 .current_dir(&self.repo_path)
1353 .output()
1354 .map_err(|e| CascadeError::config(format!("Failed to get current branch: {e}")))?;
1355
1356 if !output.status.success() {
1357 return Ok(BranchType::Unknown);
1358 }
1359
1360 let branch_name = String::from_utf8_lossy(&output.stdout)
1361 .trim()
1362 .to_lowercase();
1363
1364 if branch_name == "main" || branch_name == "master" || branch_name == "develop" {
1365 Ok(BranchType::Main)
1366 } else if !branch_name.is_empty() {
1367 Ok(BranchType::Feature)
1368 } else {
1369 Ok(BranchType::Unknown)
1370 }
1371 }
1372
1373 pub fn validate_prerequisites(&self) -> Result<()> {
1375 Output::check_start("Checking prerequisites for Cascade hooks");
1376
1377 let repo_type = self.detect_repository_type()?;
1379 match repo_type {
1380 RepositoryType::Bitbucket => {
1381 Output::success("Bitbucket repository detected");
1382 Output::tip("Hooks will work great with 'ca submit' and 'ca autoland' for Bitbucket integration");
1383 }
1384 RepositoryType::GitHub => {
1385 Output::success("GitHub repository detected");
1386 Output::tip("Consider setting up GitHub Actions for CI/CD integration");
1387 }
1388 RepositoryType::GitLab => {
1389 Output::success("GitLab repository detected");
1390 Output::tip("GitLab CI integration works well with Cascade stacks");
1391 }
1392 RepositoryType::AzureDevOps => {
1393 Output::success("Azure DevOps repository detected");
1394 Output::tip("Azure Pipelines can be configured to work with Cascade workflows");
1395 }
1396 RepositoryType::Unknown => {
1397 Output::info(
1398 "Unknown repository type - hooks will still work for local Git operations",
1399 );
1400 }
1401 }
1402
1403 let config_dir = crate::config::get_repo_config_dir(&self.repo_path)?;
1405 let config_path = config_dir.join("config.json");
1406 if !config_path.exists() {
1407 return Err(CascadeError::config(
1408 "🚫 Cascade not initialized!\n\n\
1409 Please run 'ca init' or 'ca setup' first to configure Cascade CLI.\n\
1410 Hooks require proper Bitbucket Server configuration.\n\n\
1411 Use --force to install anyway (not recommended)."
1412 .to_string(),
1413 ));
1414 }
1415
1416 let config = Settings::load_from_file(&config_path)?;
1418
1419 if config.bitbucket.url == "https://bitbucket.example.com"
1420 || config.bitbucket.url.contains("example.com")
1421 {
1422 return Err(CascadeError::config(
1423 "🚫 Invalid Bitbucket configuration!\n\n\
1424 Your Bitbucket URL appears to be a placeholder.\n\
1425 Please run 'ca setup' to configure a real Bitbucket Server.\n\n\
1426 Use --force to install anyway (not recommended)."
1427 .to_string(),
1428 ));
1429 }
1430
1431 if config.bitbucket.project == "PROJECT" || config.bitbucket.repo == "repo" {
1432 return Err(CascadeError::config(
1433 "🚫 Incomplete Bitbucket configuration!\n\n\
1434 Your project/repository settings appear to be placeholders.\n\
1435 Please run 'ca setup' to complete configuration.\n\n\
1436 Use --force to install anyway (not recommended)."
1437 .to_string(),
1438 ));
1439 }
1440
1441 Output::success("Prerequisites validation passed");
1442 Ok(())
1443 }
1444
1445 pub fn validate_branch_suitability(&self) -> Result<()> {
1447 let branch_type = self.detect_branch_type()?;
1448
1449 match branch_type {
1450 BranchType::Main => {
1451 return Err(CascadeError::config(
1452 "🚫 Currently on main/master branch!\n\n\
1453 Cascade hooks are designed for feature branch development.\n\
1454 Working directly on main/master with stacked diffs can:\n\
1455 • Complicate the commit history\n\
1456 • Interfere with team collaboration\n\
1457 • Break CI/CD workflows\n\n\
1458 💡 Recommended workflow:\n\
1459 1. Create a feature branch: git checkout -b feature/my-feature\n\
1460 2. Install hooks: ca hooks install\n\
1461 3. Develop with stacked commits (auto-added with hooks)\n\
1462 4. Push & submit: ca push && ca submit (all by default)\n\
1463 5. Auto-land when ready: ca autoland\n\n\
1464 Use --force to install anyway (not recommended)."
1465 .to_string(),
1466 ));
1467 }
1468 BranchType::Feature => {
1469 Output::success("Feature branch detected - suitable for stacked development");
1470 }
1471 BranchType::Unknown => {
1472 Output::warning("Unknown branch type - proceeding with caution");
1473 }
1474 }
1475
1476 Ok(())
1477 }
1478
1479 pub fn confirm_installation(&self) -> Result<()> {
1481 Output::section("Hook Installation Summary");
1482
1483 let hooks = vec![
1484 HookType::PostCommit,
1485 HookType::PrePush,
1486 HookType::CommitMsg,
1487 HookType::PrepareCommitMsg,
1488 ];
1489
1490 for hook in &hooks {
1491 Output::sub_item(format!("{}: {}", hook.filename(), hook.description()));
1492 }
1493
1494 println!();
1495 Output::section("These hooks will automatically");
1496 Output::bullet("Add commits to your active stack");
1497 Output::bullet("Validate commit messages");
1498 Output::bullet("Prevent force pushes that break stack integrity");
1499 Output::bullet("Add stack context to commit messages");
1500
1501 println!();
1502 Output::section("With hooks + new defaults, your workflow becomes");
1503 Output::sub_item("git commit → Auto-added to stack");
1504 Output::sub_item("ca push → Pushes all by default");
1505 Output::sub_item("ca submit → Submits all by default");
1506 Output::sub_item("ca autoland → Auto-merges when ready");
1507
1508 let should_install = Confirm::with_theme(&ColorfulTheme::default())
1510 .with_prompt("Install Cascade hooks?")
1511 .default(true)
1512 .interact()
1513 .map_err(|e| CascadeError::config(format!("Failed to get user confirmation: {e}")))?;
1514
1515 if should_install {
1516 Output::success("Proceeding with installation");
1517 Ok(())
1518 } else {
1519 Err(CascadeError::config(
1520 "Installation cancelled by user".to_string(),
1521 ))
1522 }
1523 }
1524}
1525
1526pub async fn install() -> Result<()> {
1528 install_with_options(false, false, false, false).await
1529}
1530
1531pub async fn install_essential() -> Result<()> {
1532 let current_dir = env::current_dir()
1533 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1534
1535 let repo_root = find_repository_root(¤t_dir)
1536 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1537
1538 let hooks_manager = HooksManager::new(&repo_root)?;
1539 hooks_manager.install_essential()
1540}
1541
1542pub async fn install_with_options(
1543 skip_checks: bool,
1544 allow_main_branch: bool,
1545 yes: bool,
1546 force: bool,
1547) -> Result<()> {
1548 let current_dir = env::current_dir()
1549 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1550
1551 let repo_root = find_repository_root(¤t_dir)
1552 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1553
1554 let hooks_manager = HooksManager::new(&repo_root)?;
1555
1556 let options = InstallOptions {
1557 check_prerequisites: !skip_checks,
1558 feature_branches_only: !allow_main_branch,
1559 confirm: !yes,
1560 force,
1561 };
1562
1563 hooks_manager.install_with_options(&options)
1564}
1565
1566pub async fn uninstall() -> Result<()> {
1567 let current_dir = env::current_dir()
1568 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1569
1570 let repo_root = find_repository_root(¤t_dir)
1571 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1572
1573 let hooks_manager = HooksManager::new(&repo_root)?;
1574 hooks_manager.uninstall_all()
1575}
1576
1577pub async fn status() -> Result<()> {
1578 let current_dir = env::current_dir()
1579 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1580
1581 let repo_root = find_repository_root(¤t_dir)
1582 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1583
1584 let hooks_manager = HooksManager::new(&repo_root)?;
1585 hooks_manager.list_installed_hooks()
1586}
1587
1588pub async fn install_hook(hook_name: &str) -> Result<()> {
1589 install_hook_with_options(hook_name, false, false).await
1590}
1591
1592pub async fn install_hook_with_options(
1593 hook_name: &str,
1594 skip_checks: bool,
1595 force: bool,
1596) -> Result<()> {
1597 let current_dir = env::current_dir()
1598 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1599
1600 let repo_root = find_repository_root(¤t_dir)
1601 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1602
1603 let hooks_manager = HooksManager::new(&repo_root)?;
1604
1605 let hook_type = match hook_name {
1606 "post-commit" => HookType::PostCommit,
1607 "pre-push" => HookType::PrePush,
1608 "commit-msg" => HookType::CommitMsg,
1609 "pre-commit" => HookType::PreCommit,
1610 "prepare-commit-msg" => HookType::PrepareCommitMsg,
1611 _ => {
1612 return Err(CascadeError::config(format!(
1613 "Unknown hook type: {hook_name}"
1614 )))
1615 }
1616 };
1617
1618 if !skip_checks && !force {
1620 hooks_manager.validate_prerequisites()?;
1621 }
1622
1623 hooks_manager.install_hook(&hook_type)
1624}
1625
1626pub async fn uninstall_hook(hook_name: &str) -> Result<()> {
1627 let current_dir = env::current_dir()
1628 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1629
1630 let repo_root = find_repository_root(¤t_dir)
1631 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1632
1633 let hooks_manager = HooksManager::new(&repo_root)?;
1634
1635 let hook_type = match hook_name {
1636 "post-commit" => HookType::PostCommit,
1637 "pre-push" => HookType::PrePush,
1638 "commit-msg" => HookType::CommitMsg,
1639 "pre-commit" => HookType::PreCommit,
1640 "prepare-commit-msg" => HookType::PrepareCommitMsg,
1641 _ => {
1642 return Err(CascadeError::config(format!(
1643 "Unknown hook type: {hook_name}"
1644 )))
1645 }
1646 };
1647
1648 hooks_manager.uninstall_hook(&hook_type)
1649}
1650
1651#[cfg(test)]
1652mod tests {
1653 use super::*;
1654 use std::process::Command;
1655 use tempfile::TempDir;
1656
1657 fn create_test_repo() -> (TempDir, std::path::PathBuf) {
1658 let temp_dir = TempDir::new().unwrap();
1659 let repo_path = temp_dir.path().to_path_buf();
1660
1661 Command::new("git")
1663 .args(["init"])
1664 .current_dir(&repo_path)
1665 .output()
1666 .unwrap();
1667 Command::new("git")
1668 .args(["config", "user.name", "Test"])
1669 .current_dir(&repo_path)
1670 .output()
1671 .unwrap();
1672 Command::new("git")
1673 .args(["config", "user.email", "test@test.com"])
1674 .current_dir(&repo_path)
1675 .output()
1676 .unwrap();
1677
1678 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1680 Command::new("git")
1681 .args(["add", "."])
1682 .current_dir(&repo_path)
1683 .output()
1684 .unwrap();
1685 Command::new("git")
1686 .args(["commit", "-m", "Initial"])
1687 .current_dir(&repo_path)
1688 .output()
1689 .unwrap();
1690
1691 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
1693 .unwrap();
1694
1695 (temp_dir, repo_path)
1696 }
1697
1698 #[test]
1699 fn test_hooks_manager_creation() {
1700 let (_temp_dir, repo_path) = create_test_repo();
1701 let _manager = HooksManager::new(&repo_path).unwrap();
1702
1703 assert_eq!(_manager.repo_path, repo_path);
1704 assert!(!_manager.repo_id.is_empty());
1706 }
1707
1708 #[test]
1709 fn test_hooks_manager_custom_hooks_path() {
1710 let (_temp_dir, repo_path) = create_test_repo();
1711
1712 Command::new("git")
1714 .args(["config", "core.hooksPath", "custom-hooks"])
1715 .current_dir(&repo_path)
1716 .output()
1717 .unwrap();
1718
1719 let custom_hooks_dir = repo_path.join("custom-hooks");
1721 std::fs::create_dir_all(&custom_hooks_dir).unwrap();
1722
1723 let _manager = HooksManager::new(&repo_path).unwrap();
1724
1725 assert_eq!(_manager.repo_path, repo_path);
1726 assert!(!_manager.repo_id.is_empty());
1728 }
1729
1730 #[test]
1731 fn test_hook_chaining_with_existing_hooks() {
1732 let (_temp_dir, repo_path) = create_test_repo();
1733 let manager = HooksManager::new(&repo_path).unwrap();
1734
1735 let hook_type = HookType::PreCommit;
1736 let hook_path = repo_path.join(".git/hooks").join(hook_type.filename());
1737
1738 let existing_hook_content = "#!/bin/bash\n# Project pre-commit hook\n./scripts/lint.sh\n";
1740 std::fs::write(&hook_path, existing_hook_content).unwrap();
1741 crate::utils::platform::make_executable(&hook_path).unwrap();
1742
1743 let result = manager.install_hook(&hook_type);
1745 assert!(result.is_ok());
1746
1747 let original_content = std::fs::read_to_string(&hook_path).unwrap();
1749 assert!(original_content.contains("# Project pre-commit hook"));
1750 assert!(original_content.contains("./scripts/lint.sh"));
1751
1752 let cascade_hooks_dir = manager.get_cascade_hooks_dir().unwrap();
1754 let cascade_hook_path = cascade_hooks_dir.join(hook_type.filename());
1755 assert!(cascade_hook_path.exists());
1756
1757 let uninstall_result = manager.uninstall_hook(&hook_type);
1759 assert!(uninstall_result.is_ok());
1760
1761 let after_uninstall = std::fs::read_to_string(&hook_path).unwrap();
1763 assert!(after_uninstall.contains("# Project pre-commit hook"));
1764 assert!(after_uninstall.contains("./scripts/lint.sh"));
1765
1766 assert!(!cascade_hook_path.exists());
1768 }
1769
1770 #[test]
1771 fn test_hook_installation() {
1772 let (_temp_dir, repo_path) = create_test_repo();
1773 let manager = HooksManager::new(&repo_path).unwrap();
1774
1775 let hook_type = HookType::PostCommit;
1777 let result = manager.install_hook(&hook_type);
1778 assert!(result.is_ok());
1779
1780 let hook_filename = hook_type.filename();
1782 let cascade_hooks_dir = manager.get_cascade_hooks_dir().unwrap();
1783 let hook_path = cascade_hooks_dir.join(&hook_filename);
1784 assert!(hook_path.exists());
1785
1786 #[cfg(unix)]
1788 {
1789 use std::os::unix::fs::PermissionsExt;
1790 let metadata = std::fs::metadata(&hook_path).unwrap();
1791 let permissions = metadata.permissions();
1792 assert!(permissions.mode() & 0o111 != 0); }
1794
1795 #[cfg(windows)]
1796 {
1797 assert!(hook_filename.ends_with(".bat"));
1799 assert!(hook_path.exists());
1800 }
1801 }
1802
1803 #[test]
1804 fn test_hook_detection() {
1805 let (_temp_dir, repo_path) = create_test_repo();
1806 let _manager = HooksManager::new(&repo_path).unwrap();
1807
1808 let post_commit_path = repo_path
1810 .join(".git/hooks")
1811 .join(HookType::PostCommit.filename());
1812 let pre_push_path = repo_path
1813 .join(".git/hooks")
1814 .join(HookType::PrePush.filename());
1815 let commit_msg_path = repo_path
1816 .join(".git/hooks")
1817 .join(HookType::CommitMsg.filename());
1818
1819 assert!(!post_commit_path.exists());
1821 assert!(!pre_push_path.exists());
1822 assert!(!commit_msg_path.exists());
1823 }
1824
1825 #[test]
1826 fn test_hook_validation() {
1827 let (_temp_dir, repo_path) = create_test_repo();
1828 let manager = HooksManager::new(&repo_path).unwrap();
1829
1830 let validation = manager.validate_prerequisites();
1832 let _ = validation; let branch_validation = manager.validate_branch_suitability();
1838 let _ = branch_validation; }
1841
1842 #[test]
1843 fn test_hook_uninstallation() {
1844 let (_temp_dir, repo_path) = create_test_repo();
1845 let manager = HooksManager::new(&repo_path).unwrap();
1846
1847 let hook_type = HookType::PostCommit;
1849 manager.install_hook(&hook_type).unwrap();
1850
1851 let cascade_hooks_dir = manager.get_cascade_hooks_dir().unwrap();
1852 let hook_path = cascade_hooks_dir.join(hook_type.filename());
1853 assert!(hook_path.exists());
1854
1855 let result = manager.uninstall_hook(&hook_type);
1856 assert!(result.is_ok());
1857 assert!(!hook_path.exists());
1858 }
1859
1860 #[test]
1861 fn test_hook_content_generation() {
1862 let (_temp_dir, repo_path) = create_test_repo();
1863 let manager = HooksManager::new(&repo_path).unwrap();
1864
1865 let binary_name = "cascade-cli";
1867
1868 let post_commit_content = manager.generate_post_commit_hook(binary_name);
1870 #[cfg(windows)]
1871 {
1872 assert!(post_commit_content.contains("@echo off"));
1873 assert!(post_commit_content.contains("rem Cascade CLI Hook"));
1874 }
1875 #[cfg(not(windows))]
1876 {
1877 assert!(post_commit_content.contains("#!/bin/sh"));
1878 assert!(post_commit_content.contains("# Cascade CLI Hook"));
1879 }
1880 assert!(post_commit_content.contains(binary_name));
1881
1882 let pre_push_content = manager.generate_pre_push_hook(binary_name);
1884 #[cfg(windows)]
1885 {
1886 assert!(pre_push_content.contains("@echo off"));
1887 assert!(pre_push_content.contains("rem Cascade CLI Hook"));
1888 }
1889 #[cfg(not(windows))]
1890 {
1891 assert!(pre_push_content.contains("#!/bin/sh"));
1892 assert!(pre_push_content.contains("# Cascade CLI Hook"));
1893 }
1894 assert!(pre_push_content.contains(binary_name));
1895
1896 let commit_msg_content = manager.generate_commit_msg_hook(binary_name);
1898 #[cfg(windows)]
1899 {
1900 assert!(commit_msg_content.contains("@echo off"));
1901 assert!(commit_msg_content.contains("rem Cascade CLI Hook"));
1902 }
1903 #[cfg(not(windows))]
1904 {
1905 assert!(commit_msg_content.contains("#!/bin/sh"));
1906 assert!(commit_msg_content.contains("# Cascade CLI Hook"));
1907 }
1908
1909 let prepare_commit_content = manager.generate_prepare_commit_msg_hook(binary_name);
1911 #[cfg(windows)]
1912 {
1913 assert!(prepare_commit_content.contains("@echo off"));
1914 assert!(prepare_commit_content.contains("rem Cascade CLI Hook"));
1915 }
1916 #[cfg(not(windows))]
1917 {
1918 assert!(prepare_commit_content.contains("#!/bin/sh"));
1919 assert!(prepare_commit_content.contains("# Cascade CLI Hook"));
1920 }
1921 assert!(prepare_commit_content.contains(binary_name));
1922 }
1923
1924 #[test]
1925 fn test_hook_status_reporting() {
1926 let (_temp_dir, repo_path) = create_test_repo();
1927 let manager = HooksManager::new(&repo_path).unwrap();
1928
1929 let repo_type = manager.detect_repository_type().unwrap();
1931 assert!(matches!(
1933 repo_type,
1934 RepositoryType::Bitbucket | RepositoryType::Unknown
1935 ));
1936
1937 let branch_type = manager.detect_branch_type().unwrap();
1939 assert!(matches!(
1941 branch_type,
1942 BranchType::Main | BranchType::Unknown
1943 ));
1944 }
1945
1946 #[test]
1947 fn test_force_installation() {
1948 let (_temp_dir, repo_path) = create_test_repo();
1949 let manager = HooksManager::new(&repo_path).unwrap();
1950
1951 let hook_filename = HookType::PostCommit.filename();
1953 let hook_path = repo_path.join(".git/hooks").join(&hook_filename);
1954
1955 #[cfg(windows)]
1956 let existing_content = "@echo off\necho existing hook";
1957 #[cfg(not(windows))]
1958 let existing_content = "#!/bin/sh\necho 'existing hook'";
1959
1960 std::fs::write(&hook_path, existing_content).unwrap();
1961
1962 let hook_type = HookType::PostCommit;
1964 let result = manager.install_hook(&hook_type);
1965 assert!(result.is_ok());
1966
1967 let cascade_hooks_dir = manager.get_cascade_hooks_dir().unwrap();
1969 let cascade_hook_path = cascade_hooks_dir.join(&hook_filename);
1970 assert!(cascade_hook_path.exists());
1971
1972 let original_content = std::fs::read_to_string(&hook_path).unwrap();
1974 assert!(original_content.contains("existing hook"));
1975
1976 let cascade_content = std::fs::read_to_string(&cascade_hook_path).unwrap();
1978 assert!(cascade_content.contains("cascade-cli") || cascade_content.contains("ca"));
1979 }
1980}