use clap::FromArgMatches;
use clap::error::ErrorKind as ClapErrorKind;
use color_print::{ceprintln, cformat};
use std::process;
use worktrunk::config::set_config_path;
use worktrunk::git::{
ErrorExt, Repository, WorktrunkError, current_or_recover, cwd_removed_hint, set_base_path,
};
use worktrunk::styling::{
eprintln, error_message, format_with_gutter, hint_message, info_message, warning_message,
};
use commands::hooks::HookAnnouncer;
mod cli;
mod commands;
mod completion;
mod diagnostic;
mod display;
mod help;
pub(crate) mod help_pager;
mod invocation;
mod llm;
mod log_files;
mod logging;
mod md_help;
mod output;
mod pager;
mod summary;
pub(crate) use invocation::{
binary_name, invocation_path, is_git_subcommand, was_invoked_with_explicit_path,
};
pub(crate) use crate::cli::OutputFormat;
use commands::commit::HookGate;
#[cfg(unix)]
use commands::handle_picker;
use commands::worktree::{PushKind, PushOutcome, PushResult, handle_no_ff_merge, handle_push};
use commands::{
HookCliArgs, MergeFlagOverrides, MergeOptions, RebaseResult, SquashResult, add_approvals,
clear_approvals, flag_pair, handle_alias_dry_run, handle_alias_show, handle_cache_clear,
handle_cache_get, handle_claude_install, handle_claude_install_statusline,
handle_claude_uninstall, handle_codex_install, handle_codex_uninstall, handle_completions,
handle_config_create, handle_config_show, handle_config_update, handle_configure_shell,
handle_custom_command, handle_hints_clear, handle_hints_get, handle_hook_show, handle_init,
handle_list, handle_logs_list, handle_merge, handle_opencode_install,
handle_opencode_uninstall, handle_promote, handle_rebase, handle_remove_command,
handle_show_theme, handle_squash, handle_state_clear, handle_state_clear_all, handle_state_get,
handle_state_set, handle_state_show, handle_switch_command, handle_unconfigure_shell,
handle_vars_clear, handle_vars_get, handle_vars_list, handle_vars_set, run_hook, step_commit,
step_copy_ignored, step_diff, step_eval, step_for_each, step_prune, step_relocate, step_tether,
};
use cli::{
ApprovalsCommand, CacheAction, CiStatusAction, Cli, Commands, ConfigAliasCommand,
ConfigCommand, ConfigPluginsClaudeCommand, ConfigPluginsCodexCommand, ConfigPluginsCommand,
ConfigPluginsOpencodeCommand, ConfigShellCommand, DefaultBranchAction, GlobalFormatFlag,
HintsAction, HookCommand, HookOptions, ListArgs, ListSubcommand, LogsAction, MarkerAction,
MergeArgs, PreviousBranchAction, StateCommand, StateWrite, StepCommand, SwitchFormat,
VarsAction,
};
fn print_enhanced_clap_error(err: &clap::Error) {
if err.kind() == ClapErrorKind::InvalidSubcommand
&& let Some(unknown) = err.get(clap::error::ContextKind::InvalidSubcommand)
{
let cmd = cli::build_command();
if let Some(suggestion) = cli::suggest_nested_subcommand(&cmd, &unknown.to_string()) {
ceprintln!(
"{}
<yellow>tip:</> perhaps <cyan,bold>{suggestion}</cyan,bold>?",
err.render().ansi()
);
return;
}
}
let _ = err.print();
}
pub(crate) fn enhance_and_exit_error(err: clap::Error) -> ! {
print_enhanced_clap_error(&err);
process::exit(err.exit_code());
}
pub(crate) fn enhance_clap_error(err: clap::Error) -> anyhow::Error {
let exit_code = err.exit_code();
print_enhanced_clap_error(&err);
WorktrunkError::AlreadyDisplayed { exit_code }.into()
}
fn warn_select_deprecated() {
eprintln!(
"{}",
warning_message(cformat!(
"wt select is deprecated; use <bold>wt switch</> instead"
))
);
}
fn warn_state_subcommand_deprecated(name: &str) {
eprintln!(
"{}",
warning_message(cformat!(
"wt config state {name} is deprecated; use <bold>wt config state cache</> instead"
))
);
}
pub(crate) fn warn_no_verify_deprecated() {
eprintln!(
"{}",
warning_message(cformat!(
"--no-verify is deprecated; use <bold>--no-hooks</> instead"
))
);
}
fn handle_hook_command(action: HookCommand, yes: bool) -> anyhow::Result<()> {
match action {
HookCommand::Show {
hook_type,
expanded,
format,
} => handle_hook_show(hook_type.as_deref(), expanded, format),
HookCommand::RunPipeline => commands::run_pipeline(),
HookCommand::Approvals { action } => {
eprintln!(
"{}",
warning_message(cformat!(
"wt hook approvals is deprecated; use <bold>wt config approvals</> instead"
))
);
match action {
ApprovalsCommand::Add { all } => add_approvals(all),
ApprovalsCommand::Clear { global } => clear_approvals(global),
}
}
HookCommand::Run(args) => {
let opts = HookOptions::parse(&args)?;
run_hook(
opts.hook_type,
yes || opts.yes,
opts.foreground,
opts.dry_run,
HookCliArgs {
name_filters: &opts.name_filters,
explicit_vars: &opts.explicit_vars,
shorthand_vars: &opts.shorthand_vars,
forwarded_args: &opts.forwarded_args,
},
)
}
}
}
fn handle_step_command(action: StepCommand, yes: bool) -> anyhow::Result<()> {
match action {
StepCommand::Commit(args) => {
let verify = args.hooks.resolve();
let format = args.format;
if format == SwitchFormat::Json && (args.show_prompt || args.dry_run) {
anyhow::bail!("--show-prompt / --dry-run cannot be combined with --format=json");
}
let outcome = step_commit(
args.branch,
yes,
verify,
args.stage,
args.show_prompt,
args.dry_run,
)?;
if format == SwitchFormat::Json
&& let Some(outcome) = outcome
{
let payload = serde_json::json!({
"commit": outcome.sha,
"message": outcome.message,
"stage_mode": outcome.stage_mode,
});
println!("{}", serde_json::to_string_pretty(&payload)?);
}
Ok(())
}
StepCommand::Squash(args) => {
let verify = args.hooks.resolve();
if args.format == SwitchFormat::Json && (args.show_prompt || args.dry_run) {
anyhow::bail!("--show-prompt / --dry-run cannot be combined with --format=json");
}
if args.show_prompt {
commands::step_show_squash_prompt(args.target.as_deref())
} else if args.dry_run {
commands::step_dry_run_squash(args.target.as_deref(), yes)
} else {
let repo = Repository::current()?;
let hooks = if verify {
HookGate::Run
} else {
HookGate::NoHooksFlag
};
let mut announcer = HookAnnouncer::new(&repo, false);
let format = args.format;
let result = handle_squash(
args.target.as_deref(),
yes,
hooks,
args.stage,
&mut announcer,
commands::PreApprovedGuidance::RunOwnGate,
)?;
announcer.flush()?;
if format == SwitchFormat::Json {
let payload = match &result {
SquashResult::Squashed {
sha,
message,
stage_mode,
} => serde_json::json!({
"outcome": "squashed",
"commit": sha,
"message": message,
"stage_mode": stage_mode,
}),
SquashResult::NoCommitsAhead(target) => serde_json::json!({
"outcome": "no_commits_ahead",
"target": target,
}),
SquashResult::AlreadySingleCommit => serde_json::json!({
"outcome": "already_single_commit",
}),
SquashResult::NoNetChanges => serde_json::json!({
"outcome": "no_net_changes",
}),
};
println!("{}", serde_json::to_string_pretty(&payload)?);
} else {
match result {
SquashResult::Squashed { .. } | SquashResult::NoNetChanges => {}
SquashResult::NoCommitsAhead(branch) => {
eprintln!(
"{}",
info_message(cformat!(
"Nothing to squash; no commits ahead of <bold>{branch}</>"
))
);
}
SquashResult::AlreadySingleCommit => {
eprintln!(
"{}",
info_message("Nothing to squash; already a single commit")
);
}
}
}
Ok(())
}
}
StepCommand::Push {
target,
no_ff,
format,
..
} => {
let result = if no_ff {
let repo = Repository::current()?;
let current_branch = repo.require_current_branch("step push --no-ff")?;
handle_no_ff_merge(target.as_deref(), None, ¤t_branch)?
} else {
handle_push(target.as_deref(), PushKind::Standalone, None)?
};
if format == SwitchFormat::Json {
let PushResult {
target,
commit_count,
outcome,
} = result;
let mut payload = serde_json::json!({
"target": target,
"outcome": match outcome {
PushOutcome::FastForwarded => "fast_forwarded",
PushOutcome::UpToDate => "up_to_date",
PushOutcome::MergeCommit { .. } => "merge_commit",
},
"commits": commit_count,
});
if let PushOutcome::MergeCommit { merge_sha } = outcome {
payload["merge_sha"] = serde_json::Value::String(merge_sha);
}
println!("{}", serde_json::to_string_pretty(&payload)?);
}
Ok(())
}
StepCommand::Rebase { target, format } => {
let result = handle_rebase(target.as_deref())?;
if format == SwitchFormat::Json {
let output = match &result {
RebaseResult::Rebased {
target,
fast_forward,
} => serde_json::json!({
"target": target,
"outcome": if *fast_forward { "fast_forwarded" } else { "rebased" },
}),
RebaseResult::UpToDate(target) => serde_json::json!({
"target": target,
"outcome": "up_to_date",
}),
};
println!("{}", serde_json::to_string_pretty(&output)?);
} else if let RebaseResult::UpToDate(branch) = &result {
eprintln!(
"{}",
info_message(cformat!("Already up to date with <bold>{branch}</>"))
);
}
Ok(())
}
StepCommand::Diff {
target,
branch,
extra_args,
} => step_diff(branch.as_deref(), target.as_deref(), &extra_args),
StepCommand::CopyIgnored {
from,
to,
dry_run,
force,
format,
} => step_copy_ignored(from.as_deref(), to.as_deref(), dry_run, force, format),
StepCommand::Eval { template } => step_eval(&template),
StepCommand::ForEach { format, args } => step_for_each(args, format),
StepCommand::Promote { branch } => {
handle_promote(branch.as_deref()).map(|result| match result {
commands::PromoteResult::Promoted => (),
commands::PromoteResult::AlreadyInMain(branch) => {
eprintln!(
"{}",
info_message(cformat!(
"Branch <bold>{branch}</> is already in main worktree"
))
);
}
})
}
StepCommand::Prune {
dry_run,
min_age,
foreground,
format,
} => step_prune(dry_run, yes, &min_age, foreground, format),
StepCommand::Relocate {
branches,
dry_run,
commit,
clobber,
format,
} => step_relocate(branches, dry_run, commit, clobber, format),
StepCommand::Tether { command } => step_tether(&command),
StepCommand::External(args) => commands::step_alias(args, yes),
}
}
fn guard_format_on_write(action_name: &str, format: SwitchFormat) -> anyhow::Result<()> {
if format == SwitchFormat::Text {
return Ok(());
}
let mut cmd = cli::build_command();
let usage = cmd.render_usage();
let mut err = clap::Error::new(ClapErrorKind::ArgumentConflict).with_cmd(&cmd);
err.insert(
clap::error::ContextKind::InvalidArg,
clap::error::ContextValue::String("--format <FORMAT>".to_owned()),
);
err.insert(
clap::error::ContextKind::PriorArg,
clap::error::ContextValue::String(action_name.to_owned()),
);
err.insert(
clap::error::ContextKind::Usage,
clap::error::ContextValue::StyledStr(usage),
);
Err(enhance_clap_error(err))
}
fn handle_state_command(action: StateCommand, yes: bool) -> anyhow::Result<()> {
match action {
StateCommand::Cache {
action,
format: GlobalFormatFlag { format },
} => {
if let Some(verb) = action.as_ref().and_then(StateWrite::write_verb) {
guard_format_on_write(verb, format)?;
}
match action {
Some(CacheAction::Get) | None => handle_cache_get(format),
Some(CacheAction::Clear) => handle_cache_clear(),
}
}
StateCommand::DefaultBranch { action } => match action {
Some(DefaultBranchAction::Get) | None => {
handle_state_get("default-branch", None, SwitchFormat::Text)
}
Some(DefaultBranchAction::Set { branch }) => {
handle_state_set("default-branch", branch, None)
}
Some(DefaultBranchAction::Clear) => handle_state_clear("default-branch", None, false),
},
StateCommand::PreviousBranch { action } => {
warn_state_subcommand_deprecated("previous-branch");
match action {
Some(PreviousBranchAction::Get) | None => {
handle_state_get("previous-branch", None, SwitchFormat::Text)
}
Some(PreviousBranchAction::Set { branch }) => {
handle_state_set("previous-branch", branch, None)
}
Some(PreviousBranchAction::Clear) => {
handle_state_clear("previous-branch", None, false)
}
}
}
StateCommand::CiStatus {
action,
format: GlobalFormatFlag { format },
} => {
warn_state_subcommand_deprecated("ci-status");
if let Some(verb) = action.as_ref().and_then(StateWrite::write_verb) {
guard_format_on_write(verb, format)?;
}
match action {
Some(CiStatusAction::Get { branch }) => {
handle_state_get("ci-status", branch, format)
}
None => handle_state_get("ci-status", None, format),
Some(CiStatusAction::Clear { branch, all }) => {
handle_state_clear("ci-status", branch, all)
}
}
}
StateCommand::Marker {
action,
format: GlobalFormatFlag { format },
} => {
if let Some(verb) = action.as_ref().and_then(StateWrite::write_verb) {
guard_format_on_write(verb, format)?;
}
match action {
Some(MarkerAction::Get { branch }) => handle_state_get("marker", branch, format),
None => handle_state_get("marker", None, format),
Some(MarkerAction::Set { value, branch }) => {
handle_state_set("marker", value, branch)
}
Some(MarkerAction::Clear { branch, all }) => {
handle_state_clear("marker", branch, all)
}
}
}
StateCommand::Logs {
action,
format: GlobalFormatFlag { format },
} => {
if let Some(verb) = action.as_ref().and_then(StateWrite::write_verb) {
guard_format_on_write(verb, format)?;
}
match action {
Some(LogsAction::Get) | None => handle_logs_list(format),
Some(LogsAction::Clear) => handle_state_clear("logs", None, false),
}
}
StateCommand::Hints {
action,
format: GlobalFormatFlag { format },
} => {
warn_state_subcommand_deprecated("hints");
if let Some(verb) = action.as_ref().and_then(StateWrite::write_verb) {
guard_format_on_write(verb, format)?;
}
match action {
Some(HintsAction::Get) | None => handle_hints_get(format),
Some(HintsAction::Clear { name }) => handle_hints_clear(name),
}
}
StateCommand::Vars { action } => match action {
VarsAction::Get { key, branch } => handle_vars_get(&key, branch),
VarsAction::Set {
assignment: (key, value),
branch,
} => handle_vars_set(&key, &value, branch),
VarsAction::List { branch, format } => handle_vars_list(branch, format),
VarsAction::Clear { key, all, branch } => {
handle_vars_clear(key.as_deref(), all, branch)
}
},
StateCommand::Get { format } => handle_state_show(format),
StateCommand::Clear => handle_state_clear_all(yes),
}
}
fn handle_config_shell_command(action: ConfigShellCommand, yes: bool) -> anyhow::Result<()> {
match action {
ConfigShellCommand::Init { shell, cmd } => {
let cmd = cmd.unwrap_or_else(binary_name);
handle_init(shell, cmd).map_err(|e| anyhow::anyhow!("{}", e))
}
ConfigShellCommand::Install {
shell,
dry_run,
cmd,
} => {
let cmd = cmd.unwrap_or_else(binary_name);
handle_configure_shell(shell, yes, dry_run, cmd)
.map_err(|e| anyhow::anyhow!("{}", e))
.and_then(|scan_result| {
if scan_result.configured.is_empty() {
crate::output::print_skipped_shells(&scan_result.skipped);
return Err(worktrunk::git::GitError::Other {
message: "No shell config files found".into(),
}
.into());
}
if dry_run {
return Ok(());
}
crate::output::print_shell_install_result(&scan_result);
Ok(())
})
}
ConfigShellCommand::Uninstall { shell, dry_run } => {
let explicit_shell = shell.is_some();
handle_unconfigure_shell(shell, yes, dry_run, &binary_name())
.map_err(|e| anyhow::anyhow!("{}", e))
.map(|result| {
if !dry_run {
crate::output::print_shell_uninstall_result(&result, explicit_shell);
}
})
}
ConfigShellCommand::ShowTheme => {
handle_show_theme();
Ok(())
}
ConfigShellCommand::Completions { shell } => handle_completions(shell),
}
}
fn handle_config_command(action: ConfigCommand, yes: bool) -> anyhow::Result<()> {
match action {
ConfigCommand::Shell { action } => handle_config_shell_command(action, yes),
ConfigCommand::Create { project } => handle_config_create(project),
ConfigCommand::Show { full, format } => handle_config_show(full, format),
ConfigCommand::Update { print } => handle_config_update(yes, print),
ConfigCommand::Approvals { action } => match action {
ApprovalsCommand::Add { all } => add_approvals(all),
ApprovalsCommand::Clear { global } => clear_approvals(global),
},
ConfigCommand::Alias { action } => match action {
ConfigAliasCommand::Show { name } => handle_alias_show(name),
ConfigAliasCommand::DryRun { name, args } => handle_alias_dry_run(name, args),
},
ConfigCommand::Plugins { action } => handle_plugins_command(action, yes),
ConfigCommand::State { action } => handle_state_command(action, yes),
}
}
fn handle_plugins_command(action: ConfigPluginsCommand, yes: bool) -> anyhow::Result<()> {
match action {
ConfigPluginsCommand::Claude { action } => match action {
ConfigPluginsClaudeCommand::Install => handle_claude_install(yes),
ConfigPluginsClaudeCommand::Uninstall => handle_claude_uninstall(yes),
ConfigPluginsClaudeCommand::InstallStatusline => handle_claude_install_statusline(yes),
},
ConfigPluginsCommand::Codex { action } => match action {
ConfigPluginsCodexCommand::Install => handle_codex_install(yes),
ConfigPluginsCodexCommand::Uninstall => handle_codex_uninstall(yes),
},
ConfigPluginsCommand::Opencode { action } => match action {
ConfigPluginsOpencodeCommand::Install => handle_opencode_install(yes),
ConfigPluginsOpencodeCommand::Uninstall => handle_opencode_uninstall(yes),
},
}
}
fn handle_list_command(args: ListArgs) -> anyhow::Result<()> {
match args.subcommand {
Some(ListSubcommand::Statusline {
format,
claude_code,
}) => {
if claude_code {
eprintln!(
"{}",
warning_message(
"--claude-code is deprecated; use --format=claude-code instead"
)
);
}
let effective_format = if claude_code && matches!(format, OutputFormat::Table) {
OutputFormat::ClaudeCode
} else {
format
};
commands::statusline::run(effective_format)
}
None => {
let (repo, _recovered) = current_or_recover()?;
handle_list(
repo,
args.format,
args.branches,
args.remotes,
args.full,
flag_pair(args.progressive, args.no_progressive),
)
}
}
}
#[cfg(unix)]
fn handle_select_command(branches: bool, remotes: bool) -> anyhow::Result<()> {
warn_select_deprecated();
worktrunk::config::suppress_warnings();
handle_picker(branches, remotes, None, SwitchFormat::Text)
}
#[cfg(not(unix))]
fn handle_select_command(_branches: bool, _remotes: bool) -> anyhow::Result<()> {
use worktrunk::git::WorktrunkError;
warn_select_deprecated();
commands::print_windows_picker_unavailable();
Err(WorktrunkError::AlreadyDisplayed { exit_code: 1 }.into())
}
pub(crate) fn rayon_thread_count() -> usize {
std::thread::available_parallelism()
.map(|n| n.get() * 2)
.unwrap_or(8)
}
fn init_rayon_thread_pool() {
let num_threads = if std::env::var_os("RAYON_NUM_THREADS").is_some() {
0 } else {
rayon_thread_count()
};
let _ = rayon::ThreadPoolBuilder::new()
.num_threads(num_threads)
.build_global();
}
fn parse_cli() -> Option<Cli> {
if completion::maybe_handle_env_completion() {
return None;
}
let (directory, config, alias_help_context) = parse_early_globals();
apply_global_options(directory, config);
help::maybe_handle_help_with_pager(alias_help_context);
let cmd = cli::build_command();
let matches = cmd
.try_get_matches_from(std::env::args_os())
.unwrap_or_else(|e| {
enhance_and_exit_error(e);
});
Some(Cli::from_arg_matches(&matches).unwrap_or_else(|e| e.exit()))
}
fn apply_global_options(directory: Option<std::path::PathBuf>, config: Option<std::path::PathBuf>) {
if let Some(path) = directory {
set_base_path(path);
}
if let Some(path) = config {
set_config_path(path);
}
}
fn parse_early_globals() -> (
Option<std::path::PathBuf>,
Option<std::path::PathBuf>,
Option<commands::HelpContext>,
) {
let cmd = cli::build_command()
.ignore_errors(true)
.disable_help_flag(true);
let Ok(matches) = cmd.try_get_matches_from(std::env::args_os()) else {
return (None, None, None);
};
let directory = matches.get_one::<std::path::PathBuf>("directory").cloned();
let config = matches.get_one::<std::path::PathBuf>("config").cloned();
let alias_help_context = match matches.subcommand() {
None => Some(commands::HelpContext::TopLevel),
Some(("step", sub)) if sub.subcommand_name().is_none() => Some(commands::HelpContext::Step),
_ => None,
};
(directory, config, alias_help_context)
}
fn init_command_log(command_line: &str) {
if let Ok(repo) = worktrunk::git::Repository::current() {
worktrunk::command_log::init(&repo.wt_logs_dir(), command_line);
}
}
fn handle_merge_command(args: MergeArgs, yes: bool) -> anyhow::Result<()> {
if args.no_verify {
warn_no_verify_deprecated();
}
handle_merge(MergeOptions {
target: args.target.as_deref(),
flags: MergeFlagOverrides::from_cli(&args),
yes,
stage: args.stage,
format: args.format,
})
}
fn command_suppresses_warnings(command: Option<&Commands>) -> bool {
match command {
Some(Commands::Select { .. }) => true,
Some(Commands::Switch(args)) => args.branch.is_none(),
Some(Commands::List(args)) => {
matches!(args.subcommand, Some(ListSubcommand::Statusline { .. }))
}
Some(Commands::Config {
action: ConfigCommand::Update { .. },
}) => true,
_ => false,
}
}
fn dispatch_command(
command: Commands,
working_dir: Option<std::path::PathBuf>,
yes: bool,
) -> anyhow::Result<()> {
match command {
Commands::Config { action } => handle_config_command(action, yes),
Commands::Step { action } => handle_step_command(action, yes),
Commands::Hook { action } => handle_hook_command(action, yes),
Commands::Select { branches, remotes } => handle_select_command(branches, remotes),
Commands::List(args) => handle_list_command(args),
Commands::Switch(args) => handle_switch_command(args, yes),
Commands::Remove(args) => handle_remove_command(args, yes),
Commands::Merge(args) => handle_merge_command(args, yes),
Commands::Custom(args) => handle_custom_command(args, working_dir, yes),
}
}
fn print_command_error(error: &anyhow::Error) {
let formatted = format_command_error(error);
if !formatted.is_empty() {
eprintln!("{}", formatted.trim_end_matches('\n'));
}
}
fn format_command_error(error: &anyhow::Error) -> String {
use std::fmt::Write;
let mut out = String::new();
let diagnostic_hit = error.chain().enumerate().find_map(|(i, cause)| {
worktrunk::git::try_render_diagnostic(cause)
.map(|r| (i, r, cause.is::<worktrunk::git::CommandError>()))
});
let wrapped_command_error = matches!(diagnostic_hit, Some((pos, _, true)) if pos > 0);
match diagnostic_hit {
Some((_, rendered, _)) if !wrapped_command_error => {
if !rendered.is_empty() {
let _ = writeln!(out, "{rendered}");
}
}
Some(_) => {
let _ = writeln!(out, "{}", error_message(error.to_string()));
let mut gutter_parts: Vec<String> = Vec::new();
let mut command_handled = false;
for cause in error.chain().skip(1) {
if !command_handled
&& let Some(cmd_err) = cause.downcast_ref::<worktrunk::git::CommandError>()
{
let body = cmd_err.combined_output();
gutter_parts.push(if body.is_empty() {
cmd_err.to_string()
} else {
body
});
command_handled = true;
} else {
gutter_parts.push(cause.to_string());
}
}
if !gutter_parts.is_empty() {
let _ = writeln!(
out,
"{}",
format_with_gutter(&gutter_parts.join("\n"), None)
);
}
}
None => {
let msg = error.to_string();
if !msg.is_empty() {
let chain: Vec<String> = error.chain().skip(1).map(|e| e.to_string()).collect();
if !chain.is_empty() {
let _ = writeln!(out, "{}", error_message(&msg));
let chain_text = chain.join("\n");
let _ = writeln!(out, "{}", format_with_gutter(&chain_text, None));
} else if msg.contains('\n') || msg.contains('\r') {
debug_assert!(
false,
"Multiline error without CommandError or context: {msg}"
);
log::warn!("Multiline error without CommandError or context: {msg}");
let normalized = msg.replace("\r\n", "\n").replace('\r', "\n");
let _ = writeln!(out, "{}", error_message("Command failed"));
let _ = writeln!(out, "{}", format_with_gutter(&normalized, None));
} else {
let _ = writeln!(out, "{}", error_message(&msg));
}
}
}
}
out
}
fn print_cwd_removed_hint_if_needed() {
let cwd_gone = output::was_cwd_removed() || std::env::current_dir().is_err();
if cwd_gone {
if let Some(hint) = cwd_removed_hint() {
eprintln!("{}", hint_message(hint));
} else {
eprintln!("{}", info_message("Current directory was removed"));
}
}
}
fn finish_command(verbose_level: u8, command_line: &str, error: Option<&anyhow::Error>) {
let error_text = error.map(|err| err.to_string());
diagnostic::write_if_verbose(verbose_level, command_line, error_text.as_deref());
let _ = output::terminate_output();
}
fn handle_command_failure(error: anyhow::Error, verbose_level: u8, command_line: &str) -> ! {
print_command_error(&error);
print_cwd_removed_hint_if_needed();
let code = error.exit_code().unwrap_or(1);
finish_command(verbose_level, command_line, Some(&error));
process::exit(code);
}
fn print_help_to_stderr() {
let mut cmd = cli::build_command();
let help = cmd.render_help().ansi().to_string();
eprintln!("{help}");
}
fn main() {
worktrunk::shell_exec::init_startup_cwd();
init_rayon_thread_pool();
crossterm::style::force_color_output(true);
let Some(cli) = parse_cli() else {
return;
};
let Cli {
directory,
config,
verbose,
yes,
command,
} = cli;
apply_global_options(directory.clone(), config);
if command_suppresses_warnings(command.as_ref()) {
worktrunk::config::suppress_warnings();
}
{
let _span = worktrunk::trace::Span::new("init_logging");
logging::init(verbose);
}
Repository::prewarm();
let command_line = std::env::args_os()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join(" ");
{
let _span = worktrunk::trace::Span::new("init_command_log");
init_command_log(&command_line);
}
let Some(command) = command else {
print_help_to_stderr();
return;
};
let result = dispatch_command(command, directory, yes);
match result {
Ok(()) => finish_command(verbose, &command_line, None),
Err(error) => handle_command_failure(error, verbose, &command_line),
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Context;
use worktrunk::git::CommandError;
fn permission_denied_command_error() -> CommandError {
CommandError {
program: "git".into(),
args: vec!["worktree".into(), "list".into()],
stderr: "warning: unable to access '.git/config': Permission denied\nfatal: unknown error occurred while reading the configuration files".into(),
stdout: String::new(),
exit_code: Some(128),
}
}
#[test]
fn renders_command_error_without_context() {
let err: anyhow::Error = permission_denied_command_error().into();
let out = format_command_error(&err);
assert!(out.contains("git worktree list failed (exit 128)"));
assert!(out.contains("Permission denied"));
assert!(out.contains("unknown error occurred while reading"));
}
#[test]
fn renders_command_error_with_one_context() {
let err: anyhow::Error = Err::<(), _>(permission_denied_command_error())
.context("listing worktrees")
.unwrap_err();
let out = format_command_error(&err);
assert!(out.contains("listing worktrees"));
assert!(out.contains("Permission denied"));
}
#[test]
fn renders_command_error_preserves_intermediate_context() {
let err: anyhow::Error = Err::<(), _>(permission_denied_command_error())
.context("listing worktrees")
.context("running prune")
.unwrap_err();
let out = format_command_error(&err);
assert!(
out.contains("running prune"),
"missing outer context: {out}"
);
assert!(
out.contains("listing worktrees"),
"intermediate context dropped: {out}",
);
assert!(out.contains("Permission denied"), "stderr lost: {out}",);
assert!(
!out.contains("git worktree list failed"),
"summary surfaced alongside stderr: {out}",
);
}
#[test]
fn renders_command_error_with_empty_body() {
let empty = CommandError {
program: "git".into(),
args: vec!["fetch".into()],
stderr: String::new(),
stdout: String::new(),
exit_code: None,
};
let err: anyhow::Error = Err::<(), _>(empty).context("syncing remotes").unwrap_err();
let out = format_command_error(&err);
assert!(out.contains("syncing remotes"));
assert!(out.contains("git fetch failed"));
}
#[test]
fn display_message_prefers_command_error_stderr_over_summary() {
let err: anyhow::Error = Err::<(), _>(permission_denied_command_error())
.context("creating worktree")
.unwrap_err();
let detail = err.display_message();
assert!(detail.contains("Permission denied"));
assert!(detail.contains("unknown error occurred while reading"));
assert!(!detail.starts_with("creating worktree"));
}
#[test]
fn display_message_falls_back_to_summary_when_capture_empty() {
let empty = CommandError {
program: "git".into(),
args: vec!["fetch".into()],
stderr: String::new(),
stdout: String::new(),
exit_code: None,
};
let err: anyhow::Error = empty.into();
assert_eq!(err.display_message(), "git fetch failed");
}
}