use crate::commands::merge_shared::{
BlockedReasonStyle, PrBaseUpdate, WaitResult, calculate_scope, print_header,
print_header_error, print_header_success, print_native_stack_locked_note,
record_ci_history_for_branch, update_pr_base_unless_current, wait_for_pr_ready,
};
use crate::config::Config;
use crate::engine::Stack;
use crate::forge::ForgeClient;
use crate::git::GitRepo;
use crate::github::pr::MergeMethod;
use crate::progress::LiveTimer;
use crate::remote::{ForgeType, RemoteInfo};
use anyhow::{Context, Result};
use colored::Colorize;
use dialoguer::{Confirm, theme::ColorfulTheme};
use std::time::Duration;
#[derive(Debug, Clone)]
struct LandBranchInfo {
branch: String,
pr_number: u64,
}
#[derive(Debug, Clone)]
struct RemainingBranchInfo {
branch: String,
pr_number: Option<u64>,
}
struct MergeRemoteScope {
to_merge: Vec<String>,
remaining: Vec<String>,
trunk: String,
}
#[allow(clippy::too_many_arguments)]
pub fn run(
all: bool,
method: MergeMethod,
timeout_mins: u64,
interval_secs: u64,
no_delete: bool,
no_sync: bool,
yes: bool,
quiet: bool,
) -> Result<()> {
let repo = GitRepo::open()?;
let current = repo.current_branch()?;
let stack = Stack::load(&repo)?;
let config = Config::load()?;
if current == stack.trunk {
if !quiet {
println!(
"{}",
"You are on trunk. Checkout a branch in a stack to merge.".yellow()
);
}
return Ok(());
}
if !stack.branches.contains_key(¤t) {
if !quiet {
println!(
"{}",
format!(
"Branch '{}' is not tracked. Run 'stax branch track' first.",
current
)
.yellow()
);
}
return Ok(());
}
let scope = calculate_merge_scope(&stack, ¤t, all);
let mut branches: Vec<LandBranchInfo> = Vec::new();
let remote_info = RemoteInfo::from_repo(&repo, &config);
let rt = tokio::runtime::Runtime::new()?;
let _enter = rt.enter();
let probe_client = remote_info
.as_ref()
.ok()
.and_then(|info| ForgeClient::new(info).ok());
let fetch_timer = LiveTimer::maybe_new(!quiet, "Fetching PR info...");
for branch_name in &scope.to_merge {
let branch_info = stack.branches.get(branch_name);
let mut pr_number = branch_info.and_then(|b| b.pr_number);
if pr_number.is_none()
&& let Some(ref client) = probe_client
&& let Ok(Some(pr_info)) = rt.block_on(async { client.find_pr(branch_name).await })
{
pr_number = Some(pr_info.number);
}
match pr_number {
Some(num) => branches.push(LandBranchInfo {
branch: branch_name.clone(),
pr_number: num,
}),
None => {
LiveTimer::maybe_finish_err(fetch_timer, "missing PR");
anyhow::bail!(
"Branch '{}' has no PR. Run 'stax submit' first to create PRs.",
branch_name
);
}
}
}
let mut remaining_branches: Vec<RemainingBranchInfo> = Vec::new();
for branch_name in &scope.remaining {
let branch_info = stack.branches.get(branch_name);
let mut pr_number = branch_info.and_then(|b| b.pr_number);
if pr_number.is_none()
&& let Some(ref client) = probe_client
&& let Ok(Some(pr_info)) = rt.block_on(async { client.find_pr(branch_name).await })
{
pr_number = Some(pr_info.number);
}
remaining_branches.push(RemainingBranchInfo {
branch: branch_name.clone(),
pr_number,
});
}
LiveTimer::maybe_finish_ok(fetch_timer, "done");
if branches.is_empty() {
if !quiet {
println!("{}", "No branches to merge.".yellow());
}
return Ok(());
}
let remote_info = remote_info.context("Failed to read git remote configuration")?;
if remote_info.forge != ForgeType::GitHub {
anyhow::bail!(
"`stax merge --remote` is only supported for GitHub remotes (found {})",
remote_info.forge
);
}
let client = ForgeClient::new(&remote_info).context(
"Failed to connect to the configured forge. Check your token and remote configuration.",
)?;
if !quiet {
println!();
print_remote_preview(&branches, &scope.trunk, &method);
}
if !yes {
let confirm = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Proceed with merge --remote?")
.default(false)
.interact()?;
if !confirm {
println!("{}", "Aborted.".dimmed());
return Ok(());
}
}
if !quiet {
println!();
print_header("Merge Remote");
}
let timeout = Duration::from_secs(timeout_mins * 60);
let poll_interval = Duration::from_secs(interval_secs);
let total = branches.len();
let mut merged_prs: Vec<(String, u64)> = Vec::new();
let mut failed_pr: Option<(String, u64, String)> = None;
for idx in 0..total {
let pr_number = branches[idx].pr_number;
let branch_name = branches[idx].branch.clone();
let next_branch = branches.get(idx + 1).cloned();
if !quiet {
println!();
println!(
"[{}/{}] {} (#{})",
(idx + 1).to_string().cyan(),
total,
branch_name.bold(),
pr_number
);
}
let is_merged = rt.block_on(async { client.is_pr_merged(pr_number).await })?;
if is_merged {
if !quiet {
println!(" {} Already merged", "✓".green());
}
merged_prs.push((branch_name.clone(), pr_number));
if let Some(ref next_branch) = next_branch {
let update_base_timer = LiveTimer::maybe_new(
!quiet,
&format!(
"Retargeting #{} to {}...",
next_branch.pr_number, scope.trunk
),
);
match update_pr_base_unless_current(
&rt,
&client,
next_branch.pr_number,
&scope.trunk,
&next_branch.branch,
) {
Ok(PrBaseUpdate::Updated) => {
LiveTimer::maybe_finish_ok(update_base_timer, "done")
}
Ok(PrBaseUpdate::AlreadyTargeted) => {
LiveTimer::maybe_finish_ok(update_base_timer, "already on base")
}
Ok(PrBaseUpdate::NativeStackLocked) => {
LiveTimer::maybe_finish_warn(update_base_timer, "skipped (native Stack)");
print_native_stack_locked_note(quiet, next_branch.pr_number);
}
Err(e) => {
LiveTimer::maybe_finish_err(update_base_timer, "failed");
failed_pr = Some((
branch_name,
pr_number,
format!(
"Failed to retarget dependent PR #{}: {:#}",
next_branch.pr_number, e
),
));
break;
}
}
}
} else {
match wait_for_pr_ready(
&rt,
&client,
pr_number,
timeout,
poll_interval,
BlockedReasonStyle::StatusText,
quiet,
)? {
WaitResult::Ready(_) => {}
WaitResult::Failed(reason) => {
failed_pr = Some((branch_name, pr_number, reason));
break;
}
WaitResult::Timeout => {
failed_pr =
Some((branch_name, pr_number, "Timeout waiting for CI".to_string()));
break;
}
}
let merge_timer =
LiveTimer::maybe_new(!quiet, &format!("Merging ({})...", method.as_str()));
match rt.block_on(async { client.merge_pr(pr_number, method, None, None).await }) {
Ok(()) => {
LiveTimer::maybe_finish_ok(merge_timer, "done");
merged_prs.push((branch_name.clone(), pr_number));
record_ci_history_for_branch(&repo, &rt, &client, &stack, &branch_name);
}
Err(e) => {
LiveTimer::maybe_finish_err(merge_timer, "failed");
failed_pr = Some((branch_name, pr_number, format!("{:#}", e)));
break;
}
}
if let Some(ref next_branch) = next_branch {
let update_base_timer = LiveTimer::maybe_new(
!quiet,
&format!(
"Retargeting #{} to {}...",
next_branch.pr_number, scope.trunk
),
);
match update_pr_base_unless_current(
&rt,
&client,
next_branch.pr_number,
&scope.trunk,
&next_branch.branch,
) {
Ok(PrBaseUpdate::Updated) => {
LiveTimer::maybe_finish_ok(update_base_timer, "done")
}
Ok(PrBaseUpdate::AlreadyTargeted) => {
LiveTimer::maybe_finish_ok(update_base_timer, "already on base")
}
Ok(PrBaseUpdate::NativeStackLocked) => {
LiveTimer::maybe_finish_warn(update_base_timer, "skipped (native Stack)");
print_native_stack_locked_note(quiet, next_branch.pr_number);
}
Err(e) => {
LiveTimer::maybe_finish_err(update_base_timer, "failed");
failed_pr = Some((
branch_name,
pr_number,
format!(
"Failed to retarget dependent PR #{}: {:#}",
next_branch.pr_number, e
),
));
break;
}
}
}
}
if let Some(next_branch) = next_branch {
let update_timer = LiveTimer::maybe_new(
!quiet,
&format!(
"Updating branch for #{} on GitHub (merge {} → head)...",
next_branch.pr_number, scope.trunk
),
);
match rt.block_on(async { client.update_pr_branch(next_branch.pr_number).await }) {
Ok(()) => LiveTimer::maybe_finish_ok(update_timer, "done"),
Err(e) => {
LiveTimer::maybe_finish_err(update_timer, "failed");
failed_pr = Some((
next_branch.branch.clone(),
next_branch.pr_number,
format!(
"Failed to update branch for PR #{}: {}",
next_branch.pr_number, e
),
));
break;
}
}
}
}
if !merged_prs.is_empty() && !remaining_branches.is_empty() && failed_pr.is_none() {
if !quiet {
println!();
println!(
"{}",
"Updating remaining stack branches on GitHub...".dimmed()
);
}
for remaining in &remaining_branches {
let Some(pr_num) = remaining.pr_number else {
continue;
};
let base_timer = LiveTimer::maybe_new(
!quiet,
&format!("Retargeting #{} to {}...", pr_num, scope.trunk),
);
match update_pr_base_unless_current(
&rt,
&client,
pr_num,
&scope.trunk,
&remaining.branch,
) {
Ok(PrBaseUpdate::Updated) => LiveTimer::maybe_finish_ok(base_timer, "done"),
Ok(PrBaseUpdate::AlreadyTargeted) => {
LiveTimer::maybe_finish_ok(base_timer, "already on base")
}
Ok(PrBaseUpdate::NativeStackLocked) => {
LiveTimer::maybe_finish_warn(base_timer, "skipped (native Stack)");
print_native_stack_locked_note(quiet, pr_num);
}
Err(e) => {
LiveTimer::maybe_finish_err(base_timer, "failed");
failed_pr = Some((
remaining.branch.clone(),
pr_num,
format!("Failed to retarget PR #{}: {:#}", pr_num, e),
));
break;
}
}
let update_timer = LiveTimer::maybe_new(
!quiet,
&format!("Updating branch for #{} on GitHub...", pr_num),
);
match rt.block_on(async { client.update_pr_branch(pr_num).await }) {
Ok(()) => LiveTimer::maybe_finish_ok(update_timer, "done"),
Err(e) => {
LiveTimer::maybe_finish_err(update_timer, "failed");
failed_pr = Some((
remaining.branch.clone(),
pr_num,
format!("Failed to update branch for PR #{}: {}", pr_num, e),
));
break;
}
}
}
}
if !no_delete && !merged_prs.is_empty() {
if !quiet {
println!();
println!(
"{}",
"Cleaning up stax metadata for merged branches...".dimmed()
);
}
for (branch, _) in &merged_prs {
let _ = crate::git::refs::delete_metadata(repo.inner(), branch);
if !quiet {
println!(" {} metadata cleared for {}", "✓".green(), branch.dimmed());
}
}
}
println!();
if let Some((branch, pr, reason)) = &failed_pr {
print_header_error("Merge Remote Stopped");
println!();
println!("Progress:");
for (merged_branch, merged_pr) in &merged_prs {
println!(
" {} #{} {} → merged",
"✓".green(),
merged_pr,
merged_branch
);
}
println!(" {} #{} {} → {}", "✗".red(), pr, branch, reason);
println!();
println!("{}", "Already merged PRs remain merged.".dimmed());
println!(
"{}",
"Fix the issue and run 'stax merge --remote' to continue.".dimmed()
);
} else {
print_header_success("Stack Merged (remote)");
println!();
println!(
"Merged {} {} into {} via GitHub API:",
merged_prs.len(),
if merged_prs.len() == 1 { "PR" } else { "PRs" },
scope.trunk.cyan()
);
for (branch, pr) in &merged_prs {
println!(" {} #{} {}", "✓".green(), pr, branch);
}
if !remaining_branches.is_empty() {
println!();
println!("Remaining in stack (branches updated on GitHub):");
for remaining in &remaining_branches {
if let Some(pr) = remaining.pr_number {
println!(" {} #{} {}", "○".dimmed(), pr, remaining.branch);
} else {
println!(" {} {}", "○".dimmed(), remaining.branch);
}
}
}
crate::notifications::send_desktop_notification(
"stax merge --remote",
&format!(
"Merged {} {} into {}",
merged_prs.len(),
if merged_prs.len() == 1 { "PR" } else { "PRs" },
scope.trunk
),
);
}
if failed_pr.is_none() && !merged_prs.is_empty() && !no_sync && !quiet {
println!();
println!(
"{}",
"Run `stax rs` to sync your local repository (delete merged branches, reparent children)."
.dimmed()
);
}
Ok(())
}
fn calculate_merge_scope(stack: &Stack, current: &str, all: bool) -> MergeRemoteScope {
let scope = calculate_scope(stack, current, all, false);
MergeRemoteScope {
to_merge: scope.to_merge,
remaining: scope.remaining,
trunk: scope.trunk,
}
}
fn print_remote_preview(branches: &[LandBranchInfo], trunk: &str, method: &MergeMethod) {
print_header("Merge Remote");
println!();
let pr_word = if branches.len() == 1 { "PR" } else { "PRs" };
println!(
"Will merge {} {} bottom-up into {} (GitHub API only — no local git):",
branches.len().to_string().bold(),
pr_word,
trunk.cyan()
);
println!();
for (idx, branch) in branches.iter().enumerate() {
println!(
" {}. {} (#{})",
(idx + 1).to_string().bold(),
branch.branch.bold(),
branch.pr_number,
);
}
println!();
println!(
"Merge method: {} {}",
method.as_str().cyan(),
"(change with --method)".dimmed()
);
println!(
"{}",
"Each PR is polled for CI + approval; dependent branches use GitHub \"Update branch\"."
.dimmed()
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::stack::StackBranch;
use std::collections::HashMap;
fn create_test_stack() -> Stack {
let mut branches = HashMap::new();
branches.insert(
"main".to_string(),
StackBranch {
name: "main".to_string(),
parent: None,
parent_revision: None,
children: vec!["feature-a".to_string()],
needs_restack: false,
pr_number: None,
pr_state: None,
pr_is_draft: None,
},
);
branches.insert(
"feature-a".to_string(),
StackBranch {
name: "feature-a".to_string(),
parent: Some("main".to_string()),
parent_revision: None,
children: vec!["feature-b".to_string()],
needs_restack: false,
pr_number: Some(1),
pr_state: Some("OPEN".to_string()),
pr_is_draft: Some(false),
},
);
branches.insert(
"feature-b".to_string(),
StackBranch {
name: "feature-b".to_string(),
parent: Some("feature-a".to_string()),
parent_revision: None,
children: vec!["feature-c".to_string()],
needs_restack: false,
pr_number: Some(2),
pr_state: Some("OPEN".to_string()),
pr_is_draft: Some(false),
},
);
branches.insert(
"feature-c".to_string(),
StackBranch {
name: "feature-c".to_string(),
parent: Some("feature-b".to_string()),
parent_revision: None,
children: vec![],
needs_restack: false,
pr_number: Some(3),
pr_state: Some("OPEN".to_string()),
pr_is_draft: Some(false),
},
);
Stack {
branches,
trunk: "main".to_string(),
}
}
#[test]
fn calculate_merge_scope_from_middle_keeps_descendants_remaining() {
let stack = create_test_stack();
let scope = calculate_merge_scope(&stack, "feature-b", false);
assert_eq!(scope.to_merge, vec!["feature-a", "feature-b"]);
assert_eq!(scope.remaining, vec!["feature-c"]);
assert_eq!(scope.trunk, "main");
}
#[test]
fn calculate_merge_scope_all_includes_descendants() {
let stack = create_test_stack();
let scope = calculate_merge_scope(&stack, "feature-b", true);
assert_eq!(scope.to_merge, vec!["feature-a", "feature-b", "feature-c"]);
assert!(scope.remaining.is_empty());
}
}