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