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
14use crate::error::{CwError, Result};
15
16pub 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
32const LOCAL_CONFIG_FILE: &str = ".cwconfig.json";
34
35#[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
50fn 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
68fn 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
78pub 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
109pub 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
127fn 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
136pub 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 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
177pub 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
199pub 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
224pub 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
230pub 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 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}