//! Output handlers for worktree operations using the global output context

use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Stdio;

use anstyle::AnsiColor;
use color_print::cformat;
use worktrunk::shell_exec::Cmd;
use worktrunk::styling::{eprint, format_bash_with_gutter, stderr};

use crate::commands::command_executor::CommandContext;
use crate::commands::command_executor::FailureStrategy;
use crate::commands::hook_plan::{ApprovedHookPlan, execute_planned_hook, register_planned};
use crate::commands::hooks::HookAnnouncer;
use crate::commands::process::{
    HookLog, InternalOp, build_remove_command, build_remove_command_staged, spawn_detached,
};
use crate::commands::repository_ext::compute_integration_reason;
use crate::commands::worktree::hooks::PostRemoveContext;
use crate::commands::worktree::{RemoveResult, SwitchBranchInfo, SwitchResult};
use worktrunk::config::UserConfig;
use worktrunk::git::ErrorExt;
use worktrunk::git::GitError;
use worktrunk::git::IntegrationReason;
use worktrunk::git::Repository;
use worktrunk::git::path_dir_name;
use worktrunk::git::{
    BranchDeletionMode, BranchDeletionOutcome, BranchDeletionResult, RemoveOptions,
    delete_branch_if_safe, remove_worktree_with_cleanup, stage_worktree_removal,
    stop_fsmonitor_daemon,
};
use worktrunk::path::format_path_for_display;
use worktrunk::progress::{Progress, format_stats_paren};
use worktrunk::remove_dir::remove_dir_with_progress;
use worktrunk::styling::{
    FormattedMessage, eprintln, error_message, format_with_gutter, hint_message, info_message,
    progress_message, success_message, suggest_command, verbosity, warning_message,
};

use super::shell_integration::{
    compute_shell_warning_reason, explicit_path_hint, git_subcommand_warning,
    print_shell_integration_hint, should_show_explicit_path_hint,
};

// ============================================================================
// Foreground Trash Cleanup
// ============================================================================

/// Walk the staged trash directory, unlinking files with a TTY spinner.
///
/// Used by the foreground removal path after `remove_worktree_with_cleanup`
/// renames the worktree into `<git-common-dir>/wt/trash/`. The spinner shows
/// `⠼ Removing N files · X MiB` while the walk proceeds; the returned counts
/// drive the post-op summary so the success message matches the spinner.
///
/// Suppresses the spinner when stderr isn't a TTY (auto-detected by
/// `Progress::start`) or when verbosity ≥ 1 (verbose mode prefers structured
/// output over live updates). The walk itself is best-effort — see
/// [`remove_dir_with_progress`].
fn cleanup_staged_with_progress(staged: &Path) -> (usize, u64) {
    let progress = if verbosity() >= 1 {
        Progress::disabled()
    } else {
        Progress::start("Removing")
    };
    remove_dir_with_progress(staged, &progress);
    let totals = progress.totals();
    progress.finish();
    totals
}

// ============================================================================
// Background Removal Helpers
// ============================================================================

/// The removal parameters shared by [`spawn_background_removal`] and
/// [`execute_instant_removal_or_fallback`]. `branch_name` / `deletion_mode` /
/// `target_branch` drive the freshly-recomputed branch deletion; the rest
/// control the worktree removal itself.
struct BackgroundRemoval<'a> {
    worktree_path: &'a Path,
    branch_name: Option<&'a str>,
    deletion_mode: BranchDeletionMode,
    target_branch: Option<&'a str>,
    force_worktree: bool,
    changed_directory: bool,
    /// `true` when the planner already decided the branch would be retained
    /// (unmerged, or `--no-delete-branch`) — `print_hints` has explained why,
    /// so [`warn_if_branch_retained`] stays silent on the expected
    /// `NotDeleted` outcome and only fires when the deletion command errors.
    /// `false` means the planner predicted deletion; a `NotDeleted` here is a
    /// race (e.g. `pre-remove` hook advanced the branch) that the user has
    /// otherwise no signal for.
    planner_expected_retention: bool,
}

/// How a background removal behaves when the rename-into-trash fast path fails.
#[derive(Clone, Copy)]
pub enum BackgroundFallbackMode {
    /// Spawn the legacy detached `git worktree remove` — the default for
    /// `wt remove`, `wt merge`, and the picker.
    Detached,
    /// Run the fallback removal and branch deletion synchronously for a
    /// non-current worktree. `wt step prune` uses this so each iteration's
    /// removal completes before the next candidate is considered and before
    /// the final summary is printed.
    SynchronousForNonCurrent,
}

enum BackgroundRemovalPlan {
    Detached(String),
    CompletedSynchronously,
}

/// Spawn background worktree removal: stop fsmonitor, rename-then-prune, spawn detached rm.
///
/// Shared sequence for both detached HEAD and branch background removal paths.
/// The caller is responsible for output messages before this call, and hooks after.
fn spawn_background_removal(
    repo: &Repository,
    main_path: &Path,
    removal: &BackgroundRemoval<'_>,
    log_label: &str,
    fallback_mode: BackgroundFallbackMode,
) -> anyhow::Result<()> {
    // Stop the fsmonitor daemon BEFORE rename (must happen while the path
    // still exists — the IPC socket lives under its git dir). Force-kills a
    // wedged daemon so it can't leak once the worktree is gone.
    stop_fsmonitor_daemon(&repo.worktree_at(removal.worktree_path));

    let remove_plan = execute_instant_removal_or_fallback(repo, removal, fallback_mode)?;

    if let BackgroundRemovalPlan::Detached(remove_command) = remove_plan {
        spawn_detached(
            repo,
            main_path,
            &remove_command,
            log_label,
            &HookLog::internal(InternalOp::Remove),
            None,
        )?;
    }
    Ok(())
}

/// Execute instant worktree removal via rename-then-prune.
///
/// This function has side effects: it renames the worktree directory and prunes git metadata.
/// On the fast path, the branch is also deleted synchronously (since after prune, the branch
/// is no longer checked out in any worktree), and the background command is just `rm -rf`.
/// If rename fails (cross-filesystem, permissions, Windows file locking), either returns the
/// legacy `git worktree remove` command with branch deletion deferred to the background, or
/// runs that fallback synchronously for non-current worktrees when the caller needs to keep
/// the fallback's `.git/config` rewrite serialized with other git readers.
///
/// The caller is responsible for spawning detached plans in the background.
fn execute_instant_removal_or_fallback(
    repo: &Repository,
    removal: &BackgroundRemoval<'_>,
    fallback_mode: BackgroundFallbackMode,
) -> anyhow::Result<BackgroundRemovalPlan> {
    let BackgroundRemoval {
        worktree_path,
        branch_name,
        deletion_mode,
        target_branch,
        force_worktree,
        changed_directory,
        planner_expected_retention,
    } = *removal;

    if !force_worktree {
        repo.worktree_at(worktree_path)
            .ensure_clean("remove worktree", branch_name, true)?;
    }

    // Fast path: rename worktree into .git/wt/trash/ (instant on same filesystem),
    // prune git metadata, then background process just does `rm -rf`.
    if let Some(staged_path) = stage_worktree_removal(repo, worktree_path) {
        // Delete branch synchronously now that prune has removed the worktree metadata.
        // Re-capture refs here rather than trusting the pre-hook planning
        // decision: hooks or concurrent processes may have advanced the branch.
        if let Some(branch) = branch_name
            && !deletion_mode.should_keep()
        {
            let result = repo.capture_refs().and_then(|snapshot| {
                delete_branch_if_safe(
                    repo,
                    &snapshot,
                    branch,
                    target_branch.unwrap_or("HEAD"),
                    deletion_mode.is_force(),
                )
            });
            warn_if_branch_retained(branch, &result, planner_expected_retention);
        }
        if changed_directory {
            // Create an empty placeholder at the original path so the shell's working
            // directory ($env.PWD) remains valid until the wrapper has cd'd away.
            // Without this, shells that validate PWD (notably Nushell) emit errors
            // between binary exit and the cd directive executing.
            // Best-effort: if create_dir fails (permissions, race), the only effect
            // is that Nushell may still emit PWD errors — not a correctness issue.
            let _ = std::fs::create_dir(worktree_path);
        }
        Ok(BackgroundRemovalPlan::Detached(
            build_remove_command_staged(&staged_path, worktree_path, changed_directory),
        ))
    } else {
        if matches!(
            fallback_mode,
            BackgroundFallbackMode::SynchronousForNonCurrent
        ) && !changed_directory
        {
            repo.remove_worktree(worktree_path, force_worktree)?;
            if let Some(branch) = branch_name
                && !deletion_mode.should_keep()
            {
                delete_branch_in_synchronous_fallback(
                    repo,
                    branch,
                    target_branch,
                    deletion_mode,
                    planner_expected_retention,
                );
            }
            return Ok(BackgroundRemovalPlan::CompletedSynchronously);
        }

        // Fallback: cross-filesystem, permissions, Windows file locking, etc.
        // Use legacy git worktree remove which handles these cases. For
        // safe-delete, decide here whether to append a branch-deletion step:
        // run worktrunk's full integration check now, and if the branch is
        // integrated, append an atomic `git update-ref -d <ref> <sha>` keyed
        // to the snapshotted SHA. This matches the fast path and the
        // synchronous fallback — same `BranchDeletionMode::SafeDelete` input
        // yields the same semantics (squash-merged, ancestor, patch-id-match
        // all accepted), and the CAS protects against tip movement between
        // the foreground check and the detached delete. Force-delete keeps
        // the unconditional `git branch -D` shell tail.
        let command = match (branch_name, deletion_mode) {
            (Some(branch), BranchDeletionMode::ForceDelete) => build_remove_command(
                worktree_path,
                Some(branch),
                force_worktree,
                changed_directory,
            ),
            (Some(branch), BranchDeletionMode::SafeDelete) => {
                let cas_tail = build_cas_branch_delete_tail(repo, branch, target_branch);
                build_remove_command_with_tail(
                    worktree_path,
                    force_worktree,
                    changed_directory,
                    cas_tail.as_deref(),
                )
            }
            _ => build_remove_command(worktree_path, None, force_worktree, changed_directory),
        };
        Ok(BackgroundRemovalPlan::Detached(command))
    }
}

