Skip to main content

suture_core/
hooks.rs

1//! Hook system for Suture — git-compatible hook execution.
2//!
3//! Hooks are executable scripts found in `.suture/hooks/<hook-name>` that are
4//! run at specific points in the Suture workflow. A hook exits with 0 to allow
5//! the operation to proceed, or non-zero to abort.
6//!
7//! # Supported Hooks
8//!
9//! | Hook            | When                                    | Description |
10//! |-----------------|-----------------------------------------|-------------|
11//! | `pre-commit`    | Before `suture commit` finalizes          | Validate staged content, run linters/tests |
12//! | `post-commit`   | After `suture commit` succeeds            | Send notifications, trigger CI |
13//! | `pre-push`      | Before `suture push` sends to hub        | Run tests, enforce policy |
14//! | `post-push`     | After `suture push` succeeds             | Send notifications, trigger deployment |
15//! | `pre-merge`     | Before `suture merge` finalizes          | Validate merge safety |
16//! | `post-merge`    | After a clean `suture merge` succeeds       | Send notifications |
17//! | `pre-rebase`    | Before `suture rebase` replays patches    | Validate rebase safety |
18//! | `post-rebase`   | After `suture rebase` completes            | Send notifications |
19//! | `pre-cherry-pick`| Before `suture cherry-pick` applies a patch| Validate cherry-pick safety |
20//!
21//! # Hook Configuration
22//!
23//! - Hooks directory: `.suture/hooks/` (or override via `core.hooksPath` in `.suture/config`)
24//! - Hooks are executable files named exactly by their hook type (e.g., `pre-commit`)
25//! - Non-executable files or missing hooks are silently skipped
26//! - Hook scripts receive environment variables with context about the operation
27//!
28//! # Environment Variables
29//!
30//! | Variable              | Description                                    |
31//! |-----------------------|------------------------------------------------|
32//! | `SUTURE_HOOK`          | Name of the hook being run                     |
33//! | `SUTURE_REPO`          | Absolute path to the repository root           |
34//! | `SUTURE_AUTHOR`        | Current author name                           |
35//! | `SUTURE_BRANCH`        | Current branch name                          |
36//! | `SUTURE_HEAD`           | Full hash of the current HEAD patch            |
37//! | `SUTURE_OPERATION`     | Operation being performed                     |
38//! | `SUTURE_HOOK_DIR`      | Path to the hooks directory                   |
39//! | `SUTURE_DIFF_FILES`    | Space-separated list of changed file paths (pre-commit) |
40//! | `SUTURE_PUSH_REMOTE`   | Remote name (pre-push)                         |
41//! | `SUTURE_PUSH_PATCHES`  | Number of patches being pushed (pre-push)     |
42//! | `SUTURE_MERGE_SOURCE`   | Source branch name (pre-merge)                |
43//! | `SUTURE_MERGE_HEAD`     | HEAD patch hash at merge time (pre-merge)   |
44//! | `SUTURE_REVERT_TARGET`  | Patch being reverted (pre-revert)            |
45
46use std::collections::HashMap;
47use std::path::{Path, PathBuf};
48use std::process::Stdio;
49
50/// The result of running a hook.
51#[derive(Debug, Clone)]
52pub struct HookResult {
53    pub hook_name: String,
54    pub exit_code: Option<i32>,
55    pub stdout: String,
56    pub stderr: String,
57    pub elapsed: std::time::Duration,
58}
59
60impl HookResult {
61    pub fn success(&self) -> bool {
62        self.exit_code == Some(0)
63    }
64}
65
66/// A resolved hook script path.
67#[derive(Debug, Clone)]
68pub(crate) struct ResolvedHook {
69    path: PathBuf,
70}
71
72/// Find the hooks directory for a repository.
73///
74/// Priority:
75/// 1. `core.hooksPath` from `.suture/config` (if set)
76/// 2. `.suture/hooks/` (default)
77pub fn hooks_dir(repo_root: &Path) -> PathBuf {
78    // Try to read from repo config
79    let config_path = repo_root.join(".suture").join("config");
80    let Some(content) = std::fs::read_to_string(&config_path).ok() else {
81        return repo_root.join(".suture").join("hooks");
82    };
83    let Ok(config) = toml::from_str::<HashMap<String, toml::Value>>(&content) else {
84        return repo_root.join(".suture").join("hooks");
85    };
86    let Some(toml::Value::String(path)) = config.get("core").and_then(|c| c.get("hooksPath"))
87    else {
88        return repo_root.join(".suture").join("hooks");
89    };
90
91    let path = PathBuf::from(&path);
92    if path.is_absolute() {
93        path
94    } else {
95        repo_root.join(&path)
96    }
97}
98
99/// Find and resolve a hook script by name.
100///
101/// Returns `None` if the hook doesn't exist or isn't executable.
102pub(crate) fn find_hook(repo_root: &Path, hook_name: &str) -> Option<ResolvedHook> {
103    let dir = hooks_dir(repo_root);
104    let path = dir.join(hook_name);
105
106    // Must exist and be executable
107    if !path.exists() {
108        return None;
109    }
110
111    #[cfg(unix)]
112    {
113        use std::os::unix::fs::PermissionsExt;
114        if let Ok(meta) = path.metadata()
115            && meta.is_file()
116            && (meta.permissions().mode() & 0o111) != 0
117        {
118            return Some(ResolvedHook { path });
119        }
120    }
121
122    #[cfg(not(unix))]
123    {
124        // On non-Unix, just check it exists and has content
125        if path.is_file()
126            && std::fs::metadata(&path)
127                .map(|m| m.len() > 0)
128                .unwrap_or(false)
129        {
130            return Some(ResolvedHook { path });
131        }
132    }
133
134    None
135}
136
137/// Run a hook script and capture its output.
138///
139/// Returns `HookResult` with the exit code and captured stdout/stderr.
140pub fn run_hook(
141    repo_root: &Path,
142    hook_name: &str,
143    env: &HashMap<String, String>,
144) -> Result<HookResult, HookError> {
145    let hook = find_hook(repo_root, hook_name).ok_or(HookError::NotFound(hook_name.to_string()))?;
146
147    let start = std::time::Instant::now();
148
149    let output = std::process::Command::new(&hook.path)
150        .envs(env)
151        .stdout(Stdio::piped())
152        .stderr(Stdio::piped())
153        .output()
154        .map_err(|e| HookError::ExecFailed {
155            hook: hook_name.to_string(),
156            path: hook.path.display().to_string(),
157            error: e.to_string(),
158        })?;
159
160    let elapsed = start.elapsed();
161    let exit_code = output.status.code();
162
163    Ok(HookResult {
164        hook_name: hook_name.to_string(),
165        exit_code,
166        stdout: String::from_utf8_lossy(&output.stdout).to_string(),
167        stderr: String::from_utf8_lossy(&output.stderr).to_string(),
168        elapsed,
169    })
170}
171
172/// Run all hooks of a given type from a directory (for extensibility).
173///
174/// If multiple scripts match (e.g., `pre-commit.d/` directory), all are run
175/// in sorted order, and any failure aborts the chain.
176pub fn run_hooks(
177    repo_root: &Path,
178    hook_name: &str,
179    env: &HashMap<String, String>,
180) -> Result<Vec<HookResult>, HookError> {
181    let dir = hooks_dir(repo_root);
182    let direct_hook = dir.join(hook_name);
183
184    let mut results = Vec::new();
185
186    if direct_hook.exists() {
187        // Single hook file
188        match run_hook(repo_root, hook_name, env) {
189            Ok(result) => {
190                results.push(result);
191            }
192            Err(HookError::NotFound(_)) => {
193                // Not found, try directory
194            }
195            Err(e) => {
196                return Err(e);
197            }
198        }
199    }
200
201    // Check for hook directory (e.g., pre-commit.d/)
202    let hook_sub_dir = dir.join(format!("{}.d", hook_name));
203    if hook_sub_dir.is_dir() {
204        let mut entries: Vec<_> = std::fs::read_dir(&hook_sub_dir)
205            .map_err(|e| HookError::ExecFailed {
206                hook: hook_name.to_string(),
207                path: hook_sub_dir.display().to_string(),
208                error: e.to_string(),
209            })?
210            .filter_map(|entry| entry.ok())
211            .filter_map(|entry| {
212                let path = entry.path();
213                if path.is_file() { Some(path) } else { None }
214            })
215            .collect::<Vec<_>>();
216        entries.sort();
217
218        for path in entries {
219            let file_name = path
220                .file_name()
221                .map(|n| n.to_string_lossy().to_string())
222                .unwrap_or_default();
223            let sub_hook_name = format!("{}/{}", hook_name, file_name);
224
225            // Check executable bit (same logic as find_hook)
226            #[cfg(unix)]
227            {
228                use std::os::unix::fs::PermissionsExt;
229                let Ok(meta) = path.metadata() else {
230                    continue;
231                };
232                if meta.is_file() && (meta.permissions().mode() & 0o111) == 0 {
233                    continue; // Skip non-executable files
234                }
235            }
236
237            let start = std::time::Instant::now();
238            let output = std::process::Command::new(&path)
239                .envs(env)
240                .env("SUTURE_HOOK", &sub_hook_name)
241                .stdout(Stdio::piped())
242                .stderr(Stdio::piped())
243                .output()
244                .map_err(|e| HookError::ExecFailed {
245                    hook: sub_hook_name.clone(),
246                    path: path.display().to_string(),
247                    error: e.to_string(),
248                })?;
249
250            let elapsed = start.elapsed();
251            let result = HookResult {
252                hook_name: sub_hook_name,
253                exit_code: output.status.code(),
254                stdout: String::from_utf8_lossy(&output.stdout).to_string(),
255                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
256                elapsed,
257            };
258            if !result.success() {
259                return Err(HookError::ExecFailed {
260                    hook: result.hook_name,
261                    path: path.display().to_string(),
262                    error: format!("hook exited with code {:?}", result.exit_code),
263                });
264            }
265            results.push(result);
266        }
267    }
268
269    Ok(results)
270}
271
272/// Build the standard environment variables for a hook invocation.
273///
274/// The caller should provide `author`, `branch`, and `head_hash` from the
275/// repository when available (e.g. via `repo.head()` and `repo.get_config()`).
276pub fn build_env(
277    repo_root: &Path,
278    hook_name: &str,
279    author: Option<&str>,
280    branch: Option<&str>,
281    head_hash: Option<&str>,
282    extra: HashMap<String, String>,
283) -> HashMap<String, String> {
284    let mut env = HashMap::new();
285
286    // Standard suture vars
287    env.insert("SUTURE_HOOK".to_string(), hook_name.to_string());
288    env.insert(
289        "SUTURE_REPO".to_string(),
290        repo_root.to_string_lossy().to_string(),
291    );
292    env.insert(
293        "SUTURE_HOOK_DIR".to_string(),
294        hooks_dir(repo_root).to_string_lossy().to_string(),
295    );
296    env.insert("SUTURE_OPERATION".to_string(), hook_name.to_string());
297
298    // Author
299    if let Some(a) = author {
300        env.insert("SUTURE_AUTHOR".to_string(), a.to_string());
301    }
302
303    // Branch
304    if let Some(b) = branch {
305        env.insert("SUTURE_BRANCH".to_string(), b.to_string());
306    }
307
308    // HEAD hash
309    if let Some(h) = head_hash {
310        env.insert("SUTURE_HEAD".to_string(), h.to_string());
311    }
312
313    // Add any extra env vars
314    for (k, v) in extra {
315        env.insert(k, v);
316    }
317
318    env
319}
320
321/// Format hook results for display to the user.
322pub fn format_hook_result(result: &HookResult) -> String {
323    let status = if result.success() { "passed" } else { "FAILED" };
324    format!(
325        "{}: {} ({})",
326        result.hook_name,
327        status,
328        result.exit_code.unwrap_or(-1)
329    )
330}
331
332/// Errors that can occur during hook execution.
333#[derive(Debug, thiserror::Error)]
334pub enum HookError {
335    #[error("hook not found: {0}")]
336    NotFound(String),
337    #[error("hook '{hook}' exec failed: {path}: {error}")]
338    ExecFailed {
339        hook: String,
340        path: String,
341        error: String,
342    },
343    #[error("I/O error: {0}")]
344    Io(#[from] std::io::Error),
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use std::fs;
351
352    fn make_hook(dir: &Path, name: &str, content: &str) -> PathBuf {
353        let path = dir.join(name);
354        fs::write(&path, content).unwrap();
355        #[cfg(unix)]
356        {
357            use std::os::unix::fs::PermissionsExt;
358            fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap();
359        }
360        path
361    }
362
363    #[test]
364    fn test_find_hook_exists_and_executable() {
365        let tmp = tempfile::tempdir().unwrap();
366        let hook_dir = tmp.path().join(".suture").join("hooks");
367        fs::create_dir_all(&hook_dir).unwrap();
368        make_hook(&hook_dir, "pre-commit", "#!/bin/sh\nexit 0");
369
370        let hook = find_hook(tmp.path(), "pre-commit");
371        assert!(hook.is_some());
372        assert_eq!(hook.unwrap().path, hook_dir.join("pre-commit"));
373    }
374
375    #[test]
376    fn test_find_hook_not_exists() {
377        let tmp = tempfile::tempdir().unwrap();
378        let hook = find_hook(tmp.path(), "pre-commit");
379        assert!(hook.is_none());
380    }
381
382    #[test]
383    fn test_find_hook_not_executable() {
384        let tmp = tempfile::tempdir().unwrap();
385        let hook_dir = tmp.path().join(".suture").join("hooks");
386        fs::create_dir_all(&hook_dir).unwrap();
387        let path = hook_dir.join("pre-commit");
388        fs::write(&path, "#!/bin/sh\nexit 0").unwrap();
389        #[cfg(unix)]
390        {
391            use std::os::unix::fs::PermissionsExt;
392            fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
393        }
394
395        let hook = find_hook(tmp.path(), "pre-commit");
396        #[cfg(unix)]
397        {
398            assert!(hook.is_none());
399        }
400        #[cfg(not(unix))]
401        {
402            assert!(hook.is_some());
403        }
404    }
405
406    #[test]
407    fn test_run_hook_success() {
408        let tmp = tempfile::tempdir().unwrap();
409        let hook_dir = tmp.path().join(".suture").join("hooks");
410        fs::create_dir_all(&hook_dir).unwrap();
411        make_hook(
412            &hook_dir,
413            "pre-commit",
414            "#!/bin/sh\necho 'hook ran'\nexit 0",
415        );
416
417        let env = build_env(tmp.path(), "pre-commit", None, None, None, HashMap::new());
418        let result = run_hook(tmp.path(), "pre-commit", &env).unwrap();
419        assert!(result.success());
420        assert_eq!(result.stdout.trim(), "hook ran");
421    }
422
423    #[test]
424    fn test_run_hook_failure() {
425        let tmp = tempfile::tempdir().unwrap();
426        let hook_dir = tmp.path().join(".suture").join("hooks");
427        fs::create_dir_all(&hook_dir).unwrap();
428        make_hook(
429            &hook_dir,
430            "pre-commit",
431            "#!/bin/sh\necho 'failing' >&2\nexit 1",
432        );
433
434        let env = build_env(tmp.path(), "pre-commit", None, None, None, HashMap::new());
435        let result = run_hook(tmp.path(), "pre-commit", &env).unwrap();
436        assert!(!result.success());
437        assert_eq!(result.exit_code, Some(1));
438        assert!(result.stderr.contains("failing"));
439    }
440
441    #[test]
442    fn test_run_hook_not_found() {
443        let tmp = tempfile::tempdir().unwrap();
444        let env = build_env(tmp.path(), "pre-commit", None, None, None, HashMap::new());
445        let err = run_hook(tmp.path(), "pre-commit", &env);
446        assert!(matches!(err, Err(HookError::NotFound(_))));
447    }
448
449    #[test]
450    fn test_build_env_basic() {
451        let tmp = tempfile::tempdir().unwrap();
452        let env = build_env(tmp.path(), "pre-commit", None, None, None, HashMap::new());
453
454        assert_eq!(env.get("SUTURE_HOOK").unwrap(), "pre-commit");
455        assert!(
456            env.get("SUTURE_REPO")
457                .unwrap()
458                .contains(tmp.path().to_str().unwrap())
459        );
460        assert_eq!(env.get("SUTURE_OPERATION").unwrap(), "pre-commit");
461        // No author/branch/head when not provided
462        assert!(!env.contains_key("SUTURE_AUTHOR"));
463        assert!(!env.contains_key("SUTURE_BRANCH"));
464        assert!(!env.contains_key("SUTURE_HEAD"));
465    }
466
467    #[test]
468    fn test_build_env_with_author_branch() {
469        let tmp = tempfile::tempdir().unwrap();
470        let env = build_env(
471            tmp.path(),
472            "pre-commit",
473            Some("Alice"),
474            Some("main"),
475            Some("abc123"),
476            HashMap::new(),
477        );
478
479        assert_eq!(env.get("SUTURE_AUTHOR").unwrap(), "Alice");
480        assert_eq!(env.get("SUTURE_BRANCH").unwrap(), "main");
481        assert_eq!(env.get("SUTURE_HEAD").unwrap(), "abc123");
482    }
483
484    #[test]
485    fn test_build_env_with_extras() {
486        let tmp = tempfile::tempdir().unwrap();
487        let mut extras = HashMap::new();
488        extras.insert("CUSTOM_VAR".to_string(), "value".to_string());
489        let env = build_env(tmp.path(), "pre-push", None, None, None, extras);
490
491        assert_eq!(env.get("CUSTOM_VAR").unwrap(), "value");
492        assert_eq!(env.get("SUTURE_HOOK").unwrap(), "pre-push");
493    }
494
495    #[test]
496    fn test_format_hook_result() {
497        let result = HookResult {
498            hook_name: "pre-commit".to_string(),
499            exit_code: Some(0),
500            stdout: "all good".to_string(),
501            stderr: String::new(),
502            elapsed: std::time::Duration::from_millis(5),
503        };
504        let formatted = format_hook_result(&result);
505        assert!(formatted.contains("passed"));
506    }
507
508    #[test]
509    fn test_format_hook_result_failure() {
510        let result = HookResult {
511            hook_name: "pre-commit".to_string(),
512            exit_code: Some(1),
513            stdout: String::new(),
514            stderr: "error!".to_string(),
515            elapsed: std::time::Duration::from_millis(3),
516        };
517        let formatted = format_hook_result(&result);
518        assert!(formatted.contains("FAILED"));
519    }
520
521    #[test]
522    fn test_hooks_dir_default() {
523        let tmp = tempfile::tempdir().unwrap();
524        let dir = hooks_dir(tmp.path());
525        assert!(dir.to_string_lossy().contains(".suture"));
526        assert!(dir.to_string_lossy().contains("hooks"));
527    }
528
529    #[test]
530    fn test_hooks_dir_from_config() {
531        let tmp = tempfile::tempdir().unwrap();
532        let suture_dir = tmp.path().join(".suture");
533        fs::create_dir_all(&suture_dir).unwrap();
534
535        let config = r#"
536[core]
537hooksPath = "my-hooks"
538"#;
539        fs::write(suture_dir.join("config"), config).unwrap();
540
541        let dir = hooks_dir(tmp.path());
542        assert!(dir.to_string_lossy().contains("my-hooks"));
543    }
544
545    #[test]
546    fn test_hooks_dir_from_config_absolute() {
547        let tmp = tempfile::tempdir().unwrap();
548        let suture_dir = tmp.path().join(".suture");
549        fs::create_dir_all(&suture_dir).unwrap();
550
551        let config = r#"
552[core]
553hooksPath = "/tmp/custom-hooks"
554"#;
555        fs::write(suture_dir.join("config"), config).unwrap();
556
557        let dir = hooks_dir(tmp.path());
558        assert!(dir.to_string_lossy().contains("/tmp/custom-hooks"));
559    }
560
561    #[test]
562    fn test_run_hooks_directory() {
563        let tmp = tempfile::tempdir().unwrap();
564        let hook_dir = tmp.path().join(".suture").join("hooks");
565        fs::create_dir_all(&hook_dir).unwrap();
566        let hook_subdir = hook_dir.join("pre-commit.d");
567        fs::create_dir_all(&hook_subdir).unwrap();
568
569        make_hook(&hook_subdir, "01-check", "#!/bin/sh\nexit 0");
570        make_hook(&hook_subdir, "02-lint", "#!/bin/sh\nexit 0");
571        make_hook(&hook_subdir, "03-test", "#!/bin/sh\nexit 0");
572
573        let env = build_env(tmp.path(), "pre-commit", None, None, None, HashMap::new());
574        let results = run_hooks(tmp.path(), "pre-commit", &env).unwrap();
575        assert_eq!(results.len(), 3);
576        assert!(results.iter().all(|r| r.success()));
577    }
578
579    #[test]
580    fn test_run_hooks_directory_failure_stops() {
581        let tmp = tempfile::tempdir().unwrap();
582        let hook_dir = tmp.path().join(".suture").join("hooks");
583        fs::create_dir_all(&hook_dir).unwrap();
584        let hook_subdir = hook_dir.join("pre-commit.d");
585        fs::create_dir_all(&hook_subdir).unwrap();
586
587        make_hook(&hook_subdir, "01-pass", "#!/bin/sh\nexit 0");
588        make_hook(&hook_subdir, "02-fail", "#!/bin/sh\nexit 1");
589
590        let env = build_env(tmp.path(), "pre-commit", None, None, None, HashMap::new());
591        let err = run_hooks(tmp.path(), "pre-commit", &env);
592        assert!(err.is_err());
593    }
594}