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;
#[derive(Debug, clap::Args)]
pub struct Merge {
#[arg(long, action = ArgAction::SetTrue)]
dry_run: bool,
#[arg(long, short = 'y', action = ArgAction::SetTrue)]
yes: bool,
#[arg(long, action = ArgAction::SetTrue, conflicts_with = "all")]
auto: bool,
#[arg(long, action = ArgAction::SetTrue)]
all: bool,
}
impl Run for Merge {
fn run(self) -> Result<()> {
if self.all {
merge_all(self.dry_run, self.yes)
} 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(&review);
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)? {
MergeOutcome::Merged => sync(false, PushMode::Config),
MergeOutcome::Scheduled => Ok(()),
}
}
fn merge_all(dry_run: bool, yes: 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()?;
let current = crate::git::current_branch()?;
let root = stack::stack_root(¤t)?;
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)?;
println!(
"would merge {} into {} ({strategy})",
review_label(&review),
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(());
}
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)?;
match merge_and_check(review_provider.as_ref(), &review, &strategy, false)? {
MergeOutcome::Merged => {
sync(false, PushMode::Config)?;
landed += 1;
}
MergeOutcome::Scheduled => break,
}
}
println!(
"merge complete: {landed} of {count} review{} merged",
if count == 1 { "" } else { "s" }
);
Ok(())
}
fn bottom_branch() -> Result<Option<String>> {
let current = crate::git::current_branch()?;
let root = stack::stack_root(¤t)?;
let trunk = stack::trunk_branch(&crate::git::local_branches()?);
Ok(stack::branch_and_descendants(&root)?
.into_iter()
.find(|branch| Some(branch) != trunk.as_ref()))
}
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)
}
fn review_label(review: &ReviewRequest) -> String {
if review.title.is_empty() {
review.id.clone()
} else {
format!("{} ({})", review.title, review.id)
}
}
enum MergeOutcome {
Merged,
Scheduled,
}
fn merge_and_check(
review_provider: &dyn ReviewProvider,
review: &ReviewRequest,
strategy: &str,
auto: bool,
) -> Result<MergeOutcome> {
let label = review_label(review);
let output = match review_provider.merge_review(review, strategy, auto) {
Ok(output) => output,
Err(error) => {
let text = error.to_string().to_lowercase();
if text.contains("status check") || text.contains("not mergeable") {
eprintln!(
"hint: required checks may not be green yet - rerun `git stk merge` \
when they pass, or schedule with `git stk merge --auto`"
);
}
return Err(error);
}
};
if !output.is_empty() {
println!("{output}");
}
match review_provider.review_for_branch(&review.branch)? {
Some(after) if after.state == ReviewState::Merged => {
println!("merged {label}");
Ok(MergeOutcome::Merged)
}
_ => {
println!("merge scheduled for {label}; rerun `git stk sync` once checks pass");
Ok(MergeOutcome::Scheduled)
}
}
}