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