/// Delete the just-removed worktree's branch in the synchronous fallback path.
///
/// Only `wt step prune` reaches this (via `SynchronousForNonCurrent`). Mirrors
/// the fast path above by re-capturing refs and calling `delete_branch_if_safe`,
/// so squash-merged / patch-id-matched branches that prune accepted as candidates
/// are deleted here too — a plain `git branch -d` would refuse them on
/// reachability from HEAD alone and leave the branch behind while the worktree
/// was already removed. The caller filters out `None` branches and `Keep` modes.
fn delete_branch_in_synchronous_fallback(
    repo: &Repository,
    branch: &str,
    target_branch: Option<&str>,
    deletion_mode: BranchDeletionMode,
    planner_expected_retention: bool,
) {
    let result = repo.capture_refs().and_then(|snapshot| {
        delete_branch_if_safe(
            repo,
            &snapshot,
            branch,
            target_branch.unwrap_or("HEAD"),
            deletion_mode.is_force(),
        )
    });
    warn_if_branch_retained(branch, &result, planner_expected_retention);
}

/// Surface the residual branch when `delete_branch_if_safe` returned an
/// unexpected outcome the user wouldn't otherwise see.
///
/// The worktree has already been removed by the time this runs, so silently
/// dropping the branch-deletion outcome (the prior behavior) left the user
/// with no signal that the branch survived. Both the fast path and the
/// synchronous fallback route through here so the message is consistent.
///
/// - `Ok(RetainedRaced)`: atomic CAS rejected the delete because the ref
///   tip moved between integration check and delete (a hook, a concurrent
///   push). Always surface — the unmerged commits would otherwise vanish
///   silently from the user's view.
/// - `Ok(NotDeleted)`: integration check declined the branch. Warn only
///   when the planner predicted deletion (a `pre-remove` hook commit, or
///   similar race) — otherwise `print_hints` has explained the case and a
///   second message would duplicate.
/// - `Ok(ForceDeleted)` / `Ok(Integrated(_))`: succeeded; no message.
/// - `Err`: `tracing::warn!` for developer diagnostics; the failure modes
///   here (`git update-ref` exec error, refs DB I/O failure) are not
///   user-actionable beyond re-running the command.
fn warn_if_branch_retained(
    branch: &str,
    result: &anyhow::Result<BranchDeletionResult>,
    planner_expected_retention: bool,
) {
    match result {
        Ok(r) if matches!(r.outcome, BranchDeletionOutcome::RetainedRaced) => {
            // The branch tip moved between the integration check and the
            // atomic delete (a hook commit, a concurrent push). The
            // compare-and-swap refused — fail-closed — so the unmerged
            // commits are preserved. Always surface, regardless of planner
            // prediction.
            eprintln!("{}", retained_raced_branch_message(branch, true));
        }
        Ok(r)
            if matches!(r.outcome, BranchDeletionOutcome::NotDeleted)
                && !planner_expected_retention =>
        {
            let cmd = suggest_command("remove", &[branch], &["-D"]);
            eprintln!(
                "{}",
                warning_message(cformat!(
                    "Removed worktree but kept branch <bold>{branch}</> (not integrated); to delete, run <bold>{cmd}</>"
                ))
            );
        }
        Err(e) => {
            tracing::warn!(branch = %branch, error = %e, "Failed to delete branch {branch} after removing worktree: {e}");
        }
        Ok(_) => {}
    }
}

/// Compute the safe-delete branch-deletion shell tail for the Detached
/// fallback path.
///
/// Captures a fresh snapshot, runs the same `integration_reason` check the
/// fast path uses, and — if the branch is integrated — returns an atomic
/// `git update-ref -d refs/heads/<branch> <expected-sha>` that will be
/// appended to the detached `git worktree remove` command. The CAS keeps the
/// deletion safe against tip movement between this foreground check and the
/// detached process executing it.
///
/// Returns `None` when the branch is not integrated, when the snapshot
/// doesn't carry the branch SHA, or when the snapshot/integration call
/// errors — all "don't delete" outcomes that preserve the pre-CAS detached
/// behavior of skipping branch deletion.
fn build_cas_branch_delete_tail(
    repo: &Repository,
    branch: &str,
    target_branch: Option<&str>,
) -> Option<String> {
    use shell_escape::unix::escape;

    let target = target_branch.unwrap_or("HEAD");
    let snapshot = repo.capture_refs().ok()?;
    let (_effective_target, reason) = repo.integration_reason(&snapshot, branch, target).ok()?;
    reason?;
    let expected_sha = snapshot.local_branch(branch)?.commit_sha.clone();

    let ref_name = format!("refs/heads/{branch}");
    let ref_escaped = escape(ref_name.as_str().into());
    let sha_escaped = escape(expected_sha.as_str().into());
    Some(format!("git update-ref -d {ref_escaped} {sha_escaped}"))
}

/// Build the detached worktree-removal command, optionally appending a
/// branch-deletion tail (force-delete or CAS-delete) with `&&` so the branch
/// step runs only when the worktree removal succeeded.
fn build_remove_command_with_tail(
    worktree_path: &Path,
    force_worktree: bool,
    changed_directory: bool,
    tail: Option<&str>,
) -> String {
    let remove_command =
        build_remove_command(worktree_path, None, force_worktree, changed_directory);
    match tail {
        Some(tail) => format!("{remove_command} && {tail}"),
        None => remove_command,
    }
}

/// List top-level entries remaining in a directory after a failed removal.
///
/// Returns None if the directory doesn't exist, can't be read, or is empty.
/// Entries are sorted, with directories suffixed with `/`.
fn list_remaining_entries(path: &Path) -> Option<Vec<String>> {
    let mut entries: Vec<String> = std::fs::read_dir(path)
        .ok()?
        .filter_map(|e| {
            let e = e.ok()?;
            let name = e.file_name().to_string_lossy().into_owned();
            if e.file_type().ok()?.is_dir() {
                Some(format!("{name}/"))
            } else {
                Some(name)
            }
        })
        .collect();
    entries.sort();
    (!entries.is_empty()).then_some(entries)
}

// ============================================================================
// Switch Output Handlers
// ============================================================================

/// Format a switch message based on what was created
///
/// # Message formats
/// - Branch + worktree created (`--create`): "Created branch X from Y and worktree @ path"
/// - Branch from remote + worktree (DWIM): "Created branch X (tracking remote) and worktree @ path"
/// - Worktree only created: "Created worktree for X @ path"
/// - Switched to existing: "Switched to worktree for X @ path"
fn format_switch_message(
    branch: &str,
    path: &Path,
    worktree_created: bool,
    created_branch: bool,
    base_branch: Option<&str>,
    from_remote: Option<&str>,
) -> String {
    let path_display = format_path_for_display(path);

    if created_branch {
        // --create flag: created branch and worktree
        match base_branch {
            Some(base) => cformat!(
                "Created branch <bold>{branch}</> from <bold>{base}</> and worktree @ <bold>{path_display}</>"
            ),
            None => {
                cformat!("Created branch <bold>{branch}</> and worktree @ <bold>{path_display}</>")
            }
        }
    } else if let Some(remote) = from_remote {
        // DWIM from remote: created local tracking branch and worktree
        cformat!(
            "Created branch <bold>{branch}</> (tracking <bold>{remote}</>) and worktree @ <bold>{path_display}</>"
        )
    } else if worktree_created {
        // Local branch existed, created worktree only
        cformat!("Created worktree for <bold>{branch}</> @ <bold>{path_display}</>")
    } else {
        // Switched to existing worktree
        cformat!("Switched to worktree for <bold>{branch}</> @ <bold>{path_display}</>")
    }
}

struct SwitchOutputContext {
    path: PathBuf,
    path_display: String,
    branch: String,
    shell_warning_reason: Option<String>,
    user_wont_be_in_worktree: bool,
    is_git_subcommand: bool,
}

fn build_switch_output_context(
    result: &SwitchResult,
    branch_info: &SwitchBranchInfo,
    change_dir: bool,
) -> SwitchOutputContext {
    let path = super::to_logical_path(result.path());
    let path_display = format_path_for_display(&path);
    let branch = branch_info
        .branch
        .clone()
        .unwrap_or_else(|| "detached worktree".to_string());

    let is_git_subcommand = crate::is_git_subcommand();
    let is_shell_integration_active = super::is_shell_integration_active();
    let shell_warning_reason = if !change_dir || is_shell_integration_active {
        None
    } else if is_git_subcommand {
        Some("ran git wt; running through git prevents cd".to_string())
    } else {
        Some(compute_shell_warning_reason())
    };
    let user_wont_be_in_worktree = !change_dir || shell_warning_reason.is_some();

    SwitchOutputContext {
        path,
        path_display,
        branch,
        shell_warning_reason,
        user_wont_be_in_worktree,
        is_git_subcommand,
    }
}

fn print_switch_directory_hint(branch: &str, is_git_subcommand: bool) {
    if is_git_subcommand {
        eprintln!("{}", hint_message(git_subcommand_warning()));
    } else if should_show_explicit_path_hint() {
        eprintln!("{}", hint_message(explicit_path_hint(branch)));
    }
}

fn handle_switch_already_at_output(ctx: &SwitchOutputContext) -> Option<PathBuf> {
    eprintln!(
        "{}",
        info_message(cformat!(
            "Already on worktree for <bold>{}</> @ <bold>{}</>",
            ctx.branch,
            ctx.path_display
        ))
    );
    None
}

fn handle_switch_existing_output(ctx: &SwitchOutputContext) -> Option<PathBuf> {
    if let Some(reason) = &ctx.shell_warning_reason {
        eprintln!(
            "{}",
            warning_message(cformat!(
                "Worktree for <bold>{}</> @ <bold>{}</>, but cannot change directory — {reason}",
                ctx.branch,
                ctx.path_display
            ))
        );
        print_switch_directory_hint(&ctx.branch, ctx.is_git_subcommand);
    } else {
        eprintln!(
            "{}",
            info_message(format_switch_message(
                &ctx.branch,
                &ctx.path,
                false, // worktree_created
                false, // created_branch
                None,
                None,
            ))
        );
    }

    ctx.user_wont_be_in_worktree.then(|| ctx.path.clone())
}

