1use crate::config::Hook;
2use crate::error::{GitGardenerError, Result};
3use std::path::Path;
4use std::process::Command;
5use std::collections::HashMap;
6
7pub struct HookExecutor;
8
9impl HookExecutor {
10 pub fn new() -> Self {
11 Self
12 }
13
14 pub fn execute_hooks(&self, worktree_path: &Path, branch: &str, hooks: &[Hook]) -> Result<()> {
15 for hook in hooks {
16 match &hook.hook_type {
17 crate::config::HookType::Copy => {
18 self.execute_copy_hook(hook, worktree_path)?;
19 }
20 crate::config::HookType::Command => {
21 self.execute_command_hook(hook, worktree_path, branch)?;
22 }
23 }
24 }
25
26 Ok(())
27 }
28
29 fn execute_copy_hook(&self, hook: &Hook, worktree_path: &Path) -> Result<()> {
30 let from = hook.from.as_ref()
31 .ok_or_else(|| GitGardenerError::Custom("Copy hook requires 'from' field".to_string()))?;
32 let to = hook.to.as_ref()
33 .ok_or_else(|| GitGardenerError::Custom("Copy hook requires 'to' field".to_string()))?;
34
35 let source = Path::new(from);
36 let dest = worktree_path.join(to);
37
38 if !source.exists() {
39 return Err(GitGardenerError::Custom(
40 format!("Source file does not exist: {}", source.display())
41 ));
42 }
43
44 if let Some(parent) = dest.parent() {
46 std::fs::create_dir_all(parent)?;
47 }
48
49 std::fs::copy(source, &dest)?;
50 println!("✓ Copied {} to {}", source.display(), dest.display());
51
52 Ok(())
53 }
54
55 fn execute_command_hook(&self, hook: &Hook, worktree_path: &Path, branch: &str) -> Result<()> {
56 let command = hook.command.as_ref()
57 .ok_or_else(|| GitGardenerError::Custom("Command hook requires 'command' field".to_string()))?;
58
59 let expanded_command = self.expand_variables(command, worktree_path, branch);
60
61 let mut env = HashMap::new();
62 if let Some(hook_env) = &hook.env {
63 for (key, value) in hook_env {
64 env.insert(key.clone(), self.expand_variables(value, worktree_path, branch));
65 }
66 }
67
68 match self.execute_shell_command(&expanded_command, worktree_path, &env) {
69 Ok(_) => {
70 println!("✓ Executed: {}", expanded_command);
71 }
72 Err(e) => {
73 return Err(GitGardenerError::Custom(
74 format!("Command failed: {}", e)
75 ));
76 }
77 }
78
79 Ok(())
80 }
81
82 fn expand_variables(&self, command: &str, worktree_path: &Path, branch: &str) -> String {
83 command
85 .replace("${WORKTREE_PATH}", &worktree_path.display().to_string())
86 .replace("${BRANCH}", branch)
87 .replace("${REPO_ROOT}", &worktree_path.parent().unwrap_or(worktree_path).display().to_string())
88 }
89
90 fn execute_shell_command(&self, command: &str, working_dir: &Path, env: &HashMap<String, String>) -> Result<()> {
91 let mut cmd = if cfg!(target_os = "windows") {
93 let mut cmd = Command::new("cmd");
94 cmd.args(&["/C", command]);
95 cmd
96 } else {
97 let mut cmd = Command::new("sh");
98 cmd.args(&["-c", command]);
99 cmd
100 };
101
102 if working_dir.exists() {
104 cmd.current_dir(working_dir);
105 }
106
107 cmd.envs(env);
109
110 let output = cmd.output()?;
111
112 if !output.status.success() {
113 let stderr = String::from_utf8_lossy(&output.stderr);
114 return Err(GitGardenerError::Custom(
115 format!("Command failed with exit code {:?}: {}", output.status.code(), stderr)
116 ));
117 }
118
119 Ok(())
120 }
121
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use crate::config::{Hook, HookType};
128 use tempfile::tempdir;
129 use std::fs;
130
131 #[test]
132 fn test_hook_executor_new_creates_instance() {
133 let _executor = HookExecutor::new();
135 }
136
137 #[test]
138 fn test_execute_copy_hook_copies_file() {
139 let temp_dir = tempdir().unwrap();
141 let worktree_path = temp_dir.path().join("worktree");
142 fs::create_dir_all(&worktree_path).unwrap();
143
144 let source_file = temp_dir.path().join("source.txt");
146 fs::write(&source_file, "test content").unwrap();
147
148 let hook = Hook {
149 hook_type: HookType::Copy,
150 from: Some(source_file.to_string_lossy().to_string()),
151 to: Some("dest.txt".to_string()),
152 command: None,
153 env: None,
154 };
155
156 let executor = HookExecutor::new();
157 let result = executor.execute_copy_hook(&hook, &worktree_path);
158
159 assert!(result.is_ok());
160
161 let dest_file = worktree_path.join("dest.txt");
163 assert!(dest_file.exists());
164 let content = fs::read_to_string(dest_file).unwrap();
165 assert_eq!(content, "test content");
166 }
167
168 #[test]
169 fn test_execute_copy_hook_fails_without_from_field() {
170 let temp_dir = tempdir().unwrap();
172 let worktree_path = temp_dir.path().join("worktree");
173
174 let hook = Hook {
175 hook_type: HookType::Copy,
176 from: None,
177 to: Some("dest.txt".to_string()),
178 command: None,
179 env: None,
180 };
181
182 let executor = HookExecutor::new();
183 let result = executor.execute_copy_hook(&hook, &worktree_path);
184
185 assert!(result.is_err());
186 assert!(matches!(result.unwrap_err(), GitGardenerError::Custom(_)));
187 }
188
189 #[test]
190 fn test_execute_copy_hook_fails_without_to_field() {
191 let temp_dir = tempdir().unwrap();
193 let worktree_path = temp_dir.path().join("worktree");
194
195 let hook = Hook {
196 hook_type: HookType::Copy,
197 from: Some("source.txt".to_string()),
198 to: None,
199 command: None,
200 env: None,
201 };
202
203 let executor = HookExecutor::new();
204 let result = executor.execute_copy_hook(&hook, &worktree_path);
205
206 assert!(result.is_err());
207 assert!(matches!(result.unwrap_err(), GitGardenerError::Custom(_)));
208 }
209
210 #[test]
211 fn test_execute_copy_hook_fails_for_nonexistent_source() {
212 let temp_dir = tempdir().unwrap();
214 let worktree_path = temp_dir.path().join("worktree");
215
216 let hook = Hook {
217 hook_type: HookType::Copy,
218 from: Some("nonexistent.txt".to_string()),
219 to: Some("dest.txt".to_string()),
220 command: None,
221 env: None,
222 };
223
224 let executor = HookExecutor::new();
225 let result = executor.execute_copy_hook(&hook, &worktree_path);
226
227 assert!(result.is_err());
228 assert!(matches!(result.unwrap_err(), GitGardenerError::Custom(_)));
229 }
230
231 #[test]
232 fn test_execute_command_hook_runs_command() {
233 let temp_dir = tempdir().unwrap();
235 let worktree_path = temp_dir.path().join("worktree");
236 fs::create_dir_all(&worktree_path).unwrap();
237
238 let hook = Hook {
239 hook_type: HookType::Command,
240 from: None,
241 to: None,
242 command: Some("echo 'test' > test.txt".to_string()),
243 env: None,
244 };
245
246 let executor = HookExecutor::new();
247 let result = executor.execute_command_hook(&hook, &worktree_path, "test-branch");
248
249 assert!(result.is_ok());
250
251 let test_file = worktree_path.join("test.txt");
253 assert!(test_file.exists());
254 }
255
256 #[test]
257 fn test_execute_command_hook_fails_without_command_field() {
258 let temp_dir = tempdir().unwrap();
260 let worktree_path = temp_dir.path().join("worktree");
261
262 let hook = Hook {
263 hook_type: HookType::Command,
264 from: None,
265 to: None,
266 command: None,
267 env: None,
268 };
269
270 let executor = HookExecutor::new();
271 let result = executor.execute_command_hook(&hook, &worktree_path, "test-branch");
272
273 assert!(result.is_err());
274 assert!(matches!(result.unwrap_err(), GitGardenerError::Custom(_)));
275 }
276
277 #[test]
278 fn test_expand_variables_replaces_placeholders() {
279 let temp_dir = tempdir().unwrap();
281 let worktree_path = temp_dir.path().join("worktree");
282
283 let executor = HookExecutor::new();
284 let command = "echo '${BRANCH}' '${WORKTREE_PATH}'";
285 let expanded = executor.expand_variables(command, &worktree_path, "feature-test");
286
287 assert!(expanded.contains("feature-test"));
288 assert!(expanded.contains(&worktree_path.display().to_string()));
289 }
290
291 #[test]
292 fn test_execute_hooks_runs_multiple_hooks() {
293 let temp_dir = tempdir().unwrap();
295 let worktree_path = temp_dir.path().join("worktree");
296 fs::create_dir_all(&worktree_path).unwrap();
297
298 let source_file = temp_dir.path().join("source.txt");
300 fs::write(&source_file, "test content").unwrap();
301
302 let hooks = vec![
303 Hook {
304 hook_type: HookType::Copy,
305 from: Some(source_file.to_string_lossy().to_string()),
306 to: Some("copied.txt".to_string()),
307 command: None,
308 env: None,
309 },
310 Hook {
311 hook_type: HookType::Command,
312 from: None,
313 to: None,
314 command: Some("echo 'command executed' > executed.txt".to_string()),
315 env: None,
316 },
317 ];
318
319 let executor = HookExecutor::new();
320 let result = executor.execute_hooks(&worktree_path, "test-branch", &hooks);
321
322 assert!(result.is_ok());
323
324 assert!(worktree_path.join("copied.txt").exists());
326 assert!(worktree_path.join("executed.txt").exists());
327 }
328}