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 PrepareCommitMsg,
64}
65
66impl HookType {
67 fn filename(&self) -> String {
68 let base_name = match self {
69 HookType::PostCommit => "post-commit",
70 HookType::PrePush => "pre-push",
71 HookType::CommitMsg => "commit-msg",
72 HookType::PrepareCommitMsg => "prepare-commit-msg",
73 };
74 format!(
75 "{}{}",
76 base_name,
77 crate::utils::platform::git_hook_extension()
78 )
79 }
80
81 fn description(&self) -> &'static str {
82 match self {
83 HookType::PostCommit => "Auto-add new commits to active stack",
84 HookType::PrePush => "Prevent force pushes and validate stack state",
85 HookType::CommitMsg => "Validate commit message format",
86 HookType::PrepareCommitMsg => "Add stack context to commit messages",
87 }
88 }
89}
90
91impl HooksManager {
92 pub fn new(repo_path: &Path) -> Result<Self> {
93 let hooks_dir = repo_path.join(".git").join("hooks");
94
95 if !hooks_dir.exists() {
96 return Err(CascadeError::config(
97 "Git hooks directory not found. Is this a Git repository?".to_string(),
98 ));
99 }
100
101 Ok(Self {
102 repo_path: repo_path.to_path_buf(),
103 hooks_dir,
104 })
105 }
106
107 pub fn install_all(&self) -> Result<()> {
109 self.install_with_options(&InstallOptions::default())
110 }
111
112 pub fn install_essential(&self) -> Result<()> {
114 println!("šŖ Installing essential Cascade Git hooks...");
115
116 let essential_hooks = vec![
117 HookType::PrePush,
118 HookType::CommitMsg,
119 HookType::PrepareCommitMsg,
120 ];
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::PrepareCommitMsg => self.generate_prepare_commit_msg_hook(&cascade_cli),
319 };
320
321 Ok(script)
322 }
323
324 fn generate_post_commit_hook(&self, cascade_cli: &str) -> String {
325 #[cfg(windows)]
326 {
327 format!(
328 "@echo off\n\
329 rem Cascade CLI Hook - Post Commit\n\
330 rem Automatically adds new commits to the active stack\n\n\
331 rem Get the commit hash and message\n\
332 for /f \"tokens=*\" %%i in ('git rev-parse HEAD') do set COMMIT_HASH=%%i\n\
333 for /f \"tokens=*\" %%i in ('git log --format=%%s -n 1 HEAD') do set COMMIT_MSG=%%i\n\n\
334 rem Find repository root and check if Cascade is initialized\n\
335 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
336 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
337 if not exist \"%REPO_ROOT%\\.cascade\" (\n\
338 echo ā¹ļø Cascade not initialized, skipping stack management\n\
339 echo š” Run 'ca init' to start using stacked diffs\n\
340 exit /b 0\n\
341 )\n\n\
342 rem Check if there's an active stack\n\
343 \"{cascade_cli}\" stack list --active >nul 2>&1\n\
344 if %ERRORLEVEL% neq 0 (\n\
345 echo ā¹ļø No active stack found, commit will not be added to any stack\n\
346 echo š” Use 'ca stack create ^<name^>' to create a stack for this commit\n\
347 exit /b 0\n\
348 )\n\n\
349 rem Add commit to active stack\n\
350 echo šŖ Adding commit to active stack...\n\
351 echo š Commit: %COMMIT_MSG%\n\
352 \"{cascade_cli}\" stack push --commit \"%COMMIT_HASH%\" --message \"%COMMIT_MSG%\"\n\
353 if %ERRORLEVEL% equ 0 (\n\
354 echo ā
Commit added to stack successfully\n\
355 echo š” Next: 'ca submit' to create PRs when ready\n\
356 ) else (\n\
357 echo ā ļø Failed to add commit to stack\n\
358 echo š” You can manually add it with: ca push --commit %COMMIT_HASH%\n\
359 )\n"
360 )
361 }
362
363 #[cfg(not(windows))]
364 {
365 format!(
366 "#!/bin/sh\n\
367 # Cascade CLI Hook - Post Commit\n\
368 # Automatically adds new commits to the active stack\n\n\
369 set -e\n\n\
370 # Get the commit hash and message\n\
371 COMMIT_HASH=$(git rev-parse HEAD)\n\
372 COMMIT_MSG=$(git log --format=%s -n 1 HEAD)\n\n\
373 # Find repository root and check if Cascade is initialized\n\
374 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
375 if [ ! -d \"$REPO_ROOT/.cascade\" ]; then\n\
376 echo \"ā¹ļø Cascade not initialized, skipping stack management\"\n\
377 echo \"š” Run 'ca init' to start using stacked diffs\"\n\
378 exit 0\n\
379 fi\n\n\
380 # Check if there's an active stack\n\
381 if ! \"{cascade_cli}\" stack list --active > /dev/null 2>&1; then\n\
382 echo \"ā¹ļø No active stack found, commit will not be added to any stack\"\n\
383 echo \"š” Use 'ca stack create <name>' to create a stack for this commit\"\n\
384 exit 0\n\
385 fi\n\n\
386 # Add commit to active stack (using specific commit targeting)\n\
387 echo \"šŖ Adding commit to active stack...\"\n\
388 echo \"š Commit: $COMMIT_MSG\"\n\
389 if \"{cascade_cli}\" stack push --commit \"$COMMIT_HASH\" --message \"$COMMIT_MSG\"; then\n\
390 echo \"ā
Commit added to stack successfully\"\n\
391 echo \"š” Next: 'ca submit' to create PRs when ready\"\n\
392 else\n\
393 echo \"ā ļø Failed to add commit to stack\"\n\
394 echo \"š” You can manually add it with: ca push --commit $COMMIT_HASH\"\n\
395 fi\n"
396 )
397 }
398 }
399
400 fn generate_pre_push_hook(&self, cascade_cli: &str) -> String {
401 #[cfg(windows)]
402 {
403 format!(
404 "@echo off\n\
405 rem Cascade CLI Hook - Pre Push\n\
406 rem Prevents force pushes and validates stack state\n\n\
407 rem Check for force push\n\
408 echo %* | findstr /C:\"--force\" /C:\"--force-with-lease\" /C:\"-f\" >nul\n\
409 if %ERRORLEVEL% equ 0 (\n\
410 echo ā Force push detected!\n\
411 echo š Cascade CLI uses stacked diffs - force pushes can break stack integrity\n\
412 echo.\n\
413 echo š” Instead of force pushing, try these streamlined commands:\n\
414 echo ⢠ca sync - Sync with remote changes ^(handles rebasing^)\n\
415 echo ⢠ca push - Push all unpushed commits ^(new default^)\n\
416 echo ⢠ca submit - Submit all entries for review ^(new default^)\n\
417 echo ⢠ca autoland - Auto-merge when approved + builds pass\n\
418 echo.\n\
419 echo šØ If you really need to force push, run:\n\
420 echo git push --force-with-lease [remote] [branch]\n\
421 echo ^(But consider if this will affect other stack entries^)\n\
422 exit /b 1\n\
423 )\n\n\
424 rem Find repository root and check if Cascade is initialized\n\
425 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
426 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
427 if not exist \"%REPO_ROOT%\\.cascade\" (\n\
428 echo ā¹ļø Cascade not initialized, allowing push\n\
429 exit /b 0\n\
430 )\n\n\
431 rem Validate stack state\n\
432 echo šŖ Validating stack state before push...\n\
433 \"{cascade_cli}\" stack validate\n\
434 if %ERRORLEVEL% equ 0 (\n\
435 echo ā
Stack validation passed\n\
436 ) else (\n\
437 echo ā Stack validation failed\n\
438 echo š” Fix validation errors before pushing:\n\
439 echo ⢠ca doctor - Check overall health\n\
440 echo ⢠ca status - Check current stack status\n\
441 echo ⢠ca sync - Sync with remote and rebase if needed\n\
442 exit /b 1\n\
443 )\n\n\
444 echo ā
Pre-push validation complete\n"
445 )
446 }
447
448 #[cfg(not(windows))]
449 {
450 format!(
451 "#!/bin/sh\n\
452 # Cascade CLI Hook - Pre Push\n\
453 # Prevents force pushes and validates stack state\n\n\
454 set -e\n\n\
455 # Check for force push\n\
456 if echo \"$*\" | grep -q -- \"--force\\|--force-with-lease\\|-f\"; then\n\
457 echo \"ā Force push detected!\"\n\
458 echo \"š Cascade CLI uses stacked diffs - force pushes can break stack integrity\"\n\
459 echo \"\"\n\
460 echo \"š” Instead of force pushing, try these streamlined commands:\"\n\
461 echo \" ⢠ca sync - Sync with remote changes (handles rebasing)\"\n\
462 echo \" ⢠ca push - Push all unpushed commits (new default)\"\n\
463 echo \" ⢠ca submit - Submit all entries for review (new default)\"\n\
464 echo \" ⢠ca autoland - Auto-merge when approved + builds pass\"\n\
465 echo \"\"\n\
466 echo \"šØ If you really need to force push, run:\"\n\
467 echo \" git push --force-with-lease [remote] [branch]\"\n\
468 echo \" (But consider if this will affect other stack entries)\"\n\
469 exit 1\n\
470 fi\n\n\
471 # Find repository root and check if Cascade is initialized\n\
472 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
473 if [ ! -d \"$REPO_ROOT/.cascade\" ]; then\n\
474 echo \"ā¹ļø Cascade not initialized, allowing push\"\n\
475 exit 0\n\
476 fi\n\n\
477 # Validate stack state\n\
478 echo \"šŖ Validating stack state before push...\"\n\
479 if \"{cascade_cli}\" stack validate; then\n\
480 echo \"ā
Stack validation passed\"\n\
481 else\n\
482 echo \"ā Stack validation failed\"\n\
483 echo \"š” Fix validation errors before pushing:\"\n\
484 echo \" ⢠ca doctor - Check overall health\"\n\
485 echo \" ⢠ca status - Check current stack status\"\n\
486 echo \" ⢠ca sync - Sync with remote and rebase if needed\"\n\
487 exit 1\n\
488 fi\n\n\
489 echo \"ā
Pre-push validation complete\"\n"
490 )
491 }
492 }
493
494 fn generate_commit_msg_hook(&self, _cascade_cli: &str) -> String {
495 #[cfg(windows)]
496 {
497 r#"@echo off
498rem Cascade CLI Hook - Commit Message
499rem Validates commit message format
500
501set COMMIT_MSG_FILE=%1
502if "%COMMIT_MSG_FILE%"=="" (
503 echo ā No commit message file provided
504 exit /b 1
505)
506
507rem Read commit message (Windows batch is limited, but this covers basic cases)
508for /f "delims=" %%i in ('type "%COMMIT_MSG_FILE%"') do set COMMIT_MSG=%%i
509
510rem Skip validation for merge commits, fixup commits, etc.
511echo %COMMIT_MSG% | findstr /B /C:"Merge" /C:"Revert" /C:"fixup!" /C:"squash!" >nul
512if %ERRORLEVEL% equ 0 exit /b 0
513
514rem Find repository root and check if Cascade is initialized
515for /f "tokens=*" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i
516if "%REPO_ROOT%"=="" set REPO_ROOT=.
517if not exist "%REPO_ROOT%\.cascade" exit /b 0
518
519rem Basic commit message validation
520echo %COMMIT_MSG% | findstr /R "^..........*" >nul
521if %ERRORLEVEL% neq 0 (
522 echo ā Commit message too short (minimum 10 characters)
523 echo š” Write a descriptive commit message for better stack management
524 exit /b 1
525)
526
527rem Check for very long messages (approximate check in batch)
528echo %COMMIT_MSG% | findstr /R "^..................................................................................*" >nul
529if %ERRORLEVEL% equ 0 (
530 echo ā ļø Warning: Commit message longer than 72 characters
531 echo š” Consider keeping the first line short for better readability
532)
533
534rem Check for conventional commit format (optional)
535echo %COMMIT_MSG% | findstr /R "^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)" >nul
536if %ERRORLEVEL% neq 0 (
537 echo š” Consider using conventional commit format:
538 echo feat: add new feature
539 echo fix: resolve bug
540 echo docs: update documentation
541 echo etc.
542)
543
544echo ā
Commit message validation passed
545"#.to_string()
546 }
547
548 #[cfg(not(windows))]
549 {
550 r#"#!/bin/sh
551# Cascade CLI Hook - Commit Message
552# Validates commit message format
553
554set -e
555
556COMMIT_MSG_FILE="$1"
557COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
558
559# Skip validation for merge commits, fixup commits, etc.
560if echo "$COMMIT_MSG" | grep -E "^(Merge|Revert|fixup!|squash!)" > /dev/null; then
561 exit 0
562fi
563
564# Find repository root and check if Cascade is initialized
565REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo ".")
566if [ ! -d "$REPO_ROOT/.cascade" ]; then
567 exit 0
568fi
569
570# Basic commit message validation
571if [ ${#COMMIT_MSG} -lt 10 ]; then
572 echo "ā Commit message too short (minimum 10 characters)"
573 echo "š” Write a descriptive commit message for better stack management"
574 exit 1
575fi
576
577if [ ${#COMMIT_MSG} -gt 72 ]; then
578 echo "ā ļø Warning: Commit message longer than 72 characters"
579 echo "š” Consider keeping the first line short for better readability"
580fi
581
582# Check for conventional commit format (optional)
583if ! echo "$COMMIT_MSG" | grep -E "^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)(\(.+\))?: .+" > /dev/null; then
584 echo "š” Consider using conventional commit format:"
585 echo " feat: add new feature"
586 echo " fix: resolve bug"
587 echo " docs: update documentation"
588 echo " etc."
589fi
590
591echo "ā
Commit message validation passed"
592"#.to_string()
593 }
594 }
595
596 fn generate_prepare_commit_msg_hook(&self, cascade_cli: &str) -> String {
597 #[cfg(windows)]
598 {
599 format!(
600 "@echo off\n\
601 rem Cascade CLI Hook - Prepare Commit Message\n\
602 rem Adds stack context to commit messages\n\n\
603 set COMMIT_MSG_FILE=%1\n\
604 set COMMIT_SOURCE=%2\n\
605 set COMMIT_SHA=%3\n\n\
606 rem Only modify message if it's a regular commit (not merge, template, etc.)\n\
607 if not \"%COMMIT_SOURCE%\"==\"\" if not \"%COMMIT_SOURCE%\"==\"message\" exit /b 0\n\n\
608 rem Find repository root and check if Cascade is initialized\n\
609 for /f \"tokens=*\" %%i in ('git rev-parse --show-toplevel 2^>nul') do set REPO_ROOT=%%i\n\
610 if \"%REPO_ROOT%\"==\"\" set REPO_ROOT=.\n\
611 if not exist \"%REPO_ROOT%\\.cascade\" exit /b 0\n\n\
612 rem Get active stack info\n\
613 for /f \"tokens=*\" %%i in ('\"{cascade_cli}\" stack list --active --format=name 2^>nul') do set ACTIVE_STACK=%%i\n\n\
614 if not \"%ACTIVE_STACK%\"==\"\" (\n\
615 rem Get current commit message\n\
616 set /p CURRENT_MSG=<%COMMIT_MSG_FILE%\n\n\
617 rem Skip if message already has stack context\n\
618 echo !CURRENT_MSG! | findstr \"[stack:\" >nul\n\
619 if %ERRORLEVEL% equ 0 exit /b 0\n\n\
620 rem Add stack context to commit message\n\
621 echo.\n\
622 echo # Stack: %ACTIVE_STACK%\n\
623 echo # This commit will be added to the active stack automatically.\n\
624 echo # Use 'ca stack status' to see the current stack state.\n\
625 type \"%COMMIT_MSG_FILE%\"\n\
626 ) > \"%COMMIT_MSG_FILE%.tmp\"\n\
627 move \"%COMMIT_MSG_FILE%.tmp\" \"%COMMIT_MSG_FILE%\"\n"
628 )
629 }
630
631 #[cfg(not(windows))]
632 {
633 format!(
634 "#!/bin/sh\n\
635 # Cascade CLI Hook - Prepare Commit Message\n\
636 # Adds stack context to commit messages\n\n\
637 set -e\n\n\
638 COMMIT_MSG_FILE=\"$1\"\n\
639 COMMIT_SOURCE=\"$2\"\n\
640 COMMIT_SHA=\"$3\"\n\n\
641 # Only modify message if it's a regular commit (not merge, template, etc.)\n\
642 if [ \"$COMMIT_SOURCE\" != \"\" ] && [ \"$COMMIT_SOURCE\" != \"message\" ]; then\n\
643 exit 0\n\
644 fi\n\n\
645 # Find repository root and check if Cascade is initialized\n\
646 REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo \".\")\n\
647 if [ ! -d \"$REPO_ROOT/.cascade\" ]; then\n\
648 exit 0\n\
649 fi\n\n\
650 # Get active stack info\n\
651 ACTIVE_STACK=$(\"{cascade_cli}\" stack list --active --format=name 2>/dev/null || echo \"\")\n\n\
652 if [ -n \"$ACTIVE_STACK\" ]; then\n\
653 # Get current commit message\n\
654 CURRENT_MSG=$(cat \"$COMMIT_MSG_FILE\")\n\
655 \n\
656 # Skip if message already has stack context\n\
657 if echo \"$CURRENT_MSG\" | grep -q \"\\[stack:\"; then\n\
658 exit 0\n\
659 fi\n\
660 \n\
661 # Add stack context to commit message\n\
662 echo \"\n\
663 # Stack: $ACTIVE_STACK\n\
664 # This commit will be added to the active stack automatically.\n\
665 # Use 'ca stack status' to see the current stack state.\n\
666 $CURRENT_MSG\" > \"$COMMIT_MSG_FILE\"\n\
667 fi\n"
668 )
669 }
670 }
671
672 pub fn detect_repository_type(&self) -> Result<RepositoryType> {
674 let output = Command::new("git")
675 .args(["remote", "get-url", "origin"])
676 .current_dir(&self.repo_path)
677 .output()
678 .map_err(|e| CascadeError::config(format!("Failed to get remote URL: {e}")))?;
679
680 if !output.status.success() {
681 return Ok(RepositoryType::Unknown);
682 }
683
684 let remote_url = String::from_utf8_lossy(&output.stdout)
685 .trim()
686 .to_lowercase();
687
688 if remote_url.contains("github.com") {
689 Ok(RepositoryType::GitHub)
690 } else if remote_url.contains("gitlab.com") || remote_url.contains("gitlab") {
691 Ok(RepositoryType::GitLab)
692 } else if remote_url.contains("dev.azure.com") || remote_url.contains("visualstudio.com") {
693 Ok(RepositoryType::AzureDevOps)
694 } else if remote_url.contains("bitbucket") {
695 Ok(RepositoryType::Bitbucket)
696 } else {
697 Ok(RepositoryType::Unknown)
698 }
699 }
700
701 pub fn detect_branch_type(&self) -> Result<BranchType> {
703 let output = Command::new("git")
704 .args(["branch", "--show-current"])
705 .current_dir(&self.repo_path)
706 .output()
707 .map_err(|e| CascadeError::config(format!("Failed to get current branch: {e}")))?;
708
709 if !output.status.success() {
710 return Ok(BranchType::Unknown);
711 }
712
713 let branch_name = String::from_utf8_lossy(&output.stdout)
714 .trim()
715 .to_lowercase();
716
717 if branch_name == "main" || branch_name == "master" || branch_name == "develop" {
718 Ok(BranchType::Main)
719 } else if !branch_name.is_empty() {
720 Ok(BranchType::Feature)
721 } else {
722 Ok(BranchType::Unknown)
723 }
724 }
725
726 pub fn validate_prerequisites(&self) -> Result<()> {
728 println!("š Checking prerequisites for Cascade hooks...");
729
730 let repo_type = self.detect_repository_type()?;
732 match repo_type {
733 RepositoryType::Bitbucket => {
734 println!("ā
Bitbucket repository detected");
735 println!("š” Hooks will work great with 'ca submit' and 'ca autoland' for Bitbucket integration");
736 }
737 RepositoryType::GitHub => {
738 println!("ā
GitHub repository detected");
739 println!("š” Consider setting up GitHub Actions for CI/CD integration");
740 }
741 RepositoryType::GitLab => {
742 println!("ā
GitLab repository detected");
743 println!("š” GitLab CI integration works well with Cascade stacks");
744 }
745 RepositoryType::AzureDevOps => {
746 println!("ā
Azure DevOps repository detected");
747 println!("š” Azure Pipelines can be configured to work with Cascade workflows");
748 }
749 RepositoryType::Unknown => {
750 println!(
751 "ā¹ļø Unknown repository type - hooks will still work for local Git operations"
752 );
753 }
754 }
755
756 let config_dir = crate::config::get_repo_config_dir(&self.repo_path)?;
758 let config_path = config_dir.join("config.json");
759 if !config_path.exists() {
760 return Err(CascadeError::config(
761 "š« Cascade not initialized!\n\n\
762 Please run 'ca init' or 'ca setup' first to configure Cascade CLI.\n\
763 Hooks require proper Bitbucket Server configuration.\n\n\
764 Use --force to install anyway (not recommended)."
765 .to_string(),
766 ));
767 }
768
769 let config = Settings::load_from_file(&config_path)?;
771
772 if config.bitbucket.url == "https://bitbucket.example.com"
773 || config.bitbucket.url.contains("example.com")
774 {
775 return Err(CascadeError::config(
776 "š« Invalid Bitbucket configuration!\n\n\
777 Your Bitbucket URL appears to be a placeholder.\n\
778 Please run 'ca setup' to configure a real Bitbucket Server.\n\n\
779 Use --force to install anyway (not recommended)."
780 .to_string(),
781 ));
782 }
783
784 if config.bitbucket.project == "PROJECT" || config.bitbucket.repo == "repo" {
785 return Err(CascadeError::config(
786 "š« Incomplete Bitbucket configuration!\n\n\
787 Your project/repository settings appear to be placeholders.\n\
788 Please run 'ca setup' to complete configuration.\n\n\
789 Use --force to install anyway (not recommended)."
790 .to_string(),
791 ));
792 }
793
794 println!("ā
Prerequisites validation passed");
795 Ok(())
796 }
797
798 pub fn validate_branch_suitability(&self) -> Result<()> {
800 let branch_type = self.detect_branch_type()?;
801
802 match branch_type {
803 BranchType::Main => {
804 return Err(CascadeError::config(
805 "š« Currently on main/master branch!\n\n\
806 Cascade hooks are designed for feature branch development.\n\
807 Working directly on main/master with stacked diffs can:\n\
808 ⢠Complicate the commit history\n\
809 ⢠Interfere with team collaboration\n\
810 ⢠Break CI/CD workflows\n\n\
811 š” Recommended workflow:\n\
812 1. Create a feature branch: git checkout -b feature/my-feature\n\
813 2. Install hooks: ca hooks install\n\
814 3. Develop with stacked commits (auto-added with hooks)\n\
815 4. Push & submit: ca push && ca submit (all by default)\n\
816 5. Auto-land when ready: ca autoland\n\n\
817 Use --force to install anyway (not recommended)."
818 .to_string(),
819 ));
820 }
821 BranchType::Feature => {
822 println!("ā
Feature branch detected - suitable for stacked development");
823 }
824 BranchType::Unknown => {
825 println!("ā ļø Unknown branch type - proceeding with caution");
826 }
827 }
828
829 Ok(())
830 }
831
832 pub fn confirm_installation(&self) -> Result<()> {
834 println!("\nš Hook Installation Summary:");
835 println!("āāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
836 println!("ā Hook ā Description ā");
837 println!("āāāāāāāāāāāāāāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā¤");
838
839 let hooks = vec![
840 HookType::PostCommit,
841 HookType::PrePush,
842 HookType::CommitMsg,
843 HookType::PrepareCommitMsg,
844 ];
845
846 for hook in &hooks {
847 println!("ā {:19} ā {:31} ā", hook.filename(), hook.description());
848 }
849 println!("āāāāāāāāāāāāāāāāāāāāāāā“āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
850
851 println!("\nš These hooks will automatically:");
852 println!("⢠Add commits to your active stack");
853 println!("⢠Validate commit messages");
854 println!("⢠Prevent force pushes that break stack integrity");
855 println!("⢠Add stack context to commit messages");
856
857 println!("\n⨠With hooks + new defaults, your workflow becomes:");
858 println!(" git commit ā Auto-added to stack");
859 println!(" ca push ā Pushes all by default");
860 println!(" ca submit ā Submits all by default");
861 println!(" ca autoland ā Auto-merges when ready");
862
863 use std::io::{self, Write};
864 print!("\nā Install Cascade hooks? [Y/n]: ");
865 io::stdout().flush().unwrap();
866
867 let mut input = String::new();
868 io::stdin().read_line(&mut input).unwrap();
869 let input = input.trim().to_lowercase();
870
871 if input.is_empty() || input == "y" || input == "yes" {
872 println!("ā
Proceeding with installation");
873 Ok(())
874 } else {
875 Err(CascadeError::config(
876 "Installation cancelled by user".to_string(),
877 ))
878 }
879 }
880}
881
882pub async fn install() -> Result<()> {
884 install_with_options(false, false, false, false).await
885}
886
887pub async fn install_essential() -> Result<()> {
888 let current_dir = env::current_dir()
889 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
890
891 let repo_root = find_repository_root(¤t_dir)
892 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
893
894 let hooks_manager = HooksManager::new(&repo_root)?;
895 hooks_manager.install_essential()
896}
897
898pub async fn install_with_options(
899 skip_checks: bool,
900 allow_main_branch: bool,
901 yes: bool,
902 force: bool,
903) -> Result<()> {
904 let current_dir = env::current_dir()
905 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
906
907 let repo_root = find_repository_root(¤t_dir)
908 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
909
910 let hooks_manager = HooksManager::new(&repo_root)?;
911
912 let options = InstallOptions {
913 check_prerequisites: !skip_checks,
914 feature_branches_only: !allow_main_branch,
915 confirm: !yes,
916 force,
917 };
918
919 hooks_manager.install_with_options(&options)
920}
921
922pub async fn uninstall() -> Result<()> {
923 let current_dir = env::current_dir()
924 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
925
926 let repo_root = find_repository_root(¤t_dir)
927 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
928
929 let hooks_manager = HooksManager::new(&repo_root)?;
930 hooks_manager.uninstall_all()
931}
932
933pub async fn status() -> Result<()> {
934 let current_dir = env::current_dir()
935 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
936
937 let repo_root = find_repository_root(¤t_dir)
938 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
939
940 let hooks_manager = HooksManager::new(&repo_root)?;
941 hooks_manager.list_installed_hooks()
942}
943
944pub async fn install_hook(hook_name: &str) -> Result<()> {
945 install_hook_with_options(hook_name, false, false).await
946}
947
948pub async fn install_hook_with_options(
949 hook_name: &str,
950 skip_checks: bool,
951 force: bool,
952) -> Result<()> {
953 let current_dir = env::current_dir()
954 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
955
956 let repo_root = find_repository_root(¤t_dir)
957 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
958
959 let hooks_manager = HooksManager::new(&repo_root)?;
960
961 let hook_type = match hook_name {
962 "post-commit" => HookType::PostCommit,
963 "pre-push" => HookType::PrePush,
964 "commit-msg" => HookType::CommitMsg,
965 "prepare-commit-msg" => HookType::PrepareCommitMsg,
966 _ => {
967 return Err(CascadeError::config(format!(
968 "Unknown hook type: {hook_name}"
969 )))
970 }
971 };
972
973 if !skip_checks && !force {
975 hooks_manager.validate_prerequisites()?;
976 }
977
978 hooks_manager.install_hook(&hook_type)
979}
980
981pub async fn uninstall_hook(hook_name: &str) -> Result<()> {
982 let current_dir = env::current_dir()
983 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
984
985 let repo_root = find_repository_root(¤t_dir)
986 .map_err(|e| CascadeError::config(format!("Could not find git repository: {e}")))?;
987
988 let hooks_manager = HooksManager::new(&repo_root)?;
989
990 let hook_type = match hook_name {
991 "post-commit" => HookType::PostCommit,
992 "pre-push" => HookType::PrePush,
993 "commit-msg" => HookType::CommitMsg,
994 "prepare-commit-msg" => HookType::PrepareCommitMsg,
995 _ => {
996 return Err(CascadeError::config(format!(
997 "Unknown hook type: {hook_name}"
998 )))
999 }
1000 };
1001
1002 hooks_manager.uninstall_hook(&hook_type)
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007 use super::*;
1008 use std::process::Command;
1009 use tempfile::TempDir;
1010
1011 fn create_test_repo() -> (TempDir, std::path::PathBuf) {
1012 let temp_dir = TempDir::new().unwrap();
1013 let repo_path = temp_dir.path().to_path_buf();
1014
1015 Command::new("git")
1017 .args(["init"])
1018 .current_dir(&repo_path)
1019 .output()
1020 .unwrap();
1021 Command::new("git")
1022 .args(["config", "user.name", "Test"])
1023 .current_dir(&repo_path)
1024 .output()
1025 .unwrap();
1026 Command::new("git")
1027 .args(["config", "user.email", "test@test.com"])
1028 .current_dir(&repo_path)
1029 .output()
1030 .unwrap();
1031
1032 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
1034 Command::new("git")
1035 .args(["add", "."])
1036 .current_dir(&repo_path)
1037 .output()
1038 .unwrap();
1039 Command::new("git")
1040 .args(["commit", "-m", "Initial"])
1041 .current_dir(&repo_path)
1042 .output()
1043 .unwrap();
1044
1045 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
1047 .unwrap();
1048
1049 (temp_dir, repo_path)
1050 }
1051
1052 #[test]
1053 fn test_hooks_manager_creation() {
1054 let (_temp_dir, repo_path) = create_test_repo();
1055 let _manager = HooksManager::new(&repo_path).unwrap();
1056
1057 assert_eq!(_manager.repo_path, repo_path);
1058 assert_eq!(_manager.hooks_dir, repo_path.join(".git/hooks"));
1059 }
1060
1061 #[test]
1062 fn test_hook_installation() {
1063 let (_temp_dir, repo_path) = create_test_repo();
1064 let manager = HooksManager::new(&repo_path).unwrap();
1065
1066 let hook_type = HookType::PostCommit;
1068 let result = manager.install_hook(&hook_type);
1069 assert!(result.is_ok());
1070
1071 let hook_filename = hook_type.filename();
1073 let hook_path = repo_path.join(".git/hooks").join(&hook_filename);
1074 assert!(hook_path.exists());
1075
1076 #[cfg(unix)]
1078 {
1079 use std::os::unix::fs::PermissionsExt;
1080 let metadata = std::fs::metadata(&hook_path).unwrap();
1081 let permissions = metadata.permissions();
1082 assert!(permissions.mode() & 0o111 != 0); }
1084
1085 #[cfg(windows)]
1086 {
1087 assert!(hook_filename.ends_with(".bat"));
1089 assert!(hook_path.exists());
1090 }
1091 }
1092
1093 #[test]
1094 fn test_hook_detection() {
1095 let (_temp_dir, repo_path) = create_test_repo();
1096 let _manager = HooksManager::new(&repo_path).unwrap();
1097
1098 let post_commit_path = repo_path
1100 .join(".git/hooks")
1101 .join(HookType::PostCommit.filename());
1102 let pre_push_path = repo_path
1103 .join(".git/hooks")
1104 .join(HookType::PrePush.filename());
1105 let commit_msg_path = repo_path
1106 .join(".git/hooks")
1107 .join(HookType::CommitMsg.filename());
1108
1109 assert!(!post_commit_path.exists());
1111 assert!(!pre_push_path.exists());
1112 assert!(!commit_msg_path.exists());
1113 }
1114
1115 #[test]
1116 fn test_hook_validation() {
1117 let (_temp_dir, repo_path) = create_test_repo();
1118 let manager = HooksManager::new(&repo_path).unwrap();
1119
1120 let validation = manager.validate_prerequisites();
1122 let _ = validation; let branch_validation = manager.validate_branch_suitability();
1128 let _ = branch_validation; }
1131
1132 #[test]
1133 fn test_hook_uninstallation() {
1134 let (_temp_dir, repo_path) = create_test_repo();
1135 let manager = HooksManager::new(&repo_path).unwrap();
1136
1137 let hook_type = HookType::PostCommit;
1139 manager.install_hook(&hook_type).unwrap();
1140
1141 let hook_path = repo_path.join(".git/hooks").join(hook_type.filename());
1142 assert!(hook_path.exists());
1143
1144 let result = manager.uninstall_hook(&hook_type);
1145 assert!(result.is_ok());
1146 assert!(!hook_path.exists());
1147 }
1148
1149 #[test]
1150 fn test_hook_content_generation() {
1151 let (_temp_dir, repo_path) = create_test_repo();
1152 let manager = HooksManager::new(&repo_path).unwrap();
1153
1154 let binary_name = "cascade-cli";
1156
1157 let post_commit_content = manager.generate_post_commit_hook(binary_name);
1159 #[cfg(windows)]
1160 {
1161 assert!(post_commit_content.contains("@echo off"));
1162 assert!(post_commit_content.contains("rem Cascade CLI Hook"));
1163 }
1164 #[cfg(not(windows))]
1165 {
1166 assert!(post_commit_content.contains("#!/bin/sh"));
1167 assert!(post_commit_content.contains("# Cascade CLI Hook"));
1168 }
1169 assert!(post_commit_content.contains(binary_name));
1170
1171 let pre_push_content = manager.generate_pre_push_hook(binary_name);
1173 #[cfg(windows)]
1174 {
1175 assert!(pre_push_content.contains("@echo off"));
1176 assert!(pre_push_content.contains("rem Cascade CLI Hook"));
1177 }
1178 #[cfg(not(windows))]
1179 {
1180 assert!(pre_push_content.contains("#!/bin/sh"));
1181 assert!(pre_push_content.contains("# Cascade CLI Hook"));
1182 }
1183 assert!(pre_push_content.contains(binary_name));
1184
1185 let commit_msg_content = manager.generate_commit_msg_hook(binary_name);
1187 #[cfg(windows)]
1188 {
1189 assert!(commit_msg_content.contains("@echo off"));
1190 assert!(commit_msg_content.contains("rem Cascade CLI Hook"));
1191 }
1192 #[cfg(not(windows))]
1193 {
1194 assert!(commit_msg_content.contains("#!/bin/sh"));
1195 assert!(commit_msg_content.contains("# Cascade CLI Hook"));
1196 }
1197
1198 let prepare_commit_content = manager.generate_prepare_commit_msg_hook(binary_name);
1200 #[cfg(windows)]
1201 {
1202 assert!(prepare_commit_content.contains("@echo off"));
1203 assert!(prepare_commit_content.contains("rem Cascade CLI Hook"));
1204 }
1205 #[cfg(not(windows))]
1206 {
1207 assert!(prepare_commit_content.contains("#!/bin/sh"));
1208 assert!(prepare_commit_content.contains("# Cascade CLI Hook"));
1209 }
1210 assert!(prepare_commit_content.contains(binary_name));
1211 }
1212
1213 #[test]
1214 fn test_hook_status_reporting() {
1215 let (_temp_dir, repo_path) = create_test_repo();
1216 let manager = HooksManager::new(&repo_path).unwrap();
1217
1218 let repo_type = manager.detect_repository_type().unwrap();
1220 assert!(matches!(
1222 repo_type,
1223 RepositoryType::Bitbucket | RepositoryType::Unknown
1224 ));
1225
1226 let branch_type = manager.detect_branch_type().unwrap();
1228 assert!(matches!(
1230 branch_type,
1231 BranchType::Main | BranchType::Unknown
1232 ));
1233 }
1234
1235 #[test]
1236 fn test_force_installation() {
1237 let (_temp_dir, repo_path) = create_test_repo();
1238 let manager = HooksManager::new(&repo_path).unwrap();
1239
1240 let hook_filename = HookType::PostCommit.filename();
1242 let hook_path = repo_path.join(".git/hooks").join(&hook_filename);
1243
1244 #[cfg(windows)]
1245 let existing_content = "@echo off\necho existing hook";
1246 #[cfg(not(windows))]
1247 let existing_content = "#!/bin/sh\necho 'existing hook'";
1248
1249 std::fs::write(&hook_path, existing_content).unwrap();
1250
1251 let hook_type = HookType::PostCommit;
1253 let result = manager.install_hook(&hook_type);
1254 assert!(result.is_ok());
1255
1256 let content = std::fs::read_to_string(&hook_path).unwrap();
1258 #[cfg(windows)]
1259 {
1260 assert!(content.contains("rem Cascade CLI Hook"));
1261 }
1262 #[cfg(not(windows))]
1263 {
1264 assert!(content.contains("# Cascade CLI Hook"));
1265 }
1266 assert!(!content.contains("existing hook"));
1267
1268 let backup_path = hook_path.with_extension("cascade-backup");
1270 assert!(backup_path.exists());
1271 }
1272}