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::{git, stack};
11
12/// Create or update a remote review request for a branch.
13#[derive(Debug, clap::Args)]
14pub struct Submit {
15    #[arg(add = ArgValueCompleter::new(completions::branch_candidates))]
16    branch: Option<String>,
17    /// Print what would change without creating or updating reviews.
18    #[arg(long, action = ArgAction::SetTrue)]
19    dry_run: bool,
20    /// Submit the whole stack parent-first, from anywhere in it.
21    #[arg(long, conflicts_with = "branch")]
22    stack: bool,
23    /// Submit only the current branch, overriding stk.submitStack.
24    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "stack")]
25    no_stack: bool,
26    /// Push branches (-u --force-with-lease) before submitting.
27    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_push")]
28    push: bool,
29    /// Do not push branches, overriding stk.pushOnSubmit.
30    #[arg(long, action = ArgAction::SetTrue)]
31    no_push: bool,
32    /// Set a description block at the top of the review body; an empty
33    /// string clears it. Applies to the current or named branch only.
34    #[arg(long, short = 'd')]
35    desc: Option<String>,
36}
37
38impl Run for Submit {
39    fn run(self) -> Result<()> {
40        // Stack mode: --stack forces it on; --no-stack or an explicit branch
41        // forces it off; otherwise stk.submitStack decides.
42        let submit_stack = if self.stack {
43            true
44        } else if self.no_stack || self.branch.is_some() {
45            false
46        } else {
47            settings::bool_setting(settings::SUBMIT_STACK_KEY)?
48        };
49
50        submit(
51            self.branch.as_deref(),
52            submit_stack,
53            self.dry_run,
54            PushMode::from_flags(self.push, self.no_push),
55            self.desc.as_deref(),
56        )
57    }
58}
59
60pub fn submit(
61    branch: Option<&str>,
62    submit_stack: bool,
63    dry_run: bool,
64    push_mode: crate::cli::PushMode,
65    desc: Option<&str>,
66) -> Result<()> {
67    let branch = branch
68        .map(str::to_owned)
69        .map_or_else(git::current_branch, Ok)?;
70    // The description targets this branch's review even in stack mode.
71    let desc_branch = branch.clone();
72
73    let branches = if submit_stack {
74        // The whole stack containing the current branch, from anywhere in it:
75        // walk to the root, then take its descendants. The root is excluded
76        // only when it is the trunk (the base everything sits on); an
77        // unanchored root stays in so validation can point at the missing
78        // parent instead of silently skipping it.
79        let root = stack::stack_root(&branch)?;
80        let trunk = stack::trunk_branch(&git::local_branches()?);
81        let full = stack::branch_and_descendants(&root)?;
82        if Some(root) == trunk {
83            full.into_iter().skip(1).collect()
84        } else {
85            full
86        }
87    } else {
88        vec![branch]
89    };
90
91    let branch_parents = branch_parents(&branches)?;
92
93    // Push after stack validation but before any provider calls: creating a
94    // review requires the branch to exist remotely, and -u --force-with-lease
95    // covers both first pushes and safely updating rebased branches.
96    let push = settings::push_enabled(push_mode, settings::PUSH_ON_SUBMIT_KEY)?;
97    if push {
98        let remote = settings::remote()?;
99        if dry_run {
100            println!("would push {} to {remote}", branches.join(" "));
101        } else {
102            git::push_set_upstream_force_with_lease(&remote, &branches)?;
103            println!("pushed {} to {remote}", branches.join(" "));
104        }
105    }
106
107    let provider = detect_provider()?;
108    let review_provider = review_provider(provider.kind);
109    let mut summary = SubmitSummary::default();
110
111    for (branch, parent) in &branch_parents {
112        summary.record(submit_branch(
113            review_provider.as_ref(),
114            branch,
115            parent,
116            dry_run,
117        )?);
118    }
119
120    // After every review exists, write the description, link any issue the
121    // branch name references, then (in stack mode) write the stack overview
122    // into each body.
123    if let Some(desc) = desc {
124        crate::notes::update_description_note(
125            review_provider.as_ref(),
126            &desc_branch,
127            desc,
128            dry_run,
129        )?;
130    }
131    crate::notes::update_closes_notes(review_provider.as_ref(), &branches, dry_run)?;
132    if submit_stack {
133        crate::notes::update_stack_notes(review_provider.as_ref(), &branch_parents, dry_run)?;
134    }
135
136    println!(
137        "submit complete: {} created, {} updated, {} skipped",
138        summary.created, summary.updated, summary.skipped
139    );
140    Ok(())
141}
142
143fn branch_parents(branches: &[String]) -> Result<Vec<(String, String)>> {
144    let mut branch_parents = Vec::new();
145    for branch in branches {
146        let Some(parent) = stack::parent_for_branch(branch)? else {
147            bail!("{branch} has no stack parent; run `git stk adopt` or `git stk sync` first");
148        };
149        branch_parents.push((branch.to_owned(), parent));
150    }
151    Ok(branch_parents)
152}
153
154fn submit_branch(
155    review_provider: &dyn ReviewProvider,
156    branch: &str,
157    parent: &str,
158    dry_run: bool,
159) -> Result<SubmitAction> {
160    if let Some(review) = review_provider.review_for_branch(branch)? {
161        if review.base == parent {
162            if dry_run {
163                println!(
164                    "would skip {} -> {} ({})",
165                    review.branch, review.base, review.id
166                );
167            } else {
168                println!(
169                    "{} already targets {} ({})",
170                    review.branch, review.base, review.id
171                );
172            }
173            return Ok(SubmitAction::Skipped);
174        }
175
176        let output = if dry_run {
177            String::new()
178        } else {
179            review_provider.update_review_base(&review, parent)?
180        };
181        println!(
182            "{} {} -> {} ({})",
183            if dry_run { "would update" } else { "updated" },
184            review.branch,
185            parent,
186            review.id
187        );
188        if !output.is_empty() {
189            println!("{output}");
190        }
191    } else {
192        let output = if dry_run {
193            String::new()
194        } else {
195            review_provider.create_review(branch, parent)?
196        };
197        println!(
198            "{} {branch} -> {parent}",
199            if dry_run { "would create" } else { "created" }
200        );
201        if !output.is_empty() {
202            println!("{output}");
203        }
204        return Ok(SubmitAction::Created);
205    }
206
207    Ok(SubmitAction::Updated)
208}
209
210#[derive(Debug, Default)]
211struct SubmitSummary {
212    created: usize,
213    updated: usize,
214    skipped: usize,
215}
216
217impl SubmitSummary {
218    fn record(&mut self, action: SubmitAction) {
219        match action {
220            SubmitAction::Created => self.created += 1,
221            SubmitAction::Updated => self.updated += 1,
222            SubmitAction::Skipped => self.skipped += 1,
223        }
224    }
225}
226
227#[derive(Debug, Clone, Copy, Eq, PartialEq)]
228enum SubmitAction {
229    Created,
230    Updated,
231    Skipped,
232}