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