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