use std::path::{Path, PathBuf};
use anyhow::Context;
use color_print::cformat;
use worktrunk::HookType;
use worktrunk::config::{CommandConfig, format_hook_variables};
use worktrunk::git::{Repository, add_hook_skip_hint};
use worktrunk::path::format_path_for_display;
use worktrunk::styling::{
eprintln, format_with_gutter, info_message, progress_message, verbosity, warning_message,
};
use super::command_executor::{
CommandContext, FailureStrategy, ForegroundStep, PipelineKind, PreparedCommand, PreparedStep,
alias_error_wrapper, execute_pipeline_foreground, hook_error_wrapper, prepare_steps,
};
use super::hook_announcement::{SourcedStep, format_pipeline_summary};
use crate::commands::process::{HookLog, spawn_detached_exec};
use crate::output::DirectivePassthrough;
pub use super::hook_filter::{HookSource, ParsedFilter};
pub(crate) fn prepare_and_check(
ctx: &CommandContext,
user_config: Option<&CommandConfig>,
project_config: Option<&CommandConfig>,
hook_type: HookType,
extra_vars: &[(&str, &str)],
name_filters: &[String],
) -> anyhow::Result<Vec<SourcedStep>> {
let parsed_filters: Vec<ParsedFilter<'_>> = name_filters
.iter()
.map(|f| ParsedFilter::parse(f))
.collect();
let mut result = 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.is_empty() && !parsed_filters.iter().any(|f| f.matches_source(source)) {
continue;
}
let steps = prepare_steps(config, ctx, extra_vars, hook_type, source)?;
for step in steps {
if let Some(filtered) = filter_step_by_name(step, source, &parsed_filters) {
result.push(SourcedStep {
step: filtered,
source,
});
}
}
}
if !name_filters.is_empty() && result.is_empty() {
return Err(no_matching_commands_error(
name_filters,
user_config,
project_config,
));
}
Ok(result)
}
fn filter_step_by_name(
step: PreparedStep,
source: HookSource,
parsed_filters: &[ParsedFilter<'_>],
) -> Option<PreparedStep> {
if parsed_filters.is_empty() {
return Some(step);
}
let matches = |cmd: &PreparedCommand| {
parsed_filters
.iter()
.any(|f| f.matches_command(source, cmd.name.as_deref()))
};
match step {
PreparedStep::Single(cmd) => matches(&cmd).then_some(PreparedStep::Single(cmd)),
PreparedStep::Concurrent(cmds) => {
let mut kept: Vec<_> = cmds.into_iter().filter(matches).collect();
match kept.len() {
0 => None,
1 => Some(PreparedStep::Single(kept.pop().unwrap())),
_ => Some(PreparedStep::Concurrent(kept)),
}
}
}
}
fn no_matching_commands_error(
name_filters: &[String],
user_config: Option<&CommandConfig>,
project_config: Option<&CommandConfig>,
) -> anyhow::Error {
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}"))),
);
}
worktrunk::git::GitError::HookCommandNotFound {
name: filter_display,
available,
}
.into()
}
pub struct HookAnnouncer<'a> {
pending: Vec<PendingPipeline>,
repo: &'a Repository,
show_branch: bool,
}
struct PendingPipeline {
worktree_path: PathBuf,
branch: Option<String>,
hook_type: HookType,
display_path: Option<PathBuf>,
steps: Vec<SourcedStep>,
}
impl<'a> HookAnnouncer<'a> {
pub fn new(repo: &'a Repository, show_branch: bool) -> Self {
Self {
pending: Vec::new(),
repo,
show_branch,
}
}
pub fn add_groups(
&mut self,
ctx: &CommandContext<'_>,
hook_type: HookType,
display_path: Option<&Path>,
groups: Vec<Vec<SourcedStep>>,
) {
for steps in groups {
debug_assert!(!steps.is_empty(), "add_groups: empty step group");
self.pending.push(PendingPipeline {
worktree_path: ctx.worktree_path.to_path_buf(),
branch: ctx.branch.map(String::from),
hook_type,
display_path: display_path.map(Path::to_path_buf),
steps,
});
}
}
pub fn register(
&mut self,
ctx: &CommandContext<'_>,
hook_type: HookType,
extra_vars: &[(&str, &str)],
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 flat = prepare_and_check(
ctx,
user_hooks.get(hook_type),
project_config.as_ref().and_then(|c| c.hooks.get(hook_type)),
hook_type,
extra_vars,
&[],
)?;
self.add_groups(ctx, hook_type, display_path, into_source_groups(flat));
Ok(())
}
pub fn flush(&mut self) -> anyhow::Result<()> {
let pending = std::mem::take(&mut self.pending);
if pending.is_empty() {
return Ok(());
}
run_hooks_background(self.repo, pending, self.show_branch)
}
}
impl Drop for HookAnnouncer<'_> {
fn drop(&mut self) {
if self.pending.is_empty() {
return;
}
if let Err(err) = self.flush() {
eprintln!(
"{}",
warning_message(format!("Failed to spawn pending hooks: {err:#}"))
);
}
}
}
fn run_hooks_background(
repo: &Repository,
pipelines: Vec<PendingPipeline>,
show_branch: bool,
) -> anyhow::Result<()> {
let mut display_path: Option<&Path> = None;
let mut type_summaries: Vec<(HookType, Vec<String>)> = Vec::new();
for pipeline in &pipelines {
if display_path.is_none() {
display_path = pipeline.display_path.as_deref();
}
let summary = format_pipeline_summary(&pipeline.steps);
if let Some(entry) = type_summaries
.iter_mut()
.find(|(ht, _)| *ht == pipeline.hook_type)
{
entry.1.push(summary);
} else {
type_summaries.push((pipeline.hook_type, vec![summary]));
}
}
let branch_suffix = if show_branch {
pipelines
.first()
.and_then(|p| p.branch.as_deref())
.map(|b| cformat!(" for <bold>{b}</>"))
} else {
None
};
if verbosity() >= 1 {
for (ht, _) in &type_summaries {
print_background_variable_table(&pipelines, *ht);
}
}
let suffix = branch_suffix.as_deref().unwrap_or("");
let combined: String = type_summaries
.iter()
.map(|(ht, summaries)| 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 pipeline in &pipelines {
spawn_hook_pipeline_quiet(repo, pipeline)?;
}
Ok(())
}
pub(crate) fn into_source_groups(flat: Vec<SourcedStep>) -> Vec<Vec<SourcedStep>> {
let mut groups: Vec<Vec<SourcedStep>> = Vec::new();
for step in flat {
match groups.last_mut() {
Some(g) if g.last().is_some_and(|s| s.source == step.source) => g.push(step),
_ => groups.push(vec![step]),
}
}
groups
}
fn print_background_variable_table(pipelines: &[PendingPipeline], hook_type: HookType) {
for pipeline in pipelines {
if pipeline.hook_type != hook_type {
continue;
}
let cmd = match &pipeline.steps[0].step {
PreparedStep::Single(cmd) => cmd,
PreparedStep::Concurrent(cmds) => &cmds[0],
};
eprintln!("{}", info_message("template variables:"));
eprintln!(
"{}",
format_with_gutter(&format_hook_variables(hook_type, &cmd.context), None)
);
return;
}
}
fn spawn_hook_pipeline_quiet(repo: &Repository, pipeline: &PendingPipeline) -> anyhow::Result<()> {
use super::pipeline_spec::{PipelineCommandSpec, PipelineSpec, PipelineStepSpec};
let steps = &pipeline.steps;
let source = steps[0].source;
let first_cmd = match &steps[0].step {
PreparedStep::Single(cmd) => cmd,
PreparedStep::Concurrent(cmds) => &cmds[0],
};
let mut context = first_cmd.context.clone();
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_name: cmd.template_name.clone(),
template: cmd.template.clone(),
},
PreparedStep::Concurrent(cmds) => PipelineStepSpec::Concurrent {
commands: cmds
.iter()
.map(|c| PipelineCommandSpec {
name: c.name.clone(),
template_name: c.template_name.clone(),
template: c.template.clone(),
})
.collect(),
},
})
.collect();
let branch = pipeline.branch.as_deref().unwrap_or("HEAD");
let hook_type = pipeline.hook_type;
let spec = PipelineSpec {
worktree_path: pipeline.worktree_path.clone(),
branch: branch.to_string(),
hook_type,
source,
context,
steps: spec_steps,
log_dir: 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(
repo,
&pipeline.worktree_path,
&wt_bin,
&["hook", "run-pipeline"],
branch,
&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 sourced_steps_to_foreground(
sourced_steps: Vec<SourcedStep>,
kind: &PipelineKind,
) -> Vec<ForegroundStep> {
sourced_steps
.into_iter()
.map(|sourced| {
let directives = match (kind, sourced.source) {
(PipelineKind::Alias { .. }, HookSource::User) => {
DirectivePassthrough::inherit_from_env_with_exec()
}
_ => DirectivePassthrough::inherit_from_env(),
};
let (pipe_stdin, redirect_stdout_to_stderr, error_wrapper) = match kind {
PipelineKind::Hook { hook_type, .. } => {
(true, true, hook_error_wrapper(*hook_type))
}
PipelineKind::Alias { name } => (false, false, alias_error_wrapper(name.clone())),
};
ForegroundStep {
step: sourced.step,
announce: kind.clone(),
pipe_stdin,
redirect_stdout_to_stderr,
error_wrapper,
directives,
}
})
.collect()
}
pub(crate) fn run_hooks_foreground(
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<()> {
let sourced_steps = prepare_and_check(
ctx,
user_config,
project_config,
hook_type,
extra_vars,
name_filters,
)?;
if sourced_steps.is_empty() {
return Ok(());
}
let kind = PipelineKind::Hook {
hook_type,
display_path: crate::output::pre_hook_display_path(ctx.worktree_path)
.map(Path::to_path_buf),
};
let foreground_steps = sourced_steps_to_foreground(sourced_steps, &kind);
execute_pipeline_foreground(
&foreground_steps,
ctx.repo,
ctx.worktree_path,
failure_strategy,
)
}
pub(crate) fn execute_hook(
ctx: &CommandContext,
hook_type: HookType,
extra_vars: &[(&str, &str)],
failure_strategy: FailureStrategy,
) -> anyhow::Result<()> {
let project_config = ctx.repo.load_project_config()?;
let user_hooks = ctx.config.hooks(ctx.project_id().as_deref());
run_hooks_foreground(
ctx,
user_hooks.get(hook_type),
project_config.as_ref().and_then(|c| c.hooks.get(hook_type)),
hook_type,
extra_vars,
&[],
failure_strategy,
)
.map_err(add_hook_skip_hint)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hook_source_display() {
assert_eq!(HookSource::User.to_string(), "user");
assert_eq!(HookSource::Project.to_string(), "project");
}
#[test]
fn test_failure_strategy_copy() {
let strategy = FailureStrategy::FailFast;
let copied = strategy; assert!(matches!(copied, FailureStrategy::FailFast));
let warn = FailureStrategy::Warn;
let copied_warn = warn;
assert!(matches!(copied_warn, FailureStrategy::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, "");
}
}