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