use crate::commands::open::open_url_in_browser;
use crate::config::{Config, StackLinksMode};
use crate::engine::{BranchMetadata, Stack};
use crate::forge::ForgeClient;
use crate::git::GitRepo;
use crate::github::pr::{
generate_stack_links_markdown, remove_stack_links_from_body, upsert_stack_links_in_body,
PrInfoWithHead, StackPrInfo,
};
use crate::github::pr_template::{discover_pr_templates, select_template_interactive};
use crate::ops::receipt::{OpKind, PlanSummary};
use crate::ops::tx::{self, Transaction};
use crate::progress::LiveTimer;
use crate::remote::{self, RemoteInfo};
use anyhow::{Context, Result};
use colored::Colorize;
use dialoguer::{theme::ColorfulTheme, Editor, Input, Select};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::fs;
use std::path::Path;
use std::process::Command;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SubmitScope {
Branch,
Downstack,
Upstack,
Stack,
}
impl SubmitScope {
fn label(self) -> &'static str {
match self {
SubmitScope::Branch => "branch",
SubmitScope::Downstack => "downstack",
SubmitScope::Upstack => "upstack",
SubmitScope::Stack => "stack",
}
}
}
struct PrPlan {
branch: String,
parent: String,
existing_pr: Option<u64>,
existing_pr_is_draft: Option<bool>,
tip_commit_subject: Option<String>,
needs_title_update: bool,
title: Option<String>,
body: Option<String>,
is_draft: Option<bool>,
needs_push: bool,
needs_pr_update: bool,
is_empty: bool,
}
#[derive(Default, Clone)]
struct SubmitPhaseTimings {
planning: Duration,
open_pr_discovery: Duration,
pr_create_update: Duration,
stack_links: Duration,
}
const PR_TYPE_OPTIONS: [&str; 2] = ["Create as draft", "Publish immediately"];
const PR_TYPE_DEFAULT_INDEX: usize = 0;
fn resolve_is_draft_without_prompt(
draft_flag_set: bool,
publish_flag_set: bool,
draft: bool,
no_prompt: bool,
) -> Option<bool> {
if draft_flag_set {
Some(draft)
} else if publish_flag_set {
Some(false)
} else if no_prompt {
Some(true)
} else {
None
}
}
#[allow(clippy::too_many_arguments)]
pub fn run(
scope: SubmitScope,
draft: bool,
publish: bool,
no_pr: bool,
no_fetch: bool,
_force: bool, yes: bool,
no_prompt: bool,
reviewers: Vec<String>,
labels: Vec<String>,
assignees: Vec<String>,
quiet: bool,
open: bool,
verbose: bool,
template: Option<String>,
no_template: bool,
edit: bool,
ai_body: bool,
rerequest_review: bool,
squash: bool,
update_title: bool,
) -> Result<()> {
let repo = GitRepo::open()?;
let current = repo.current_branch()?;
let stack = Stack::load(&repo)?;
let config = Config::load()?;
let stack_links_mode = config.submit.stack_links;
let _ = yes;
let draft_flag_set = draft;
if matches!(scope, SubmitScope::Branch) && current == stack.trunk {
anyhow::bail!(
"Cannot submit trunk '{}' as a single branch.\n\
Checkout a tracked branch and run `stax branch submit`, or run `stax submit` for the whole stack.",
stack.trunk
);
}
let branches_to_submit = resolve_branches_for_scope(&stack, ¤t, scope);
if branches_to_submit.is_empty() {
if !quiet {
println!("{}", "No tracked branches to submit.".yellow());
}
return Ok(());
}
if !quiet {
println!("{} {}...", "Submitting".bold(), scope.label().bold());
}
let needs_restack: Vec<_> = branches_to_submit
.iter()
.filter(|b| {
stack
.branches
.get(*b)
.map(|br| br.needs_restack)
.unwrap_or(false)
})
.collect();
if !needs_restack.is_empty() && !quiet {
for b in &needs_restack {
println!(" {} {} needs restack", "!".yellow(), b.cyan());
}
}
let empty_branches: Vec<_> = branches_to_submit
.iter()
.filter(|b| {
if let Some(branch_info) = stack.branches.get(*b) {
if let Some(parent) = &branch_info.parent {
if let Ok(branch_commit) = repo.branch_commit(b) {
if let Ok(parent_commit) = repo.branch_commit(parent) {
return branch_commit == parent_commit;
}
}
}
}
false
})
.collect();
let empty_set: HashSet<_> = empty_branches.iter().cloned().collect();
if !empty_branches.is_empty() && !quiet {
println!(" {} Empty branches (will push, skip PR):", "!".yellow());
for b in &empty_branches {
println!(" {}", b.dimmed());
}
}
let remote_info = RemoteInfo::from_repo(&repo, &config)?;
let (fetch_summary, remote_branches) = if no_fetch {
if !quiet {
println!(
" {} {}",
"Skipping fetch".yellow(),
"(--no-fetch)".dimmed()
);
}
let rb = remote::get_remote_branches(repo.workdir()?, &remote_info.name)?
.into_iter()
.collect::<HashSet<_>>();
("skipped (--no-fetch)".to_string(), rb)
} else {
let refs = branches_to_fetch_for_submit(&repo, &stack, scope, &branches_to_submit)?;
let fetch_timer =
LiveTimer::maybe_new(!quiet, &format!("Fetching from {}...", remote_info.name));
let workdir = repo.workdir()?.to_path_buf();
let remote_name = remote_info.name.clone();
let refs_clone = refs.clone();
let wd_fetch = workdir.clone();
let rn_fetch = remote_name.clone();
let fetch_handle = std::thread::spawn(move || {
remote::fetch_remote_refs(&wd_fetch, &rn_fetch, &refs_clone)
});
let wd_ls = workdir;
let rn_ls = remote_name.clone();
let ls_handle = std::thread::spawn(move || remote::ls_remote_heads(&wd_ls, &rn_ls));
let fetch_res = fetch_handle
.join()
.map_err(|_| anyhow::anyhow!("submit fetch thread panicked"))?;
let ls_joined = ls_handle
.join()
.map_err(|_| anyhow::anyhow!("submit ls-remote thread panicked"))?;
let summary = match fetch_res {
Ok(()) => {
LiveTimer::maybe_finish_ok(fetch_timer, "done");
"ok".to_string()
}
Err(_) => {
LiveTimer::maybe_finish_warn(fetch_timer, "skipped (continuing with local refs)");
"failed (continued with cached refs)".to_string()
}
};
let rb = match ls_joined {
Ok(set) => set,
Err(_) => remote::get_remote_branches(repo.workdir()?, &remote_info.name)?
.into_iter()
.collect(),
};
(summary, rb)
};
if !remote_branches.contains(&stack.trunk) {
if no_fetch {
anyhow::bail!(
"Base branch '{}' was not found in cached ref '{}/{}'.\n\
You used --no-fetch, so stax did not refresh remote refs.\n\n\
Try one of:\n \
- Run without --no-fetch\n \
- git fetch {} {}\n",
stack.trunk,
remote_info.name,
stack.trunk,
remote_info.name,
stack.trunk
);
} else {
anyhow::bail!(
"Base branch '{}' does not exist on the remote.\n\n\
This can happen if:\n \
- This is a new repository that hasn't been pushed yet\n \
- The default branch has a different name on the remote\n\n\
To fix this, push your base branch first:\n \
git push -u {} {}",
stack.trunk,
remote_info.name,
stack.trunk
);
}
}
if matches!(scope, SubmitScope::Branch | SubmitScope::Upstack) {
validate_narrow_scope_submit(
scope,
&repo,
&stack,
¤t,
&remote_info.name,
&branches_to_submit,
no_fetch,
)?;
}
let planning_timer = LiveTimer::maybe_new(!quiet, "Planning PR operations...");
let planning_started_at = Instant::now();
let mut timings = SubmitPhaseTimings::default();
let mut full_scan_fallbacks = 0usize;
let mut plans: Vec<PrPlan> = Vec::new();
let mut rt: Option<tokio::runtime::Runtime> = None;
let client: Option<ForgeClient>;
if no_pr {
let runtime = tokio::runtime::Runtime::new().ok();
let _enter = runtime.as_ref().map(|rt| rt.enter());
let forge_client = ForgeClient::new(&remote_info).ok();
client = forge_client.clone();
let mut open_prs_by_head: Option<HashMap<String, PrInfoWithHead>> = None;
for branch in &branches_to_submit {
let mut meta = BranchMetadata::read(repo.inner(), branch)?
.context(format!("No metadata for branch {}", branch))?;
let is_empty = empty_set.contains(branch);
let needs_push = branch_needs_push(repo.workdir()?, &remote_info.name, branch);
let mut existing_pr = None;
let had_metadata_pr = meta.pr_info.as_ref().filter(|p| p.number > 0).is_some();
if !is_empty {
if let (Some(runtime), Some(forge_client)) =
(runtime.as_ref(), forge_client.as_ref())
{
let mut found_pr: Option<PrInfoWithHead> = None;
if let Some(pr_info) = meta.pr_info.as_ref().filter(|p| p.number > 0) {
let lookup_started_at = Instant::now();
found_pr = runtime
.block_on(async { forge_client.get_pr_with_head(pr_info.number).await })
.ok();
timings.open_pr_discovery += lookup_started_at.elapsed();
}
if found_pr.is_none() {
let lookup_started_at = Instant::now();
found_pr = runtime
.block_on(async { forge_client.find_open_pr_by_head(branch).await })
.ok()
.flatten();
timings.open_pr_discovery += lookup_started_at.elapsed();
}
if found_pr.is_none() && (had_metadata_pr || remote_branches.contains(branch)) {
full_scan_fallbacks += 1;
if verbose && !quiet {
println!(
" Falling back to full open PR scan for {} (metadata mismatch)",
branch.cyan()
);
}
if open_prs_by_head.is_none() {
let lookup_started_at = Instant::now();
open_prs_by_head = runtime
.block_on(async { forge_client.list_open_prs_by_head().await })
.ok();
timings.open_pr_discovery += lookup_started_at.elapsed();
if verbose && !quiet {
if let Some(map) = &open_prs_by_head {
println!(" Cached {} open PRs", map.len());
}
}
}
if let Some(map) = &open_prs_by_head {
found_pr = map.get(branch).cloned();
}
}
if let Some(pr) = found_pr {
existing_pr = Some(pr.info.number);
let owner_matches = pr
.head_label
.as_ref()
.and_then(|label| label.split_once(':').map(|(owner, _)| owner))
.map(|owner| owner == remote_info.owner())
.unwrap_or(false);
let needs_meta_update = meta
.pr_info
.as_ref()
.map(|info| {
info.number != pr.info.number
|| info.state != pr.info.state
|| info.is_draft.unwrap_or(false) != pr.info.is_draft
})
.unwrap_or(true);
if needs_meta_update && owner_matches {
meta = BranchMetadata {
pr_info: Some(crate::engine::metadata::PrInfo {
number: pr.info.number,
state: pr.info.state.clone(),
is_draft: Some(pr.info.is_draft),
}),
..meta
};
meta.write(repo.inner(), branch)?;
}
}
}
}
plans.push(PrPlan {
branch: branch.clone(),
parent: meta.parent_branch_name,
existing_pr,
existing_pr_is_draft: None,
tip_commit_subject: None,
needs_title_update: false,
title: None,
body: None,
is_draft: None,
needs_push,
needs_pr_update: false,
is_empty,
});
}
} else {
let runtime = tokio::runtime::Runtime::new()?;
let _enter = runtime.enter();
let forge_client = ForgeClient::new(&remote_info)?;
let mut open_prs_by_head: Option<HashMap<String, PrInfoWithHead>> = None;
for branch in &branches_to_submit {
let meta = BranchMetadata::read(repo.inner(), branch)?
.context(format!("No metadata for branch {}", branch))?;
let is_empty = empty_set.contains(branch);
let had_metadata_pr = meta.pr_info.as_ref().filter(|p| p.number > 0).is_some();
let mut existing_pr: Option<PrInfoWithHead> = None;
if !is_empty {
if verbose && !quiet {
println!(" Checking PR for {}", branch.cyan());
}
if let Some(pr_info) = meta.pr_info.as_ref().filter(|p| p.number > 0) {
if verbose && !quiet {
println!(" Using metadata PR #{}", pr_info.number);
}
let lookup_started_at = Instant::now();
match runtime
.block_on(async { forge_client.get_pr_with_head(pr_info.number).await })
{
Ok(pr) => {
let state = pr.info.state.to_ascii_lowercase();
if pr.head == *branch && matches!(state.as_str(), "open" | "opened") {
existing_pr = Some(pr);
} else if verbose && !quiet {
println!(
" PR #{} head '{}' does not match '{}', trying head lookup",
pr_info.number, pr.head, branch
);
}
}
Err(_) => {
if verbose && !quiet {
println!(
" Failed to fetch PR #{} from metadata, trying head lookup",
pr_info.number
);
}
}
}
timings.open_pr_discovery += lookup_started_at.elapsed();
}
if existing_pr.is_none() {
let lookup_started_at = Instant::now();
existing_pr = runtime
.block_on(async { forge_client.find_open_pr_by_head(branch).await })?;
timings.open_pr_discovery += lookup_started_at.elapsed();
if verbose && !quiet {
if let Some(found) = &existing_pr {
println!(" Found open PR #{} via head lookup", found.info.number);
} else {
println!(" No open PR found via head lookup");
}
}
}
if existing_pr.is_none() && (had_metadata_pr || remote_branches.contains(branch)) {
full_scan_fallbacks += 1;
if verbose && !quiet {
println!(" Falling back to full open PR scan (metadata mismatch)");
}
if open_prs_by_head.is_none() {
let lookup_started_at = Instant::now();
let prs = runtime
.block_on(async { forge_client.list_open_prs_by_head().await })?;
timings.open_pr_discovery += lookup_started_at.elapsed();
if verbose && !quiet {
println!(" Cached {} open PRs", prs.len());
}
open_prs_by_head = Some(prs);
}
if let Some(map) = &open_prs_by_head {
existing_pr = map.get(branch).cloned();
if verbose && !quiet {
if let Some(found) = &existing_pr {
println!(
" Found open PR #{} in fallback list",
found.info.number
);
} else {
println!(" No open PR found in fallback list");
}
}
}
}
} else if verbose && !quiet {
println!(" Empty branch {}, skipping PR lookup", branch.cyan());
}
let pr_number = existing_pr.as_ref().map(|p| p.info.number);
if let Some(pr) = &existing_pr {
let owner_matches = pr
.head_label
.as_ref()
.and_then(|label| label.split_once(':').map(|(owner, _)| owner))
.map(|owner| owner == remote_info.owner())
.unwrap_or(false);
let needs_meta_update = meta
.pr_info
.as_ref()
.map(|info| {
info.number != pr.info.number
|| info.state != pr.info.state
|| info.is_draft.unwrap_or(false) != pr.info.is_draft
})
.unwrap_or(true);
if needs_meta_update && owner_matches {
let updated_meta = BranchMetadata {
pr_info: Some(crate::engine::metadata::PrInfo {
number: pr.info.number,
state: pr.info.state.clone(),
is_draft: Some(pr.info.is_draft),
}),
..meta.clone()
};
updated_meta.write(repo.inner(), branch)?;
if verbose && !quiet {
println!(" Cached PR #{} in metadata", pr.info.number);
}
} else if needs_meta_update && verbose && !quiet {
println!(
" Skipped caching PR #{} (fork or unknown owner)",
pr.info.number
);
}
}
let base = meta.parent_branch_name.clone();
let needs_push = branch_needs_push(repo.workdir()?, &remote_info.name, branch);
let needs_pr_update = if is_empty {
false
} else if let Some(pr) = &existing_pr {
pr.info.base != base || needs_push
} else {
true };
let tip_commit_subject = if update_title && pr_number.is_some() && !is_empty {
tip_commit_subject(repo.workdir()?, branch)
} else {
None
};
let needs_title_update = update_title
&& existing_pr
.as_ref()
.zip(tip_commit_subject.as_ref())
.map(|(pr, commit_subject)| pr.title != *commit_subject)
.unwrap_or(false);
plans.push(PrPlan {
branch: branch.clone(),
parent: base,
existing_pr: pr_number,
existing_pr_is_draft: existing_pr.as_ref().map(|pr| pr.info.is_draft),
tip_commit_subject,
needs_title_update,
title: None,
body: None,
is_draft: None,
needs_push,
needs_pr_update,
is_empty,
});
}
rt = Some(runtime);
client = Some(forge_client);
}
timings.planning = planning_started_at.elapsed();
LiveTimer::maybe_finish_ok(planning_timer, "done");
let creates: Vec<_> = plans
.iter()
.filter(|p| p.existing_pr.is_none() && !p.is_empty)
.collect();
let updates: Vec<_> = plans
.iter()
.filter(|p| p.existing_pr.is_some() && p.needs_pr_update && !p.is_empty)
.collect();
let noops: Vec<_> = plans
.iter()
.filter(|p| p.existing_pr.is_some() && !p.needs_pr_update && !p.needs_push && !p.is_empty)
.collect();
if !quiet {
if !creates.is_empty() {
println!(
" {} {} {} to create",
creates.len().to_string().cyan(),
"â–¸".dimmed(),
if creates.len() == 1 { "PR" } else { "PRs" }
);
}
if !updates.is_empty() {
println!(
" {} {} {} to update",
updates.len().to_string().cyan(),
"â–¸".dimmed(),
if updates.len() == 1 { "PR" } else { "PRs" }
);
}
if !noops.is_empty() {
println!(
" {} {} {} already up to date",
noops.len().to_string().dimmed(),
"â–¸".dimmed(),
if noops.len() == 1 { "PR" } else { "PRs" }
);
}
}
if !no_pr {
let discovered_templates = if no_template {
Vec::new()
} else {
discover_pr_templates(repo.workdir()?).unwrap_or_default()
};
let new_prs: Vec<_> = plans
.iter()
.filter(|p| p.existing_pr.is_none() && !p.is_empty)
.collect();
if !new_prs.is_empty() && !quiet {
println!();
println!("{}", "New PR details:".bold());
}
for plan in &mut plans {
if plan.existing_pr.is_some() || plan.is_empty {
continue;
}
let selected_template = if no_template {
None
} else if let Some(ref template_name) = template {
let found = discovered_templates
.iter()
.find(|t| t.name == *template_name)
.cloned();
if found.is_none() && !quiet {
eprintln!(
" {} Template '{}' not found, using no template",
"!".yellow(),
template_name
);
}
found
} else if no_prompt {
if discovered_templates.len() == 1 {
Some(discovered_templates[0].clone())
} else {
None
}
} else {
select_template_interactive(&discovered_templates)?
};
let commit_messages =
collect_commit_messages(repo.workdir()?, &plan.parent, &plan.branch);
let default_title = default_pr_title(&commit_messages, &plan.branch);
let template_content = selected_template.as_ref().map(|t| t.content.as_str());
let default_body =
build_default_pr_body(template_content, &plan.branch, &commit_messages);
if !quiet {
println!(" {}", plan.branch.cyan());
}
let title = if no_prompt {
default_title
} else {
Input::with_theme(&ColorfulTheme::default())
.with_prompt(" Title")
.default(default_title)
.interact_text()?
};
let body = if ai_body {
if !quiet {
println!(" {}", "Generating PR body with AI...".dimmed());
}
let ai_body_result = generate_ai_body(
repo.workdir()?,
&plan.parent,
&plan.branch,
template_content,
);
match ai_body_result {
Ok(generated) => {
if edit {
Editor::new().edit(&generated)?.unwrap_or(generated)
} else {
generated
}
}
Err(e) => {
if !quiet {
eprintln!(
" {} AI generation failed: {}. Falling back to default.",
"âš ".yellow(),
e
);
}
default_body
}
}
} else if no_prompt {
default_body
} else if edit {
Editor::new().edit(&default_body)?.unwrap_or(default_body)
} else {
let options = if default_body.trim().is_empty() {
vec!["Edit", "Skip (leave empty)"]
} else {
vec!["Use default", "Edit", "Skip (leave empty)"]
};
let choice = Select::with_theme(&ColorfulTheme::default())
.with_prompt(" Body")
.items(&options)
.default(0)
.interact()?;
match options[choice] {
"Use default" => default_body,
"Edit" => Editor::new().edit(&default_body)?.unwrap_or(default_body),
_ => String::new(),
}
};
let is_draft = if let Some(is_draft) =
resolve_is_draft_without_prompt(draft_flag_set, publish, draft, no_prompt)
{
is_draft
} else {
let choice = Select::with_theme(&ColorfulTheme::default())
.with_prompt(" PR type")
.items(PR_TYPE_OPTIONS)
.default(PR_TYPE_DEFAULT_INDEX)
.interact()?;
choice == PR_TYPE_DEFAULT_INDEX
};
plan.title = Some(title);
plan.body = Some(body);
plan.is_draft = Some(is_draft);
}
}
let branches_needing_push: Vec<_> = plans.iter().filter(|p| p.needs_push).collect();
let mut tx = if !branches_needing_push.is_empty() {
let mut tx = Transaction::begin(OpKind::Submit, &repo, quiet)?;
let branch_names: Vec<String> = branches_needing_push
.iter()
.map(|p| p.branch.clone())
.collect();
tx.plan_branches(&repo, &branch_names)?;
for plan in &branches_needing_push {
tx.plan_remote_branch(&repo, &remote_info.name, &plan.branch)?;
}
let summary = PlanSummary {
branches_to_rebase: 0,
branches_to_push: branches_needing_push.len(),
description: vec![format!(
"Submit {} {}",
branches_needing_push.len(),
if branches_needing_push.len() == 1 {
"branch"
} else {
"branches"
}
)],
};
tx::print_plan(tx.kind(), &summary, quiet);
tx.set_plan_summary(summary);
tx.snapshot()?;
Some(tx)
} else {
None
};
if !branches_needing_push.is_empty() {
if !quiet {
println!();
println!("{}", "Pushing branches...".bold());
}
for plan in &branches_needing_push {
if squash {
if let Err(e) = squash_branch_commits(repo.workdir()?, &plan.branch, &plan.parent) {
if !quiet {
println!(" {} squash {}: {}", "âš ".yellow(), plan.branch, e);
}
}
}
let push_timer = LiveTimer::maybe_new(!quiet, &format!("Pushing {}...", plan.branch));
let local_oid = repo.branch_commit(&plan.branch).ok();
match push_branch(repo.workdir()?, &remote_info.name, &plan.branch) {
Ok(()) => {
if let Some(ref mut tx) = tx {
let _ = tx.record_after(&repo, &plan.branch);
if let Some(oid) = &local_oid {
tx.record_remote_after(&remote_info.name, &plan.branch, oid);
}
}
LiveTimer::maybe_finish_ok(push_timer, "done");
}
Err(e) => {
LiveTimer::maybe_finish_err(push_timer, "failed");
if let Some(tx) = tx {
tx.finish_err(
&format!("Push failed: {}", e),
Some("push"),
Some(&plan.branch),
)?;
}
return Err(e);
}
}
}
}
if no_pr {
if let Some(tx) = tx {
tx.finish_ok()?;
}
if !quiet {
println!();
println!("{}", "✓ Branches pushed successfully!".green().bold());
if verbose {
print_verbose_network_summary(
client.as_ref(),
&remote_info.name,
&fetch_summary,
&timings,
full_scan_fallbacks,
);
}
}
return Ok(());
}
let any_pr_work = plans
.iter()
.any(|p| !p.is_empty && (p.existing_pr.is_none() || p.needs_pr_update));
let any_existing_prs = plans.iter().any(|p| !p.is_empty && p.existing_pr.is_some());
if !any_pr_work && branches_needing_push.is_empty() && !any_existing_prs {
if !quiet {
println!();
println!("{}", "✓ Stack already up to date!".green().bold());
if verbose {
print_verbose_network_summary(
client.as_ref(),
&remote_info.name,
&fetch_summary,
&timings,
full_scan_fallbacks,
);
}
}
return Ok(());
}
if any_pr_work && !quiet {
println!();
println!("{}", "Processing PRs...".bold());
}
let rt = rt.context("Internal error: missing runtime for PR submission")?;
let client = client.context("Internal error: missing forge client for PR submission")?;
let (open_pr_url, async_timings, async_full_scan_fallbacks) = rt.block_on(async {
let mut pr_infos: Vec<StackPrInfo> = Vec::new();
let mut created_pr_numbers: HashSet<u64> = HashSet::new();
let mut async_timings = SubmitPhaseTimings::default();
let async_full_scan_fallbacks = 0usize;
let create_update_started_at = Instant::now();
for plan in &plans {
if plan.is_empty {
continue;
}
let meta = BranchMetadata::read(repo.inner(), &plan.branch)?
.context(format!("No metadata for branch {}", plan.branch))?;
let desired_draft_state = if draft {
Some(true)
} else if publish {
Some(false)
} else {
None
};
if let Some(existing_pr_number) = plan.existing_pr {
if plan.needs_pr_update {
let update_timer = LiveTimer::maybe_new(
!quiet,
&format!("Updating {} #{}...", plan.branch, existing_pr_number),
);
client
.update_pr_base(existing_pr_number, &plan.parent)
.await?;
if plan.needs_title_update {
if let Some(ref commit_subject) = plan.tip_commit_subject {
client
.update_pr_title(existing_pr_number, commit_subject)
.await?;
}
}
apply_pr_metadata(&client, existing_pr_number, &reviewers, &labels, &assignees)
.await?;
if let Some(is_draft) = desired_draft_state {
if plan.existing_pr_is_draft == Some(is_draft) {
let reason = if is_draft {
"already draft"
} else {
"already published"
};
if verbose && !quiet {
println!(
" Skipping draft toggle for #{} ({})",
existing_pr_number, reason
);
}
} else {
client.set_pr_draft(existing_pr_number, is_draft).await?;
}
}
if rerequest_review {
let existing_reviewers = client
.get_requested_reviewers(existing_pr_number)
.await
.unwrap_or_default();
if !existing_reviewers.is_empty() {
client
.request_reviewers(existing_pr_number, &existing_reviewers)
.await?;
}
}
LiveTimer::maybe_finish_ok(update_timer, "done");
let pr = client.get_pr(existing_pr_number).await?;
let updated_meta = BranchMetadata {
pr_info: Some(crate::engine::metadata::PrInfo {
number: pr.number,
state: pr.state.clone(),
is_draft: Some(pr.is_draft),
}),
..meta
};
updated_meta.write(repo.inner(), &plan.branch)?;
pr_infos.push(StackPrInfo {
branch: plan.branch.clone(),
pr_number: Some(pr.number),
});
} else {
if let Some(is_draft) = desired_draft_state {
let draft_timer = LiveTimer::maybe_new(
!quiet,
&format!(
"{} {} #{}...",
if is_draft {
"Converting to draft"
} else {
"Publishing"
},
plan.branch,
existing_pr_number,
),
);
if plan.existing_pr_is_draft == Some(is_draft) {
LiveTimer::maybe_finish_skipped(
draft_timer,
if is_draft {
"already draft"
} else {
"already published"
},
);
} else {
client.set_pr_draft(existing_pr_number, is_draft).await?;
LiveTimer::maybe_finish_ok(draft_timer, "done");
let pr = client.get_pr(existing_pr_number).await?;
let updated_meta = BranchMetadata {
pr_info: Some(crate::engine::metadata::PrInfo {
number: pr.number,
state: pr.state.clone(),
is_draft: Some(pr.is_draft),
}),
..meta
};
updated_meta.write(repo.inner(), &plan.branch)?;
}
}
if plan.needs_title_update {
let title_timer = LiveTimer::maybe_new(
!quiet,
&format!(
"Updating title for {} #{}...",
plan.branch, existing_pr_number
),
);
if let Some(ref commit_subject) = plan.tip_commit_subject {
client
.update_pr_title(existing_pr_number, commit_subject)
.await?;
}
LiveTimer::maybe_finish_ok(title_timer, "done");
}
pr_infos.push(StackPrInfo {
branch: plan.branch.clone(),
pr_number: Some(existing_pr_number),
});
}
} else {
let title = plan.title.as_ref().unwrap();
let body = plan.body.as_ref().unwrap();
let is_draft = plan.is_draft.unwrap_or(draft);
let create_timer =
LiveTimer::maybe_new(!quiet, &format!("Creating {}...", plan.branch));
let pr = client
.create_pr(&plan.branch, &plan.parent, title, body, is_draft)
.await
.context(format!(
"Failed to create PR for '{}' with base '{}'\n\
This may happen if:\n \
- The base branch '{}' doesn't exist on the remote\n \
- The branch has no commits different from base\n \
- API request timed out (check network/VPN and retry)\n \
Try: git log {}..{} to see the commits",
plan.branch, plan.parent, plan.parent, plan.parent, plan.branch
))?;
created_pr_numbers.insert(pr.number);
LiveTimer::maybe_finish_ok(
create_timer,
&format!("created {}", format!("#{}", pr.number).dimmed()),
);
let updated_meta = BranchMetadata {
pr_info: Some(crate::engine::metadata::PrInfo {
number: pr.number,
state: pr.state.clone(),
is_draft: Some(pr.is_draft),
}),
..meta
};
updated_meta.write(repo.inner(), &plan.branch)?;
apply_pr_metadata(&client, pr.number, &reviewers, &labels, &assignees).await?;
pr_infos.push(StackPrInfo {
branch: plan.branch.clone(),
pr_number: Some(pr.number),
});
}
}
async_timings.pr_create_update = create_update_started_at.elapsed();
let prs_with_numbers: Vec<_> = pr_infos
.iter()
.filter_map(|p| p.pr_number.map(|num| (num, p.branch.clone())))
.collect();
let stack_links_started_at = Instant::now();
for (pr_number, _branch) in &prs_with_numbers {
let sync_timer =
LiveTimer::maybe_new(!quiet, &format!("Syncing stack links on #{}...", pr_number));
let stack_links =
generate_stack_links_markdown(&pr_infos, *pr_number, &remote_info, &stack.trunk);
match stack_links_mode {
StackLinksMode::Comment | StackLinksMode::Both => {
if created_pr_numbers.contains(pr_number) {
client
.create_stack_comment(*pr_number, &stack_links)
.await?;
} else {
client
.update_stack_comment(*pr_number, &stack_links)
.await?;
}
}
StackLinksMode::Body | StackLinksMode::Off => {
client.delete_stack_comment(*pr_number).await?;
}
}
let current_body = client.get_pr_body(*pr_number).await?;
let desired_body = match stack_links_mode {
StackLinksMode::Body | StackLinksMode::Both => {
upsert_stack_links_in_body(¤t_body, &stack_links)
}
StackLinksMode::Comment | StackLinksMode::Off => {
remove_stack_links_from_body(¤t_body)
}
};
if desired_body != current_body {
client.update_pr_body(*pr_number, &desired_body).await?;
}
LiveTimer::maybe_finish_ok(sync_timer, "done");
}
async_timings.stack_links = stack_links_started_at.elapsed();
if !quiet {
println!();
println!("{}", "✓ Stack submitted!".green().bold());
if !pr_infos.is_empty() {
for pr_info in &pr_infos {
if let Some(num) = pr_info.pr_number {
println!(" {} {}", "✓".green(), remote_info.pr_url(num));
}
}
}
}
let open_pr_url = if open {
pr_infos
.iter()
.find(|pr_info| pr_info.branch == current)
.and_then(|pr_info| pr_info.pr_number)
.map(|num| remote_info.pr_url(num))
} else {
None
};
Ok::<(Option<String>, SubmitPhaseTimings, usize), anyhow::Error>((
open_pr_url,
async_timings,
async_full_scan_fallbacks,
))
})?;
timings.open_pr_discovery += async_timings.open_pr_discovery;
timings.pr_create_update += async_timings.pr_create_update;
timings.stack_links += async_timings.stack_links;
full_scan_fallbacks += async_full_scan_fallbacks;
if let Some(pr_url) = open_pr_url {
if !quiet {
println!("Opening {} in browser...", pr_url.cyan());
}
open_url_in_browser(&pr_url);
} else if open && !quiet {
eprintln!(
" {} No PR found for current branch {}; nothing to open.",
"!".yellow(),
current.cyan()
);
}
if let Some(tx) = tx {
tx.finish_ok()?;
}
if verbose && !quiet {
print_verbose_network_summary(
Some(&client),
&remote_info.name,
&fetch_summary,
&timings,
full_scan_fallbacks,
);
}
Ok(())
}
fn squash_branch_commits(workdir: &Path, branch: &str, base: &str) -> Result<()> {
let output = Command::new("git")
.args(["rev-list", "--count", &format!("{}..{}", base, branch)])
.current_dir(workdir)
.output()
.context("Failed to count commits")?;
let count: usize = String::from_utf8_lossy(&output.stdout)
.trim()
.parse()
.unwrap_or(0);
if count <= 1 {
return Ok(()); }
let current_output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(workdir)
.output()?;
let current = String::from_utf8_lossy(¤t_output.stdout)
.trim()
.to_string();
let msg_output = Command::new("git")
.args([
"log",
"--format=%s",
"--reverse",
&format!("{}..{}", base, branch),
])
.current_dir(workdir)
.output()?;
let first_msg = String::from_utf8_lossy(&msg_output.stdout)
.lines()
.next()
.unwrap_or(branch)
.to_string();
let _ = Command::new("git")
.args(["checkout", branch])
.current_dir(workdir)
.output();
let reset = Command::new("git")
.args(["reset", "--soft", base])
.current_dir(workdir)
.status()?;
if !reset.success() {
let _ = Command::new("git")
.args(["checkout", ¤t])
.current_dir(workdir)
.output();
anyhow::bail!("Failed to soft-reset {} to {}", branch, base);
}
let commit = Command::new("git")
.args(["commit", "-m", &first_msg])
.current_dir(workdir)
.status()?;
if !commit.success() {
let _ = Command::new("git")
.args(["checkout", ¤t])
.current_dir(workdir)
.output();
anyhow::bail!("Failed to commit squashed changes on {}", branch);
}
if current != branch {
let _ = Command::new("git")
.args(["checkout", ¤t])
.current_dir(workdir)
.output();
}
Ok(())
}
fn push_branch(workdir: &std::path::Path, remote: &str, branch: &str) -> Result<()> {
let status = Command::new("git")
.args(["push", "--force-with-lease", "-u", remote, branch])
.current_dir(workdir)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.context("Failed to push branch")?;
if !status.success() {
anyhow::bail!("Failed to push branch {}", branch);
}
Ok(())
}
fn resolve_branches_for_scope(stack: &Stack, current: &str, scope: SubmitScope) -> Vec<String> {
let branches = match scope {
SubmitScope::Stack => stack.current_stack(current),
SubmitScope::Downstack => {
let mut ancestors = stack.ancestors(current);
ancestors.reverse();
ancestors.push(current.to_string());
ancestors
}
SubmitScope::Upstack => {
let mut upstack = vec![current.to_string()];
upstack.extend(stack.descendants(current));
upstack
}
SubmitScope::Branch => vec![current.to_string()],
};
branches
.into_iter()
.filter(|branch| branch != &stack.trunk)
.collect()
}
fn branches_to_fetch_for_submit(
repo: &GitRepo,
stack: &Stack,
scope: SubmitScope,
branches_to_submit: &[String],
) -> Result<Vec<String>> {
let mut names = BTreeSet::new();
names.insert(stack.trunk.clone());
for b in branches_to_submit {
names.insert(b.clone());
}
if matches!(scope, SubmitScope::Branch | SubmitScope::Upstack) {
let submitted: HashSet<&str> = branches_to_submit.iter().map(String::as_str).collect();
for branch in branches_to_submit {
let meta = BranchMetadata::read(repo.inner(), branch)?
.with_context(|| format!("No metadata for branch {}", branch))?;
let parent = &meta.parent_branch_name;
if parent == &stack.trunk || submitted.contains(parent.as_str()) {
continue;
}
names.insert(parent.clone());
}
}
Ok(names.into_iter().collect())
}
fn validate_narrow_scope_submit(
scope: SubmitScope,
repo: &GitRepo,
stack: &Stack,
current: &str,
remote_name: &str,
branches_to_submit: &[String],
no_fetch: bool,
) -> Result<()> {
if matches!(scope, SubmitScope::Branch) && current == stack.trunk {
anyhow::bail!(
"Cannot submit trunk '{}' as a single branch.\n\
Checkout a tracked branch and run `stax branch submit`, or run `stax submit` for the whole stack.",
stack.trunk
);
}
let current_meta = BranchMetadata::read(repo.inner(), current)?;
if current != stack.trunk && current_meta.is_none() {
anyhow::bail!(
"Branch '{}' is not tracked by stax.\n\
Use `stax branch track --parent <branch>` (or `stax branch reparent`) and retry.",
current
);
}
let submitted: HashSet<&str> = branches_to_submit.iter().map(String::as_str).collect();
for branch in branches_to_submit {
let meta = BranchMetadata::read(repo.inner(), branch)?
.context(format!("No metadata for branch {}", branch))?;
let parent = meta.parent_branch_name;
if parent == stack.trunk || submitted.contains(parent.as_str()) {
continue;
}
let needs_restack = stack
.branches
.get(branch)
.map(|b| b.needs_restack)
.unwrap_or(false);
if needs_restack {
anyhow::bail!(
"Branch '{}' needs restack before scoped submit.\n\
Run `stax restack` or submit with ancestor scope: `stax downstack submit` / `stax submit`.",
branch
);
}
if !branch_matches_remote(repo.workdir()?, remote_name, &parent) {
if no_fetch {
anyhow::bail!(
"Parent branch '{}' is not in sync with cached '{}/{}'.\n\
You used --no-fetch, so cached refs may be stale.\n\
Try rerunning without --no-fetch, or run `git fetch {}` first.\n\
Narrow scope submit for '{}' is unsafe while parent appears out-of-sync.",
parent,
remote_name,
parent,
remote_name,
branch
);
} else {
anyhow::bail!(
"Parent branch '{}' is not in sync with '{}/{}'.\n\
Narrow scope submit for '{}' is unsafe because its parent is excluded.\n\
Run `stax downstack submit` or `stax submit` to include ancestors first.",
parent,
remote_name,
parent,
branch
);
}
}
}
Ok(())
}
fn branch_needs_push(workdir: &Path, remote: &str, branch: &str) -> bool {
let local = Command::new("git")
.args(["rev-parse", branch])
.current_dir(workdir)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
let remote_ref = format!("{}/{}", remote, branch);
let remote_commit = Command::new("git")
.args(["rev-parse", &remote_ref])
.current_dir(workdir)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
match (local, remote_commit) {
(Some(l), Some(r)) => l != r, (Some(_), None) => true, _ => true, }
}
fn branch_matches_remote(workdir: &Path, remote: &str, branch: &str) -> bool {
let local = Command::new("git")
.args(["rev-parse", branch])
.current_dir(workdir)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
let remote_ref = format!("{}/{}", remote, branch);
let remote_commit = Command::new("git")
.args(["rev-parse", &remote_ref])
.current_dir(workdir)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
match (local, remote_commit) {
(Some(l), Some(r)) => l == r,
_ => false,
}
}
fn tip_commit_subject(workdir: &Path, branch: &str) -> Option<String> {
Command::new("git")
.args(["log", "-1", "--format=%s", branch])
.current_dir(workdir)
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| {
let s = String::from_utf8_lossy(&o.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
})
}
fn collect_commit_messages(workdir: &Path, parent: &str, branch: &str) -> Vec<String> {
let output = Command::new("git")
.args([
"log",
"--reverse",
"--format=%s",
&format!("{}..{}", parent, branch),
])
.current_dir(workdir)
.output();
match output {
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout)
.lines()
.map(|line| line.trim().to_string())
.filter(|line| !line.is_empty())
.collect(),
_ => Vec::new(),
}
}
fn default_pr_title(commit_messages: &[String], branch: &str) -> String {
if let Some(first) = commit_messages.first() {
return first.clone();
}
branch
.split('/')
.next_back()
.unwrap_or(branch)
.replace(['-', '_'], " ")
}
fn build_default_pr_body(
template: Option<&str>,
branch: &str,
commit_messages: &[String],
) -> String {
let commits_text = render_commit_list(commit_messages);
let mut body = if let Some(template) = template {
template.to_string()
} else if commits_text.is_empty() {
String::new()
} else {
format!("## Summary\n\n{}", commits_text)
};
if !body.is_empty() {
body = body.replace("{{BRANCH}}", branch);
body = body.replace("{{COMMITS}}", &commits_text);
}
body
}
fn render_commit_list(commit_messages: &[String]) -> String {
if commit_messages.is_empty() {
return String::new();
}
commit_messages
.iter()
.map(|msg| format!("- {}", msg))
.collect::<Vec<_>>()
.join("\n")
}
#[allow(dead_code)]
fn load_pr_template(workdir: &Path) -> Option<String> {
let candidates = [
".github/pull_request_template.md",
".github/PULL_REQUEST_TEMPLATE.md",
"PULL_REQUEST_TEMPLATE.md",
"pull_request_template.md",
];
for candidate in &candidates {
let path = workdir.join(candidate);
if path.is_file() {
if let Ok(content) = fs::read_to_string(path) {
return Some(content);
}
}
}
let dir = workdir.join(".github").join("pull_request_template");
if dir.is_dir() {
let mut entries: Vec<_> = fs::read_dir(dir)
.ok()?
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.path()
.extension()
.map(|ext| ext == "md")
.unwrap_or(false)
})
.collect();
entries.sort_by_key(|entry| entry.path());
if let Some(entry) = entries.first() {
if let Ok(content) = fs::read_to_string(entry.path()) {
return Some(content);
}
}
}
None
}
async fn apply_pr_metadata(
client: &ForgeClient,
pr_number: u64,
reviewers: &[String],
labels: &[String],
assignees: &[String],
) -> Result<()> {
if !reviewers.is_empty() {
client.request_reviewers(pr_number, reviewers).await?;
}
if !labels.is_empty() {
client.add_labels(pr_number, labels).await?;
}
if !assignees.is_empty() {
client.add_assignees(pr_number, assignees).await?;
}
Ok(())
}
fn print_verbose_network_summary(
client: Option<&ForgeClient>,
remote_name: &str,
fetch_summary: &str,
timings: &SubmitPhaseTimings,
full_scan_fallbacks: usize,
) {
println!();
println!("{}", "Verbose network summary:".bold());
println!(
" {:<28} {}",
format!("git fetch {}", remote_name),
fetch_summary
);
if let Some(stats) = client.and_then(|client| client.api_call_stats()) {
println!(
" {:<28} {}",
"forge.api.total",
stats.total_requests.to_string().cyan()
);
if stats.by_operation.is_empty() {
println!(" {}", "No forge API requests recorded".dimmed());
} else {
for (operation, count) in stats.by_operation {
println!(" {:<28} {}", operation, count);
}
}
} else {
println!(" {:<28} {}", "forge.api.total", "0".cyan());
println!(" {}", "No API stats available".dimmed());
}
println!();
println!("{}", "Phase timings:".bold());
println!(" {:<28} {}", "planning", format_duration(timings.planning));
println!(
" {:<28} {}",
"open PR discovery",
format_duration(timings.open_pr_discovery)
);
println!(
" {:<28} {}",
"create/update PRs",
format_duration(timings.pr_create_update)
);
println!(
" {:<28} {}",
"stack links",
format_duration(timings.stack_links)
);
println!(
" {:<28} {}",
"full-scan fallbacks",
full_scan_fallbacks.to_string().cyan()
);
}
fn format_duration(duration: Duration) -> String {
if duration.as_secs_f64() < 0.001 {
"0.000s".to_string()
} else {
format!("{:.3}s", duration.as_secs_f64())
}
}
fn generate_ai_body(
workdir: &Path,
parent: &str,
branch: &str,
template: Option<&str>,
) -> Result<String> {
use super::generate;
let config = Config::load()?;
let agent = config
.ai
.agent_for("generate")
.context(
"No AI agent configured. Run `stax generate --pr-body` first to set up, \
or add [ai] agent = \"claude\" (or \"codex\" / \"gemini\" / \"opencode\") to ~/.config/stax/config.toml",
)?
.to_string();
let model = config.ai.model_for("generate").map(String::from);
generate::print_using_agent(&agent, model.as_deref());
let diff_stat = generate::get_diff_stat(workdir, parent, branch);
let diff = generate::get_full_diff(workdir, parent, branch);
let commits = collect_commit_messages(workdir, parent, branch);
let prompt = generate::build_ai_prompt(&diff_stat, &diff, &commits, template);
generate::invoke_ai_agent(&agent, model.as_deref(), &prompt)
}
#[cfg(test)]
mod tests {
use super::{resolve_is_draft_without_prompt, PR_TYPE_DEFAULT_INDEX, PR_TYPE_OPTIONS};
#[test]
fn no_prompt_defaults_to_draft() {
assert_eq!(
resolve_is_draft_without_prompt(false, false, false, true),
Some(true)
);
}
#[test]
fn explicit_draft_flag_still_forces_draft() {
assert_eq!(
resolve_is_draft_without_prompt(true, false, true, false),
Some(true)
);
}
#[test]
fn explicit_no_draft_flag_still_requires_prompt() {
assert_eq!(
resolve_is_draft_without_prompt(false, false, false, false),
None
);
}
#[test]
fn publish_flag_forces_non_draft() {
assert_eq!(
resolve_is_draft_without_prompt(false, true, false, false),
Some(false)
);
}
#[test]
fn publish_flag_overrides_no_prompt_default() {
assert_eq!(
resolve_is_draft_without_prompt(false, true, false, true),
Some(false)
);
}
#[test]
fn interactive_default_option_is_draft() {
assert_eq!(PR_TYPE_DEFAULT_INDEX, 0);
assert_eq!(PR_TYPE_OPTIONS[PR_TYPE_DEFAULT_INDEX], "Create as draft");
}
}