use std::collections::HashMap;
use std::io::Write;
use std::process::Stdio;
use color_print::cformat;
use worktrunk::config::{UserConfig, expand_template};
use worktrunk::git::Repository;
use worktrunk::git::WorktrunkError;
use worktrunk::shell_exec::ShellConfig;
use worktrunk::styling::{
eprintln, error_message, format_with_gutter, progress_message, success_message, warning_message,
};
use crate::commands::command_executor::{CommandContext, build_hook_context};
use crate::commands::worktree_display_name;
pub fn step_for_each(args: Vec<String>, format: crate::cli::SwitchFormat) -> anyhow::Result<()> {
let json_mode = format == crate::cli::SwitchFormat::Json;
let repo = Repository::current()?;
let worktrees: Vec<_> = repo
.list_worktrees()?
.into_iter()
.filter(|wt| !wt.is_prunable())
.collect();
let config = UserConfig::load()?;
let mut failed: Vec<String> = Vec::new();
let mut json_results: Vec<serde_json::Value> = Vec::new();
let total = worktrees.len();
let command_template = args.join(" ");
for wt in &worktrees {
let display_name = worktree_display_name(wt, &repo, &config);
eprintln!(
"{}",
progress_message(format!("Running in {display_name}..."))
);
let ctx = CommandContext::new(&repo, &config, wt.branch.as_deref(), &wt.path, false);
let context_map = build_hook_context(&ctx, &[])?;
let vars: HashMap<&str, &str> = context_map
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let command = expand_template(&command_template, &vars, true, &repo, "for-each command")?;
let context_json = serde_json::to_string(&context_map)
.expect("HashMap<String, String> serialization should never fail");
match run_command_streaming(&command, &wt.path, Some(&context_json)) {
Ok(()) => {
if json_mode {
json_results.push(serde_json::json!({
"branch": wt.branch,
"path": wt.path,
"exit_code": 0,
"success": true,
}));
}
}
Err(err) => {
match &err {
CommandError::SpawnFailed(e) => {
eprintln!(
"{}",
error_message(cformat!(
"Failed in <bold>{display_name}</> (spawn failed)"
))
);
eprintln!("{}", format_with_gutter(e, None));
}
CommandError::ExitCode(code) => {
let exit_info = code
.map(|c| format!(" (exit code {c})"))
.unwrap_or_default();
eprintln!(
"{}",
error_message(cformat!("Failed in <bold>{display_name}</>{exit_info}"))
);
}
}
failed.push(display_name.to_string());
if json_mode {
json_results.push(serde_json::json!({
"branch": wt.branch,
"path": wt.path,
"exit_code": err.exit_code(),
"success": false,
"error": err.to_string(),
}));
}
}
}
}
if json_mode {
println!("{}", serde_json::to_string_pretty(&json_results)?);
if failed.is_empty() {
return Ok(());
} else {
return Err(WorktrunkError::AlreadyDisplayed { exit_code: 1 }.into());
}
}
eprintln!();
if failed.is_empty() {
eprintln!(
"{}",
success_message(format!(
"Completed in {total} worktree{}",
if total == 1 { "" } else { "s" }
))
);
Ok(())
} else {
eprintln!(
"{}",
warning_message(format!(
"{} of {total} worktree{} failed",
failed.len(),
if total == 1 { "" } else { "s" }
))
);
let failed_list = failed.join("\n");
eprintln!("{}", format_with_gutter(&failed_list, None));
Err(WorktrunkError::AlreadyDisplayed { exit_code: 1 }.into())
}
}
pub(crate) enum CommandError {
SpawnFailed(String),
ExitCode(Option<i32>),
}
impl CommandError {
fn exit_code(&self) -> Option<i32> {
match self {
CommandError::SpawnFailed(_) => None,
CommandError::ExitCode(code) => *code,
}
}
}
impl std::fmt::Display for CommandError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CommandError::SpawnFailed(e) => write!(f, "spawn failed: {e}"),
CommandError::ExitCode(Some(c)) => write!(f, "exit code {c}"),
CommandError::ExitCode(None) => write!(f, "killed by signal"),
}
}
}
pub(crate) fn run_command_streaming(
command: &str,
working_dir: &std::path::Path,
stdin_content: Option<&str>,
) -> Result<(), CommandError> {
let shell = ShellConfig::get().map_err(|e| CommandError::SpawnFailed(e.to_string()))?;
log::debug!("$ {} (streaming)", command);
let stdin_mode = if stdin_content.is_some() {
Stdio::piped()
} else {
Stdio::inherit() };
let mut child = shell
.command(command)
.current_dir(working_dir)
.stdin(stdin_mode)
.stdout(Stdio::from(std::io::stderr()))
.stderr(Stdio::inherit())
.env_remove(worktrunk::shell_exec::DIRECTIVE_FILE_ENV_VAR)
.spawn()
.map_err(|e| CommandError::SpawnFailed(e.to_string()))?;
if let Some(content) = stdin_content
&& let Some(mut stdin) = child.stdin.take()
{
let _ = stdin.write_all(content.as_bytes());
}
let status = child
.wait()
.map_err(|e| CommandError::SpawnFailed(e.to_string()))?;
if status.success() {
Ok(())
} else {
Err(CommandError::ExitCode(status.code()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_command_error_display_and_exit_code() {
let spawn = CommandError::SpawnFailed("no such file".into());
assert_eq!(spawn.to_string(), "spawn failed: no such file");
assert_eq!(spawn.exit_code(), None);
let exit = CommandError::ExitCode(Some(42));
assert_eq!(exit.to_string(), "exit code 42");
assert_eq!(exit.exit_code(), Some(42));
let signal = CommandError::ExitCode(None);
assert_eq!(signal.to_string(), "killed by signal");
assert_eq!(signal.exit_code(), None);
}
}