cascade_cli/cli/commands/
hooks.rs1use crate::config::Settings;
2use crate::errors::{CascadeError, Result};
3use std::env;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8#[derive(Debug, Clone, PartialEq)]
10pub enum RepositoryType {
11 Bitbucket,
12 GitHub,
13 GitLab,
14 AzureDevOps,
15 Unknown,
16}
17
18#[derive(Debug, Clone, PartialEq)]
20pub enum BranchType {
21 Main, Feature, Unknown,
24}
25
26#[derive(Debug, Clone)]
28pub struct InstallOptions {
29 pub check_prerequisites: bool,
30 pub feature_branches_only: bool,
31 pub confirm: bool,
32 pub force: bool,
33}
34
35impl Default for InstallOptions {
36 fn default() -> Self {
37 Self {
38 check_prerequisites: true,
39 feature_branches_only: true,
40 confirm: true,
41 force: false,
42 }
43 }
44}
45
46pub struct HooksManager {
48 repo_path: PathBuf,
49 hooks_dir: PathBuf,
50}
51
52#[derive(Debug, Clone)]
54pub enum HookType {
55 PostCommit,
57 PrePush,
59 CommitMsg,
61 PrepareCommitMsg,
63}
64
65impl HookType {
66 fn filename(&self) -> &'static str {
67 match self {
68 HookType::PostCommit => "post-commit",
69 HookType::PrePush => "pre-push",
70 HookType::CommitMsg => "commit-msg",
71 HookType::PrepareCommitMsg => "prepare-commit-msg",
72 }
73 }
74
75 fn description(&self) -> &'static str {
76 match self {
77 HookType::PostCommit => "Auto-add new commits to active stack",
78 HookType::PrePush => "Prevent force pushes and validate stack state",
79 HookType::CommitMsg => "Validate commit message format",
80 HookType::PrepareCommitMsg => "Add stack context to commit messages",
81 }
82 }
83}
84
85impl HooksManager {
86 pub fn new(repo_path: &Path) -> Result<Self> {
87 let hooks_dir = repo_path.join(".git").join("hooks");
88
89 if !hooks_dir.exists() {
90 return Err(CascadeError::config(
91 "Git hooks directory not found. Is this a Git repository?".to_string(),
92 ));
93 }
94
95 Ok(Self {
96 repo_path: repo_path.to_path_buf(),
97 hooks_dir,
98 })
99 }
100
101 pub fn install_all(&self) -> Result<()> {
103 self.install_with_options(&InstallOptions::default())
104 }
105
106 pub fn install_with_options(&self, options: &InstallOptions) -> Result<()> {
108 if options.check_prerequisites && !options.force {
109 self.validate_prerequisites()?;
110 }
111
112 if options.feature_branches_only && !options.force {
113 self.validate_branch_suitability()?;
114 }
115
116 if options.confirm && !options.force {
117 self.confirm_installation()?;
118 }
119
120 println!("šŖ Installing Cascade Git hooks...");
121
122 let hooks = vec![
123 HookType::PostCommit,
124 HookType::PrePush,
125 HookType::CommitMsg,
126 HookType::PrepareCommitMsg,
127 ];
128
129 for hook in hooks {
130 self.install_hook(&hook)?;
131 }
132
133 println!("ā
All Cascade hooks installed successfully!");
134 println!("\nš” Hooks installed:");
135 self.list_installed_hooks()?;
136
137 Ok(())
138 }
139
140 pub fn install_hook(&self, hook_type: &HookType) -> Result<()> {
142 let hook_path = self.hooks_dir.join(hook_type.filename());
143 let hook_content = self.generate_hook_script(hook_type)?;
144
145 if hook_path.exists() {
147 let backup_path = hook_path.with_extension("cascade-backup");
148 fs::copy(&hook_path, &backup_path).map_err(|e| {
149 CascadeError::config(format!("Failed to backup existing hook: {e}"))
150 })?;
151 println!("š¦ Backed up existing {} hook", hook_type.filename());
152 }
153
154 fs::write(&hook_path, hook_content)
156 .map_err(|e| CascadeError::config(format!("Failed to write hook file: {e}")))?;
157
158 #[cfg(unix)]
160 {
161 use std::os::unix::fs::PermissionsExt;
162 let mut perms = fs::metadata(&hook_path)
163 .map_err(|e| {
164 CascadeError::config(format!("Failed to get hook file metadata: {e}"))
165 })?
166 .permissions();
167 perms.set_mode(0o755);
168 fs::set_permissions(&hook_path, perms).map_err(|e| {
169 CascadeError::config(format!("Failed to make hook executable: {e}"))
170 })?;
171 }
172
173 #[cfg(windows)]
174 {
175 }
178
179 println!("ā
Installed {} hook", hook_type.filename());
180 Ok(())
181 }
182
183 pub fn uninstall_all(&self) -> Result<()> {
185 println!("šļø Removing Cascade Git hooks...");
186
187 let hooks = vec![
188 HookType::PostCommit,
189 HookType::PrePush,
190 HookType::CommitMsg,
191 HookType::PrepareCommitMsg,
192 ];
193
194 for hook in hooks {
195 self.uninstall_hook(&hook)?;
196 }
197
198 println!("ā
All Cascade hooks removed!");
199 Ok(())
200 }
201
202 pub fn uninstall_hook(&self, hook_type: &HookType) -> Result<()> {
204 let hook_path = self.hooks_dir.join(hook_type.filename());
205
206 if hook_path.exists() {
207 let content = fs::read_to_string(&hook_path)
209 .map_err(|e| CascadeError::config(format!("Failed to read hook file: {e}")))?;
210
211 if content.contains("# Cascade CLI Hook") {
212 fs::remove_file(&hook_path).map_err(|e| {
213 CascadeError::config(format!("Failed to remove hook file: {e}"))
214 })?;
215
216 let backup_path = hook_path.with_extension("cascade-backup");
218 if backup_path.exists() {
219 fs::rename(&backup_path, &hook_path).map_err(|e| {
220 CascadeError::config(format!("Failed to restore backup: {e}"))
221 })?;
222 println!("š¦ Restored original {} hook", hook_type.filename());
223 } else {
224 println!("šļø Removed {} hook", hook_type.filename());
225 }
226 } else {
227 println!(
228 "ā ļø {} hook exists but is not a Cascade hook, skipping",
229 hook_type.filename()
230 );
231 }
232 } else {
233 println!("ā¹ļø {} hook not found", hook_type.filename());
234 }
235
236 Ok(())
237 }
238
239 pub fn list_installed_hooks(&self) -> Result<()> {
241 let hooks = vec![
242 HookType::PostCommit,
243 HookType::PrePush,
244 HookType::CommitMsg,
245 HookType::PrepareCommitMsg,
246 ];
247
248 println!("\nš Git Hooks Status:");
249 println!("āāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
250 println!("ā Hook ā Status ā Description ā");
251 println!("āāāāāāāāāāāāāāāāāāāāāāā¼āāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā¤");
252
253 for hook in hooks {
254 let hook_path = self.hooks_dir.join(hook.filename());
255 let status = if hook_path.exists() {
256 let content = fs::read_to_string(&hook_path).unwrap_or_default();
257 if content.contains("# Cascade CLI Hook") {
258 "ā
Cascade"
259 } else {
260 "ā ļø Custom "
261 }
262 } else {
263 "ā Missing"
264 };
265
266 println!(
267 "ā {:19} ā {:8} ā {:31} ā",
268 hook.filename(),
269 status,
270 hook.description()
271 );
272 }
273 println!("āāāāāāāāāāāāāāāāāāāāāāā“āāāāāāāāāāā“āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
274
275 Ok(())
276 }
277
278 fn generate_hook_script(&self, hook_type: &HookType) -> Result<String> {
280 let cascade_cli = env::current_exe()
281 .map_err(|e| {
282 CascadeError::config(format!("Failed to get current executable path: {e}"))
283 })?
284 .to_string_lossy()
285 .to_string();
286
287 let script = match hook_type {
288 HookType::PostCommit => self.generate_post_commit_hook(&cascade_cli),
289 HookType::PrePush => self.generate_pre_push_hook(&cascade_cli),
290 HookType::CommitMsg => self.generate_commit_msg_hook(&cascade_cli),
291 HookType::PrepareCommitMsg => self.generate_prepare_commit_msg_hook(&cascade_cli),
292 };
293
294 Ok(script)
295 }
296
297 fn generate_post_commit_hook(&self, cascade_cli: &str) -> String {
298 format!(
299 r#"#!/bin/sh
300# Cascade CLI Hook - Post Commit
301# Automatically adds new commits to the active stack
302
303set -e
304
305# Get the commit hash and message
306COMMIT_HASH=$(git rev-parse HEAD)
307COMMIT_MSG=$(git log --format=%s -n 1 HEAD)
308
309# Check if Cascade is initialized
310if [ ! -d ".cascade" ]; then
311 echo "ā¹ļø Cascade not initialized, skipping stack management"
312 echo "š” Run 'cc init' to start using stacked diffs"
313 exit 0
314fi
315
316# Check if there's an active stack
317if ! "{cascade_cli}" stack list --active > /dev/null 2>&1; then
318 echo "ā¹ļø No active stack found, commit will not be added to any stack"
319 echo "š” Use 'cc stack create <name>' to create a stack for this commit"
320 exit 0
321fi
322
323# Add commit to active stack (using specific commit targeting)
324echo "šŖ Adding commit to active stack..."
325echo "š Commit: $COMMIT_MSG"
326if "{cascade_cli}" stack push --commit "$COMMIT_HASH" --message "$COMMIT_MSG"; then
327 echo "ā
Commit added to stack successfully"
328 echo "š” Next: 'cc submit' to create PRs when ready"
329else
330 echo "ā ļø Failed to add commit to stack"
331 echo "š” You can manually add it with: cc push --commit $COMMIT_HASH"
332fi
333"#
334 )
335 }
336
337 fn generate_pre_push_hook(&self, cascade_cli: &str) -> String {
338 format!(
339 r#"#!/bin/sh
340# Cascade CLI Hook - Pre Push
341# Prevents force pushes and validates stack state
342
343set -e
344
345# Check for force push
346if echo "$*" | grep -q -- "--force\|--force-with-lease\|-f"; then
347 echo "ā Force push detected!"
348 echo "š Cascade CLI uses stacked diffs - force pushes can break stack integrity"
349 echo ""
350 echo "š” Instead of force pushing, try these streamlined commands:"
351 echo " ⢠cc sync - Sync with remote changes (handles rebasing)"
352 echo " ⢠cc push - Push all unpushed commits (new default)"
353 echo " ⢠cc submit - Submit all entries for review (new default)"
354 echo " ⢠cc autoland - Auto-merge when approved + builds pass"
355 echo ""
356 echo "šØ If you really need to force push, run:"
357 echo " git push --force-with-lease [remote] [branch]"
358 echo " (But consider if this will affect other stack entries)"
359 exit 1
360fi
361
362# Check if Cascade is initialized
363if [ ! -d ".cascade" ]; then
364 echo "ā¹ļø Cascade not initialized, allowing push"
365 exit 0
366fi
367
368# Validate stack state
369echo "šŖ Validating stack state before push..."
370if "{cascade_cli}" stack validate; then
371 echo "ā
Stack validation passed"
372else
373 echo "ā Stack validation failed"
374 echo "š” Fix validation errors before pushing:"
375 echo " ⢠cc doctor - Check overall health"
376 echo " ⢠cc status - Check current stack status"
377 echo " ⢠cc sync - Sync with remote and rebase if needed"
378 exit 1
379fi
380
381echo "ā
Pre-push validation complete"
382"#
383 )
384 }
385
386 fn generate_commit_msg_hook(&self, _cascade_cli: &str) -> String {
387 r#"#!/bin/sh
388# Cascade CLI Hook - Commit Message
389# Validates commit message format
390
391set -e
392
393COMMIT_MSG_FILE="$1"
394COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
395
396# Skip validation for merge commits, fixup commits, etc.
397if echo "$COMMIT_MSG" | grep -E "^(Merge|Revert|fixup!|squash!)" > /dev/null; then
398 exit 0
399fi
400
401# Check if Cascade is initialized
402if [ ! -d ".cascade" ]; then
403 exit 0
404fi
405
406# Basic commit message validation
407if [ ${#COMMIT_MSG} -lt 10 ]; then
408 echo "ā Commit message too short (minimum 10 characters)"
409 echo "š” Write a descriptive commit message for better stack management"
410 exit 1
411fi
412
413if [ ${#COMMIT_MSG} -gt 72 ]; then
414 echo "ā ļø Warning: Commit message longer than 72 characters"
415 echo "š” Consider keeping the first line short for better readability"
416fi
417
418# Check for conventional commit format (optional)
419if ! echo "$COMMIT_MSG" | grep -E "^(feat|fix|docs|style|refactor|test|chore|perf|ci|build)(\(.+\))?: .+" > /dev/null; then
420 echo "š” Consider using conventional commit format:"
421 echo " feat: add new feature"
422 echo " fix: resolve bug"
423 echo " docs: update documentation"
424 echo " etc."
425fi
426
427echo "ā
Commit message validation passed"
428"#.to_string()
429 }
430
431 fn generate_prepare_commit_msg_hook(&self, cascade_cli: &str) -> String {
432 format!(
433 r#"#!/bin/sh
434# Cascade CLI Hook - Prepare Commit Message
435# Adds stack context to commit messages
436
437set -e
438
439COMMIT_MSG_FILE="$1"
440COMMIT_SOURCE="$2"
441COMMIT_SHA="$3"
442
443# Only modify message if it's a regular commit (not merge, template, etc.)
444if [ "$COMMIT_SOURCE" != "" ] && [ "$COMMIT_SOURCE" != "message" ]; then
445 exit 0
446fi
447
448# Check if Cascade is initialized
449if [ ! -d ".cascade" ]; then
450 exit 0
451fi
452
453# Get active stack info
454ACTIVE_STACK=$("{cascade_cli}" stack list --active --format=name 2>/dev/null || echo "")
455
456if [ -n "$ACTIVE_STACK" ]; then
457 # Get current commit message
458 CURRENT_MSG=$(cat "$COMMIT_MSG_FILE")
459
460 # Skip if message already has stack context
461 if echo "$CURRENT_MSG" | grep -q "\[stack:"; then
462 exit 0
463 fi
464
465 # Add stack context to commit message
466 echo "
467# Stack: $ACTIVE_STACK
468# This commit will be added to the active stack automatically.
469# Use 'cc stack status' to see the current stack state.
470$CURRENT_MSG" > "$COMMIT_MSG_FILE"
471fi
472"#
473 )
474 }
475
476 pub fn detect_repository_type(&self) -> Result<RepositoryType> {
478 let output = Command::new("git")
479 .args(["remote", "get-url", "origin"])
480 .current_dir(&self.repo_path)
481 .output()
482 .map_err(|e| CascadeError::config(format!("Failed to get remote URL: {e}")))?;
483
484 if !output.status.success() {
485 return Ok(RepositoryType::Unknown);
486 }
487
488 let remote_url = String::from_utf8_lossy(&output.stdout)
489 .trim()
490 .to_lowercase();
491
492 if remote_url.contains("github.com") {
493 Ok(RepositoryType::GitHub)
494 } else if remote_url.contains("gitlab.com") || remote_url.contains("gitlab") {
495 Ok(RepositoryType::GitLab)
496 } else if remote_url.contains("dev.azure.com") || remote_url.contains("visualstudio.com") {
497 Ok(RepositoryType::AzureDevOps)
498 } else if remote_url.contains("bitbucket") {
499 Ok(RepositoryType::Bitbucket)
500 } else {
501 Ok(RepositoryType::Unknown)
502 }
503 }
504
505 pub fn detect_branch_type(&self) -> Result<BranchType> {
507 let output = Command::new("git")
508 .args(["branch", "--show-current"])
509 .current_dir(&self.repo_path)
510 .output()
511 .map_err(|e| CascadeError::config(format!("Failed to get current branch: {e}")))?;
512
513 if !output.status.success() {
514 return Ok(BranchType::Unknown);
515 }
516
517 let branch_name = String::from_utf8_lossy(&output.stdout)
518 .trim()
519 .to_lowercase();
520
521 if branch_name == "main" || branch_name == "master" || branch_name == "develop" {
522 Ok(BranchType::Main)
523 } else if !branch_name.is_empty() {
524 Ok(BranchType::Feature)
525 } else {
526 Ok(BranchType::Unknown)
527 }
528 }
529
530 pub fn validate_prerequisites(&self) -> Result<()> {
532 println!("š Checking prerequisites for Cascade hooks...");
533
534 let repo_type = self.detect_repository_type()?;
536 match repo_type {
537 RepositoryType::Bitbucket => {
538 println!("ā
Bitbucket repository detected");
539 println!("š” Hooks will work great with 'cc submit' and 'cc autoland' for Bitbucket integration");
540 }
541 RepositoryType::GitHub => {
542 println!("ā
GitHub repository detected");
543 println!("š” Consider setting up GitHub Actions for CI/CD integration");
544 }
545 RepositoryType::GitLab => {
546 println!("ā
GitLab repository detected");
547 println!("š” GitLab CI integration works well with Cascade stacks");
548 }
549 RepositoryType::AzureDevOps => {
550 println!("ā
Azure DevOps repository detected");
551 println!("š” Azure Pipelines can be configured to work with Cascade workflows");
552 }
553 RepositoryType::Unknown => {
554 println!(
555 "ā¹ļø Unknown repository type - hooks will still work for local Git operations"
556 );
557 }
558 }
559
560 let config_path = self.repo_path.join(".cascade").join("config.json");
562 if !config_path.exists() {
563 return Err(CascadeError::config(
564 "š« Cascade not initialized!\n\n\
565 Please run 'cc init' or 'cc setup' first to configure Cascade CLI.\n\
566 Hooks require proper Bitbucket Server configuration.\n\n\
567 Use --force to install anyway (not recommended)."
568 .to_string(),
569 ));
570 }
571
572 let config = Settings::load_from_file(&config_path)?;
574
575 if config.bitbucket.url == "https://bitbucket.example.com"
576 || config.bitbucket.url.contains("example.com")
577 {
578 return Err(CascadeError::config(
579 "š« Invalid Bitbucket configuration!\n\n\
580 Your Bitbucket URL appears to be a placeholder.\n\
581 Please run 'cc setup' to configure a real Bitbucket Server.\n\n\
582 Use --force to install anyway (not recommended)."
583 .to_string(),
584 ));
585 }
586
587 if config.bitbucket.project == "PROJECT" || config.bitbucket.repo == "repo" {
588 return Err(CascadeError::config(
589 "š« Incomplete Bitbucket configuration!\n\n\
590 Your project/repository settings appear to be placeholders.\n\
591 Please run 'cc setup' to complete configuration.\n\n\
592 Use --force to install anyway (not recommended)."
593 .to_string(),
594 ));
595 }
596
597 println!("ā
Prerequisites validation passed");
598 Ok(())
599 }
600
601 pub fn validate_branch_suitability(&self) -> Result<()> {
603 let branch_type = self.detect_branch_type()?;
604
605 match branch_type {
606 BranchType::Main => {
607 return Err(CascadeError::config(
608 "š« Currently on main/master branch!\n\n\
609 Cascade hooks are designed for feature branch development.\n\
610 Working directly on main/master with stacked diffs can:\n\
611 ⢠Complicate the commit history\n\
612 ⢠Interfere with team collaboration\n\
613 ⢠Break CI/CD workflows\n\n\
614 š” Recommended workflow:\n\
615 1. Create a feature branch: git checkout -b feature/my-feature\n\
616 2. Install hooks: cc hooks install\n\
617 3. Develop with stacked commits (auto-added with hooks)\n\
618 4. Push & submit: cc push && cc submit (all by default)\n\
619 5. Auto-land when ready: cc autoland\n\n\
620 Use --force to install anyway (not recommended)."
621 .to_string(),
622 ));
623 }
624 BranchType::Feature => {
625 println!("ā
Feature branch detected - suitable for stacked development");
626 }
627 BranchType::Unknown => {
628 println!("ā ļø Unknown branch type - proceeding with caution");
629 }
630 }
631
632 Ok(())
633 }
634
635 pub fn confirm_installation(&self) -> Result<()> {
637 println!("\nš Hook Installation Summary:");
638 println!("āāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
639 println!("ā Hook ā Description ā");
640 println!("āāāāāāāāāāāāāāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā¤");
641
642 let hooks = vec![
643 HookType::PostCommit,
644 HookType::PrePush,
645 HookType::CommitMsg,
646 HookType::PrepareCommitMsg,
647 ];
648
649 for hook in &hooks {
650 println!("ā {:19} ā {:31} ā", hook.filename(), hook.description());
651 }
652 println!("āāāāāāāāāāāāāāāāāāāāāāā“āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā");
653
654 println!("\nš These hooks will automatically:");
655 println!("⢠Add commits to your active stack");
656 println!("⢠Validate commit messages");
657 println!("⢠Prevent force pushes that break stack integrity");
658 println!("⢠Add stack context to commit messages");
659
660 println!("\n⨠With hooks + new defaults, your workflow becomes:");
661 println!(" git commit ā Auto-added to stack");
662 println!(" cc push ā Pushes all by default");
663 println!(" cc submit ā Submits all by default");
664 println!(" cc autoland ā Auto-merges when ready");
665
666 use std::io::{self, Write};
667 print!("\nā Install Cascade hooks? [Y/n]: ");
668 io::stdout().flush().unwrap();
669
670 let mut input = String::new();
671 io::stdin().read_line(&mut input).unwrap();
672 let input = input.trim().to_lowercase();
673
674 if input.is_empty() || input == "y" || input == "yes" {
675 println!("ā
Proceeding with installation");
676 Ok(())
677 } else {
678 Err(CascadeError::config(
679 "Installation cancelled by user".to_string(),
680 ))
681 }
682 }
683}
684
685pub async fn install() -> Result<()> {
687 install_with_options(false, false, false, false).await
688}
689
690pub async fn install_with_options(
691 skip_checks: bool,
692 allow_main_branch: bool,
693 yes: bool,
694 force: bool,
695) -> Result<()> {
696 let current_dir = env::current_dir()
697 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
698
699 let hooks_manager = HooksManager::new(¤t_dir)?;
700
701 let options = InstallOptions {
702 check_prerequisites: !skip_checks,
703 feature_branches_only: !allow_main_branch,
704 confirm: !yes,
705 force,
706 };
707
708 hooks_manager.install_with_options(&options)
709}
710
711pub async fn uninstall() -> Result<()> {
712 let current_dir = env::current_dir()
713 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
714
715 let hooks_manager = HooksManager::new(¤t_dir)?;
716 hooks_manager.uninstall_all()
717}
718
719pub async fn status() -> Result<()> {
720 let current_dir = env::current_dir()
721 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
722
723 let hooks_manager = HooksManager::new(¤t_dir)?;
724 hooks_manager.list_installed_hooks()
725}
726
727pub async fn install_hook(hook_name: &str) -> Result<()> {
728 install_hook_with_options(hook_name, false, false).await
729}
730
731pub async fn install_hook_with_options(
732 hook_name: &str,
733 skip_checks: bool,
734 force: bool,
735) -> Result<()> {
736 let current_dir = env::current_dir()
737 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
738
739 let hooks_manager = HooksManager::new(¤t_dir)?;
740
741 let hook_type = match hook_name {
742 "post-commit" => HookType::PostCommit,
743 "pre-push" => HookType::PrePush,
744 "commit-msg" => HookType::CommitMsg,
745 "prepare-commit-msg" => HookType::PrepareCommitMsg,
746 _ => {
747 return Err(CascadeError::config(format!(
748 "Unknown hook type: {hook_name}"
749 )))
750 }
751 };
752
753 if !skip_checks && !force {
755 hooks_manager.validate_prerequisites()?;
756 }
757
758 hooks_manager.install_hook(&hook_type)
759}
760
761pub async fn uninstall_hook(hook_name: &str) -> Result<()> {
762 let current_dir = env::current_dir()
763 .map_err(|e| CascadeError::config(format!("Could not get current directory: {e}")))?;
764
765 let hooks_manager = HooksManager::new(¤t_dir)?;
766
767 let hook_type = match hook_name {
768 "post-commit" => HookType::PostCommit,
769 "pre-push" => HookType::PrePush,
770 "commit-msg" => HookType::CommitMsg,
771 "prepare-commit-msg" => HookType::PrepareCommitMsg,
772 _ => {
773 return Err(CascadeError::config(format!(
774 "Unknown hook type: {hook_name}"
775 )))
776 }
777 };
778
779 hooks_manager.uninstall_hook(&hook_type)
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785 use std::process::Command;
786 use tempfile::TempDir;
787
788 fn create_test_repo() -> (TempDir, std::path::PathBuf) {
789 let temp_dir = TempDir::new().unwrap();
790 let repo_path = temp_dir.path().to_path_buf();
791
792 Command::new("git")
794 .args(["init"])
795 .current_dir(&repo_path)
796 .output()
797 .unwrap();
798 Command::new("git")
799 .args(["config", "user.name", "Test"])
800 .current_dir(&repo_path)
801 .output()
802 .unwrap();
803 Command::new("git")
804 .args(["config", "user.email", "test@test.com"])
805 .current_dir(&repo_path)
806 .output()
807 .unwrap();
808
809 std::fs::write(repo_path.join("README.md"), "# Test").unwrap();
811 Command::new("git")
812 .args(["add", "."])
813 .current_dir(&repo_path)
814 .output()
815 .unwrap();
816 Command::new("git")
817 .args(["commit", "-m", "Initial"])
818 .current_dir(&repo_path)
819 .output()
820 .unwrap();
821
822 crate::config::initialize_repo(&repo_path, Some("https://test.bitbucket.com".to_string()))
824 .unwrap();
825
826 (temp_dir, repo_path)
827 }
828
829 #[test]
830 fn test_hooks_manager_creation() {
831 let (_temp_dir, repo_path) = create_test_repo();
832 let _manager = HooksManager::new(&repo_path).unwrap();
833
834 assert_eq!(_manager.repo_path, repo_path);
835 assert_eq!(_manager.hooks_dir, repo_path.join(".git/hooks"));
836 }
837
838 #[test]
839 fn test_hook_installation() {
840 let (_temp_dir, repo_path) = create_test_repo();
841 let manager = HooksManager::new(&repo_path).unwrap();
842
843 let hook_type = HookType::PostCommit;
845 let result = manager.install_hook(&hook_type);
846 assert!(result.is_ok());
847
848 let hook_path = repo_path.join(".git/hooks/post-commit");
850 assert!(hook_path.exists());
851
852 #[cfg(unix)]
854 {
855 use std::os::unix::fs::PermissionsExt;
856 let metadata = std::fs::metadata(&hook_path).unwrap();
857 let permissions = metadata.permissions();
858 assert!(permissions.mode() & 0o111 != 0); }
860 }
861
862 #[test]
863 fn test_hook_detection() {
864 let (_temp_dir, repo_path) = create_test_repo();
865 let _manager = HooksManager::new(&repo_path).unwrap();
866
867 let post_commit_path = repo_path.join(".git/hooks/post-commit");
869 let pre_push_path = repo_path.join(".git/hooks/pre-push");
870 let commit_msg_path = repo_path.join(".git/hooks/commit-msg");
871
872 assert!(!post_commit_path.exists());
874 assert!(!pre_push_path.exists());
875 assert!(!commit_msg_path.exists());
876 }
877
878 #[test]
879 fn test_hook_validation() {
880 let (_temp_dir, repo_path) = create_test_repo();
881 let manager = HooksManager::new(&repo_path).unwrap();
882
883 let validation = manager.validate_prerequisites();
885 let _ = validation; let branch_validation = manager.validate_branch_suitability();
891 let _ = branch_validation; }
894
895 #[test]
896 fn test_hook_uninstallation() {
897 let (_temp_dir, repo_path) = create_test_repo();
898 let manager = HooksManager::new(&repo_path).unwrap();
899
900 let hook_type = HookType::PostCommit;
902 manager.install_hook(&hook_type).unwrap();
903
904 let hook_path = repo_path.join(".git/hooks/post-commit");
905 assert!(hook_path.exists());
906
907 let result = manager.uninstall_hook(&hook_type);
908 assert!(result.is_ok());
909 assert!(!hook_path.exists());
910 }
911
912 #[test]
913 fn test_hook_content_generation() {
914 let (_temp_dir, repo_path) = create_test_repo();
915 let manager = HooksManager::new(&repo_path).unwrap();
916
917 let binary_name = "cascade-cli";
919
920 let post_commit_content = manager.generate_post_commit_hook(binary_name);
922 assert!(post_commit_content.contains("#!/bin/sh"));
923 assert!(post_commit_content.contains(binary_name));
924
925 let pre_push_content = manager.generate_pre_push_hook(binary_name);
927 assert!(pre_push_content.contains("#!/bin/sh"));
928 assert!(pre_push_content.contains(binary_name));
929
930 let commit_msg_content = manager.generate_commit_msg_hook(binary_name);
932 assert!(commit_msg_content.contains("#!/bin/sh"));
933 assert!(commit_msg_content.contains("Cascade CLI Hook")); let prepare_commit_content = manager.generate_prepare_commit_msg_hook(binary_name);
937 assert!(prepare_commit_content.contains("#!/bin/sh"));
938 assert!(prepare_commit_content.contains(binary_name));
939 }
940
941 #[test]
942 fn test_hook_status_reporting() {
943 let (_temp_dir, repo_path) = create_test_repo();
944 let manager = HooksManager::new(&repo_path).unwrap();
945
946 let repo_type = manager.detect_repository_type().unwrap();
948 assert!(matches!(
950 repo_type,
951 RepositoryType::Bitbucket | RepositoryType::Unknown
952 ));
953
954 let branch_type = manager.detect_branch_type().unwrap();
956 assert!(matches!(
958 branch_type,
959 BranchType::Main | BranchType::Unknown
960 ));
961 }
962
963 #[test]
964 fn test_force_installation() {
965 let (_temp_dir, repo_path) = create_test_repo();
966 let manager = HooksManager::new(&repo_path).unwrap();
967
968 let hook_path = repo_path.join(".git/hooks/post-commit");
970 std::fs::write(&hook_path, "#!/bin/sh\necho 'existing hook'").unwrap();
971
972 let hook_type = HookType::PostCommit;
974 let result = manager.install_hook(&hook_type);
975 assert!(result.is_ok());
976
977 let content = std::fs::read_to_string(&hook_path).unwrap();
979 assert!(content.contains("Cascade CLI Hook"));
980 assert!(!content.contains("existing hook"));
981
982 let backup_path = hook_path.with_extension("cascade-backup");
984 assert!(backup_path.exists());
985 }
986}