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