git-stk 0.7.5

Git-native stacked branch workflow helper
Documentation
use anyhow::{Result, bail};
use clap::ArgAction;

use crate::cli::PushMode;
use crate::commands::Run;
use crate::commands::sync::sync;
use crate::prompt::confirm;
use crate::providers::{ProviderKind, ReviewProvider, ReviewRequest, ReviewState};
use crate::providers::{detect_provider, review_provider};
use crate::settings;
use crate::stack;
use crate::style;

/// Merge the review at the bottom of the stack, then sync.
#[derive(Debug, clap::Args)]
pub struct Merge {
    /// Print what would happen without merging anything.
    #[arg(long, action = ArgAction::SetTrue)]
    dry_run: bool,
    /// Skip the confirmation prompt.
    #[arg(long, short = 'y', action = ArgAction::SetTrue)]
    yes: bool,
    /// Schedule the merge for when required checks pass instead of merging
    /// now.
    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "all")]
    auto: bool,
    /// Repeat merge-and-sync bottom-up until the whole stack has landed.
    #[arg(long, action = ArgAction::SetTrue)]
    all: bool,
    /// With --all: wait for each review's checks before merging it.
    #[arg(long, action = ArgAction::SetTrue, requires = "all", conflicts_with = "no_wait")]
    wait: bool,
    /// With --all: do not wait for checks, overriding stk.mergeWait.
    #[arg(long, action = ArgAction::SetTrue, requires = "all")]
    no_wait: bool,
}

impl Run for Merge {
    fn run(self) -> Result<()> {
        if self.all {
            // Waiting: --wait forces it on, --no-wait off; otherwise
            // stk.mergeWait decides.
            let wait = if self.wait {
                true
            } else if self.no_wait {
                false
            } else {
                settings::bool_setting(settings::MERGE_WAIT_KEY)?
            };
            merge_all(self.dry_run, self.yes, wait)
        } else {
            merge(self.dry_run, self.yes, self.auto)
        }
    }
}

fn merge(dry_run: bool, yes: bool, auto: bool) -> Result<()> {
    let Some(bottom) = bottom_branch()? else {
        bail!("no stacked branches to merge");
    };

    let provider = detect_provider()?;
    let review_provider = review_provider(provider.kind);
    let review = open_review_for(review_provider.as_ref(), provider.kind, &bottom)?;

    let strategy = settings::merge_strategy()?;
    let mode = if auto {
        format!("{strategy}, auto")
    } else {
        strategy.clone()
    };
    let label = review.label();

    if dry_run {
        println!("would merge {label} into {} ({mode})", review.base);
        println!("would sync afterwards");
        return Ok(());
    }

    if !yes
        && !confirm(&format!(
            "merge {label} into {} ({mode})? [y/N] ",
            review.base
        ))?
    {
        println!("merge cancelled");
        return Ok(());
    }

    match merge_and_check(review_provider.as_ref(), &review, &strategy, auto)? {
        // Reconcile everything the merge changed: fetch, clean up, restack,
        // push.
        MergeOutcome::Merged => sync(false, PushMode::Config),
        MergeOutcome::Scheduled => Ok(()),
    }
}

