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