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_provider, 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, 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}
55
56impl Run for Submit {
57    fn run(self) -> Result<()> {
58        // Stack mode: --stack forces it on; --no-stack or an explicit branch
59        // forces it off; otherwise stk.submitStack decides.
60        let submit_stack = if self.stack {
61            true
62        } else if self.no_stack || self.branch.is_some() {
63            false
64        } else {
65            settings::bool_setting(settings::SUBMIT_STACK_KEY)?
66        };
67
68        // Draft mode: --draft forces it on, --no-draft off; otherwise
69        // stk.submitDraft decides.
70        let draft = if self.draft {
71            true
72        } else if self.no_draft {
73            false
74        } else {
75            settings::bool_setting(settings::SUBMIT_DRAFT_KEY)?
76        };
77
78        submit(
79            self.branch.as_deref(),
80            submit_stack,
81            self.downstack,
82            self.dry_run,
83            PushMode::from_flags(self.push, self.no_push),
84            self.desc.as_deref(),
85            draft,
86            self.ready,
87        )
88    }
89}
90
91#[allow(clippy::too_many_arguments)]
92pub fn submit(
93    branch: Option<&str>,
94    submit_stack: bool,
95    downstack: bool,
96    dry_run: bool,
97    push_mode: crate::cli::PushMode,
98    desc: Option<&str>,
99    draft: bool,
100    ready: bool,
101) -> Result<()> {
102    let branch = branch
103        .map(str::to_owned)
104        .map_or_else(git::current_branch, Ok)?;
105    // The description targets this branch's review even in stack mode.
106    let desc_branch = branch.clone();
107
108    let branches = if downstack {
109        // Bottom of the stack through the current branch: anything above is
110        // work in progress that stays local.
111        stack::path_from_root(&branch)?
112    } else if submit_stack {
113        // The whole stack containing the current branch, from anywhere in it:
114        // walk to the root, then take its descendants. The root is excluded
115        // only when it is the trunk (the base everything sits on); an
116        // unanchored root stays in so validation can point at the missing
117        // parent instead of silently skipping it.
118        let root = stack::stack_root(&branch)?;
119        let trunk = stack::trunk_branch(&git::local_branches()?);
120        let full = stack::branch_and_descendants(&root)?;
121        if Some(root) == trunk {
122            full.into_iter().skip(1).collect()
123        } else {
124            full
125        }
126    } else {
127        vec![branch]
128    };
129
130    let branch_parents = branch_parents(&branches)?;
131
132    // Push after stack validation but before any provider calls: creating a
133    // review requires the branch to exist remotely, and -u --force-with-lease
134    // covers both first pushes and safely updating rebased branches.
135    let push = settings::push_enabled(push_mode, settings::PUSH_ON_SUBMIT_KEY)?;
136    if push {
137        let remote = settings::remote()?;
138        if dry_run {
139            anstream::println!(
140                "would push {} to {remote}",
141                style::branch(&branches.join(" "))
142            );
143        } else {
144            git::push_set_upstream_force_with_lease(&remote, &branches)?;
145            anstream::println!("pushed {} to {remote}", style::branch(&branches.join(" ")));
146        }
147    }
148
149    let provider = detect_provider()?;
150    let review_provider = review_provider(provider.kind);
151    let mut summary = SubmitSummary::default();
152
153    for (branch, parent) in &branch_parents {
154        summary.record(submit_branch(
155            review_provider.as_ref(),
156            branch,
157            parent,
158            dry_run,
159            draft,
160        )?);
161    }
162
163    // Flip drafts in scope to ready for review (the escape hatch for
164    // stk.submitDraft users).
165    if ready {
166        for branch in &branches {
167            let Some(review) = review_provider.review_for_branch(branch)? else {
168                continue;
169            };
170            if review.branch != *branch || !review.draft {
171                continue;
172            }
173            if dry_run {
174                println!("would mark {} ready", review.id);
175                continue;
176            }
177            let output = review_provider.mark_ready(&review)?;
178            anstream::println!("marked {} ready", review.id);
179            if !output.is_empty() {
180                println!("{output}");
181            }
182        }
183    }
184
185    // After every review exists, write the description, link any issue the
186    // branch name references, then (in stack mode) write the stack overview
187    // into each body.
188    if let Some(desc) = desc {
189        crate::notes::update_description_note(
190            review_provider.as_ref(),
191            &desc_branch,
192            desc,
193            dry_run,
194        )?;
195    }
196    crate::notes::update_closes_notes(review_provider.as_ref(), &branches, dry_run)?;
197    if submit_stack || downstack {
198        crate::notes::update_stack_notes(review_provider.as_ref(), &branch_parents, dry_run)?;
199    }
200
201    anstream::println!(
202        "{}",
203        style::success(&format!(
204            "submit complete: {} created, {} updated, {} skipped",
205            summary.created, summary.updated, summary.skipped
206        ))
207    );
208    Ok(())
209}
210
211fn branch_parents(branches: &[String]) -> Result<Vec<(String, String)>> {
212    let mut branch_parents = Vec::new();
213    for branch in branches {
214        let Some(parent) = stack::parent_for_branch(branch)? else {
215            bail!("{branch} has no stack parent; run `git stk adopt` or `git stk sync` first");
216        };
217        branch_parents.push((branch.to_owned(), parent));
218    }
219    Ok(branch_parents)
220}
221
222fn submit_branch(
223    review_provider: &dyn ReviewProvider,
224    branch: &str,
225    parent: &str,
226    dry_run: bool,
227    draft: bool,
228) -> Result<SubmitAction> {
229    if let Some(review) = review_provider.review_for_branch(branch)? {
230        if review.base == parent {
231            if dry_run {
232                println!(
233                    "would skip {} -> {} ({})",
234                    review.branch, review.base, review.id
235                );
236            } else {
237                anstream::println!(
238                    "{}",
239                    style::dim(&format!(
240                        "{} already targets {} ({})",
241                        review.branch, review.base, review.id
242                    ))
243                );
244            }
245            return Ok(SubmitAction::Skipped);
246        }
247
248        let output = if dry_run {
249            String::new()
250        } else {
251            review_provider.update_review_base(&review, parent)?
252        };
253        anstream::println!(
254            "{} {} -> {} {}",
255            if dry_run { "would update" } else { "updated" },
256            style::branch(&review.branch),
257            style::branch(parent),
258            style::dim(&format!("({})", review.id))
259        );
260        if !output.is_empty() {
261            println!("{output}");
262        }
263    } else {
264        let output = if dry_run {
265            String::new()
266        } else {
267            review_provider.create_review(branch, parent, draft)?
268        };
269        anstream::println!(
270            "{} {} -> {}",
271            if dry_run { "would create" } else { "created" },
272            style::branch(branch),
273            style::branch(parent)
274        );
275        if !output.is_empty() {
276            println!("{output}");
277        }
278        return Ok(SubmitAction::Created);
279    }
280
281    Ok(SubmitAction::Updated)
282}
283
284#[derive(Debug, Default)]
285struct SubmitSummary {
286    created: usize,
287    updated: usize,
288    skipped: usize,
289}
290
291impl SubmitSummary {
292    fn record(&mut self, action: SubmitAction) {
293        match action {
294            SubmitAction::Created => self.created += 1,
295            SubmitAction::Updated => self.updated += 1,
296            SubmitAction::Skipped => self.skipped += 1,
297        }
298    }
299}
300
301#[derive(Debug, Clone, Copy, Eq, PartialEq)]
302enum SubmitAction {
303    Created,
304    Updated,
305    Skipped,
306}