use std::path::{Path, PathBuf};
use anyhow::Context;
use color_print::cformat;
use worktrunk::HookType;
use worktrunk::config::CommandConfig;
use worktrunk::git::WorktrunkError;
use worktrunk::path::format_path_for_display;
use worktrunk::styling::{
eprintln, error_message, format_bash_with_gutter, progress_message, warning_message,
};
use super::command_executor::{
CommandContext, PreparedCommand, PreparedStep, prepare_commands, prepare_steps,
};
use crate::commands::process::{HookLog, spawn_detached_exec};
use crate::output::execute_command_in_worktree;
pub struct SourcedCommand {
pub prepared: PreparedCommand,
pub source: HookSource,
pub hook_type: HookType,
pub display_path: Option<PathBuf>,
}
impl SourcedCommand {
fn summary_name(&self) -> String {
match &self.prepared.name {
Some(n) => format!("{}:{}", self.source, n),
None => self.source.to_string(),
}
}
fn announce(&self) -> anyhow::Result<()> {
let full_label = match &self.prepared.name {
Some(n) => {
let display_name = format!("{}:{}", self.source, n);
crate::commands::format_command_label(
&self.hook_type.to_string(),
Some(&display_name),
)
}
None => format!("Running {} {} hook", self.hook_type, self.source),
};
let message = match &self.display_path {
Some(path) => {
let path_display = format_path_for_display(path);
cformat!("{full_label} @ <bold>{path_display}</>")
}
None => full_label,
};
eprintln!("{}", progress_message(message));
eprintln!("{}", format_bash_with_gutter(&self.prepared.expanded));
Ok(())
}
}
#[derive(Clone, Copy)]
pub enum HookFailureStrategy {
FailFast,
Warn,
}
pub use super::hook_filter::{HookSource, ParsedFilter};
#[derive(Clone, Copy)]
pub struct HookCommandSpec<'cfg, 'vars, 'name, 'path> {
pub user_config: Option<&'cfg CommandConfig>,
pub project_config: Option<&'cfg CommandConfig>,
pub hook_type: HookType,
pub extra_vars: &'vars [(&'vars str, &'vars str)],
pub name_filters: &'name [String],
pub display_path: Option<&'path Path>,
}
pub fn prepare_hook_commands(
ctx: &CommandContext,
spec: HookCommandSpec<'_, '_, '_, '_>,
) -> anyhow::Result<Vec<SourcedCommand>> {
let HookCommandSpec {
user_config,
project_config,
hook_type,
extra_vars,
name_filters,
display_path,
} = spec;
let parsed_filters: Vec<ParsedFilter<'_>> = name_filters
.iter()
.map(|f| ParsedFilter::parse(f))
.collect();
let mut commands = Vec::new();
let display_path = display_path.map(|p| p.to_path_buf());
let sources = [
(HookSource::User, user_config),
(HookSource::Project, project_config),
];
for (source, config) in sources {
let Some(config) = config else { continue };
if !parsed_filters.is_empty() && !parsed_filters.iter().any(|f| f.matches_source(source)) {
continue;
}
let prepared = prepare_commands(config, ctx, extra_vars, hook_type, source)?;
let filtered = filter_by_name(prepared, &parsed_filters);
commands.extend(filtered.into_iter().map(|p| SourcedCommand {
prepared: p,
source,
hook_type,
display_path: display_path.clone(),
}));
}
Ok(commands)
}
fn filter_by_name(
commands: Vec<PreparedCommand>,
parsed_filters: &[ParsedFilter<'_>],
) -> Vec<PreparedCommand> {
if parsed_filters.is_empty() {
return commands; }
let filter_names: Vec<&str> = parsed_filters
.iter()
.map(|f| f.name)
.filter(|n| !n.is_empty())
.collect();
if filter_names.is_empty() {
return commands;
}
commands
.into_iter()
.filter(|cmd| {
cmd.name
.as_deref()
.is_some_and(|n| filter_names.contains(&n))
})
.collect()
}
pub struct SourcedStep {
pub step: PreparedStep,
pub source: HookSource,
pub hook_type: HookType,
pub display_path: Option<PathBuf>,
}
fn format_pipeline_summary(steps: &[SourcedStep]) -> String {
let source_label = steps[0].source.to_string();
let mut parts: Vec<String> = Vec::new();
let mut unnamed_count: usize = 0;
for step in steps {
let step_names: Vec<Option<&str>> = match &step.step {
PreparedStep::Single(cmd) => vec![cmd.name.as_deref()],
PreparedStep::Concurrent(cmds) => cmds.iter().map(|c| c.name.as_deref()).collect(),
};
let named: Vec<_> = step_names
.iter()
.filter_map(|n| n.map(|name| cformat!("<bold>{source_label}:{name}</>")))
.collect();
unnamed_count += step_names.iter().filter(|n| n.is_none()).count();
if !named.is_empty() {
if unnamed_count > 0 {
parts.push(format_unnamed(&source_label, unnamed_count));
unnamed_count = 0;
}
parts.push(named.join(", "));
}
}
if unnamed_count > 0 {
parts.push(format_unnamed(&source_label, unnamed_count));
}
parts.join("; ")
}
fn format_unnamed(source_label: &str, count: usize) -> String {
if count == 1 {
cformat!("<bold>{source_label}</>")
} else {
cformat!("<bold>{source_label}</> ×{count}")
}
}
pub fn announce_and_spawn_background_hooks(
pipelines: Vec<(CommandContext<'_>, Vec<SourcedStep>)>,
show_branch: bool,
) -> anyhow::Result<()> {
let non_empty: Vec<_> = pipelines
.into_iter()
.filter(|(_, steps)| !steps.is_empty())
.collect();
if non_empty.is_empty() {
return Ok(());
}
let display_path = non_empty
.iter()
.flat_map(|(_, g)| g.iter())
.find_map(|s| s.display_path.as_ref());
let mut type_summaries: Vec<(HookType, Vec<String>)> = Vec::new();
for (_, group) in &non_empty {
let hook_type = group[0].hook_type;
let summary = format_pipeline_summary(group);
if let Some(entry) = type_summaries.iter_mut().find(|(ht, _)| *ht == hook_type) {
entry.1.push(summary);
} else {
type_summaries.push((hook_type, vec![summary]));
}
}
let branch_suffix = if show_branch {
non_empty
.first()
.and_then(|(ctx, _)| ctx.branch)
.map(|b| cformat!(" for <bold>{b}</>"))
} else {
None
};
let combined: String = type_summaries
.iter()
.map(|(ht, summaries)| {
let suffix = branch_suffix.as_deref().unwrap_or("");
format!("{ht}{suffix}: {}", summaries.join(", "))
})
.collect::<Vec<_>>()
.join("; ");
let message = match display_path {
Some(path) => {
let path_display = format_path_for_display(path);
cformat!("Running {combined} @ <bold>{path_display}</>")
}
None => format!("Running {combined}"),
};
eprintln!("{}", progress_message(message));
for (ctx, group) in non_empty {
spawn_hook_pipeline_quiet(&ctx, group)?;
}
Ok(())
}
pub fn spawn_hook_pipeline(ctx: &CommandContext, steps: Vec<SourcedStep>) -> anyhow::Result<()> {
if steps.is_empty() {
return Ok(());
}
let hook_type = steps[0].hook_type;
let display_path = steps[0].display_path.as_ref();
let summary = format_pipeline_summary(&steps);
let message = match display_path {
Some(path) => {
let path_display = format_path_for_display(path);
cformat!("Running {hook_type}: {summary} @ <bold>{path_display}</>")
}
None => format!("Running {hook_type}: {summary}"),
};
eprintln!("{}", progress_message(message));
spawn_hook_pipeline_quiet(ctx, steps)
}
fn spawn_hook_pipeline_quiet(ctx: &CommandContext, steps: Vec<SourcedStep>) -> anyhow::Result<()> {
use super::pipeline_spec::{PipelineCommandSpec, PipelineSpec, PipelineStepSpec};
let hook_type = steps[0].hook_type;
let source = steps[0].source;
let mut context: std::collections::HashMap<String, String> = steps
.iter()
.find_map(|s| match &s.step {
PreparedStep::Single(cmd) => Some(&cmd.context_json),
PreparedStep::Concurrent(cmds) => cmds.first().map(|c| &c.context_json),
})
.map(|json| serde_json::from_str(json).context("failed to deserialize context_json"))
.transpose()?
.unwrap_or_default();
context.remove("hook_name");
let spec_steps: Vec<PipelineStepSpec> = steps
.iter()
.map(|s| match &s.step {
PreparedStep::Single(cmd) => PipelineStepSpec::Single {
name: cmd.name.clone(),
template: cmd.lazy_template.as_ref().unwrap_or(&cmd.expanded).clone(),
},
PreparedStep::Concurrent(cmds) => PipelineStepSpec::Concurrent {
commands: cmds
.iter()
.map(|c| PipelineCommandSpec {
name: c.name.clone(),
template: c.lazy_template.as_ref().unwrap_or(&c.expanded).clone(),
})
.collect(),
},
})
.collect();
let spec = PipelineSpec {
worktree_path: ctx.worktree_path.to_path_buf(),
branch: ctx.branch_or_head().to_string(),
hook_type,
source,
context,
steps: spec_steps,
log_dir: ctx.repo.wt_logs_dir(),
};
let spec_json = serde_json::to_vec(&spec).context("failed to serialize pipeline spec")?;
let wt_bin = std::env::current_exe().context("failed to resolve wt binary path")?;
let hook_log = HookLog::hook(source, hook_type, "runner");
let log_label = format!("{hook_type} {source} runner");
if let Err(err) = spawn_detached_exec(
ctx.repo,
ctx.worktree_path,
&wt_bin,
&["hook", "run-pipeline"],
ctx.branch_or_head(),
&hook_log,
&spec_json,
) {
eprintln!(
"{}",
warning_message(format!("Failed to spawn pipeline: {err}"))
);
} else {
let cmd_display = format!("{} hook run-pipeline", wt_bin.display());
worktrunk::command_log::log_command(&log_label, &cmd_display, None, None);
}
Ok(())
}
pub(crate) fn check_name_filter_matched(
name_filters: &[String],
total_commands_run: usize,
user_config: Option<&CommandConfig>,
project_config: Option<&CommandConfig>,
) -> anyhow::Result<()> {
if !name_filters.is_empty() && total_commands_run == 0 {
let filter_display = name_filters.join(", ");
let parsed_filters: Vec<ParsedFilter<'_>> = name_filters
.iter()
.map(|f| ParsedFilter::parse(f))
.collect();
let mut available = Vec::new();
let sources = [
(HookSource::User, user_config),
(HookSource::Project, project_config),
];
for (source, config) in sources {
let Some(config) = config else { continue };
if !parsed_filters.iter().any(|f| f.matches_source(source)) {
continue;
}
available.extend(
config
.commands()
.filter_map(|c| c.name.as_ref().map(|n| format!("{source}:{n}"))),
);
}
return Err(worktrunk::git::GitError::HookCommandNotFound {
name: filter_display,
available,
}
.into());
}
Ok(())
}
pub fn run_hook_with_filter(
ctx: &CommandContext,
spec: HookCommandSpec<'_, '_, '_, '_>,
failure_strategy: HookFailureStrategy,
) -> anyhow::Result<()> {
let commands = prepare_hook_commands(ctx, spec)?;
let HookCommandSpec {
user_config,
project_config,
hook_type,
name_filters,
..
} = spec;
check_name_filter_matched(name_filters, commands.len(), user_config, project_config)?;
if commands.is_empty() {
return Ok(());
}
for cmd in commands {
cmd.announce()?;
let expanded = if let Some(ref template) = cmd.prepared.lazy_template {
let name = cmd.summary_name();
expand_lazy_template(template, &cmd.prepared.context_json, ctx.repo, &name)?
} else {
cmd.prepared.expanded.clone()
};
let log_label = format!("{} {}", cmd.hook_type, cmd.summary_name());
if let Err(err) = execute_command_in_worktree(
ctx.worktree_path,
&expanded,
Some(&cmd.prepared.context_json),
Some(&log_label),
) {
let (err_msg, exit_code) = if let Some(wt_err) = err.downcast_ref::<WorktrunkError>() {
match wt_err {
WorktrunkError::ChildProcessExited { message, code } => {
(message.clone(), Some(*code))
}
_ => (err.to_string(), None),
}
} else {
(err.to_string(), None)
};
match &failure_strategy {
HookFailureStrategy::FailFast => {
return Err(WorktrunkError::HookCommandFailed {
hook_type,
command_name: cmd.prepared.name.clone(),
error: err_msg,
exit_code,
}
.into());
}
HookFailureStrategy::Warn => {
let message = match &cmd.prepared.name {
Some(name) => cformat!("Command <bold>{name}</> failed: {err_msg}"),
None => format!("Command failed: {err_msg}"),
};
eprintln!("{}", error_message(message));
}
}
}
}
Ok(())
}
pub(crate) fn lookup_hook_configs<'a>(
user_hooks: &'a worktrunk::config::HooksConfig,
project_config: Option<&'a worktrunk::config::ProjectConfig>,
hook_type: HookType,
) -> (Option<&'a CommandConfig>, Option<&'a CommandConfig>) {
(
user_hooks.get(hook_type),
project_config.and_then(|c| c.hooks.get(hook_type)),
)
}
pub fn execute_hook(
ctx: &CommandContext,
hook_type: HookType,
extra_vars: &[(&str, &str)],
failure_strategy: HookFailureStrategy,
name_filters: &[String],
display_path: Option<&Path>,
) -> anyhow::Result<()> {
let project_config = ctx.repo.load_project_config()?;
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);
run_hook_with_filter(
ctx,
HookCommandSpec {
user_config,
project_config: proj_config,
hook_type,
extra_vars,
name_filters,
display_path,
},
failure_strategy,
)
.map_err(worktrunk::git::add_hook_skip_hint)
}
pub(crate) fn prepare_background_hooks(
ctx: &CommandContext,
hook_type: HookType,
extra_vars: &[(&str, &str)],
display_path: Option<&Path>,
) -> anyhow::Result<Vec<Vec<SourcedStep>>> {
let project_config = ctx.repo.load_project_config()?;
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);
let display_path = display_path.map(|p| p.to_path_buf());
let mut groups = Vec::new();
let sources = [
(HookSource::User, user_config),
(HookSource::Project, proj_config),
];
for (source, config) in sources {
let Some(config) = config else { continue };
let steps = prepare_steps(config, ctx, extra_vars, hook_type, source)?;
if steps.is_empty() {
continue;
}
groups.push(
steps
.into_iter()
.map(|step| SourcedStep {
step,
source,
hook_type,
display_path: display_path.clone(),
})
.collect(),
);
}
Ok(groups)
}
fn expand_lazy_template(
template: &str,
context_json: &str,
repo: &worktrunk::git::Repository,
label: &str,
) -> anyhow::Result<String> {
let context_map: std::collections::HashMap<String, String> =
serde_json::from_str(context_json).context("failed to deserialize context_json")?;
let vars: std::collections::HashMap<&str, &str> = context_map
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
Ok(worktrunk::config::expand_template(
template, &vars, true, repo, label,
)?)
}
#[cfg(test)]
mod tests {
use super::*;
use ansi_str::AnsiStr;
use insta::assert_snapshot;
#[test]
fn test_hook_source_display() {
assert_eq!(HookSource::User.to_string(), "user");
assert_eq!(HookSource::Project.to_string(), "project");
}
#[test]
fn test_hook_failure_strategy_copy() {
let strategy = HookFailureStrategy::FailFast;
let copied = strategy; assert!(matches!(copied, HookFailureStrategy::FailFast));
let warn = HookFailureStrategy::Warn;
let copied_warn = warn;
assert!(matches!(copied_warn, HookFailureStrategy::Warn));
}
#[test]
fn test_parsed_filter() {
let f = ParsedFilter::parse("foo");
assert!(f.source.is_none());
assert_eq!(f.name, "foo");
assert!(f.matches_source(HookSource::User));
assert!(f.matches_source(HookSource::Project));
let f = ParsedFilter::parse("user:foo");
assert_eq!(f.source, Some(HookSource::User));
assert_eq!(f.name, "foo");
assert!(f.matches_source(HookSource::User));
assert!(!f.matches_source(HookSource::Project));
let f = ParsedFilter::parse("project:bar");
assert_eq!(f.source, Some(HookSource::Project));
assert_eq!(f.name, "bar");
assert!(!f.matches_source(HookSource::User));
assert!(f.matches_source(HookSource::Project));
let f = ParsedFilter::parse("my:hook");
assert!(f.source.is_none());
assert_eq!(f.name, "my:hook");
let f = ParsedFilter::parse("user:");
assert_eq!(f.source, Some(HookSource::User));
assert_eq!(f.name, "");
let f = ParsedFilter::parse("project:");
assert_eq!(f.source, Some(HookSource::Project));
assert_eq!(f.name, "");
}
fn make_sourced_step(step: PreparedStep) -> SourcedStep {
SourcedStep {
step,
source: HookSource::User,
hook_type: worktrunk::HookType::PostStart,
display_path: None,
}
}
fn make_cmd(name: Option<&str>, expanded: &str) -> PreparedCommand {
PreparedCommand {
name: name.map(String::from),
expanded: expanded.to_string(),
context_json: "{}".to_string(),
lazy_template: None,
}
}
#[test]
fn test_format_pipeline_summary_named() {
let steps = vec![
make_sourced_step(PreparedStep::Single(make_cmd(
Some("install"),
"npm install",
))),
make_sourced_step(PreparedStep::Concurrent(vec![
make_cmd(Some("build"), "npm run build"),
make_cmd(Some("lint"), "npm run lint"),
])),
];
let summary = format_pipeline_summary(&steps);
assert_snapshot!(summary.ansi_strip(), @"user:install; user:build, user:lint");
}
#[test]
fn test_format_pipeline_summary_unnamed() {
let steps = vec![
make_sourced_step(PreparedStep::Single(make_cmd(None, "npm install"))),
make_sourced_step(PreparedStep::Single(make_cmd(None, "npm run build"))),
];
let summary = format_pipeline_summary(&steps);
assert_snapshot!(summary.ansi_strip(), @"user ×2");
}
#[test]
fn test_format_pipeline_summary_mixed_named_unnamed() {
let steps = vec![
make_sourced_step(PreparedStep::Single(make_cmd(None, "npm install"))),
make_sourced_step(PreparedStep::Single(make_cmd(Some("bg"), "npm run dev"))),
];
let summary = format_pipeline_summary(&steps);
assert_snapshot!(summary.ansi_strip(), @"user; user:bg");
}
#[test]
fn test_format_pipeline_summary_single_unnamed() {
let steps = vec![make_sourced_step(PreparedStep::Single(make_cmd(
None,
"npm install",
)))];
let summary = format_pipeline_summary(&steps);
assert_snapshot!(summary.ansi_strip(), @"user");
}
#[test]
fn test_format_pipeline_summary_concurrent_then_concurrent() {
let steps = vec![
make_sourced_step(PreparedStep::Concurrent(vec![
make_cmd(Some("install"), "npm install"),
make_cmd(Some("setup"), "setup-db"),
])),
make_sourced_step(PreparedStep::Concurrent(vec![
make_cmd(Some("build"), "npm run build"),
make_cmd(Some("lint"), "npm run lint"),
])),
];
let summary = format_pipeline_summary(&steps);
assert_snapshot!(summary.ansi_strip(), @"user:install, user:setup; user:build, user:lint");
}
#[test]
fn test_is_pipeline() {
use worktrunk::config::CommandConfig;
let single = CommandConfig::single("npm install");
assert!(!single.is_pipeline());
}
}