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::{
ALIAS_ARGS_KEY, Approvals, CommandConfig, ProjectConfig, UserConfig, referenced_vars_for_config,
};
use worktrunk::git::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, warning_message,
};
use super::command_approval::approve_hooks_filtered;
use super::command_executor::build_hook_context;
use super::command_executor::CommandContext;
use super::command_executor::FailureStrategy;
use super::context::CommandEnv;
use super::hooks::{
HookAnnouncer, HookCommandSpec, lookup_hook_configs, prepare_and_check, run_hooks_foreground,
};
use super::template_vars::TemplateVars;
fn run_filtered_hook(
ctx: &CommandContext,
user_config: Option<&CommandConfig>,
project_config: Option<&CommandConfig>,
hook_type: HookType,
extra_vars: &[(&str, &str)],
name_filters: &[String],
failure_strategy: FailureStrategy,
) -> anyhow::Result<()> {
run_hooks_foreground(
ctx,
HookCommandSpec {
user_config,
project_config,
hook_type,
extra_vars,
name_filters,
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_filters: &[String],
) -> anyhow::Result<()> {
if foreground.unwrap_or(false) {
return run_filtered_hook(
ctx,
user_config,
project_config,
hook_type,
extra_vars,
name_filters,
FailureStrategy::Warn,
);
}
let mut announcer = HookAnnouncer::new(ctx.repo, ctx.config, false);
if name_filters.is_empty() {
announcer.register(ctx, hook_type, extra_vars, None)?;
} else {
let flat = prepare_and_check(
ctx,
HookCommandSpec {
user_config,
project_config,
hook_type,
extra_vars,
name_filters,
display_path: None,
},
)?;
if flat.is_empty() {
return Ok(());
}
announcer.extend(std::iter::once((*ctx, hook_type, None, flat)));
}
announcer.flush()
}
fn build_manual_hook_template_vars(
ctx: &CommandContext,
hook_type: HookType,
default_branch: Option<&str>,
) -> TemplateVars {
let branch = ctx.branch_or_head();
let worktree_path = ctx.worktree_path;
match hook_type {
HookType::PreCommit | HookType::PostCommit => {
default_branch.map_or_else(TemplateVars::new, |t| TemplateVars::new().with_target(t))
}
HookType::PreMerge | HookType::PostMerge => TemplateVars::new()
.with_target(branch)
.with_target_worktree_path(worktree_path),
HookType::PreSwitch | HookType::PreStart | HookType::PostStart | HookType::PostSwitch => {
TemplateVars::new()
.with_base(branch, worktree_path)
.with_target(branch)
.with_target_worktree_path(worktree_path)
}
HookType::PreRemove | HookType::PostRemove => TemplateVars::new()
.with_target(branch)
.with_target_worktree_path(worktree_path),
}
}
fn parse_shorthand_token(raw: &str) -> anyhow::Result<(String, String, String)> {
let (key, value) = raw
.split_once('=')
.ok_or_else(|| anyhow::anyhow!("invalid shorthand (missing `=`): {raw}"))?;
if key.is_empty() {
anyhow::bail!("invalid shorthand (empty key): {raw}");
}
Ok((key.replace('-', "_"), key.to_string(), value.to_string()))
}
fn referenced_vars_union(
user_config: Option<&CommandConfig>,
project_config: Option<&CommandConfig>,
) -> anyhow::Result<std::collections::BTreeSet<String>> {
let mut out = std::collections::BTreeSet::new();
if let Some(cfg) = user_config {
out.extend(referenced_vars_for_config(cfg)?);
}
if let Some(cfg) = project_config {
out.extend(referenced_vars_for_config(cfg)?);
}
Ok(out)
}
pub struct HookCliArgs<'a> {
pub name_filters: &'a [String],
pub explicit_vars: &'a [(String, String)],
pub shorthand_vars: &'a [String],
pub forwarded_args: &'a [String],
}
pub fn run_hook(
hook_type: HookType,
yes: bool,
foreground: Option<bool>,
dry_run: bool,
cli: HookCliArgs<'_>,
) -> anyhow::Result<()> {
let HookCliArgs {
name_filters,
explicit_vars,
shorthand_vars,
forwarded_args,
} = cli;
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_filters)?;
if !approved {
eprintln!("{}", worktrunk::styling::info_message("Commands declined"));
return Ok(());
}
}
let user_hooks = ctx.config.hooks(ctx.project_id().as_deref());
let (user_config, proj_config) =
lookup_hook_configs(&user_hooks, project_config.as_ref(), hook_type);
if user_config.is_none() && proj_config.is_none() {
eprintln!(
"{}",
warning_message(format!("No {hook_type} hooks configured"))
);
return Ok(());
}
let referenced = referenced_vars_union(user_config, proj_config)?;
let mut bindings: Vec<(String, String)> = Vec::new();
let mut args: Vec<String> = Vec::new();
for raw in shorthand_vars {
let (canon_key, orig_key, value) = parse_shorthand_token(raw)?;
if referenced.contains(&canon_key) {
bindings.push((canon_key, value));
} else {
args.push(format!("--{orig_key}={value}"));
}
}
args.extend(forwarded_args.iter().cloned());
if !explicit_vars.is_empty() {
eprintln!(
"{}",
warning_message(
"--var is deprecated; use --KEY=VALUE shorthand (binds automatically when any hook template references KEY)",
)
);
bindings.extend(explicit_vars.iter().cloned());
}
let custom_vars_refs: Vec<(&str, &str)> = bindings
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let default_branch = repo.default_branch();
let args_json =
serde_json::to_string(&args).expect("Vec<String> serialization should never fail");
let template_vars = build_manual_hook_template_vars(&ctx, hook_type, default_branch.as_deref());
let mut extra_vars = template_vars.as_extra_vars();
extra_vars.extend(custom_vars_refs.iter().copied());
extra_vars.push((ALIAS_ARGS_KEY, &args_json));
if dry_run {
let steps = prepare_and_check(
&ctx,
HookCommandSpec {
user_config,
project_config: proj_config,
hook_type,
extra_vars: &extra_vars,
name_filters,
display_path: None,
},
)?;
for sourced in steps {
for cmd in sourced.step.into_commands() {
let label = if cmd.name.is_some() {
cformat!("{hook_type} <bold>{}</> would run:", cmd.label)
} else {
cformat!("{hook_type} <bold>{}</> hook would run:", cmd.label)
};
eprintln!(
"{}",
info_message(cformat!(
"{label}\n{}",
format_bash_with_gutter(&cmd.expanded)
))
);
}
}
return Ok(());
}
if hook_type.is_pre() {
run_filtered_hook(
&ctx,
user_config,
proj_config,
hook_type,
&extra_vars,
name_filters,
FailureStrategy::default_for(hook_type),
)
} else {
run_post_hook(
&ctx,
foreground,
user_config,
proj_config,
hook_type,
&extra_vars,
name_filters,
)
}
}
pub fn handle_hook_show(
hook_type_filter: Option<&str>,
expanded: bool,
format: crate::cli::SwitchFormat,
) -> anyhow::Result<()> {
use crate::help_pager::show_help_in_pager;
let repo = Repository::current().context("Failed to show hooks")?;
let config: &UserConfig = repo.user_config();
let project_config: Option<&ProjectConfig> = repo
.project_config()
.context("Failed to load project config")?;
let approvals = Approvals::load().context("Failed to load approvals")?;
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));
if format == crate::cli::SwitchFormat::Json {
return emit_hook_show_json(
config,
project_config,
&approvals,
project_id.as_deref(),
filter,
ctx.as_ref(),
expanded,
);
}
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,
&approvals,
project_id.as_deref(),
filter,
ctx.as_ref(),
)?;
show_help_in_pager(&output, true);
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn emit_hook_show_json(
user_config: &UserConfig,
project_config: Option<&ProjectConfig>,
approvals: &Approvals,
project_id: Option<&str>,
filter: Option<HookType>,
ctx: Option<&CommandContext>,
expanded: bool,
) -> anyhow::Result<()> {
let mut entries: Vec<serde_json::Value> = Vec::new();
let mut emit = |hook_type: HookType,
source: &'static str,
cfg: &CommandConfig,
needs_approval_for: Option<(&Approvals, Option<&str>)>|
-> anyhow::Result<()> {
for cmd in cfg.commands() {
let needs_approval = needs_approval_for
.map(|(approvals, project_id)| {
project_id.is_some_and(|pid| !approvals.is_command_approved(pid, &cmd.template))
})
.unwrap_or(false);
let mut obj = serde_json::json!({
"type": hook_type.to_string(),
"source": source,
"name": cmd.name,
"template": cmd.template,
"needs_approval": needs_approval,
});
if expanded && let Some(command_ctx) = ctx {
let rendered = expand_command_template(
&cmd.template,
command_ctx,
hook_type,
cmd.name.as_deref(),
)?;
obj["expanded"] = serde_json::Value::String(rendered);
}
entries.push(obj);
}
Ok(())
};
let user_hooks = &user_config.hooks;
for hook_type in HookType::iter() {
if let Some(f) = filter
&& f != hook_type
{
continue;
}
if let Some(cfg) = user_hooks.get(hook_type) {
emit(hook_type, "user", cfg, None)?;
}
}
if let Some(project) = project_config {
for hook_type in HookType::iter() {
if let Some(f) = filter
&& f != hook_type
{
continue;
}
if let Some(cfg) = project.hooks.get(hook_type) {
emit(hook_type, "project", cfg, Some((approvals, project_id)))?;
}
}
}
println!("{}", serde_json::to_string_pretty(&entries)?);
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.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 template_vars = build_manual_hook_template_vars(ctx, hook_type, default_branch.as_deref());
let extra_vars = template_vars.as_extra_vars();
let mut template_ctx = build_hook_context(ctx, &extra_vars, None)?;
template_ctx.insert("hook_type".into(), hook_type.to_string());
if let Some(name) = hook_name {
template_ctx.insert("hook_name".into(), name.into());
}
template_ctx.insert(ALIAS_ARGS_KEY.into(), "[]".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)),
)
}