1use 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
36pub 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
42pub 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
48pub 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
54const 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
101const 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
178pub 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 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 fs::create_dir_all(hooks_dir)?;
196
197 fs::write(&hook_path, hook_content)?;
199
200 #[cfg(unix)]
202 {
203 use std::os::unix::fs::PermissionsExt;
204 fs::set_permissions(&hook_path, fs::Permissions::from_mode(0o755))?;
205 }
206
207 #[cfg(windows)]
209 {
210 }
213
214 Ok(HookInstallation {
215 hook_name: hook_name.to_string(),
216 installed: true,
217 skipped: false,
218 reason: None,
219 })
220}
221
222pub 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 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 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 let hooks_dir = get_hooks_dir(project_path)?;
250
251 let mut hooks_installed = Vec::new();
253
254 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 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 let temp = TempDir::new().unwrap();
287 let git_dir = temp.path().join(".git");
288 fs::create_dir_all(&git_dir).unwrap();
289
290 let result = is_git_repo(temp.path()).unwrap();
292
293 assert!(result, "Should detect .git directory");
295 }
296
297 #[test]
298 fn test_is_git_repo_returns_false_for_non_git() {
299 let temp = TempDir::new().unwrap();
301
302 let result = is_git_repo(temp.path()).unwrap();
304
305 assert!(!result, "Should not detect git repo");
307 }
308
309 #[test]
310 fn test_get_hooks_dir_returns_correct_path() {
311 let temp = TempDir::new().unwrap();
313
314 let hooks_dir = get_hooks_dir(temp.path()).unwrap();
316
317 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 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 let result = is_hook_installed(&hooks_dir, "pre-commit").unwrap();
336
337 assert!(result, "Should detect installed hook");
339 }
340
341 #[test]
342 fn test_is_hook_installed_returns_false_for_missing_hook() {
343 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 let result = is_hook_installed(&hooks_dir, "pre-commit").unwrap();
350
351 assert!(!result, "Should not detect missing hook");
353 }
354
355 #[test]
356 fn test_install_hook_creates_hook_file() {
357 let temp = TempDir::new().unwrap();
359 let hooks_dir = temp.path().join(".git").join("hooks");
360
361 let result = install_hook(&hooks_dir, "pre-commit", PRE_COMMIT_HOOK).unwrap();
363
364 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 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 let result = install_hook(&hooks_dir, "pre-commit", PRE_COMMIT_HOOK).unwrap();
384
385 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 let temp = TempDir::new().unwrap();
400
401 let result = install_git_hooks(temp.path(), true).unwrap();
403
404 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 let temp = TempDir::new().unwrap();
417
418 let result = install_git_hooks(temp.path(), false).unwrap();
420
421 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 let temp = TempDir::new().unwrap();
434 let git_dir = temp.path().join(".git");
435 fs::create_dir_all(&git_dir).unwrap();
436
437 let result = install_git_hooks(temp.path(), false).unwrap();
439
440 assert!(result.git_repo_detected, "Should detect git repo");
442 assert_eq!(result.hooks_installed.len(), 2, "Should install 2 hooks");
443
444 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 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 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 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 let result = install_git_hooks(temp.path(), false).unwrap();
477
478 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 let temp = TempDir::new().unwrap();
498 let git_dir = temp.path().join(".git");
499 fs::create_dir_all(&git_dir).unwrap();
500
501 install_git_hooks(temp.path(), false).unwrap();
503
504 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}