use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use anyhow::Result;
use color_print::cformat;
use worktrunk::HookType;
use worktrunk::config::{
Command, CommandConfig, HookStep, UserConfig, expand_template, format_hook_variables,
template_references_var, validate_template_syntax,
};
use worktrunk::git::{ErrorExt, Repository, WorktrunkError};
use worktrunk::path::{format_path_for_display, to_posix_path};
use worktrunk::shell_exec::ShellEscapeMode;
use worktrunk::styling::{
eprintln, error_message, format_bash_with_gutter, format_with_gutter, info_message,
progress_message, verbosity,
};
use worktrunk::trace::Span;
use super::format_command_label;
use super::hook_filter::HookSource;
use crate::output::concurrent::{ConcurrentCommand, run_concurrent_commands};
use crate::output::{DirectivePassthrough, execute_shell_command};
#[derive(Debug)]
pub struct PreparedCommand {
pub name: Option<String>,
pub template: String,
pub context: HashMap<String, String>,
pub template_name: String,
pub label: String,
}
impl PreparedCommand {
pub fn context_json(&self) -> String {
serde_json::to_string(&self.context)
.expect("HashMap<String, String> serialization should never fail")
}
}
#[derive(Debug)]
pub enum PreparedStep {
Single(PreparedCommand),
Concurrent(Vec<PreparedCommand>),
}
impl PreparedStep {
pub fn into_commands(self) -> Vec<PreparedCommand> {
match self {
Self::Single(cmd) => vec![cmd],
Self::Concurrent(cmds) => cmds,
}
}
}
pub type ErrorWrapper = Box<dyn Fn(&PreparedCommand, String, Option<i32>) -> anyhow::Error>;
#[derive(Clone)]
pub enum PipelineKind {
Hook {
hook_type: HookType,
display_path: Option<PathBuf>,
},
Alias {
name: String,
},
}
impl PipelineKind {
fn log_label(&self, cmd: &PreparedCommand) -> Option<String> {
match self {
Self::Hook { hook_type, .. } => Some(format!("{hook_type} {}", cmd.label)),
Self::Alias { .. } => None,
}
}
}
pub struct ForegroundStep {
pub step: PreparedStep,
pub announce: PipelineKind,
pub pipe_stdin: bool,
pub redirect_stdout_to_stderr: bool,
pub error_wrapper: ErrorWrapper,
pub directives: DirectivePassthrough,
}
#[derive(Clone, Copy)]
pub enum FailureStrategy {
FailFast,
Warn,
}
impl FailureStrategy {
pub fn default_for(hook_type: HookType) -> Self {
if hook_type.is_pre() {
Self::FailFast
} else {
Self::Warn
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct CommandContext<'a> {
pub repo: &'a Repository,
pub config: &'a UserConfig,
pub branch: Option<&'a str>,
pub worktree_path: &'a Path,
pub yes: bool,
}
impl<'a> CommandContext<'a> {
pub fn new(
repo: &'a Repository,
config: &'a UserConfig,
branch: Option<&'a str>,
worktree_path: &'a Path,
yes: bool,
) -> Self {
Self {
repo,
config,
branch,
worktree_path,
yes,
}
}
pub fn branch_or_head(&self) -> &str {
self.branch.unwrap_or("HEAD")
}
pub fn project_id(&self) -> Option<String> {
self.repo.project_identifier().ok()
}
pub fn commit_generation(&self) -> worktrunk::config::CommitGenerationConfig {
self.config.commit_generation(self.project_id().as_deref())
}
}
pub fn build_hook_context(
ctx: &CommandContext<'_>,
extra_vars: &[(&str, &str)],
referenced: Option<&BTreeSet<String>>,
) -> Result<HashMap<String, String>> {
let repo_root = ctx.repo.repo_path()?;
let repo_name = repo_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let worktree = to_posix_path(&ctx.worktree_path.to_string_lossy());
let worktree_name = ctx
.worktree_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let repo_path = to_posix_path(&repo_root.to_string_lossy());
let want = |key: &str| referenced.is_none_or(|r| r.contains(key));
let mut map = HashMap::new();
map.insert("repo".into(), repo_name.into());
map.insert("branch".into(), ctx.branch_or_head().into());
map.insert("worktree_name".into(), worktree_name.into());
map.insert("repo_path".into(), repo_path.clone());
map.insert("worktree_path".into(), worktree.clone());
map.insert("main_worktree".into(), repo_name.into());
map.insert("repo_root".into(), repo_path);
map.insert("worktree".into(), worktree);
if let Some(parsed_remote) = ctx.repo.primary_remote_parsed_url() {
map.insert("owner".into(), parsed_remote.owner().to_string());
}
if want("default_branch") {
let _span = Span::new("var_default_branch");
if let Some(default_branch) = ctx.repo.default_branch() {
map.insert("default_branch".into(), default_branch);
}
}
if want("primary_worktree_path") || want("main_worktree_path") {
let _span = Span::new("var_primary_worktree");
if let Ok(Some(path)) = ctx.repo.primary_worktree() {
let path_str = to_posix_path(&path.to_string_lossy());
if want("primary_worktree_path") {
map.insert("primary_worktree_path".into(), path_str.clone());
}
if want("main_worktree_path") {
map.insert("main_worktree_path".into(), path_str);
}
}
}
if want("commit") || want("short_commit") {
let _span = Span::new("var_commit");
let commit = match ctx.branch {
Some(branch) => ctx
.repo
.run_command(&["rev-parse", "--verify", "--end-of-options", branch])
.ok()
.map(|s| s.trim().to_owned()),
None => ctx
.repo
.worktree_at(ctx.worktree_path)
.head_sha()
.ok()
.flatten(),
};
if let Some(commit) = commit {
if want("short_commit")
&& let Ok(short) = ctx.repo.short_sha(&commit)
{
map.insert("short_commit".into(), short);
}
if want("commit") {
map.insert("commit".into(), commit);
}
}
}
if want("remote") || want("remote_url") || want("upstream") {
let _span = Span::new("var_remote");
if let Ok(remote) = ctx.repo.primary_remote() {
if want("remote") {
map.insert("remote".into(), remote.to_string());
}
if want("remote_url")
&& let Some(url) = ctx.repo.remote_url(&remote)
{
map.insert("remote_url".into(), url);
}
if want("upstream")
&& let Some(branch) = ctx.branch
&& let Ok(Some(upstream)) = ctx.repo.branch(branch).upstream()
{
map.insert("upstream".into(), upstream);
}
}
}
map.insert(
"cwd".into(),
to_posix_path(&ctx.worktree_path.to_string_lossy()),
);
for (k, v) in extra_vars {
map.insert((*k).into(), (*v).into());
}
Ok(map)
}
pub fn wait_first_error<E>(
results: impl IntoIterator<Item = std::result::Result<(), E>>,
) -> std::result::Result<(), E> {
let mut first = None;
for r in results {
if let Err(e) = r
&& first.is_none()
{
first = Some(e);
}
}
first.map_or(Ok(()), Err)
}
pub fn expand_shell_template(
template: &str,
context: &HashMap<String, String>,
repo: &Repository,
label: &str,
) -> Result<String> {
let vars: HashMap<&str, &str> = context
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
Ok(expand_template(
template,
&vars,
ShellEscapeMode::Posix,
repo,
label,
)?)
}
fn resolve_command_str(cmd: &PreparedCommand, repo: &Repository) -> Result<String> {
expand_shell_template(&cmd.template, &cmd.context, repo, &cmd.template_name)
}
pub fn render_template_preview(
template: &str,
context: &HashMap<String, String>,
repo: &Repository,
name: &str,
) -> Result<String> {
if template_references_var(template, "vars") {
validate_template_syntax(template, name)?;
Ok(template.to_string())
} else {
expand_shell_template(template, context, repo, name)
}
}
pub(crate) fn command_summary_name(name: Option<&str>, source: HookSource) -> String {
match name {
Some(n) => format!("{source}:{n}"),
None => source.to_string(),
}
}
pub fn execute_pipeline_foreground(
steps: &[ForegroundStep],
repo: &Repository,
wt_path: &Path,
failure_strategy: FailureStrategy,
) -> anyhow::Result<()> {
for fg_step in steps {
match &fg_step.step {
PreparedStep::Single(cmd) => {
run_one_command(cmd, fg_step, repo, wt_path, failure_strategy)?;
}
PreparedStep::Concurrent(cmds) => {
run_concurrent_group(cmds, fg_step, repo, wt_path, failure_strategy)?;
}
}
}
Ok(())
}
fn run_concurrent_group(
cmds: &[PreparedCommand],
fg_step: &ForegroundStep,
repo: &Repository,
wt_path: &Path,
failure_strategy: FailureStrategy,
) -> anyhow::Result<()> {
let directives = &fg_step.directives;
let expanded: Vec<String> = cmds
.iter()
.map(|cmd| {
let _span = Span::new(format!("template_render:{}", cmd.label));
resolve_command_str(cmd, repo)
})
.collect::<Result<_>>()?;
for (cmd, command_str) in cmds.iter().zip(&expanded) {
announce_command(cmd, &fg_step.announce, command_str);
}
let labels: Vec<&str> = cmds
.iter()
.map(|cmd| {
cmd.name
.as_deref()
.expect("concurrent group commands are always named")
})
.collect();
let context_jsons: Vec<String> = cmds.iter().map(PreparedCommand::context_json).collect();
let log_labels: Vec<Option<String>> = cmds
.iter()
.map(|cmd| fg_step.announce.log_label(cmd))
.collect();
let specs: Vec<ConcurrentCommand<'_>> = (0..cmds.len())
.map(|i| ConcurrentCommand {
label: labels[i],
expanded: &expanded[i],
working_dir: wt_path,
context_json: &context_jsons[i],
log_label: log_labels[i].as_deref(),
directives,
})
.collect();
let outcomes = run_concurrent_commands(&specs)?;
let mut first_failure: Option<anyhow::Error> = None;
for (outcome, cmd) in outcomes.into_iter().zip(cmds) {
let Err(err) = outcome else { continue };
match handle_command_error(err, cmd, &fg_step.error_wrapper, failure_strategy) {
Ok(()) => {}
Err(e) => {
if first_failure.is_none() {
first_failure = Some(e);
}
}
}
}
match first_failure {
Some(err) => Err(err),
None => Ok(()),
}
}
fn run_one_command(
cmd: &PreparedCommand,
fg_step: &ForegroundStep,
repo: &Repository,
wt_path: &Path,
failure_strategy: FailureStrategy,
) -> anyhow::Result<()> {
let directives = &fg_step.directives;
let command_str = {
let _span = Span::new(format!("template_render:{}", cmd.label));
resolve_command_str(cmd, repo)?
};
announce_command(cmd, &fg_step.announce, &command_str);
let stdin_json = fg_step.pipe_stdin.then(|| cmd.context_json());
let log_label = fg_step.announce.log_label(cmd);
let result = execute_shell_command(
wt_path,
&command_str,
stdin_json.as_deref(),
log_label.as_deref(),
directives.clone(),
fg_step.redirect_stdout_to_stderr,
);
match result {
Ok(()) => Ok(()),
Err(err) => handle_command_error(err, cmd, &fg_step.error_wrapper, failure_strategy),
}
}
fn announce_command(cmd: &PreparedCommand, kind: &PipelineKind, command_str: &str) {
let PipelineKind::Hook {
hook_type,
display_path,
} = kind
else {
return;
};
let full_label = match &cmd.name {
Some(_) => format_command_label(&hook_type.to_string(), Some(&cmd.label)),
None => format!("Running {hook_type} {} hook", cmd.label),
};
let message = match display_path.as_deref() {
Some(path) => {
let path_display = format_path_for_display(path);
cformat!("{full_label} @ <bold>{path_display}</>")
}
None => full_label,
};
if verbosity() >= 1 {
let vars = format_hook_variables(*hook_type, &cmd.context);
eprintln!("{}", info_message("template variables:"));
eprintln!("{}", format_with_gutter(&vars, None));
}
eprintln!("{}", progress_message(message));
eprintln!("{}", format_bash_with_gutter(command_str));
}
pub fn hook_error_wrapper(hook_type: HookType) -> ErrorWrapper {
Box::new(move |cmd, err_msg, exit_code| {
WorktrunkError::HookCommandFailed {
hook_type,
command_name: cmd.name.clone(),
error: err_msg,
exit_code,
}
.into()
})
}
pub fn alias_error_wrapper(alias_name: String) -> ErrorWrapper {
Box::new(move |_cmd, err_msg, exit_code| match exit_code {
Some(code) => WorktrunkError::AlreadyDisplayed { exit_code: code }.into(),
None => anyhow::anyhow!("Failed to run alias '{}': {}", alias_name, err_msg),
})
}
fn handle_command_error(
err: anyhow::Error,
cmd: &PreparedCommand,
error_wrapper: &ErrorWrapper,
failure_strategy: FailureStrategy,
) -> anyhow::Result<()> {
if let Some(exit_code) = err.interrupt_exit_code() {
return Err(WorktrunkError::AlreadyDisplayed { exit_code }.into());
}
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 {
FailureStrategy::FailFast => Err(error_wrapper(cmd, err_msg, exit_code)),
FailureStrategy::Warn => {
let message = match &cmd.name {
Some(name) => cformat!("Command <bold>{name}</> failed: {err_msg}"),
None => format!("Command failed: {err_msg}"),
};
eprintln!("{}", error_message(message));
Ok(())
}
}
}
pub fn map_config_steps(
config: &CommandConfig,
mut prepare: impl FnMut(&Command) -> anyhow::Result<PreparedCommand>,
) -> anyhow::Result<Vec<PreparedStep>> {
config
.steps()
.iter()
.map(|step| match step {
HookStep::Single(cmd) => Ok(PreparedStep::Single(prepare(cmd)?)),
HookStep::Concurrent(cmds) => Ok(PreparedStep::Concurrent(
cmds.iter().map(&mut prepare).collect::<Result<_>>()?,
)),
})
.collect()
}
pub fn prepare_steps(
command_config: &CommandConfig,
ctx: &CommandContext<'_>,
extra_vars: &[(&str, &str)],
hook_type: HookType,
source: HookSource,
) -> anyhow::Result<Vec<PreparedStep>> {
let mut base_context = build_hook_context(ctx, extra_vars, None)?;
base_context.insert("hook_type".into(), hook_type.to_string());
base_context
.entry(worktrunk::config::ALIAS_ARGS_KEY.to_string())
.or_insert_with(|| "[]".to_string());
map_config_steps(command_config, |cmd| {
let mut cmd_context = base_context.clone();
if let Some(ref name) = cmd.name {
cmd_context.insert("hook_name".into(), name.clone());
}
let template_name = match &cmd.name {
Some(name) => format!("{source}:{name}"),
None => format!("{source} {hook_type} hook"),
};
validate_template_syntax(&cmd.template, &template_name)?;
Ok(PreparedCommand {
name: cmd.name.clone(),
template: cmd.template.clone(),
context: cmd_context,
template_name,
label: command_summary_name(cmd.name.as_deref(), source),
})
})
}
#[cfg(test)]
mod tests {
use super::*;
fn make_cmd(name: Option<&str>) -> PreparedCommand {
let label = command_summary_name(name, HookSource::User);
PreparedCommand {
name: name.map(String::from),
template: "echo test".to_string(),
context: HashMap::new(),
template_name: label.clone(),
label,
}
}
#[test]
fn test_handle_command_error_hook_failfast_child_process_exited() {
let err: anyhow::Error = WorktrunkError::ChildProcessExited {
code: 42,
message: "command failed".into(),
signal: None,
}
.into();
let cmd = make_cmd(Some("lint"));
let wrapper = hook_error_wrapper(HookType::PreMerge);
let result = handle_command_error(err, &cmd, &wrapper, FailureStrategy::FailFast);
let err = result.unwrap_err();
let wt_err = err.downcast_ref::<WorktrunkError>().unwrap();
assert!(matches!(
wt_err,
WorktrunkError::HookCommandFailed {
exit_code: Some(42),
..
}
));
}
#[test]
fn test_handle_command_error_hook_failfast_non_child_worktrunk_error() {
let err: anyhow::Error = WorktrunkError::CommandNotApproved.into();
let cmd = make_cmd(Some("build"));
let wrapper = hook_error_wrapper(HookType::PreMerge);
let result = handle_command_error(err, &cmd, &wrapper, FailureStrategy::FailFast);
let err = result.unwrap_err();
let wt_err = err.downcast_ref::<WorktrunkError>().unwrap();
assert!(matches!(
wt_err,
WorktrunkError::HookCommandFailed {
exit_code: None,
..
}
));
}
#[test]
fn test_handle_command_error_alias_failfast_child_process_exited() {
let err: anyhow::Error = WorktrunkError::ChildProcessExited {
code: 1,
message: "exit 1".into(),
signal: None,
}
.into();
let cmd = make_cmd(None);
let wrapper = alias_error_wrapper("deploy".into());
let result = handle_command_error(err, &cmd, &wrapper, FailureStrategy::FailFast);
let err = result.unwrap_err();
let wt_err = err.downcast_ref::<WorktrunkError>().unwrap();
assert!(matches!(
wt_err,
WorktrunkError::AlreadyDisplayed { exit_code: 1 }
));
}
#[test]
fn test_handle_command_error_alias_failfast_other_error() {
let err = anyhow::anyhow!("template error");
let cmd = make_cmd(None);
let wrapper = alias_error_wrapper("deploy".into());
let result = handle_command_error(err, &cmd, &wrapper, FailureStrategy::FailFast);
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Failed to run alias 'deploy'"));
assert!(err_msg.contains("template error"));
}
#[test]
fn test_handle_command_error_warn_continues() {
let err: anyhow::Error = WorktrunkError::ChildProcessExited {
code: 1,
message: "lint failed".into(),
signal: None,
}
.into();
let cmd = make_cmd(Some("lint"));
let wrapper = hook_error_wrapper(HookType::PostCreate);
let result = handle_command_error(err, &cmd, &wrapper, FailureStrategy::Warn);
assert!(result.is_ok());
}
#[test]
fn test_handle_command_error_warn_signal_aborts() {
let err: anyhow::Error = WorktrunkError::ChildProcessExited {
code: 143,
message: "terminated".into(),
signal: Some(15),
}
.into();
let cmd = make_cmd(Some("cleanup"));
let wrapper = hook_error_wrapper(HookType::PostCreate);
let result = handle_command_error(err, &cmd, &wrapper, FailureStrategy::Warn);
let err = result.unwrap_err();
let wt_err = err.downcast_ref::<WorktrunkError>().unwrap();
assert!(matches!(
wt_err,
WorktrunkError::AlreadyDisplayed { exit_code: 143 }
));
}
#[test]
fn test_handle_command_error_warn_unnamed() {
let err = anyhow::anyhow!("unexpected failure");
let cmd = make_cmd(None);
let wrapper = hook_error_wrapper(HookType::PostCreate);
let result = handle_command_error(err, &cmd, &wrapper, FailureStrategy::Warn);
assert!(result.is_ok());
}
#[test]
fn test_template_references_var_for_vars() {
assert!(template_references_var("{{ vars.container }}", "vars"));
assert!(template_references_var("{{vars.container}}", "vars"));
assert!(template_references_var(
"docker run --name {{ vars.name }}",
"vars"
));
assert!(template_references_var(
"{% if vars.key %}yes{% endif %}",
"vars"
));
assert!(!template_references_var(
"echo hello > template_vars.txt",
"vars"
));
assert!(!template_references_var("no vars references here", "vars"));
}
}