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