Skip to main content

git_stk/commands/
submit.rs

1use anyhow::{Result, bail};
2use clap::ArgAction;
3use clap_complete::engine::ArgValueCompleter;
4
5use crate::cli::PushMode;
6use crate::commands::Run;
7use crate::completions;
8use crate::providers::{ReviewProvider, detect_review_provider};
9use crate::settings;
10use crate::style;
11use crate::{git, stack};
12
13/// Create or update a remote review request for a branch.
14#[derive(Debug, clap::Args)]
15pub struct Submit {
16    /// Branch to submit (defaults to the current branch).
17    #[arg(add = ArgValueCompleter::new(completions::branch_candidates))]
18    branch: Option<String>,
19    /// Print what would change without creating or updating reviews.
20    #[arg(long, short = 'n', action = ArgAction::SetTrue)]
21    dry_run: bool,
22    /// Submit the whole stack parent-first, from anywhere in it.
23    #[arg(long, conflicts_with = "branch")]
24    stack: bool,
25    /// Submit only the current branch, overriding stk.submitStack.
26    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "stack")]
27    no_stack: bool,
28    /// Submit the stack from its bottom through the current branch only,
29    /// leaving work-in-progress branches above it unsubmitted.
30    #[arg(
31        long,
32        action = ArgAction::SetTrue,
33        conflicts_with_all = ["branch", "stack", "no_stack"],
34    )]
35    downstack: bool,
36    /// Push branches (-u --force-with-lease) before submitting.
37    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_push")]
38    push: bool,
39    /// Do not push branches, overriding stk.pushOnSubmit.
40    #[arg(long, action = ArgAction::SetTrue)]
41    no_push: bool,
42    /// Set a description block at the top of the review body; an empty
43    /// string clears it. Applies to the current or named branch only.
44    #[arg(long, short = 'd')]
45    desc: Option<String>,
46    /// Create new reviews as drafts.
47    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_draft")]
48    draft: bool,
49    /// Create new reviews ready for review, overriding stk.submitDraft.
50    #[arg(long, action = ArgAction::SetTrue)]
51    no_draft: bool,
52    /// Mark the submitted branches' existing draft reviews as ready.
53    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "draft")]
54    ready: bool,
55    /// Rebuild each review's stack overview from the live stack plus merged
56    /// history, dropping closed or orphaned rows that drifted in. Stack mode.
57    #[arg(long, action = ArgAction::SetTrue)]
58    rebuild_overview: bool,
59}
60
61impl Run for Submit {
62    fn run(self) -> Result<()> {
63        // Stack mode: --stack forces it on; --no-stack or an explicit branch
64        // forces it off; otherwise stk.submitStack decides.
65        let submit_stack = if self.stack {
66            true
67        } else if self.no_stack || self.branch.is_some() {
68            false
69        } else {
70            settings::bool_setting(settings::SUBMIT_STACK_KEY)?
71        };
72
73        // Draft mode: --draft forces it on, --no-draft off; otherwise
74        // stk.submitDraft decides.
75        let draft = if self.draft {
76            true
77        } else if self.no_draft {
78            false
79        } else {
80            settings::bool_setting(settings::SUBMIT_DRAFT_KEY)?
81        };
82
83        submit(SubmitOptions {
84            branch: self.branch,
85            submit_stack,
86            downstack: self.downstack,
87            dry_run: self.dry_run,
88            push_mode: PushMode::from_flags(self.push, self.no_push),
89            desc: self.desc,
90            draft,
91            ready: self.ready,
92            rebuild_overview: self.rebuild_overview,
93        })
94    }
95}
96
97/// The resolved inputs for [`submit`] - one bundle instead of nine positional
98/// arguments. `Submit::run` resolves the flag/config defaults and fills it.
99pub struct SubmitOptions {
100    pub branch: Option<String>,
101    pub submit_stack: bool,
102    pub downstack: bool,
103    pub dry_run: bool,
104    pub push_mode: crate::cli::PushMode,
105    pub desc: Option<String>,
106    pub draft: bool,
107    pub ready: bool,
108    pub rebuild_overview: bool,
109}
110
111pub fn submit(options: SubmitOptions) -> Result<()> {
112    let SubmitOptions {
113        branch,
114        submit_stack,
115        downstack,
116        dry_run,
117        push_mode,
118        desc,
119        draft,
120        ready,
121        rebuild_overview,
122    } = options;
123
124    let branch = branch.map_or_else(git::current_branch, Ok)?;
125    // The description targets this branch's review even in stack mode.
126    let desc_branch = branch.clone();
127
128    let branches = if downstack {
129        // Bottom of the stack through the current branch: anything above is
130        // work in progress that stays local.
131        stack::path_from_root(&branch)?
132    } else if submit_stack {
133        // The stack containing the current branch: its own line, bottom
134        // through current and out to its descendants. Sibling stacks that
135        // merely share the trunk are left for their own submit.
136        stack::stack_line(&branch)?
137    } else {
138        vec![branch.clone()]
139    };
140
141    // The trunk is never part of a stack, so a stack-wide submit from it has
142    // nothing of its own to submit (its descendants are sibling stacks). Say so
143    // plainly rather than pushing an empty set or sweeping every stack.
144    if submit_stack || downstack {
145        let trunk = stack::trunk_branch(&git::local_branches()?);
146        if Some(&branch) == trunk.as_ref() {
147            if stack::children_of(&branch)?.is_empty() {
148                bail!("no stacked branches to submit");
149            }
150            bail!("you are on the trunk ({branch}); check out a stacked branch first");
151        }
152    }
153
154    let branch_parents = branch_parents(&branches)?;
155
156    // Push after stack validation but before any provider calls: creating a
157    // review requires the branch to exist remotely, and -u --force-with-lease
158    // covers both first pushes and safely updating rebased branches.
159    let push = settings::push_enabled(push_mode, settings::PUSH_ON_SUBMIT_KEY)?;
160    if push {
161        let remote = settings::remote()?;
162        if dry_run {
163            anstream::println!(
164                "would push {} to {remote}",
165                style::branch(&branches.join(" "))
166            );
167        } else {
168            git::push_set_upstream_force_with_lease(&remote, &branches)?;
169            anstream::println!("pushed {} to {remote}", style::branch(&branches.join(" ")));
170            // Carry the stack's parent map along so another clone can rebuild
171            // it with `git stk repair --from-remote`.
172            stack::publish_metadata(&remote);
173        }
174    }
175
176    let (provider, review_provider) = detect_review_provider()?;
177    let mut summary = SubmitSummary::default();
178
179    let mut created = Vec::new();
180    for (branch, parent) in &branch_parents {
181        let action = submit_branch(review_provider.as_ref(), branch, parent, dry_run, draft)?;
182        if action == SubmitAction::Created {
183            created.push(branch.clone());
184        }
185        summary.record(action);
186    }
187
188    // Seed freshly created reviews from the repo's PR template before the
189    // managed sections go in, so our content appends beneath it rather than
190    // replacing it. The flag per branch predicts whether a managed section
191    // (stack overview, description, or Closes #N) will follow - mirroring the
192    // writers below - so the template only grows a seam rule when something
193    // actually lands under it.
194    let writes_stack_overview = submit_stack || downstack;
195    let created: Vec<(String, bool)> = created
196        .into_iter()
197        .map(|branch| {
198            let managed = writes_stack_overview
199                || (desc.is_some() && branch == desc_branch)
200                || crate::notes::branch_references_issue(&branch);
201            (branch, managed)
202        })
203        .collect();
204    crate::notes::seed_template_notes(review_provider.as_ref(), provider.kind, &created, dry_run)?;
205
206    // Flip drafts in scope to ready for review (the escape hatch for
207    // stk.submitDraft users).
208    if ready {
209        for branch in &branches {
210            let Some(review) = review_provider.review_for_branch(branch)? else {
211                continue;
212            };
213            if review.branch != *branch || !review.draft {
214                continue;
215            }
216            if dry_run {
217                anstream::println!("would mark {} ready", review.id);
218                continue;
219            }
220            let output = review_provider.mark_ready(&review)?;
221            anstream::println!("marked {} ready", review.id);
222            if !output.is_empty() {
223                println!("{output}");
224            }
225        }
226    }
227
228    // A renamed branch's fresh review now exists, so retire the review the old
229    // name still heads. Only handle this when the ledger prune below actually
230    // runs (stack-wide submit): the marker is the sole signal that identifies
231    // the stale row across every other overview, so closing and clearing it in
232    // a single-branch submit - which never prunes - would orphan those rows
233    // permanently. Left set, the marker waits for a later `submit --stack`.
234    let renamed: Vec<(String, String)> = if submit_stack || downstack {
235        branch_parents
236            .iter()
237            .filter_map(|(branch, _)| {
238                stack::renamed_from(branch)
239                    .ok()
240                    .flatten()
241                    .map(|old| (branch.clone(), old))
242            })
243            .collect()
244    } else {
245        Vec::new()
246    };
247    for (_, old) in &renamed {
248        close_superseded_review(review_provider.as_ref(), old, dry_run)?;
249    }
250
251    // After every review exists, write the description, link any issue the
252    // branch name references, then (in stack mode) write the stack overview
253    // into each body.
254    if let Some(desc) = desc {
255        crate::notes::update_description_note(
256            review_provider.as_ref(),
257            &desc_branch,
258            &desc,
259            dry_run,
260        )?;
261    }
262    crate::notes::update_closes_notes(review_provider.as_ref(), &branches, dry_run)?;
263    if submit_stack || downstack {
264        crate::notes::update_stack_notes(
265            review_provider.as_ref(),
266            &branch_parents,
267            dry_run,
268            rebuild_overview,
269        )?;
270    }
271
272    // The ledger has now pruned the superseded entries, so drop the markers.
273    if !dry_run {
274        for (branch, _) in &renamed {
275            stack::clear_renamed_from(branch)?;
276        }
277    }
278
279    anstream::println!(
280        "{}",
281        style::success(&format!(
282            "submit complete: {} created, {} updated, {} skipped",
283            summary.created, summary.updated, summary.skipped
284        ))
285    );
286    Ok(())
287}
288
289/// Retire the open review still heading a renamed-away branch. The fresh
290/// review already exists, so closing here never leaves the work without one.
291/// Prompts (default yes; a non-interactive run proceeds) before closing.
292fn close_superseded_review(
293    review_provider: &dyn ReviewProvider,
294    old: &str,
295    dry_run: bool,
296) -> Result<()> {
297    let Some(review) = review_provider.review_for_branch(old)? else {
298        return Ok(());
299    };
300    if review.branch != *old {
301        return Ok(());
302    }
303
304    if dry_run {
305        anstream::println!("would close superseded review {} for {old}", review.id);
306        return Ok(());
307    }
308    if !crate::prompt::confirm_default_yes(&format!(
309        "close the replaced review {} for {old} and delete its branch? [Y/n] ",
310        review.id
311    ))? {
312        anstream::println!("kept review {} for {old}", review.id);
313        return Ok(());
314    }
315
316    review_provider.close_review(&review, true)?;
317    anstream::println!("closed superseded review {} for {old}", review.id);
318    Ok(())
319}
320
321fn branch_parents(branches: &[String]) -> Result<Vec<(String, String)>> {
322    let mut branch_parents = Vec::new();
323    for branch in branches {
324        let Some(parent) = stack::parent_of(branch)? else {
325            bail!("{branch} has no stack parent; run `git stk adopt` or `git stk sync` first");
326        };
327        branch_parents.push((branch.to_owned(), parent));
328    }
329    Ok(branch_parents)
330}
331
332fn submit_branch(
333    review_provider: &dyn ReviewProvider,
334    branch: &str,
335    parent: &str,
336    dry_run: bool,
337    draft: bool,
338) -> Result<SubmitAction> {
339    if let Some(review) = review_provider.review_for_branch(branch)? {
340        if review.base == parent {
341            if dry_run {
342                anstream::println!(
343                    "would skip {} -> {} ({})",
344                    review.branch,
345                    review.base,
346                    review.id
347                );
348            } else {
349                anstream::println!(
350                    "{}",
351                    style::dim(&format!(
352                        "{} already targets {} ({})",
353                        review.branch, review.base, review.id
354                    ))
355                );
356            }
357            return Ok(SubmitAction::Skipped);
358        }
359
360        let output = if dry_run {
361            String::new()
362        } else {
363            review_provider.update_review_base(&review, parent)?
364        };
365        anstream::println!(
366            "{} {} -> {} {}",
367            if dry_run { "would update" } else { "updated" },
368            style::branch(&review.branch),
369            style::branch(parent),
370            style::dim(&format!("({})", review.id))
371        );
372        if !output.is_empty() {
373            println!("{output}");
374        }
375    } else {
376        let output = if dry_run {
377            String::new()
378        } else {
379            review_provider.create_review(branch, parent, draft)?
380        };
381        anstream::println!(
382            "{} {} -> {}",
383            if dry_run { "would create" } else { "created" },
384            style::branch(branch),
385            style::branch(parent)
386        );
387        if !output.is_empty() {
388            println!("{output}");
389        }
390        return Ok(SubmitAction::Created);
391    }
392
393    Ok(SubmitAction::Updated)
394}
395
396#[derive(Debug, Default)]
397struct SubmitSummary {
398    created: usize,
399    updated: usize,
400    skipped: usize,
401}
402
403impl SubmitSummary {
404    fn record(&mut self, action: SubmitAction) {
405        match action {
406            SubmitAction::Created => self.created += 1,
407            SubmitAction::Updated => self.updated += 1,
408            SubmitAction::Skipped => self.skipped += 1,
409        }
410    }
411}
412
413#[derive(Debug, Clone, Copy, Eq, PartialEq)]
414enum SubmitAction {
415    Created,
416    Updated,
417    Skipped,
418}