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