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