use std::collections::HashSet;
use std::io::Write;
use std::path::Path;
use anyhow::Context;
use clap::FromArgMatches;
use clap::error::ErrorKind as ClapErrorKind;
use color_print::{ceprintln, cformat};
use std::process;
use worktrunk::config::{Approvals, UserConfig, set_config_path};
use worktrunk::git::{
ErrorExt, Repository, ResolvedWorktree, 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::command_approval::approve_command_batch;
use commands::hooks::HookAnnouncer;
use commands::project_config::collect_remove_hook_commands;
use commands::worktree::RemoveResult;
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 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::repository_ext::RepositoryCliExt;
use commands::worktree::{PushKind, PushOutcome, PushResult, handle_no_ff_merge, handle_push};
use commands::{
HookCliArgs, MergeFlagOverrides, MergeOptions, OperationMode, RebaseResult, RemoveTarget,
SquashResult, SwitchOptions, add_approvals, clear_approvals, handle_alias_dry_run,
handle_alias_show, handle_claude_install, handle_claude_install_statusline,
handle_claude_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_show_theme, handle_squash, handle_state_clear, handle_state_clear_all, handle_state_get,
handle_state_set, handle_state_show, handle_unconfigure_shell, handle_vars_clear,
handle_vars_get, handle_vars_list, handle_vars_set, resolve_worktree_arg, run_hook, run_switch,
step_commit, step_copy_ignored, step_diff, step_eval, step_for_each, step_prune, step_relocate,
};
use output::handle_remove_output;
use worktrunk::git::BranchDeletionMode;
use cli::{
ApprovalsCommand, CiStatusAction, Cli, Commands, ConfigAliasCommand, ConfigCommand,
ConfigPluginsClaudeCommand, ConfigPluginsCommand, ConfigPluginsOpencodeCommand,
ConfigShellCommand, DefaultBranchAction, HintsAction, HookCommand, HookOptions, ListArgs,
ListSubcommand, LogsAction, MarkerAction, MergeArgs, PreviousBranchAction, RemoveArgs,
StateCommand, StepCommand, SwitchArgs, 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()
}
#[cfg(not(unix))]
fn print_windows_picker_unavailable() {
eprintln!(
"{}",
error_message("Interactive picker is not available on Windows")
);
eprintln!(
"{}",
hint_message(cformat!("Specify a branch: <underline>wt switch BRANCH</>"))
);
}
pub(crate) fn flag_pair(positive: bool, negative: bool) -> Option<bool> {
match (positive, negative) {
(true, _) => Some(true),
(_, true) => Some(false),
_ => None,
}
}
fn warn_select_deprecated() {
eprintln!(
"{}",
warning_message("wt select is deprecated; use wt switch instead")
);
}
fn resolve_verify(verify: bool, no_verify_deprecated: bool) -> bool {
if no_verify_deprecated {
eprintln!(
"{}",
warning_message("--no-verify is deprecated; use --no-hooks instead")
);
false
} else {
verify
}
}
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("wt hook approvals is deprecated; use wt config approvals instead")
);
match action {
ApprovalsCommand::Add { all } => add_approvals(all),
ApprovalsCommand::Clear { global } => clear_approvals(global),
}
}
HookCommand::Run(args) => {
if args.first().and_then(|s| s.to_str()) == Some("post-create") {
eprintln!(
"{}",
warning_message(
"wt hook post-create is deprecated; use wt hook pre-start instead"
)
);
}
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 = resolve_verify(args.verify, args.no_verify_deprecated);
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 = resolve_verify(args.verify, args.no_verify_deprecated);
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())
} else {
let repo = Repository::current()?;
let config = UserConfig::load().context("Failed to load config")?;
let hooks = if verify {
HookGate::Run
} else {
HookGate::NoHooksFlag
};
let mut announcer = HookAnnouncer::new(&repo, &config, false);
let format = args.format;
let result = handle_squash(
args.target.as_deref(),
yes,
hooks,
args.stage,
&mut announcer,
)?;
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, extra_args } => step_diff(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, dry_run } => step_eval(&template, dry_run),
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::External(args) => commands::step_alias(args, yes),
}
}
fn guard_format_on_write(action_name: &str, format: SwitchFormat) {
if format == SwitchFormat::Text {
return;
}
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.exit()
}
fn handle_state_command(action: StateCommand) -> anyhow::Result<()> {
match action {
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 } => 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 } => 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 }) => {
guard_format_on_write("clear", format);
handle_state_clear("ci-status", branch, all)
}
},
StateCommand::Marker { action, 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 }) => {
guard_format_on_write("set", format);
handle_state_set("marker", value, branch)
}
Some(MarkerAction::Clear { branch, all }) => {
guard_format_on_write("clear", format);
handle_state_clear("marker", branch, all)
}
},
StateCommand::Logs { action, format } => match action {
Some(LogsAction::Get) | None => handle_logs_list(format),
Some(LogsAction::Clear) => {
guard_format_on_write("clear", format);
handle_state_clear("logs", None, false)
}
},
StateCommand::Hints { action, format } => match action {
Some(HintsAction::Get) | None => handle_hints_get(format),
Some(HintsAction::Clear { name }) => {
guard_format_on_write("clear", format);
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(),
}
}
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),
}
}
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::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)
}
#[cfg(not(unix))]
fn handle_select_command(_branches: bool, _remotes: bool) -> anyhow::Result<()> {
use worktrunk::git::WorktrunkError;
warn_select_deprecated();
print_windows_picker_unavailable();
Err(WorktrunkError::AlreadyDisplayed { exit_code: 1 }.into())
}
fn handle_switch_command(args: SwitchArgs, yes: bool) -> anyhow::Result<()> {
let verify = resolve_verify(args.verify, args.no_verify_deprecated);
if args.branch.is_none() {
worktrunk::config::suppress_warnings();
}
UserConfig::load()
.context("Failed to load config")
.and_then(|mut config| {
let change_dir_flag = flag_pair(args.cd, args.no_cd);
let Some(branch) = args.branch else {
#[cfg(unix)]
{
return handle_picker(args.branches, args.remotes, change_dir_flag);
}
#[cfg(not(unix))]
{
use worktrunk::git::WorktrunkError;
let _ = (args.branches, args.remotes, change_dir_flag);
print_windows_picker_unavailable();
return Err(WorktrunkError::AlreadyDisplayed { exit_code: 2 }.into());
}
};
run_switch(
SwitchOptions {
branch: &branch,
create: args.create,
base: args.base.as_deref(),
execute: args.execute.as_deref(),
execute_args: &args.execute_args,
yes,
clobber: args.clobber,
change_dir: change_dir_flag,
verify,
format: args.format,
},
&mut config,
&binary_name(),
)
})
}
struct RemovePlans {
others: Vec<RemoveResult>,
branch_only: Vec<RemoveResult>,
current: Option<RemoveResult>,
errors: Vec<anyhow::Error>,
}
impl RemovePlans {
fn has_valid_plans(&self) -> bool {
!self.others.is_empty() || !self.branch_only.is_empty() || self.current.is_some()
}
fn record_error(&mut self, e: anyhow::Error) {
let rendered = e.render_diagnostic().unwrap_or_else(|| e.to_string());
if !rendered.is_empty() {
eprintln!("{rendered}");
}
self.errors.push(e);
}
}
fn validate_remove_targets(
repo: &Repository,
branches: Vec<String>,
config: &UserConfig,
keep_branch: bool,
force_delete: bool,
force: bool,
) -> RemovePlans {
let current_worktree = repo
.current_worktree()
.root()
.ok()
.and_then(|p| dunce::canonicalize(&p).ok());
let branches: Vec<_> = {
let mut seen = HashSet::new();
branches
.into_iter()
.filter(|b| seen.insert(b.clone()))
.collect()
};
let deletion_mode = BranchDeletionMode::from_flags(keep_branch, force_delete);
let worktrees = repo.list_worktrees().ok();
let snapshot = repo.capture_refs().ok();
let mut plans = RemovePlans {
others: Vec::new(),
branch_only: Vec::new(),
current: None,
errors: Vec::new(),
};
for branch_name in &branches {
let resolved = match resolve_worktree_arg(repo, branch_name, config, OperationMode::Remove)
{
Ok(r) => r,
Err(e) => {
plans.record_error(e);
continue;
}
};
match resolved {
ResolvedWorktree::Worktree { path, branch } => {
let path_canonical = dunce::canonicalize(&path).unwrap_or(path);
let is_current = current_worktree.as_ref() == Some(&path_canonical);
if is_current {
match repo.prepare_worktree_removal(
RemoveTarget::Current,
deletion_mode,
force,
config,
None,
worktrees,
snapshot.as_ref(),
) {
Ok(result) => plans.current = Some(result),
Err(e) => plans.record_error(e),
}
continue;
}
let target = if let Some(ref branch_name) = branch {
RemoveTarget::Branch(branch_name)
} else {
RemoveTarget::Path(&path_canonical)
};
match repo.prepare_worktree_removal(
target,
deletion_mode,
force,
config,
None,
worktrees,
snapshot.as_ref(),
) {
Ok(result) => plans.others.push(result),
Err(e) => plans.record_error(e),
}
}
ResolvedWorktree::BranchOnly { branch } => {
match repo.prepare_worktree_removal(
RemoveTarget::Branch(&branch),
deletion_mode,
force,
config,
None,
worktrees,
snapshot.as_ref(),
) {
Ok(result) => plans.branch_only.push(result),
Err(e) => plans.record_error(e),
}
}
}
}
plans
}
fn handle_remove_command(args: RemoveArgs, yes: bool) -> anyhow::Result<()> {
let json_mode = args.format == SwitchFormat::Json;
let verify = resolve_verify(args.verify, args.no_verify_deprecated);
UserConfig::load()
.context("Failed to load config")
.and_then(|config| {
let repo = Repository::current().context("Failed to remove worktree")?;
let project = repo.project_identifier().ok();
let cli_override = flag_pair(args.delete_branch, args.no_delete_branch);
let delete_branch =
cli_override.unwrap_or_else(|| config.remove(project.as_deref()).delete_branch());
if !delete_branch && args.force_delete {
return Err(worktrunk::git::GitError::Other {
message: "Cannot use --force-delete with delete-branch=false (set via --no-delete-branch or [remove] delete-branch = false)".into(),
}
.into());
}
let approve_remove =
|removed_worktree_paths: &[&Path], destination_paths: &[&Path], yes: bool| -> anyhow::Result<bool> {
let commands = collect_remove_hook_commands(
removed_worktree_paths,
destination_paths,
)?;
if commands.is_empty() {
return Ok(true);
}
let project_id = repo.project_identifier()?;
let approvals = Approvals::load().context("Failed to load approvals")?;
let approved =
approve_command_batch(&commands, &project_id, &approvals, yes, false)?;
if !approved {
eprintln!("{}", info_message("Commands declined, continuing removal"));
}
Ok(approved)
};
let branches = args.branches;
if branches.is_empty() {
let result = repo
.prepare_worktree_removal(
RemoveTarget::Current,
BranchDeletionMode::from_flags(!delete_branch, args.force_delete),
args.force,
&config,
None,
None,
None,
)
.context("Failed to remove worktree")?;
if std::env::var_os("WORKTRUNK_FIRST_OUTPUT").is_some() {
return Ok(());
}
let run_hooks = verify
&& approve_remove(
result.removed_worktree_path().as_slice(),
result.destination_path().as_slice(),
yes,
)?;
let mut announcer = HookAnnouncer::new(&repo, &config, false);
handle_remove_output(
&result,
args.foreground,
run_hooks,
false,
false,
&mut announcer,
)?;
announcer.flush()?;
if json_mode {
let json = serde_json::json!([result.to_json()]);
println!("{}", serde_json::to_string_pretty(&json)?);
}
commands::process::sweep_stale_trash(&repo);
Ok(())
} else {
let plans = validate_remove_targets(
&repo,
branches,
&config,
!delete_branch,
args.force_delete,
args.force,
);
if !plans.has_valid_plans() {
anyhow::bail!("");
}
if std::env::var_os("WORKTRUNK_FIRST_OUTPUT").is_some() {
return Ok(());
}
let all_plans = || {
plans
.others
.iter()
.chain(&plans.branch_only)
.chain(plans.current.iter())
};
let removed_targets: Vec<&Path> =
all_plans().filter_map(|r| r.removed_worktree_path()).collect();
let destination_targets: Vec<&Path> =
all_plans().filter_map(|r| r.destination_path()).collect();
let run_hooks =
verify && approve_remove(&removed_targets, &destination_targets, yes)?;
let show_branch =
plans.others.len() + plans.branch_only.len() + plans.current.iter().len() > 1;
let run = |result: &RemoveResult| -> anyhow::Result<()> {
let mut announcer = HookAnnouncer::new(&repo, &config, show_branch);
handle_remove_output(
result,
args.foreground,
run_hooks,
false,
false,
&mut announcer,
)?;
announcer.flush()
};
for result in &plans.others {
run(result)?;
}
for result in &plans.branch_only {
run(result)?;
}
if let Some(ref result) = plans.current {
run(result)?;
}
if json_mode {
let json_items: Vec<serde_json::Value> = plans
.others
.iter()
.chain(&plans.branch_only)
.chain(plans.current.as_ref())
.map(RemoveResult::to_json)
.collect();
println!("{}", serde_json::to_string_pretty(&json_items)?);
}
commands::process::sweep_stale_trash(&repo);
if !plans.errors.is_empty() {
anyhow::bail!("");
}
Ok(())
}
})
}
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 thread_label() -> char {
let thread_id = format!("{:?}", std::thread::current().id());
thread_id
.strip_prefix("ThreadId(")
.and_then(|s| s.strip_suffix(")"))
.and_then(|s| s.parse::<usize>().ok())
.map(|n| {
if n == 0 {
'0'
} else if n <= 26 {
char::from(b'a' + (n - 1) as u8)
} else if n <= 52 {
char::from(b'A' + (n - 27) as u8)
} else {
'?'
}
})
.unwrap_or('?')
}
fn init_logging(verbose_level: u8) {
output::set_verbosity(verbose_level);
let mut builder = match verbose_level {
0 => env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("off")),
1 => {
let mut b = env_logger::Builder::new();
b.filter_level(log::LevelFilter::Info);
b
}
_ => {
let mut b = env_logger::Builder::new();
b.filter_level(log::LevelFilter::Debug);
b
}
};
builder
.format(|buf, record| {
let route = log_files::route(record.target());
if matches!(route, log_files::Route::Drop) {
return Ok(());
}
let thread_num = thread_label();
let msg = record.args().to_string();
let file_line = format!("[{thread_num}] {msg}");
if let log_files::Route::File(sink) = route {
sink.write_line(&file_line);
return Ok(());
}
log_files::TRACE.write_line(&file_line);
if let Some(rest) = msg.strip_prefix("$ ") {
if let Some(bracket_pos) = rest.find(" [") {
let command = &rest[..bracket_pos];
let worktree = &rest[bracket_pos..];
writeln!(
buf,
"{}",
cformat!("<dim>[{thread_num}]</> $ <bold>{command}</>{worktree}")
)
} else {
writeln!(
buf,
"{}",
cformat!("<dim>[{thread_num}]</> $ <bold>{rest}</>")
)
}
} else if msg.starts_with(" ! ") {
writeln!(buf, "{}", cformat!("<dim>[{thread_num}]</> <red>{msg}</>"))
} else {
writeln!(buf, "{}", cformat!("<dim>[{thread_num}]</> {msg}"))
}
})
.init();
if verbose_level >= 2 {
log_files::init();
}
}
fn handle_merge_command(args: MergeArgs, yes: bool) -> anyhow::Result<()> {
if args.no_verify {
eprintln!(
"{}",
warning_message("--no-verify is deprecated; use --no-hooks instead")
);
}
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");
init_logging(verbose);
}
Repository::prewarm();
let command_line = std::env::args().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 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");
}
}