Skip to main content

ggen_cli_lib/cmds/
git_hooks.rs

1//! Git hooks installation and management
2//!
3//! Provides automatic installation of pre-commit and pre-push hooks
4//! integrated with cargo make workflow.
5//!
6//! ## Features
7//!
8//! - **Auto-detection**: Detects if in git repository
9//! - **Smart installation**: Skips if hooks already installed
10//! - **Cross-platform**: Works on Unix and Windows
11//! - **Verification**: Validates hooks after installation
12//!
13//! ## Hooks Installed
14//!
15//! - **pre-commit**: Fast validation (cargo make check + format)
16//! - **pre-push**: Full validation (cargo make pre-commit)
17
18use std::fs;
19use std::path::{Path, PathBuf};
20
21#[derive(Debug, Clone, serde::Serialize)]
22pub struct HookInstallation {
23    pub hook_name: String,
24    pub installed: bool,
25    pub skipped: bool,
26    pub reason: Option<String>,
27}
28
29#[derive(Debug, Clone, serde::Serialize)]
30pub struct HooksInstallOutput {
31    pub git_repo_detected: bool,
32    pub hooks_installed: Vec<HookInstallation>,
33    pub warnings: Vec<String>,
34}
35
36/// Check if a directory is a git repository
37pub fn is_git_repo(path: &Path) -> Result<bool, std::io::Error> {
38    let git_dir = path.join(".git");
39    Ok(git_dir.exists() && git_dir.is_dir())
40}
41
42/// Get the .git/hooks directory path
43pub fn get_hooks_dir(project_path: &Path) -> Result<PathBuf, std::io::Error> {
44    let hooks_dir = project_path.join(".git").join("hooks");
45    Ok(hooks_dir)
46}
47
48/// Check if a hook is already installed
49pub fn is_hook_installed(hooks_dir: &Path, hook_name: &str) -> Result<bool, std::io::Error> {
50    let hook_path = hooks_dir.join(hook_name);
51    Ok(hook_path.exists())
52}
53
54/// Pre-commit hook content
55const PRE_COMMIT_HOOK: &str = r#"#!/usr/bin/env bash
56# Pre-commit hook - Fast validation tier
57# Auto-installed by ggen init
58# Target: <10 seconds | Catches compilation errors early
59
60set -e
61cd "$(git rev-parse --show-toplevel)"
62
63# Only run validation when on the default branch (main)
64CURRENT_BRANCH=$(git symbolic-ref --short HEAD)
65if [ "$CURRENT_BRANCH" != "main" ]; then
66    exit 0
67fi
68
69echo ""
70echo "Pre-commit validation (fast tier)..."
71echo ""
72
73# Gate 1: Cargo check (compilation)
74echo -n "  Cargo check... "
75if timeout 10s cargo check --quiet 2>/dev/null; then
76    echo "PASS"
77else
78    echo "FAIL"
79    echo ""
80    echo "STOP: Compilation errors must be fixed"
81    cargo check 2>&1 | head -30
82    exit 1
83fi
84
85# Gate 2: Format check (ensures consistency)
86echo -n "  Format check... "
87if cargo fmt --all -- --check >/dev/null 2>&1; then
88    echo "PASS"
89else
90    echo "FAIL"
91    echo ""
92    echo "STOP: Code not formatted. Run 'cargo fmt --all' before committing."
93    exit 1
94fi
95
96echo ""
97echo "Pre-commit passed."
98exit 0
99"#;
100
101/// Pre-push hook content
102const PRE_PUSH_HOOK: &str = r#"#!/usr/bin/env bash
103# Pre-push hook - Full validation tier
104# Auto-installed by ggen init
105# Target: <90 seconds | Comprehensive checks
106
107set -e
108cd "$(git rev-parse --show-toplevel)"
109
110# Only run validation when pushing to the default branch (main)
111IS_DEFAULT_BRANCH=false
112while read local_ref local_sha remote_ref remote_sha; do
113    if [[ "$remote_ref" == "refs/heads/main" ]]; then
114        IS_DEFAULT_BRANCH=true
115    fi
116done
117
118if [ "$IS_DEFAULT_BRANCH" = false ]; then
119    exit 0
120fi
121
122echo ""
123echo "Pre-push validation (full tier)..."
124echo ""
125
126# Gate 1: Cargo Check
127echo -n "  [1/4] Cargo check... "
128if timeout 15s cargo check --quiet 2>/dev/null; then
129    echo "PASS"
130else
131    echo "FAIL"
132    echo ""
133    echo "STOP: Compilation errors"
134    cargo check 2>&1 | head -30
135    exit 1
136fi
137
138# Gate 2: Workspace-wide Clippy Lint
139echo -n "  [2/4] Workspace-wide clippy... "
140if timeout 120s cargo clippy --workspace --quiet -- -D warnings 2>/dev/null; then
141    echo "PASS"
142else
143    echo "FAIL"
144    echo ""
145    echo "STOP: Clippy warnings"
146    cargo clippy -- -D warnings 2>&1 | head -40
147    exit 1
148fi
149
150# Gate 3: Format Check
151echo -n "  [3/4] Format check... "
152if cargo fmt --all -- --check >/dev/null 2>&1; then
153    echo "PASS"
154else
155    echo "FAIL"
156    echo ""
157    echo "STOP: Code not formatted"
158    exit 1
159fi
160
161# Gate 4: Unit Tests
162echo -n "  [4/4] Unit tests... "
163if timeout 300s cargo test --workspace --lib --quiet 2>/dev/null; then
164    echo "PASS"
165else
166    echo "FAIL"
167    echo ""
168    echo "STOP: Test failures"
169    cargo test --workspace --lib 2>&1 | grep -E "(FAILED|error\[)" | head -20
170    exit 1
171fi
172
173echo ""
174echo "All gates passed. Push will proceed."
175exit 0
176"#;
177
178/// Install a single git hook
179pub fn install_hook(
180    hooks_dir: &Path, hook_name: &str, hook_content: &str,
181) -> Result<HookInstallation, std::io::Error> {
182    let hook_path = hooks_dir.join(hook_name);
183
184    // Check if already exists
185    if hook_path.exists() {
186        return Ok(HookInstallation {
187            hook_name: hook_name.to_string(),
188            installed: false,
189            skipped: true,
190            reason: Some("Hook already exists".to_string()),
191        });
192    }
193
194    // Ensure hooks directory exists
195    fs::create_dir_all(hooks_dir)?;
196
197    // Write hook file
198    fs::write(&hook_path, hook_content)?;
199
200    // Make executable on Unix systems
201    #[cfg(unix)]
202    {
203        use std::os::unix::fs::PermissionsExt;
204        fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755))?;
205    }
206
207    // On Windows, the git bash will handle execution
208    #[cfg(windows)]
209    {
210        // Windows: No chmod needed, git bash handles it
211        // We could add a .bat wrapper here if needed
212    }
213
214    Ok(HookInstallation {
215        hook_name: hook_name.to_string(),
216        installed: true,
217        skipped: false,
218        reason: None,
219    })
220}
221
222/// Install all git hooks
223pub fn install_git_hooks(
224    project_path: &Path, skip_hooks: bool,
225) -> Result<HooksInstallOutput, std::io::Error> {
226    let mut warnings = Vec::new();
227
228    // Check if skip flag is set
229    if skip_hooks {
230        return Ok(HooksInstallOutput {
231            git_repo_detected: false,
232            hooks_installed: vec![],
233            warnings: vec!["Git hooks installation skipped (--skip-hooks flag)".to_string()],
234        });
235    }
236
237    // Check if git repo
238    let is_git = is_git_repo(project_path)?;
239    if !is_git {
240        warnings.push("Not a git repository, skipping hook installation".to_string());
241        return Ok(HooksInstallOutput {
242            git_repo_detected: false,
243            hooks_installed: vec![],
244            warnings,
245        });
246    }
247
248    // Get hooks directory
249    let hooks_dir = get_hooks_dir(project_path)?;
250
251    // Install hooks
252    let mut hooks_installed = Vec::new();
253
254    // Install pre-commit hook
255    match install_hook(&hooks_dir, "pre-commit", PRE_COMMIT_HOOK) {
256        Ok(result) => hooks_installed.push(result),
257        Err(e) => {
258            warnings.push(format!("Failed to install pre-commit hook: {}", e));
259        }
260    }
261
262    // Install pre-push hook
263    match install_hook(&hooks_dir, "pre-push", PRE_PUSH_HOOK) {
264        Ok(result) => hooks_installed.push(result),
265        Err(e) => {
266            warnings.push(format!("Failed to install pre-push hook: {}", e));
267        }
268    }
269
270    Ok(HooksInstallOutput {
271        git_repo_detected: is_git,
272        hooks_installed,
273        warnings,
274    })
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use std::fs;
281    use tempfile::TempDir;
282
283    #[test]
284    fn test_is_git_repo_detects_git_directory() {
285        // Arrange
286        let temp = TempDir::new().unwrap();
287        let git_dir = temp.path().join(".git");
288        fs::create_dir_all(&git_dir).unwrap();
289
290        // Act
291        let result = is_git_repo(temp.path()).unwrap();
292
293        // Assert
294        assert!(result, "Should detect .git directory");
295    }
296
297    #[test]
298    fn test_is_git_repo_returns_false_for_non_git() {
299        // Arrange
300        let temp = TempDir::new().unwrap();
301
302        // Act
303        let result = is_git_repo(temp.path()).unwrap();
304
305        // Assert
306        assert!(!result, "Should not detect git repo");
307    }
308
309    #[test]
310    fn test_get_hooks_dir_returns_correct_path() {
311        // Arrange
312        let temp = TempDir::new().unwrap();
313
314        // Act
315        let hooks_dir = get_hooks_dir(temp.path()).unwrap();
316
317        // Assert
318        assert_eq!(
319            hooks_dir,
320            temp.path().join(".git").join("hooks"),
321            "Should return correct hooks directory path"
322        );
323    }
324
325    #[test]
326    fn test_is_hook_installed_detects_existing_hook() {
327        // Arrange
328        let temp = TempDir::new().unwrap();
329        let hooks_dir = temp.path().join(".git").join("hooks");
330        fs::create_dir_all(&hooks_dir).unwrap();
331        let hook_path = hooks_dir.join("pre-commit");
332        fs::write(&hook_path, "#!/bin/bash\necho test").unwrap();
333
334        // Act
335        let result = is_hook_installed(&hooks_dir, "pre-commit").unwrap();
336
337        // Assert
338        assert!(result, "Should detect installed hook");
339    }
340
341    #[test]
342    fn test_is_hook_installed_returns_false_for_missing_hook() {
343        // Arrange
344        let temp = TempDir::new().unwrap();
345        let hooks_dir = temp.path().join(".git").join("hooks");
346        fs::create_dir_all(&hooks_dir).unwrap();
347
348        // Act
349        let result = is_hook_installed(&hooks_dir, "pre-commit").unwrap();
350
351        // Assert
352        assert!(!result, "Should not detect missing hook");
353    }
354
355    #[test]
356    fn test_install_hook_creates_hook_file() {
357        // Arrange
358        let temp = TempDir::new().unwrap();
359        let hooks_dir = temp.path().join(".git").join("hooks");
360
361        // Act
362        let result = install_hook(&hooks_dir, "pre-commit", PRE_COMMIT_HOOK).unwrap();
363
364        // Assert
365        assert!(result.installed, "Hook should be marked as installed");
366        assert!(!result.skipped, "Hook should not be skipped");
367        let hook_path = hooks_dir.join("pre-commit");
368        assert!(hook_path.exists(), "Hook file should exist");
369        let content = fs::read_to_string(&hook_path).unwrap();
370        assert_eq!(content, PRE_COMMIT_HOOK, "Hook content should match");
371    }
372
373    #[test]
374    fn test_install_hook_skips_existing_hook() {
375        // Arrange
376        let temp = TempDir::new().unwrap();
377        let hooks_dir = temp.path().join(".git").join("hooks");
378        fs::create_dir_all(&hooks_dir).unwrap();
379        let hook_path = hooks_dir.join("pre-commit");
380        fs::write(&hook_path, "#!/bin/bash\necho existing").unwrap();
381
382        // Act
383        let result = install_hook(&hooks_dir, "pre-commit", PRE_COMMIT_HOOK).unwrap();
384
385        // Assert
386        assert!(!result.installed, "Hook should not be marked as installed");
387        assert!(result.skipped, "Hook should be marked as skipped");
388        assert!(result.reason.is_some(), "Should have reason for skipping");
389        let content = fs::read_to_string(&hook_path).unwrap();
390        assert_eq!(
391            content, "#!/bin/bash\necho existing",
392            "Existing hook should not be overwritten"
393        );
394    }
395
396    #[test]
397    fn test_install_git_hooks_with_skip_flag() {
398        // Arrange
399        let temp = TempDir::new().unwrap();
400
401        // Act
402        let result = install_git_hooks(temp.path(), true).unwrap();
403
404        // Assert
405        assert!(!result.git_repo_detected);
406        assert_eq!(result.hooks_installed.len(), 0);
407        assert!(
408            !result.warnings.is_empty(),
409            "Should have warning about skipping"
410        );
411    }
412
413    #[test]
414    fn test_install_git_hooks_in_non_git_repo() {
415        // Arrange
416        let temp = TempDir::new().unwrap();
417
418        // Act
419        let result = install_git_hooks(temp.path(), false).unwrap();
420
421        // Assert
422        assert!(!result.git_repo_detected);
423        assert_eq!(result.hooks_installed.len(), 0);
424        assert!(
425            !result.warnings.is_empty(),
426            "Should have warning about non-git repo"
427        );
428    }
429
430    #[test]
431    fn test_install_git_hooks_in_git_repo() {
432        // Arrange
433        let temp = TempDir::new().unwrap();
434        let git_dir = temp.path().join(".git");
435        fs::create_dir_all(&git_dir).unwrap();
436
437        // Act
438        let result = install_git_hooks(temp.path(), false).unwrap();
439
440        // Assert
441        assert!(result.git_repo_detected, "Should detect git repo");
442        assert_eq!(result.hooks_installed.len(), 2, "Should install 2 hooks");
443
444        // Check pre-commit
445        let pre_commit = &result.hooks_installed[0];
446        assert_eq!(pre_commit.hook_name, "pre-commit");
447        assert!(pre_commit.installed, "pre-commit should be installed");
448
449        // Check pre-push
450        let pre_push = &result.hooks_installed[1];
451        assert_eq!(pre_push.hook_name, "pre-push");
452        assert!(pre_push.installed, "pre-push should be installed");
453
454        // Verify files exist
455        let hooks_dir = git_dir.join("hooks");
456        assert!(
457            hooks_dir.join("pre-commit").exists(),
458            "pre-commit file should exist"
459        );
460        assert!(
461            hooks_dir.join("pre-push").exists(),
462            "pre-push file should exist"
463        );
464    }
465
466    #[test]
467    fn test_install_git_hooks_skips_existing() {
468        // Arrange
469        let temp = TempDir::new().unwrap();
470        let git_dir = temp.path().join(".git");
471        let hooks_dir = git_dir.join("hooks");
472        fs::create_dir_all(&hooks_dir).unwrap();
473        fs::write(hooks_dir.join("pre-commit"), "#!/bin/bash\necho existing").unwrap();
474
475        // Act
476        let result = install_git_hooks(temp.path(), false).unwrap();
477
478        // Assert
479        assert!(result.git_repo_detected);
480        assert_eq!(result.hooks_installed.len(), 2);
481
482        let pre_commit = &result.hooks_installed[0];
483        assert_eq!(pre_commit.hook_name, "pre-commit");
484        assert!(pre_commit.skipped, "Existing pre-commit should be skipped");
485
486        let pre_push = &result.hooks_installed[1];
487        assert_eq!(pre_push.hook_name, "pre-push");
488        assert!(pre_push.installed, "New pre-push should be installed");
489    }
490
491    #[cfg(unix)]
492    #[test]
493    fn test_hook_is_executable_on_unix() {
494        use std::os::unix::fs::PermissionsExt;
495
496        // Arrange
497        let temp = TempDir::new().unwrap();
498        let git_dir = temp.path().join(".git");
499        fs::create_dir_all(&git_dir).unwrap();
500
501        // Act
502        install_git_hooks(temp.path(), false).unwrap();
503
504        // Assert
505        let hooks_dir = git_dir.join("hooks");
506        let pre_commit_path = hooks_dir.join("pre-commit");
507        let metadata = fs::metadata(&pre_commit_path).unwrap();
508        let permissions = metadata.permissions();
509        let mode = permissions.mode();
510
511        assert!(mode & 0o111 != 0, "Hook should have executable permissions");
512    }
513}