Skip to main content

ferrous_forge/git_hooks/
installer.rs

1//! Git hooks installation and removal logic
2
3use super::scripts::{COMMIT_MSG_HOOK, PRE_COMMIT_HOOK, PRE_PUSH_HOOK};
4use crate::{Error, Result};
5use std::path::Path;
6use tokio::fs;
7
8/// Install git hooks for a project
9///
10/// # Errors
11///
12/// Returns [`Error::Validation`] if the path is not a git repository.
13/// Returns [`Error::Process`] if hook files cannot be written.
14pub async fn install_git_hooks(project_path: &Path) -> Result<()> {
15    // Check if we're in a git repository
16    let git_dir = project_path.join(".git");
17    if !git_dir.exists() {
18        return Err(Error::validation(
19            "Not a git repository. Run 'git init' first.".to_string(),
20        ));
21    }
22
23    // Ensure hooks directory exists
24    let hooks_dir = git_dir.join("hooks");
25    if !hooks_dir.exists() {
26        fs::create_dir_all(&hooks_dir)
27            .await
28            .map_err(|e| Error::process(format!("Failed to create hooks directory: {}", e)))?;
29    }
30
31    println!("📎 Installing git hooks...");
32
33    // Install pre-commit hook
34    install_hook(&hooks_dir, "pre-commit", PRE_COMMIT_HOOK).await?;
35    println!("  ✅ Installed pre-commit hook");
36
37    // Install pre-push hook
38    install_hook(&hooks_dir, "pre-push", PRE_PUSH_HOOK).await?;
39    println!("  ✅ Installed pre-push hook");
40
41    // Install commit-msg hook
42    install_hook(&hooks_dir, "commit-msg", COMMIT_MSG_HOOK).await?;
43    println!("  ✅ Installed commit-msg hook");
44
45    println!("🎉 Git hooks installed successfully!");
46    println!();
47    println!("Hooks will now run automatically:");
48    println!("  • pre-commit: Validates code before each commit");
49    println!("  • pre-push: Runs tests and full validation before push");
50    println!("  • commit-msg: Ensures conventional commit format");
51    println!();
52    println!("To bypass hooks temporarily, use: git commit --no-verify");
53
54    Ok(())
55}
56
57/// Install a single hook
58async fn install_hook(hooks_dir: &Path, name: &str, content: &str) -> Result<()> {
59    let hook_path = hooks_dir.join(name);
60
61    // Check if hook already exists
62    if hook_path.exists() {
63        let existing = fs::read_to_string(&hook_path)
64            .await
65            .map_err(|e| Error::process(format!("Failed to read existing hook: {}", e)))?;
66
67        if existing.contains("Ferrous Forge") {
68            // Our hook is already installed
69            return Ok(());
70        }
71
72        // Backup existing hook
73        let backup_path = hooks_dir.join(format!("{}.backup", name));
74        fs::rename(&hook_path, &backup_path)
75            .await
76            .map_err(|e| Error::process(format!("Failed to backup existing hook: {}", e)))?;
77
78        println!(
79            "  ⚠️  Backed up existing {} hook to {}",
80            name,
81            backup_path.display()
82        );
83    }
84
85    // Write hook content
86    fs::write(&hook_path, content)
87        .await
88        .map_err(|e| Error::process(format!("Failed to write hook: {}", e)))?;
89
90    // Make executable on Unix
91    #[cfg(unix)]
92    {
93        use std::os::unix::fs::PermissionsExt;
94        let mut perms = fs::metadata(&hook_path)
95            .await
96            .map_err(|e| Error::process(format!("Failed to get hook metadata: {}", e)))?
97            .permissions();
98        perms.set_mode(0o755);
99        fs::set_permissions(&hook_path, perms)
100            .await
101            .map_err(|e| Error::process(format!("Failed to set hook permissions: {}", e)))?;
102    }
103
104    Ok(())
105}
106
107/// Remove git hooks from a project
108///
109/// # Errors
110///
111/// Returns [`Error::Process`] if hook files cannot be removed or backups cannot be restored.
112pub async fn uninstall_git_hooks(project_path: &Path) -> Result<()> {
113    let git_dir = project_path.join(".git");
114    if !git_dir.exists() {
115        return Ok(()); // No git repo, nothing to uninstall
116    }
117
118    let hooks_dir = git_dir.join("hooks");
119
120    println!("🗑️  Removing git hooks...");
121
122    // Remove our hooks
123    for hook_name in &["pre-commit", "pre-push", "commit-msg"] {
124        let hook_path = hooks_dir.join(hook_name);
125        if hook_path.exists() {
126            let content = fs::read_to_string(&hook_path).await.unwrap_or_default();
127
128            if content.contains("Ferrous Forge") {
129                fs::remove_file(&hook_path)
130                    .await
131                    .map_err(|e| Error::process(format!("Failed to remove hook: {}", e)))?;
132                println!("  ✅ Removed {} hook", hook_name);
133
134                // Restore backup if exists
135                let backup_path = hooks_dir.join(format!("{}.backup", hook_name));
136                if backup_path.exists() {
137                    fs::rename(&backup_path, &hook_path)
138                        .await
139                        .map_err(|e| Error::process(format!("Failed to restore backup: {}", e)))?;
140                    println!("  ✅ Restored original {} hook", hook_name);
141                }
142            }
143        }
144    }
145
146    println!("🎉 Git hooks removed successfully!");
147    Ok(())
148}