Skip to main content

souk_core/ci/
hooks.rs

1//! Git hook integration for pre-commit and pre-push validation.
2//!
3//! This module provides functions that detect staged changes and run targeted
4//! or full marketplace validation, designed to be called from git hooks
5//! (`pre-commit` and `pre-push`).
6
7use std::process::Command;
8
9use crate::discovery::MarketplaceConfig;
10use crate::error::{SoukError, ValidationDiagnostic, ValidationResult};
11use crate::validation::{validate_marketplace, validate_plugin};
12
13/// Detect which plugins have changes staged for commit.
14///
15/// Runs `git diff --cached --name-only` and matches paths against the
16/// configured `pluginRoot`. Returns a deduplicated, sorted list of plugin
17/// directory names that have at least one staged file.
18///
19/// # Errors
20///
21/// Returns `SoukError::Other` if the `git` command fails to execute or
22/// exits with a non-zero status.
23pub fn detect_changed_plugins(config: &MarketplaceConfig) -> Result<Vec<String>, SoukError> {
24    let output = Command::new("git")
25        .args(["diff", "--cached", "--name-only"])
26        .current_dir(&config.project_root)
27        .output()
28        .map_err(|e| SoukError::Other(format!("Failed to run git: {e}")))?;
29
30    if !output.status.success() {
31        return Err(SoukError::Other("git diff failed".into()));
32    }
33
34    let stdout = String::from_utf8_lossy(&output.stdout);
35    let plugin_root_rel = config.marketplace.normalized_plugin_root();
36    // Strip leading "./" from plugin root for matching against git paths
37    let prefix = plugin_root_rel
38        .strip_prefix("./")
39        .unwrap_or(&plugin_root_rel);
40
41    let mut plugin_names: Vec<String> = stdout
42        .lines()
43        .filter_map(|line| {
44            let line = line.trim();
45            if line.starts_with(prefix) {
46                // Extract the plugin directory name (first path component after prefix)
47                let rest = line.strip_prefix(prefix)?.trim_start_matches('/');
48                let name = rest.split('/').next()?;
49                if name.is_empty() {
50                    None
51                } else {
52                    Some(name.to_string())
53                }
54            } else {
55                None
56            }
57        })
58        .collect();
59
60    plugin_names.sort();
61    plugin_names.dedup();
62
63    Ok(plugin_names)
64}
65
66/// Check if marketplace.json is staged for commit.
67///
68/// Runs `git diff --cached --name-only` and looks for any staged file path
69/// that ends with `marketplace.json`.
70///
71/// # Errors
72///
73/// Returns `SoukError::Other` if the `git` command fails to execute or
74/// exits with a non-zero status.
75pub fn is_marketplace_staged(config: &MarketplaceConfig) -> Result<bool, SoukError> {
76    let output = Command::new("git")
77        .args(["diff", "--cached", "--name-only"])
78        .current_dir(&config.project_root)
79        .output()
80        .map_err(|e| SoukError::Other(format!("Failed to run git: {e}")))?;
81
82    if !output.status.success() {
83        return Err(SoukError::Other("git diff failed".into()));
84    }
85
86    let stdout = String::from_utf8_lossy(&output.stdout);
87    Ok(stdout.lines().any(|line| line.contains("marketplace.json")))
88}
89
90/// Run pre-commit validation.
91///
92/// This validates only the plugins that have staged changes (detected via
93/// `git diff --cached`). If `marketplace.json` itself is staged, the
94/// marketplace structure is also validated (skipping individual plugin
95/// validation to avoid redundancy).
96///
97/// Returns a [`ValidationResult`] that the caller can inspect to decide
98/// whether to allow or block the commit.
99pub fn run_pre_commit(config: &MarketplaceConfig) -> ValidationResult {
100    let mut result = ValidationResult::new();
101
102    // Get changed plugins
103    let changed = match detect_changed_plugins(config) {
104        Ok(names) => names,
105        Err(e) => {
106            result.push(ValidationDiagnostic::error(format!(
107                "Failed to detect changed plugins: {e}"
108            )));
109            return result;
110        }
111    };
112
113    // Validate each changed plugin
114    for name in &changed {
115        let plugin_path = config.plugin_root_abs.join(name);
116        if plugin_path.is_dir() {
117            let plugin_result = validate_plugin(&plugin_path);
118            result.merge(plugin_result);
119        }
120    }
121
122    // If marketplace.json is staged, validate marketplace structure
123    if let Ok(true) = is_marketplace_staged(config) {
124        let mp_result = validate_marketplace(config, true); // skip individual plugins
125        result.merge(mp_result);
126    }
127
128    result
129}
130
131/// Run pre-push validation.
132///
133/// This performs a full marketplace validation including all plugins,
134/// equivalent to `souk validate marketplace`. Use this in a `pre-push`
135/// git hook to ensure only valid marketplaces are pushed to remote.
136pub fn run_pre_push(config: &MarketplaceConfig) -> ValidationResult {
137    validate_marketplace(config, false)
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::discovery::load_marketplace_config;
144    use tempfile::TempDir;
145
146    /// Helper: create a valid marketplace inside a git repository.
147    fn setup_git_marketplace(
148        tmp: &TempDir,
149        plugin_dirs: &[&str],
150        plugins_json: &[(&str, &str)],
151    ) -> MarketplaceConfig {
152        // Initialize a git repo so `git diff` works
153        Command::new("git")
154            .args(["init"])
155            .current_dir(tmp.path())
156            .output()
157            .expect("git init failed");
158
159        let claude_dir = tmp.path().join(".claude-plugin");
160        std::fs::create_dir_all(&claude_dir).unwrap();
161        let plugins_dir = tmp.path().join("plugins");
162        std::fs::create_dir_all(&plugins_dir).unwrap();
163
164        // Create plugin directories with valid manifests
165        for name in plugin_dirs {
166            let p = plugins_dir.join(name).join(".claude-plugin");
167            std::fs::create_dir_all(&p).unwrap();
168            std::fs::write(
169                p.join("plugin.json"),
170                format!(r#"{{"name":"{name}","version":"1.0.0","description":"test plugin"}}"#),
171            )
172            .unwrap();
173        }
174
175        // Build plugins array for marketplace.json
176        let entries: Vec<String> = plugins_json
177            .iter()
178            .map(|(name, source)| format!(r#"{{"name":"{name}","source":"{source}"}}"#))
179            .collect();
180        let plugins_arr = entries.join(",");
181
182        std::fs::write(
183            claude_dir.join("marketplace.json"),
184            format!(r#"{{"version":"0.1.0","pluginRoot":"./plugins","plugins":[{plugins_arr}]}}"#),
185        )
186        .unwrap();
187
188        load_marketplace_config(&claude_dir.join("marketplace.json")).unwrap()
189    }
190
191    #[test]
192    fn pre_push_validates_entire_marketplace() {
193        let tmp = TempDir::new().unwrap();
194        let config = setup_git_marketplace(&tmp, &["my-plugin"], &[("my-plugin", "my-plugin")]);
195
196        let result = run_pre_push(&config);
197        assert!(
198            !result.has_errors(),
199            "Expected no errors, got: {:?}",
200            result.diagnostics
201        );
202    }
203
204    #[test]
205    fn pre_push_catches_invalid_plugin() {
206        let tmp = TempDir::new().unwrap();
207        let config = setup_git_marketplace(&tmp, &["bad-plugin"], &[("bad-plugin", "bad-plugin")]);
208
209        // Corrupt the plugin.json to trigger a validation error
210        let plugin_json = tmp
211            .path()
212            .join("plugins/bad-plugin/.claude-plugin/plugin.json");
213        std::fs::write(&plugin_json, "not valid json").unwrap();
214
215        let result = run_pre_push(&config);
216        assert!(result.has_errors());
217    }
218
219    #[test]
220    fn pre_commit_returns_empty_when_no_staged_changes() {
221        let tmp = TempDir::new().unwrap();
222        let config = setup_git_marketplace(&tmp, &["my-plugin"], &[("my-plugin", "my-plugin")]);
223
224        // No files staged, so pre-commit should produce no diagnostics
225        let result = run_pre_commit(&config);
226        assert!(
227            !result.has_errors(),
228            "Expected no errors for clean pre-commit, got: {:?}",
229            result.diagnostics
230        );
231        assert_eq!(
232            result.diagnostics.len(),
233            0,
234            "Expected zero diagnostics when nothing is staged"
235        );
236    }
237
238    #[test]
239    fn detect_changed_plugins_with_no_staged_files() {
240        let tmp = TempDir::new().unwrap();
241        let config = setup_git_marketplace(&tmp, &["alpha"], &[("alpha", "alpha")]);
242
243        let changed = detect_changed_plugins(&config).unwrap();
244        assert!(changed.is_empty());
245    }
246
247    #[test]
248    fn detect_changed_plugins_with_staged_plugin_file() {
249        let tmp = TempDir::new().unwrap();
250        let config = setup_git_marketplace(
251            &tmp,
252            &["alpha", "beta"],
253            &[("alpha", "alpha"), ("beta", "beta")],
254        );
255
256        // Stage a file inside alpha plugin
257        let test_file = tmp.path().join("plugins/alpha/test.txt");
258        std::fs::write(&test_file, "hello").unwrap();
259        Command::new("git")
260            .args(["add", "plugins/alpha/test.txt"])
261            .current_dir(tmp.path())
262            .output()
263            .expect("git add failed");
264
265        let changed = detect_changed_plugins(&config).unwrap();
266        assert_eq!(changed, vec!["alpha"]);
267    }
268
269    #[test]
270    fn detect_changed_plugins_deduplicates() {
271        let tmp = TempDir::new().unwrap();
272        let config = setup_git_marketplace(&tmp, &["alpha"], &[("alpha", "alpha")]);
273
274        // Stage two files inside the same plugin
275        let file1 = tmp.path().join("plugins/alpha/a.txt");
276        let file2 = tmp.path().join("plugins/alpha/b.txt");
277        std::fs::write(&file1, "a").unwrap();
278        std::fs::write(&file2, "b").unwrap();
279        Command::new("git")
280            .args(["add", "plugins/alpha/a.txt", "plugins/alpha/b.txt"])
281            .current_dir(tmp.path())
282            .output()
283            .expect("git add failed");
284
285        let changed = detect_changed_plugins(&config).unwrap();
286        assert_eq!(changed, vec!["alpha"]);
287    }
288
289    #[test]
290    fn is_marketplace_staged_returns_false_when_not_staged() {
291        let tmp = TempDir::new().unwrap();
292        let config = setup_git_marketplace(&tmp, &["alpha"], &[("alpha", "alpha")]);
293
294        assert!(!is_marketplace_staged(&config).unwrap());
295    }
296
297    #[test]
298    fn is_marketplace_staged_returns_true_when_staged() {
299        let tmp = TempDir::new().unwrap();
300        let config = setup_git_marketplace(&tmp, &["alpha"], &[("alpha", "alpha")]);
301
302        // Stage the marketplace.json
303        Command::new("git")
304            .args(["add", ".claude-plugin/marketplace.json"])
305            .current_dir(tmp.path())
306            .output()
307            .expect("git add failed");
308
309        assert!(is_marketplace_staged(&config).unwrap());
310    }
311
312    #[test]
313    fn pre_commit_validates_staged_plugin() {
314        let tmp = TempDir::new().unwrap();
315        let config = setup_git_marketplace(&tmp, &["alpha"], &[("alpha", "alpha")]);
316
317        // Stage a file in alpha
318        let test_file = tmp.path().join("plugins/alpha/readme.txt");
319        std::fs::write(&test_file, "some content").unwrap();
320        Command::new("git")
321            .args(["add", "plugins/alpha/readme.txt"])
322            .current_dir(tmp.path())
323            .output()
324            .expect("git add failed");
325
326        // alpha is valid, so pre-commit should pass
327        let result = run_pre_commit(&config);
328        assert!(
329            !result.has_errors(),
330            "Expected valid plugin to pass pre-commit: {:?}",
331            result.diagnostics
332        );
333    }
334
335    #[test]
336    fn pre_commit_catches_invalid_staged_plugin() {
337        let tmp = TempDir::new().unwrap();
338        let config = setup_git_marketplace(&tmp, &["broken"], &[("broken", "broken")]);
339
340        // Corrupt the plugin
341        let plugin_json = tmp.path().join("plugins/broken/.claude-plugin/plugin.json");
342        std::fs::write(&plugin_json, "not json").unwrap();
343
344        // Stage a file in broken
345        let test_file = tmp.path().join("plugins/broken/file.txt");
346        std::fs::write(&test_file, "content").unwrap();
347        Command::new("git")
348            .args(["add", "plugins/broken/file.txt"])
349            .current_dir(tmp.path())
350            .output()
351            .expect("git add failed");
352
353        let result = run_pre_commit(&config);
354        assert!(result.has_errors());
355    }
356
357    #[test]
358    fn pre_commit_validates_marketplace_when_staged() {
359        let tmp = TempDir::new().unwrap();
360        let config = setup_git_marketplace(&tmp, &["alpha"], &[("alpha", "alpha")]);
361
362        // Stage marketplace.json
363        Command::new("git")
364            .args(["add", ".claude-plugin/marketplace.json"])
365            .current_dir(tmp.path())
366            .output()
367            .expect("git add failed");
368
369        // marketplace is valid, so should pass
370        let result = run_pre_commit(&config);
371        assert!(
372            !result.has_errors(),
373            "Expected valid marketplace to pass pre-commit: {:?}",
374            result.diagnostics
375        );
376    }
377}