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