1use 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
17const LOCAL_CONFIG_FILE: &str = ".cwconfig.json";
19
20#[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
35fn 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
53fn 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
63pub 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
94pub 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
112fn 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
121pub fn normalize_event_name(event: &str) -> String {
124 if HOOK_EVENTS.contains(&event) {
126 return event.to_string();
127 }
128
129 let normalized = event.replace('-', "_");
131 if HOOK_EVENTS.contains(&normalized.as_str()) {
132 return normalized;
133 }
134
135 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 event.to_string()
160}
161
162pub 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 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
208pub 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
230pub 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
255pub 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
261pub 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 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}