fn maybe_print_worktree_path_hint(created_branch: bool) {
    if !created_branch {
        return;
    }

    if let Ok(repo) = worktrunk::git::Repository::current() {
        let has_custom_config = UserConfig::load()
            .map(|c| {
                c.has_custom_worktree_path()
                    || repo
                        .project_identifier()
                        .ok()
                        .is_some_and(|p| c.has_project_worktree_path(&p))
            })
            .unwrap_or(false);
        if !has_custom_config && !repo.has_shown_hint("worktree-path") {
            let hint = hint_message(cformat!(
                "To customize worktree locations, run <underline>wt config create</>"
            ));
            eprintln!("{}", hint);
            let _ = repo.mark_hint_shown("worktree-path");
        }
    }
}

fn handle_switch_created_output(
    ctx: &SwitchOutputContext,
    created_branch: bool,
    base_branch: Option<&str>,
    from_remote: Option<&str>,
) -> Option<PathBuf> {
    eprintln!(
        "{}",
        success_message(format_switch_message(
            &ctx.branch,
            &ctx.path,
            true, // worktree_created
            created_branch,
            base_branch,
            from_remote,
        ))
    );

    maybe_print_worktree_path_hint(created_branch);

    if let Some(reason) = &ctx.shell_warning_reason {
        eprintln!(
            "{}",
            warning_message(cformat!("Cannot change directory — {reason}"))
        );
        print_switch_directory_hint(&ctx.branch, ctx.is_git_subcommand);
    }

    ctx.user_wont_be_in_worktree.then(|| ctx.path.clone())
}

struct BranchDeletionDisplay {
    result: BranchDeletionResult,
    show_unmerged_hint: bool,
}

/// The canonical "branch retained because unmerged" info + hint lines, as an
/// `(info, hint)` pair. [`print_retained_unmerged_branch`] prints them; the
/// picker stashes them via `stash_retained_unmerged_branch` (it can't print
/// mid-render) from its branch-only keep path — a `/ branch` row whose unmerged
/// branch `SafeDelete` declines to delete stays put, so this explains the no-op.
/// (A worktree removal that keeps its branch transforms the row to `/ branch`
/// live instead, with no stashed message.) Shared so the emit paths can't drift
/// in wording, flag, or styling.
pub(crate) fn retained_unmerged_branch_messages(branch_name: &str) -> (String, String) {
    let info = info_message(cformat!(
        "Branch <bold>{branch_name}</> retained; has unmerged changes"
    ))
    .to_string();
    let cmd = suggest_command("remove", &[branch_name], &["-D"]);
    let hint = hint_message(cformat!(
        "To delete the unmerged branch, run <underline>{cmd}</>"
    ))
    .to_string();
    (info, hint)
}

fn print_retained_unmerged_branch(branch_name: &str) {
    let (info, hint) = retained_unmerged_branch_messages(branch_name);
    eprintln!("{info}");
    eprintln!("{hint}");
}

/// The canonical "branch retained because the atomic CAS delete was refused"
/// warning. The ref moved between the integration check and the delete (a hook
/// commit, a concurrent push), so the integrated branch is kept fail-closed
/// rather than dropping the new commits. Shared across the worktree-removal and
/// branch-only emit paths so the wording, flag, and styling can't drift.
///
/// `removed_worktree` selects the lead-in: the worktree has already been removed
/// on the worktree-removal paths, but the branch-only path never had one.
fn retained_raced_branch_message(branch_name: &str, removed_worktree: bool) -> String {
    let lead_in = if removed_worktree {
        "Removed worktree but kept branch"
    } else {
        "Kept branch"
    };
    let cmd = suggest_command("remove", &[branch_name], &["-D"]);
    warning_message(cformat!(
        "{lead_in} <bold>{branch_name}</> (moved during deletion); inspect commits, then run <bold>{cmd}</> if safe"
    ))
    .to_string()
}

/// Handle the result of a branch deletion attempt.
///
/// Converts a deletion attempt into structured display data:
/// - `NotDeleted`: We checked and chose not to delete (not integrated) — sets
///   `show_unmerged_hint`.
/// - `RetainedRaced`: integration check passed but the atomic CAS delete was
///   refused because the ref moved (a hook or concurrent process advanced it).
///   Callers surface this with [`retained_raced_branch_message`], not the
///   unmerged hint, so it is *not* folded into `show_unmerged_hint`.
/// - `Err(e)`: Git command failed - show warning with actual error
fn handle_branch_deletion_result(
    result: anyhow::Result<BranchDeletionResult>,
    branch_name: &str,
) -> anyhow::Result<BranchDeletionDisplay> {
    match result {
        Ok(result) => Ok(BranchDeletionDisplay {
            show_unmerged_hint: matches!(result.outcome, BranchDeletionOutcome::NotDeleted),
            result,
        }),
        Err(e) => {
            // Git command failed - this is an error (we decided to delete but couldn't)
            eprintln!(
                "{}",
                error_message(cformat!("Failed to delete branch <bold>{branch_name}</>"))
            );
            eprintln!("{}", format_with_gutter(&e.display_message(), None));
            Err(e)
        }
    }
}

struct FlagNote {
    text: String,
    symbol: Option<String>,
    suffix: String,
}

impl FlagNote {
    fn empty() -> Self {
        Self {
            text: String::new(),
            symbol: None,
            suffix: String::new(),
        }
    }

    fn text_only(text: impl Into<String>) -> Self {
        Self {
            text: text.into(),
            symbol: None,
            suffix: String::new(),
        }
    }

    fn with_symbol(
        text: impl Into<String>,
        symbol: impl Into<String>,
        suffix: impl Into<String>,
    ) -> Self {
        Self {
            text: text.into(),
            symbol: Some(symbol.into()),
            suffix: suffix.into(),
        }
    }

    fn after(&self, color: AnsiColor) -> String {
        match &self.symbol {
            Some(s) => match color {
                AnsiColor::Cyan => cformat!("{}<cyan>{}</>", s, self.suffix),
                AnsiColor::Green => cformat!("{}<green>{}</>", s, self.suffix),
                _ => format!("{s}{}", self.suffix),
            },
            None => String::new(),
        }
    }
}

/// Get flag acknowledgment note for remove messages
///
/// `target_branch`: The branch we checked integration against (shown in reason)
fn flag_note(
    deletion_mode: BranchDeletionMode,
    outcome: &BranchDeletionOutcome,
    target_branch: Option<&str>,
) -> FlagNote {
    if deletion_mode.should_keep() {
        return FlagNote::text_only(" (--no-delete-branch)");
    }

    match outcome {
        BranchDeletionOutcome::NotDeleted | BranchDeletionOutcome::RetainedRaced => {
            FlagNote::empty()
        }
        BranchDeletionOutcome::ForceDeleted => FlagNote::text_only(" (--force-delete)"),
        BranchDeletionOutcome::Integrated(reason) => {
            let Some(target) = target_branch else {
                return FlagNote::empty();
            };
            let symbol = reason.symbol();
            let desc = reason.description();
            FlagNote::with_symbol(
                cformat!(" ({desc} <bold>{target}</>,"),
                cformat!(" <dim>{symbol}</>"),
                ")",
            )
        }
    }
}

/// Show switch message when changing directory after worktree removal.
///
/// When shell integration is not active, warns that cd cannot happen.
/// This is important for remove/merge since the user would be left in a deleted directory.
///
/// # Warning Message Format
///
/// Uses the standard "Cannot change directory — {reason}" pattern.
/// See [`compute_shell_warning_reason`] for the full list of reasons.
fn print_switch_message_if_changed(
    changed_directory: bool,
    main_path: &Path,
) -> anyhow::Result<()> {
    if !changed_directory {
        return Ok(());
    }

    // Use main_path for discovery - the worktree we came from may have been removed
    let Ok(repo) = Repository::at(main_path) else {
        return Ok(());
    };
    let Ok(Some(dest_branch)) = repo.worktree_at(main_path).branch() else {
        return Ok(());
    };

    let logical_path = super::to_logical_path(main_path);
    let path_display = format_path_for_display(&logical_path);

    if super::is_shell_integration_active() {
        // Shell integration active - cd will work
        eprintln!(
            "{}",
            info_message(cformat!(
                "Switched to worktree for <bold>{dest_branch}</> @ <bold>{path_display}</>"
            ))
        );
    } else if crate::is_git_subcommand() {
        // Running as `git wt` - explain why cd can't work
        eprintln!(
            "{}",
            warning_message(
                "Cannot change directory — ran git wt; running through git prevents cd",
            )
        );
        eprintln!("{}", hint_message(git_subcommand_warning()));
    } else {
        // Shell integration not active - compute specific reason
        let reason = compute_shell_warning_reason();
        eprintln!(
            "{}",
            warning_message(cformat!("Cannot change directory — {reason}"))
        );
        // Show appropriate hint based on invocation mode
        if should_show_explicit_path_hint() {
            eprintln!("{}", hint_message(explicit_path_hint(&dest_branch)));
        } else {
            print_shell_integration_hint(&repo);
        }
    }
    Ok(())
}

/// Compute the target directory for `cd` after switching, preserving the user's
/// subdirectory position when possible.
///
/// If the user is in `source_root/apps/gateway/` and `target_root/apps/gateway/`
/// exists, returns `target_root/apps/gateway/`. Otherwise returns `target_root`.
fn resolve_subdir_in_target(target_root: &Path, source_root: Option<&Path>, cwd: &Path) -> PathBuf {
    if let Some(source_root) = source_root {
        // Canonicalize both paths to handle symlinks (e.g., /var -> /private/var on macOS)
        let cwd = dunce::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
        let source_root =
            dunce::canonicalize(source_root).unwrap_or_else(|_| source_root.to_path_buf());
        if let Ok(relative) = cwd.strip_prefix(&source_root)
            && !relative.as_os_str().is_empty()
        {
            let candidate = target_root.join(relative);
            if candidate.is_dir() {
                return candidate;
            }
        }
    }
    target_root.to_path_buf()
}

