Skip to main content

git_stk/commands/
submit.rs

1use std::env;
2use std::path::PathBuf;
3
4use anyhow::{Context, Result, bail};
5use clap::ArgAction;
6use clap_complete::engine::ArgValueCompleter;
7
8use crate::cli::PushMode;
9use crate::commands::Run;
10use crate::completions;
11use crate::providers::{BaseGap, NativeStack, ReviewProvider, ReviewState, detect_review_provider};
12use crate::settings;
13use crate::style;
14use crate::{git, stack};
15
16/// Create or update a remote review request for a branch.
17#[derive(Debug, clap::Args)]
18pub struct Submit {
19    /// Branch to submit (defaults to the current branch).
20    #[arg(add = ArgValueCompleter::new(completions::branch_candidates))]
21    branch: Option<String>,
22    /// Print what would change without creating or updating reviews.
23    #[arg(long, short = 'n', action = ArgAction::SetTrue)]
24    dry_run: bool,
25    /// Submit the whole stack parent-first, from anywhere in it.
26    #[arg(long, conflicts_with = "branch")]
27    stack: bool,
28    /// Submit only the current branch, overriding stk.submitStack.
29    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "stack")]
30    no_stack: bool,
31    /// Submit the stack from its bottom through the current branch only,
32    /// leaving work-in-progress branches above it unsubmitted.
33    #[arg(
34        long,
35        action = ArgAction::SetTrue,
36        conflicts_with_all = ["branch", "stack", "no_stack"],
37    )]
38    downstack: bool,
39    /// Push branches (-u --force-with-lease) before submitting.
40    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_push")]
41    push: bool,
42    /// Do not push branches, overriding stk.pushOnSubmit.
43    #[arg(long, action = ArgAction::SetTrue)]
44    no_push: bool,
45    /// Set the review's title, replacing the branch tip's commit subject.
46    /// Applies to the current or named branch only.
47    #[arg(long, short = 't', value_name = "TEXT")]
48    title: Option<String>,
49    /// Set a description block at the top of the review body; an empty
50    /// string clears it. Applies to the current or named branch only.
51    #[arg(long, short = 'd')]
52    desc: Option<String>,
53    /// Read the description block from a markdown or text file instead of an
54    /// inline string, handy for agent-authored bodies. Incompatible with
55    /// --desc; an empty file clears the block, like `--desc ""`.
56    #[arg(
57        long = "desc-file",
58        value_name = "PATH",
59        value_hint = clap::ValueHint::FilePath,
60        conflicts_with = "desc",
61    )]
62    desc_file: Option<PathBuf>,
63    /// Request reviews from these users or teams on every submitted review
64    /// (comma-separated, or repeat the flag). A leading `@` is optional and
65    /// stripped, so `@foo,@bar` and `foo,bar` mean the same. GitHub/Gitea team
66    /// reviewers use the `org/team` form (`@my-org/backend`).
67    #[arg(long, value_name = "CSV", value_delimiter = ',')]
68    reviewers: Vec<String>,
69    /// Create new reviews as drafts.
70    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_draft")]
71    draft: bool,
72    /// Create new reviews ready for review, overriding stk.submitDraft.
73    #[arg(long, action = ArgAction::SetTrue)]
74    no_draft: bool,
75    /// Mark the submitted branches' existing draft reviews as ready.
76    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "draft")]
77    ready: bool,
78    /// Rebuild each review's stack overview from the live stack plus merged
79    /// history, dropping closed or orphaned rows that drifted in. Stack mode.
80    #[arg(long, action = ArgAction::SetTrue)]
81    rebuild_overview: bool,
82}
83
84impl Run for Submit {
85    fn run(self) -> Result<()> {
86        // Stack mode: --stack forces it on; --no-stack or an explicit branch
87        // forces it off; otherwise stk.submitStack decides.
88        let submit_stack = if self.stack {
89            true
90        } else if self.no_stack || self.branch.is_some() {
91            false
92        } else {
93            settings::bool_setting(settings::SUBMIT_STACK_KEY)?
94        };
95
96        // Draft mode: --draft forces it on, --no-draft off; otherwise
97        // stk.submitDraft decides.
98        let draft = if self.draft {
99            true
100        } else if self.no_draft {
101            false
102        } else {
103            settings::bool_setting(settings::SUBMIT_DRAFT_KEY)?
104        };
105
106        // A file source resolves to the same description string; clap's
107        // conflicts_with guarantees at most one of the two is set.
108        let desc = match self.desc_file {
109            Some(path) => {
110                let path = expand_tilde(path);
111                let raw = std::fs::read_to_string(&path).with_context(|| {
112                    format!("failed to read description file {}", path.display())
113                })?;
114                Some(raw.trim().to_owned())
115            }
116            None => self.desc,
117        };
118
119        // Unlike a description, a title has no "clear it" meaning - every
120        // review must have one - so an empty string is a mistake, not a verb.
121        let title = match self.title {
122            Some(title) if title.trim().is_empty() => bail!("--title cannot be empty"),
123            Some(title) => Some(title.trim().to_owned()),
124            None => None,
125        };
126
127        submit(SubmitOptions {
128            branch: self.branch,
129            submit_stack,
130            downstack: self.downstack,
131            dry_run: self.dry_run,
132            push_mode: PushMode::from_flags(self.push, self.no_push),
133            title,
134            desc,
135            reviewers: normalize_reviewers(&self.reviewers),
136            draft,
137            ready: self.ready,
138            rebuild_overview: self.rebuild_overview,
139        })
140    }
141}
142
143/// Clean a raw `--reviewers` list: trim each entry, drop one optional leading
144/// `@` (so `@foo` and `foo` are the same), discard blanks, and de-duplicate
145/// while preserving order. A team keeps its `org/team` form - only the `@`
146/// prefix is stripped, never the slash. GitHub's Copilot reviewer is the one
147/// login that *needs* its `@` (`@copilot`), so it is preserved (and a bare
148/// `copilot` is canonicalized to it) rather than stripped like a username.
149fn normalize_reviewers(raw: &[String]) -> Vec<String> {
150    let mut reviewers: Vec<String> = Vec::new();
151    for entry in raw {
152        let trimmed = entry.trim();
153        let stripped = trimmed.strip_prefix('@').unwrap_or(trimmed).trim();
154        let name = if stripped.eq_ignore_ascii_case("copilot") {
155            "@copilot"
156        } else {
157            stripped
158        };
159        if name.is_empty() || reviewers.iter().any(|seen| seen == name) {
160            continue;
161        }
162        reviewers.push(name.to_owned());
163    }
164    reviewers
165}
166
167/// The resolved inputs for [`submit`] - one bundle instead of nine positional
168/// arguments. `Submit::run` resolves the flag/config defaults and fills it.
169pub struct SubmitOptions {
170    pub branch: Option<String>,
171    pub submit_stack: bool,
172    pub downstack: bool,
173    pub dry_run: bool,
174    pub push_mode: crate::cli::PushMode,
175    pub title: Option<String>,
176    pub desc: Option<String>,
177    pub reviewers: Vec<String>,
178    pub draft: bool,
179    pub ready: bool,
180    pub rebuild_overview: bool,
181}
182
183/// Expand a leading `~` in a `--desc-file` path to the user's home, since the
184/// path reaches us literally when the shell did not expand it (quoted, or
185/// handed over by a script or agent). Cross-platform: `HOME` on Unix (and
186/// Git Bash/WSL), falling back to `USERPROFILE` on native Windows.
187fn expand_tilde(path: PathBuf) -> PathBuf {
188    let home = env::var_os("HOME")
189        .or_else(|| env::var_os("USERPROFILE"))
190        .map(PathBuf::from);
191    expand_tilde_with(path, home)
192}
193
194fn expand_tilde_with(path: PathBuf, home: Option<PathBuf>) -> PathBuf {
195    let Some(rest) = path.to_str().and_then(|text| text.strip_prefix('~')) else {
196        return path;
197    };
198    // Only bare `~` and `~/...` (or `~\...` on Windows) expand; a `~user` form
199    // would need a passwd lookup we do not do, so leave it untouched.
200    let mut chars = rest.chars();
201    let tail = match chars.next() {
202        None => "",
203        Some(separator) if std::path::is_separator(separator) => chars.as_str(),
204        Some(_) => return path,
205    };
206    let Some(home) = home else {
207        return path;
208    };
209    if tail.is_empty() {
210        home
211    } else {
212        home.join(tail)
213    }
214}
215
216pub fn submit(options: SubmitOptions) -> Result<()> {
217    let SubmitOptions {
218        branch,
219        submit_stack,
220        downstack,
221        dry_run,
222        push_mode,
223        title,
224        desc,
225        reviewers,
226        draft,
227        ready,
228        rebuild_overview,
229    } = options;
230
231    let branch = branch.map_or_else(git::current_branch, Ok)?;
232    // The title and description target this branch's review even in stack mode.
233    let target_branch = branch.clone();
234
235    let mut branches = if downstack {
236        // Bottom of the stack through the current branch: anything above is
237        // work in progress that stays local.
238        stack::path_from_root(&branch)?
239    } else if submit_stack {
240        // The stack containing the current branch: its own line, bottom
241        // through current and out to its descendants. Sibling stacks that
242        // merely share the trunk are left for their own submit.
243        stack::stack_line(&branch)?
244    } else {
245        vec![branch.clone()]
246    };
247
248    // The trunk is never part of a stack, so a stack-wide submit from it has
249    // nothing of its own to submit (its descendants are sibling stacks). Say so
250    // plainly rather than pushing an empty set or sweeping every stack.
251    if submit_stack || downstack {
252        let trunk = stack::trunk_branch(&git::local_branches()?);
253        if Some(&branch) == trunk.as_ref() {
254            if !stack::has_stacked_branches()? {
255                bail!("no stacked branches to submit");
256            }
257            bail!("you are on the trunk ({branch}); check out a stacked branch first");
258        }
259    }
260
261    // A line rooted off the trunk keeps its parentless root - the branch the
262    // one above it targets, not something to submit. Drop it so stack mode
263    // ships exactly what `--no-stack` does, rather than refusing a shape
264    // `new` and `adopt` both accept. Restack already treats such a root as
265    // the floor; this keeps submit, and the push below, in step with it.
266    let mut base = None;
267    if submit_stack || downstack {
268        base = stack::unanchored_base(&branches)?;
269        // `--downstack` standing on an unmarked base: `path_from_root` stops
270        // at the branch you are on, so the slice is just the base and there is
271        // nothing for `unanchored_base` to read the shape from. Its children
272        // still say what it is.
273        let unmarked_base = base.is_none()
274            && branches.len() == 1
275            && stack::parent_of(&branches[0])?.is_none()
276            && !stack::children_of(&branches[0])?.is_empty();
277
278        if let Some(found) = &base {
279            branches.retain(|branch| branch != found);
280        }
281
282        // Nothing left once the base is out: everything stacked here is above
283        // you. Name the base rather than report a no-op, or let
284        // `branch_parents` call it unstacked and offer to re-root it.
285        if unmarked_base || (base.is_some() && branches.is_empty()) {
286            let name = base.as_deref().unwrap_or_else(|| branches[0].as_str());
287            return Err(base_has_nothing_to_submit(name)?);
288        }
289
290        if let Some(found) = &base {
291            anstream::println!(
292                "{}",
293                style::dim(&format!("{found} is this stack's base; not submitted"))
294            );
295        }
296    }
297
298    // `--title`/`--desc` act on the current branch. When that is the base the
299    // trim just dropped, it is not part of the stack and its review - a
300    // release PR, say - is not ours to retitle or write a description into.
301    let target_in_scope = branches.contains(&target_branch);
302
303    let branch_parents = branch_parents(&branches)?;
304
305    // Push after stack validation but before any provider calls: creating a
306    // review requires the branch to exist remotely, and -u --force-with-lease
307    // covers both first pushes and safely updating rebased branches.
308    let push = settings::push_enabled(push_mode, settings::PUSH_ON_SUBMIT_KEY)?;
309
310    // The base is not pushed with the stack - it is not ours to move - but it
311    // is the base of the review opened for the branch above it. Every other
312    // base is a branch we just pushed, so this is the one that can be missing
313    // from the remote; catch it here rather than let the forge reject the
314    // create with its own wording, after the push has already happened.
315    if let Some(base) = &base
316        && push
317    {
318        let remote = settings::remote()?;
319        if !git::remote_has_branch(&remote, base)? {
320            // Name the branch to re-root. `adopt` defaults to the branch you
321            // are on, and standing on the base is a supported position here -
322            // so a bare `adopt --parent` would re-root the base itself, the
323            // very thing this stack stopped suggesting.
324            let lowest = &branches[0];
325            bail!(
326                "{base} is this stack's base, but {remote} has no such branch; \
327                 push {base} to {remote} first, or re-root the stack with \
328                 `git stk adopt {lowest} --parent <parent>`"
329            );
330        }
331    }
332    if push {
333        let remote = settings::remote()?;
334        if dry_run {
335            anstream::println!(
336                "would push {} to {remote}",
337                style::branch(&branches.join(" "))
338            );
339        } else {
340            git::push_set_upstream_force_with_lease(&remote, &branches)?;
341            anstream::println!("pushed {} to {remote}", style::branch(&branches.join(" ")));
342            // Carry the stack's parent map along so another clone can rebuild
343            // it with `git stk repair --from-remote`.
344            stack::publish_metadata(&remote);
345        }
346    }
347
348    let (provider, review_provider) = detect_review_provider()?;
349    let mut summary = SubmitSummary::default();
350
351    let mut created = Vec::new();
352    for (branch, parent) in &branch_parents {
353        // A new review opens under the given title directly, so it is never
354        // briefly published under the commit subject.
355        let branch_title = title.as_deref().filter(|_| *branch == target_branch);
356        let action = submit_branch(
357            review_provider.as_ref(),
358            branch,
359            parent,
360            dry_run,
361            draft,
362            branch_title,
363        )?;
364        if action == SubmitAction::Created {
365            created.push(branch.clone());
366        }
367        summary.record(action);
368    }
369
370    // Seed freshly created reviews from the repo's PR template before the
371    // managed sections go in, so our content joins it rather than replacing it.
372    // Without a user description the template is wrapped in the managed
373    // description block; the branch that gets `--desc` keeps the template
374    // freeform above a seam so the description reads as a distinct block below.
375    let desc_target = desc.as_ref().map(|_| target_branch.as_str());
376    crate::notes::seed_template_notes(
377        review_provider.as_ref(),
378        provider.kind,
379        &created,
380        desc_target,
381        dry_run,
382    )?;
383
384    // Flip drafts in scope to ready for review (the escape hatch for
385    // stk.submitDraft users).
386    if ready {
387        for branch in &branches {
388            let Some(review) = review_provider.review_for_branch(branch)? else {
389                continue;
390            };
391            if review.branch != *branch || !review.draft {
392                continue;
393            }
394            if dry_run {
395                anstream::println!("would mark {} ready", review.id);
396                continue;
397            }
398            let output = review_provider.mark_ready(&review)?;
399            anstream::println!("marked {} ready", review.id);
400            if !output.is_empty() {
401                println!("{output}");
402            }
403        }
404    }
405
406    // A renamed branch's fresh review now exists, so retire the review the old
407    // name still heads. Only handle this when the ledger prune below actually
408    // runs (stack-wide submit): the marker is the sole signal that identifies
409    // the stale row across every other overview, so closing and clearing it in
410    // a single-branch submit - which never prunes - would orphan those rows
411    // permanently. Left set, the marker waits for a later `submit --stack`.
412    let renamed: Vec<(String, String)> = if submit_stack || downstack {
413        branch_parents
414            .iter()
415            .filter_map(|(branch, _)| {
416                stack::renamed_from(branch)
417                    .ok()
418                    .flatten()
419                    .map(|old| (branch.clone(), old))
420            })
421            .collect()
422    } else {
423        Vec::new()
424    };
425    // Track which markers are safe to drop: those whose old review was
426    // actually retired (or had nothing to retire). A declined close keeps its
427    // marker so a later submit re-offers the reconciliation.
428    let mut reconciled: Vec<&str> = Vec::new();
429    for (branch, old) in &renamed {
430        if close_superseded_review(review_provider.as_ref(), old, dry_run)? {
431            reconciled.push(branch);
432        }
433    }
434
435    // After every review exists, set the title and description, link any issue
436    // the branch name references, then (in stack mode) write the stack overview
437    // into each body.
438    if let Some(title) = &title {
439        if !target_in_scope {
440            anstream::println!("skipped title: {target_branch} is this stack's base");
441        } else if !created.contains(&target_branch) {
442            // Reviews created just now already carry the title; only one that
443            // existed before this submit needs the edit.
444            apply_title(review_provider.as_ref(), &target_branch, title, dry_run)?;
445        }
446    }
447    if let Some(desc) = desc {
448        if target_in_scope {
449            crate::notes::update_description_note(
450                review_provider.as_ref(),
451                &target_branch,
452                &desc,
453                dry_run,
454            )?;
455        } else {
456            anstream::println!("skipped description: {target_branch} is this stack's base");
457        }
458    }
459    crate::notes::update_closes_notes(review_provider.as_ref(), &branches, dry_run)?;
460    if submit_stack || downstack {
461        crate::notes::update_stack_notes(
462            review_provider.as_ref(),
463            &branch_parents,
464            dry_run,
465            rebuild_overview,
466        )?;
467    }
468    if submit_stack || downstack {
469        register_native_stack(review_provider.as_ref(), &branches, dry_run)?;
470    }
471    apply_reviewers(review_provider.as_ref(), &branches, &reviewers, dry_run)?;
472
473    // The ledger has now pruned the superseded entries, so drop the markers -
474    // but only for reviews that were retired, not ones the user kept.
475    if !dry_run {
476        for branch in &reconciled {
477            stack::clear_renamed_from(branch)?;
478        }
479    }
480
481    anstream::println!(
482        "{}",
483        style::success(&format!(
484            "submit complete: {} created, {} updated, {} skipped",
485            summary.created, summary.updated, summary.skipped
486        ))
487    );
488    Ok(())
489}
490
491/// Retire the open review still heading a renamed-away branch. The fresh
492/// review already exists, so closing here never leaves the work without one.
493/// Prompts (default yes; a non-interactive run proceeds) before closing.
494///
495/// Returns whether the supersession was reconciled: `true` when the old review
496/// was closed or there was nothing to close, `false` when the user declined -
497/// so the caller keeps the rename marker for a later submit to re-offer.
498fn close_superseded_review(
499    review_provider: &dyn ReviewProvider,
500    old: &str,
501    dry_run: bool,
502) -> Result<bool> {
503    let Some(review) = review_provider.review_for_branch(old)? else {
504        return Ok(true);
505    };
506    if review.branch != *old {
507        return Ok(true);
508    }
509
510    if dry_run {
511        anstream::println!("would close superseded review {} for {old}", review.id);
512        return Ok(true);
513    }
514    if !crate::prompt::confirm_default_yes(&format!(
515        "close the replaced review {} for {old} and delete its branch? [Y/n] ",
516        review.id
517    ))? {
518        anstream::println!("kept review {} for {old}", review.id);
519        return Ok(false);
520    }
521
522    review_provider.close_review(&review, true)?;
523    anstream::println!("closed superseded review {} for {old}", review.id);
524    Ok(true)
525}
526
527/// Retitle the branch's existing review. Mirrors the description step: a
528/// missing review, or one that heads a different branch, is passed over with a
529/// note rather than failing the submit.
530fn apply_title(
531    review_provider: &dyn ReviewProvider,
532    branch: &str,
533    title: &str,
534    dry_run: bool,
535) -> Result<()> {
536    let Some(review) = review_provider.review_for_branch(branch)? else {
537        if dry_run {
538            anstream::println!("would set the title on the review for {branch}");
539        } else {
540            anstream::println!("skipped title: no review found for {branch}");
541        }
542        return Ok(());
543    };
544    if review.branch != branch {
545        anstream::println!(
546            "skipped title: review {} belongs to {}",
547            review.id,
548            review.branch
549        );
550        return Ok(());
551    }
552    if dry_run {
553        anstream::println!("would set the title in {}", review.id);
554        return Ok(());
555    }
556
557    let output = review_provider.update_review_title(&review, title)?;
558    anstream::println!("set title in {}", review.id);
559    if !output.is_empty() {
560        println!("{output}");
561    }
562    Ok(())
563}
564
565/// Request reviews from `reviewers` on every submitted branch's review. A
566/// merged review is skipped - there is nothing left to review - and a branch
567/// whose review is missing (or heads a different branch) is passed over with a
568/// note, mirroring the description and closes steps. No-op with no reviewers.
569fn apply_reviewers(
570    review_provider: &dyn ReviewProvider,
571    branches: &[String],
572    reviewers: &[String],
573    dry_run: bool,
574) -> Result<()> {
575    if reviewers.is_empty() {
576        return Ok(());
577    }
578    let list = reviewers.join(", ");
579    for branch in branches {
580        let Some(review) = review_provider.review_for_branch(branch)? else {
581            // On a dry run the review was likely never created; for real the
582            // submit just failed to produce one, which deserves a mention.
583            if dry_run {
584                anstream::println!("would request reviews from {list} for {branch}");
585            } else {
586                anstream::println!("skipped reviewers: no review found for {branch}");
587            }
588            continue;
589        };
590        if review.branch != *branch || review.state == ReviewState::Merged {
591            continue;
592        }
593        if dry_run {
594            anstream::println!("would request reviews from {list} in {}", review.id);
595            continue;
596        }
597        let output = review_provider.request_reviewers(&review, reviewers)?;
598        anstream::println!("requested reviews from {list} in {}", review.id);
599        if !output.is_empty() {
600            println!("{output}");
601        }
602    }
603    Ok(())
604}
605
606/// The error for a submit that resolves to nothing but the stack's base. Two
607/// sentences, on whether anything is stacked on it - shared because both the
608/// stack-mode trim and the single-branch path reach this state, and keeping
609/// two copies in step has already failed twice.
610fn base_has_nothing_to_submit(branch: &str) -> Result<anyhow::Error> {
611    if stack::children_of(branch)?.is_empty() {
612        return Ok(anyhow::anyhow!(
613            "{branch} is this stack's base, and nothing is stacked on it"
614        ));
615    }
616    // Name the branch in the remedy: `--stack` conflicts with naming one, so
617    // it has to be run from there rather than pointed at it - otherwise
618    // `submit <base>` from a sibling stack sends you to submit that one.
619    Ok(anyhow::anyhow!(
620        "{branch} is this stack's base; there is nothing below it to submit - \
621         run `git stk submit --stack` from {branch} to submit the branches above it"
622    ))
623}
624
625/// Hand the submitted stack to the platform, when it keeps stacks of its own -
626/// GitHub, with `stk.githubStacks` on. `branches` is bottom-first, which is
627/// the order a stack lands in, and every review now targets the branch below
628/// it, which is the shape the stack is recorded against.
629///
630/// Best effort by design: the reviews already exist, and what this adds is
631/// presentation - the stack map, and parallel review across layers. A failure
632/// is reported and the submit still succeeds.
633fn register_native_stack(
634    review_provider: &dyn ReviewProvider,
635    branches: &[String],
636    dry_run: bool,
637) -> Result<()> {
638    if branches.is_empty() {
639        return Ok(());
640    }
641    // Nothing to register on a provider that would not, and asking would
642    // spend a lookup - a 404 on every repo without the preview - to reach a
643    // `register_stack` that answers `None` anyway. Asked of the provider
644    // rather than read from config here: which setting, if any, gates this is
645    // the provider's to know, and the dry run below asks the same way.
646    if !review_provider.registers_stacks() {
647        return Ok(());
648    }
649    // Membership is per-review, so the line's bottom is not necessarily the
650    // stack's: root the line lower with `adopt` and the bottom is a branch the
651    // stack never held. Looking only there would read "no stack" for one that
652    // exists and POST a duplicate holding reviews GitHub already has.
653    let existing = branches
654        .iter()
655        .find_map(|branch| review_provider.native_stack_for(branch).ok().flatten());
656
657    let mut reviews = Vec::with_capacity(branches.len());
658    for branch in branches {
659        let Some(review) = review_provider.review_for_branch(branch)? else {
660            // A branch whose review is missing would register a stack with a
661            // hole in it. On a dry run there is simply nothing to look at yet.
662            if !dry_run {
663                anstream::println!("skipped stack registration: no review found for {branch}");
664            }
665            return Ok(());
666        };
667        if review.branch != *branch {
668            return Ok(());
669        }
670        reviews.push(review.id);
671    }
672
673    if dry_run {
674        // Only say so when it would actually do something.
675        if let Some(action) = would_register(review_provider, &reviews, existing.as_ref()) {
676            anstream::println!("{action}");
677        }
678        return Ok(());
679    }
680
681    match review_provider.register_stack(&reviews, existing.as_ref()) {
682        Ok(Some(line)) => anstream::println!("{line}"),
683        Ok(None) => {}
684        Err(error) => anstream::println!(
685            "{}",
686            style::warn(&format!("stack registration failed: {error}"))
687        ),
688    }
689    Ok(())
690}
691
692/// Render what registration would do, from the same plan the real run acts on,
693/// so a dry run cannot promise something the run then declines - and says
694/// nothing at all on a provider that keeps no stacks, or with the setting off.
695fn would_register(
696    review_provider: &dyn ReviewProvider,
697    reviews: &[String],
698    existing: Option<&NativeStack>,
699) -> Option<String> {
700    if !review_provider.registers_stacks() {
701        return None;
702    }
703    match crate::providers::plan_stack_registration(reviews, existing)? {
704        crate::providers::StackPlan::Register(reviews) => {
705            Some(format!("would register {} as a stack", reviews.join(" ")))
706        }
707        crate::providers::StackPlan::Extend { number, fresh } => Some(format!(
708            "would extend stack {number} with {}",
709            fresh.join(" ")
710        )),
711        crate::providers::StackPlan::Mismatch { number } => Some(format!(
712            "would leave stack {number} as recorded: it no longer matches this stack"
713        )),
714    }
715}
716
717fn branch_parents(branches: &[String]) -> Result<Vec<(String, String)>> {
718    let mut branch_parents = Vec::new();
719    for branch in branches {
720        // The trunk has no parent, so it would classify as a base below - it
721        // is not, whatever its children are. Its own message first, as
722        // `nothing_to_merge_hint` does. Most necessary in an off-trunk-only
723        // repo: without it the trunk falls through and gets an `adopt` remedy
724        // aimed at itself.
725        if Some(branch) == stack::trunk_branch(&git::local_branches()?).as_ref() {
726            // Position first, so every arm below inherits it. This is the one
727            // path that can be pointed at a branch you are not on
728            // (`--stack`/`--downstack` both conflict with naming one).
729            // `is_ok_and` because `submit <branch>` works on a detached HEAD.
730            if !git::current_branch().is_ok_and(|current| current == *branch) {
731                bail!(
732                    "{branch} is the trunk, so it is never part of a stack - \
733                     name a stacked branch instead"
734                );
735            }
736            if !stack::has_stacked_branches()? {
737                bail!("no stacked branches to submit");
738            }
739            bail!("you are on the trunk ({branch}); check out a stacked branch first");
740        }
741
742        // Every entrance to "you named the base" lands here: a bare `submit`
743        // (`stk.submitStack` is off by default) or `--no-stack`, where the
744        // trim never ran; and `--downstack` standing on it, where
745        // `path_from_root` stops at the branch you are on so the slice is just
746        // the base. A recorded base counts whatever parent it picked up;
747        // unmarked, its children are the only signal.
748        let is_base = stack::is_floor(branch)?
749            || (stack::parent_of(branch)?.is_none() && !stack::children_of(branch)?.is_empty());
750        if is_base {
751            return Err(base_has_nothing_to_submit(branch)?);
752        }
753
754        let Some(parent) = stack::parent_of(branch)? else {
755            // Name the branch: `adopt` defaults to the one you are on, and
756            // `submit <branch>` can be pointed at another - so the bare form
757            // would re-root whatever you happen to be standing on.
758            bail!(
759                "{branch} has no stack parent; attach it with \
760                 `git stk adopt {branch} --parent <parent>`, \
761                 or rebuild its metadata with `git stk repair`"
762            );
763        };
764        branch_parents.push((branch.to_owned(), parent));
765    }
766    Ok(branch_parents)
767}
768
769fn submit_branch(
770    review_provider: &dyn ReviewProvider,
771    branch: &str,
772    parent: &str,
773    dry_run: bool,
774    draft: bool,
775    title: Option<&str>,
776) -> Result<SubmitAction> {
777    if let Some(review) = review_provider.review_for_branch(branch)? {
778        if review.base == parent {
779            if dry_run {
780                anstream::println!(
781                    "would skip {} -> {} ({})",
782                    review.branch,
783                    review.base,
784                    review.id
785                );
786            } else {
787                anstream::println!(
788                    "{}",
789                    style::dim(&format!(
790                        "{} already targets {} ({})",
791                        review.branch, review.base, review.id
792                    ))
793                );
794            }
795            return Ok(SubmitAction::Skipped);
796        }
797
798        // A base git-stk will not move itself: either the platform is going
799        // to, or nothing is. Say which, since the remedies differ.
800        match review_provider.base_gap(&review, parent)? {
801            Some(BaseGap::Platform) => {
802                anstream::println!(
803                    "{}",
804                    style::dim(&format!(
805                        "{} targets {} and is in a stack; the platform moves it as the stack lands",
806                        review.id, review.base
807                    ))
808                );
809                return Ok(SubmitAction::Skipped);
810            }
811            Some(BaseGap::Sync) => {
812                anstream::println!(
813                    "{}",
814                    style::warn(&format!(
815                        "{} already targets {} - the platform moved it when {parent} landed; \
816                         run `git stk sync` to catch the local stack up",
817                        review.id, review.base
818                    ))
819                );
820                return Ok(SubmitAction::Skipped);
821            }
822            Some(BaseGap::Neither) => {
823                anstream::println!(
824                    "{}",
825                    style::warn(&format!(
826                        "{} targets {} but should target {parent}, and its stack will not \
827                         move it there - the platform refuses a change by hand too; \
828                         run `git stk unstack` and submit again",
829                        review.id, review.base
830                    ))
831                );
832                return Ok(SubmitAction::Skipped);
833            }
834            None => {}
835        }
836
837        let output = if dry_run {
838            String::new()
839        } else {
840            review_provider.update_review_base(&review, parent)?
841        };
842        anstream::println!(
843            "{} {} -> {} {}",
844            if dry_run { "would update" } else { "updated" },
845            style::branch(&review.branch),
846            style::branch(parent),
847            style::dim(&format!("({})", review.id))
848        );
849        if !output.is_empty() {
850            println!("{output}");
851        }
852    } else {
853        let output = if dry_run {
854            String::new()
855        } else {
856            review_provider.create_review(branch, parent, draft, title)?
857        };
858        anstream::println!(
859            "{} {} -> {}{}",
860            if dry_run { "would create" } else { "created" },
861            style::branch(branch),
862            style::branch(parent),
863            title.map_or_else(String::new, |title| format!(" titled \"{title}\""))
864        );
865        if !output.is_empty() {
866            println!("{output}");
867        }
868        return Ok(SubmitAction::Created);
869    }
870
871    Ok(SubmitAction::Updated)
872}
873
874#[derive(Debug, Default)]
875struct SubmitSummary {
876    created: usize,
877    updated: usize,
878    skipped: usize,
879}
880
881impl SubmitSummary {
882    fn record(&mut self, action: SubmitAction) {
883        match action {
884            SubmitAction::Created => self.created += 1,
885            SubmitAction::Updated => self.updated += 1,
886            SubmitAction::Skipped => self.skipped += 1,
887        }
888    }
889}
890
891#[derive(Debug, Clone, Copy, Eq, PartialEq)]
892enum SubmitAction {
893    Created,
894    Updated,
895    Skipped,
896}
897
898#[cfg(test)]
899mod tests {
900    use super::*;
901
902    fn home() -> Option<PathBuf> {
903        Some(PathBuf::from("/home/dev"))
904    }
905
906    #[test]
907    fn expand_tilde_resolves_a_bare_tilde_and_subpaths() {
908        assert_eq!(
909            expand_tilde_with(PathBuf::from("~"), home()),
910            PathBuf::from("/home/dev")
911        );
912        assert_eq!(
913            expand_tilde_with(PathBuf::from("~/notes/pr.md"), home()),
914            PathBuf::from("/home/dev/notes/pr.md")
915        );
916    }
917
918    #[test]
919    fn expand_tilde_leaves_other_paths_untouched() {
920        // Absolute, relative, `~user`, and an embedded (non-leading) tilde all
921        // pass through unchanged.
922        for raw in ["/etc/pr.md", "notes/pr.md", "~alice/pr.md", "docs/~x.md"] {
923            assert_eq!(
924                expand_tilde_with(PathBuf::from(raw), home()),
925                PathBuf::from(raw)
926            );
927        }
928    }
929
930    #[test]
931    fn expand_tilde_passes_through_when_home_is_unset() {
932        assert_eq!(
933            expand_tilde_with(PathBuf::from("~/pr.md"), None),
934            PathBuf::from("~/pr.md")
935        );
936    }
937
938    fn reviewers(raw: &[&str]) -> Vec<String> {
939        normalize_reviewers(
940            &raw.iter()
941                .map(|entry| (*entry).to_owned())
942                .collect::<Vec<_>>(),
943        )
944    }
945
946    #[test]
947    fn normalize_reviewers_strips_at_and_trims() {
948        // A leading `@` is optional, so both spellings normalize alike.
949        assert_eq!(reviewers(&["@foo", "@bar"]), vec!["foo", "bar"]);
950        assert_eq!(reviewers(&["foo", "bar"]), vec!["foo", "bar"]);
951        assert_eq!(reviewers(&[" @foo ", "  bar"]), vec!["foo", "bar"]);
952    }
953
954    #[test]
955    fn normalize_reviewers_keeps_team_paths_but_drops_the_at() {
956        // Only the `@` prefix is stripped; the `org/team` slug stays intact.
957        assert_eq!(
958            reviewers(&["@my-org/backend", "acme/team"]),
959            vec!["my-org/backend", "acme/team"]
960        );
961    }
962
963    #[test]
964    fn normalize_reviewers_drops_blanks_and_dedupes_in_order() {
965        assert_eq!(
966            reviewers(&["foo", "", "  ", "@foo", "bar", "@bar"]),
967            vec!["foo", "bar"]
968        );
969    }
970
971    #[test]
972    fn normalize_reviewers_preserves_the_copilot_at_prefix() {
973        // gh needs the literal `@copilot`; a bare `copilot` canonicalizes to it,
974        // and both spellings collapse to one entry.
975        assert_eq!(reviewers(&["@copilot"]), vec!["@copilot"]);
976        assert_eq!(reviewers(&["copilot"]), vec!["@copilot"]);
977        assert_eq!(reviewers(&["@Copilot", "copilot"]), vec!["@copilot"]);
978    }
979
980    #[cfg(windows)]
981    #[test]
982    fn expand_tilde_accepts_a_backslash_on_windows() {
983        assert_eq!(
984            expand_tilde_with(PathBuf::from(r"~\notes\pr.md"), home()),
985            PathBuf::from("/home/dev").join(r"notes\pr.md")
986        );
987    }
988}