/// Land the whole stack: merge the bottom review and sync, bottom-up, until
/// the stack is complete. One confirmation up front; a merge that only gets
/// scheduled stops the loop, and with `wait` each review's checks settle
/// before its merge.
fn merge_all(dry_run: bool, yes: bool, wait: bool) -> Result<()> {
    let Some(bottom) = bottom_branch()? else {
        bail!("no stacked branches to merge");
    };

    let provider = detect_provider()?;
    let review_provider = review_provider(provider.kind);
    let strategy = settings::merge_strategy()?;

    // What is about to land, bottom-up, for the dry run and the prompt.
    let current = crate::git::current_branch()?;
    let root = stack::stack_root(&current)?;
    let trunk = stack::trunk_branch(&crate::git::local_branches()?);
    let branches: Vec<String> = stack::branch_and_descendants(&root)?
        .into_iter()
        .filter(|branch| Some(branch) != trunk.as_ref())
        .collect();
    let count = branches.len();

    if dry_run {
        for branch in &branches {
            let review = open_review_for(review_provider.as_ref(), provider.kind, branch)?;
            if wait {
                println!("would wait for checks on {}", review.id);
            }
            println!(
                "would merge {} into {} ({strategy})",
                review.label(),
                review.base
            );
        }
        println!("would sync after each merge");
        return Ok(());
    }

    let base = stack::parent_for_branch(&bottom)?.unwrap_or_else(|| "its base".to_owned());
    if !yes
        && !confirm(&format!(
            "merge {count} review{} into {base}, bottom-up ({strategy})? [y/N] ",
            if count == 1 { "" } else { "s" }
        ))?
    {
        println!("merge cancelled");
        return Ok(());
    }

    // Each sync removes the merged bottom, so the loop is bounded by the
    // number of branches it started with.
    let mut landed = 0;
    for _ in 0..count {
        let Some(bottom) = bottom_branch()? else {
            break;
        };
        let review = open_review_for(review_provider.as_ref(), provider.kind, &bottom)?;

        // Each sync force-pushes the next branch and restarts its checks;
        // waiting here is what turns the landing into one command.
        if wait {
            anstream::println!(
                "waiting for checks on {} {}",
                review.id,
                style::dim("(ctrl-c is safe; rerun `git stk merge --all` to resume)")
            );
            if !review_provider.wait_for_checks(&review)? {
                bail!(
                    "checks failed for {}; fix them and rerun `git stk merge --all`",
                    review.id
                );
            }
        }

        match merge_and_check(review_provider.as_ref(), &review, &strategy, false)? {
            MergeOutcome::Merged => {
                sync(false, PushMode::Config)?;
                landed += 1;
            }
            MergeOutcome::Scheduled => break,
        }
    }

    anstream::println!(
        "{}",
        style::success(&format!(
            "merge complete: {landed} of {count} review{} merged",
            if count == 1 { "" } else { "s" }
        ))
    );
    Ok(())
}

/// The bottom of the stack containing the current branch: the first branch
/// that is not the trunk. (A rootless fragment's own root is its bottom.)
fn bottom_branch() -> Result<Option<String>> {
    let current = crate::git::current_branch()?;
    let root = stack::stack_root(&current)?;
    let trunk = stack::trunk_branch(&crate::git::local_branches()?);

    Ok(stack::branch_and_descendants(&root)?
        .into_iter()
        .find(|branch| Some(branch) != trunk.as_ref()))
}

/// The branch's review, validated as mergeable: it exists, is open, and
/// still targets the branch's stack parent.
fn open_review_for(
    review_provider: &dyn ReviewProvider,
    kind: ProviderKind,
    branch: &str,
) -> Result<ReviewRequest> {
    let Some(review) = review_provider.review_for_branch(branch)? else {
        bail!("no {kind} review found for {branch}; submit the stack first");
    };
    if review.state != ReviewState::Open {
        bail!(
            "review {} for {branch} is {}, not open",
            review.id,
            review.state
        );
    }

    let expected_base = stack::parent_for_branch(branch)?;
    if let Some(expected) = &expected_base
        && *expected != review.base
    {
        bail!(
            "review {} targets {}, but {branch}'s stack parent is {expected}; \
             run `git stk submit` first",
            review.id,
            review.base
        );
    }

    Ok(review)
}

enum MergeOutcome {
    Merged,
    Scheduled,
}

/// Merge the review and report what actually happened: gh --auto and glab's
/// default auto-merge schedule the merge instead of performing it, and only
/// a review that reads merged afterwards should start a sync.
fn merge_and_check(
    review_provider: &dyn ReviewProvider,
    review: &ReviewRequest,
    strategy: &str,
    auto: bool,
) -> Result<MergeOutcome> {
    let label = review.label();

    let output = match review_provider.merge_review(review, strategy, auto) {
        Ok(output) => output,
        Err(error) => {
            // gh refuses outright when required checks are not green.
            let text = error.to_string().to_lowercase();
            if text.contains("status check") || text.contains("not mergeable") {
                anstream::eprintln!(
                    "{} required checks may not be green yet - rerun `git stk merge` \
                     when they pass, or schedule with `git stk merge --auto`",
                    style::hint_prefix()
                );
            }
            return Err(error);
        }
    };
    if !output.is_empty() {
        println!("{output}");
    }

    match review_provider.review_for_branch(&review.branch)? {
        Some(after) if after.state == ReviewState::Merged => {
            anstream::println!("{}", style::success(&format!("merged {label}")));
            Ok(MergeOutcome::Merged)
        }
        _ => {
            anstream::println!(
                "{}",
                style::warn(&format!(
                    "merge scheduled for {label}; rerun `git stk sync` once checks pass"
                ))
            );
            Ok(MergeOutcome::Scheduled)
        }
    }
}