/// Handle output for a switch operation
///
/// # Shell Integration Warnings
///
/// Always warn when the shell's directory won't change. Users expect to be in
/// the target worktree after switching.
///
/// **When to warn:** Shell integration is not active (no directive files set).
/// This applies to both `Existing` and `Created` results.
///
/// **When NOT to warn:**
/// - `AlreadyAt` — user is already in the target directory
/// - Shell integration IS active — cd will happen automatically
///
/// **Warning format:** `Cannot change directory — {reason}`
///
/// See [`compute_shell_warning_reason`] for the full list of reasons.
///
/// **Message order for Created:** Success message first, then warning. Creation
/// is a real accomplishment, but users still need to know they won't cd there.
///
/// # Arguments
///
/// * `change_dir` — When false, skip the directory change (user requested `--no-cd`)
///
/// # Return Value
///
/// Returns `Some(path)` when post-switch hooks should show "@ path" in their
/// announcements (because the user's shell won't be in that directory). This happens when:
/// - Shell integration is not active (user's shell stays in original directory)
/// - `change_dir` is false (user explicitly requested no directory change)
///
/// Returns `None` when the user will be in the worktree directory (shell integration
/// active or already at the worktree), so no path annotation needed.
pub fn handle_switch_output(
    result: &SwitchResult,
    branch_info: &SwitchBranchInfo,
    change_dir: bool,
    source_worktree_root: Option<&Path>,
    cwd: &Path,
) -> anyhow::Result<Option<std::path::PathBuf>> {
    // Set target directory for command execution, preserving subdirectory position.
    // If the user is in apps/gateway/ in the source worktree and that directory exists
    // in the target, cd to apps/gateway/ in the target instead of the root.
    if change_dir {
        let cd_target = resolve_subdir_in_target(result.path(), source_worktree_root, cwd);
        super::change_directory(&cd_target)?;
    }

    // Translate to the user's logical (symlink-preserved) path for display messages.
    // The cd directive (above) handles its own translation internally.
    let ctx = build_switch_output_context(result, branch_info, change_dir);

    let display_path_for_hooks = match result {
        SwitchResult::AlreadyAt(_) => handle_switch_already_at_output(&ctx),
        SwitchResult::Existing { .. } => handle_switch_existing_output(&ctx),
        SwitchResult::Created {
            created_branch,
            base_branch,
            from_remote,
            ..
        } => handle_switch_created_output(
            &ctx,
            *created_branch,
            base_branch.as_deref(),
            from_remote.as_deref(),
        ),
    };

    stderr().flush()?;
    Ok(display_path_for_hooks)
}

/// Execute the --execute command after hooks have run.
///
/// `display_path` is shown when the user's shell won't be in the worktree
/// directory (shell integration not active). This helps users understand where
/// the command runs.
///
/// When the conservative EXEC scrub is in effect (nested `wt` inside an alias
/// or hook body), no `Executing` header is printed — `execute()` emits its own
/// warning explaining the skip, and a contradictory header would read as a
/// broken promise. See `output::global::warn_exec_scrubbed_once`.
pub fn execute_user_command(command: &str, display_path: Option<&Path>) -> anyhow::Result<()> {
    if super::exec_would_be_refused() {
        // execute() will emit the conservative-scrub warning and return Ok.
        return super::execute(command);
    }

    // Show what command is being executed (section header + gutter content)
    // Include path when user's shell won't be there (shell integration not active)
    let header = match display_path {
        Some(path) => {
            let path_display = format_path_for_display(path);
            cformat!("Executing (--execute) @ <bold>{path_display}</>:")
        }
        None => "Executing (--execute):".to_string(),
    };
    eprintln!("{}", progress_message(header));
    eprintln!("{}", format_bash_with_gutter(command));

    super::execute(command)?;

    Ok(())
}

/// Handle output for a remove operation
///
/// Approval is handled at the gate (command entry point), not here. The
/// `announcer`'s `show_branch` setting (set by the caller) controls whether
/// hook announce lines include the branch name for batch-context disambiguation.
/// When `quiet` is true (prune context), suppresses informational messages
/// like "No worktree found for branch X" that are noise in batch operations.
///
/// `plan` is the frozen, approved hook set; an empty plan (`--no-hooks`,
/// declined, or no project config) runs no project hooks. `pre-remove` /
/// `post-remove` / `post-switch` execute only from it — the selection was
/// frozen at the gate, never re-read.
///
/// When `silent` is true (the TUI picker — this runs while skim owns the
/// terminal), a `RemovedWorktree` result is removed with no progress/success
/// messages, no trash-cleanup spinner, and no `cd` directive: just the git
/// removal plus the planned `pre-remove` / `post-remove` / `post-switch`
/// hooks. (The picker can't prompt mid-render, so its `plan` comes from a
/// read-only `Approvals` filter — see `do_removal`.) The removal runs inline
/// regardless of `foreground` (there's no detached `rm` to background to).
/// `silent` has no effect on `BranchOnly` results — the picker handles that
/// arm itself.
///
/// `background_fallback_mode` selects how a background removal whose
/// rename-into-trash fast path fails behaves: every caller but `wt step prune`
/// passes [`BackgroundFallbackMode::Detached`] (spawn the legacy `git worktree
/// remove`); prune passes [`BackgroundFallbackMode::SynchronousForNonCurrent`]
/// to keep the fallback's `.git/config` rewrite serialized with its parallel
/// integration-check readers.
pub fn handle_remove_output(
    result: &RemoveResult,
    foreground: bool,
    plan: &ApprovedHookPlan,
    quiet: bool,
    silent: bool,
    announcer: &mut HookAnnouncer<'_>,
    background_fallback_mode: BackgroundFallbackMode,
) -> anyhow::Result<()> {
    match result {
        RemoveResult::RemovedWorktree {
            main_path,
            worktree_path,
            changed_directory,
            branch_name,
            deletion_mode,
            target_branch,
            force_worktree,
            removed_commit,
        } => handle_removed_worktree_output(
            RemovedWorktreeOutputContext {
                main_path,
                worktree_path,
                changed_directory: *changed_directory,
                branch_name: branch_name.as_deref(),
                deletion_mode: *deletion_mode,
                target_branch: target_branch.as_deref(),
                force_worktree: *force_worktree,
                removed_commit: removed_commit.as_deref(),
                plan,
                foreground,
                silent,
                background_fallback_mode,
            },
            announcer,
        ),
        RemoveResult::BranchOnly {
            branch_name,
            deletion_mode,
            pruned,
            target_branch,
            integration_reason,
        } => handle_branch_only_output(
            branch_name,
            *deletion_mode,
            *pruned,
            *integration_reason,
            target_branch.as_deref(),
            quiet,
        ),
    }
}

/// Handle output for BranchOnly removal (branch exists but no worktree)
///
/// When `quiet` is true, suppresses the "No worktree found for branch X"
/// info line for non-pruned cases (noise in prune/batch context).
fn handle_branch_only_output(
    branch_name: &str,
    deletion_mode: BranchDeletionMode,
    pruned: bool,
    integration_reason: Option<IntegrationReason>,
    target_branch: Option<&str>,
    quiet: bool,
) -> anyhow::Result<()> {
    let branch_info = if pruned {
        cformat!("Worktree directory missing for <bold>{branch_name}</>; pruned")
    } else {
        cformat!("No worktree found for branch <bold>{branch_name}</>")
    };

    // If we won't delete the branch, show info and return early
    if deletion_mode.should_keep() {
        eprintln!("{}", info_message(&branch_info));
        stderr().flush()?;
        return Ok(());
    }

    let check_target = target_branch.unwrap_or("HEAD");

    // Force-delete bypasses CAS entirely — `git branch -D` is the user's
    // explicit override. For safe-delete with a pre-computed integration
    // reason, re-run `delete_branch_if_safe`: a fresh snapshot lets the
    // atomic CAS in there catch any tip movement since planning, rather than
    // trusting the planner's stale view of the ref.
    let deletion = if let Some(integrated_reason) = integration_reason
        && !deletion_mode.is_force()
    {
        let repo = worktrunk::git::Repository::current()?;
        let result = repo
            .capture_refs()
            .and_then(|snapshot| {
                delete_branch_if_safe(&repo, &snapshot, branch_name, check_target, false)
            })
            .map(|mut r| {
                // Preserve the planner's recorded reason so a CAS-accepted
                // delete still reports the original "integrated because X"
                // explanation. The fresh integration target stays as the
                // helper computed it.
                if matches!(r.outcome, BranchDeletionOutcome::Integrated(_)) {
                    r.outcome = BranchDeletionOutcome::Integrated(integrated_reason);
                }
                r
            });
        handle_branch_deletion_result(result, branch_name)?
    } else if deletion_mode.is_force() {
        let repo = worktrunk::git::Repository::current()?;
        let result = repo.run_command(&["branch", "-D", "--", branch_name]);
        handle_branch_deletion_result(
            result.map(|_| BranchDeletionResult {
                outcome: BranchDeletionOutcome::ForceDeleted,
                integration_target: check_target.to_string(),
            }),
            branch_name,
        )?
    } else {
        BranchDeletionDisplay {
            result: BranchDeletionResult {
                outcome: BranchDeletionOutcome::NotDeleted,
                integration_target: check_target.to_string(),
            },
            show_unmerged_hint: true,
        }
    };

    if matches!(
        deletion.result.outcome,
        BranchDeletionOutcome::NotDeleted | BranchDeletionOutcome::RetainedRaced
    ) {
        eprintln!("{}", info_message(&branch_info));
        if matches!(
            deletion.result.outcome,
            BranchDeletionOutcome::RetainedRaced
        ) {
            eprintln!("{}", retained_raced_branch_message(branch_name, false));
        } else if deletion.show_unmerged_hint {
            print_retained_unmerged_branch(branch_name);
        }
    } else {
        let flag_note = flag_note(
            deletion_mode,
            &deletion.result.outcome,
            Some(&deletion.result.integration_target),
        );
        let flag_text = &flag_note.text;
        let flag_after = flag_note.after(AnsiColor::Green);

        if pruned {
            // Combined: pruned stale metadata & deleted branch in one line
            eprintln!(
                "{}",
                FormattedMessage::new(cformat!(
                    "<green>✓ Pruned stale worktree & removed branch <bold>{branch_name}</>{flag_text}</>{flag_after}"
                ))
            );
        } else {
            if !quiet {
                eprintln!("{}", info_message(&branch_info));
            }
            eprintln!(
                "{}",
                FormattedMessage::new(cformat!(
                    "<green>✓ Removed branch <bold>{branch_name}</>{flag_text}</>{flag_after}"
                ))
            );
        }
    }

    stderr().flush()?;
    Ok(())
}

