Skip to main content

git_gardener/
hooks.rs

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        // 宛先ディレクトリを作成
45        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        // 環境変数を展開
84        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        // POSIXシェルでコマンドを実行
92        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        // ワーキングディレクトリが存在する場合のみ設定
103        if working_dir.exists() {
104            cmd.current_dir(working_dir);
105        }
106        
107        // 環境変数を設定
108        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        // What: HookExecutor::newが正しくインスタンスを作成するかテスト
134        let _executor = HookExecutor::new();
135    }
136
137    #[test]
138    fn test_execute_copy_hook_copies_file() {
139        // What: copyフックがファイルを正しくコピーするかテスト
140        let temp_dir = tempdir().unwrap();
141        let worktree_path = temp_dir.path().join("worktree");
142        fs::create_dir_all(&worktree_path).unwrap();
143        
144        // ソースファイルを作成
145        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        // ファイルがコピーされたことを確認
162        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        // What: fromフィールドがないcopyフックがエラーになるかテスト
171        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        // What: toフィールドがないcopyフックがエラーになるかテスト
192        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        // What: 存在しないソースファイルでcopyフックがエラーになるかテスト
213        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        // What: commandフックがコマンドを正しく実行するかテスト
234        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        // コマンドが実行されたことを確認
252        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        // What: commandフィールドがないcommandフックがエラーになるかテスト
259        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        // What: 環境変数のプレースホルダが正しく置換されるかテスト
280        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        // What: 複数のフックが順番に実行されるかテスト
294        let temp_dir = tempdir().unwrap();
295        let worktree_path = temp_dir.path().join("worktree");
296        fs::create_dir_all(&worktree_path).unwrap();
297        
298        // ソースファイルを作成
299        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        // 両方のフックが実行されたことを確認
325        assert!(worktree_path.join("copied.txt").exists());
326        assert!(worktree_path.join("executed.txt").exists());
327    }
328}