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_filter: Option<&'name str>,
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_filter,
display_path,
} = spec;
let parsed_filter = name_filter.map(ParsedFilter::parse);
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_filter
.as_ref()
.is_none_or(|f| f.matches_source(source))
{
continue;
}
let prepared = prepare_commands(config, ctx, extra_vars, hook_type, source)?;
let filtered = filter_by_name(prepared, parsed_filter.as_ref().map(|f| f.name));
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>,
name_filter: Option<&str>,
) -> Vec<PreparedCommand> {
match name_filter {
Some(name) if !name.is_empty() => commands
.into_iter()
.filter(|cmd| cmd.name.as_deref() == Some(name))
.collect(),
_ => commands, }
}
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 mut parts = Vec::new();
for step in steps {
let source_label = step.source.to_string();
match &step.step {
PreparedStep::Single(cmd) => {
let label = cmd.name.as_deref().unwrap_or(&source_label);
parts.push(cformat!("<bold>{}</>", label));
}
PreparedStep::Concurrent(cmds) => {
let names: Vec<String> = cmds
.iter()
.map(|c| {
let label = c.name.as_deref().unwrap_or(&source_label);
cformat!("<bold>{}</>", label)
})
.collect();
parts.push(names.join(", "));
}
}
}
parts.join(" → ")
}
pub fn spawn_hook_pipeline(ctx: &CommandContext, steps: Vec<SourcedStep>) -> anyhow::Result<()> {
use super::pipeline_spec::{PipelineCommandSpec, PipelineSpec, PipelineStepSpec};
if steps.is_empty() {
return Ok(());
}
let hook_type = steps[0].hook_type;
let source = steps[0].source;
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));
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: hook_type.to_string(),
source: source.to_string(),
context,
steps: spec_steps,
};
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, "pipeline");
let log_label = format!("{hook_type} {source} pipeline");
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_filter: Option<&str>,
total_commands_run: usize,
user_config: Option<&CommandConfig>,
project_config: Option<&CommandConfig>,
) -> anyhow::Result<()> {
if let Some(filter_str) = name_filter
&& total_commands_run == 0
{
let parsed = ParsedFilter::parse(filter_str);
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.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_str.to_string(),
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_filter,
..
} = spec;
check_name_filter_matched(name_filter, 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_filter: Option<&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 (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_filter,
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::*;
#[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!(summary.contains("→"));
assert!(summary.contains("install"));
assert!(summary.contains("build"));
assert!(summary.contains("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!(summary.contains("user"));
assert!(summary.contains("→"));
}
#[test]
fn test_is_pipeline() {
use worktrunk::config::CommandConfig;
let single = CommandConfig::single("npm install");
assert!(!single.is_pipeline());
}
}