1use 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
14pub 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
30const LOCAL_CONFIG_FILE: &str = ".cwconfig.json";
32
33#[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
48fn 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
66fn 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
76pub 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
107pub 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
125fn 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
134pub 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 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
175pub 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
197pub 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
222pub 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
228pub 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 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}