ferrous_forge/
git_hooks.rs

1//! Git hooks installation and management
2//!
3//! This module provides functionality to install and manage git hooks
4//! for automatic validation on commits.
5
6use crate::{Error, Result};
7use std::path::Path;
8use tokio::fs;
9
10/// Pre-commit hook script content
11const PRE_COMMIT_HOOK: &str = r#"#!/bin/sh
12# Ferrous Forge pre-commit hook
13# Automatically validates code before allowing commits
14
15set -e
16
17echo "๐Ÿ”จ Running Ferrous Forge pre-commit validation..."
18
19# Check if ferrous-forge is installed
20if ! command -v ferrous-forge >/dev/null 2>&1; then
21    echo "โš ๏ธ  Ferrous Forge not found in PATH"
22    echo "   Install with: cargo install ferrous-forge"
23    echo "   Skipping validation..."
24    exit 0
25fi
26
27# Run formatting check
28echo "๐Ÿ“ Checking code formatting..."
29if ! cargo fmt -- --check >/dev/null 2>&1; then
30    echo "โŒ Code is not formatted properly"
31    echo "   Run 'cargo fmt' to fix formatting"
32    exit 1
33fi
34
35# Run Ferrous Forge validation
36echo "๐Ÿ” Running standards validation..."
37if ! ferrous-forge validate --quiet; then
38    echo "โŒ Ferrous Forge validation failed"
39    echo "   Run 'ferrous-forge validate' to see detailed errors"
40    echo "   Fix all violations before committing"
41    exit 1
42fi
43
44# Run clippy
45echo "๐Ÿ“Ž Running clippy checks..."
46if ! cargo clippy -- -D warnings 2>/dev/null; then
47    echo "โŒ Clippy found issues"
48    echo "   Run 'cargo clippy' to see warnings"
49    exit 1
50fi
51
52# Check for security vulnerabilities (if cargo-audit is installed)
53if command -v cargo-audit >/dev/null 2>&1; then
54    echo "๐Ÿ”’ Checking for security vulnerabilities..."
55    if ! cargo audit --quiet 2>/dev/null; then
56        echo "โš ๏ธ  Security vulnerabilities detected"
57        echo "   Run 'cargo audit' for details"
58        echo "   Consider fixing before committing"
59        # Don't block commit for security issues (just warn)
60    fi
61fi
62
63echo "โœ… All pre-commit checks passed!"
64"#;
65
66/// Pre-push hook script content
67const PRE_PUSH_HOOK: &str = r#"#!/bin/sh
68# Ferrous Forge pre-push hook
69# Runs comprehensive validation before pushing
70
71set -e
72
73echo "๐Ÿ”จ Running Ferrous Forge pre-push validation..."
74
75# Run all tests
76echo "๐Ÿงช Running tests..."
77if ! cargo test --quiet; then
78    echo "โŒ Tests failed"
79    echo "   Fix failing tests before pushing"
80    exit 1
81fi
82
83# Run documentation check
84echo "๐Ÿ“š Checking documentation..."
85if ! cargo doc --no-deps --document-private-items >/dev/null 2>&1; then
86    echo "โš ๏ธ  Documentation issues found"
87    echo "   Run 'cargo doc' to see warnings"
88fi
89
90# Full validation
91echo "๐Ÿ” Running full validation..."
92if ! ferrous-forge validate; then
93    echo "โŒ Validation failed"
94    echo "   Fix all issues before pushing"
95    exit 1
96fi
97
98echo "โœ… All pre-push checks passed!"
99"#;
100
101/// Commit message hook script content
102const COMMIT_MSG_HOOK: &str = r#"#!/bin/sh
103# Ferrous Forge commit message hook
104# Enforces conventional commit format
105
106COMMIT_MSG_FILE=$1
107COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
108
109# Check for conventional commit format
110if ! echo "$COMMIT_MSG" | grep -qE '^(feat|fix|docs|style|refactor|perf|test|chore|build|ci|revert)(\([a-z0-9-]+\))?: .+'; then
111    echo "โŒ Invalid commit message format!"
112    echo ""
113    echo "๐Ÿ“ Please use conventional commit format:"
114    echo "   <type>(<scope>): <description>"
115    echo ""
116    echo "Types: feat, fix, docs, style, refactor, perf, test, chore, build, ci, revert"
117    echo ""
118    echo "Examples:"
119    echo "   feat: add new validation rule"
120    echo "   fix(validation): correct line counting logic"
121    echo "   docs: update README with new features"
122    echo ""
123    exit 1
124fi
125
126# Check message length
127FIRST_LINE=$(echo "$COMMIT_MSG" | head -n1)
128if [ ${#FIRST_LINE} -gt 72 ]; then
129    echo "โš ๏ธ  Commit message first line is too long (${#FIRST_LINE} > 72 characters)"
130    echo "   Consider making it more concise"
131fi
132
133echo "โœ… Commit message format valid"
134"#;
135
136/// Install git hooks in a project
137pub async fn install_git_hooks(project_path: &Path) -> Result<()> {
138    // Check if it's a git repository
139    let git_dir = project_path.join(".git");
140    if !git_dir.exists() {
141        return Err(Error::validation(
142            "Not a git repository. Run 'git init' first.",
143        ));
144    }
145
146    let hooks_dir = git_dir.join("hooks");
147
148    // Create hooks directory if it doesn't exist
149    fs::create_dir_all(&hooks_dir)
150        .await
151        .map_err(|e| Error::process(format!("Failed to create hooks directory: {}", e)))?;
152
153    println!("๐Ÿ“ Installing git hooks...");
154
155    // Install pre-commit hook
156    install_hook(&hooks_dir, "pre-commit", PRE_COMMIT_HOOK).await?;
157    println!("  โœ… Installed pre-commit hook");
158
159    // Install pre-push hook
160    install_hook(&hooks_dir, "pre-push", PRE_PUSH_HOOK).await?;
161    println!("  โœ… Installed pre-push hook");
162
163    // Install commit-msg hook
164    install_hook(&hooks_dir, "commit-msg", COMMIT_MSG_HOOK).await?;
165    println!("  โœ… Installed commit-msg hook");
166
167    println!("๐ŸŽ‰ Git hooks installed successfully!");
168    println!();
169    println!("Hooks will now run automatically:");
170    println!("  โ€ข pre-commit: Validates code before each commit");
171    println!("  โ€ข pre-push: Runs tests and full validation before push");
172    println!("  โ€ข commit-msg: Ensures conventional commit format");
173    println!();
174    println!("To bypass hooks temporarily, use: git commit --no-verify");
175
176    Ok(())
177}
178
179/// Install a single hook
180async fn install_hook(hooks_dir: &Path, name: &str, content: &str) -> Result<()> {
181    let hook_path = hooks_dir.join(name);
182
183    // Check if hook already exists
184    if hook_path.exists() {
185        let existing = fs::read_to_string(&hook_path)
186            .await
187            .map_err(|e| Error::process(format!("Failed to read existing hook: {}", e)))?;
188
189        if existing.contains("Ferrous Forge") {
190            // Our hook is already installed
191            return Ok(());
192        }
193
194        // Backup existing hook
195        let backup_path = hooks_dir.join(format!("{}.backup", name));
196        fs::rename(&hook_path, &backup_path)
197            .await
198            .map_err(|e| Error::process(format!("Failed to backup existing hook: {}", e)))?;
199
200        println!(
201            "  โš ๏ธ  Backed up existing {} hook to {}",
202            name,
203            backup_path.display()
204        );
205    }
206
207    // Write hook content
208    fs::write(&hook_path, content)
209        .await
210        .map_err(|e| Error::process(format!("Failed to write hook: {}", e)))?;
211
212    // Make executable on Unix
213    #[cfg(unix)]
214    {
215        use std::os::unix::fs::PermissionsExt;
216        let mut perms = fs::metadata(&hook_path)
217            .await
218            .map_err(|e| Error::process(format!("Failed to get hook metadata: {}", e)))?
219            .permissions();
220        perms.set_mode(0o755);
221        fs::set_permissions(&hook_path, perms)
222            .await
223            .map_err(|e| Error::process(format!("Failed to set hook permissions: {}", e)))?;
224    }
225
226    Ok(())
227}
228
229/// Remove git hooks from a project
230pub async fn uninstall_git_hooks(project_path: &Path) -> Result<()> {
231    let git_dir = project_path.join(".git");
232    if !git_dir.exists() {
233        return Ok(()); // No git repo, nothing to uninstall
234    }
235
236    let hooks_dir = git_dir.join("hooks");
237
238    println!("๐Ÿ—‘๏ธ  Removing git hooks...");
239
240    for hook_name in &["pre-commit", "pre-push", "commit-msg"] {
241        let hook_path = hooks_dir.join(hook_name);
242
243        if hook_path.exists() {
244            // Check if it's our hook
245            let content = fs::read_to_string(&hook_path).await.unwrap_or_default();
246
247            if content.contains("Ferrous Forge") {
248                fs::remove_file(&hook_path)
249                    .await
250                    .map_err(|e| Error::process(format!("Failed to remove hook: {}", e)))?;
251                println!("  โœ… Removed {} hook", hook_name);
252
253                // Restore backup if it exists
254                let backup_path = hooks_dir.join(format!("{}.backup", hook_name));
255                if backup_path.exists() {
256                    fs::rename(&backup_path, &hook_path)
257                        .await
258                        .map_err(|e| Error::process(format!("Failed to restore backup: {}", e)))?;
259                    println!("  โœ… Restored original {} hook", hook_name);
260                }
261            }
262        }
263    }
264
265    println!("โœ… Git hooks removed");
266
267    Ok(())
268}
269
270/// Check if git hooks are installed
271pub async fn check_hooks_installed(project_path: &Path) -> Result<bool> {
272    let git_dir = project_path.join(".git");
273    if !git_dir.exists() {
274        return Ok(false);
275    }
276
277    let hooks_dir = git_dir.join("hooks");
278    let pre_commit = hooks_dir.join("pre-commit");
279
280    if pre_commit.exists() {
281        let content = fs::read_to_string(&pre_commit).await.unwrap_or_default();
282        Ok(content.contains("Ferrous Forge"))
283    } else {
284        Ok(false)
285    }
286}
287
288#[cfg(test)]
289#[allow(clippy::expect_used, clippy::unwrap_used)]
290mod tests {
291    use super::*;
292    use tempfile::TempDir;
293
294    #[tokio::test]
295    async fn test_hooks_require_git_repo() {
296        let temp_dir = TempDir::new().expect("Failed to create temp dir for test");
297        let result = install_git_hooks(temp_dir.path()).await;
298        assert!(result.is_err());
299        assert!(result
300            .expect_err("Should have failed")
301            .to_string()
302            .contains("Not a git repository"));
303    }
304
305    #[tokio::test]
306    async fn test_check_hooks_not_installed() {
307        let temp_dir = TempDir::new().expect("Failed to create temp dir for test");
308        let installed = check_hooks_installed(temp_dir.path())
309            .await
310            .expect("Check should succeed");
311        assert!(!installed);
312    }
313}