use crate::commands::ci::{fetch_ci_statuses, record_ci_history};
use crate::commands::merge_rebase::{
fetch_remote_for_descendant_rebase, rebase_descendant_onto_parent_with_provenance,
rebase_descendant_onto_remote_trunk_with_provenance,
};
use crate::config::Config;
use crate::engine::Stack;
use crate::forge::ForgeClient;
use crate::git::{GitRepo, RebaseResult};
use crate::github::pr::{CiStatus, MergeMethod, PrMergeStatus};
use crate::progress::LiveTimer;
use crate::remote::RemoteInfo;
use anyhow::{Context, Result};
use colored::Colorize;
use dialoguer::{Confirm, theme::ColorfulTheme};
use std::io::Write;
use std::process::Command;
use std::time::{Duration, Instant};
const DUPLICATE_PR_BASE_RECHECK_TIMEOUT: Duration = Duration::from_secs(2);
const DUPLICATE_PR_BASE_RECHECK_INTERVAL: Duration = Duration::from_millis(200);
#[derive(Debug, Clone)]
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,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PrBaseUpdate {
Updated,
AlreadyTargeted,
}
#[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() {
if 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 {
if 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 {
println!();
}
if !quiet {
print_merge_preview(&scope, &method);
}
if dry_run {
if !quiet {
println!();
println!("{}", "Dry run - no changes made.".dimmed());
}
return Ok(());
}
if !yes && !quiet {
let has_waiting = scope.to_merge.iter().any(|b| {
b.pr_status
.as_ref()
.map(|s| s.is_waiting())
.unwrap_or(false)
});
let prompt = if has_waiting && !no_wait {
"Proceed with merge? (will wait for pending checks)"
} else {
"Proceed with merge?"
};
let confirm = Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(prompt)
.default(false)
.interact()?;
if !confirm {
println!("{}", "Aborted.".dimmed());
return Ok(());
}
}
if !quiet {
println!();
print_header("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;
let total = scope.to_merge.len();
for (idx, branch_info) in scope.to_merge.iter().enumerate() {
let pr_number = branch_info.pr_number.unwrap();
let position = idx + 1;
let next_branch = scope.to_merge.get(idx + 1);
if !quiet {
println!();
println!(
"[{}/{}] {} (#{})",
position.to_string().cyan(),
total,
branch_info.branch.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_info.branch.clone(), pr_number));
} else {
if !no_wait {
match wait_for_pr_ready(&rt, &client, pr_number, timeout, 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() {
failed_pr = Some((
branch_info.branch.clone(),
pr_number,
format!("PR not ready: {}", status.status_text()),
));
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_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, "failed");
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");
}
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() {
LiveTimer::maybe_finish_err(push_timer, "failed");
failed_pr = Some((
next_branch.branch.clone(),
next_pr,
"Failed to push rebased branch".to_string(),
));
break;
}
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() {
if !quiet {
println!();
println!("{}", "Cleaning up merged branches...".dimmed());
}
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 {
print_header_error("Merge 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' to continue.".dimmed()
);
} else {
print_header_success("Stack Merged!");
println!();
println!(
"Merged {} {} into {}:",
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 !scope.remaining.is_empty() {
println!();
println!("Remaining in stack (rebased onto {}):", scope.trunk.cyan());
for remaining in &scope.remaining {
if let Some(pr) = remaining.pr_number {
println!(" {} #{} {}", "○".dimmed(), pr, remaining.branch);
} else {
println!(" {} {}", "○".dimmed(), remaining.branch);
}
}
}
if !no_delete && !merged_prs.is_empty() {
println!();
println!("Cleanup:");
println!(
" • Deleted {} local {}",
merged_prs.len(),
if merged_prs.len() == 1 {
"branch"
} else {
"branches"
}
);
let checkout_after_cleanup = if scope.downstack_only {
&scope.current
} else {
&scope.trunk
};
println!(" • Switched to: {}", checkout_after_cleanup.cyan());
}
if !scope.remaining.is_empty() {
println!();
println!(
"{}",
"Tip: Run 'stax merge' again to continue merging the rest of the stack.".dimmed()
);
}
if !no_sync {
if !quiet {
println!();
println!("{}", "Running post-merge sync...".dimmed());
}
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, &[],
) {
if !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_preview(scope: &MergeScope, method: &MergeMethod) {
print_header("Stack Merge");
println!();
let current = scope
.to_merge
.iter()
.chain(scope.remaining.iter())
.find(|b| b.is_current)
.map(|b| b.branch.as_str())
.unwrap_or("unknown");
let current_pr = scope
.to_merge
.iter()
.chain(scope.remaining.iter())
.find(|b| b.is_current)
.and_then(|b| b.pr_number)
.map(|n| format!(" (PR #{})", n))
.unwrap_or_default();
println!(
"You are on: {}{}",
current.cyan().bold(),
current_pr.dimmed()
);
println!();
let pr_word = if scope.to_merge.len() == 1 {
"PR"
} else {
"PRs"
};
let scope_label = if scope.downstack_only {
"below current"
} else {
"from bottom → current"
};
println!(
"This will merge {} {} {}:",
scope.to_merge.len().to_string().bold(),
pr_word,
scope_label
);
println!();
print_branch_box(&scope.to_merge, true, &scope.trunk);
if !scope.remaining.is_empty() {
println!();
print_branch_box(&scope.remaining, false, &scope.trunk);
}
println!();
println!(
"Merge method: {} {}",
method.as_str().cyan(),
"(change with --method)".dimmed()
);
}
fn print_branch_box(branches: &[MergeBranchInfo], included: bool, trunk: &str) {
println!();
for (idx, branch) in branches.iter().enumerate() {
let pr_text = branch
.pr_number
.map(|n| format!("#{}", n))
.unwrap_or_else(|| "no PR".to_string());
let current_marker = if branch.is_current {
" (current)".cyan().to_string()
} else {
String::new()
};
println!(
" {}. {}{} {}",
branch.position.to_string().bold(),
branch.branch.bold(),
current_marker,
format!("({})", pr_text).dimmed()
);
if included {
if let Some(ref pr_status) = branch.pr_status {
let ci_check = match pr_status.ci_status {
CiStatus::Success => format!(" {} CI checks passed", "✓".green()),
CiStatus::Pending => format!(" {} CI checks running...", "○".yellow()),
CiStatus::Failure => format!(" {} CI checks failed", "✗".red()),
CiStatus::NoCi => format!(" {} No CI checks required", "✓".green()),
};
println!("{}", ci_check);
let review_check = if pr_status.changes_requested {
format!(" {} Changes requested", "✗".red())
} else if pr_status.approvals > 0 {
format!(
" {} Approved ({} review{})",
"✓".green(),
pr_status.approvals,
if pr_status.approvals == 1 { "" } else { "s" }
)
} else {
format!(" {} Awaiting review...", "○".yellow())
};
println!("{}", review_check);
let mergeable_check = if pr_status.mergeable == Some(false) {
format!(" {} Has merge conflicts", "✗".red())
} else if pr_status.mergeable == Some(true) {
format!(" {} No conflicts", "✓".green())
} else {
format!(" {} Checking conflicts...", "○".yellow())
};
println!("{}", mergeable_check);
let merge_into = merge_target_label(branch.position, trunk);
println!(" {} Merge into {}", "→".dimmed(), merge_into);
} else {
println!(" {} Fetching status...", "○".yellow());
}
} else {
println!(" {} Not included in this merge", "·".dimmed());
println!(
" {} Will be rebased onto {}",
"→".dimmed(),
remaining_rebase_target_label(idx, branches, trunk)
);
}
if idx < branches.len() - 1 {
println!();
}
}
println!();
}
fn merge_target_label(position: usize, trunk: &str) -> String {
if position == 1 {
trunk.to_string()
} else {
format!("{trunk} (after rebase)")
}
}
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 strip_ansi(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut in_escape = false;
for c in s.chars() {
if c == '\x1b' {
in_escape = true;
continue;
}
if in_escape {
if c == 'm' {
in_escape = false;
}
continue;
}
result.push(c);
}
result
}
fn display_width(s: &str) -> usize {
let stripped = strip_ansi(s);
stripped.chars().map(char_width).sum()
}
fn char_width(c: char) -> usize {
match c {
'\x00'..='\x1f' | '\x7f' => 0,
'\x20'..='\x7e' => 1,
'─' | '│' | '┌' | '┐' | '└' | '┘' | '├' | '┤' | '┬' | '┴' | '┼' | '╭' | '╮' | '╯' | '╰'
| '║' | '═' => 1,
'←' | '→' | '↑' | '↓' => 1,
'✓' | '✗' | '✔' | '✘' => 1,
_ => 2,
}
}
fn print_header(title: &str) {
let width: usize = 56;
let title_width = display_width(title);
let padding = width.saturating_sub(title_width) / 2;
println!("╭{}╮", "─".repeat(width));
println!(
"│{}{}{}│",
" ".repeat(padding),
title.bold(),
" ".repeat(width.saturating_sub(padding + title_width))
);
println!("╰{}╯", "─".repeat(width));
}
fn print_header_success(title: &str) {
let width: usize = 56;
let full_title = format!("✓ {}", title);
let title_width = display_width(&full_title);
let padding = width.saturating_sub(title_width) / 2;
println!("╭{}╮", "─".repeat(width));
println!(
"│{}{}{}│",
" ".repeat(padding),
full_title.green().bold(),
" ".repeat(width.saturating_sub(padding + title_width))
);
println!("╰{}╯", "─".repeat(width));
}
fn print_header_error(title: &str) {
let width: usize = 56;
let full_title = format!("✗ {}", title);
let title_width = display_width(&full_title);
let padding = width.saturating_sub(title_width) / 2;
println!("╭{}╮", "─".repeat(width));
println!(
"│{}{}{}│",
" ".repeat(padding),
full_title.red().bold(),
" ".repeat(width.saturating_sub(padding + title_width))
);
println!("╰{}╯", "─".repeat(width));
}
enum WaitResult {
Ready,
Failed(String),
Timeout,
}
fn wait_for_pr_ready(
rt: &tokio::runtime::Runtime,
client: &ForgeClient,
pr_number: u64,
timeout: Duration,
quiet: bool,
) -> Result<WaitResult> {
let start = Instant::now();
let poll_interval = Duration::from_secs(10);
let mut last_status: Option<String> = None;
loop {
let status = rt.block_on(async { client.get_pr_merge_status(pr_number).await })?;
if status.is_ready() {
if !quiet && last_status.is_some() {
println!(); }
return Ok(WaitResult::Ready);
}
if status.is_blocked() {
if !quiet && last_status.is_some() {
println!(); }
return Ok(WaitResult::Failed(status.status_text().to_string()));
}
if start.elapsed() > timeout {
if !quiet && last_status.is_some() {
println!(); }
return Ok(WaitResult::Timeout);
}
if !quiet {
let elapsed = start.elapsed().as_secs();
let status_text = format!(
" {} Waiting for {}... ({}s)",
"⏳".yellow(),
status.status_text().to_lowercase(),
elapsed
);
if last_status.is_some() {
print!("\r{}\r", " ".repeat(80));
}
print!("{}", status_text);
std::io::stdout().flush().ok();
last_status = Some(status_text);
}
std::thread::sleep(poll_interval);
}
}
fn wait_for_github_head_sync(
rt: &tokio::runtime::Runtime,
client: &ForgeClient,
pr_number: u64,
expected_sha: &str,
max_wait: Duration,
) {
let start = Instant::now();
let poll_interval = Duration::from_millis(500);
loop {
if let Ok(sha) = rt.block_on(async { client.get_pr_head_sha(pr_number).await }) {
if sha == expected_sha {
return;
}
}
if start.elapsed() + poll_interval >= max_wait {
return;
}
std::thread::sleep(poll_interval);
}
}
fn summarize_git_stderr(stderr: &[u8]) -> String {
let text = String::from_utf8_lossy(stderr);
text.lines()
.map(|line| line.trim())
.find(|line| !line.is_empty())
.unwrap_or("push rejected")
.to_string()
}
pub(crate) fn update_pr_base_unless_current(
rt: &tokio::runtime::Runtime,
client: &ForgeClient,
pr_number: u64,
new_base: &str,
expected_head: &str,
) -> Result<PrBaseUpdate> {
if let Ok(current_pr) = rt.block_on(async { client.get_pr_with_head(pr_number).await }) {
ensure_pr_head_matches(pr_number, ¤t_pr.head, expected_head)?;
if current_pr.info.base == new_base {
return Ok(PrBaseUpdate::AlreadyTargeted);
}
}
let update_result = rt.block_on(async { client.update_pr_base(pr_number, new_base).await });
match update_result {
Ok(()) => Ok(PrBaseUpdate::Updated),
Err(e) => {
if is_duplicate_pr_base_error(&e, new_base, expected_head)
&& pr_base_matches_after_duplicate_error(rt, client, pr_number, new_base)
{
return Ok(PrBaseUpdate::AlreadyTargeted);
}
Err(e).with_context(|| format!("failed to retarget PR #{} to {}", pr_number, new_base))
}
}
}
fn ensure_pr_head_matches(pr_number: u64, actual_head: &str, expected_head: &str) -> Result<()> {
if actual_head != expected_head {
anyhow::bail!(
"PR #{} is for head branch '{}', expected '{}'",
pr_number,
actual_head,
expected_head
);
}
Ok(())
}
fn pr_base_matches_after_duplicate_error(
rt: &tokio::runtime::Runtime,
client: &ForgeClient,
pr_number: u64,
new_base: &str,
) -> bool {
let deadline = Instant::now() + DUPLICATE_PR_BASE_RECHECK_TIMEOUT;
loop {
if let Ok(pr) = rt.block_on(async { client.get_pr(pr_number).await }) {
if pr.base == new_base {
return true;
}
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(DUPLICATE_PR_BASE_RECHECK_INTERVAL);
}
}
fn is_duplicate_pr_base_error(error: &anyhow::Error, new_base: &str, expected_head: &str) -> bool {
let message = format!("{:#}", error);
message.contains("A pull request already exists")
&& message.contains(&format!("base branch '{}'", new_base))
&& message.contains(&format!("head branch '{}'", expected_head))
}
#[allow(clippy::too_many_arguments)]
fn finalize_remaining_branch_push(
repo: &GitRepo,
rt: &tokio::runtime::Runtime,
client: &ForgeClient,
remote_name: &str,
branch: &str,
pr_number: Option<u64>,
new_base: &str,
timer: Option<LiveTimer>,
) -> Result<()> {
let push_output = Command::new("git")
.args(["push", "--force-with-lease", remote_name, branch])
.current_dir(repo.workdir()?)
.output();
let push_err = match &push_output {
Ok(out) if out.status.success() => None,
Ok(out) => Some(summarize_git_stderr(&out.stderr)),
Err(e) => Some(e.to_string()),
};
if let Some(reason) = push_err {
LiveTimer::maybe_finish_err(
timer,
&format!("push failed; PR base unchanged ({})", reason),
);
return Ok(());
}
if let Some(pr_num) = pr_number {
if let Err(e) = update_pr_base_unless_current(rt, client, pr_num, new_base, branch) {
LiveTimer::maybe_finish_err(timer, &format!("retarget failed: {:#}", e));
return Ok(());
}
}
LiveTimer::maybe_finish_ok(timer, "done");
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn rebase_and_finalize_remaining_branch(
repo: &GitRepo,
rt: &tokio::runtime::Runtime,
client: &ForgeClient,
remote_name: &str,
trunk: &str,
branch: &str,
pr_number: Option<u64>,
previous_remaining: Option<&str>,
quiet: bool,
) -> Result<()> {
let parent_is_trunk = previous_remaining.is_none();
let parent_branch = previous_remaining
.map(|s| s.to_string())
.unwrap_or_else(|| trunk.to_string());
let fetch_timer = LiveTimer::maybe_new(!quiet, "Fetching latest...");
let fetch_ok = fetch_remote_for_descendant_rebase(repo, remote_name)?;
if !fetch_ok {
LiveTimer::maybe_finish_warn(fetch_timer, "warning");
} else {
LiveTimer::maybe_finish_ok(fetch_timer, "done");
}
let remaining_timer = LiveTimer::maybe_new(
!quiet,
&format!("Rebasing {} onto {}...", branch, parent_branch),
);
let rebase_result = if parent_is_trunk {
rebase_descendant_onto_remote_trunk_with_provenance(repo, branch, trunk, remote_name)
} else {
rebase_descendant_onto_parent_with_provenance(
repo,
branch,
&parent_branch,
remote_name,
false,
)
};
match rebase_result {
Ok(RebaseResult::Success) => {
finalize_remaining_branch_push(
repo,
rt,
client,
remote_name,
branch,
pr_number,
&parent_branch,
remaining_timer,
)?;
}
Ok(RebaseResult::Conflict) => {
let abort_dir = repo
.branch_worktree_path(branch)?
.unwrap_or(repo.workdir()?.to_path_buf());
let _ = Command::new("git")
.args(["rebase", "--abort"])
.current_dir(&abort_dir)
.output();
LiveTimer::maybe_finish_warn(remaining_timer, "conflict (skipped)");
}
Err(_) => {
LiveTimer::maybe_finish_err(remaining_timer, "failed");
}
}
Ok(())
}
pub(crate) fn sync_head_after_push(
rt: &tokio::runtime::Runtime,
client: &ForgeClient,
pr_number: u64,
repo: &GitRepo,
branch: &str,
) {
if std::env::var_os("STAX_TEST_DISABLE_HEAD_SYNC").is_some() {
return;
}
if let Ok(pushed_sha) = repo.rev_parse(branch) {
wait_for_github_head_sync(rt, client, pr_number, &pushed_sha, Duration::from_secs(15));
}
}
fn record_ci_history_for_branch(
repo: &GitRepo,
rt: &tokio::runtime::Runtime,
client: &ForgeClient,
stack: &Stack,
branch: &str,
) {
if repo.branch_commit(branch).is_err() {
return; }
let branches = vec![branch.to_string()];
if let Ok(statuses) = fetch_ci_statuses(repo, rt, client, stack, &branches) {
record_ci_history(repo, &statuses);
}
}
#[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_strip_ansi_empty_string() {
assert_eq!(strip_ansi(""), "");
}
#[test]
fn test_strip_ansi_no_codes() {
assert_eq!(strip_ansi("hello world"), "hello world");
}
#[test]
fn test_strip_ansi_with_color_codes() {
assert_eq!(strip_ansi("\x1b[31mred\x1b[0m"), "red");
}
#[test]
fn test_strip_ansi_with_multiple_codes() {
assert_eq!(strip_ansi("\x1b[1m\x1b[31mtext\x1b[0m"), "text");
}
#[test]
fn test_strip_ansi_complex() {
let colored = "\x1b[32m✓\x1b[0m \x1b[1mBold\x1b[0m \x1b[33mYellow\x1b[0m";
assert_eq!(strip_ansi(colored), "✓ Bold Yellow");
}
#[test]
fn test_strip_ansi_preserves_unicode() {
let with_emoji = "\x1b[32m✓\x1b[0m Success 🎉";
assert_eq!(strip_ansi(with_emoji), "✓ Success 🎉");
}
#[test]
fn test_display_width_ascii() {
assert_eq!(display_width("hello"), 5);
assert_eq!(display_width("hello world"), 11);
}
#[test]
fn test_display_width_symbols() {
assert_eq!(display_width("✓"), 1);
assert_eq!(display_width("✗"), 1);
assert_eq!(display_width("⏳"), 2);
}
#[test]
fn test_display_width_mixed() {
assert_eq!(display_width("✓ passed"), 8);
assert_eq!(display_width("~ pending"), 9);
}
#[test]
fn test_display_width_with_ansi() {
assert_eq!(display_width("\x1b[32m✓\x1b[0m passed"), 8);
}
#[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"
);
}
#[test]
fn test_duplicate_pr_base_error_matches_requested_base_and_head() {
let err = anyhow::anyhow!(
"GitHub: Validation Failed\nErrors:\n- {{\"message\":\"A pull request already exists for base branch 'master' and head branch 'jonny/feature'\"}}"
);
assert!(is_duplicate_pr_base_error(&err, "master", "jonny/feature"));
assert!(!is_duplicate_pr_base_error(&err, "main", "jonny/feature"));
assert!(!is_duplicate_pr_base_error(&err, "master", "other/feature"));
}
#[test]
fn test_pr_head_mismatch_bails() {
let err = ensure_pr_head_matches(42, "wrong-head", "expected-head")
.expect_err("head mismatch should fail");
let message = format!("{:#}", err);
assert!(message.contains("PR #42 is for head branch 'wrong-head'"));
assert!(message.contains("expected 'expected-head'"));
}
#[test]
fn test_wait_result_variants() {
let _ready = WaitResult::Ready;
let _failed = WaitResult::Failed("CI failed".to_string());
let _timeout = WaitResult::Timeout;
}
}