Skip to main content

git_worktree_manager/
hooks.rs

1/// Hook execution system for git-worktree-manager.
2///
3/// Hooks allow users to run custom commands at lifecycle events.
4/// Stored per-repository in .cwconfig.json.
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12use console::style;
13
14use crate::error::{CwError, Result};
15
16/// Valid hook events.
17pub const HOOK_EVENTS: &[&str] = &[
18    "worktree.pre_create",
19    "worktree.post_create",
20    "worktree.pre_delete",
21    "worktree.post_delete",
22    "merge.pre",
23    "merge.post",
24    "pr.pre",
25    "pr.post",
26    "resume.pre",
27    "resume.post",
28    "sync.pre",
29    "sync.post",
30];
31
32/// Local config file name (stored in repository root).
33const LOCAL_CONFIG_FILE: &str = ".cwconfig.json";
34
35/// A single hook entry.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct HookEntry {
38    pub id: String,
39    pub command: String,
40    #[serde(default = "default_true")]
41    pub enabled: bool,
42    #[serde(default)]
43    pub description: String,
44}
45
46fn default_true() -> bool {
47    true
48}
49
50/// Find the git repository root by walking up from start_path.
51fn find_repo_root(start_path: Option<&Path>) -> Option<PathBuf> {
52    let start = start_path
53        .map(|p| p.to_path_buf())
54        .or_else(|| std::env::current_dir().ok())?;
55
56    let mut current = start.canonicalize().unwrap_or(start);
57    loop {
58        if current.join(".git").exists() {
59            return Some(current);
60        }
61        if !current.pop() {
62            break;
63        }
64    }
65    None
66}
67
68/// Get the path to the local config file.
69fn get_hooks_file_path(repo_root: Option<&Path>) -> Option<PathBuf> {
70    let root = if let Some(r) = repo_root {
71        r.to_path_buf()
72    } else {
73        find_repo_root(None)?
74    };
75    Some(root.join(LOCAL_CONFIG_FILE))
76}
77
78/// Load hooks configuration from the repository.
79pub fn load_hooks_config(repo_root: Option<&Path>) -> HashMap<String, Vec<HookEntry>> {
80    let hooks_file = match get_hooks_file_path(repo_root) {
81        Some(p) if p.exists() => p,
82        _ => return HashMap::new(),
83    };
84
85    let content = match std::fs::read_to_string(&hooks_file) {
86        Ok(c) => c,
87        Err(_) => return HashMap::new(),
88    };
89
90    let data: Value = match serde_json::from_str(&content) {
91        Ok(v) => v,
92        Err(_) => return HashMap::new(),
93    };
94
95    let hooks_obj = match data.get("hooks") {
96        Some(Value::Object(m)) => m,
97        _ => return HashMap::new(),
98    };
99
100    let mut result = HashMap::new();
101    for (event, entries) in hooks_obj {
102        if let Ok(hooks) = serde_json::from_value::<Vec<HookEntry>>(entries.clone()) {
103            result.insert(event.clone(), hooks);
104        }
105    }
106    result
107}
108
109/// Save hooks configuration.
110pub fn save_hooks_config(
111    hooks: &HashMap<String, Vec<HookEntry>>,
112    repo_root: Option<&Path>,
113) -> Result<()> {
114    let root = if let Some(r) = repo_root {
115        r.to_path_buf()
116    } else {
117        find_repo_root(None).ok_or_else(|| CwError::Hook("Not in a git repository".to_string()))?
118    };
119
120    let config_file = root.join(LOCAL_CONFIG_FILE);
121    let data = serde_json::json!({ "hooks": hooks });
122    let content = serde_json::to_string_pretty(&data)?;
123    std::fs::write(&config_file, content)?;
124    Ok(())
125}
126
127/// Generate a unique ID for a hook based on command hash.
128fn generate_hook_id(command: &str) -> String {
129    use std::collections::hash_map::DefaultHasher;
130    use std::hash::{Hash, Hasher};
131    let mut hasher = DefaultHasher::new();
132    command.hash(&mut hasher);
133    format!("hook-{:08x}", hasher.finish() as u32)
134}
135
136/// Add a new hook for an event.
137pub fn add_hook(
138    event: &str,
139    command: &str,
140    hook_id: Option<&str>,
141    description: Option<&str>,
142) -> Result<String> {
143    if !HOOK_EVENTS.contains(&event) {
144        return Err(CwError::Hook(format!(
145            "Invalid hook event: {}. Valid events: {}",
146            event,
147            HOOK_EVENTS.join(", ")
148        )));
149    }
150
151    let mut hooks = load_hooks_config(None);
152    let event_hooks = hooks.entry(event.to_string()).or_default();
153
154    let id = hook_id
155        .map(|s| s.to_string())
156        .unwrap_or_else(|| generate_hook_id(command));
157
158    // Check for duplicate
159    if event_hooks.iter().any(|h| h.id == id) {
160        return Err(CwError::Hook(format!(
161            "Hook with ID '{}' already exists for event '{}'",
162            id, event
163        )));
164    }
165
166    event_hooks.push(HookEntry {
167        id: id.clone(),
168        command: command.to_string(),
169        enabled: true,
170        description: description.unwrap_or("").to_string(),
171    });
172
173    save_hooks_config(&hooks, None)?;
174    Ok(id)
175}
176
177/// Remove a hook by event and ID.
178pub fn remove_hook(event: &str, hook_id: &str) -> Result<()> {
179    let mut hooks = load_hooks_config(None);
180    let event_hooks = hooks
181        .get_mut(event)
182        .ok_or_else(|| CwError::Hook(format!("No hooks found for event '{}'", event)))?;
183
184    let original_len = event_hooks.len();
185    event_hooks.retain(|h| h.id != hook_id);
186
187    if event_hooks.len() == original_len {
188        return Err(CwError::Hook(format!(
189            "Hook '{}' not found for event '{}'",
190            hook_id, event
191        )));
192    }
193
194    save_hooks_config(&hooks, None)?;
195    println!("* Removed hook '{}' from {}", hook_id, event);
196    Ok(())
197}
198
199/// Enable or disable a hook.
200pub fn set_hook_enabled(event: &str, hook_id: &str, enabled: bool) -> Result<()> {
201    let mut hooks = load_hooks_config(None);
202    let event_hooks = hooks
203        .get_mut(event)
204        .ok_or_else(|| CwError::Hook(format!("No hooks found for event '{}'", event)))?;
205
206    let hook = event_hooks
207        .iter_mut()
208        .find(|h| h.id == hook_id)
209        .ok_or_else(|| {
210            CwError::Hook(format!(
211                "Hook '{}' not found for event '{}'",
212                hook_id, event
213            ))
214        })?;
215
216    hook.enabled = enabled;
217    save_hooks_config(&hooks, None)?;
218
219    let action = if enabled { "Enabled" } else { "Disabled" };
220    println!("* {} hook '{}'", action, hook_id);
221    Ok(())
222}
223
224/// Get hooks for a specific event.
225pub fn get_hooks(event: &str, repo_root: Option<&Path>) -> Vec<HookEntry> {
226    let hooks = load_hooks_config(repo_root);
227    hooks.get(event).cloned().unwrap_or_default()
228}
229
230/// Run all enabled hooks for an event.
231///
232/// Pre-hooks (containing ".pre") abort the operation on failure.
233/// Post-hooks log warnings but don't abort.
234pub fn run_hooks(
235    event: &str,
236    context: &HashMap<String, String>,
237    cwd: Option<&Path>,
238    repo_root: Option<&Path>,
239) -> Result<bool> {
240    let hooks = get_hooks(event, repo_root);
241    if hooks.is_empty() {
242        return Ok(true);
243    }
244
245    let enabled: Vec<&HookEntry> = hooks.iter().filter(|h| h.enabled).collect();
246    if enabled.is_empty() {
247        return Ok(true);
248    }
249
250    let is_pre_hook = event.contains(".pre");
251
252    eprintln!(
253        "{} Running {} hook(s) for {}...",
254        style("*").cyan().bold(),
255        enabled.len(),
256        style(event).yellow()
257    );
258
259    // Build environment
260    let mut env: HashMap<String, String> = std::env::vars().collect();
261    for (key, value) in context {
262        env.insert(format!("CW_{}", key.to_uppercase()), value.clone());
263    }
264
265    let mut all_succeeded = true;
266
267    for hook in enabled {
268        let desc_suffix = if hook.description.is_empty() {
269            String::new()
270        } else {
271            format!(" ({})", hook.description)
272        };
273        eprintln!(
274            "  {} {}{}",
275            style("Running:").dim(),
276            style(&hook.id).bold(),
277            style(desc_suffix).dim()
278        );
279
280        let mut cmd = if cfg!(target_os = "windows") {
281            let mut c = Command::new("cmd");
282            c.args(["/C", &hook.command]);
283            c
284        } else {
285            let mut c = Command::new("sh");
286            c.args(["-c", &hook.command]);
287            c
288        };
289
290        cmd.envs(&env);
291        if let Some(dir) = cwd {
292            cmd.current_dir(dir);
293        }
294        cmd.stdout(std::process::Stdio::piped());
295        cmd.stderr(std::process::Stdio::piped());
296
297        match cmd.output() {
298            Ok(output) => {
299                if !output.status.success() {
300                    all_succeeded = false;
301                    let code = output.status.code().unwrap_or(-1);
302                    eprintln!(
303                        "  {} Hook '{}' failed (exit code {})",
304                        style("x").red().bold(),
305                        style(&hook.id).bold(),
306                        code
307                    );
308
309                    let stderr = String::from_utf8_lossy(&output.stderr);
310                    for line in stderr.lines().take(5) {
311                        eprintln!("    {}", style(line).dim());
312                    }
313
314                    if is_pre_hook {
315                        return Err(CwError::Hook(format!(
316                            "Pre-hook '{}' failed with exit code {}. Operation aborted.",
317                            hook.id, code
318                        )));
319                    }
320                } else {
321                    eprintln!(
322                        "  {} Hook '{}' completed",
323                        style("*").green().bold(),
324                        style(&hook.id).bold()
325                    );
326                }
327            }
328            Err(e) => {
329                all_succeeded = false;
330                eprintln!(
331                    "  {} Hook '{}' failed: {}",
332                    style("x").red().bold(),
333                    style(&hook.id).bold(),
334                    e
335                );
336                if is_pre_hook {
337                    return Err(CwError::Hook(format!(
338                        "Pre-hook '{}' failed to execute: {}",
339                        hook.id, e
340                    )));
341                }
342            }
343        }
344    }
345
346    if !all_succeeded && !is_pre_hook {
347        eprintln!(
348            "{} Some post-hooks failed. See output above.",
349            style("Warning:").yellow().bold()
350        );
351    }
352
353    Ok(all_succeeded)
354}