stax 0.50.2

Fast stacked Git branches and PRs
Documentation
use super::shared::{
    find_current_worktree, find_worktree, run_blocking_hook, spawn_background_hook,
};
use crate::config::Config;
use crate::engine::BranchMetadata;
use crate::git::repo::WorktreeInfo;
use crate::git::GitRepo;
use anyhow::{bail, Result};
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, Confirm};

fn effective_remove_force(force: bool, confirmed_dirty_removal: bool) -> bool {
    force || confirmed_dirty_removal
}

pub(crate) fn remove_worktree_with_hooks(
    repo: &GitRepo,
    config: &Config,
    worktree: &WorktreeInfo,
    force: bool,
) -> Result<String> {
    if worktree.is_main {
        bail!("Cannot remove the main worktree.");
    }

    if !worktree.path.exists() {
        bail!(
            "Worktree path '{}' no longer exists. Run `stax worktree prune`.",
            worktree.path.display()
        );
    }

    let main_workdir = repo.main_repo_workdir()?;
    run_blocking_hook(
        config.worktree.hooks.pre_remove.as_deref(),
        &main_workdir,
        "pre_remove",
    )?;

    repo.worktree_remove(&worktree.path, force)?;

    spawn_background_hook(
        config.worktree.hooks.post_remove.as_deref(),
        &main_workdir,
        "post_remove",
    )?;

    Ok(worktree
        .branch
        .clone()
        .unwrap_or_else(|| worktree.name.clone()))
}

pub fn run(name: Option<String>, force: bool, delete_branch: bool) -> Result<()> {
    let repo = GitRepo::open()?;
    let config = Config::load()?;
    let worktree = match name {
        Some(name) => find_worktree(&repo, &name)?
            .ok_or_else(|| anyhow::anyhow!("No worktree named '{}'", name))?,
        None => find_current_worktree(&repo)?,
    };

    if worktree.is_main {
        bail!("Cannot remove the main worktree.");
    }

    if !worktree.path.exists() {
        bail!(
            "Worktree path '{}' no longer exists. Run `stax worktree prune`.",
            worktree.path.display()
        );
    }

    let mut confirmed_dirty_removal = false;
    if !force && repo.is_dirty_at(&worktree.path)? {
        eprintln!(
            "{} Worktree '{}' has uncommitted changes.",
            "Warning:".yellow().bold(),
            worktree.name
        );
        let proceed = Confirm::with_theme(&ColorfulTheme::default())
            .with_prompt("Remove anyway?")
            .default(false)
            .interact()?;
        if !proceed {
            println!("{}", "Aborted.".dimmed());
            return Ok(());
        }

        confirmed_dirty_removal = true;
    }

    let branch = worktree.branch.clone();
    let main_workdir = repo.main_repo_workdir()?;
    let remove_force = effective_remove_force(force, confirmed_dirty_removal);
    let display_name = remove_worktree_with_hooks(&repo, &config, &worktree, remove_force)?;

    if delete_branch {
        let repo = GitRepo::open_from_path(&main_workdir)?;
        if let Some(branch) = branch.as_deref() {
            match repo.delete_branch(branch, force) {
                Ok(()) => {
                    let _ = BranchMetadata::delete(repo.inner(), branch);
                    println!("  Deleted branch '{}'", branch.blue());
                }
                Err(error) => {
                    eprintln!(
                        "{}",
                        format!("  Warning: could not delete branch '{}': {}", branch, error)
                            .yellow()
                    );
                }
            }
        } else {
            eprintln!(
                "{}",
                "  Warning: detached worktrees do not have a branch to delete.".yellow()
            );
        }
    }

    println!(
        "{}  worktree '{}'",
        "Removed".green().bold(),
        display_name.cyan()
    );

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::effective_remove_force;

    #[test]
    fn confirmed_dirty_removal_upgrades_to_force() {
        assert!(effective_remove_force(false, true));
    }

    #[test]
    fn force_flag_stays_enabled_without_extra_confirmation() {
        assert!(effective_remove_force(true, false));
    }

    #[test]
    fn clean_non_forced_removal_stays_non_forced() {
        assert!(!effective_remove_force(false, false));
    }
}