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