use crate::commands::merge_rebase::{
fetch_remote_for_descendant_rebase, rebase_descendant_onto_remote_trunk_with_provenance,
};
use crate::commands::merge_shared::{
BlockedReasonStyle, PrBaseUpdate, WaitResult, blocked_reason, print_native_stack_locked_note,
rebase_and_finalize_remaining_branch, record_ci_history_for_branch, sync_head_after_push,
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, RebaseResult};
use crate::github::pr::{MergeMethod, PrMergeStatus};
use crate::progress::LiveTimer;
use crate::remote::RemoteInfo;
use anyhow::{Context, Result};
use colored::Colorize;
use std::io::Write;
use std::path::Path;
use std::process::Command;
use std::time::Duration;
#[derive(Debug, Clone)]
#[allow(dead_code)]
struct MergeBranchInfo {
branch: String,
pr_number: Option<u64>,
pr_status: Option<PrMergeStatus>,
is_current: bool,
position: usize,
}
struct MergeScope {
to_merge: Vec<MergeBranchInfo>,
remaining: Vec<MergeBranchInfo>,
trunk: String,
current: String,
downstack_only: bool,
}
#[allow(clippy::too_many_arguments)]
pub fn run(
all: bool,
downstack_only: bool,
dry_run: bool,
method: MergeMethod,
no_delete: bool,
no_wait: bool,
timeout_mins: u64,
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 mut scope = calculate_merge_scope(&stack, ¤t, all, downstack_only);
if scope.to_merge.is_empty() {
if !quiet {
println!("{}", "No branches to merge.".yellow());
}
return Ok(());
}
let remote_info = RemoteInfo::from_repo(&repo, &config);
let rt = tokio::runtime::Runtime::new()?;
let client = remote_info.as_ref().ok().and_then(|info| {
let _enter = rt.enter();
ForgeClient::new(info).ok()
});
if let Some(ref client) = client {
for branch_info in &mut scope.to_merge {
if branch_info.pr_number.is_none()
&& let Ok(Some(pr_info)) =
rt.block_on(async { client.find_pr(&branch_info.branch).await })
{
branch_info.pr_number = Some(pr_info.number);
}
}
}
let missing_prs: Vec<_> = scope
.to_merge
.iter()
.filter(|b| b.pr_number.is_none())
.map(|b| b.branch.clone())
.collect();
if !missing_prs.is_empty() {
anyhow::bail!(
"The following branches don't have PRs:\n {}\n\nRun 'stax submit' first to create PRs.",
missing_prs.join("\n ")
);
}
let remote_info = remote_info?;
let client = client.ok_or_else(|| {
anyhow::anyhow!(
"Failed to connect to the configured forge. Check your token and remote configuration."
)
})?;
let fetch_status_timer = LiveTimer::maybe_new(!quiet, "Fetching PR status...");
for branch_info in &mut scope.to_merge {
if let Some(pr_num) = branch_info.pr_number {
let status = rt.block_on(async { client.get_pr_merge_status(pr_num).await })?;
branch_info.pr_status = Some(status);
}
}
for branch_info in &mut scope.remaining {
if let Some(pr_num) = branch_info.pr_number
&& let Ok(status) = rt.block_on(async { client.get_pr_merge_status(pr_num).await })
{
branch_info.pr_status = Some(status);
}
}
LiveTimer::maybe_finish_ok(fetch_status_timer, "done");
if !quiet {
print_merge_plan(&scope, &method);
}
if dry_run {
if !quiet {
println!("{}", " Dry run — no changes made.".dimmed());
}
return Ok(());
}
if !yes && !quiet {
print!("Proceed? [Y/n] ");
std::io::stdout().flush().ok();
let mut input = String::new();
std::io::stdin().read_line(&mut input).ok();
if input.trim().eq_ignore_ascii_case("n") {
println!("{}", "Aborted.".dimmed());
return Ok(());
}
}
if !quiet {
println!("Merging stack...");
}
let timeout = Duration::from_secs(timeout_mins * 60);
let mut merged_prs: Vec<(String, u64)> = Vec::new();
let mut failed_pr: Option<(String, u64, String)> = None;
for (idx, branch_info) in scope.to_merge.iter().enumerate() {
let pr_number = branch_info.pr_number.unwrap();
let next_branch = scope.to_merge.get(idx + 1);
let is_merged = rt.block_on(async { client.is_pr_merged(pr_number).await })?;
if is_merged {
let timer =
LiveTimer::maybe_new(!quiet, &format!("#{} {}...", pr_number, branch_info.branch));
LiveTimer::maybe_finish_ok(timer, "already merged");
merged_prs.push((branch_info.branch.clone(), pr_number));
} else {
if !no_wait {
match wait_for_pr_ready(
&rt,
&client,
pr_number,
timeout,
Duration::from_secs(10),
BlockedReasonStyle::Detailed,
quiet,
)? {
WaitResult::Ready(_) => {}
WaitResult::Failed(reason) => {
failed_pr = Some((branch_info.branch.clone(), pr_number, reason));
break;
}
WaitResult::Timeout => {
failed_pr = Some((
branch_info.branch.clone(),
pr_number,
"Timeout waiting for CI".to_string(),
));
break;
}
}
} else {
let status = rt.block_on(async { client.get_pr_merge_status(pr_number).await })?;
if !status.is_ready() {
let reason = if status.is_blocked() {
blocked_reason(&status)
} else {
format!("PR not ready: {}", status.status_text())
};
failed_pr = Some((branch_info.branch.clone(), pr_number, reason));
break;
}
}
let merge_timer =
LiveTimer::maybe_new(!quiet, &format!("#{} {}...", pr_number, branch_info.branch));
match rt.block_on(async { client.merge_pr(pr_number, method, None, None).await }) {
Ok(()) => {
LiveTimer::maybe_finish_ok(merge_timer, "merged");
merged_prs.push((branch_info.branch.clone(), pr_number));
record_ci_history_for_branch(&repo, &rt, &client, &stack, &branch_info.branch);
}
Err(e) => {
LiveTimer::maybe_finish_err(merge_timer, &format!("{:#}", e));
failed_pr = Some((branch_info.branch.clone(), pr_number, format!("{:#}", e)));
break;
}
}
if let Some(next_branch) = next_branch {
let next_pr = next_branch.pr_number.unwrap();
let update_base_timer = LiveTimer::maybe_new(
!quiet,
&format!("Retargeting #{} to {}...", next_pr, scope.trunk),
);
match update_pr_base_unless_current(
&rt,
&client,
next_pr,
&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_pr);
}
Err(e) => {
LiveTimer::maybe_finish_err(update_base_timer, "failed");
failed_pr = Some((
branch_info.branch.clone(),
pr_number,
format!("Failed to retarget dependent PR #{}: {:#}", next_pr, e),
));
break;
}
}
}
}
if let Some(next_branch) = next_branch {
let next_pr = next_branch.pr_number.unwrap();
let fetch_timer = LiveTimer::maybe_new(!quiet, "Fetching latest...");
let fetch_ok = fetch_remote_for_descendant_rebase(&repo, &remote_info.name)?;
if !fetch_ok {
LiveTimer::maybe_finish_warn(fetch_timer, "warning");
} else {
LiveTimer::maybe_finish_ok(fetch_timer, "done");
}
let rebase_timer = LiveTimer::maybe_new(
!quiet,
&format!("Rebasing {} onto {}...", next_branch.branch, scope.trunk),
);
let rebase_result = rebase_descendant_onto_remote_trunk_with_provenance(
&repo,
&next_branch.branch,
&scope.trunk,
&remote_info.name,
)?;
match rebase_result {
RebaseResult::Success => {
LiveTimer::maybe_finish_ok(rebase_timer, "done");
}
RebaseResult::Conflict => {
let abort_dir = repo
.branch_worktree_path(&next_branch.branch)?
.unwrap_or(repo.workdir()?.to_path_buf());
let _ = Command::new("git")
.args(["rebase", "--abort"])
.current_dir(&abort_dir)
.output();
LiveTimer::maybe_finish_err(rebase_timer, "conflict");
failed_pr = Some((
next_branch.branch.clone(),
next_pr,
"Rebase conflict".to_string(),
));
break;
}
}
let push_timer =
LiveTimer::maybe_new(!quiet, &format!("Pushing {}...", next_branch.branch));
let push_status = Command::new("git")
.args([
"push",
"--force-with-lease",
&remote_info.name,
&next_branch.branch,
])
.current_dir(repo.workdir()?)
.output()
.context("Failed to push")?;
if !push_status.status.success() {
let next_is_merged = rt
.block_on(async { client.is_pr_merged(next_pr).await })
.unwrap_or(false);
if next_is_merged {
LiveTimer::maybe_finish_ok(push_timer, "skipped (already merged)");
} else {
LiveTimer::maybe_finish_err(push_timer, "failed");
failed_pr = Some((
next_branch.branch.clone(),
next_pr,
"Failed to push rebased branch".to_string(),
));
break;
}
} else {
LiveTimer::maybe_finish_ok(push_timer, "done");
}
sync_head_after_push(&rt, &client, next_pr, &repo, &next_branch.branch);
}
}
if !merged_prs.is_empty() && !scope.remaining.is_empty() && failed_pr.is_none() {
if !quiet {
println!();
println!("{}", "Rebasing remaining stack branches...".dimmed());
}
for (idx, remaining) in scope.remaining.iter().enumerate() {
let previous = if idx == 0 {
None
} else {
Some(scope.remaining[idx - 1].branch.as_str())
};
rebase_and_finalize_remaining_branch(
&repo,
&rt,
&client,
&remote_info.name,
&scope.trunk,
&remaining.branch,
remaining.pr_number,
previous,
quiet,
)?;
}
}
if !no_delete && !merged_prs.is_empty() {
for (branch, _pr) in &merged_prs {
let local_deleted = Command::new("git")
.args(["branch", "-D", branch])
.current_dir(repo.workdir()?)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
let remote_deleted = Command::new("git")
.args(["push", &remote_info.name, "--delete", branch])
.current_dir(repo.workdir()?)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
let _ = crate::git::refs::delete_metadata(repo.inner(), branch);
if !quiet {
if local_deleted && remote_deleted {
println!(" {} {} deleted", "✓".green(), branch.dimmed());
} else if local_deleted {
println!(" {} {} deleted (local only)", "✓".green(), branch.dimmed());
}
}
}
let checkout_after_cleanup = if scope.downstack_only {
&scope.current
} else {
&scope.trunk
};
let _ = repo.checkout(checkout_after_cleanup);
}
println!();
if let Some((branch, pr, reason)) = failed_pr {
println!(" {} #{} {} → {}", "✗".red(), pr, branch, reason);
println!("{}", "Fix the issue and run 'stax merge' again.".dimmed());
} else {
let pr_word = if merged_prs.len() == 1 { "PR" } else { "PRs" };
println!(
"{} {} {} merged into {}",
"✓".green(),
merged_prs.len(),
pr_word,
scope.trunk.cyan()
);
if !no_sync {
if !quiet {
println!();
println!("{}", "Running post-merge sync...".dimmed());
}
let workdir = repo.workdir()?.to_path_buf();
if local_trunk_reset_is_safe(&workdir, &scope.trunk) {
if !quiet {
println!("{}", " Resetting local trunk to remote...".dimmed());
}
let remote_ref = format!("refs/remotes/origin/{}", scope.trunk);
let local_ref = format!("refs/heads/{}", scope.trunk);
let _ = Command::new("git")
.args(["update-ref", &local_ref, &remote_ref])
.current_dir(&workdir)
.output();
}
drop(rt);
drop(client);
drop(repo);
if let Err(err) = crate::commands::sync::run(
false, false, false, !no_delete, false, true, false, false, quiet,
false, false, &[],
) && !quiet
{
println!();
println!(
"{} {}",
"warning:".yellow().bold(),
format!("post-merge sync failed: {}", err).yellow()
);
println!(
"{}",
"Run 'stax rs --force' manually to sync local state.".dimmed()
);
}
}
}
Ok(())
}
fn calculate_merge_scope(
stack: &Stack,
current: &str,
all: bool,
downstack_only: bool,
) -> MergeScope {
let mut ancestors = stack.ancestors(current);
ancestors.reverse();
ancestors.retain(|b| b != &stack.trunk);
let mut to_merge: Vec<MergeBranchInfo> = Vec::new();
for (idx, branch) in ancestors.iter().enumerate() {
let branch_info = stack.branches.get(branch);
let pr_number = branch_info.and_then(|b| b.pr_number);
to_merge.push(MergeBranchInfo {
branch: branch.clone(),
pr_number,
pr_status: None,
is_current: false,
position: idx + 1,
});
}
let current_info = stack.branches.get(current);
let current_pr = current_info.and_then(|b| b.pr_number);
let current_position = to_merge.len() + 1;
let current_branch_info = MergeBranchInfo {
branch: current.to_string(),
pr_number: current_pr,
pr_status: None,
is_current: true,
position: current_position,
};
let mut remaining: Vec<MergeBranchInfo> = Vec::new();
if downstack_only {
remaining.push(current_branch_info);
} else {
to_merge.push(current_branch_info);
}
let descendants = stack.descendants(current);
for (idx, branch) in descendants.iter().enumerate() {
let branch_info = stack.branches.get(branch);
let pr_number = branch_info.and_then(|b| b.pr_number);
remaining.push(MergeBranchInfo {
branch: branch.clone(),
pr_number,
pr_status: None,
is_current: false,
position: current_position + idx + 1,
});
}
if all && !remaining.is_empty() {
to_merge.extend(remaining);
remaining = Vec::new();
}
MergeScope {
to_merge,
remaining,
trunk: stack.trunk.clone(),
current: current.to_string(),
downstack_only,
}
}
fn print_merge_plan(scope: &MergeScope, method: &MergeMethod) {
let n = scope.to_merge.len();
let pr_word = if n == 1 { "PR" } else { "PRs" };
println!(
" {} {} to merge → {} ({}):",
n.to_string().bold(),
pr_word,
scope.trunk.cyan(),
method.as_str()
);
for (i, branch_info) in scope.to_merge.iter().enumerate() {
let pr_str = branch_info
.pr_number
.map(|n| format!(" (#{n})"))
.unwrap_or_default();
let current_marker = if branch_info.is_current {
format!(" {}", "← current".dimmed())
} else {
String::new()
};
println!(
" {}. {}{}{}",
i + 1,
branch_info.branch.bold(),
pr_str.dimmed(),
current_marker
);
}
}
#[cfg_attr(not(test), allow(dead_code))]
fn merge_target_label(position: usize, trunk: &str) -> String {
if position == 1 {
trunk.to_string()
} else {
format!("{trunk} (after rebase)")
}
}
#[cfg_attr(not(test), allow(dead_code))]
fn remaining_rebase_target_label<'a>(
idx: usize,
branches: &'a [MergeBranchInfo],
trunk: &'a str,
) -> &'a str {
if idx == 0 {
trunk
} else {
&branches[idx - 1].branch
}
}
fn local_trunk_reset_is_safe(workdir: &Path, trunk: &str) -> bool {
let remote_trunk = format!("origin/{}", trunk);
let diverged = Command::new("git")
.args([
"rev-list",
"--count",
&format!("{}..{}", remote_trunk, trunk),
])
.current_dir(workdir)
.output();
match diverged {
Ok(out) if out.status.success() => {
let count = String::from_utf8_lossy(&out.stdout).trim().to_string();
if count == "0" {
return false;
}
}
_ => return false,
}
let lost = Command::new("git")
.args([
"diff",
"--diff-filter=D",
"--name-only",
trunk,
&remote_trunk,
])
.current_dir(workdir)
.output();
match lost {
Ok(out) if out.status.success() => out.stdout.iter().all(|b| b.is_ascii_whitespace()),
_ => false,
}
}
#[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 test_merge_branch_info_creation() {
let info = MergeBranchInfo {
branch: "feature-test".to_string(),
pr_number: Some(42),
pr_status: None,
is_current: true,
position: 1,
};
assert_eq!(info.branch, "feature-test");
assert_eq!(info.pr_number, Some(42));
assert!(info.is_current);
assert_eq!(info.position, 1);
}
#[test]
fn test_merge_scope_creation() {
let scope = MergeScope {
to_merge: vec![
MergeBranchInfo {
branch: "feature-a".to_string(),
pr_number: Some(1),
pr_status: None,
is_current: false,
position: 1,
},
MergeBranchInfo {
branch: "feature-b".to_string(),
pr_number: Some(2),
pr_status: None,
is_current: true,
position: 2,
},
],
remaining: vec![MergeBranchInfo {
branch: "feature-c".to_string(),
pr_number: Some(3),
pr_status: None,
is_current: false,
position: 3,
}],
trunk: "main".to_string(),
current: "feature-b".to_string(),
downstack_only: false,
};
assert_eq!(scope.to_merge.len(), 2);
assert_eq!(scope.remaining.len(), 1);
assert_eq!(scope.trunk, "main");
}
#[test]
fn test_calculate_merge_scope_downstack_only_excludes_current() {
let stack = create_test_stack();
let scope = calculate_merge_scope(&stack, "feature-b", false, true);
let to_merge: Vec<_> = scope.to_merge.iter().map(|b| b.branch.as_str()).collect();
let remaining: Vec<_> = scope.remaining.iter().map(|b| b.branch.as_str()).collect();
assert_eq!(to_merge, vec!["feature-a"]);
assert_eq!(remaining, vec!["feature-b", "feature-c"]);
assert!(scope.remaining[0].is_current);
assert_eq!(scope.remaining[0].position, 2);
assert_eq!(scope.current, "feature-b");
assert!(scope.downstack_only);
}
#[test]
fn test_calculate_merge_scope_downstack_only_direct_child_has_no_merge_targets() {
let stack = create_test_stack();
let scope = calculate_merge_scope(&stack, "feature-a", false, true);
let remaining: Vec<_> = scope.remaining.iter().map(|b| b.branch.as_str()).collect();
assert!(scope.to_merge.is_empty());
assert_eq!(remaining, vec!["feature-a", "feature-b", "feature-c"]);
assert!(scope.remaining[0].is_current);
}
#[test]
fn test_merge_target_label_uses_configured_trunk() {
assert_eq!(merge_target_label(1, "master"), "master");
assert_eq!(merge_target_label(2, "master"), "master (after rebase)");
}
#[test]
fn test_remaining_rebase_target_label_preserves_remaining_chain() {
let branches = vec![
MergeBranchInfo {
branch: "feature-b".to_string(),
pr_number: Some(2),
pr_status: None,
is_current: false,
position: 2,
},
MergeBranchInfo {
branch: "feature-c".to_string(),
pr_number: Some(3),
pr_status: None,
is_current: false,
position: 3,
},
];
assert_eq!(
remaining_rebase_target_label(0, &branches, "master"),
"master"
);
assert_eq!(
remaining_rebase_target_label(1, &branches, "master"),
"feature-b"
);
}
}