1use crate::cli::output::Output;
2use crate::config::Settings;
3use crate::errors::{CascadeError, Result};
4use crate::git::find_repository_root;
5use std::env;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::process::Command;
9
10#[derive(Debug, Clone, PartialEq)]
12pub enum RepositoryType {
13 Bitbucket,
14 GitHub,
15 GitLab,
16 AzureDevOps,
17 Unknown,
18}
19
20#[derive(Debug, Clone, PartialEq)]
22pub enum BranchType {
23 Main, Feature, Unknown,
26}
27
28#[derive(Debug, Clone)]
30pub struct InstallOptions {
31 pub check_prerequisites: bool,
32 pub feature_branches_only: bool,
33 pub confirm: bool,
34 pub force: bool,
35}
36
37impl Default for InstallOptions {
38 fn default() -> Self {
39 Self {
40 check_prerequisites: true,
41 feature_branches_only: true,
42 confirm: true,
43 force: false,
44 }
45 }
46}
47
48pub struct HooksManager {
50 repo_path: PathBuf,
51 hooks_dir: PathBuf,
52}
53
54#[derive(Debug, Clone)]
56pub enum HookType {
57 PostCommit,
59 PrePush,
61 CommitMsg,
63 PreCommit,
65 PrepareCommitMsg,
67}
68
69impl HookType {
70 fn filename(&self) -> String {
71 let base_name = match self {
72 HookType::PostCommit => "post-commit",
73 HookType::PrePush => "pre-push",
74 HookType::CommitMsg => "commit-msg",
75 HookType::PreCommit => "pre-commit",
76 HookType::PrepareCommitMsg => "prepare-commit-msg",
77 };
78 format!(
79 "{}{}",
80 base_name,
81 crate::utils::platform::git_hook_extension()
82 )
83 }
84
85 fn description(&self) -> &'static str {
86 match self {
87 HookType::PostCommit => "Auto-add new commits to active stack",
88 HookType::PrePush => "Prevent force pushes and validate stack state",
89 HookType::CommitMsg => "Validate commit message format",
90 HookType::PreCommit => "Smart edit mode guidance for better UX",
91 HookType::PrepareCommitMsg => "Add stack context to commit messages",
92 }
93 }
94}
95
96impl HooksManager {
97 pub fn new(repo_path: &Path) -> Result<Self> {
98 let hooks_dir = Self::get_hooks_path(repo_path)?;
99
100 if !hooks_dir.exists() {
101 return Err(CascadeError::config(
102 "Git hooks directory not found. Is this a Git repository?".to_string(),
103 ));
104 }
105
106 Ok(Self {
107 repo_path: repo_path.to_path_buf(),
108 hooks_dir,
109 })
110 }
111
112 fn get_hooks_path(repo_path: &Path) -> Result<PathBuf> {
114 use std::process::Command;
115
116 let output = Command::new("git")
118 .args(["config", "--get", "core.hooksPath"])
119 .current_dir(repo_path)
120 .output()
121 .map_err(|e| CascadeError::config(format!("Failed to check git config: {e}")))?;
122
123 let hooks_path = if output.status.success() {
124 let configured_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
125 if configured_path.is_empty() {
126 repo_path.join(".git").join("hooks")
128 } else if configured_path.starts_with('/') {
129 PathBuf::from(configured_path)
131 } else {
132 repo_path.join(configured_path)
134 }
135 } else {
136 repo_path.join(".git").join("hooks")
138 };
139
140 Ok(hooks_path)
141 }
142
143 pub fn install_all(&self) -> Result<()> {
145 self.install_with_options(&InstallOptions::default())
146 }
147
148 pub fn install_essential(&self) -> Result<()> {
150 Output::progress("Installing essential Cascade Git hooks");
151
152 let essential_hooks = vec![HookType::PrePush, HookType::CommitMsg, HookType::PreCommit];
153
154 for hook in essential_hooks {
155 self.install_hook(&hook)?;
156 }
157
158 Output::success("Essential Cascade hooks installed successfully!");
159 Output::tip(
160 "Note: Post-commit hook available separately with 'ca hooks install post-commit'",
161 );
162 Output::section("Hooks installed");
163 self.list_installed_hooks()?;
164
165 Ok(())
166 }
167
168 pub fn install_with_options(&self, options: &InstallOptions) -> Result<()> {
170 if options.check_prerequisites && !options.force {
171 self.validate_prerequisites()?;
172 }
173
174 if options.feature_branches_only && !options.force {
175 self.validate_branch_suitability()?;
176 }
177
178 if options.confirm && !options.force {
179 self.confirm_installation()?;
180 }
181
182 Output::progress("Installing Cascade Git hooks");
183
184 let hooks = vec![
185 HookType::PostCommit,
186 HookType::PrePush,
187 HookType::CommitMsg,
188 HookType::PrepareCommitMsg,
189 ];
190
191 for hook in hooks {
192 self.install_hook(&hook)?;
193 }
194
195 Output::success("All Cascade hooks installed successfully!");
196 Output::section("Hooks installed");
197 self.list_installed_hooks()?;
198
199 Ok(())
200 }
201
202 pub fn install_hook(&self, hook_type: &HookType) -> Result<()> {
204 let hook_path = self.hooks_dir.join(hook_type.filename());
205 let cascade_content = self.generate_hook_script(hook_type)?;
206
207 if hook_path.exists() {
208 let existing_content = fs::read_to_string(&hook_path)
210 .map_err(|e| CascadeError::config(format!("Failed to read existing hook: {e}")))?;
211
212 if existing_content.contains("=== CASCADE CLI HOOKS START ===") {
213 Output::info(format!(
214 "{} hook already has cascade hooks installed",
215 hook_type.filename()
216 ));
217 return Ok(());
218 }
219
220 self.append_to_existing_hook(&hook_path, &cascade_content)?;
222 Output::success(format!(
223 "Added cascade hooks to existing {} hook",
224 hook_type.filename()
225 ));
226 } else {
227 fs::write(&hook_path, cascade_content)
229 .map_err(|e| CascadeError::config(format!("Failed to write hook file: {e}")))?;
230
231 crate::utils::platform::make_executable(&hook_path).map_err(|e| {
233 CascadeError::config(format!("Failed to make hook executable: {e}"))
234 })?;
235
236 Output::success(format!("Installed {} hook", hook_type.filename()));
237 }
238
239 Ok(())
240 }
241
242 fn append_to_existing_hook(&self, hook_path: &Path, cascade_content: &str) -> Result<()> {
244 let existing_content = fs::read_to_string(hook_path)
245 .map_err(|e| CascadeError::config(format!("Failed to read existing hook: {e}")))?;
246
247 let cascade_section = format!(
249 "\n# === CASCADE CLI HOOKS START ===\n{cascade_content}\n# === CASCADE CLI HOOKS END ===\n"
250 );
251
252 let new_content = format!("{existing_content}{cascade_section}");
254
255 fs::write(hook_path, new_content)
256 .map_err(|e| CascadeError::config(format!("Failed to update hook file: {e}")))?;
257
258 crate::utils::platform::make_executable(hook_path)
260 .map_err(|e| CascadeError::config(format!("Failed to make hook executable: {e}")))?;
261
262 Ok(())
263 }
264
265 pub fn uninstall_all(&self) -> Result<()> {
267 Output::progress("Removing Cascade Git hooks");
268
269 let hooks = vec![
270 HookType::PostCommit,
271 HookType::PrePush,
272 HookType::CommitMsg,
273 HookType::PrepareCommitMsg,
274 ];
275
276 for hook in hooks {
277 self.uninstall_hook(&hook)?;
278 }
279
280 Output::success("All Cascade hooks removed!");
281 Ok(())
282 }
283
284 pub fn uninstall_hook(&self, hook_type: &HookType) -> Result<()> {
286 let hook_path = self.hooks_dir.join(hook_type.filename());
287
288 if hook_path.exists() {
289 let content = fs::read_to_string(&hook_path)
290 .map_err(|e| CascadeError::config(format!("Failed to read hook file: {e}")))?;
291
292 if content.contains("=== CASCADE CLI HOOKS START ===") {
293 self.remove_cascade_section_from_hook(&hook_path)?;
295 Output::success(format!(
296 "Removed cascade hooks from {} hook",
297 hook_type.filename()
298 ));
299 } else {
300 let is_cascade_hook = if cfg!(windows) {
302 content.contains("rem Cascade CLI Hook")
303 } else {
304 content.contains("# Cascade CLI Hook")
305 };
306
307 if is_cascade_hook {
308 fs::remove_file(&hook_path).map_err(|e| {
309 CascadeError::config(format!("Failed to remove hook file: {e}"))
310 })?;
311 Output::info(format!("Removed {} hook", hook_type.filename()));
312 } else {
313 Output::warning(format!(
314 "{} hook exists but is not a Cascade hook, skipping",
315 hook_type.filename()
316 ));
317 }
318 }
319 } else {
320 Output::info(format!("{} hook not found", hook_type.filename()));
321 }
322
323 Ok(())
324 }
325
326 fn remove_cascade_section_from_hook(&self, hook_path: &Path) -> Result<()> {
328 let content = fs::read_to_string(hook_path)
329 .map_err(|e| CascadeError::config(format!("Failed to read hook file: {e}")))?;
330
331 let start_marker = "# === CASCADE CLI HOOKS START ===";
333 let end_marker = "# === CASCADE CLI HOOKS END ===";
334
335 if let Some(start_pos) = content.find(start_marker) {
336 if let Some(end_pos) = content.find(end_marker) {
337 let end_pos = end_pos + end_marker.len();
338
339 let mut new_content = String::new();
341 new_content.push_str(&content[..start_pos]);
342 new_content.push_str(&content[end_pos..]);
343
344 let new_content = new_content.trim_end().to_string();
346
347 fs::write(hook_path, new_content).map_err(|e| {
348 CascadeError::config(format!("Failed to update hook file: {e}"))
349 })?;
350 }
351 }
352
353 Ok(())
354 }
355
356 pub fn list_installed_hooks(&self) -> Result<()> {
358 let hooks = vec![
359 HookType::PostCommit,
360 HookType::PrePush,
361 HookType::CommitMsg,
362 HookType::PrepareCommitMsg,
363 ];
364
365 Output::section("Git Hooks Status");
366
367 for hook in hooks {
368 let hook_path = self.hooks_dir.join(hook.filename());
369 if hook_path.exists() {
370 let content = fs::read_to_string(&hook_path).unwrap_or_default();
371
372 let has_cascade_hooks = content.contains("=== CASCADE CLI HOOKS START ===")
374 || if cfg!(windows) {
375 content.contains("rem Cascade CLI Hook")
376 } else {
377 content.contains("# Cascade CLI Hook")
378 };
379
380 if has_cascade_hooks {
381 if content.contains("=== CASCADE CLI HOOKS START ===") {
383 Output::success(format!(
384 "{}: {} (Chained)",
385 hook.filename(),
386 hook.description()
387 ));
388 } else {
389 Output::success(format!("{}: {}", hook.filename(), hook.description()));
390 }
391 } else {
392 Output::warning(format!(
393 "{}: {} (Custom)",
394 hook.filename(),
395 hook.description()
396 ));
397 }
398 } else {
399 Output::error(format!(
400 "{}: {} (Missing)",
401 hook.filename(),
402 hook.description()
403 ));
404 }
405 }
406
407 Ok(())
408 }
409
410 pub fn generate_hook_script(&self, hook_type: &HookType) -> Result<String> {
412 let cascade_cli = env::current_exe()
413 .map_err(|e| {
414 CascadeError::config(format!("Failed to get current executable path: {e}"))
415 })?
416 .to_string_lossy()
417 .to_string();
418
419 let script = match hook_type {
420 HookType::PostCommit => self.generate_post_commit_hook(&cascade_cli),
421 HookType::PrePush => self.generate_pre_push_hook(&cascade_cli),
422 HookType::CommitMsg => self.generate_commit_msg_hook(&cascade_cli),
423 HookType::PreCommit => self.generate_pre_commit_hook(&cascade_cli),
424 HookType::PrepareCommitMsg => self.generate_prepare_commit_msg_hook(&cascade_cli),
425 };
426
427 Ok(script)
428 }
429
430 fn generate_post_commit_hook(&self, cascade_cli: &str) -> String {
431 #[cfg(windows)]
432 {
433 format!(
434 "@echo off\n\
435 rem Cascade CLI Hook - Post Commit\n\
436 rem Automatically adds new commits to the active stack\n\n\
437 rem Get the commit hash and message\n\
438 for /f \"tokens=*\" %%i in ('git rev-parse HEAD') do set COMMIT_HASH=%%i\n\
439 for /f \"tokens=*\" %%i in ('git log --format=%%s -n 1 HEAD') do set COMMIT_MSG=%%i\n\n\
440 rem Find repository root and check if Cascade is initialized\n\
441 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
442 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
443 if not exist \"%REPO_ROOT%\\.cascade\" (\n\
444 echo ℹ️ Cascade not initialized, skipping stack management\n\
445 echo 💡 Run 'ca init' to start using stacked diffs\n\
446 exit /b 0\n\
447 )\n\n\
448 rem Check if there's an active stack\n\
449 \"{cascade_cli}\" stack list --active >nul 2>&1\n\
450 if %ERRORLEVEL% neq 0 (\n\
451 echo ℹ️ No active stack found, commit will not be added to any stack\n\
452 echo 💡 Use 'ca stack create ^<name^>' to create a stack for this commit\n\
453 exit /b 0\n\
454 )\n\n\
455 rem Add commit to active stack\n\
456 echo 🪝 Adding commit to active stack...\n\
457 echo 📝 Commit: %COMMIT_MSG%\n\
458 \"{cascade_cli}\" stack push --commit \"%COMMIT_HASH%\" --message \"%COMMIT_MSG%\"\n\
459 if %ERRORLEVEL% equ 0 (\n\
460 echo ✅ Commit added to stack successfully\n\
461 echo 💡 Next: 'ca submit' to create PRs when ready\n\
462 ) else (\n\
463 echo ⚠️ Failed to add commit to stack\n\
464 echo 💡 You can manually add it with: ca push --commit %COMMIT_HASH%\n\
465 )\n"
466 )
467 }
468
469 #[cfg(not(windows))]
470 {
471 format!(
472 "#!/bin/sh\n\
473 # Cascade CLI Hook - Post Commit\n\
474 # Automatically adds new commits to the active stack\n\n\
475 set -e\n\n\
476 # Get the commit hash and message\n\
477 COMMIT_HASH=$(git rev-parse HEAD)\n\
478 COMMIT_MSG=$(git log --format=%s -n 1 HEAD)\n\n\
479 # Find repository root and check if Cascade is initialized\n\
480 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
481 if [ ! -d \"$REPO_ROOT/.cascade\" ]; then\n\
482 echo \"ℹ️ Cascade not initialized, skipping stack management\"\n\
483 echo \"💡 Run 'ca init' to start using stacked diffs\"\n\
484 exit 0\n\
485 fi\n\n\
486 # Check if there's an active stack\n\
487 if ! \"{cascade_cli}\" stack list --active > /dev/null 2>&1; then\n\
488 echo \"ℹ️ No active stack found, commit will not be added to any stack\"\n\
489 echo \"💡 Use 'ca stack create <name>' to create a stack for this commit\"\n\
490 exit 0\n\
491 fi\n\n\
492 # Add commit to active stack (using specific commit targeting)\n\
493 echo \"🪝 Adding commit to active stack...\"\n\
494 echo \"📝 Commit: $COMMIT_MSG\"\n\
495 if \"{cascade_cli}\" stack push --commit \"$COMMIT_HASH\" --message \"$COMMIT_MSG\"; then\n\
496 echo \"✅ Commit added to stack successfully\"\n\
497 echo \"💡 Next: 'ca submit' to create PRs when ready\"\n\
498 else\n\
499 echo \"⚠️ Failed to add commit to stack\"\n\
500 echo \"💡 You can manually add it with: ca push --commit $COMMIT_HASH\"\n\
501 fi\n"
502 )
503 }
504 }
505
506 fn generate_pre_push_hook(&self, cascade_cli: &str) -> String {
507 #[cfg(windows)]
508 {
509 format!(
510 "@echo off\n\
511 rem Cascade CLI Hook - Pre Push\n\
512 rem Prevents force pushes and validates stack state\n\n\
513 rem Check for force push\n\
514 echo %* | findstr /C:\"--force\" /C:\"--force-with-lease\" /C:\"-f\" >nul\n\
515 if %ERRORLEVEL% equ 0 (\n\
516 echo ❌ Force push detected!\n\
517 echo 🌊 Cascade CLI uses stacked diffs - force pushes can break stack integrity\n\
518 echo.\n\
519 echo 💡 Instead of force pushing, try these streamlined commands:\n\
520 echo • ca sync - Sync with remote changes ^(handles rebasing^)\n\
521 echo • ca push - Push all unpushed commits ^(new default^)\n\
522 echo • ca submit - Submit all entries for review ^(new default^)\n\
523 echo • ca autoland - Auto-merge when approved + builds pass\n\
524 echo.\n\
525 echo 🚨 If you really need to force push, run:\n\
526 echo git push --force-with-lease [remote] [branch]\n\
527 echo ^(But consider if this will affect other stack entries^)\n\
528 exit /b 1\n\
529 )\n\n\
530 rem Find repository root and check if Cascade is initialized\n\
531 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
532 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
533 if not exist \"%REPO_ROOT%\\.cascade\" (\n\
534 echo ℹ️ Cascade not initialized, allowing push\n\
535 exit /b 0\n\
536 )\n\n\
537 rem Validate stack state\n\
538 echo 🪝 Validating stack state before push...\n\
539 \"{cascade_cli}\" stack validate\n\
540 if %ERRORLEVEL% equ 0 (\n\
541 echo ✅ Stack validation passed\n\
542 ) else (\n\
543 echo ❌ Stack validation failed\n\
544 echo 💡 Fix validation errors before pushing:\n\
545 echo • ca doctor - Check overall health\n\
546 echo • ca status - Check current stack status\n\
547 echo • ca sync - Sync with remote and rebase if needed\n\
548 exit /b 1\n\
549 )\n\n\
550 echo ✅ Pre-push validation complete\n"
551 )
552 }
553
554 #[cfg(not(windows))]
555 {
556 format!(
557 "#!/bin/sh\n\
558 # Cascade CLI Hook - Pre Push\n\
559 # Prevents force pushes and validates stack state\n\n\
560 set -e\n\n\
561 # Check for force push\n\
562 if echo \"$*\" | grep -q -- \"--force\\|--force-with-lease\\|-f\"; then\n\
563 echo \"❌ Force push detected!\"\n\
564 echo \"🌊 Cascade CLI uses stacked diffs - force pushes can break stack integrity\"\n\
565 echo \"\"\n\
566 echo \"💡 Instead of force pushing, try these streamlined commands:\"\n\
567 echo \" • ca sync - Sync with remote changes (handles rebasing)\"\n\
568 echo \" • ca push - Push all unpushed commits (new default)\"\n\
569 echo \" • ca submit - Submit all entries for review (new default)\"\n\
570 echo \" • ca autoland - Auto-merge when approved + builds pass\"\n\
571 echo \"\"\n\
572 echo \"🚨 If you really need to force push, run:\"\n\
573 echo \" git push --force-with-lease [remote] [branch]\"\n\
574 echo \" (But consider if this will affect other stack entries)\"\n\
575 exit 1\n\
576 fi\n\n\
577 # Find repository root and check if Cascade is initialized\n\
578 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
579 if [ ! -d \"$REPO_ROOT/.cascade\" ]; then\n\
580 echo \"ℹ️ Cascade not initialized, allowing push\"\n\
581 exit 0\n\
582 fi\n\n\
583 # Validate stack state\n\
584 echo \"🪝 Validating stack state before push...\"\n\
585 if \"{cascade_cli}\" stack validate; then\n\
586 echo \"✅ Stack validation passed\"\n\
587 else\n\
588 echo \"❌ Stack validation failed\"\n\
589 echo \"💡 Fix validation errors before pushing:\"\n\
590 echo \" • ca doctor - Check overall health\"\n\
591 echo \" • ca status - Check current stack status\"\n\
592 echo \" • ca sync - Sync with remote and rebase if needed\"\n\
593 exit 1\n\
594 fi\n\n\
595 echo \"✅ Pre-push validation complete\"\n"
596 )
597 }
598 }
599
600 fn generate_commit_msg_hook(&self, _cascade_cli: &str) -> String {
601 #[cfg(windows)]
602 {
603 r#"@echo off
604rem Cascade CLI Hook - Commit Message
605rem Validates commit message format
606
607set COMMIT_MSG_FILE=%1
608if "%COMMIT_MSG_FILE%"=="" (
609 echo ❌ No commit message file provided
610 exit /b 1
611)
612
613rem Read commit message (Windows batch is limited, but this covers basic cases)
614for /f "delims=" %%i in ('type "%COMMIT_MSG_FILE%"') do set COMMIT_MSG=%%i
615
616rem Skip validation for merge commits, fixup commits, etc.
617echo %COMMIT_MSG% | findstr /B /C:"Merge" /C:"Revert" /C:"fixup!" /C:"squash!" >nul
618if %ERRORLEVEL% equ 0 exit /b 0
619
620rem Find repository root and check if Cascade is initialized
621for /f "tokens=*" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i
622if "%REPO_ROOT%"=="" set REPO_ROOT=.
623if not exist "%REPO_ROOT%\.cascade" exit /b 0
624
625rem Basic commit message validation
626echo %COMMIT_MSG% | findstr /R "^..........*" >nul
627if %ERRORLEVEL% neq 0 (
628 echo ❌ Commit message too short (minimum 10 characters)
629 echo 💡 Write a descriptive commit message for better stack management
630 exit /b 1
631)
632
633rem Check for very long messages (approximate check in batch)
634echo %COMMIT_MSG% | findstr /R "^..................................................................................*" >nul
635if %ERRORLEVEL% equ 0 (
636 echo ⚠️ Warning: Commit message longer than 72 characters
637 echo 💡 Consider keeping the first line short for better readability
638)
639
640rem Check for conventional commit format (optional)
641echo %COMMIT_MSG% | findstr /R "^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)" >nul
642if %ERRORLEVEL% neq 0 (
643 echo 💡 Consider using conventional commit format:
644 echo feat: add new feature
645 echo fix: resolve bug
646 echo docs: update documentation
647 echo etc.
648)
649
650echo ✅ Commit message validation passed
651"#.to_string()
652 }
653
654 #[cfg(not(windows))]
655 {
656 r#"#!/bin/sh
657# Cascade CLI Hook - Commit Message
658# Validates commit message format
659
660set -e
661
662COMMIT_MSG_FILE="$1"
663COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
664
665# Skip validation for merge commits, fixup commits, etc.
666if echo "$COMMIT_MSG" | grep -E "^(Merge|Revert|fixup!|squash!)" > /dev/null; then
667 exit 0
668fi
669
670# Find repository root and check if Cascade is initialized
671REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")
672if [ ! -d "$REPO_ROOT/.cascade" ]; then
673 exit 0
674fi
675
676# Basic commit message validation
677if [ ${#COMMIT_MSG} -lt 10 ]; then
678 echo "❌ Commit message too short (minimum 10 characters)"
679 echo "💡 Write a descriptive commit message for better stack management"
680 exit 1
681fi
682
683if [ ${#COMMIT_MSG} -gt 72 ]; then
684 echo "⚠️ Warning: Commit message longer than 72 characters"
685 echo "💡 Consider keeping the first line short for better readability"
686fi
687
688# Check for conventional commit format (optional)
689if ! echo "$COMMIT_MSG" | grep -E "^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)(\(.+\))?: .+" > /dev/null; then
690 echo "💡 Consider using conventional commit format:"
691 echo " feat: add new feature"
692 echo " fix: resolve bug"
693 echo " docs: update documentation"
694 echo " etc."
695fi
696
697echo "✅ Commit message validation passed"
698"#.to_string()
699 }
700 }
701
702 #[allow(clippy::uninlined_format_args)]
703 fn generate_pre_commit_hook(&self, cascade_cli: &str) -> String {
704 #[cfg(windows)]
705 {
706 format!(
707 "@echo off\n\
708 rem Cascade CLI Hook - Pre Commit\n\
709 rem Smart edit mode guidance for better UX\n\n\
710 rem Check if Cascade is initialized\n\
711 for /f \\\"tokens=*\\\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
712 if \\\"%REPO_ROOT%\\\"==\\\"\\\" set REPO_ROOT=.\n\
713 if not exist \\\"%REPO_ROOT%\\.cascade\\\" exit /b 0\n\n\
714 rem Check if we're in edit mode\n\
715 \\\"{0}\\\" entry status --quiet >nul 2>&1\n\
716 if %ERRORLEVEL% equ 0 (\n\
717 echo ⚠️ You're in EDIT MODE for a stack entry!\n\
718 echo.\n\
719 echo Choose your action:\n\
720 echo 🔄 [A]mend: Modify the current entry\n\
721 echo ➕ [N]ew: Create new entry on top ^(current behavior^)\n\
722 echo ❌ [C]ancel: Stop and think about it\n\
723 echo.\n\
724 set /p choice=\\\"Your choice (A/n/c): \\\"\n\
725 \n\
726 if /i \\\"%choice%\\\"==\\\"A\\\" (\n\
727 echo ✅ Running: git commit --amend\n\
728 git commit --amend\n\
729 if %ERRORLEVEL% equ 0 (\n\
730 echo 💡 Entry updated! Run 'ca sync' to update PRs\n\
731 )\n\
732 exit /b %ERRORLEVEL%\n\
733 ) else if /i \\\"%choice%\\\"==\\\"C\\\" (\n\
734 echo ❌ Commit cancelled\n\
735 exit /b 1\n\
736 ) else (\n\
737 echo ➕ Creating new stack entry...\n\
738 rem Let the commit proceed normally\n\
739 exit /b 0\n\
740 )\n\
741 )\n\n\
742 rem Not in edit mode, proceed normally\n\
743 exit /b 0\n",
744 cascade_cli
745 )
746 }
747
748 #[cfg(not(windows))]
749 {
750 format!(
751 "#!/bin/sh\n\
752 # Cascade CLI Hook - Pre Commit\n\
753 # Smart edit mode guidance for better UX\n\n\
754 set -e\n\n\
755 # Check if Cascade is initialized\n\
756 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \\\".\\\")\n\
757 if [ ! -d \\\"$REPO_ROOT/.cascade\\\" ]; then\n\
758 exit 0\n\
759 fi\n\n\
760 # Check if we're in edit mode\n\
761 if \\\"{0}\\\" entry status --quiet >/dev/null 2>&1; then\n\
762 echo \\\"⚠️ You're in EDIT MODE for a stack entry!\\\"\n\
763 echo \\\"\\\"\n\
764 echo \\\"Choose your action:\\\"\n\
765 echo \\\" 🔄 [A]mend: Modify the current entry\\\"\n\
766 echo \\\" ➕ [N]ew: Create new entry on top (current behavior)\\\"\n\
767 echo \\\" ❌ [C]ancel: Stop and think about it\\\"\n\
768 echo \\\"\\\"\n\
769 \n\
770 # Read user choice with default to 'new'\n\
771 read -p \\\"Your choice (A/n/c): \\\" choice\n\
772 \n\
773 case \\\"$choice\\\" in\n\
774 [Aa])\n\
775 echo \\\"✅ Running: git commit --amend\\\"\n\
776 # Temporarily disable hooks to avoid recursion\n\
777 git -c core.hooksPath=/dev/null commit --amend\n\
778 if [ $? -eq 0 ]; then\n\
779 echo \\\"💡 Entry updated! Run 'ca sync' to update PRs\\\"\n\
780 fi\n\
781 exit $?\n\
782 ;;\n\
783 [Cc])\n\
784 echo \\\"❌ Commit cancelled\\\"\n\
785 exit 1\n\
786 ;;\n\
787 *)\n\
788 echo \\\"➕ Creating new stack entry...\\\"\n\
789 # Let the commit proceed normally\n\
790 exit 0\n\
791 ;;\n\
792 esac\n\
793 fi\n\n\
794 # Not in edit mode, proceed normally\n\
795 exit 0\n",
796 cascade_cli
797 )
798 }
799 }
800
801 fn generate_prepare_commit_msg_hook(&self, cascade_cli: &str) -> String {
802 #[cfg(windows)]
803 {
804 format!(
805 "@echo off\n\
806 rem Cascade CLI Hook - Prepare Commit Message\n\
807 rem Adds stack context to commit messages\n\n\
808 set COMMIT_MSG_FILE=%1\n\
809 set COMMIT_SOURCE=%2\n\
810 set COMMIT_SHA=%3\n\n\
811 rem Only modify message if it's a regular commit (not merge, template, etc.)\n\
812 if not \"%COMMIT_SOURCE%\"==\"\" if not \"%COMMIT_SOURCE%\"==\"message\" exit /b 0\n\n\
813 rem Find repository root and check if Cascade is initialized\n\
814 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
815 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
816 if not exist \"%REPO_ROOT%\\.cascade\" exit /b 0\n\n\
817 rem Check if in edit mode first\n\
818 for /f \"tokens=*\" %%i in ('\"{cascade_cli}\" entry status --quiet 2^>nul') do set EDIT_STATUS=%%i\n\
819 if \"%EDIT_STATUS%\"==\"\" set EDIT_STATUS=inactive\n\n\
820 if not \"%EDIT_STATUS%\"==\"inactive\" (\n\
821 rem In edit mode - provide smart guidance\n\
822 set /p CURRENT_MSG=<%COMMIT_MSG_FILE%\n\n\
823 rem Skip if message already has edit guidance\n\
824 echo !CURRENT_MSG! | findstr \"[EDIT MODE]\" >nul\n\
825 if %ERRORLEVEL% equ 0 exit /b 0\n\n\
826 rem Add edit mode guidance to commit message\n\
827 echo.\n\
828 echo # [EDIT MODE] You're editing a stack entry\n\
829 echo #\n\
830 echo # Choose your action:\n\
831 echo # 🔄 AMEND: To modify the current entry, use:\n\
832 echo # git commit --amend\n\
833 echo #\n\
834 echo # ➕ NEW: To create a new entry on top, use:\n\
835 echo # git commit ^(this command^)\n\
836 echo #\n\
837 echo # 💡 After committing, run 'ca sync' to update PRs\n\
838 echo.\n\
839 type \"%COMMIT_MSG_FILE%\"\n\
840 ) > \"%COMMIT_MSG_FILE%.tmp\" && (\n\
841 move \"%COMMIT_MSG_FILE%.tmp\" \"%COMMIT_MSG_FILE%\"\n\
842 ) else (\n\
843 rem Regular stack mode - check for active stack\n\
844 for /f \"tokens=*\" %%i in ('\"{cascade_cli}\" stack list --active --format=name 2^>nul') do set ACTIVE_STACK=%%i\n\n\
845 if not \"%ACTIVE_STACK%\"==\"\" (\n\
846 rem Get current commit message\n\
847 set /p CURRENT_MSG=<%COMMIT_MSG_FILE%\n\n\
848 rem Skip if message already has stack context\n\
849 echo !CURRENT_MSG! | findstr \"[stack:\" >nul\n\
850 if %ERRORLEVEL% equ 0 exit /b 0\n\n\
851 rem Add stack context to commit message\n\
852 echo.\n\
853 echo # Stack: %ACTIVE_STACK%\n\
854 echo # This commit will be added to the active stack automatically.\n\
855 echo # Use 'ca stack status' to see the current stack state.\n\
856 type \"%COMMIT_MSG_FILE%\"\n\
857 ) > \"%COMMIT_MSG_FILE%.tmp\"\n\
858 move \"%COMMIT_MSG_FILE%.tmp\" \"%COMMIT_MSG_FILE%\"\n\
859 )\n"
860 )
861 }
862
863 #[cfg(not(windows))]
864 {
865 format!(
866 "#!/bin/sh\n\
867 # Cascade CLI Hook - Prepare Commit Message\n\
868 # Adds stack context to commit messages\n\n\
869 set -e\n\n\
870 COMMIT_MSG_FILE=\"$1\"\n\
871 COMMIT_SOURCE=\"$2\"\n\
872 COMMIT_SHA=\"$3\"\n\n\
873 # Only modify message if it's a regular commit (not merge, template, etc.)\n\
874 if [ \"$COMMIT_SOURCE\" != \"\" ] && [ \"$COMMIT_SOURCE\" != \"message\" ]; then\n\
875 exit 0\n\
876 fi\n\n\
877 # Find repository root and check if Cascade is initialized\n\
878 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
879 if [ ! -d \"$REPO_ROOT/.cascade\" ]; then\n\
880 exit 0\n\
881 fi\n\n\
882 # Check if in edit mode first\n\
883 EDIT_STATUS=$(\"{cascade_cli}\" entry status --quiet 2>/dev/null || echo \"inactive\")\n\
884 \n\
885 if [ \"$EDIT_STATUS\" != \"inactive\" ]; then\n\
886 # In edit mode - provide smart guidance\n\
887 CURRENT_MSG=$(cat \"$COMMIT_MSG_FILE\")\n\
888 \n\
889 # Skip if message already has edit guidance\n\
890 if echo \"$CURRENT_MSG\" | grep -q \"\\[EDIT MODE\\]\"; then\n\
891 exit 0\n\
892 fi\n\
893 \n\
894 echo \"\n\
895 # [EDIT MODE] You're editing a stack entry\n\
896 #\n\
897 # Choose your action:\n\
898 # 🔄 AMEND: To modify the current entry, use:\n\
899 # git commit --amend\n\
900 #\n\
901 # ➕ NEW: To create a new entry on top, use:\n\
902 # git commit (this command)\n\
903 #\n\
904 # 💡 After committing, run 'ca sync' to update PRs\n\
905 \n\
906 $CURRENT_MSG\" > \"$COMMIT_MSG_FILE\"\n\
907 else\n\
908 # Regular stack mode - check for active stack\n\
909 ACTIVE_STACK=$(\"{cascade_cli}\" stack list --active --format=name 2>/dev/null || echo \"\")\n\
910 \n\
911 if [ -n \"$ACTIVE_STACK\" ]; then\n\
912 # Get current commit message\n\
913 CURRENT_MSG=$(cat \"$COMMIT_MSG_FILE\")\n\
914 \n\
915 # Skip if message already has stack context\n\
916 if echo \"$CURRENT_MSG\" | grep -q \"\\[stack:\"; then\n\
917 exit 0\n\
918 fi\n\
919 \n\
920 # Add stack context to commit message\n\
921 echo \"\n\
922 # Stack: $ACTIVE_STACK\n\
923 # This commit will be added to the active stack automatically.\n\
924 # Use 'ca stack status' to see the current stack state.\n\
925 $CURRENT_MSG\" > \"$COMMIT_MSG_FILE\"\n\
926 fi\n\
927 fi\n"
928 )
929 }
930 }
931
932 pub fn detect_repository_type(&self) -> Result<RepositoryType> {
934 let output = Command::new("git")
935 .args(["remote", "get-url", "origin"])
936 .current_dir(&self.repo_path)
937 .output()
938 .map_err(|e| CascadeError::config(format!("Failed to get remote URL: {e}")))?;
939
940 if !output.status.success() {
941 return Ok(RepositoryType::Unknown);
942 }
943
944 let remote_url = String::from_utf8_lossy(&output.stdout)
945 .trim()
946 .to_lowercase();
947
948 if remote_url.contains("github.com") {
949 Ok(RepositoryType::GitHub)
950 } else if remote_url.contains("gitlab.com") || remote_url.contains("gitlab") {
951 Ok(RepositoryType::GitLab)
952 } else if remote_url.contains("dev.azure.com") || remote_url.contains("visualstudio.com") {
953 Ok(RepositoryType::AzureDevOps)
954 } else if remote_url.contains("bitbucket") {
955 Ok(RepositoryType::Bitbucket)
956 } else {
957 Ok(RepositoryType::Unknown)
958 }
959 }
960
961 pub fn detect_branch_type(&self) -> Result<BranchType> {
963 let output = Command::new("git")
964 .args(["branch", "--show-current"])
965 .current_dir(&self.repo_path)
966 .output()
967 .map_err(|e| CascadeError::config(format!("Failed to get current branch: {e}")))?;
968
969 if !output.status.success() {
970 return Ok(BranchType::Unknown);
971 }
972
973 let branch_name = String::from_utf8_lossy(&output.stdout)
974 .trim()
975 .to_lowercase();
976
977 if branch_name == "main" || branch_name == "master" || branch_name == "develop" {
978 Ok(BranchType::Main)
979 } else if !branch_name.is_empty() {
980 Ok(BranchType::Feature)
981 } else {
982 Ok(BranchType::Unknown)
983 }
984 }
985
986 pub fn validate_prerequisites(&self) -> Result<()> {
988 Output::check_start("Checking prerequisites for Cascade hooks");
989
990 let repo_type = self.detect_repository_type()?;
992 match repo_type {
993 RepositoryType::Bitbucket => {
994 Output::success("Bitbucket repository detected");
995 Output::tip("Hooks will work great with 'ca submit' and 'ca autoland' for Bitbucket integration");
996 }
997 RepositoryType::GitHub => {
998 Output::success("GitHub repository detected");
999 Output::tip("Consider setting up GitHub Actions for CI/CD integration");
1000 }
1001 RepositoryType::GitLab => {
1002 Output::success("GitLab repository detected");
1003 Output::tip("GitLab CI integration works well with Cascade stacks");
1004 }
1005 RepositoryType::AzureDevOps => {
1006 Output::success("Azure DevOps repository detected");
1007 Output::tip("Azure Pipelines can be configured to work with Cascade workflows");
1008 }
1009 RepositoryType::Unknown => {
1010 Output::info(
1011 "Unknown repository type - hooks will still work for local Git operations",
1012 );
1013 }
1014 }
1015
1016 let config_dir = crate::config::get_repo_config_dir(&self.repo_path)?;
1018 let config_path = config_dir.join("config.json");
1019 if !config_path.exists() {
1020 return Err(CascadeError::config(
1021 "🚫 Cascade not initialized!\n\n\
1022 Please run 'ca init' or 'ca setup' first to configure Cascade CLI.\n\
1023 Hooks require proper Bitbucket Server configuration.\n\n\
1024 Use --force to install anyway (not recommended)."
1025 .to_string(),
1026 ));
1027 }
1028
1029 let config = Settings::load_from_file(&config_path)?;
1031
1032 if config.bitbucket.url == "https://bitbucket.example.com"
1033 || config.bitbucket.url.contains("example.com")
1034 {
1035 return Err(CascadeError::config(
1036 "🚫 Invalid Bitbucket configuration!\n\n\
1037 Your Bitbucket URL appears to be a placeholder.\n\
1038 Please run 'ca setup' to configure a real Bitbucket Server.\n\n\
1039 Use --force to install anyway (not recommended)."
1040 .to_string(),
1041 ));
1042 }
1043
1044 if config.bitbucket.project == "PROJECT" || config.bitbucket.repo == "repo" {
1045 return Err(CascadeError::config(
1046 "🚫 Incomplete Bitbucket configuration!\n\n\
1047 Your project/repository settings appear to be placeholders.\n\
1048 Please run 'ca setup' to complete configuration.\n\n\
1049 Use --force to install anyway (not recommended)."
1050 .to_string(),
1051 ));
1052 }
1053
1054 Output::success("Prerequisites validation passed");
1055 Ok(())
1056 }
1057
1058 pub fn validate_branch_suitability(&self) -> Result<()> {
1060 let branch_type = self.detect_branch_type()?;
1061
1062 match branch_type {
1063 BranchType::Main => {
1064 return Err(CascadeError::config(
1065 "🚫 Currently on main/master branch!\n\n\
1066 Cascade hooks are designed for feature branch development.\n\
1067 Working directly on main/master with stacked diffs can:\n\
1068 • Complicate the commit history\n\
1069 • Interfere with team collaboration\n\
1070 • Break CI/CD workflows\n\n\
1071 💡 Recommended workflow:\n\
1072 1. Create a feature branch: git checkout -b feature/my-feature\n\
1073 2. Install hooks: ca hooks install\n\
1074 3. Develop with stacked commits (auto-added with hooks)\n\
1075 4. Push & submit: ca push && ca submit (all by default)\n\
1076 5. Auto-land when ready: ca autoland\n\n\
1077 Use --force to install anyway (not recommended)."
1078 .to_string(),
1079 ));
1080 }
1081 BranchType::Feature => {
1082 Output::success("Feature branch detected - suitable for stacked development");
1083 }
1084 BranchType::Unknown => {
1085 Output::warning("Unknown branch type - proceeding with caution");
1086 }
1087 }
1088
1089 Ok(())
1090 }
1091
1092 pub fn confirm_installation(&self) -> Result<()> {
1094 Output::section("Hook Installation Summary");
1095
1096 let hooks = vec![
1097 HookType::PostCommit,
1098 HookType::PrePush,
1099 HookType::CommitMsg,
1100 HookType::PrepareCommitMsg,
1101 ];
1102
1103 for hook in &hooks {
1104 Output::sub_item(format!("{}: {}", hook.filename(), hook.description()));
1105 }
1106
1107 println!();
1108 Output::section("These hooks will automatically");
1109 Output::bullet("Add commits to your active stack");
1110 Output::bullet("Validate commit messages");
1111 Output::bullet("Prevent force pushes that break stack integrity");
1112 Output::bullet("Add stack context to commit messages");
1113
1114 println!();
1115 Output::section("With hooks + new defaults, your workflow becomes");
1116 Output::sub_item("git commit → Auto-added to stack");
1117 Output::sub_item("ca push → Pushes all by default");
1118 Output::sub_item("ca submit → Submits all by default");
1119 Output::sub_item("ca autoland → Auto-merges when ready");
1120
1121 use std::io::{self, Write};
1122 print!("\nInstall Cascade hooks? [Y/n]: ");
1123 io::stdout().flush().unwrap();
1124
1125 let mut input = String::new();
1126 io::stdin().read_line(&mut input).unwrap();
1127 let input = input.trim().to_lowercase();
1128
1129 if input.is_empty() || input == "y" || input == "yes" {
1130 Output::success("Proceeding with installation");
1131 Ok(())
1132 } else {
1133 Err(CascadeError::config(
1134 "Installation cancelled by user".to_string(),
1135 ))
1136 }
1137 }
1138}
1139
1140pub async fn install() -> Result<()> {
1142 install_with_options(false, false, false, false).await
1143}
1144
1145pub async fn install_essential() -> Result<()> {
1146 let current_dir = env::current_dir()
1147 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1148
1149 let repo_root = find_repository_root(¤t_dir)
1150 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1151
1152 let hooks_manager = HooksManager::new(&repo_root)?;
1153 hooks_manager.install_essential()
1154}
1155
1156pub async fn install_with_options(
1157 skip_checks: bool,
1158 allow_main_branch: bool,
1159 yes: bool,
1160 force: bool,
1161) -> Result<()> {
1162 let current_dir = env::current_dir()
1163 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1164
1165 let repo_root = find_repository_root(¤t_dir)
1166 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1167
1168 let hooks_manager = HooksManager::new(&repo_root)?;
1169
1170 let options = InstallOptions {
1171 check_prerequisites: !skip_checks,
1172 feature_branches_only: !allow_main_branch,
1173 confirm: !yes,
1174 force,
1175 };
1176
1177 hooks_manager.install_with_options(&options)
1178}
1179
1180pub async fn uninstall() -> Result<()> {
1181 let current_dir = env::current_dir()
1182 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1183
1184 let repo_root = find_repository_root(¤t_dir)
1185 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1186
1187 let hooks_manager = HooksManager::new(&repo_root)?;
1188 hooks_manager.uninstall_all()
1189}
1190
1191pub async fn status() -> Result<()> {
1192 let current_dir = env::current_dir()
1193 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1194
1195 let repo_root = find_repository_root(¤t_dir)
1196 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1197
1198 let hooks_manager = HooksManager::new(&repo_root)?;
1199 hooks_manager.list_installed_hooks()
1200}
1201
1202pub async fn install_hook(hook_name: &str) -> Result<()> {
1203 install_hook_with_options(hook_name, false, false).await
1204}
1205
1206pub async fn install_hook_with_options(
1207 hook_name: &str,
1208 skip_checks: bool,
1209 force: bool,
1210) -> Result<()> {
1211 let current_dir = env::current_dir()
1212 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1213
1214 let repo_root = find_repository_root(¤t_dir)
1215 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1216
1217 let hooks_manager = HooksManager::new(&repo_root)?;
1218
1219 let hook_type = match hook_name {
1220 "post-commit" => HookType::PostCommit,
1221 "pre-push" => HookType::PrePush,
1222 "commit-msg" => HookType::CommitMsg,
1223 "pre-commit" => HookType::PreCommit,
1224 "prepare-commit-msg" => HookType::PrepareCommitMsg,
1225 _ => {
1226 return Err(CascadeError::config(format!(
1227 "Unknown hook type: {hook_name}"
1228 )))
1229 }
1230 };
1231
1232 if !skip_checks && !force {
1234 hooks_manager.validate_prerequisites()?;
1235 }
1236
1237 hooks_manager.install_hook(&hook_type)
1238}
1239
1240pub async fn uninstall_hook(hook_name: &str) -> Result<()> {
1241 let current_dir = env::current_dir()
1242 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
1243
1244 let repo_root = find_repository_root(¤t_dir)
1245 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
1246
1247 let hooks_manager = HooksManager::new(&repo_root)?;
1248
1249 let hook_type = match hook_name {
1250 "post-commit" => HookType::PostCommit,
1251 "pre-push" => HookType::PrePush,
1252 "commit-msg" => HookType::CommitMsg,
1253 "pre-commit" => HookType::PreCommit,
1254 "prepare-commit-msg" => HookType::PrepareCommitMsg,
1255 _ => {
1256 return Err(CascadeError::config(format!(
1257 "Unknown hook type: {hook_name}"
1258 )))
1259 }
1260 };
1261
1262 hooks_manager.uninstall_hook(&hook_type)
1263}
1264
1265#[cfg(test)]
1266mod tests {
1267 use super::*;
1268 use std::process::Command;
1269 use tempfile::TempDir;
1270
1271 fn create_test_repo() -> (TempDir, std::path::PathBuf) {
1272 let temp_dir = TempDir::new().unwrap();
1273 let repo_path = temp_dir.path().to_path_buf();
1274
1275 Command::new("git")
1277 .args(["init"])
1278 .current_dir(&repo_path)
1279 .output()
1280 .unwrap();
1281 Command::new("git")
1282 .args(["config", "user.name", "Test"])
1283 .current_dir(&repo_path)
1284 .output()
1285 .unwrap();
1286 Command::new("git")
1287 .args(["config", "user.email", "test@test.com"])
1288 .current_dir(&repo_path)
1289 .output()
1290 .unwrap();
1291
1292 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1294 Command::new("git")
1295 .args(["add", "."])
1296 .current_dir(&repo_path)
1297 .output()
1298 .unwrap();
1299 Command::new("git")
1300 .args(["commit", "-m", "Initial"])
1301 .current_dir(&repo_path)
1302 .output()
1303 .unwrap();
1304
1305 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
1307 .unwrap();
1308
1309 (temp_dir, repo_path)
1310 }
1311
1312 #[test]
1313 fn test_hooks_manager_creation() {
1314 let (_temp_dir, repo_path) = create_test_repo();
1315 let _manager = HooksManager::new(&repo_path).unwrap();
1316
1317 assert_eq!(_manager.repo_path, repo_path);
1318 assert_eq!(_manager.hooks_dir, repo_path.join(".git/hooks"));
1320 }
1321
1322 #[test]
1323 fn test_hooks_manager_custom_hooks_path() {
1324 let (_temp_dir, repo_path) = create_test_repo();
1325
1326 Command::new("git")
1328 .args(["config", "core.hooksPath", "custom-hooks"])
1329 .current_dir(&repo_path)
1330 .output()
1331 .unwrap();
1332
1333 let custom_hooks_dir = repo_path.join("custom-hooks");
1335 std::fs::create_dir_all(&custom_hooks_dir).unwrap();
1336
1337 let _manager = HooksManager::new(&repo_path).unwrap();
1338
1339 assert_eq!(_manager.repo_path, repo_path);
1340 assert_eq!(_manager.hooks_dir, custom_hooks_dir);
1342 }
1343
1344 #[test]
1345 fn test_hook_chaining_with_existing_hooks() {
1346 let (_temp_dir, repo_path) = create_test_repo();
1347 let manager = HooksManager::new(&repo_path).unwrap();
1348
1349 let hook_type = HookType::PreCommit;
1350 let hook_path = repo_path.join(".git/hooks").join(hook_type.filename());
1351
1352 let existing_hook_content = "#!/bin/bash\n# Project pre-commit hook\n./scripts/lint.sh\n";
1354 std::fs::write(&hook_path, existing_hook_content).unwrap();
1355 crate::utils::platform::make_executable(&hook_path).unwrap();
1356
1357 let result = manager.install_hook(&hook_type);
1359 assert!(result.is_ok());
1360
1361 let final_content = std::fs::read_to_string(&hook_path).unwrap();
1363
1364 assert!(final_content.contains("# Project pre-commit hook"));
1366 assert!(final_content.contains("./scripts/lint.sh"));
1367 assert!(final_content.contains("=== CASCADE CLI HOOKS START ==="));
1368 assert!(final_content.contains("=== CASCADE CLI HOOKS END ==="));
1369
1370 let uninstall_result = manager.uninstall_hook(&hook_type);
1372 assert!(uninstall_result.is_ok());
1373
1374 let after_uninstall = std::fs::read_to_string(&hook_path).unwrap();
1376
1377 assert!(after_uninstall.contains("# Project pre-commit hook"));
1379 assert!(after_uninstall.contains("./scripts/lint.sh"));
1380 assert!(!after_uninstall.contains("=== CASCADE CLI HOOKS START ==="));
1382 assert!(!after_uninstall.contains("=== CASCADE CLI HOOKS END ==="));
1383 }
1384
1385 #[test]
1386 fn test_hook_installation() {
1387 let (_temp_dir, repo_path) = create_test_repo();
1388 let manager = HooksManager::new(&repo_path).unwrap();
1389
1390 let hook_type = HookType::PostCommit;
1392 let result = manager.install_hook(&hook_type);
1393 assert!(result.is_ok());
1394
1395 let hook_filename = hook_type.filename();
1397 let hook_path = repo_path.join(".git/hooks").join(&hook_filename);
1398 assert!(hook_path.exists());
1399
1400 #[cfg(unix)]
1402 {
1403 use std::os::unix::fs::PermissionsExt;
1404 let metadata = std::fs::metadata(&hook_path).unwrap();
1405 let permissions = metadata.permissions();
1406 assert!(permissions.mode() & 0o111 != 0); }
1408
1409 #[cfg(windows)]
1410 {
1411 assert!(hook_filename.ends_with(".bat"));
1413 assert!(hook_path.exists());
1414 }
1415 }
1416
1417 #[test]
1418 fn test_hook_detection() {
1419 let (_temp_dir, repo_path) = create_test_repo();
1420 let _manager = HooksManager::new(&repo_path).unwrap();
1421
1422 let post_commit_path = repo_path
1424 .join(".git/hooks")
1425 .join(HookType::PostCommit.filename());
1426 let pre_push_path = repo_path
1427 .join(".git/hooks")
1428 .join(HookType::PrePush.filename());
1429 let commit_msg_path = repo_path
1430 .join(".git/hooks")
1431 .join(HookType::CommitMsg.filename());
1432
1433 assert!(!post_commit_path.exists());
1435 assert!(!pre_push_path.exists());
1436 assert!(!commit_msg_path.exists());
1437 }
1438
1439 #[test]
1440 fn test_hook_validation() {
1441 let (_temp_dir, repo_path) = create_test_repo();
1442 let manager = HooksManager::new(&repo_path).unwrap();
1443
1444 let validation = manager.validate_prerequisites();
1446 let _ = validation; let branch_validation = manager.validate_branch_suitability();
1452 let _ = branch_validation; }
1455
1456 #[test]
1457 fn test_hook_uninstallation() {
1458 let (_temp_dir, repo_path) = create_test_repo();
1459 let manager = HooksManager::new(&repo_path).unwrap();
1460
1461 let hook_type = HookType::PostCommit;
1463 manager.install_hook(&hook_type).unwrap();
1464
1465 let hook_path = repo_path.join(".git/hooks").join(hook_type.filename());
1466 assert!(hook_path.exists());
1467
1468 let result = manager.uninstall_hook(&hook_type);
1469 assert!(result.is_ok());
1470 assert!(!hook_path.exists());
1471 }
1472
1473 #[test]
1474 fn test_hook_content_generation() {
1475 let (_temp_dir, repo_path) = create_test_repo();
1476 let manager = HooksManager::new(&repo_path).unwrap();
1477
1478 let binary_name = "cascade-cli";
1480
1481 let post_commit_content = manager.generate_post_commit_hook(binary_name);
1483 #[cfg(windows)]
1484 {
1485 assert!(post_commit_content.contains("@echo off"));
1486 assert!(post_commit_content.contains("rem Cascade CLI Hook"));
1487 }
1488 #[cfg(not(windows))]
1489 {
1490 assert!(post_commit_content.contains("#!/bin/sh"));
1491 assert!(post_commit_content.contains("# Cascade CLI Hook"));
1492 }
1493 assert!(post_commit_content.contains(binary_name));
1494
1495 let pre_push_content = manager.generate_pre_push_hook(binary_name);
1497 #[cfg(windows)]
1498 {
1499 assert!(pre_push_content.contains("@echo off"));
1500 assert!(pre_push_content.contains("rem Cascade CLI Hook"));
1501 }
1502 #[cfg(not(windows))]
1503 {
1504 assert!(pre_push_content.contains("#!/bin/sh"));
1505 assert!(pre_push_content.contains("# Cascade CLI Hook"));
1506 }
1507 assert!(pre_push_content.contains(binary_name));
1508
1509 let commit_msg_content = manager.generate_commit_msg_hook(binary_name);
1511 #[cfg(windows)]
1512 {
1513 assert!(commit_msg_content.contains("@echo off"));
1514 assert!(commit_msg_content.contains("rem Cascade CLI Hook"));
1515 }
1516 #[cfg(not(windows))]
1517 {
1518 assert!(commit_msg_content.contains("#!/bin/sh"));
1519 assert!(commit_msg_content.contains("# Cascade CLI Hook"));
1520 }
1521
1522 let prepare_commit_content = manager.generate_prepare_commit_msg_hook(binary_name);
1524 #[cfg(windows)]
1525 {
1526 assert!(prepare_commit_content.contains("@echo off"));
1527 assert!(prepare_commit_content.contains("rem Cascade CLI Hook"));
1528 }
1529 #[cfg(not(windows))]
1530 {
1531 assert!(prepare_commit_content.contains("#!/bin/sh"));
1532 assert!(prepare_commit_content.contains("# Cascade CLI Hook"));
1533 }
1534 assert!(prepare_commit_content.contains(binary_name));
1535 }
1536
1537 #[test]
1538 fn test_hook_status_reporting() {
1539 let (_temp_dir, repo_path) = create_test_repo();
1540 let manager = HooksManager::new(&repo_path).unwrap();
1541
1542 let repo_type = manager.detect_repository_type().unwrap();
1544 assert!(matches!(
1546 repo_type,
1547 RepositoryType::Bitbucket | RepositoryType::Unknown
1548 ));
1549
1550 let branch_type = manager.detect_branch_type().unwrap();
1552 assert!(matches!(
1554 branch_type,
1555 BranchType::Main | BranchType::Unknown
1556 ));
1557 }
1558
1559 #[test]
1560 fn test_force_installation() {
1561 let (_temp_dir, repo_path) = create_test_repo();
1562 let manager = HooksManager::new(&repo_path).unwrap();
1563
1564 let hook_filename = HookType::PostCommit.filename();
1566 let hook_path = repo_path.join(".git/hooks").join(&hook_filename);
1567
1568 #[cfg(windows)]
1569 let existing_content = "@echo off\necho existing hook";
1570 #[cfg(not(windows))]
1571 let existing_content = "#!/bin/sh\necho 'existing hook'";
1572
1573 std::fs::write(&hook_path, existing_content).unwrap();
1574
1575 let hook_type = HookType::PostCommit;
1577 let result = manager.install_hook(&hook_type);
1578 assert!(result.is_ok());
1579
1580 let content = std::fs::read_to_string(&hook_path).unwrap();
1582 #[cfg(windows)]
1583 {
1584 assert!(content.contains("rem Cascade CLI Hook"));
1585 }
1586 #[cfg(not(windows))]
1587 {
1588 assert!(content.contains("# Cascade CLI Hook"));
1589 }
1590 assert!(content.contains("existing hook"));
1592 assert!(content.contains("=== CASCADE CLI HOOKS START ==="));
1594 assert!(content.contains("=== CASCADE CLI HOOKS END ==="));
1595 }
1596}