use std::env;
use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use clap::ArgAction;
use clap_complete::engine::ArgValueCompleter;
use crate::cli::PushMode;
use crate::commands::Run;
use crate::completions;
use crate::providers::{BaseGap, NativeStack, ReviewProvider, ReviewState, detect_review_provider};
use crate::settings;
use crate::style;
use crate::{git, stack};
#[derive(Debug, clap::Args)]
pub struct Submit {
#[arg(add = ArgValueCompleter::new(completions::branch_candidates))]
branch: Option<String>,
#[arg(long, short = 'n', action = ArgAction::SetTrue)]
dry_run: bool,
#[arg(long, conflicts_with = "branch")]
stack: bool,
#[arg(long, action = ArgAction::SetTrue, conflicts_with = "stack")]
no_stack: bool,
#[arg(
long,
action = ArgAction::SetTrue,
conflicts_with_all = ["branch", "stack", "no_stack"],
)]
downstack: bool,
#[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_push")]
push: bool,
#[arg(long, action = ArgAction::SetTrue)]
no_push: bool,
#[arg(long, short = 't', value_name = "TEXT")]
title: Option<String>,
#[arg(long, short = 'd')]
desc: Option<String>,
#[arg(
long = "desc-file",
value_name = "PATH",
value_hint = clap::ValueHint::FilePath,
conflicts_with = "desc",
)]
desc_file: Option<PathBuf>,
#[arg(long, value_name = "CSV", value_delimiter = ',')]
reviewers: Vec<String>,
#[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_draft")]
draft: bool,
#[arg(long, action = ArgAction::SetTrue)]
no_draft: bool,
#[arg(long, action = ArgAction::SetTrue, conflicts_with = "draft")]
ready: bool,
#[arg(long, action = ArgAction::SetTrue)]
rebuild_overview: bool,
}
impl Run for Submit {
fn run(self) -> Result<()> {
let submit_stack = if self.stack {
true
} else if self.no_stack || self.branch.is_some() {
false
} else {
settings::bool_setting(settings::SUBMIT_STACK_KEY)?
};
let draft = if self.draft {
true
} else if self.no_draft {
false
} else {
settings::bool_setting(settings::SUBMIT_DRAFT_KEY)?
};
let desc = match self.desc_file {
Some(path) => {
let path = expand_tilde(path);
let raw = std::fs::read_to_string(&path).with_context(|| {
format!("failed to read description file {}", path.display())
})?;
Some(raw.trim().to_owned())
}
None => self.desc,
};
let title = match self.title {
Some(title) if title.trim().is_empty() => bail!("--title cannot be empty"),
Some(title) => Some(title.trim().to_owned()),
None => None,
};
submit(SubmitOptions {
branch: self.branch,
submit_stack,
downstack: self.downstack,
dry_run: self.dry_run,
push_mode: PushMode::from_flags(self.push, self.no_push),
title,
desc,
reviewers: normalize_reviewers(&self.reviewers),
draft,
ready: self.ready,
rebuild_overview: self.rebuild_overview,
})
}
}
fn normalize_reviewers(raw: &[String]) -> Vec<String> {
let mut reviewers: Vec<String> = Vec::new();
for entry in raw {
let trimmed = entry.trim();
let stripped = trimmed.strip_prefix('@').unwrap_or(trimmed).trim();
let name = if stripped.eq_ignore_ascii_case("copilot") {
"@copilot"
} else {
stripped
};
if name.is_empty() || reviewers.iter().any(|seen| seen == name) {
continue;
}
reviewers.push(name.to_owned());
}
reviewers
}
pub struct SubmitOptions {
pub branch: Option<String>,
pub submit_stack: bool,
pub downstack: bool,
pub dry_run: bool,
pub push_mode: crate::cli::PushMode,
pub title: Option<String>,
pub desc: Option<String>,
pub reviewers: Vec<String>,
pub draft: bool,
pub ready: bool,
pub rebuild_overview: bool,
}
fn expand_tilde(path: PathBuf) -> PathBuf {
let home = env::var_os("HOME")
.or_else(|| env::var_os("USERPROFILE"))
.map(PathBuf::from);
expand_tilde_with(path, home)
}
fn expand_tilde_with(path: PathBuf, home: Option<PathBuf>) -> PathBuf {
let Some(rest) = path.to_str().and_then(|text| text.strip_prefix('~')) else {
return path;
};
let mut chars = rest.chars();
let tail = match chars.next() {
None => "",
Some(separator) if std::path::is_separator(separator) => chars.as_str(),
Some(_) => return path,
};
let Some(home) = home else {
return path;
};
if tail.is_empty() {
home
} else {
home.join(tail)
}
}
pub fn submit(options: SubmitOptions) -> Result<()> {
let SubmitOptions {
branch,
submit_stack,
downstack,
dry_run,
push_mode,
title,
desc,
reviewers,
draft,
ready,
rebuild_overview,
} = options;
let branch = branch.map_or_else(git::current_branch, Ok)?;
let target_branch = branch.clone();
let mut branches = if downstack {
stack::path_from_root(&branch)?
} else if submit_stack {
stack::stack_line(&branch)?
} else {
vec![branch.clone()]
};
if submit_stack || downstack {
let trunk = stack::trunk_branch(&git::local_branches()?);
if Some(&branch) == trunk.as_ref() {
if !stack::has_stacked_branches()? {
bail!("no stacked branches to submit");
}
bail!("you are on the trunk ({branch}); check out a stacked branch first");
}
}
let mut base = None;
if submit_stack || downstack {
base = stack::unanchored_base(&branches)?;
let unmarked_base = base.is_none()
&& branches.len() == 1
&& stack::parent_of(&branches[0])?.is_none()
&& !stack::children_of(&branches[0])?.is_empty();
if let Some(found) = &base {
branches.retain(|branch| branch != found);
}
if unmarked_base || (base.is_some() && branches.is_empty()) {
let name = base.as_deref().unwrap_or_else(|| branches[0].as_str());
return Err(base_has_nothing_to_submit(name)?);
}
if let Some(found) = &base {
anstream::println!(
"{}",
style::dim(&format!("{found} is this stack's base; not submitted"))
);
}
}
let target_in_scope = branches.contains(&target_branch);
let branch_parents = branch_parents(&branches)?;
let push = settings::push_enabled(push_mode, settings::PUSH_ON_SUBMIT_KEY)?;
if let Some(base) = &base
&& push
{
let remote = settings::remote()?;
if !git::remote_has_branch(&remote, base)? {
let lowest = &branches[0];
bail!(
"{base} is this stack's base, but {remote} has no such branch; \
push {base} to {remote} first, or re-root the stack with \
`git stk adopt {lowest} --parent <parent>`"
);
}
}
if push {
let remote = settings::remote()?;
if dry_run {
anstream::println!(
"would push {} to {remote}",
style::branch(&branches.join(" "))
);
} else {
git::push_set_upstream_force_with_lease(&remote, &branches)?;
anstream::println!("pushed {} to {remote}", style::branch(&branches.join(" ")));
stack::publish_metadata(&remote);
}
}
let (provider, review_provider) = detect_review_provider()?;
let mut summary = SubmitSummary::default();
let mut created = Vec::new();
for (branch, parent) in &branch_parents {
let branch_title = title.as_deref().filter(|_| *branch == target_branch);
let action = submit_branch(
review_provider.as_ref(),
branch,
parent,
dry_run,
draft,
branch_title,
)?;
if action == SubmitAction::Created {
created.push(branch.clone());
}
summary.record(action);
}
let desc_target = desc.as_ref().map(|_| target_branch.as_str());
crate::notes::seed_template_notes(
review_provider.as_ref(),
provider.kind,
&created,
desc_target,
dry_run,
)?;
if ready {
for branch in &branches {
let Some(review) = review_provider.review_for_branch(branch)? else {
continue;
};
if review.branch != *branch || !review.draft {
continue;
}
if dry_run {
anstream::println!("would mark {} ready", review.id);
continue;
}
let output = review_provider.mark_ready(&review)?;
anstream::println!("marked {} ready", review.id);
if !output.is_empty() {
println!("{output}");
}
}
}
let renamed: Vec<(String, String)> = if submit_stack || downstack {
branch_parents
.iter()
.filter_map(|(branch, _)| {
stack::renamed_from(branch)
.ok()
.flatten()
.map(|old| (branch.clone(), old))
})
.collect()
} else {
Vec::new()
};
let mut reconciled: Vec<&str> = Vec::new();
for (branch, old) in &renamed {
if close_superseded_review(review_provider.as_ref(), old, dry_run)? {
reconciled.push(branch);
}
}
if let Some(title) = &title {
if !target_in_scope {
anstream::println!("skipped title: {target_branch} is this stack's base");
} else if !created.contains(&target_branch) {
apply_title(review_provider.as_ref(), &target_branch, title, dry_run)?;
}
}
if let Some(desc) = desc {
if target_in_scope {
crate::notes::update_description_note(
review_provider.as_ref(),
&target_branch,
&desc,
dry_run,
)?;
} else {
anstream::println!("skipped description: {target_branch} is this stack's base");
}
}
crate::notes::update_closes_notes(review_provider.as_ref(), &branches, dry_run)?;
if submit_stack || downstack {
crate::notes::update_stack_notes(
review_provider.as_ref(),
&branch_parents,
dry_run,
rebuild_overview,
)?;
}
if submit_stack || downstack {
register_native_stack(review_provider.as_ref(), &branches, dry_run)?;
}
apply_reviewers(review_provider.as_ref(), &branches, &reviewers, dry_run)?;
if !dry_run {
for branch in &reconciled {
stack::clear_renamed_from(branch)?;
}
}
anstream::println!(
"{}",
style::success(&format!(
"submit complete: {} created, {} updated, {} skipped",
summary.created, summary.updated, summary.skipped
))
);
Ok(())
}
fn close_superseded_review(
review_provider: &dyn ReviewProvider,
old: &str,
dry_run: bool,
) -> Result<bool> {
let Some(review) = review_provider.review_for_branch(old)? else {
return Ok(true);
};
if review.branch != *old {
return Ok(true);
}
if dry_run {
anstream::println!("would close superseded review {} for {old}", review.id);
return Ok(true);
}
if !crate::prompt::confirm_default_yes(&format!(
"close the replaced review {} for {old} and delete its branch? [Y/n] ",
review.id
))? {
anstream::println!("kept review {} for {old}", review.id);
return Ok(false);
}
review_provider.close_review(&review, true)?;
anstream::println!("closed superseded review {} for {old}", review.id);
Ok(true)
}
fn apply_title(
review_provider: &dyn ReviewProvider,
branch: &str,
title: &str,
dry_run: bool,
) -> Result<()> {
let Some(review) = review_provider.review_for_branch(branch)? else {
if dry_run {
anstream::println!("would set the title on the review for {branch}");
} else {
anstream::println!("skipped title: no review found for {branch}");
}
return Ok(());
};
if review.branch != branch {
anstream::println!(
"skipped title: review {} belongs to {}",
review.id,
review.branch
);
return Ok(());
}
if dry_run {
anstream::println!("would set the title in {}", review.id);
return Ok(());
}
let output = review_provider.update_review_title(&review, title)?;
anstream::println!("set title in {}", review.id);
if !output.is_empty() {
println!("{output}");
}
Ok(())
}
fn apply_reviewers(
review_provider: &dyn ReviewProvider,
branches: &[String],
reviewers: &[String],
dry_run: bool,
) -> Result<()> {
if reviewers.is_empty() {
return Ok(());
}
let list = reviewers.join(", ");
for branch in branches {
let Some(review) = review_provider.review_for_branch(branch)? else {
if dry_run {
anstream::println!("would request reviews from {list} for {branch}");
} else {
anstream::println!("skipped reviewers: no review found for {branch}");
}
continue;
};
if review.branch != *branch || review.state == ReviewState::Merged {
continue;
}
if dry_run {
anstream::println!("would request reviews from {list} in {}", review.id);
continue;
}
let output = review_provider.request_reviewers(&review, reviewers)?;
anstream::println!("requested reviews from {list} in {}", review.id);
if !output.is_empty() {
println!("{output}");
}
}
Ok(())
}
fn base_has_nothing_to_submit(branch: &str) -> Result<anyhow::Error> {
if stack::children_of(branch)?.is_empty() {
return Ok(anyhow::anyhow!(
"{branch} is this stack's base, and nothing is stacked on it"
));
}
Ok(anyhow::anyhow!(
"{branch} is this stack's base; there is nothing below it to submit - \
run `git stk submit --stack` from {branch} to submit the branches above it"
))
}
fn register_native_stack(
review_provider: &dyn ReviewProvider,
branches: &[String],
dry_run: bool,
) -> Result<()> {
if branches.is_empty() {
return Ok(());
}
if !review_provider.registers_stacks() {
return Ok(());
}
let existing = branches
.iter()
.find_map(|branch| review_provider.native_stack_for(branch).ok().flatten());
let mut reviews = Vec::with_capacity(branches.len());
for branch in branches {
let Some(review) = review_provider.review_for_branch(branch)? else {
if !dry_run {
anstream::println!("skipped stack registration: no review found for {branch}");
}
return Ok(());
};
if review.branch != *branch {
return Ok(());
}
reviews.push(review.id);
}
if dry_run {
if let Some(action) = would_register(review_provider, &reviews, existing.as_ref()) {
anstream::println!("{action}");
}
return Ok(());
}
match review_provider.register_stack(&reviews, existing.as_ref()) {
Ok(Some(line)) => anstream::println!("{line}"),
Ok(None) => {}
Err(error) => anstream::println!(
"{}",
style::warn(&format!("stack registration failed: {error}"))
),
}
Ok(())
}
fn would_register(
review_provider: &dyn ReviewProvider,
reviews: &[String],
existing: Option<&NativeStack>,
) -> Option<String> {
if !review_provider.registers_stacks() {
return None;
}
match crate::providers::plan_stack_registration(reviews, existing)? {
crate::providers::StackPlan::Register(reviews) => {
Some(format!("would register {} as a stack", reviews.join(" ")))
}
crate::providers::StackPlan::Extend { number, fresh } => Some(format!(
"would extend stack {number} with {}",
fresh.join(" ")
)),
crate::providers::StackPlan::Mismatch { number } => Some(format!(
"would leave stack {number} as recorded: it no longer matches this stack"
)),
}
}
fn branch_parents(branches: &[String]) -> Result<Vec<(String, String)>> {
let mut branch_parents = Vec::new();
for branch in branches {
if Some(branch) == stack::trunk_branch(&git::local_branches()?).as_ref() {
if !git::current_branch().is_ok_and(|current| current == *branch) {
bail!(
"{branch} is the trunk, so it is never part of a stack - \
name a stacked branch instead"
);
}
if !stack::has_stacked_branches()? {
bail!("no stacked branches to submit");
}
bail!("you are on the trunk ({branch}); check out a stacked branch first");
}
let is_base = stack::is_floor(branch)?
|| (stack::parent_of(branch)?.is_none() && !stack::children_of(branch)?.is_empty());
if is_base {
return Err(base_has_nothing_to_submit(branch)?);
}
let Some(parent) = stack::parent_of(branch)? else {
bail!(
"{branch} has no stack parent; attach it with \
`git stk adopt {branch} --parent <parent>`, \
or rebuild its metadata with `git stk repair`"
);
};
branch_parents.push((branch.to_owned(), parent));
}
Ok(branch_parents)
}
fn submit_branch(
review_provider: &dyn ReviewProvider,
branch: &str,
parent: &str,
dry_run: bool,
draft: bool,
title: Option<&str>,
) -> Result<SubmitAction> {
if let Some(review) = review_provider.review_for_branch(branch)? {
if review.base == parent {
if dry_run {
anstream::println!(
"would skip {} -> {} ({})",
review.branch,
review.base,
review.id
);
} else {
anstream::println!(
"{}",
style::dim(&format!(
"{} already targets {} ({})",
review.branch, review.base, review.id
))
);
}
return Ok(SubmitAction::Skipped);
}
match review_provider.base_gap(&review, parent)? {
Some(BaseGap::Platform) => {
anstream::println!(
"{}",
style::dim(&format!(
"{} targets {} and is in a stack; the platform moves it as the stack lands",
review.id, review.base
))
);
return Ok(SubmitAction::Skipped);
}
Some(BaseGap::Sync) => {
anstream::println!(
"{}",
style::warn(&format!(
"{} already targets {} - the platform moved it when {parent} landed; \
run `git stk sync` to catch the local stack up",
review.id, review.base
))
);
return Ok(SubmitAction::Skipped);
}
Some(BaseGap::Neither) => {
anstream::println!(
"{}",
style::warn(&format!(
"{} targets {} but should target {parent}, and its stack will not \
move it there - the platform refuses a change by hand too; \
run `git stk unstack` and submit again",
review.id, review.base
))
);
return Ok(SubmitAction::Skipped);
}
None => {}
}
let output = if dry_run {
String::new()
} else {
review_provider.update_review_base(&review, parent)?
};
anstream::println!(
"{} {} -> {} {}",
if dry_run { "would update" } else { "updated" },
style::branch(&review.branch),
style::branch(parent),
style::dim(&format!("({})", review.id))
);
if !output.is_empty() {
println!("{output}");
}
} else {
let output = if dry_run {
String::new()
} else {
review_provider.create_review(branch, parent, draft, title)?
};
anstream::println!(
"{} {} -> {}{}",
if dry_run { "would create" } else { "created" },
style::branch(branch),
style::branch(parent),
title.map_or_else(String::new, |title| format!(" titled \"{title}\""))
);
if !output.is_empty() {
println!("{output}");
}
return Ok(SubmitAction::Created);
}
Ok(SubmitAction::Updated)
}
#[derive(Debug, Default)]
struct SubmitSummary {
created: usize,
updated: usize,
skipped: usize,
}
impl SubmitSummary {
fn record(&mut self, action: SubmitAction) {
match action {
SubmitAction::Created => self.created += 1,
SubmitAction::Updated => self.updated += 1,
SubmitAction::Skipped => self.skipped += 1,
}
}
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum SubmitAction {
Created,
Updated,
Skipped,
}
#[cfg(test)]
mod tests {
use super::*;
fn home() -> Option<PathBuf> {
Some(PathBuf::from("/home/dev"))
}
#[test]
fn expand_tilde_resolves_a_bare_tilde_and_subpaths() {
assert_eq!(
expand_tilde_with(PathBuf::from("~"), home()),
PathBuf::from("/home/dev")
);
assert_eq!(
expand_tilde_with(PathBuf::from("~/notes/pr.md"), home()),
PathBuf::from("/home/dev/notes/pr.md")
);
}
#[test]
fn expand_tilde_leaves_other_paths_untouched() {
for raw in ["/etc/pr.md", "notes/pr.md", "~alice/pr.md", "docs/~x.md"] {
assert_eq!(
expand_tilde_with(PathBuf::from(raw), home()),
PathBuf::from(raw)
);
}
}
#[test]
fn expand_tilde_passes_through_when_home_is_unset() {
assert_eq!(
expand_tilde_with(PathBuf::from("~/pr.md"), None),
PathBuf::from("~/pr.md")
);
}
fn reviewers(raw: &[&str]) -> Vec<String> {
normalize_reviewers(
&raw.iter()
.map(|entry| (*entry).to_owned())
.collect::<Vec<_>>(),
)
}
#[test]
fn normalize_reviewers_strips_at_and_trims() {
assert_eq!(reviewers(&["@foo", "@bar"]), vec!["foo", "bar"]);
assert_eq!(reviewers(&["foo", "bar"]), vec!["foo", "bar"]);
assert_eq!(reviewers(&[" @foo ", " bar"]), vec!["foo", "bar"]);
}
#[test]
fn normalize_reviewers_keeps_team_paths_but_drops_the_at() {
assert_eq!(
reviewers(&["@my-org/backend", "acme/team"]),
vec!["my-org/backend", "acme/team"]
);
}
#[test]
fn normalize_reviewers_drops_blanks_and_dedupes_in_order() {
assert_eq!(
reviewers(&["foo", "", " ", "@foo", "bar", "@bar"]),
vec!["foo", "bar"]
);
}
#[test]
fn normalize_reviewers_preserves_the_copilot_at_prefix() {
assert_eq!(reviewers(&["@copilot"]), vec!["@copilot"]);
assert_eq!(reviewers(&["copilot"]), vec!["@copilot"]);
assert_eq!(reviewers(&["@Copilot", "copilot"]), vec!["@copilot"]);
}
#[cfg(windows)]
#[test]
fn expand_tilde_accepts_a_backslash_on_windows() {
assert_eq!(
expand_tilde_with(PathBuf::from(r"~\notes\pr.md"), home()),
PathBuf::from("/home/dev").join(r"notes\pr.md")
);
}
}