Skip to main content

ferrous_forge/git_hooks/
installer.rs

1//! Git hooks installation and removal logic for mandatory safety pipeline
2//!
3//! @task T017
4//! @epic T014
5
6use super::scripts::{COMMIT_MSG_HOOK, PRE_COMMIT_HOOK, PRE_PUSH_HOOK};
7use crate::{Error, Result};
8use std::path::Path;
9use tokio::fs;
10
11/// Install mandatory blocking git hooks for a project
12///
13/// These hooks BLOCK commits/pushes when safety checks fail.
14/// Bypass is available via: ferrous-forge safety bypass --stage=...
15///
16/// # Errors
17///
18/// Returns [`Error::Validation`] if the path is not a git repository.
19/// Returns [`Error::Process`] if hook files cannot be written.
20pub async fn install_git_hooks(project_path: &Path) -> Result<()> {
21    // Check if we're in a git repository
22    let git_dir = project_path.join(".git");
23    if !git_dir.exists() {
24        return Err(Error::validation(
25            "Not a git repository. Run 'git init' first.".to_string(),
26        ));
27    }
28
29    // Ensure hooks directory exists
30    let hooks_dir = git_dir.join("hooks");
31    if !hooks_dir.exists() {
32        fs::create_dir_all(&hooks_dir)
33            .await
34            .map_err(|e| Error::process(format!("Failed to create hooks directory: {}", e)))?;
35    }
36
37    println!("🔒 Installing mandatory safety hooks...");
38
39    // Install pre-commit hook (blocking)
40    install_hook(&hooks_dir, "pre-commit", PRE_COMMIT_HOOK).await?;
41    println!("  ✅ Installed blocking pre-commit hook");
42
43    // Install pre-push hook (blocking)
44    install_hook(&hooks_dir, "pre-push", PRE_PUSH_HOOK).await?;
45    println!("  ✅ Installed blocking pre-push hook");
46
47    // Install commit-msg hook (non-blocking, format check)
48    install_hook(&hooks_dir, "commit-msg", COMMIT_MSG_HOOK).await?;
49    println!("  ✅ Installed commit-msg hook");
50
51    println!();
52    println!("🛡️  Mandatory safety hooks installed!");
53    println!();
54    println!("These hooks will BLOCK commits/pushes that fail checks.");
55    println!("To bypass temporarily: ferrous-forge safety bypass --stage=...");
56
57    Ok(())
58}
59
60/// Install a single hook
61async fn install_hook(hooks_dir: &Path, name: &str, content: &str) -> Result<()> {
62    let hook_path = hooks_dir.join(name);
63
64    // Check if hook already exists
65    if hook_path.exists() {
66        let existing = fs::read_to_string(&hook_path)
67            .await
68            .map_err(|e| Error::process(format!("Failed to read existing hook: {}", e)))?;
69
70        if existing.contains("Ferrous Forge") {
71            // Our hook is already installed - check if it's the new blocking version
72            if existing.contains("🛡️  FERROUS FORGE BLOCKED") {
73                // Already has the blocking version
74                return Ok(());
75            }
76            // Has old version, needs upgrade
77            println!("  🔄 Upgrading {} hook to blocking version", name);
78        } else {
79            // Backup existing hook
80            let backup_path = hooks_dir.join(format!("{}.backup", name));
81            fs::rename(&hook_path, &backup_path)
82                .await
83                .map_err(|e| Error::process(format!("Failed to backup existing hook: {}", e)))?;
84
85            println!(
86                "  ⚠️  Backed up existing {} hook to {}",
87                name,
88                backup_path.display()
89            );
90        }
91    }
92
93    // Write hook content
94    fs::write(&hook_path, content)
95        .await
96        .map_err(|e| Error::process(format!("Failed to write hook: {}", e)))?;
97
98    // Make executable on Unix
99    #[cfg(unix)]
100    {
101        use std::os::unix::fs::PermissionsExt;
102        let mut perms = fs::metadata(&hook_path)
103            .await
104            .map_err(|e| Error::process(format!("Failed to get hook metadata: {}", e)))?
105            .permissions();
106        perms.set_mode(0o755);
107        fs::set_permissions(&hook_path, perms)
108            .await
109            .map_err(|e| Error::process(format!("Failed to set hook permissions: {}", e)))?;
110    }
111
112    Ok(())
113}
114
115/// Remove git hooks from a project
116///
117/// # Errors
118///
119/// Returns [`Error::Process`] if hook files cannot be removed or backups cannot be restored.
120pub async fn uninstall_git_hooks(project_path: &Path) -> Result<()> {
121    let git_dir = project_path.join(".git");
122    if !git_dir.exists() {
123        return Ok(()); // No git repo, nothing to uninstall
124    }
125
126    let hooks_dir = git_dir.join("hooks");
127
128    println!("🗑️  Removing safety hooks...");
129
130    // Remove our hooks
131    for hook_name in &["pre-commit", "pre-push", "commit-msg"] {
132        let hook_path = hooks_dir.join(hook_name);
133        if hook_path.exists() {
134            let content = fs::read_to_string(&hook_path).await.unwrap_or_default();
135
136            if content.contains("Ferrous Forge") {
137                fs::remove_file(&hook_path)
138                    .await
139                    .map_err(|e| Error::process(format!("Failed to remove hook: {}", e)))?;
140                println!("  ✅ Removed {} hook", hook_name);
141
142                // Restore backup if exists
143                let backup_path = hooks_dir.join(format!("{}.backup", hook_name));
144                if backup_path.exists() {
145                    fs::rename(&backup_path, &hook_path)
146                        .await
147                        .map_err(|e| Error::process(format!("Failed to restore backup: {}", e)))?;
148                    println!("  ✅ Restored original {} hook", hook_name);
149                }
150            }
151        }
152    }
153
154    println!("🎉 Safety hooks removed successfully!");
155    Ok(())
156}
157
158/// Check if mandatory safety hooks are installed
159///
160/// Returns a tuple of (`pre_commit_installed`, `pre_push_installed`)
161pub fn check_hooks_status(project_path: &Path) -> (bool, bool) {
162    let hooks_dir = project_path.join(".git").join("hooks");
163
164    let pre_commit_installed =
165        if let Ok(content) = std::fs::read_to_string(hooks_dir.join("pre-commit")) {
166            content.contains("Ferrous Forge") && content.contains("🛡️  FERROUS FORGE BLOCKED")
167        } else {
168            false
169        };
170
171    let pre_push_installed =
172        if let Ok(content) = std::fs::read_to_string(hooks_dir.join("pre-push")) {
173            content.contains("Ferrous Forge") && content.contains("🛡️  FERROUS FORGE BLOCKED")
174        } else {
175            false
176        };
177
178    (pre_commit_installed, pre_push_installed)
179}