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