/// Register post-remove and post-switch hooks after worktree removal onto the
/// caller's announcer.
///
/// Pipelines come from two contexts: post-remove uses the removed worktree's
/// identity (branch, path, commit), while post-switch (only when
/// `changed_directory` is true) uses the destination worktree's branch. Both
/// types share whatever announce line the caller's announcer eventually
/// flushes — multi-phase callers (e.g. `wt merge`) batch with later phases,
/// standalone callers (e.g. `wt remove`) flush immediately after.
///
/// Only runs if `ctx.verify` is true (hooks approved).
fn spawn_hooks_after_remove(
    repo: &Repository,
    ctx: &RemovedWorktreeOutputContext<'_>,
    removed_branch: &str,
    announcer: &mut HookAnnouncer<'_>,
) -> anyhow::Result<()> {
    let Ok(config) = UserConfig::load() else {
        return Ok(());
    };

    // When removing the current worktree, user cd's to main_path → use post_hook logic
    // (suppresses path if shell integration will cd there).
    // When removing a different worktree, user stays at cwd → use pre_hook logic
    // (shows path if main_path differs from cwd).
    let display_path = if ctx.changed_directory {
        super::post_hook_display_path(ctx.main_path)
    } else {
        super::pre_hook_display_path(ctx.main_path)
    };

    // Build post-remove template variables from the removed worktree identity.
    let remove_vars =
        PostRemoveContext::new(ctx.worktree_path, ctx.removed_commit, ctx.main_path, repo);
    let extra_vars = remove_vars.extra_vars(removed_branch);

    // All hooks use remove_ctx for spawning: log files are named after the removed
    // branch since both post-remove and post-switch are consequences of that removal.
    let remove_ctx = CommandContext::new(repo, &config, Some(removed_branch), ctx.main_path, false);

    // `post-remove` is *about* the removed worktree (gone by now); it was
    // selected and frozen into `plan` at the gate, anchored at the removed
    // worktree path. `remove_ctx` (rooted at `ctx.main_path`) only renders.
    register_planned(
        announcer,
        ctx.plan,
        ctx.worktree_path,
        &remove_ctx,
        worktrunk::HookType::PostRemove,
        &extra_vars,
        display_path,
    )?;

    // Post-switch: only when the user actually changed directory. Anchored at
    // the destination worktree (where the user landed) at the gate.
    if ctx.changed_directory {
        let dest_branch = repo.worktree_at(ctx.main_path).branch()?;
        let switch_ctx =
            CommandContext::new(repo, &config, dest_branch.as_deref(), ctx.main_path, false);
        register_planned(
            announcer,
            ctx.plan,
            ctx.main_path,
            &switch_ctx,
            worktrunk::HookType::PostSwitch,
            &[],
            display_path,
        )?;
    }

    Ok(())
}

// ============================================================================
// Removal Display Info: Shared data for background/foreground output
// ============================================================================

/// Information needed to display removal messages and hints.
///
/// This struct captures the outcome of a branch deletion decision (freshly
/// computed for background mode or actual for foreground mode) so that message
/// formatting can be shared between both modes.
struct RemovalDisplayInfo {
    /// The deletion outcome (NotDeleted, ForceDeleted, or Integrated)
    outcome: BranchDeletionOutcome,
    /// The target branch used for integration check (may be upstream if ahead of local)
    integration_target: Option<String>,
    /// Whether the branch was integrated (used for hints when branch is kept)
    branch_was_integrated: bool,
    /// Whether to show the "unmerged, run -D" hint (foreground only, based on actual deletion)
    show_unmerged_hint: bool,
    /// Whether --force was used (for display purposes)
    force_worktree: bool,
}

impl RemovalDisplayInfo {
    /// Build display info from the refreshed integration check (background mode).
    ///
    /// The caller refreshes this after `pre-remove` hooks and immediately
    /// before staging/removing the worktree.
    fn from_precomputed(
        deletion_mode: BranchDeletionMode,
        pre_computed_integration: Option<IntegrationReason>,
        target_branch: Option<&str>,
        force_worktree: bool,
    ) -> Self {
        let (outcome, integration_target) = if deletion_mode.should_keep() {
            (
                BranchDeletionOutcome::NotDeleted,
                target_branch.map(String::from),
            )
        } else if deletion_mode.is_force() {
            (
                BranchDeletionOutcome::ForceDeleted,
                target_branch.map(String::from),
            )
        } else {
            let outcome = match pre_computed_integration {
                Some(r) => BranchDeletionOutcome::Integrated(r),
                None => BranchDeletionOutcome::NotDeleted,
            };
            (outcome, target_branch.map(String::from))
        };

        Self {
            outcome,
            integration_target,
            branch_was_integrated: pre_computed_integration.is_some(),
            show_unmerged_hint: false, // Background mode never shows this hint
            force_worktree,
        }
    }

    /// Build display info from actual deletion result (foreground mode).
    fn from_branch_result(
        branch_deletion: Option<anyhow::Result<BranchDeletionResult>>,
        branch_name: &str,
        pre_computed_integration: Option<IntegrationReason>,
        target_branch: Option<&str>,
        force_worktree: bool,
    ) -> anyhow::Result<Self> {
        let branch_was_integrated = pre_computed_integration.is_some();

        let (outcome, integration_target, show_unmerged_hint) = match branch_deletion {
            Some(result) => {
                let deletion = handle_branch_deletion_result(result, branch_name)?;
                // Only use integration_target for display if we had a real target (not "HEAD" fallback)
                let display_target =
                    target_branch.map(|_| deletion.result.integration_target.clone());
                (
                    deletion.result.outcome,
                    display_target,
                    deletion.show_unmerged_hint,
                )
            }
            None => (
                BranchDeletionOutcome::NotDeleted,
                target_branch.map(String::from),
                false,
            ),
        };

        Ok(Self {
            outcome,
            integration_target,
            branch_was_integrated,
            show_unmerged_hint,
            force_worktree,
        })
    }

    /// Whether the branch will be/was deleted.
    fn branch_deleted(&self) -> bool {
        matches!(
            self.outcome,
            BranchDeletionOutcome::ForceDeleted | BranchDeletionOutcome::Integrated(_)
        )
    }

    /// Print the removal message (progress for background, success for foreground).
    ///
    /// `stats` carries the trash-cleanup file/byte counts surfaced by the
    /// foreground spinner (see `remove_dir_with_progress`). Pass `None` for
    /// the background path — those don't run a spinner and shouldn't show
    /// stats. The `(N files · X MiB)` suffix is gray, matching the
    /// "stats parentheses" convention in the user-output skill.
    fn print_message(
        &self,
        branch_name: &str,
        foreground: bool,
        stats: Option<(usize, u64)>,
    ) -> anyhow::Result<()> {
        let flag_note = flag_note(
            if self.branch_deleted() {
                BranchDeletionMode::SafeDelete // Doesn't matter, outcome already determined
            } else {
                BranchDeletionMode::Keep
            },
            &self.outcome,
            self.integration_target.as_deref(),
        );
        let force_text = if self.force_worktree {
            " (--force)"
        } else {
            ""
        };
        let stats_paren = stats
            .map(|(f, b)| format_stats_paren(f, b))
            .unwrap_or_default();

        let msg = if foreground {
            if self.branch_deleted() {
                let flag_text = &flag_note.text;
                success_message(cformat!(
                    "Removed <bold>{branch_name}</> worktree{force_text} & branch{flag_text}"
                ))
                .append(&flag_note.after(AnsiColor::Green))
                .append(&stats_paren)
            } else {
                success_message(cformat!(
                    "Removed <bold>{branch_name}</> worktree{force_text}"
                ))
                .append(&stats_paren)
            }
        } else if self.branch_deleted() {
            let flag_text = &flag_note.text;
            progress_message(cformat!(
                "Removing <bold>{branch_name}</> worktree{force_text} & branch in background{flag_text}"
            ))
            .append(&flag_note.after(AnsiColor::Cyan))
        } else {
            progress_message(cformat!(
                "Removing <bold>{branch_name}</> worktree{force_text} in background"
            ))
        };
        eprintln!("{msg}");
        Ok(())
    }

    /// Print hints about branch status (why it was kept, how to force delete).
    fn print_hints(
        &self,
        branch_name: &str,
        deletion_mode: BranchDeletionMode,
        pre_computed_integration: Option<IntegrationReason>,
    ) -> anyhow::Result<()> {
        if self.branch_deleted() {
            return Ok(());
        }

        // A raced retention isn't "unmerged" — the branch was integrated but
        // its tip moved during the delete. Surface the dedicated message
        // (inspect commits, then force-delete if safe) rather than falling
        // through to the generic unmerged hint, whose bare `-D` would drop the
        // racing commits.
        if matches!(self.outcome, BranchDeletionOutcome::RetainedRaced) {
            eprintln!("{}", retained_raced_branch_message(branch_name, true));
            return Ok(());
        }

        if deletion_mode.should_keep() {
            if let Some(reason) = pre_computed_integration.as_ref() {
                // User kept an integrated branch - show integration info
                let target = self.integration_target.as_deref().unwrap_or("target");
                let desc = reason.description();
                let symbol = reason.symbol();
                eprintln!(
                    "{}",
                    hint_message(cformat!(
                        "Branch integrated ({desc} <underline>{target}</>, <dim>{symbol}</>); retained with <underline>--no-delete-branch</>"
                    ))
                );
            }
        } else if self.show_unmerged_hint
            || (!deletion_mode.should_keep() && !self.branch_was_integrated)
        {
            // Unmerged, no flag - show how to force delete
            // (Background: !should_keep && !integrated, Foreground: show_unmerged_hint)
            let cmd = suggest_command("remove", &[branch_name], &["-D"]);
            eprintln!(
                "{}",
                hint_message(cformat!(
                    "Branch unmerged; to delete, run <underline>{cmd}</>"
                ))
            );
        }
        // else: Unmerged + flag - no hint (flag had no effect)

        Ok(())
    }
}

