use std::collections::HashMap;
use std::fmt::Write as _;
use anyhow::Context;
use color_print::cformat;
use strum::IntoEnumIterator;
use worktrunk::HookType;
use worktrunk::config::{Approvals, CommandConfig, ProjectConfig, UserConfig};
use worktrunk::git::{GitError, Repository};
use worktrunk::path::format_path_for_display;
use worktrunk::styling::{
INFO_SYMBOL, PROMPT_SYMBOL, eprintln, format_bash_with_gutter, format_heading, hint_message,
info_message, success_message,
};
use super::command_approval::approve_hooks_filtered;
use super::command_executor::build_hook_context;
use super::command_executor::CommandContext;
use super::context::CommandEnv;
use super::hooks::{
HookCommandSpec, HookFailureStrategy, SourcedStep, check_name_filter_matched,
prepare_background_hooks, prepare_hook_commands, run_hook_with_filter, spawn_hook_pipeline,
};
use super::project_config::collect_commands_for_hooks;
fn run_filtered_hook(
ctx: &CommandContext,
user_config: Option<&CommandConfig>,
project_config: Option<&CommandConfig>,
hook_type: HookType,
extra_vars: &[(&str, &str)],
name_filter: Option<&str>,
failure_strategy: HookFailureStrategy,
) -> anyhow::Result<()> {
run_hook_with_filter(
ctx,
HookCommandSpec {
user_config,
project_config,
hook_type,
extra_vars,
name_filter,
display_path: crate::output::pre_hook_display_path(ctx.worktree_path),
},
failure_strategy,
)
}
fn run_post_hook(
ctx: &CommandContext,
foreground: Option<bool>,
user_config: Option<&CommandConfig>,
project_config: Option<&CommandConfig>,
hook_type: HookType,
extra_vars: &[(&str, &str)],
name_filter: Option<&str>,
) -> anyhow::Result<()> {
if !foreground.unwrap_or(false) {
if name_filter.is_some() {
let commands = prepare_hook_commands(
ctx,
HookCommandSpec {
user_config,
project_config,
hook_type,
extra_vars,
name_filter,
display_path: None,
},
)?;
check_name_filter_matched(name_filter, commands.len(), user_config, project_config)?;
let steps: Vec<SourcedStep> = commands
.into_iter()
.map(|cmd| SourcedStep {
step: super::command_executor::PreparedStep::Single(cmd.prepared),
source: cmd.source,
hook_type: cmd.hook_type,
display_path: cmd.display_path,
})
.collect();
return spawn_hook_pipeline(ctx, steps);
}
for steps in prepare_background_hooks(ctx, hook_type, extra_vars, None)? {
spawn_hook_pipeline(ctx, steps)?;
}
return Ok(());
}
run_filtered_hook(
ctx,
user_config,
project_config,
hook_type,
extra_vars,
name_filter,
HookFailureStrategy::Warn,
)
}
fn build_manual_hook_extra_vars<'a>(
ctx: &'a CommandContext,
hook_type: HookType,
custom_vars: &'a [(&'a str, &'a str)],
default_branch: Option<&'a str>,
worktree_path_str: &'a str,
) -> Vec<(&'a str, &'a str)> {
let branch = ctx.branch_or_head();
let mut vars: Vec<(&str, &str)> = match hook_type {
HookType::PreCommit | HookType::PostCommit => {
default_branch.into_iter().map(|t| ("target", t)).collect()
}
HookType::PreMerge | HookType::PostMerge => {
vec![
("target", branch),
("target_worktree_path", worktree_path_str),
]
}
HookType::PreSwitch | HookType::PreStart | HookType::PostStart | HookType::PostSwitch => {
vec![
("base", branch),
("base_worktree_path", worktree_path_str),
("target", branch),
("target_worktree_path", worktree_path_str),
]
}
HookType::PreRemove | HookType::PostRemove => {
vec![
("target", branch),
("target_worktree_path", worktree_path_str),
]
}
};
vars.extend(custom_vars.iter().copied());
vars
}
pub fn run_hook(
hook_type: HookType,
yes: bool,
foreground: Option<bool>,
dry_run: bool,
name_filter: Option<&str>,
custom_vars: &[(String, String)],
) -> anyhow::Result<()> {
let env = CommandEnv::for_action_branchless()?;
let repo = &env.repo;
let ctx = env.context(yes);
let project_config = repo.load_project_config()?;
if !dry_run {
let approved = approve_hooks_filtered(&ctx, &[hook_type], name_filter)?;
if !approved {
eprintln!("{}", worktrunk::styling::info_message("Commands declined"));
return Ok(());
}
}
let custom_vars_refs: Vec<(&str, &str)> = custom_vars
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
fn require_hooks(
user: Option<&CommandConfig>,
project: Option<&CommandConfig>,
hook_type: HookType,
) -> anyhow::Result<()> {
if user.is_none() && project.is_none() {
return Err(worktrunk::git::GitError::Other {
message: format!("No {hook_type} hook configured; checked both user and project"),
}
.into());
}
Ok(())
}
let user_hooks = ctx.config.hooks(ctx.project_id().as_deref());
let (user_config, proj_config) = (
user_hooks.get(hook_type),
project_config.as_ref().and_then(|c| c.hooks.get(hook_type)),
);
require_hooks(user_config, proj_config, hook_type)?;
let default_branch = repo.default_branch();
let worktree_path_str = worktrunk::path::to_posix_path(&ctx.worktree_path.to_string_lossy());
let extra_vars = build_manual_hook_extra_vars(
&ctx,
hook_type,
&custom_vars_refs,
default_branch.as_deref(),
&worktree_path_str,
);
if dry_run {
let commands = prepare_hook_commands(
&ctx,
HookCommandSpec {
user_config,
project_config: proj_config,
hook_type,
extra_vars: &extra_vars,
name_filter,
display_path: None,
},
)?;
check_name_filter_matched(name_filter, commands.len(), user_config, proj_config)?;
for cmd in &commands {
let label = match &cmd.prepared.name {
Some(n) => {
let display_name = format!("{}:{}", cmd.source, n);
cformat!("{hook_type} <bold>{display_name}</> would run:")
}
None => cformat!("{hook_type} {} hook would run:", cmd.source),
};
eprintln!(
"{}",
info_message(cformat!(
"{label}\n{}",
format_bash_with_gutter(&cmd.prepared.expanded)
))
);
}
return Ok(());
}
match hook_type {
HookType::PreSwitch
| HookType::PreStart
| HookType::PreRemove
| HookType::PreCommit
| HookType::PreMerge => run_filtered_hook(
&ctx,
user_config,
proj_config,
hook_type,
&extra_vars,
name_filter,
HookFailureStrategy::FailFast,
),
HookType::PostStart
| HookType::PostSwitch
| HookType::PostCommit
| HookType::PostMerge
| HookType::PostRemove => run_post_hook(
&ctx,
foreground,
user_config,
proj_config,
hook_type,
&extra_vars,
name_filter,
),
}
}
pub fn add_approvals(show_all: bool) -> anyhow::Result<()> {
use super::command_approval::approve_command_batch;
let repo = Repository::current()?;
let project_id = repo.project_identifier()?;
let approvals = Approvals::load().context("Failed to load approvals")?;
let config_path = repo
.project_config_path()?
.context("Cannot determine project config location — no worktree found")?;
let project_config = repo
.load_project_config()?
.ok_or(GitError::ProjectConfigNotFound { config_path })?;
let all_hooks: Vec<_> = HookType::iter().collect();
let commands = collect_commands_for_hooks(&project_config, &all_hooks);
if commands.is_empty() {
eprintln!("{}", info_message("No commands configured in project"));
return Ok(());
}
let commands_to_approve = if !show_all {
let unapproved: Vec<_> = commands
.into_iter()
.filter(|cmd| !approvals.is_command_approved(&project_id, &cmd.command.template))
.collect();
if unapproved.is_empty() {
eprintln!("{}", info_message("All commands already approved"));
return Ok(());
}
unapproved
} else {
commands
};
let approved =
approve_command_batch(&commands_to_approve, &project_id, &approvals, false, true)?;
if approved {
eprintln!("{}", success_message("Commands approved & saved to config"));
} else {
eprintln!("{}", info_message("Commands declined"));
}
Ok(())
}
pub fn clear_approvals(global: bool) -> anyhow::Result<()> {
let mut approvals = Approvals::load().context("Failed to load approvals")?;
if global {
let project_count = approvals
.projects()
.filter(|(_, cmds)| !cmds.is_empty())
.count();
if project_count == 0 {
eprintln!("{}", info_message("No approvals to clear"));
return Ok(());
}
approvals
.clear_all(None)
.context("Failed to clear approvals")?;
eprintln!(
"{}",
success_message(format!(
"Cleared approvals for {project_count} project{}",
if project_count == 1 { "" } else { "s" }
))
);
} else {
let repo = Repository::current()?;
let project_id = repo.project_identifier()?;
let approval_count = approvals
.projects()
.find(|(id, _)| *id == project_id)
.map(|(_, cmds)| cmds.len())
.unwrap_or(0);
if approval_count == 0 {
eprintln!("{}", info_message("No approvals to clear for this project"));
return Ok(());
}
approvals
.revoke_project(&project_id, None)
.context("Failed to clear project approvals")?;
eprintln!(
"{}",
success_message(format!(
"Cleared {approval_count} approval{} for this project",
if approval_count == 1 { "" } else { "s" }
))
);
}
Ok(())
}
pub fn handle_hook_show(hook_type_filter: Option<&str>, expanded: bool) -> anyhow::Result<()> {
use crate::help_pager::show_help_in_pager;
let repo = Repository::current().context("Failed to show hooks")?;
let config = UserConfig::load().context("Failed to load user config")?;
let approvals = Approvals::load().context("Failed to load approvals")?;
let project_config = repo.load_project_config()?;
let project_id = repo.project_identifier().ok();
let filter: Option<HookType> = hook_type_filter.map(|s| match s {
"pre-switch" => HookType::PreSwitch,
"pre-start" | "post-create" => HookType::PreStart,
"post-start" => HookType::PostStart,
"post-switch" => HookType::PostSwitch,
"pre-commit" => HookType::PreCommit,
"post-commit" => HookType::PostCommit,
"pre-merge" => HookType::PreMerge,
"post-merge" => HookType::PostMerge,
"pre-remove" => HookType::PreRemove,
"post-remove" => HookType::PostRemove,
_ => unreachable!("clap validates hook type"),
});
let env = if expanded {
Some(CommandEnv::for_action_branchless()?)
} else {
None
};
let ctx = env.as_ref().map(|e| e.context(false));
let mut output = String::new();
render_user_hooks(&mut output, &config, filter, ctx.as_ref())?;
output.push('\n');
render_project_hooks(
&mut output,
&repo,
project_config.as_ref(),
&approvals,
project_id.as_deref(),
filter,
ctx.as_ref(),
)?;
if show_help_in_pager(&output, true).is_err() {
worktrunk::styling::eprintln!("{}", output);
}
Ok(())
}
fn render_user_hooks(
out: &mut String,
config: &UserConfig,
filter: Option<HookType>,
ctx: Option<&CommandContext>,
) -> anyhow::Result<()> {
let config_path = worktrunk::config::config_path();
writeln!(
out,
"{}",
format_heading(
"USER HOOKS",
Some(
&config_path
.as_ref()
.map(|p| format!("@ {}", format_path_for_display(p)))
.unwrap_or_else(|| "(not found)".to_string())
)
)
)?;
let user_hooks = &config.configs.hooks;
let hooks: Vec<_> = HookType::iter()
.filter_map(|ht| user_hooks.get(ht).map(|cfg| (ht, cfg)))
.collect();
let mut has_any = false;
for (hook_type, cfg) in &hooks {
if let Some(f) = filter
&& f != *hook_type
{
continue;
}
has_any = true;
render_hook_commands(out, *hook_type, cfg, None, ctx)?;
}
if !has_any {
writeln!(out, "{}", hint_message("(none configured)"))?;
}
Ok(())
}
fn render_project_hooks(
out: &mut String,
repo: &Repository,
project_config: Option<&ProjectConfig>,
approvals: &Approvals,
project_id: Option<&str>,
filter: Option<HookType>,
ctx: Option<&CommandContext>,
) -> anyhow::Result<()> {
let config_path = repo
.project_config_path()?
.context("Cannot determine project config location — no worktree found")?;
writeln!(
out,
"{}",
format_heading(
"PROJECT HOOKS",
Some(&format!("@ {}", format_path_for_display(&config_path)))
)
)?;
let Some(config) = project_config else {
writeln!(out, "{}", hint_message("(not found)"))?;
return Ok(());
};
let hooks: Vec<_> = HookType::iter()
.filter_map(|ht| config.hooks.get(ht).map(|cfg| (ht, cfg)))
.collect();
let mut has_any = false;
for (hook_type, cfg) in &hooks {
if let Some(f) = filter
&& f != *hook_type
{
continue;
}
has_any = true;
render_hook_commands(out, *hook_type, cfg, Some((approvals, project_id)), ctx)?;
}
if !has_any {
writeln!(out, "{}", hint_message("(none configured)"))?;
}
Ok(())
}
fn render_hook_commands(
out: &mut String,
hook_type: HookType,
config: &CommandConfig,
approval_context: Option<(&Approvals, Option<&str>)>,
ctx: Option<&CommandContext>,
) -> anyhow::Result<()> {
let commands: Vec<_> = config.commands().collect();
if commands.is_empty() {
return Ok(());
}
for cmd in commands {
let label = match &cmd.name {
Some(name) => cformat!("{hook_type} <bold>{name}</>:"),
None => format!("{hook_type}:"),
};
let needs_approval = if let Some((approvals, Some(project_id))) = approval_context {
!approvals.is_command_approved(project_id, &cmd.template)
} else {
false
};
let (emoji, suffix) = if needs_approval {
(PROMPT_SYMBOL, cformat!(" <dim>(requires approval)</>"))
} else {
(INFO_SYMBOL, String::new())
};
writeln!(out, "{emoji} {label}{suffix}")?;
let command_text = if let Some(command_ctx) = ctx {
expand_command_template(&cmd.template, command_ctx, hook_type, cmd.name.as_deref())?
} else {
cmd.template.clone()
};
writeln!(out, "{}", format_bash_with_gutter(&command_text))?;
}
Ok(())
}
fn expand_command_template(
template: &str,
ctx: &CommandContext,
hook_type: HookType,
hook_name: Option<&str>,
) -> anyhow::Result<String> {
let default_branch = ctx.repo.default_branch();
let worktree_path_str = worktrunk::path::to_posix_path(&ctx.worktree_path.to_string_lossy());
let extra_vars = build_manual_hook_extra_vars(
ctx,
hook_type,
&[],
default_branch.as_deref(),
&worktree_path_str,
);
let mut template_ctx = build_hook_context(ctx, &extra_vars)?;
template_ctx.insert("hook_type".into(), hook_type.to_string());
if let Some(name) = hook_name {
template_ctx.insert("hook_name".into(), name.into());
}
let vars: HashMap<&str, &str> = template_ctx
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
Ok(
worktrunk::config::expand_template(template, &vars, true, ctx.repo, "hook preview")
.unwrap_or_else(|err| format!("# {}\n{}", err.message, template)),
)
}