ferrous_forge/
git_hooks.rs1use crate::{Error, Result};
7use std::path::Path;
8use tokio::fs;
9
10const 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
66const 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
101const 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
136pub async fn install_git_hooks(project_path: &Path) -> Result<()> {
138 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 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_hook(&hooks_dir, "pre-commit", PRE_COMMIT_HOOK).await?;
157 println!(" โ
Installed pre-commit hook");
158
159 install_hook(&hooks_dir, "pre-push", PRE_PUSH_HOOK).await?;
161 println!(" โ
Installed pre-push hook");
162
163 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
179async fn install_hook(hooks_dir: &Path, name: &str, content: &str) -> Result<()> {
181 let hook_path = hooks_dir.join(name);
182
183 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 return Ok(());
192 }
193
194 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 fs::write(&hook_path, content)
209 .await
210 .map_err(|e| Error::process(format!("Failed to write hook: {}", e)))?;
211
212 #[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
229pub 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(()); }
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 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 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
270pub 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}