// ============================================================================

struct RemovedWorktreeOutputContext<'a> {
    main_path: &'a Path,
    worktree_path: &'a Path,
    changed_directory: bool,
    branch_name: Option<&'a str>,
    deletion_mode: BranchDeletionMode,
    target_branch: Option<&'a str>,
    force_worktree: bool,
    removed_commit: Option<&'a str>,
    /// The frozen, approved hook plan. `pre-remove` / `post-remove` /
    /// `post-switch` execute only from this — no `.config/wt.toml` re-read,
    /// no `ProjectConfig` snapshot to thread.
    plan: &'a ApprovedHookPlan,
    foreground: bool,
    /// TUI (picker) path: do the git removal and hook registration but emit no
    /// progress/success messages, no trash-cleanup spinner, and no `cd`
    /// directive — see [`handle_remove_output`].
    silent: bool,
    background_fallback_mode: BackgroundFallbackMode,
}

struct RefreshedRemovalSafety {
    integration_reason: Option<IntegrationReason>,
    target_branch: Option<String>,
}

impl RefreshedRemovalSafety {
    fn target_branch(&self) -> Option<&str> {
        self.target_branch.as_deref()
    }
}

fn execute_pre_remove_hooks_if_needed(
    repo: &Repository,
    ctx: &RemovedWorktreeOutputContext<'_>,
) -> anyhow::Result<()> {
    let Ok(config) = UserConfig::load() else {
        return Ok(());
    };

    // `pre-remove` runs in the worktree being removed (still on disk here).
    // `pre_remove_repo` roots the *render* context there for template vars;
    // the command set is the frozen `plan` selected at the gate, so no
    // `.config/wt.toml` is re-read here.
    let pre_remove_repo = Repository::at(ctx.worktree_path)?;
    let command_ctx = CommandContext::new(
        &pre_remove_repo,
        &config,
        ctx.branch_name,
        ctx.worktree_path,
        false, // yes=false for CommandContext (not approval-related)
    );
    let display_path = if ctx.changed_directory {
        None
    } else {
        Some(ctx.worktree_path)
    };
    let target_branch = repo
        .worktree_at(ctx.main_path)
        .branch()
        .ok()
        .flatten()
        .unwrap_or_default();
    let target_path_str = worktrunk::path::to_posix_path(&ctx.main_path.to_string_lossy());
    let extra_vars: Vec<(&str, &str)> = vec![
        ("target", &target_branch),
        ("target_worktree_path", &target_path_str),
    ];

    execute_planned_hook(
        ctx.plan,
        ctx.worktree_path,
        &command_ctx,
        worktrunk::HookType::PreRemove,
        &extra_vars,
        FailureStrategy::FailFast,
        display_path,
    )
}

fn refresh_removal_safety_after_pre_remove(
    repo: &Repository,
    ctx: &RemovedWorktreeOutputContext<'_>,
) -> anyhow::Result<RefreshedRemovalSafety> {
    // `pre-remove` hooks run arbitrary approved commands in the worktree being
    // removed. Re-check before the rename/branch-delete cleanup uses this state.
    if !ctx.force_worktree {
        repo.worktree_at(ctx.worktree_path).ensure_clean(
            "remove worktree",
            ctx.branch_name,
            true,
        )?;
    }

    let mut target_branch = ctx.target_branch.map(String::from);
    let integration_reason = if ctx.branch_name.is_some() && ctx.target_branch.is_some() {
        let snapshot = repo.capture_refs()?;
        let (reason, effective_target) = compute_integration_reason(
            repo,
            &snapshot,
            ctx.branch_name,
            ctx.target_branch,
            ctx.deletion_mode,
        );
        if let Some(target) = effective_target {
            target_branch = Some(target);
        }
        reason
    } else {
        None
    };

    Ok(RefreshedRemovalSafety {
        integration_reason,
        target_branch,
    })
}

fn prepare_remove_directory_change(
    main_path: &Path,
    worktree_path: &Path,
    changed_directory: bool,
) -> anyhow::Result<()> {
    if changed_directory {
        // Preserve the user's subdirectory position, mirroring `wt switch`
        // (#3343). The removal hasn't run yet, so the current directory still
        // exists inside the worktree being removed — if the user is in
        // `worktree/apps/gateway/` and `main/apps/gateway/` exists, cd there
        // instead of the main worktree root. Falls back to the root when the
        // subdir is absent in the destination or the cwd can't be read.
        let cd_target = std::env::current_dir()
            .map(|cwd| resolve_subdir_in_target(main_path, Some(worktree_path), &cwd))
            .unwrap_or_else(|_| main_path.to_path_buf());
        super::change_directory(&cd_target)?;
        stderr().flush()?; // Force flush to ensure shell processes the cd
        // Mark that the CWD worktree is being removed, so the error handler
        // can show a hint if a subsequent command (e.g., post-merge hook) fails.
        super::mark_cwd_removed();
    }

    Ok(())
}

fn handle_detached_removed_worktree_output(
    repo: &Repository,
    ctx: &RemovedWorktreeOutputContext<'_>,
    announcer: &mut HookAnnouncer<'_>,
) -> anyhow::Result<()> {
    if ctx.foreground {
        eprintln!(
            "{}",
            progress_message(cformat!(
                "Removing worktree @ <bold>{}</>... (detached HEAD, no branch to delete)",
                format_path_for_display(ctx.worktree_path)
            ))
        );
        let snapshot = repo.capture_refs()?;
        let output = remove_worktree_with_cleanup(
            repo,
            &snapshot,
            ctx.worktree_path,
            RemoveOptions {
                branch: None,
                deletion_mode: ctx.deletion_mode,
                target_branch: ctx.target_branch.map(String::from),
                force_worktree: ctx.force_worktree,
            },
        )
        .map_err(|err| GitError::WorktreeRemovalFailed {
            branch: path_dir_name(ctx.worktree_path).to_string(),
            path: ctx.worktree_path.to_path_buf(),
            remaining_entries: list_remaining_entries(ctx.worktree_path),
            error: err.display_message(),
        })?;
        let (files, bytes) = output
            .staged_path
            .as_deref()
            .map(cleanup_staged_with_progress)
            .unwrap_or((0, 0));
        let stats_paren = format_stats_paren(files, bytes);
        eprintln!(
            "{}",
            success_message(cformat!(
                "Removed worktree @ <bold>{}</> (detached HEAD, no branch to delete)",
                format_path_for_display(ctx.worktree_path)
            ))
            .append(&stats_paren)
        );
    } else {
        let path_display = format_path_for_display(ctx.worktree_path);
        eprintln!(
            "{}",
            progress_message(cformat!(
                "Removing worktree @ <bold>{path_display}</> in background (detached HEAD, no branch to delete)"
            ))
        );

        spawn_background_removal(
            repo,
            ctx.main_path,
            &BackgroundRemoval {
                worktree_path: ctx.worktree_path,
                branch_name: None,
                deletion_mode: ctx.deletion_mode,
                target_branch: ctx.target_branch,
                force_worktree: ctx.force_worktree,
                changed_directory: ctx.changed_directory,
                // No branch → field is unused, but pick the silent-on-NotDeleted
                // default in case the detached HEAD ever gains a branch name.
                planner_expected_retention: true,
            },
            "detached",
            ctx.background_fallback_mode,
        )?;
    }

    // Post-remove hooks for detached HEAD use "HEAD" as the branch identifier
    spawn_hooks_after_remove(repo, ctx, "HEAD", announcer)?;
    stderr().flush()?;
    Ok(())
}

fn handle_named_removed_worktree_foreground(
    repo: &Repository,
    ctx: &RemovedWorktreeOutputContext<'_>,
    safety: &RefreshedRemovalSafety,
    branch_name: &str,
    announcer: &mut HookAnnouncer<'_>,
) -> anyhow::Result<()> {
    eprintln!(
        "{}",
        progress_message(cformat!("Removing <bold>{branch_name}</> worktree..."))
    );

    let snapshot = repo.capture_refs()?;
    let output = remove_worktree_with_cleanup(
        repo,
        &snapshot,
        ctx.worktree_path,
        RemoveOptions {
            branch: Some(branch_name.to_string()),
            deletion_mode: ctx.deletion_mode,
            target_branch: safety.target_branch.clone(),
            force_worktree: ctx.force_worktree,
        },
    )
    .map_err(|err| GitError::WorktreeRemovalFailed {
        branch: branch_name.into(),
        path: ctx.worktree_path.to_path_buf(),
        remaining_entries: list_remaining_entries(ctx.worktree_path),
        error: err.display_message(),
    })?;
    let stats = output
        .staged_path
        .as_deref()
        .map(cleanup_staged_with_progress)
        .unwrap_or((0, 0));

    let display_info = RemovalDisplayInfo::from_branch_result(
        output.branch_result,
        branch_name,
        safety.integration_reason,
        safety.target_branch(),
        ctx.force_worktree,
    )?;

    display_info.print_message(branch_name, true, Some(stats))?;
    display_info.print_hints(branch_name, ctx.deletion_mode, safety.integration_reason)?;
    print_switch_message_if_changed(ctx.changed_directory, ctx.main_path)?;

    spawn_hooks_after_remove(repo, ctx, branch_name, announcer)?;
    stderr().flush()?;
    Ok(())
}

