stax 0.95.0

Fast stacked Git branches and PRs
Documentation
use super::remove::{RemovalMode, retire_worktree};
use super::shared::{
    WorktreeDetails, compute_worktree_details, emit_shell_message, emit_shell_payload,
    run_blocking_hook, spawn_background_hook,
};
use crate::commands::shell_setup;
use crate::config::Config;
use crate::git::GitRepo;
use crate::git::repo::WorktreeInfo;
use anyhow::{Context, Result, anyhow, bail};
use colored::Colorize;
use std::path::Path;

pub fn run(shell_output: bool) -> Result<()> {
    let repo = GitRepo::open()?;
    let config = Config::load()?;
    let worktrees = repo.list_worktrees()?;
    let source = worktrees
        .iter()
        .find(|worktree| worktree.is_current)
        .cloned()
        .context("Could not identify the current worktree")?;
    let main = worktrees
        .iter()
        .find(|worktree| worktree.is_main)
        .cloned()
        .context("Could not identify the main worktree")?;

    if source.is_main {
        bail!("Cannot promote the main worktree.");
    }
    let branch = source
        .branch
        .clone()
        .context("Cannot promote a detached worktree. Check out a branch first.")?;

    ensure_clean_checkout(
        "current linked worktree",
        &compute_worktree_details(&repo, source.clone())?,
    )?;
    ensure_clean_checkout(
        "main worktree",
        &compute_worktree_details(&repo, main.clone())?,
    )?;

    let main_head = repo.head_oid_in(&main.path)?;
    run_blocking_hook(
        config.worktree.hooks.pre_remove.as_deref(),
        &main.path,
        "pre_remove",
    )?;
    repo.switch_detached_in(&source.path, None)?;

    if let Err(error) = repo.switch_branch_in(&main.path, &branch) {
        let rollback = repo.switch_branch_in(&source.path, &branch).err();
        return Err(transaction_error(
            "switch the main worktree",
            error,
            rollback.into_iter(),
        ));
    }

    std::env::set_current_dir(&main.path).with_context(|| {
        format!(
            "Failed to enter the main worktree at '{}'",
            main.path.display()
        )
    })?;
    let retiring_source = WorktreeInfo {
        is_current: false,
        ..source.clone()
    };

    if let Err(error) = retire_worktree(
        &repo,
        &config,
        &retiring_source,
        false,
        RemovalMode::AllowParking,
    ) {
        let main_rollback = restore_checkout(&repo, &main, &main_head).err();
        let source_rollback = repo.switch_branch_in(&source.path, &branch).err();
        return Err(transaction_error(
            "retire the linked worktree",
            error,
            [main_rollback, source_rollback].into_iter().flatten(),
        ));
    }

    spawn_background_hook(
        config.worktree.hooks.post_remove.as_deref(),
        &main.path,
        "post_remove",
    )?;
    finish_success(shell_output, &main.path, &branch);
    Ok(())
}

fn ensure_clean_checkout(label: &str, details: &WorktreeDetails) -> Result<()> {
    let path = details.info.path.display();
    if !details.info.path.exists() || details.info.is_prunable {
        bail!("Cannot promote: the {label} path is unavailable at '{path}'.");
    }
    if details.info.is_locked {
        bail!("Cannot promote: the {label} is locked at '{path}'.");
    }
    if details.dirty {
        bail!(
            "Cannot promote: the {label} has uncommitted changes at '{path}'.\nCommit or stash those changes, then retry `st wt promote`."
        );
    }
    if details.rebase_in_progress {
        bail!("Cannot promote: the {label} has a rebase in progress at '{path}'.");
    }
    if details.merge_in_progress {
        bail!("Cannot promote: the {label} has a merge in progress at '{path}'.");
    }
    if details.has_conflicts {
        bail!("Cannot promote: the {label} has unresolved conflicts at '{path}'.");
    }
    Ok(())
}

fn restore_checkout(repo: &GitRepo, worktree: &WorktreeInfo, original_head: &str) -> Result<()> {
    match worktree.branch.as_deref() {
        Some(branch) => repo.switch_branch_in(&worktree.path, branch),
        None => repo.switch_detached_in(&worktree.path, Some(original_head)),
    }
}

fn transaction_error(
    action: &str,
    error: anyhow::Error,
    rollback_errors: impl Iterator<Item = anyhow::Error>,
) -> anyhow::Error {
    let mut message = format!("Failed to {action}: {error}");
    for rollback_error in rollback_errors {
        message.push_str(&format!("\nRollback also failed: {rollback_error}"));
    }
    anyhow!(message)
}

fn finish_success(shell_output: bool, main_path: &Path, branch: &str) {
    let message = format!("Promoted '{branch}' to the main worktree");
    if shell_output {
        emit_shell_payload(main_path, None);
        emit_shell_message(&message);
        return;
    }

    println!("{}  '{}'", "Promoted".green().bold(), branch.cyan());
    println!("  Main worktree: {}", main_path.display());
    println!();
    println!("{}", "Current shell did not move automatically.".yellow());
    println!("  {}", format!("cd {}", main_path.display()).cyan());
    if !shell_setup::is_installed() {
        println!();
        println!(
            "{}",
            "Tip: add shell integration for automatic cd:".dimmed()
        );
        println!("  {}", "stax setup".cyan());
    }
}