Skip to content

Commit 44c3f48

Browse files
authored
Merge pull request #10 from wasabeef/feat-hook-confirm
feat: add user confirmation before executing hooks
2 parents 982f0fa + b4a78cb commit 44c3f48

File tree

3 files changed

+298
-9
lines changed

3 files changed

+298
-9
lines changed

src/infrastructure/hooks.rs

Lines changed: 105 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ use std::process::Command;
3434

3535
use super::super::config::Config;
3636
use super::super::constants::*;
37+
use super::super::ui::UserInterface;
3738

3839
/// Context information passed to hook commands
3940
///
@@ -70,16 +71,17 @@ pub struct HookContext {
7071
pub worktree_path: PathBuf,
7172
}
7273

73-
/// Executes configured hooks for a specific event type
74+
/// Executes configured hooks for a specific event type with user confirmation
7475
///
7576
/// This function loads the configuration, looks up hooks for the specified
76-
/// event type, and executes them in order. Each command is run in a shell
77-
/// with the worktree directory as the working directory.
77+
/// event type, asks for user confirmation, and executes them in order.
78+
/// Each command is run in a shell with the worktree directory as the working directory.
7879
///
7980
/// # Arguments
8081
///
8182
/// * `hook_type` - The type of hook to execute (e.g., "post-create", "pre-remove")
8283
/// * `context` - Context information about the worktree
84+
/// * `ui` - User interface for confirmation prompts
8385
///
8486
/// # Hook Types
8587
///
@@ -96,16 +98,18 @@ pub struct HookContext {
9698
/// # Example
9799
///
98100
/// ```no_run
99-
/// use git_workers::hooks::{execute_hooks, HookContext};
101+
/// use git_workers::hooks::{execute_hooks_with_ui, HookContext};
102+
/// use git_workers::ui::DialoguerUI;
100103
/// use std::path::PathBuf;
101104
///
102105
/// let context = HookContext {
103106
/// worktree_name: "feature-branch".to_string(),
104107
/// worktree_path: PathBuf::from("/path/to/worktree"),
105108
/// };
109+
/// let ui = DialoguerUI;
106110
///
107111
/// // Execute post-create hooks
108-
/// execute_hooks("post-create", &context).ok();
112+
/// execute_hooks_with_ui("post-create", &context, &ui).ok();
109113
/// ```
110114
///
111115
/// # Configuration Loading
@@ -122,17 +126,47 @@ pub struct HookContext {
122126
///
123127
/// Command execution errors (spawn failures) are also handled gracefully,
124128
/// allowing other hooks to continue even if one command fails to start.
125-
pub fn execute_hooks(hook_type: &str, context: &HookContext) -> Result<()> {
129+
pub fn execute_hooks_with_ui(
130+
hook_type: &str,
131+
context: &HookContext,
132+
ui: &dyn UserInterface,
133+
) -> Result<()> {
126134
// Always load config from the current directory where the command is executed,
127135
// not from the newly created worktree which doesn't have a config yet
128136
let config = Config::load()?;
129137

130138
if let Some(commands) = config.hooks.get(hook_type) {
139+
if commands.is_empty() {
140+
return Ok(());
141+
}
142+
143+
// Ask for confirmation before running hooks
144+
println!();
131145
println!(
132-
"{} {hook_type} hooks...",
146+
"{} {hook_type} hooks found:",
133147
INFO_RUNNING_HOOKS.replace("{}", "").trim()
134148
);
149+
for cmd in commands {
150+
let expanded_cmd = cmd
151+
.replace(TEMPLATE_WORKTREE_NAME, &context.worktree_name)
152+
.replace(
153+
TEMPLATE_WORKTREE_PATH,
154+
&context.worktree_path.display().to_string(),
155+
);
156+
println!(" • {expanded_cmd}");
157+
}
158+
159+
println!();
160+
let confirm = ui
161+
.confirm_with_default(&format!("Execute {hook_type} hooks?"), true)
162+
.unwrap_or(false);
135163

164+
if !confirm {
165+
println!("Skipping {hook_type} hooks.");
166+
return Ok(());
167+
}
168+
169+
println!();
136170
for cmd in commands {
137171
// Replace template placeholders with actual values
138172
let expanded_cmd = cmd
@@ -183,9 +217,40 @@ pub fn execute_hooks(hook_type: &str, context: &HookContext) -> Result<()> {
183217
Ok(())
184218
}
185219

220+
/// Executes configured hooks for a specific event type (legacy interface)
221+
///
222+
/// This is a convenience wrapper that creates a DialoguerUI instance
223+
/// for backward compatibility with existing code.
224+
///
225+
/// # Arguments
226+
///
227+
/// * `hook_type` - The type of hook to execute (e.g., "post-create", "pre-remove")
228+
/// * `context` - Context information about the worktree
229+
///
230+
/// # Example
231+
///
232+
/// ```no_run
233+
/// use git_workers::hooks::{execute_hooks, HookContext};
234+
/// use std::path::PathBuf;
235+
///
236+
/// let context = HookContext {
237+
/// worktree_name: "feature-branch".to_string(),
238+
/// worktree_path: PathBuf::from("/path/to/worktree"),
239+
/// };
240+
///
241+
/// // Execute post-create hooks
242+
/// execute_hooks("post-create", &context).ok();
243+
/// ```
244+
pub fn execute_hooks(hook_type: &str, context: &HookContext) -> Result<()> {
245+
use super::super::ui::DialoguerUI;
246+
let ui = DialoguerUI;
247+
execute_hooks_with_ui(hook_type, context, &ui)
248+
}
249+
186250
#[cfg(test)]
187251
mod tests {
188252
use super::*;
253+
use crate::ui::MockUI;
189254
use tempfile::TempDir;
190255

191256
#[test]
@@ -257,4 +322,37 @@ mod tests {
257322

258323
assert_eq!(expanded, "npm install");
259324
}
325+
326+
#[test]
327+
fn test_hook_execution_with_confirmation() {
328+
let context = HookContext {
329+
worktree_name: "test".to_string(),
330+
worktree_path: PathBuf::from("/test/path"),
331+
};
332+
333+
// Test with confirmation accepted
334+
let ui = MockUI::new().with_confirm(true);
335+
// This would require a full test setup with config
336+
// but we can test the interface exists
337+
let _result = execute_hooks_with_ui("post-create", &context, &ui);
338+
339+
// Test with confirmation rejected
340+
let ui = MockUI::new().with_confirm(false);
341+
let _result = execute_hooks_with_ui("post-create", &context, &ui);
342+
}
343+
344+
#[test]
345+
fn test_hook_confirmation_prompt_display() {
346+
// Test that proper hook information is displayed before confirmation
347+
let context = HookContext {
348+
worktree_name: "feature-xyz".to_string(),
349+
worktree_path: PathBuf::from("/workspace/feature-xyz"),
350+
};
351+
352+
// Mock UI that rejects confirmation
353+
let ui = MockUI::new().with_confirm(false);
354+
355+
// In real usage, this would show hook commands before asking
356+
let _result = execute_hooks_with_ui("post-create", &context, &ui);
357+
}
260358
}

src/infrastructure/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ pub mod hooks;
1515
pub use file_copy::copy_configured_files;
1616
pub use filesystem::{FileSystem, RealFileSystem};
1717
pub use git::{GitWorktreeManager, WorktreeInfo};
18-
pub use hooks::{execute_hooks, HookContext};
18+
pub use hooks::{execute_hooks, execute_hooks_with_ui, HookContext};
1919

2020
// Re-export FilesConfig from config module
2121
pub use super::config::FilesConfig;

tests/unit/infrastructure/hooks.rs

Lines changed: 192 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
//! and template variable substitution.
55
66
use anyhow::Result;
7-
use git_workers::infrastructure::hooks::{execute_hooks, HookContext};
7+
use git_workers::infrastructure::hooks::{execute_hooks, execute_hooks_with_ui, HookContext};
8+
use git_workers::ui::MockUI;
89
use std::fs;
910
use std::path::PathBuf;
1011
use tempfile::TempDir;
@@ -236,3 +237,193 @@ fn test_hook_execution_flow_simulation() -> Result<()> {
236237

237238
Ok(())
238239
}
240+
241+
// ============================================================================
242+
// Hook Confirmation Tests
243+
// ============================================================================
244+
245+
#[test]
246+
#[ignore = "Hook execution requires specific command availability"]
247+
fn test_hook_confirmation_accepted() -> Result<()> {
248+
let temp_dir = TempDir::new()?;
249+
250+
// Initialize git repository
251+
git2::Repository::init(temp_dir.path())?;
252+
253+
// Create config with hooks
254+
let config_content = r#"
255+
[hooks]
256+
post-create = ["echo 'test hook'"]
257+
"#;
258+
fs::write(temp_dir.path().join(".git-workers.toml"), config_content)?;
259+
260+
let original_dir = std::env::current_dir()?;
261+
std::env::set_current_dir(temp_dir.path())?;
262+
263+
let context = HookContext {
264+
worktree_name: "test".to_string(),
265+
worktree_path: temp_dir.path().to_path_buf(),
266+
};
267+
268+
// Mock UI that accepts confirmation
269+
let ui = MockUI::new().with_confirm(true);
270+
271+
// Execute hooks with UI - should succeed when confirmation is accepted
272+
let result = execute_hooks_with_ui("post-create", &context, &ui);
273+
274+
std::env::set_current_dir(original_dir)?;
275+
276+
assert!(result.is_ok());
277+
Ok(())
278+
}
279+
280+
#[test]
281+
#[ignore = "Hook execution requires specific command availability"]
282+
fn test_hook_confirmation_rejected() -> Result<()> {
283+
let temp_dir = TempDir::new()?;
284+
285+
// Initialize git repository
286+
git2::Repository::init(temp_dir.path())?;
287+
288+
// Create config with hooks
289+
let config_content = r#"
290+
[hooks]
291+
post-create = ["echo 'test hook'"]
292+
"#;
293+
fs::write(temp_dir.path().join(".git-workers.toml"), config_content)?;
294+
295+
let original_dir = std::env::current_dir()?;
296+
std::env::set_current_dir(temp_dir.path())?;
297+
298+
let context = HookContext {
299+
worktree_name: "test".to_string(),
300+
worktree_path: temp_dir.path().to_path_buf(),
301+
};
302+
303+
// Mock UI that rejects confirmation
304+
let ui = MockUI::new().with_confirm(false);
305+
306+
// Execute hooks with UI - should succeed but skip execution
307+
let result = execute_hooks_with_ui("post-create", &context, &ui);
308+
309+
std::env::set_current_dir(original_dir)?;
310+
311+
// Should still return Ok even when hooks are skipped
312+
assert!(result.is_ok());
313+
Ok(())
314+
}
315+
316+
#[test]
317+
#[ignore = "Hook execution requires specific command availability"]
318+
fn test_hook_with_template_variables_confirmation() -> Result<()> {
319+
let temp_dir = TempDir::new()?;
320+
321+
// Initialize git repository
322+
git2::Repository::init(temp_dir.path())?;
323+
324+
// Create config with hooks using template variables
325+
let config_content = r#"
326+
[hooks]
327+
post-create = [
328+
"echo 'Created worktree: {{worktree_name}}'",
329+
"echo 'At path: {{worktree_path}}'"
330+
]
331+
"#;
332+
fs::write(temp_dir.path().join(".git-workers.toml"), config_content)?;
333+
334+
let original_dir = std::env::current_dir()?;
335+
std::env::set_current_dir(temp_dir.path())?;
336+
337+
let context = HookContext {
338+
worktree_name: "feature-xyz".to_string(),
339+
worktree_path: PathBuf::from("/workspace/feature-xyz"),
340+
};
341+
342+
// Mock UI that accepts confirmation
343+
let ui = MockUI::new().with_confirm(true);
344+
345+
// Execute hooks - template variables should be expanded in display
346+
let result = execute_hooks_with_ui("post-create", &context, &ui);
347+
348+
std::env::set_current_dir(original_dir)?;
349+
350+
assert!(result.is_ok());
351+
Ok(())
352+
}
353+
354+
#[test]
355+
#[ignore = "Hook execution requires specific command availability"]
356+
fn test_multiple_hook_types_with_confirmation() -> Result<()> {
357+
let temp_dir = TempDir::new()?;
358+
359+
// Initialize git repository
360+
git2::Repository::init(temp_dir.path())?;
361+
362+
// Create config with multiple hook types
363+
let config_content = r#"
364+
[hooks]
365+
post-create = ["echo 'post-create'"]
366+
pre-remove = ["echo 'pre-remove'"]
367+
post-switch = ["echo 'post-switch'"]
368+
"#;
369+
fs::write(temp_dir.path().join(".git-workers.toml"), config_content)?;
370+
371+
let original_dir = std::env::current_dir()?;
372+
std::env::set_current_dir(temp_dir.path())?;
373+
374+
let context = HookContext {
375+
worktree_name: "test".to_string(),
376+
worktree_path: temp_dir.path().to_path_buf(),
377+
};
378+
379+
// Test each hook type with different confirmation responses
380+
let hook_types = vec![
381+
("post-create", true),
382+
("pre-remove", false),
383+
("post-switch", true),
384+
];
385+
386+
for (hook_type, confirm) in hook_types {
387+
let ui = MockUI::new().with_confirm(confirm);
388+
let result = execute_hooks_with_ui(hook_type, &context, &ui);
389+
assert!(result.is_ok(), "Hook type {hook_type} should succeed");
390+
}
391+
392+
std::env::set_current_dir(original_dir)?;
393+
Ok(())
394+
}
395+
396+
#[test]
397+
#[ignore = "Hook execution requires specific command availability"]
398+
fn test_empty_hooks_no_confirmation_needed() -> Result<()> {
399+
let temp_dir = TempDir::new()?;
400+
401+
// Initialize git repository
402+
git2::Repository::init(temp_dir.path())?;
403+
404+
// Create config with empty hooks
405+
let config_content = r#"
406+
[hooks]
407+
post-create = []
408+
"#;
409+
fs::write(temp_dir.path().join(".git-workers.toml"), config_content)?;
410+
411+
let original_dir = std::env::current_dir()?;
412+
std::env::set_current_dir(temp_dir.path())?;
413+
414+
let context = HookContext {
415+
worktree_name: "test".to_string(),
416+
worktree_path: temp_dir.path().to_path_buf(),
417+
};
418+
419+
// Mock UI without any confirmations configured
420+
let ui = MockUI::new();
421+
422+
// Should succeed without asking for confirmation
423+
let result = execute_hooks_with_ui("post-create", &context, &ui);
424+
425+
std::env::set_current_dir(original_dir)?;
426+
427+
assert!(result.is_ok());
428+
Ok(())
429+
}

0 commit comments

Comments
 (0)