fn handle_named_removed_worktree_background(
    repo: &Repository,
    ctx: &RemovedWorktreeOutputContext<'_>,
    safety: &RefreshedRemovalSafety,
    branch_name: &str,
    announcer: &mut HookAnnouncer<'_>,
) -> anyhow::Result<()> {
    let display_info = RemovalDisplayInfo::from_precomputed(
        ctx.deletion_mode,
        safety.integration_reason,
        safety.target_branch(),
        ctx.force_worktree,
    );

    display_info.print_message(branch_name, false, None)?;
    display_info.print_hints(branch_name, ctx.deletion_mode, safety.integration_reason)?;
    print_switch_message_if_changed(ctx.changed_directory, ctx.main_path)?;

    // Planner predicted retention when the user asked to keep, or when the
    // integration check before this returned no reason — `print_hints` has
    // already explained either case. A `NotDeleted` outcome surprises only if
    // the planner predicted deletion.
    let planner_expected_retention =
        ctx.deletion_mode.should_keep() || safety.integration_reason.is_none();

    spawn_background_removal(
        repo,
        ctx.main_path,
        &BackgroundRemoval {
            worktree_path: ctx.worktree_path,
            branch_name: Some(branch_name),
            deletion_mode: ctx.deletion_mode,
            target_branch: safety.target_branch(),
            force_worktree: ctx.force_worktree,
            changed_directory: ctx.changed_directory,
            planner_expected_retention,
        },
        branch_name,
        ctx.background_fallback_mode,
    )?;

    spawn_hooks_after_remove(repo, ctx, branch_name, announcer)?;
    stderr().flush()?;
    Ok(())
}

/// Handle output for RemovedWorktree removal
fn handle_removed_worktree_output(
    ctx: RemovedWorktreeOutputContext<'_>,
    announcer: &mut HookAnnouncer<'_>,
) -> anyhow::Result<()> {
    // Use main_path for discovery - the worktree being removed might be cwd,
    // and git operations after removal need a valid working directory.
    let repo = worktrunk::git::Repository::at(ctx.main_path)?;

    execute_pre_remove_hooks_if_needed(&repo, &ctx)?;
    let safety = refresh_removal_safety_after_pre_remove(&repo, &ctx)?;

    // TUI (picker) path: the removal runs in a background thread while skim
    // owns the terminal, so no messages, no spinner, no `cd` directive (the
    // picker manages its own cwd). The git removal runs inline — same as the
    // foreground path, minus the chrome.
    if ctx.silent {
        return remove_removed_worktree_silently(&repo, &ctx, &safety, announcer);
    }

    prepare_remove_directory_change(ctx.main_path, ctx.worktree_path, ctx.changed_directory)?;

    // Handle detached HEAD case (no branch known)
    let Some(branch_name) = ctx.branch_name else {
        return handle_detached_removed_worktree_output(&repo, &ctx, announcer);
    };

    if ctx.foreground {
        handle_named_removed_worktree_foreground(&repo, &ctx, &safety, branch_name, announcer)
    } else {
        handle_named_removed_worktree_background(&repo, &ctx, &safety, branch_name, announcer)
    }
}

/// Remove a `RemovedWorktree` worktree with no terminal output — the TUI
/// (`wt switch` picker) path of [`handle_remove_output`].
///
/// `pre-remove` has already run (when it was approved — `verify`), and the
/// caller skipped the `cd` directive (the picker manages its own process cwd).
/// This does the synchronous git worktree removal and registers `post-remove` /
/// `post-switch` hooks onto `announcer`, but with no progress/success message
/// and no trash-cleanup spinner — `eprintln!` while skim owns the terminal
/// would corrupt the frame. A removal failure propagates as-is (the picker logs
/// it); there's no TTY to render the foreground path's nicer "remaining
/// entries" error against.
fn remove_removed_worktree_silently(
    repo: &Repository,
    ctx: &RemovedWorktreeOutputContext<'_>,
    safety: &RefreshedRemovalSafety,
    announcer: &mut HookAnnouncer<'_>,
) -> anyhow::Result<()> {
    let snapshot = repo.capture_refs()?;
    let options = RemoveOptions {
        branch: ctx.branch_name.map(String::from),
        deletion_mode: ctx.deletion_mode,
        target_branch: safety.target_branch.clone(),
        force_worktree: ctx.force_worktree,
    };
    let output = remove_worktree_with_cleanup(repo, &snapshot, ctx.worktree_path, options)?;
    if let Some(staged) = output.staged_path {
        // Best-effort, same as the fast-path cleanup in the foreground handler
        // but without the TTY spinner (`cleanup_staged_with_progress`).
        let _ = std::fs::remove_dir_all(&staged);
    }

    // Post-remove (and post-switch when the picker cd'd away) hooks — registered
    // onto the caller's announcer, which `flush`es after this returns.
    spawn_hooks_after_remove(repo, ctx, ctx.branch_name.unwrap_or("HEAD"), announcer)
}

/// Run a shell command with streaming output, signal forwarding, and ANSI reset.
///
/// Entry point for foreground hook and alias execution. `wt step for-each`
/// (`for_each.rs`, direct argv exec with no shell) and the background
/// pipeline runner (`run_pipeline.rs`, redirects to log files and runs
/// detached) have their own spawning logic.
///
/// Capabilities: optional stdout→stderr redirect for deterministic ordering,
/// SIGINT/SIGTERM forwarding to child process group, ANSI reset before child
/// runs, `Cmd` tracing/logging, and directive file control.
///
/// ## Directive files
///
/// `directives` controls whether the child can write shell-integration
/// directives back to the parent shell. The CD file is always safe to pass
/// through (raw path, no injection surface); the EXEC file is normally scrubbed
/// because alias/hook bodies must not inject arbitrary shell into the parent
/// session — see [`DirectivePassthrough::inherit_from_env_with_exec`] for the
/// one exception.
///
/// - `DirectivePassthrough::default()` — scrubs all directive env vars from
///   the child. Used by background hooks (outlive the parent shell).
/// - `DirectivePassthrough::inherit_from_env()` — re-adds CD (and the legacy
///   compat var) but scrubs EXEC. Used by project aliases and foreground hooks,
///   which may emit `cd` directives but must not be able to inject shell.
/// - `DirectivePassthrough::inherit_from_env_with_exec()` — also re-adds EXEC.
///   Used only for user-source aliases, where the alias body is already user-
///   authored just like a top-level `wt switch --execute` invocation.
///
/// ## Stdout routing
///
/// `redirect_stdout_to_stderr` controls whether the child's stdout is merged
/// onto wt's stderr (`true`) or passed through unchanged (`false`).
///
/// - Hooks and `for-each` pass `true`: their output is decoration around
///   wt's own stderr messages, and merging keeps "Running …" / progress lines
///   ordered with the child's own writes.
/// - Aliases pass `false`: `wt <alias>` is a user-defined command and its
///   stdout must remain pipeable, so `wt my-alias | jq` and similar
///   compositions work (#2478).
///
/// ## ANSI reset
///
/// Resets ANSI codes on stderr before the child runs. Terminal emulators
/// maintain a single rendering state machine — if stdout writes color codes
/// but stderr's output arrives next, the terminal applies stdout's color
/// state to stderr's text. The reset to stderr prevents this.
///
/// ## Git discovery
///
/// `scrub_git_discovery` removes inherited `GIT_DIR`/`GIT_WORK_TREE` (and the
/// rest of [`INHERITED_GIT_PATH_VARS`]) from the child so its `git` commands
/// discover the repo from `working_dir`. Hooks pass `true` (they operate on the
/// worktree wt targets); aliases pass `false` (they keep wt's inherited context,
/// like a top-level command the user typed). See issue #3373.
///
/// [`INHERITED_GIT_PATH_VARS`]: worktrunk::shell_exec::INHERITED_GIT_PATH_VARS
pub fn execute_shell_command(
    working_dir: &std::path::Path,
    command: &str,
    stdin_content: Option<&str>,
    command_log_label: Option<&str>,
    directives: DirectivePassthrough,
    redirect_stdout_to_stderr: bool,
    scrub_git_discovery: bool,
) -> anyhow::Result<()> {
    // Flush stdout before executing command to ensure all our messages appear
    // before the child process output
    stderr().flush()?;

    // Reset ANSI codes on stderr to prevent color bleeding (see function docs for details)
    // This fixes color bleeding observed when worktrunk prints colored output to stdout
    // followed immediately by child process output to stderr (e.g., pre-commit run output).
    eprint!("{}", anstyle::Reset);
    stderr().flush().ok(); // Ignore flush errors - reset is best-effort, command execution should proceed

    let mut cmd = Cmd::shell(command)
        .current_dir(working_dir)
        .forward_signals();

    // User hooks discover their repo from the cwd wt sets, not an inherited
    // GIT_DIR/GIT_WORK_TREE (issue #3373). Aliases keep the inherited context.
    if scrub_git_discovery {
        cmd = cmd.scrub_git_discovery_env();
    }

    if redirect_stdout_to_stderr {
        cmd = cmd.stdout(Stdio::from(std::io::stderr()));
    }

    if let Some(label) = command_log_label {
        cmd = cmd.external(label);
    }

    if let Some(content) = stdin_content {
        cmd = cmd.stdin_bytes(content);
    } else {
        // Inherit the parent's stdin so interactive children (e.g. TUI
        // pickers) keep their controlling terminal. `inherit_stdin()` also
        // keeps the child in the parent's process group so `tcsetattr` on
        // `/dev/tty` succeeds — see the method's doc comment for the
        // SIGTTOU rationale.
        cmd = cmd.inherit_stdin();
    }

    if let Some(path) = directives.cd_file {
        cmd = cmd.directive_cd_file(path);
    }
    if let Some(path) = directives.exec_file {
        cmd = cmd.directive_exec_file(path);
    }
    if let Some(path) = directives.legacy_file {
        cmd = cmd.directive_legacy_file(path);
    }

    cmd.stream()?;

    // Flush to ensure all output appears before we continue
    stderr().flush()?;

    Ok(())
}

/// Selector for which directive file env vars to pass through to a child shell.
///
/// `Default` (no fields set) scrubs all directive env vars from the child;
/// [`DirectivePassthrough::inherit_from_env`] reads the current process
/// environment and re-adds CD only; [`DirectivePassthrough::inherit_from_env_with_exec`]
/// re-adds CD and EXEC. The EXEC file is only included by the `_with_exec`
/// variant — every other path scrubs it so alias/hook shell bodies cannot
/// inject arbitrary shell into the parent session.
#[derive(Debug, Default, Clone)]
pub struct DirectivePassthrough {
    pub cd_file: Option<std::path::PathBuf>,
    pub exec_file: Option<std::path::PathBuf>,
    pub legacy_file: Option<std::path::PathBuf>,
}

