pub(crate) use crate::application::SubmitScope;
use crate::application::submit::{
PushSpec, TemporaryPublishRefs, TemporarySubmitWorktree, push_branches,
};
use crate::commands::open::open_url_in_browser;
use crate::config::{
Config, NativeStackMode, SingleStackMode, StackLinksMode, StackLinksWhenNative,
};
use crate::engine::{BranchMetadata, Stack};
use crate::forge::ForgeClient;
use crate::git::GitRepo;
use crate::github::gh_stack::{self, ExtensionStatus, FeatureState, LinkOutcome};
use crate::github::pr::{
PrInfoWithHead, StackPrInfo, generate_stack_links_markdown, is_native_stack_base_locked_error,
remove_stack_links_from_body, upsert_stack_links_in_body,
};
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, ForgeType, RemoteInfo};
use anyhow::{Context, Result};
use colored::Colorize;
use dialoguer::{Editor, Input, Select, theme::ColorfulTheme};
use futures_util::future::join_all;
use serde::Deserialize;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::fs;
use std::path::Path;
use std::process::Command;
use std::time::{Duration, Instant};
#[derive(Debug, Default)]
pub struct SubmitOptions {
pub dry_run: bool,
pub json: bool,
pub draft: bool,
pub publish: bool,
pub no_pr: bool,
pub no_fetch: bool,
pub prefetched: bool,
pub no_verify: bool,
pub force: bool,
pub yes: bool,
pub no_prompt: bool,
pub reviewers: Vec<String>,
pub labels: Vec<String>,
pub assignees: Vec<String>,
pub quiet: bool,
pub open: bool,
pub verbose: bool,
pub template: Option<String>,
pub no_template: bool,
pub edit: bool,
pub ai: bool,
pub title: bool,
pub body: bool,
pub rerequest_review: bool,
pub native_stack_override: Option<NativeStackMode>,
pub squash: bool,
pub update_title: bool,
}
struct PrPlan {
branch: String,
parent: String,
commit_range_base: String,
publish_ref: String,
publish_oid: Option<String>,
uses_temporary_publish_ref: bool,
remote_oid_after_fetch: Option<String>,
existing_pr: Option<u64>,
existing_pr_is_draft: Option<bool>,
tip_commit_subject: Option<String>,
needs_title_update: bool,
existing_pr_title: Option<String>,
title: Option<String>,
body: Option<String>,
ai_title_update: Option<String>,
generated_body_update: Option<String>,
is_draft: Option<bool>,
needs_push: bool,
needs_pr_update: bool,
needs_base_update: bool,
is_empty: bool,
is_imported: bool,
}
struct ExistingPrLookup {
branch: String,
existing_pr: Option<PrInfoWithHead>,
needs_full_scan_fallback: bool,
}
#[derive(Debug, Clone)]
struct PublishSource {
source_ref: String,
commit_range_base: String,
oid: Option<String>,
is_temporary: 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;
const MAX_AI_DIFF_BYTES: usize = 80_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct AiPrTargets {
title: bool,
body: bool,
explicit_scope: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct AiPrDetails {
title: Option<String>,
body: Option<String>,
}
#[derive(Debug, Clone)]
struct AiAgentSelection {
agent: String,
model: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawAiPrDetails {
title: Option<String>,
body: Option<String>,
}
fn resolve_ai_targets(
ai: bool,
title: bool,
body: bool,
update_title: bool,
) -> Result<Option<AiPrTargets>> {
if !ai {
if title {
anyhow::bail!("--title requires --ai");
}
if body {
anyhow::bail!("--body requires --ai");
}
return Ok(None);
}
let explicit_scope = title || body;
let targets = AiPrTargets {
title: if explicit_scope { title } else { true },
body: if explicit_scope { body } else { true },
explicit_scope,
};
if update_title && targets.title {
anyhow::bail!("--update-title cannot be combined with AI title generation");
}
Ok(Some(targets))
}
fn existing_ai_targets_for_auto_accept(targets: AiPrTargets) -> Option<AiPrTargets> {
targets.explicit_scope.then_some(targets)
}
fn parse_ai_pr_details(raw: &str, targets: AiPrTargets) -> Result<AiPrDetails> {
let json = extract_ai_json(raw);
let parsed: RawAiPrDetails = serde_json::from_str(&json)
.context("AI agent did not return JSON PR details with title/body fields")?;
let title = parsed.title.and_then(non_empty_trimmed);
let body = parsed.body.and_then(non_empty_trimmed);
if targets.title && title.is_none() {
anyhow::bail!("AI agent did not return a non-empty title");
}
if targets.body && body.is_none() {
anyhow::bail!("AI agent did not return a non-empty body");
}
Ok(AiPrDetails {
title: targets.title.then_some(title).flatten(),
body: targets.body.then_some(body).flatten(),
})
}
fn non_empty_trimmed(value: String) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn extract_ai_json(raw: &str) -> String {
let trimmed = raw.trim();
let unfenced = if trimmed.starts_with("```") {
let without_opening = trimmed.lines().skip(1).collect::<Vec<_>>().join("\n");
without_opening
.trim()
.strip_suffix("```")
.unwrap_or(without_opening.trim())
.trim()
.to_string()
} else {
trimmed.to_string()
};
if unfenced.starts_with('{') && unfenced.ends_with('}') {
return unfenced;
}
match (unfenced.find('{'), unfenced.rfind('}')) {
(Some(start), Some(end)) if start < end => unfenced[start..=end].to_string(),
_ => unfenced,
}
}
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(dead_code)]
pub(crate) trait SubmitPrompter {
fn answer(
&mut self,
request: &crate::application::SubmitPromptRequest,
) -> Result<crate::application::SubmitPromptAnswer>;
}
#[allow(dead_code)]
pub(crate) trait DefaultSubmitBackend {
type Prepared;
fn prepare(
&mut self,
options: crate::application::SubmitOptions,
reporter: &mut dyn crate::application::OperationReporter,
) -> Result<Self::Prepared>;
fn execute(
&mut self,
prepared: Self::Prepared,
answers: Vec<crate::application::SubmitPromptAnswer>,
reporter: &mut dyn crate::application::OperationReporter,
) -> Result<crate::application::OperationReceipt>;
}
#[allow(dead_code)]
struct RepositorySubmitBackend<'a> {
session: &'a crate::application::RepositorySession,
}
impl DefaultSubmitBackend for RepositorySubmitBackend<'_> {
type Prepared = crate::application::PreparedSubmit;
fn prepare(
&mut self,
options: crate::application::SubmitOptions,
reporter: &mut dyn crate::application::OperationReporter,
) -> Result<Self::Prepared> {
self.session
.prepare_submit(options, reporter)
.map_err(Into::into)
}
fn execute(
&mut self,
prepared: Self::Prepared,
answers: Vec<crate::application::SubmitPromptAnswer>,
reporter: &mut dyn crate::application::OperationReporter,
) -> Result<crate::application::OperationReceipt> {
self.session
.execute_prepared_submit(prepared, answers, reporter)
.map_err(Into::into)
}
}
#[allow(dead_code)]
struct TerminalSubmitPrompter;
impl SubmitPrompter for TerminalSubmitPrompter {
fn answer(
&mut self,
request: &crate::application::SubmitPromptRequest,
) -> Result<crate::application::SubmitPromptAnswer> {
let title = Input::with_theme(&ColorfulTheme::default())
.with_prompt(" Title")
.default(request.suggested_title.clone())
.interact_text()?;
let body = request.suggested_body.clone();
let choice = Select::with_theme(&ColorfulTheme::default())
.with_prompt(" PR type")
.items(PR_TYPE_OPTIONS)
.default(PR_TYPE_DEFAULT_INDEX)
.interact()?;
Ok(crate::application::SubmitPromptAnswer {
branch: request.branch.clone(),
title,
body,
mode: if choice == 0 {
crate::application::PullRequestMode::Draft
} else {
crate::application::PullRequestMode::Ready
},
})
}
}
struct CliOperationReporter {
quiet: bool,
}
impl crate::application::OperationReporter for CliOperationReporter {
fn report(&mut self, event: crate::application::OperationEvent) {
if self.quiet {
return;
}
match event {
crate::application::OperationEvent::Started(_) => {
eprintln!(" {}", "Validating repository...".dimmed());
}
crate::application::OperationEvent::Progress(progress) => {
let count = match progress.total {
Some(total) => format!(" ({}/{total})", progress.completed),
None => String::new(),
};
let branch = progress
.branch
.as_ref()
.map(|branch| format!(" {}", branch.cyan()))
.unwrap_or_default();
eprintln!(" {}{}{}", progress.message, branch, count);
}
crate::application::OperationEvent::Completed(_)
| crate::application::OperationEvent::Failed(_) => {}
}
}
}
#[allow(dead_code)]
fn run_default_with_prompter<B, P>(
scope: SubmitScope,
options: &SubmitOptions,
backend: &mut B,
prompter: &mut P,
reporter: &mut dyn crate::application::OperationReporter,
) -> Result<crate::application::OperationReceipt>
where
B: DefaultSubmitBackend,
B::Prepared: PreparedPromptRequests,
P: SubmitPrompter,
{
let application_options = crate::application::SubmitOptions {
scope,
new_pull_requests: if options.publish {
crate::application::PullRequestMode::Ready
} else {
crate::application::PullRequestMode::Draft
},
fetch: !options.no_fetch,
prefetched: options.prefetched,
verify_hooks: !options.no_verify,
create_pull_requests: !options.no_pr,
reviewers: options.reviewers.clone(),
labels: options.labels.clone(),
assignees: options.assignees.clone(),
rerequest_review: options.rerequest_review,
native_stack_override: options.native_stack_override,
update_title: options.update_title,
};
let prepared = backend.prepare(application_options, reporter)?;
let prompt_requests = prepared_prompt_requests(&prepared);
let auto_accept_prompts = options.yes || options.no_prompt;
let answers = if auto_accept_prompts {
prompt_requests
.iter()
.map(|request| crate::application::SubmitPromptAnswer {
branch: request.branch.clone(),
title: request.suggested_title.clone(),
body: request.suggested_body.clone(),
mode: if options.publish {
crate::application::PullRequestMode::Ready
} else {
crate::application::PullRequestMode::Draft
},
})
.collect()
} else {
prompt_requests
.iter()
.map(|request| prompter.answer(request))
.collect::<Result<Vec<_>>>()?
};
backend.execute(prepared, answers, reporter)
}
#[allow(dead_code)]
trait PreparedPromptRequests {
fn prompt_requests(&self) -> &[crate::application::SubmitPromptRequest];
}
impl PreparedPromptRequests for crate::application::PreparedSubmit {
fn prompt_requests(&self) -> &[crate::application::SubmitPromptRequest] {
self.prompt_requests()
}
}
impl PreparedPromptRequests for Vec<crate::application::SubmitPromptRequest> {
fn prompt_requests(&self) -> &[crate::application::SubmitPromptRequest] {
self
}
}
#[allow(dead_code)]
fn prepared_prompt_requests<P: PreparedPromptRequests>(
prepared: &P,
) -> &[crate::application::SubmitPromptRequest] {
prepared.prompt_requests()
}
pub fn run(scope: SubmitScope, options: SubmitOptions) -> Result<()> {
if options.dry_run {
return super::submit_plan::run(scope, &options);
}
if uses_application_default_submit(scope, &options) {
return run_application_default_submit(scope, &options);
}
let SubmitOptions {
dry_run: _,
json: _,
draft,
publish,
no_pr,
no_fetch,
prefetched,
no_verify,
force,
yes,
no_prompt,
reviewers,
labels,
assignees,
quiet,
open,
verbose,
template,
no_template,
edit,
ai,
title: ai_title,
body: body_scope,
rerequest_review,
native_stack_override,
squash,
update_title,
} = options;
let ai_targets = resolve_ai_targets(ai, ai_title, body_scope, update_title)?;
let auto_accept_prompts = yes || no_prompt;
if force && !quiet {
eprintln!(
" {} --force is deprecated and has no effect (see issue #222)",
"warning:".yellow()
);
}
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 single_stack_mode = config.submit.single_stack;
let stack_links_when_native = config.submit.stack_links_when_native;
let native_stack_mode = native_stack_override.unwrap_or(config.submit.native_stack);
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)
&& let Some(parent) = &branch_info.parent
&& let Ok(branch_commit) = repo.branch_commit(b)
&& 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 || prefetched {
if no_fetch && !quiet {
println!(
" {} {}",
"Skipping fetch".yellow(),
"(--no-fetch)".dimmed()
);
}
let rb = remote::get_remote_branches(repo.workdir()?, &remote_info.name)?
.into_iter()
.collect::<HashSet<_>>();
let summary = if no_fetch {
"skipped (--no-fetch)"
} else {
"already fetched by update"
};
(summary.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 rb = match ls_joined {
Ok(set) => set,
Err(e) => anyhow::bail!(
"git ls-remote --heads {} failed: {e:#}\n\n\
Re-run with --no-fetch to fall back to cached \
remote-tracking refs.",
remote_info.name
),
};
match fetch_res {
Ok(()) => LiveTimer::maybe_finish_ok(fetch_timer, "done"),
Err(initial_err) => {
let existing_refs: Vec<String> =
refs.iter().filter(|r| rb.contains(*r)).cloned().collect();
if existing_refs.is_empty() {
LiveTimer::maybe_finish_ok(fetch_timer, "skipped (no existing remote refs)");
} else if existing_refs.len() == refs.len() {
LiveTimer::maybe_finish_warn(fetch_timer, "FAILED");
anyhow::bail!(
"git fetch from '{}' failed: {initial_err:#}\n\n\
Refusing to continue: existing remote-tracking refs \
({}) could not be refreshed, and \
--force-with-lease against stale refs is unsafe — the \
remote will reject it as `(stale info)`.\n\n\
Try one of:\n \
- Fix the underlying fetch failure (auth, network, \
.git/index.lock) and retry\n \
- Re-run with --no-fetch to explicitly accept cached \
remote-tracking refs",
remote_info.name,
existing_refs.join(", "),
);
} else {
match remote::fetch_remote_refs(
repo.workdir()?,
&remote_info.name,
&existing_refs,
) {
Ok(()) => LiveTimer::maybe_finish_ok(
fetch_timer,
"done (skipped non-existent remote refs)",
),
Err(retry_err) => {
LiveTimer::maybe_finish_warn(fetch_timer, "FAILED");
anyhow::bail!(
"git fetch from '{}' failed: {retry_err:#}\n\n\
Refusing to continue: existing remote-tracking refs \
({}) could not be refreshed, and \
--force-with-lease against stale refs is unsafe — the \
remote will reject it as `(stale info)`.\n\n\
Try one of:\n \
- Fix the underlying fetch failure (auth, network, \
.git/index.lock) and retry\n \
- Re-run with --no-fetch to explicitly accept cached \
remote-tracking refs",
remote_info.name,
existing_refs.join(", "),
);
}
}
}
}
}
("ok".to_string(), 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 (publish_sources, _temporary_publish_refs) =
prepare_publish_sources_for_submit(&repo, &stack, &branches_to_submit, quiet)?;
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 is_imported = is_imported_branch(&meta);
let publish_source = publish_sources.get(branch).cloned().unwrap_or_else(|| {
default_publish_source(repo.workdir().ok(), branch, &meta.parent_branch_name)
});
let needs_push = !is_imported
&& ref_needs_push(
repo.workdir()?,
&remote_info.name,
branch,
&publish_source.source_ref,
);
let mut existing_pr = None;
let had_metadata_pr = meta.pr_info.as_ref().filter(|p| p.number > 0).is_some();
if !is_empty
&& 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
&& 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,
commit_range_base: publish_source.commit_range_base,
publish_ref: publish_source.source_ref,
publish_oid: publish_source.oid,
uses_temporary_publish_ref: publish_source.is_temporary,
remote_oid_after_fetch: rev_parse_ref(
repo.workdir().ok(),
&format!("{}/{}", remote_info.name, branch),
),
existing_pr,
existing_pr_is_draft: None,
tip_commit_subject: None,
needs_title_update: false,
existing_pr_title: None,
title: None,
body: None,
ai_title_update: None,
generated_body_update: None,
is_draft: None,
needs_push,
needs_pr_update: false,
needs_base_update: false,
is_empty,
is_imported,
});
}
} else {
let runtime = tokio::runtime::Runtime::new()?;
let _enter = runtime.enter();
let forge_client = ForgeClient::new(&remote_info)?;
let mut lookup_inputs = Vec::new();
for branch in &branches_to_submit {
if empty_set.contains(branch) {
continue;
}
let meta = BranchMetadata::read(repo.inner(), branch)?
.context(format!("No metadata for branch {}", branch))?;
let metadata_pr_number = meta
.pr_info
.as_ref()
.filter(|p| p.number > 0)
.map(|p| p.number);
lookup_inputs.push((
branch.clone(),
metadata_pr_number,
remote_branches.contains(branch),
));
}
let lookup_started_at = Instant::now();
let lookup_results = runtime.block_on(async {
join_all(lookup_inputs.into_iter().map(
|(branch, metadata_pr_number, has_remote_branch)| {
discover_existing_pr(
forge_client.clone(),
branch,
metadata_pr_number,
has_remote_branch,
)
},
))
.await
});
timings.open_pr_discovery += lookup_started_at.elapsed();
let mut lookups_by_branch = HashMap::new();
for lookup in lookup_results {
let lookup = lookup?;
lookups_by_branch.insert(lookup.branch.clone(), lookup);
}
if lookups_by_branch
.values()
.any(|lookup| lookup.needs_full_scan_fallback)
{
full_scan_fallbacks += lookups_by_branch
.values()
.filter(|lookup| lookup.needs_full_scan_fallback)
.count();
if verbose && !quiet {
println!(" Falling back to full open PR scan for metadata mismatches");
}
let lookup_started_at = Instant::now();
let open_prs_by_head =
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", open_prs_by_head.len());
}
for lookup in lookups_by_branch.values_mut() {
if lookup.needs_full_scan_fallback {
lookup.existing_pr = open_prs_by_head.get(&lookup.branch).cloned();
}
}
}
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 is_imported = is_imported_branch(&meta);
let base = meta.parent_branch_name.clone();
let publish_source = publish_sources
.get(branch)
.cloned()
.unwrap_or_else(|| default_publish_source(repo.workdir().ok(), branch, &base));
let existing_pr = lookups_by_branch
.get(branch)
.and_then(|lookup| lookup.existing_pr.clone());
if !is_empty {
if verbose && !quiet {
println!(" Checking PR for {}", branch.cyan());
if let Some(found) = &existing_pr {
println!(" Found open PR #{}", found.info.number);
} else {
println!(" No open PR found");
}
}
} 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 needs_push = !is_imported
&& ref_needs_push(
repo.workdir()?,
&remote_info.name,
branch,
&publish_source.source_ref,
);
let needs_pr_update = if is_empty {
false
} else if is_imported {
false
} else if let Some(pr) = &existing_pr {
pr.info.base != base || needs_push
} else {
true };
let needs_base_update = !is_empty
&& !is_imported
&& existing_pr
.as_ref()
.map(|pr| pr.info.base != base)
.unwrap_or(false);
let tip_commit_subject = if update_title && pr_number.is_some() && !is_empty {
tip_commit_subject(repo.workdir()?, &publish_source.source_ref)
} 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,
commit_range_base: publish_source.commit_range_base,
publish_ref: publish_source.source_ref,
publish_oid: publish_source.oid,
uses_temporary_publish_ref: publish_source.is_temporary,
remote_oid_after_fetch: rev_parse_ref(
repo.workdir().ok(),
&format!("{}/{}", remote_info.name, branch),
),
existing_pr: pr_number,
existing_pr_is_draft: existing_pr.as_ref().map(|pr| pr.info.is_draft),
tip_commit_subject,
needs_title_update,
existing_pr_title: existing_pr.as_ref().map(|pr| pr.title.clone()),
title: None,
body: None,
ai_title_update: None,
generated_body_update: None,
is_draft: None,
needs_push,
needs_pr_update,
needs_base_update,
is_empty,
is_imported,
});
}
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 && !p.is_imported)
.collect();
let updates: Vec<_> = plans
.iter()
.filter(|p| p.existing_pr.is_some() && p.needs_pr_update && !p.is_empty && !p.is_imported)
.collect();
let noops: Vec<_> = plans
.iter()
.filter(|p| {
p.existing_pr.is_some()
&& !p.needs_pr_update
&& !p.needs_push
&& !p.is_empty
&& !p.is_imported
})
.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 mut ai_agent_selection: Option<AiAgentSelection> = None;
let new_prs: Vec<_> = plans
.iter()
.filter(|p| p.existing_pr.is_none() && !p.is_empty && !p.is_imported)
.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 || plan.is_imported {
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 auto_accept_prompts {
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.commit_range_base,
&plan.publish_ref,
);
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 ai_details = if let Some(targets) = ai_targets {
match generate_ai_pr_details(
repo.workdir()?,
&plan.commit_range_base,
&plan.publish_ref,
template_content,
targets,
&mut ai_agent_selection,
auto_accept_prompts,
quiet,
) {
Ok(details) => Some(details),
Err(e) => {
if !quiet {
eprintln!(
" {} AI generation failed: {}. Falling back to defaults.",
"⚠".yellow(),
e
);
}
finish_default_pr_detail_progress(
quiet,
&plan.branch,
"PR details",
"fallback",
);
None
}
}
} else {
None
};
if let Some(targets) = ai_targets {
if !targets.title {
finish_default_pr_detail_progress(quiet, &plan.branch, "PR title", "done");
}
if !targets.body {
finish_default_pr_detail_progress(quiet, &plan.branch, "PR body", "done");
}
}
let suggested_title = ai_details
.as_ref()
.and_then(|details| details.title.clone())
.unwrap_or(default_title);
let suggested_body = ai_details
.as_ref()
.and_then(|details| details.body.clone())
.unwrap_or(default_body);
let title = if auto_accept_prompts {
suggested_title
} else {
Input::with_theme(&ColorfulTheme::default())
.with_prompt(" Title")
.default(suggested_title)
.interact_text()?
};
let body = if auto_accept_prompts {
suggested_body
} else if edit {
Editor::new()
.edit(&suggested_body)?
.unwrap_or(suggested_body)
} else {
let options = if suggested_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" => suggested_body,
"Edit" => Editor::new()
.edit(&suggested_body)?
.unwrap_or(suggested_body),
_ => String::new(),
}
};
let is_draft = if let Some(is_draft) =
resolve_is_draft_without_prompt(draft_flag_set, publish, draft, auto_accept_prompts)
{
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);
}
if let Some(targets) = ai_targets {
let should_consider_existing_ai =
!auto_accept_prompts || existing_ai_targets_for_auto_accept(targets).is_some();
if should_consider_existing_ai {
let existing_prs: Vec<_> = plans
.iter()
.filter(|p| p.existing_pr.is_some() && !p.is_empty && !p.is_imported)
.collect();
if !existing_prs.is_empty() && !quiet {
println!();
println!("{}", "Existing PR AI updates:".bold());
}
for plan in &mut plans {
let Some(pr_number) = plan.existing_pr else {
continue;
};
if plan.is_empty {
continue;
}
if plan.is_imported {
continue;
}
let selected_targets = if auto_accept_prompts {
existing_ai_targets_for_auto_accept(targets)
} else {
if !quiet {
println!(" {} #{}", plan.branch.cyan(), pr_number);
}
prompt_existing_ai_targets(targets, &plan.branch, pr_number)?
};
let Some(selected_targets) = selected_targets else {
if !quiet && !auto_accept_prompts {
println!(" {}", "Skipping AI content update".dimmed());
}
continue;
};
let selected_template = if !selected_targets.body || 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 auto_accept_prompts {
if discovered_templates.len() == 1 {
Some(discovered_templates[0].clone())
} else {
None
}
} else {
select_template_interactive(&discovered_templates)?
};
let template_content = selected_template.as_ref().map(|t| t.content.as_str());
match generate_ai_pr_details(
repo.workdir()?,
&plan.commit_range_base,
&plan.publish_ref,
template_content,
selected_targets,
&mut ai_agent_selection,
auto_accept_prompts,
quiet,
) {
Ok(details) => {
if let Some(title) = details.title {
if plan.existing_pr_title.as_deref() == Some(title.as_str()) {
if !quiet {
println!(
" {}",
"AI title matches existing title".dimmed()
);
}
} else {
plan.ai_title_update = Some(title);
}
}
if let Some(body) = details.body {
plan.generated_body_update = Some(body);
}
}
Err(e) => {
if !quiet {
eprintln!(
" {} AI generation failed for existing PR #{}: {}",
"⚠".yellow(),
pr_number,
e
);
}
}
}
}
}
}
}
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());
}
let mut pushed_branches = Vec::new();
for plan in &branches_needing_push {
if squash {
if plan.uses_temporary_publish_ref {
anyhow::bail!(
"--squash cannot be combined with submitting a branch that needs a temporary restack. Run `stax restack` first, then submit with --squash."
);
}
if let Err(e) = squash_branch_commits(repo.workdir()?, &plan.branch, &plan.parent)
&& !quiet
{
println!(" {} squash {}: {}", "⚠".yellow(), plan.branch, e);
}
}
pushed_branches.push(PushSpec {
branch: plan.branch.clone(),
source_ref: plan.publish_ref.clone(),
oid: plan
.publish_oid
.clone()
.or_else(|| rev_parse_ref(repo.workdir().ok(), &plan.publish_ref)),
expected_remote_oid: plan.remote_oid_after_fetch.clone(),
});
}
let push_timer = LiveTimer::maybe_new(
!quiet,
&format!(
"Pushing {} {}...",
pushed_branches.len(),
if pushed_branches.len() == 1 {
"branch"
} else {
"branches"
}
),
);
match push_branches(
repo.workdir()?,
&remote_info.name,
&pushed_branches,
no_verify,
) {
Ok(()) => {
for spec in &pushed_branches {
if let Some(ref mut tx) = tx {
let _ = tx.record_after(&repo, &spec.branch);
if let Some(oid) = &spec.oid {
tx.record_remote_after(&remote_info.name, &spec.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"), None)?;
}
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
|| p.ai_title_update.is_some()
|| p.generated_body_update.is_some())
});
let any_existing_prs = plans
.iter()
.any(|p| !p.is_empty && !p.is_imported && 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 imported_stack_branches = imported_branches_for_stack(&repo, &stack, ¤t)?;
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 || plan.is_imported {
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),
);
if plan.needs_base_update
&& let Err(e) = client
.update_pr_base(existing_pr_number, &plan.parent)
.await
{
if is_native_stack_base_locked_error(&e) {
if !quiet {
println!(
" {} {}",
"note:".dimmed(),
format!(
"skipped base update for #{existing_pr_number} — \
GitHub manages the base for PRs registered in a \
native Stack; run `st stack link` to re-sync"
)
.dimmed()
);
}
} else {
LiveTimer::maybe_finish_warn(update_timer, "failed");
return Err(e).context(format!(
"Failed to update PR base for #{existing_pr_number}"
));
}
}
if plan.needs_title_update
&& let Some(ref commit_subject) = plan.tip_commit_subject
{
client
.update_pr_title(existing_pr_number, commit_subject)
.await?;
}
apply_ai_pr_content_updates(
&client,
existing_pr_number,
&plan.branch,
plan.ai_title_update.as_deref(),
plan.generated_body_update.as_deref(),
quiet,
)
.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),
is_imported: plan.is_imported,
depth: stack.ancestors(&plan.branch).len(),
});
} 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");
}
apply_ai_pr_content_updates(
&client,
existing_pr_number,
&plan.branch,
plan.ai_title_update.as_deref(),
plan.generated_body_update.as_deref(),
quiet,
)
.await?;
pr_infos.push(StackPrInfo {
branch: plan.branch.clone(),
pr_number: Some(existing_pr_number),
is_imported: plan.is_imported,
depth: stack.ancestors(&plan.branch).len(),
});
}
} 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 = match client
.create_pr(&plan.branch, &plan.parent, title, body, is_draft)
.await
{
Ok(pr) => {
created_pr_numbers.insert(pr.number);
LiveTimer::maybe_finish_ok(
create_timer,
&format!("created {}", format!("#{}", pr.number).dimmed()),
);
pr
}
Err(err) if is_duplicate_pr_create_error(&err) => {
let Some(existing_pr) =
recover_existing_pr_after_duplicate_create(&client, &plan.branch)
.await?
else {
return Err(err).context(create_pr_failure_context(plan));
};
LiveTimer::maybe_finish_ok(
create_timer,
&format!(
"using existing {}",
format!("#{}", existing_pr.info.number).dimmed()
),
);
existing_pr.info
}
Err(err) => return Err(err).context(create_pr_failure_context(plan)),
};
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),
is_imported: plan.is_imported,
depth: stack.ancestors(&plan.branch).len(),
});
}
}
async_timings.pr_create_update = create_update_started_at.elapsed();
let mut stack_link_pr_infos = pr_infos.clone();
if stack_links_mode != StackLinksMode::Off {
discover_stack_link_pr_infos(
&client,
&stack,
¤t,
&imported_stack_branches,
&mut stack_link_pr_infos,
)
.await?;
}
let stack_link_contexts = stack_link_contexts_for_sync(
&stack,
¤t,
&stack_link_pr_infos,
&imported_stack_branches,
);
let native_stack_linked = maybe_link_native_stack(
repo.workdir()?,
&remote_info,
&stack.trunk,
native_stack_mode,
&stack,
&stack_link_contexts,
quiet,
)?;
let stack_links_started_at = Instant::now();
let native_forces_links_off =
native_stack_linked && stack_links_when_native == StackLinksWhenNative::Off;
let single_stack_skips_links =
single_stack_mode == SingleStackMode::Off && stack_link_contexts.len() <= 1;
let effective_stack_links_mode = if native_forces_links_off || single_stack_skips_links {
StackLinksMode::Off
} else {
stack_links_mode
};
for (pr_number, _branch, stack_link_pr_infos) in &stack_link_contexts {
let sync_timer =
LiveTimer::maybe_new(!quiet, &format!("Syncing stack links on #{}...", pr_number));
let stack_links = generate_stack_links_markdown(
stack_link_pr_infos,
*pr_number,
&remote_info,
&stack.trunk,
);
match effective_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 effective_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 uses_application_default_submit(scope: SubmitScope, options: &SubmitOptions) -> bool {
matches!(scope, SubmitScope::Stack)
&& options.no_pr
&& !options.dry_run
&& !options.json
&& !options.ai
&& !options.title
&& !options.body
&& !options.squash
&& !options.edit
&& !options.prefetched
&& !options.no_template
&& options.template.is_none()
&& !options.update_title
}
fn run_application_default_submit(scope: SubmitScope, options: &SubmitOptions) -> Result<()> {
if options.force && !options.quiet {
eprintln!(
" {} --force is deprecated and has no effect (see issue #222)",
"warning:".yellow()
);
}
let repo = GitRepo::open()?;
let current = repo.current_branch()?;
if !options.quiet {
println!("{} {}...", "Submitting".bold(), scope.label().bold());
}
let session = crate::application::RepositorySession::open(repo.workdir()?)?;
let mut backend = RepositorySubmitBackend { session: &session };
let mut prompter = TerminalSubmitPrompter;
let mut reporter = CliOperationReporter {
quiet: options.quiet,
};
let receipt =
run_default_with_prompter(scope, options, &mut backend, &mut prompter, &mut reporter)?;
sync_application_submit_links(&repo, ¤t, &receipt, options)?;
render_application_submit_receipt(&receipt, ¤t, options.open, options.quiet);
Ok(())
}
fn sync_application_submit_links(
repo: &GitRepo,
current: &str,
receipt: &crate::application::OperationReceipt,
options: &SubmitOptions,
) -> Result<()> {
if options.no_pr {
return refresh_application_no_pr_metadata(repo, receipt);
}
let pull_requests = match &receipt.outcome {
crate::application::OperationOutcome::Submitted { pull_requests } => pull_requests,
_ => return Ok(()),
};
if pull_requests.is_empty() {
return Ok(());
}
let stack = Stack::load(repo)?;
let config = Config::load()?;
let remote_info = RemoteInfo::from_repo(repo, &config)?;
let imported_branches = imported_branches_for_stack(repo, &stack, current)?;
let mut pr_infos = pull_requests
.iter()
.map(|pull_request| StackPrInfo {
branch: pull_request.branch.clone(),
pr_number: Some(pull_request.number),
is_imported: imported_branches.contains(&pull_request.branch),
depth: stack.ancestors(&pull_request.branch).len(),
})
.collect::<Vec<_>>();
let created_pr_numbers = pull_requests
.iter()
.filter(|pull_request| {
matches!(
pull_request.change,
crate::application::PullRequestChange::Created
)
})
.map(|pull_request| pull_request.number)
.collect::<HashSet<_>>();
let runtime = tokio::runtime::Runtime::new()?;
let _enter = runtime.enter();
let client = ForgeClient::new(&remote_info)?;
runtime.block_on(async {
if config.submit.stack_links != StackLinksMode::Off {
discover_stack_link_pr_infos(
&client,
&stack,
current,
&imported_branches,
&mut pr_infos,
)
.await?;
}
let stack_link_contexts =
stack_link_contexts_for_sync(&stack, current, &pr_infos, &imported_branches);
let native_stack_mode = options
.native_stack_override
.unwrap_or(config.submit.native_stack);
let native_stack_linked = maybe_link_native_stack(
repo.workdir()?,
&remote_info,
&stack.trunk,
native_stack_mode,
&stack,
&stack_link_contexts,
options.quiet,
)?;
let native_forces_links_off = native_stack_linked
&& config.submit.stack_links_when_native == StackLinksWhenNative::Off;
let single_stack_skips_links =
config.submit.single_stack == SingleStackMode::Off && stack_link_contexts.len() <= 1;
let effective_stack_links_mode = if native_forces_links_off || single_stack_skips_links {
StackLinksMode::Off
} else {
config.submit.stack_links
};
for (pr_number, _branch, stack_link_pr_infos) in &stack_link_contexts {
let stack_links = generate_stack_links_markdown(
stack_link_pr_infos,
*pr_number,
&remote_info,
&stack.trunk,
);
match effective_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 effective_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?;
}
}
Ok::<(), anyhow::Error>(())
})?;
Ok(())
}
fn refresh_application_no_pr_metadata(
repo: &GitRepo,
receipt: &crate::application::OperationReceipt,
) -> Result<()> {
if receipt.affected_branches.is_empty() {
return Ok(());
}
let config = Config::load()?;
let remote_info = RemoteInfo::from_repo(repo, &config)?;
let runtime = tokio::runtime::Runtime::new()?;
let _enter = runtime.enter();
let client = match ForgeClient::new(&remote_info) {
Ok(client) => client,
Err(_) => return Ok(()),
};
for branch in &receipt.affected_branches {
let Some(meta) = BranchMetadata::read(repo.inner(), branch)? else {
continue;
};
let found_pr = runtime
.block_on(async { client.find_open_pr_by_head(branch).await })
.ok()
.flatten();
let Some(pr) = found_pr else {
continue;
};
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);
if !owner_matches {
continue;
}
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 {
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
};
updated_meta.write(repo.inner(), branch)?;
}
}
Ok(())
}
fn render_application_submit_receipt(
receipt: &crate::application::OperationReceipt,
current: &str,
open: bool,
quiet: bool,
) {
let pull_requests = match &receipt.outcome {
crate::application::OperationOutcome::Submitted { pull_requests } => pull_requests,
_ => return,
};
if !quiet {
println!();
println!("{}", "✓ Stack submitted!".green().bold());
for pull_request in pull_requests {
println!(" {} {}", "✓".green(), pull_request.url);
}
}
if open {
if let Some(url) = pull_requests
.iter()
.find(|pull_request| pull_request.branch == current)
.map(|pull_request| pull_request.url.as_str())
{
if !quiet {
println!("Opening {} in browser...", url.cyan());
}
open_url_in_browser(url);
} else if !quiet {
eprintln!(
" {} No PR found for current branch {}; nothing to open.",
"!".yellow(),
current.cyan()
);
}
}
}
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 stack_pr_infos_for_links(
stack: &Stack,
current: &str,
processed_pr_infos: &[StackPrInfo],
imported_branches: &HashSet<String>,
) -> Vec<StackPrInfo> {
let processed_pr_infos_by_branch: HashMap<&str, &StackPrInfo> = processed_pr_infos
.iter()
.map(|info| (info.branch.as_str(), info))
.collect();
stack
.current_stack(current)
.into_iter()
.filter(|branch| branch != &stack.trunk)
.map(|branch| {
let processed_info = processed_pr_infos_by_branch.get(branch.as_str()).copied();
let pr_number = processed_info
.and_then(|info| info.pr_number)
.or_else(|| stack.branches.get(&branch).and_then(|info| info.pr_number));
let is_imported = processed_info
.map(|info| info.is_imported)
.unwrap_or_else(|| imported_branches.contains(&branch));
let depth = stack.ancestors(&branch).len();
StackPrInfo {
branch,
pr_number,
is_imported,
depth,
}
})
.collect()
}
async fn discover_stack_link_pr_infos(
client: &ForgeClient,
stack: &Stack,
current: &str,
imported_branches: &HashSet<String>,
processed_pr_infos: &mut Vec<StackPrInfo>,
) -> Result<()> {
let mut known_branches: HashSet<String> = processed_pr_infos
.iter()
.filter(|info| info.pr_number.is_some())
.map(|info| info.branch.clone())
.collect();
let missing_pr_infos =
stack_pr_infos_for_links(stack, current, processed_pr_infos, imported_branches);
for info in missing_pr_infos {
if info.pr_number.is_some() || known_branches.contains(&info.branch) {
continue;
}
if let Some(pr) = client.find_open_pr_by_head(&info.branch).await? {
known_branches.insert(info.branch.clone());
processed_pr_infos.push(StackPrInfo {
branch: info.branch,
pr_number: Some(pr.info.number),
is_imported: info.is_imported,
depth: info.depth,
});
}
}
Ok(())
}
fn imported_branches_for_stack(
repo: &GitRepo,
stack: &Stack,
current: &str,
) -> Result<HashSet<String>> {
let mut imported = HashSet::new();
for branch in stack
.current_stack(current)
.into_iter()
.filter(|branch| branch != &stack.trunk)
{
let Some(meta) = BranchMetadata::read(repo.inner(), &branch)? else {
continue;
};
if is_imported_branch(&meta) {
imported.insert(branch);
}
}
Ok(imported)
}
fn stack_link_contexts_for_sync(
stack: &Stack,
current: &str,
processed_pr_infos: &[StackPrInfo],
imported_branches: &HashSet<String>,
) -> Vec<(u64, String, Vec<StackPrInfo>)> {
stack_pr_infos_for_links(stack, current, processed_pr_infos, imported_branches)
.into_iter()
.filter_map(|info| {
let pr_number = info.pr_number?;
let context = stack_pr_infos_for_links(
stack,
&info.branch,
processed_pr_infos,
imported_branches,
);
Some((pr_number, info.branch, context))
})
.collect()
}
fn stack_has_fork(stack: &Stack, branches: &HashSet<&str>) -> bool {
branches.iter().any(|branch| {
stack
.branches
.get(*branch)
.map(|info| {
info.children
.iter()
.filter(|child| branches.contains(child.as_str()))
.count()
> 1
})
.unwrap_or(false)
})
}
#[allow(clippy::too_many_arguments)]
fn maybe_link_native_stack(
workdir: &Path,
remote_info: &RemoteInfo,
trunk: &str,
mode: NativeStackMode,
stack: &Stack,
stack_link_contexts: &[(u64, String, Vec<StackPrInfo>)],
quiet: bool,
) -> Result<bool> {
if mode == NativeStackMode::Off || remote_info.forge != ForgeType::GitHub {
return Ok(false);
}
let pr_numbers = stack_link_contexts
.iter()
.map(|(pr_number, _, _)| *pr_number)
.collect::<Vec<_>>();
if pr_numbers.len() < 2 {
return Ok(false);
}
if mode == NativeStackMode::Auto && gh_stack::feature_enabled(workdir) == FeatureState::Disabled
{
return Ok(false);
}
let extension_status = gh_stack::extension_status();
if extension_status != ExtensionStatus::Installed {
if mode == NativeStackMode::Link && !quiet {
print_native_stack_unavailable_note(extension_status);
}
return Ok(false);
}
let branch_set: HashSet<&str> = stack_link_contexts
.iter()
.map(|(_, branch, _)| branch.as_str())
.collect();
if stack_has_fork(stack, &branch_set) {
if !quiet {
println!(
" {} {}",
"note:".dimmed(),
"native GitHub Stack link skipped: this local stack has forked — a branch \
here has more than one child branch, and GitHub's native Stack feature only \
supports one linear chain at a time. stax's own PR body/comment stack links \
still keep this branch's PRs connected."
.dimmed()
);
}
return Ok(false);
}
match gh_stack::link_stack(&pr_numbers, trunk, &remote_info.name) {
LinkOutcome::Linked => {
gh_stack::set_feature_enabled(workdir, true)?;
if !quiet {
println!("{} {}", "✓".green(), "Native GitHub Stack linked".dimmed());
}
Ok(true)
}
LinkOutcome::FeatureDisabled { .. } => {
gh_stack::set_feature_enabled(workdir, false)?;
Ok(false)
}
LinkOutcome::AuthTokenUnsupported { .. } => {
if !quiet {
println!(
" {} {}",
"note:".dimmed(),
"native GitHub Stack link skipped: no OAuth-authenticated `gh` account found \
(Personal Access Tokens are not supported during private preview). Run `gh \
auth login` to add one."
.dimmed()
);
}
Ok(false)
}
LinkOutcome::SinglePrValidationRejected { .. } => Ok(false),
LinkOutcome::Failed { message } => {
if !quiet {
let note = if gh_stack::is_stack_fork_conflict(&message) {
"native GitHub Stack link skipped: this stack has forked — another branch \
sharing the same ancestor PRs is already registered as a native Stack, and \
GitHub's native Stack feature only supports one linear chain at a time. \
stax's own PR body/comment stack links still keep this branch's PRs \
connected."
.to_string()
} else {
format!("native GitHub Stack link skipped: {message}")
};
println!(" {} {}", "note:".dimmed(), note.dimmed());
}
Ok(false)
}
}
}
fn print_native_stack_unavailable_note(status: ExtensionStatus) {
let note = match status {
ExtensionStatus::NoGh => {
"native GitHub Stack link skipped: GitHub CLI `gh` is not installed or not usable. \
Install `gh`, run `gh auth login`, then retry; `st doctor --fix` can help diagnose setup."
}
ExtensionStatus::NoExtension => {
"native GitHub Stack link skipped: gh-stack extension missing. Run `st doctor --fix` \
or `gh extension install github/gh-stack`, then retry."
}
ExtensionStatus::Outdated => {
"native GitHub Stack link skipped: gh-stack extension is outdated and lacks \
`gh stack link`. Run `st doctor --fix` or `gh extension upgrade gh-stack`, then retry."
}
ExtensionStatus::Installed => return,
};
println!(" {} {}", "note:".dimmed(), note.dimmed());
}
#[cfg(test)]
fn rejected_push_branches(porcelain: &str, specs: &[PushSpec]) -> Vec<String> {
porcelain
.lines()
.filter(|line| line.starts_with("!\t"))
.filter_map(|line| {
let local_ref = line.split('\t').nth(1)?.split(':').next()?;
specs
.iter()
.find(|spec| spec.source_ref == local_ref)
.map(|spec| spec.branch.clone())
.or_else(|| local_ref.strip_prefix("refs/heads/").map(str::to_string))
})
.filter(|branch| specs.iter().any(|spec| spec.branch == *branch))
.collect()
}
fn push_failure_details(stdout: &str, stderr: &str) -> String {
let stdout = stdout.trim();
let stderr = stderr.trim();
match (stdout.is_empty(), stderr.is_empty()) {
(true, true) => String::new(),
(false, true) => stdout.to_string(),
(true, false) => stderr.to_string(),
(false, false) => format!("stdout:\n{stdout}\nstderr:\n{stderr}"),
}
}
pub(crate) 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;
}
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(())
}
async fn discover_existing_pr(
forge_client: ForgeClient,
branch: String,
metadata_pr_number: Option<u64>,
has_remote_branch: bool,
) -> Result<ExistingPrLookup> {
let mut existing_pr = None;
let had_metadata_pr = metadata_pr_number.is_some();
if let Some(pr_number) = metadata_pr_number
&& let Ok(pr) = forge_client.get_pr_with_head(pr_number).await
{
let state = pr.info.state.to_ascii_lowercase();
if pr.head == branch && matches!(state.as_str(), "open" | "opened") {
existing_pr = Some(pr);
}
}
if existing_pr.is_none() {
existing_pr = forge_client.find_open_pr_by_head(&branch).await?;
}
Ok(ExistingPrLookup {
branch,
needs_full_scan_fallback: existing_pr.is_none() && (had_metadata_pr || has_remote_branch),
existing_pr,
})
}
async fn recover_existing_pr_after_duplicate_create(
client: &ForgeClient,
branch: &str,
) -> Result<Option<PrInfoWithHead>> {
if let Some(pr) = client.find_open_pr_by_head(branch).await? {
return Ok(Some(pr));
}
let open_prs_by_head = client.list_open_prs_by_head().await?;
Ok(open_prs_by_head.get(branch).cloned())
}
fn is_duplicate_pr_create_error(err: &anyhow::Error) -> bool {
let message = format!("{:#}", err);
message.contains("A pull request already exists") && message.contains("PullRequest")
}
fn create_pr_failure_context(plan: &PrPlan) -> String {
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
)
}
fn prepare_publish_sources_for_submit(
repo: &GitRepo,
stack: &Stack,
branches_to_submit: &[String],
quiet: bool,
) -> Result<(HashMap<String, PublishSource>, TemporaryPublishRefs)> {
let workdir = repo.workdir()?;
let mut sources = HashMap::new();
let temp_root = std::env::temp_dir().join(format!(
"stax-submit-{}-{}",
std::process::id(),
chrono_like_timestamp()
));
let mut temporary_refs = TemporaryPublishRefs::empty(workdir);
for branch in branches_to_submit {
let meta = BranchMetadata::read(repo.inner(), branch)?
.with_context(|| format!("No metadata for branch {}", branch))?;
if is_imported_branch(&meta) {
continue;
}
let parent_source_ref = sources
.get(&meta.parent_branch_name)
.map(|source: &PublishSource| source.source_ref.clone());
let parent_will_publish_from_temp = parent_source_ref.is_some();
let branch_needs_temp = stack
.branches
.get(branch)
.map(|br| br.needs_restack)
.unwrap_or(false)
|| parent_will_publish_from_temp;
if !branch_needs_temp {
continue;
}
let onto_ref = parent_source_ref.unwrap_or_else(|| meta.parent_branch_name.clone());
let temp_ref = format!(
"refs/stax/submit/{}/{}",
chrono_like_timestamp(),
hex_ref_component(branch)
);
let temp_worktree = temp_root.join(hex_ref_component(branch));
let oid = temporary_rebased_head(
workdir,
&temp_worktree,
branch,
&onto_ref,
&meta.parent_branch_revision,
)
.with_context(|| {
format!(
"Could not prepare temporary restack for submit of '{}'.\n\
Run `stax restack` to resolve it locally, then retry submit.",
branch
)
})?;
update_ref(workdir, &temp_ref, &oid)?;
temporary_refs.refs.push(temp_ref.clone());
sources.insert(
branch.clone(),
PublishSource {
source_ref: temp_ref,
commit_range_base: onto_ref,
oid: Some(oid),
is_temporary: true,
},
);
}
if !sources.is_empty() && !quiet {
println!(
" {} Prepared {} temporary restack {} for submit",
"▸".dimmed(),
sources.len().to_string().cyan(),
if sources.len() == 1 { "ref" } else { "refs" }
);
}
if let Err(err) = remove_path_if_exists(&temp_root)
&& !quiet
{
eprintln!(
" {} could not remove temporary submit worktree root {}: {}",
"warning:".yellow(),
temp_root.display(),
err
);
}
Ok((sources, temporary_refs))
}
fn temporary_rebased_head(
workdir: &Path,
temp_worktree: &Path,
branch: &str,
onto_ref: &str,
upstream: &str,
) -> Result<String> {
if let Some(parent) = temp_worktree.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("Failed to create temporary directory {}", parent.display())
})?;
}
let add = Command::new("git")
.args(["worktree", "add", "--detach"])
.arg(temp_worktree)
.arg(branch)
.current_dir(workdir)
.output()
.context("Failed to create temporary submit worktree")?;
if !add.status.success() {
anyhow::bail!("{}", command_output_details("git worktree add", &add));
}
let mut temp_worktree_guard = TemporarySubmitWorktree::new(workdir, temp_worktree);
let rebase = Command::new("git")
.args(["rebase", "--onto", onto_ref, upstream])
.current_dir(temp_worktree)
.output()
.context("Failed to run temporary submit rebase")?;
if !rebase.status.success() {
let _ = Command::new("git")
.args(["rebase", "--abort"])
.current_dir(temp_worktree)
.output();
anyhow::bail!("{}", command_output_details("git rebase", &rebase));
}
let rev = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(temp_worktree)
.output()
.context("Failed to read temporary submit head")?;
if !rev.status.success() {
anyhow::bail!("{}", command_output_details("git rev-parse", &rev));
}
let oid = String::from_utf8_lossy(&rev.stdout).trim().to_string();
temp_worktree_guard.remove()?;
Ok(oid)
}
fn update_ref(workdir: &Path, refname: &str, oid: &str) -> Result<()> {
let output = Command::new("git")
.args(["update-ref", refname, oid])
.current_dir(workdir)
.output()
.with_context(|| format!("Failed to update temporary ref {}", refname))?;
if !output.status.success() {
anyhow::bail!("{}", command_output_details("git update-ref", &output));
}
Ok(())
}
fn command_output_details(command: &str, output: &std::process::Output) -> String {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let details = push_failure_details(&stdout, &stderr);
if details.is_empty() {
format!("{command} failed")
} else {
format!("{command} failed:\n{details}")
}
}
fn remove_path_if_exists(path: &Path) -> Result<()> {
if path.exists() {
fs::remove_dir_all(path)
.with_context(|| format!("Failed to remove temporary directory {}", path.display()))?;
}
Ok(())
}
fn chrono_like_timestamp() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
format!("{}-{}", duration.as_secs(), duration.subsec_nanos())
}
fn hex_ref_component(value: &str) -> String {
value
.as_bytes()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
}
fn default_publish_source(workdir: Option<&Path>, branch: &str, parent: &str) -> PublishSource {
PublishSource {
source_ref: format!("refs/heads/{branch}"),
commit_range_base: parent.to_string(),
oid: rev_parse_ref(workdir, branch),
is_temporary: false,
}
}
pub(crate) fn ref_needs_push(
workdir: &Path,
remote: &str,
remote_branch: &str,
source_ref: &str,
) -> bool {
let local = rev_parse_ref(Some(workdir), source_ref);
let remote_ref = format!("{}/{}", remote, remote_branch);
let remote_commit = rev_parse_ref(Some(workdir), &remote_ref);
match (local, remote_commit) {
(Some(l), Some(r)) => l != r, (Some(_), None) => true, _ => true, }
}
fn rev_parse_ref(workdir: Option<&Path>, reference: &str) -> Option<String> {
let workdir = workdir?;
Command::new("git")
.args(["rev-parse", reference])
.current_dir(workdir)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
}
fn is_imported_branch(meta: &BranchMetadata) -> bool {
meta.source_remote.is_some()
}
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")
}
fn prompt_existing_ai_targets(
targets: AiPrTargets,
branch: &str,
pr_number: u64,
) -> Result<Option<AiPrTargets>> {
let items = existing_ai_prompt_items(targets);
let labels: Vec<_> = items.iter().map(|(label, _)| *label).collect();
let choice = Select::with_theme(&ColorfulTheme::default())
.with_prompt(format!(" AI update for {} #{}", branch, pr_number))
.items(&labels)
.default(0)
.interact()?;
Ok(items[choice].1)
}
fn existing_ai_prompt_items(targets: AiPrTargets) -> Vec<(&'static str, Option<AiPrTargets>)> {
let mut items: Vec<(&str, Option<AiPrTargets>)> = vec![("Skip", None)];
match (targets.title, targets.body) {
(true, true) => {
items.push((
"Update title",
Some(AiPrTargets {
title: true,
body: false,
explicit_scope: true,
}),
));
items.push((
"Update body",
Some(AiPrTargets {
title: false,
body: true,
explicit_scope: true,
}),
));
items.push((
"Update title and body",
Some(AiPrTargets {
title: true,
body: true,
explicit_scope: true,
}),
));
}
(true, false) => items.push(("Update title", Some(targets))),
(false, true) => items.push(("Update body", Some(targets))),
(false, false) => {}
}
items
}
fn finish_default_pr_detail_progress(quiet: bool, branch: &str, field: &str, suffix: &str) {
let timer = LiveTimer::maybe_new(
!quiet,
&format!(" Using default {} for {}...", field, branch),
);
if suffix == "fallback" {
LiveTimer::maybe_finish_warn(timer, suffix);
} else {
LiveTimer::maybe_finish_ok(timer, suffix);
}
}
fn resolve_ai_agent_selection(
cache: &mut Option<AiAgentSelection>,
non_interactive: bool,
) -> Result<AiAgentSelection> {
if let Some(selection) = cache {
return Ok(selection.clone());
}
use super::generate;
let mut config = Config::load()?;
let (agent, model) = if non_interactive {
let agent = generate::resolve_agent_non_interactive(None, &config, "generate")?;
let model = generate::resolve_model(None, &config, &agent, "generate")?;
(agent, model)
} else if config.ai.generate.agent.is_some() {
let agent = config
.ai
.agent_for("generate")
.context("No AI agent configured for PR generation")?
.to_string();
let model = generate::resolve_model(None, &config, &agent, "generate")?;
(agent, model)
} else {
generate::prompt_for_feature_ai(&mut config, "generate")?
};
let selection = AiAgentSelection { agent, model };
*cache = Some(selection.clone());
Ok(selection)
}
#[allow(clippy::too_many_arguments)]
fn generate_ai_pr_details(
workdir: &Path,
parent: &str,
branch: &str,
template: Option<&str>,
targets: AiPrTargets,
selection_cache: &mut Option<AiAgentSelection>,
non_interactive: bool,
quiet: bool,
) -> Result<AiPrDetails> {
use super::generate;
let selection = resolve_ai_agent_selection(selection_cache, non_interactive)?;
if !quiet {
generate::print_using_agent(&selection.agent, selection.model.as_deref());
}
let context_timer = LiveTimer::maybe_new(
!quiet,
&format!(" Collecting PR context for {}...", branch),
);
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);
LiveTimer::maybe_finish_ok(context_timer, "done");
let prompt = build_ai_pr_details_prompt(&diff_stat, &diff, &commits, template, targets);
let generation_timer = LiveTimer::maybe_new(
!quiet,
&format!(" Generating AI PR details for {}...", branch),
);
let raw = match generate::invoke_ai_agent(&selection.agent, selection.model.as_deref(), &prompt)
{
Ok(raw) => raw,
Err(err) => {
LiveTimer::maybe_finish_warn(generation_timer, "failed");
return Err(err);
}
};
match parse_ai_pr_details(&raw, targets) {
Ok(details) => {
LiveTimer::maybe_finish_ok(generation_timer, "done");
Ok(details)
}
Err(err) => {
LiveTimer::maybe_finish_warn(generation_timer, "failed");
Err(err)
}
}
}
fn build_ai_pr_details_prompt(
diff_stat: &str,
diff: &str,
commits: &[String],
template: Option<&str>,
targets: AiPrTargets,
) -> String {
let mut prompt = String::new();
match (targets.title, targets.body) {
(true, true) => {
prompt.push_str("Generate a pull request title and body for the following changes.\n\n")
}
(true, false) => {
prompt.push_str("Generate a pull request title for the following changes.\n\n")
}
(false, true) => {
prompt.push_str("Generate a pull request body for the following changes.\n\n")
}
(false, false) => prompt.push_str("Summarize the following changes.\n\n"),
}
prompt.push_str("Return only a compact JSON object with these string fields: ");
let fields = match (targets.title, targets.body) {
(true, true) => "\"title\" and \"body\"",
(true, false) => "\"title\"",
(false, true) => "\"body\"",
(false, false) => "",
};
prompt.push_str(fields);
prompt.push_str(". Do not include markdown fences or explanatory text.\n\n");
if targets.title {
prompt.push_str("Title requirements:\n- Concise PR title, no trailing period\n- Describe the user-visible change, not the implementation mechanics\n\n");
}
if targets.body {
if let Some(tmpl) = template {
prompt.push_str("Use this PR template as the body structure. Fill in each section based on the changes:\n\n");
prompt.push_str(tmpl);
prompt.push_str("\n\n");
} else {
prompt.push_str("Body requirements:\n- Markdown\n- Include a clear Summary section\n- Mention important tests or validation only when supported by the diff or commits\n\n");
}
}
if !commits.is_empty() {
prompt.push_str("Commit messages:\n");
for msg in commits {
prompt.push_str(&format!("- {}\n", msg));
}
prompt.push('\n');
}
if !diff_stat.is_empty() {
prompt.push_str("Diff stat:\n```\n");
prompt.push_str(diff_stat);
prompt.push_str("\n```\n\n");
}
if !diff.is_empty() {
prompt.push_str("Full diff:\n```diff\n");
prompt.push_str(&truncate_ai_diff(diff));
prompt.push_str("\n```\n\n");
}
prompt
}
fn truncate_ai_diff(diff: &str) -> String {
if diff.len() <= MAX_AI_DIFF_BYTES {
return diff.to_string();
}
let safe_end = safe_char_boundary(diff, MAX_AI_DIFF_BYTES);
let safe = &diff[..safe_end];
let cut = safe.rfind('\n').unwrap_or(safe.len());
format!(
"{}\n\n... (diff truncated, showing first ~80KB of {} total) ...",
&safe[..cut],
format_ai_bytes(diff.len())
)
}
fn safe_char_boundary(value: &str, max: usize) -> usize {
let mut end = max.min(value.len());
while end > 0 && !value.is_char_boundary(end) {
end -= 1;
}
end
}
fn format_ai_bytes(bytes: usize) -> String {
if bytes >= 1_048_576 {
format!("{:.1}MB", bytes as f64 / 1_048_576.0)
} else if bytes >= 1024 {
format!("{:.1}KB", bytes as f64 / 1024.0)
} else {
format!("{}B", bytes)
}
}
#[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()
&& 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()
&& 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(())
}
async fn apply_ai_pr_content_updates(
client: &ForgeClient,
pr_number: u64,
branch: &str,
title: Option<&str>,
body: Option<&str>,
quiet: bool,
) -> Result<()> {
if let Some(title) = title {
let timer = LiveTimer::maybe_new(
!quiet,
&format!("Updating AI title for {} #{}...", branch, pr_number),
);
client.update_pr_title(pr_number, title).await?;
LiveTimer::maybe_finish_ok(timer, "done");
}
if let Some(body) = body {
let timer = LiveTimer::maybe_new(
!quiet,
&format!("Updating AI body for {} #{}...", branch, pr_number),
);
client.update_pr_body(pr_number, body).await?;
LiveTimer::maybe_finish_ok(timer, "done");
}
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())
}
}
#[cfg(test)]
mod tests {
use super::{
AiPrTargets, DefaultSubmitBackend, MAX_AI_DIFF_BYTES, PR_TYPE_DEFAULT_INDEX,
PR_TYPE_OPTIONS, PushSpec, StackPrInfo, SubmitOptions, SubmitPrompter, SubmitScope,
build_ai_pr_details_prompt, existing_ai_prompt_items, existing_ai_targets_for_auto_accept,
parse_ai_pr_details, push_failure_details, rejected_push_branches, resolve_ai_targets,
resolve_is_draft_without_prompt, run_default_with_prompter, stack_has_fork,
stack_link_contexts_for_sync, stack_pr_infos_for_links, truncate_ai_diff,
};
use crate::application::{
NoopOperationReporter, OperationOutcome, OperationReceipt, OperationRequest,
OperationSideEffects, PullRequestChange, PullRequestMode, PullRequestReceipt,
SubmitOptions as ApplicationSubmitOptions, SubmitPromptAnswer, SubmitPromptRequest,
SubmitScope as ApplicationSubmitScope,
};
use crate::engine::Stack;
use crate::engine::stack::StackBranch;
use anyhow::Result;
use std::collections::{HashMap, HashSet};
#[test]
fn no_prompt_defaults_to_draft() {
assert_eq!(
resolve_is_draft_without_prompt(false, false, false, true),
Some(true)
);
}
#[derive(Default)]
struct RecordingSubmitBackend {
prepared_options: Vec<ApplicationSubmitOptions>,
prepared_requests: Vec<SubmitPromptRequest>,
executed_answers: Vec<Vec<SubmitPromptAnswer>>,
}
impl RecordingSubmitBackend {
fn with_requests(requests: Vec<SubmitPromptRequest>) -> Self {
Self {
prepared_requests: requests,
..Self::default()
}
}
}
impl DefaultSubmitBackend for RecordingSubmitBackend {
type Prepared = Vec<SubmitPromptRequest>;
fn prepare(
&mut self,
options: ApplicationSubmitOptions,
_reporter: &mut dyn crate::application::OperationReporter,
) -> Result<Self::Prepared> {
self.prepared_options.push(options);
Ok(self.prepared_requests.clone())
}
fn execute(
&mut self,
prepared: Self::Prepared,
answers: Vec<SubmitPromptAnswer>,
_reporter: &mut dyn crate::application::OperationReporter,
) -> Result<OperationReceipt> {
assert_eq!(
prepared
.iter()
.map(|request| &request.branch)
.collect::<Vec<_>>(),
answers
.iter()
.map(|answer| &answer.branch)
.collect::<Vec<_>>()
);
self.executed_answers.push(answers);
Ok(submit_receipt())
}
}
#[derive(Default)]
struct RecordingSubmitPrompter {
requests: Vec<SubmitPromptRequest>,
answers: Vec<SubmitPromptAnswer>,
}
impl RecordingSubmitPrompter {
fn answering(answers: Vec<SubmitPromptAnswer>) -> Self {
Self {
answers,
..Self::default()
}
}
}
impl SubmitPrompter for RecordingSubmitPrompter {
fn answer(&mut self, request: &SubmitPromptRequest) -> Result<SubmitPromptAnswer> {
self.requests.push(request.clone());
Ok(self.answers.remove(0))
}
}
fn submit_prompt_request() -> SubmitPromptRequest {
SubmitPromptRequest {
branch: "feature".into(),
suggested_title: "Suggested title".into(),
suggested_body: "Suggested body".into(),
suggested_mode: PullRequestMode::Draft,
}
}
fn ready_answer() -> SubmitPromptAnswer {
SubmitPromptAnswer {
branch: "feature".into(),
title: "Reviewed title".into(),
body: "Reviewed body".into(),
mode: PullRequestMode::Ready,
}
}
fn submit_receipt() -> OperationReceipt {
OperationReceipt {
request: OperationRequest::SubmitStack {
new_pull_requests: PullRequestMode::Draft,
},
summary: "Submitted 1 branch".into(),
affected_branches: vec!["feature".into()],
outcome: OperationOutcome::Submitted {
pull_requests: vec![PullRequestReceipt {
branch: "feature".into(),
number: 1,
url: "https://example.com/pull/1".into(),
change: PullRequestChange::Created,
}],
},
transaction: None,
warnings: Vec::new(),
side_effects: OperationSideEffects::RemoteMayHaveChanged,
}
}
#[test]
fn interactive_default_calls_prepare_then_execute_once() {
let request = submit_prompt_request();
let mut backend = RecordingSubmitBackend::with_requests(vec![request.clone()]);
let answer = ready_answer();
let mut prompter = RecordingSubmitPrompter::answering(vec![answer.clone()]);
let mut reporter = NoopOperationReporter;
run_default_with_prompter(
SubmitScope::Stack,
&SubmitOptions::default(),
&mut backend,
&mut prompter,
&mut reporter,
)
.unwrap();
assert_eq!(backend.prepared_options.len(), 1);
assert_eq!(
backend.prepared_options[0].scope,
ApplicationSubmitScope::Stack
);
assert_eq!(
backend.prepared_options[0].new_pull_requests,
PullRequestMode::Draft
);
assert!(backend.prepared_options[0].fetch);
assert!(backend.prepared_options[0].verify_hooks);
assert_eq!(prompter.requests, vec![request]);
assert_eq!(backend.executed_answers, vec![vec![answer]]);
}
#[test]
fn no_prompt_default_calls_prepare_then_execute_once() {
let request = submit_prompt_request();
let mut backend = RecordingSubmitBackend::with_requests(vec![request.clone()]);
let mut prompter = RecordingSubmitPrompter::default();
let mut reporter = NoopOperationReporter;
let options = SubmitOptions {
no_prompt: true,
..SubmitOptions::default()
};
run_default_with_prompter(
SubmitScope::Stack,
&options,
&mut backend,
&mut prompter,
&mut reporter,
)
.unwrap();
assert_eq!(backend.prepared_options.len(), 1);
assert!(prompter.requests.is_empty());
assert_eq!(
backend.executed_answers,
vec![vec![SubmitPromptAnswer {
branch: request.branch,
title: request.suggested_title,
body: request.suggested_body,
mode: PullRequestMode::Draft,
}]]
);
}
#[test]
fn yes_default_uses_the_same_automatic_answers_as_no_prompt() {
let request = submit_prompt_request();
let mut backend = RecordingSubmitBackend::with_requests(vec![request.clone()]);
let mut prompter = RecordingSubmitPrompter::default();
let mut reporter = NoopOperationReporter;
let options = SubmitOptions {
yes: true,
..SubmitOptions::default()
};
run_default_with_prompter(
SubmitScope::Stack,
&options,
&mut backend,
&mut prompter,
&mut reporter,
)
.unwrap();
assert_eq!(backend.prepared_options.len(), 1);
assert!(prompter.requests.is_empty());
assert_eq!(backend.executed_answers.len(), 1);
assert_eq!(backend.executed_answers[0][0].mode, PullRequestMode::Draft);
}
#[test]
fn rejected_push_branches_extracts_porcelain_failures() {
let porcelain = "\
=\trefs/heads/feature-a:refs/heads/feature-a\t[up to date]\n\
!\trefs/heads/feature-b:refs/heads/feature-b\t[rejected] (stale info)\n\
!\trefs/heads/feature-c:refs/heads/feature-c\t[remote rejected] (hook declined)\n";
assert_eq!(
rejected_push_branches(
porcelain,
&[
push_spec("feature-a", "refs/heads/feature-a"),
push_spec("feature-b", "refs/heads/feature-b"),
push_spec("feature-c", "refs/heads/feature-c"),
],
),
vec!["feature-b".to_string(), "feature-c".to_string()]
);
}
#[test]
fn rejected_push_branches_matches_exact_branch_names() {
let porcelain = "!\trefs/heads/feature-a:refs/heads/feature-a\t[rejected]\n";
assert_eq!(
rejected_push_branches(
porcelain,
&[
push_spec("feature", "refs/heads/feature"),
push_spec("feature-a", "refs/heads/feature-a"),
],
),
vec!["feature-a".to_string()]
);
}
#[test]
fn rejected_push_branches_maps_temporary_refspecs_to_branch_names() {
let porcelain = "!\trefs/stax/submit/123/feature-a:refs/heads/feature-a\t[rejected]\n";
assert_eq!(
rejected_push_branches(
porcelain,
&[push_spec("feature-a", "refs/stax/submit/123/feature-a")],
),
vec!["feature-a".to_string()]
);
}
fn push_spec(branch: &str, source_ref: &str) -> PushSpec {
PushSpec {
branch: branch.to_string(),
source_ref: source_ref.to_string(),
oid: None,
expected_remote_oid: None,
}
}
#[test]
fn push_failure_details_preserves_stdout_when_stderr_is_empty() {
assert_eq!(
push_failure_details("pre-push hook blocked submit\n", ""),
"pre-push hook blocked submit"
);
}
#[test]
fn push_failure_details_labels_both_streams() {
assert_eq!(
push_failure_details("hook stdout\n", "hook stderr\n"),
"stdout:\nhook stdout\nstderr:\nhook stderr"
);
}
#[test]
fn stack_links_for_scoped_submit_use_full_current_stack_context() {
let stack = Stack {
trunk: "main".to_string(),
branches: HashMap::from([
(
"main".to_string(),
StackBranch {
name: "main".to_string(),
parent: None,
parent_revision: None,
children: vec!["base".to_string()],
needs_restack: false,
pr_number: None,
pr_state: None,
pr_is_draft: None,
},
),
(
"base".to_string(),
StackBranch {
name: "base".to_string(),
parent: Some("main".to_string()),
parent_revision: Some("main-sha".to_string()),
children: vec!["middle".to_string()],
needs_restack: false,
pr_number: Some(10),
pr_state: Some("OPEN".to_string()),
pr_is_draft: Some(false),
},
),
(
"middle".to_string(),
StackBranch {
name: "middle".to_string(),
parent: Some("base".to_string()),
parent_revision: Some("base-sha".to_string()),
children: vec!["leaf".to_string()],
needs_restack: false,
pr_number: Some(20),
pr_state: Some("OPEN".to_string()),
pr_is_draft: Some(false),
},
),
(
"leaf".to_string(),
StackBranch {
name: "leaf".to_string(),
parent: Some("middle".to_string()),
parent_revision: Some("middle-sha".to_string()),
children: vec![],
needs_restack: false,
pr_number: Some(30),
pr_state: Some("OPEN".to_string()),
pr_is_draft: Some(false),
},
),
]),
};
let imported_branches = HashSet::from(["base".to_string()]);
let infos = stack_pr_infos_for_links(
&stack,
"middle",
&[StackPrInfo {
branch: "middle".to_string(),
pr_number: Some(22),
is_imported: false,
depth: 0, }],
&imported_branches,
);
assert_eq!(
infos
.iter()
.map(|info| (info.branch.as_str(), info.pr_number, info.is_imported))
.collect::<Vec<_>>(),
vec![
("base", Some(10), true),
("middle", Some(22), false),
("leaf", Some(30), false)
]
);
}
#[test]
fn stack_pr_infos_for_links_gives_forked_siblings_equal_depth() {
let stack = Stack {
trunk: "main".to_string(),
branches: HashMap::from([
(
"main".to_string(),
StackBranch {
name: "main".to_string(),
parent: None,
parent_revision: None,
children: vec!["bottom".to_string()],
needs_restack: false,
pr_number: None,
pr_state: None,
pr_is_draft: None,
},
),
(
"bottom".to_string(),
StackBranch {
name: "bottom".to_string(),
parent: Some("main".to_string()),
parent_revision: Some("main-sha".to_string()),
children: vec!["fork-a".to_string(), "fork-b".to_string()],
needs_restack: false,
pr_number: Some(1),
pr_state: Some("OPEN".to_string()),
pr_is_draft: Some(false),
},
),
(
"fork-a".to_string(),
StackBranch {
name: "fork-a".to_string(),
parent: Some("bottom".to_string()),
parent_revision: Some("bottom-sha".to_string()),
children: vec![],
needs_restack: false,
pr_number: Some(2),
pr_state: Some("OPEN".to_string()),
pr_is_draft: Some(false),
},
),
(
"fork-b".to_string(),
StackBranch {
name: "fork-b".to_string(),
parent: Some("bottom".to_string()),
parent_revision: Some("bottom-sha".to_string()),
children: vec![],
needs_restack: false,
pr_number: Some(3),
pr_state: Some("OPEN".to_string()),
pr_is_draft: Some(false),
},
),
]),
};
let infos = stack_pr_infos_for_links(&stack, "bottom", &[], &HashSet::new());
let depths: HashMap<&str, usize> = infos
.iter()
.map(|info| (info.branch.as_str(), info.depth))
.collect();
assert_eq!(depths["bottom"], 1);
assert_eq!(
depths["fork-a"], depths["fork-b"],
"sibling branches sharing a parent must render at the same depth"
);
assert_eq!(depths["fork-a"], 2);
let all_branches: HashSet<&str> = HashSet::from(["bottom", "fork-a", "fork-b"]);
assert!(stack_has_fork(&stack, &all_branches));
let linear_subset: HashSet<&str> = HashSet::from(["bottom", "fork-a"]);
assert!(!stack_has_fork(&stack, &linear_subset));
}
#[test]
fn stack_link_sync_from_trunk_uses_each_pr_branch_context() {
let stack = Stack {
trunk: "main".to_string(),
branches: HashMap::from([
(
"main".to_string(),
StackBranch {
name: "main".to_string(),
parent: None,
parent_revision: None,
children: vec!["feature-a".to_string(), "feature-b".to_string()],
needs_restack: false,
pr_number: None,
pr_state: None,
pr_is_draft: None,
},
),
(
"feature-a".to_string(),
StackBranch {
name: "feature-a".to_string(),
parent: Some("main".to_string()),
parent_revision: Some("main-sha".to_string()),
children: vec![],
needs_restack: false,
pr_number: Some(10),
pr_state: Some("OPEN".to_string()),
pr_is_draft: Some(false),
},
),
(
"feature-b".to_string(),
StackBranch {
name: "feature-b".to_string(),
parent: Some("main".to_string()),
parent_revision: Some("main-sha".to_string()),
children: vec!["feature-b-child".to_string()],
needs_restack: false,
pr_number: Some(20),
pr_state: Some("OPEN".to_string()),
pr_is_draft: Some(false),
},
),
(
"feature-b-child".to_string(),
StackBranch {
name: "feature-b-child".to_string(),
parent: Some("feature-b".to_string()),
parent_revision: Some("feature-b-sha".to_string()),
children: vec![],
needs_restack: false,
pr_number: Some(30),
pr_state: Some("OPEN".to_string()),
pr_is_draft: Some(false),
},
),
]),
};
let contexts = stack_link_contexts_for_sync(&stack, "main", &[], &HashSet::new());
let context_for = |branch: &str| {
contexts
.iter()
.find(|(_, context_branch, _)| context_branch == branch)
.map(|(_, _, infos)| {
infos
.iter()
.map(|info| (info.branch.as_str(), info.pr_number))
.collect::<Vec<_>>()
})
.expect("expected branch context")
};
assert_eq!(context_for("feature-a"), vec![("feature-a", Some(10))]);
assert_eq!(
context_for("feature-b-child"),
vec![("feature-b", Some(20)), ("feature-b-child", Some(30))]
);
}
#[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");
}
#[test]
fn ai_without_modifiers_targets_title_and_body() {
assert_eq!(
resolve_ai_targets(true, false, false, false).unwrap(),
Some(AiPrTargets {
title: true,
body: true,
explicit_scope: false,
})
);
}
#[test]
fn body_scope_targets_body_only() {
assert_eq!(
resolve_ai_targets(true, false, true, false).unwrap(),
Some(AiPrTargets {
title: false,
body: true,
explicit_scope: true,
})
);
}
#[test]
fn title_scope_targets_title_only() {
assert_eq!(
resolve_ai_targets(true, true, false, false).unwrap(),
Some(AiPrTargets {
title: true,
body: false,
explicit_scope: true,
})
);
}
#[test]
fn title_and_body_scope_targets_both_explicitly() {
assert_eq!(
resolve_ai_targets(true, true, true, false).unwrap(),
Some(AiPrTargets {
title: true,
body: true,
explicit_scope: true,
})
);
}
#[test]
fn title_and_body_modifiers_require_ai() {
let err = resolve_ai_targets(false, true, false, false).unwrap_err();
assert!(err.to_string().contains("--title requires --ai"));
let err = resolve_ai_targets(false, false, true, false).unwrap_err();
assert!(err.to_string().contains("--body requires --ai"));
}
#[test]
fn update_title_conflicts_when_ai_generates_title() {
let err = resolve_ai_targets(true, false, false, true).unwrap_err();
assert!(
err.to_string()
.contains("--update-title cannot be combined")
);
let err = resolve_ai_targets(true, true, false, true).unwrap_err();
assert!(
err.to_string()
.contains("--update-title cannot be combined")
);
assert!(resolve_ai_targets(true, false, true, true).is_ok());
}
#[test]
fn auto_accept_plain_ai_skips_existing_pr_content_updates() {
let targets = resolve_ai_targets(true, false, false, false)
.unwrap()
.unwrap();
assert_eq!(existing_ai_targets_for_auto_accept(targets), None);
}
#[test]
fn auto_accept_explicit_title_updates_existing_pr_title() {
let targets = resolve_ai_targets(true, true, false, false)
.unwrap()
.unwrap();
assert_eq!(
existing_ai_targets_for_auto_accept(targets),
Some(AiPrTargets {
title: true,
body: false,
explicit_scope: true,
})
);
}
#[test]
fn auto_accept_explicit_body_updates_existing_pr_body() {
let targets = resolve_ai_targets(true, false, true, false)
.unwrap()
.unwrap();
assert_eq!(
existing_ai_targets_for_auto_accept(targets),
Some(AiPrTargets {
title: false,
body: true,
explicit_scope: true,
})
);
}
#[test]
fn auto_accept_explicit_title_and_body_updates_existing_pr_both() {
let targets = resolve_ai_targets(true, true, true, false)
.unwrap()
.unwrap();
assert_eq!(
existing_ai_targets_for_auto_accept(targets),
Some(AiPrTargets {
title: true,
body: true,
explicit_scope: true,
})
);
}
#[test]
fn existing_ai_prompt_choices_match_requested_scope() {
let full = AiPrTargets {
title: true,
body: true,
explicit_scope: false,
};
let labels: Vec<_> = existing_ai_prompt_items(full)
.into_iter()
.map(|(label, _)| label)
.collect();
assert_eq!(
labels,
vec![
"Skip",
"Update title",
"Update body",
"Update title and body"
]
);
let title_only = AiPrTargets {
title: true,
body: false,
explicit_scope: true,
};
let labels: Vec<_> = existing_ai_prompt_items(title_only)
.into_iter()
.map(|(label, _)| label)
.collect();
assert_eq!(labels, vec!["Skip", "Update title"]);
let body_only = AiPrTargets {
title: false,
body: true,
explicit_scope: true,
};
let labels: Vec<_> = existing_ai_prompt_items(body_only)
.into_iter()
.map(|(label, _)| label)
.collect();
assert_eq!(labels, vec!["Skip", "Update body"]);
}
#[test]
fn parses_ai_pr_details_json() {
let targets = AiPrTargets {
title: true,
body: true,
explicit_scope: false,
};
let details = parse_ai_pr_details(
r###"{"title":"Improve submit AI","body":"## Summary\n\nAdds AI PR details."}"###,
targets,
)
.unwrap();
assert_eq!(details.title.as_deref(), Some("Improve submit AI"));
assert_eq!(
details.body.as_deref(),
Some("## Summary\n\nAdds AI PR details.")
);
}
#[test]
fn parses_ai_pr_details_from_json_fence() {
let targets = AiPrTargets {
title: false,
body: true,
explicit_scope: true,
};
let details =
parse_ai_pr_details("```json\n{\"body\":\"Only update the body\"}\n```", targets)
.unwrap();
assert_eq!(details.title, None);
assert_eq!(details.body.as_deref(), Some("Only update the body"));
}
#[test]
fn parse_ai_pr_details_requires_requested_fields() {
let targets = AiPrTargets {
title: true,
body: false,
explicit_scope: true,
};
let err = parse_ai_pr_details(r###"{"body":"Body only"}"###, targets).unwrap_err();
assert!(err.to_string().contains("non-empty title"));
let targets = AiPrTargets {
title: false,
body: true,
explicit_scope: true,
};
let err = parse_ai_pr_details(r###"{"title":"Title only"}"###, targets).unwrap_err();
assert!(err.to_string().contains("non-empty body"));
}
#[test]
fn ai_prompt_for_title_only_requests_title_json_without_body_rules() {
let prompt = build_ai_pr_details_prompt(
"src/lib.rs | 1 +",
"diff --git a/src/lib.rs b/src/lib.rs",
&["Add scoped submit AI".to_string()],
Some("## Summary\n{{COMMITS}}"),
AiPrTargets {
title: true,
body: false,
explicit_scope: true,
},
);
assert!(prompt.contains("Generate a pull request title"));
assert!(prompt.contains("\"title\""));
assert!(!prompt.contains("\"body\""));
assert!(prompt.contains("Title requirements:"));
assert!(!prompt.contains("Use this PR template"));
}
#[test]
fn ai_prompt_for_body_only_uses_template_and_body_json() {
let prompt = build_ai_pr_details_prompt(
"",
"",
&[],
Some("## Summary\n{{COMMITS}}"),
AiPrTargets {
title: false,
body: true,
explicit_scope: true,
},
);
assert!(prompt.contains("Generate a pull request body"));
assert!(prompt.contains("\"body\""));
assert!(!prompt.contains("\"title\""));
assert!(prompt.contains("Use this PR template as the body structure"));
assert!(prompt.contains("## Summary"));
}
#[test]
fn truncate_ai_diff_does_not_split_utf8() {
let mut diff = "a".repeat(MAX_AI_DIFF_BYTES - 1);
diff.push('é');
diff.push_str("\nrest");
let truncated = truncate_ai_diff(&diff);
assert!(truncated.contains("diff truncated"));
assert!(truncated.is_char_boundary(truncated.len()));
}
}