impl DirectivePassthrough {
    /// Pass CD and legacy directive files through to the child, reading the
    /// current process environment. Used by project aliases and foreground
    /// hooks that may legitimately emit a `cd` directive. The EXEC file is
    /// deliberately omitted — a project-config body could otherwise inject
    /// arbitrary shell into the parent session.
    pub fn inherit_from_env() -> Self {
        use worktrunk::shell_exec::{DIRECTIVE_CD_FILE_ENV_VAR, DIRECTIVE_FILE_ENV_VAR};
        Self {
            cd_file: read_directive_env(DIRECTIVE_CD_FILE_ENV_VAR),
            exec_file: None,
            legacy_file: read_directive_env(DIRECTIVE_FILE_ENV_VAR),
        }
    }

    /// Like [`Self::inherit_from_env`] but also passes the EXEC directive
    /// file through. Used only for user-source aliases: the body lives in the
    /// user's own config, so a nested `wt --execute` is no different from the
    /// user typing the same command at the top level. See issue #2101.
    pub fn inherit_from_env_with_exec() -> Self {
        use worktrunk::shell_exec::DIRECTIVE_EXEC_FILE_ENV_VAR;
        Self {
            exec_file: read_directive_env(DIRECTIVE_EXEC_FILE_ENV_VAR),
            ..Self::inherit_from_env()
        }
    }
}

fn read_directive_env(var: &str) -> Option<std::path::PathBuf> {
    std::env::var_os(var)
        .map(std::path::PathBuf::from)
        .filter(|p| !p.as_os_str().is_empty())
}

#[cfg(test)]
mod tests {
    use super::*;
    use insta::assert_snapshot;

    /// Exercises each arm of [`warn_if_branch_retained`] for coverage and
    /// to pin the suppression rule. Output goes to stderr and isn't captured
    /// here — the assertion is that the call doesn't panic on any arm.
    /// User-visible message shapes are exercised by the integration tests
    /// (e.g. `test_merge_pre_remove_new_commit_keeps_branch`).
    #[test]
    fn warn_if_branch_retained_arms() {
        let ok = |outcome| {
            Ok::<_, anyhow::Error>(BranchDeletionResult {
                outcome,
                integration_target: "main".to_string(),
            })
        };

        // NotDeleted + planner did NOT expect retention → warn (surprise race)
        warn_if_branch_retained("feature", &ok(BranchDeletionOutcome::NotDeleted), false);

        // NotDeleted + planner DID expect retention → silent (print_hints
        // already explained)
        warn_if_branch_retained("feature", &ok(BranchDeletionOutcome::NotDeleted), true);

        // RetainedRaced → always surface, regardless of planner prediction
        warn_if_branch_retained("feature", &ok(BranchDeletionOutcome::RetainedRaced), false);
        warn_if_branch_retained("feature", &ok(BranchDeletionOutcome::RetainedRaced), true);

        // Success arms → silent
        warn_if_branch_retained("feature", &ok(BranchDeletionOutcome::ForceDeleted), false);
        warn_if_branch_retained(
            "feature",
            &ok(BranchDeletionOutcome::Integrated(
                IntegrationReason::SameCommit,
            )),
            false,
        );

        // Err arm → tracing::warn! only, no stderr message
        warn_if_branch_retained(
            "feature",
            &Err(anyhow::anyhow!("simulated git failure")),
            false,
        );
    }

    /// The shared raced-retention message varies its lead-in with
    /// `removed_worktree` and always suggests the canonical `-D` recovery.
    #[test]
    fn retained_raced_branch_message_lead_in_varies() {
        let removed = retained_raced_branch_message("feature", true);
        assert!(removed.contains("Removed worktree but kept branch"));
        assert!(removed.contains("moved during deletion"));
        assert!(removed.contains("wt remove -D feature"));

        let branch_only = retained_raced_branch_message("feature", false);
        assert!(branch_only.contains("Kept branch"));
        assert!(!branch_only.contains("Removed worktree"));
        assert!(branch_only.contains("wt remove -D feature"));
    }

    #[test]
    fn build_remove_command_with_tail_appends_only_when_present() {
        let path = Path::new("/tmp/wt");
        let bare = build_remove_command_with_tail(path, false, false, None);
        // No tail → the command is exactly the bare worktree removal.
        assert!(!bare.contains("&&"));
        let tailed = build_remove_command_with_tail(
            path,
            false,
            false,
            Some("git update-ref -d refs/heads/x deadbeef"),
        );
        // A tail is chained with `&&` so it runs only after a successful removal.
        assert_eq!(
            tailed,
            format!("{bare} && git update-ref -d refs/heads/x deadbeef")
        );
    }

    #[test]
    fn test_format_switch_message() {
        let path = PathBuf::from("/tmp/test");

        // Switched to existing worktree (no creation)
        let msg = format_switch_message("feature", &path, false, false, None, None);
        assert_snapshot!(msg, @"Switched to worktree for [1mfeature[22m @ [1m/tmp/test[22m");

        // Created branch and worktree with --create
        let msg = format_switch_message("feature", &path, true, true, Some("main"), None);
        assert_snapshot!(msg, @"Created branch [1mfeature[22m from [1mmain[22m and worktree @ [1m/tmp/test[22m");

        // Created worktree from remote (DWIM) - also creates local tracking branch
        let msg =
            format_switch_message("feature", &path, true, false, None, Some("origin/feature"));
        assert_snapshot!(msg, @"Created branch [1mfeature[22m (tracking [1morigin/feature[22m) and worktree @ [1m/tmp/test[22m");

        // Created worktree only (local branch already existed)
        let msg = format_switch_message("feature", &path, true, false, None, None);
        assert!(!msg.contains("branch")); // Should NOT mention branch creation
        assert_snapshot!(msg, @"Created worktree for [1mfeature[22m @ [1m/tmp/test[22m");
    }

    #[test]
    fn test_flag_note() {
        // --no-delete-branch flag (text only, no symbol, no suffix)
        let note = flag_note(
            BranchDeletionMode::Keep,
            &BranchDeletionOutcome::NotDeleted,
            None,
        );
        assert_eq!(note.text, " (--no-delete-branch)");
        assert!(note.symbol.is_none());
        assert!(note.suffix.is_empty());

        // NotDeleted without flag (empty)
        let note = flag_note(
            BranchDeletionMode::SafeDelete,
            &BranchDeletionOutcome::NotDeleted,
            None,
        );
        assert!(note.text.is_empty());
        assert!(note.symbol.is_none());
        assert!(note.suffix.is_empty());

        // Force deleted (text only, no symbol, no suffix)
        let note = flag_note(
            BranchDeletionMode::ForceDelete,
            &BranchDeletionOutcome::ForceDeleted,
            None,
        );
        assert_eq!(note.text, " (--force-delete)");
        assert!(note.symbol.is_none());
        assert!(note.suffix.is_empty());

        // Integration reasons - text includes description and target, symbol is separate, suffix is closing paren
        let cases = [
            (IntegrationReason::SameCommit, "same commit as"),
            (IntegrationReason::Ancestor, "ancestor of"),
            (IntegrationReason::NoAddedChanges, "no added changes on"),
            (IntegrationReason::TreesMatch, "tree matches"),
            (IntegrationReason::MergeAddsNothing, "all changes in"),
        ];
        for (reason, expected_desc) in cases {
            let note = flag_note(
                BranchDeletionMode::SafeDelete,
                &BranchDeletionOutcome::Integrated(reason),
                Some("main"),
            );
            assert!(
                note.text.contains(expected_desc),
                "reason {:?} text should contain '{}'",
                reason,
                expected_desc
            );
            assert!(
                note.text.contains("main"),
                "reason {:?} text should contain target 'main'",
                reason
            );
            assert!(
                note.symbol.is_some(),
                "reason {:?} should have a symbol",
                reason
            );
            let symbol = note.symbol.as_ref().unwrap();
            assert!(
                symbol.contains(reason.symbol()),
                "reason {:?} symbol part should contain the symbol",
                reason
            );
            assert_eq!(
                note.suffix, ")",
                "reason {:?} suffix should be closing paren",
                reason
            );
        }
    }

    #[test]
    fn test_resolve_subdir_in_target_no_source_root() {
        let target = PathBuf::from("/target/worktree");
        let cwd = PathBuf::from("/some/dir");
        assert_eq!(resolve_subdir_in_target(&target, None, &cwd), target);
    }

    #[test]
    fn test_resolve_subdir_in_target_subdir_exists() {
        let dir = tempfile::tempdir().unwrap();
        let source = dir.path().join("source");
        let target = dir.path().join("target");
        std::fs::create_dir_all(source.join("apps/gateway")).unwrap();
        std::fs::create_dir_all(target.join("apps/gateway")).unwrap();

        let cwd = source.join("apps/gateway");
        let result = resolve_subdir_in_target(&target, Some(&source), &cwd);
        assert_eq!(result, target.join("apps/gateway"));
    }

    #[test]
    fn test_resolve_subdir_in_target_subdir_missing() {
        let dir = tempfile::tempdir().unwrap();
        let source = dir.path().join("source");
        let target = dir.path().join("target");
        std::fs::create_dir_all(source.join("apps/gateway")).unwrap();
        std::fs::create_dir_all(&target).unwrap();

        let cwd = source.join("apps/gateway");
        let result = resolve_subdir_in_target(&target, Some(&source), &cwd);
        assert_eq!(result, target); // Falls back to root
    }

    #[test]
    fn test_resolve_subdir_in_target_at_root() {
        let dir = tempfile::tempdir().unwrap();
        let source = dir.path().join("source");
        let target = dir.path().join("target");
        std::fs::create_dir_all(&source).unwrap();
        std::fs::create_dir_all(&target).unwrap();

        let result = resolve_subdir_in_target(&target, Some(&source), &source);
        assert_eq!(result, target);
    }

    #[test]
    fn test_git_subcommand_warning() {
        let warning = git_subcommand_warning();
        assert_snapshot!(warning, @"For automatic cd, invoke directly (with the [4m-[24m): [4mgit-wt[24m");
    }
}
