Skip to main content

git_stk/commands/
submit.rs

1use std::env;
2use std::path::PathBuf;
3
4use anyhow::{Context, Result, bail};
5use clap::ArgAction;
6use clap_complete::engine::ArgValueCompleter;
7
8use crate::cli::PushMode;
9use crate::commands::Run;
10use crate::completions;
11use crate::providers::{ReviewProvider, detect_review_provider};
12use crate::settings;
13use crate::style;
14use crate::{git, stack};
15
16/// Create or update a remote review request for a branch.
17#[derive(Debug, clap::Args)]
18pub struct Submit {
19    /// Branch to submit (defaults to the current branch).
20    #[arg(add = ArgValueCompleter::new(completions::branch_candidates))]
21    branch: Option<String>,
22    /// Print what would change without creating or updating reviews.
23    #[arg(long, short = 'n', action = ArgAction::SetTrue)]
24    dry_run: bool,
25    /// Submit the whole stack parent-first, from anywhere in it.
26    #[arg(long, conflicts_with = "branch")]
27    stack: bool,
28    /// Submit only the current branch, overriding stk.submitStack.
29    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "stack")]
30    no_stack: bool,
31    /// Submit the stack from its bottom through the current branch only,
32    /// leaving work-in-progress branches above it unsubmitted.
33    #[arg(
34        long,
35        action = ArgAction::SetTrue,
36        conflicts_with_all = ["branch", "stack", "no_stack"],
37    )]
38    downstack: bool,
39    /// Push branches (-u --force-with-lease) before submitting.
40    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_push")]
41    push: bool,
42    /// Do not push branches, overriding stk.pushOnSubmit.
43    #[arg(long, action = ArgAction::SetTrue)]
44    no_push: bool,
45    /// Set a description block at the top of the review body; an empty
46    /// string clears it. Applies to the current or named branch only.
47    #[arg(long, short = 'd')]
48    desc: Option<String>,
49    /// Read the description block from a markdown or text file instead of an
50    /// inline string, handy for agent-authored bodies. Incompatible with
51    /// --desc; an empty file clears the block, like `--desc ""`.
52    #[arg(
53        long = "desc-file",
54        value_name = "PATH",
55        value_hint = clap::ValueHint::FilePath,
56        conflicts_with = "desc",
57    )]
58    desc_file: Option<PathBuf>,
59    /// Create new reviews as drafts.
60    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_draft")]
61    draft: bool,
62    /// Create new reviews ready for review, overriding stk.submitDraft.
63    #[arg(long, action = ArgAction::SetTrue)]
64    no_draft: bool,
65    /// Mark the submitted branches' existing draft reviews as ready.
66    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "draft")]
67    ready: bool,
68    /// Rebuild each review's stack overview from the live stack plus merged
69    /// history, dropping closed or orphaned rows that drifted in. Stack mode.
70    #[arg(long, action = ArgAction::SetTrue)]
71    rebuild_overview: bool,
72}
73
74impl Run for Submit {
75    fn run(self) -> Result<()> {
76        // Stack mode: --stack forces it on; --no-stack or an explicit branch
77        // forces it off; otherwise stk.submitStack decides.
78        let submit_stack = if self.stack {
79            true
80        } else if self.no_stack || self.branch.is_some() {
81            false
82        } else {
83            settings::bool_setting(settings::SUBMIT_STACK_KEY)?
84        };
85
86        // Draft mode: --draft forces it on, --no-draft off; otherwise
87        // stk.submitDraft decides.
88        let draft = if self.draft {
89            true
90        } else if self.no_draft {
91            false
92        } else {
93            settings::bool_setting(settings::SUBMIT_DRAFT_KEY)?
94        };
95
96        // A file source resolves to the same description string; clap's
97        // conflicts_with guarantees at most one of the two is set.
98        let desc = match self.desc_file {
99            Some(path) => {
100                let path = expand_tilde(path);
101                let raw = std::fs::read_to_string(&path).with_context(|| {
102                    format!("failed to read description file {}", path.display())
103                })?;
104                Some(raw.trim().to_owned())
105            }
106            None => self.desc,
107        };
108
109        submit(SubmitOptions {
110            branch: self.branch,
111            submit_stack,
112            downstack: self.downstack,
113            dry_run: self.dry_run,
114            push_mode: PushMode::from_flags(self.push, self.no_push),
115            desc,
116            draft,
117            ready: self.ready,
118            rebuild_overview: self.rebuild_overview,
119        })
120    }
121}
122
123/// The resolved inputs for [`submit`] - one bundle instead of nine positional
124/// arguments. `Submit::run` resolves the flag/config defaults and fills it.
125pub struct SubmitOptions {
126    pub branch: Option<String>,
127    pub submit_stack: bool,
128    pub downstack: bool,
129    pub dry_run: bool,
130    pub push_mode: crate::cli::PushMode,
131    pub desc: Option<String>,
132    pub draft: bool,
133    pub ready: bool,
134    pub rebuild_overview: bool,
135}
136
137/// Expand a leading `~` in a `--desc-file` path to the user's home, since the
138/// path reaches us literally when the shell did not expand it (quoted, or
139/// handed over by a script or agent). Cross-platform: `HOME` on Unix (and
140/// Git Bash/WSL), falling back to `USERPROFILE` on native Windows.
141fn expand_tilde(path: PathBuf) -> PathBuf {
142    let home = env::var_os("HOME")
143        .or_else(|| env::var_os("USERPROFILE"))
144        .map(PathBuf::from);
145    expand_tilde_with(path, home)
146}
147
148fn expand_tilde_with(path: PathBuf, home: Option<PathBuf>) -> PathBuf {
149    let Some(rest) = path.to_str().and_then(|text| text.strip_prefix('~')) else {
150        return path;
151    };
152    // Only bare `~` and `~/...` (or `~\...` on Windows) expand; a `~user` form
153    // would need a passwd lookup we do not do, so leave it untouched.
154    let mut chars = rest.chars();
155    let tail = match chars.next() {
156        None => "",
157        Some(separator) if std::path::is_separator(separator) => chars.as_str(),
158        Some(_) => return path,
159    };
160    let Some(home) = home else {
161        return path;
162    };
163    if tail.is_empty() {
164        home
165    } else {
166        home.join(tail)
167    }
168}
169
170pub fn submit(options: SubmitOptions) -> Result<()> {
171    let SubmitOptions {
172        branch,
173        submit_stack,
174        downstack,
175        dry_run,
176        push_mode,
177        desc,
178        draft,
179        ready,
180        rebuild_overview,
181    } = options;
182
183    let branch = branch.map_or_else(git::current_branch, Ok)?;
184    // The description targets this branch's review even in stack mode.
185    let desc_branch = branch.clone();
186
187    let branches = if downstack {
188        // Bottom of the stack through the current branch: anything above is
189        // work in progress that stays local.
190        stack::path_from_root(&branch)?
191    } else if submit_stack {
192        // The stack containing the current branch: its own line, bottom
193        // through current and out to its descendants. Sibling stacks that
194        // merely share the trunk are left for their own submit.
195        stack::stack_line(&branch)?
196    } else {
197        vec![branch.clone()]
198    };
199
200    // The trunk is never part of a stack, so a stack-wide submit from it has
201    // nothing of its own to submit (its descendants are sibling stacks). Say so
202    // plainly rather than pushing an empty set or sweeping every stack.
203    if submit_stack || downstack {
204        let trunk = stack::trunk_branch(&git::local_branches()?);
205        if Some(&branch) == trunk.as_ref() {
206            if stack::children_of(&branch)?.is_empty() {
207                bail!("no stacked branches to submit");
208            }
209            bail!("you are on the trunk ({branch}); check out a stacked branch first");
210        }
211    }
212
213    let branch_parents = branch_parents(&branches)?;
214
215    // Push after stack validation but before any provider calls: creating a
216    // review requires the branch to exist remotely, and -u --force-with-lease
217    // covers both first pushes and safely updating rebased branches.
218    let push = settings::push_enabled(push_mode, settings::PUSH_ON_SUBMIT_KEY)?;
219    if push {
220        let remote = settings::remote()?;
221        if dry_run {
222            anstream::println!(
223                "would push {} to {remote}",
224                style::branch(&branches.join(" "))
225            );
226        } else {
227            git::push_set_upstream_force_with_lease(&remote, &branches)?;
228            anstream::println!("pushed {} to {remote}", style::branch(&branches.join(" ")));
229            // Carry the stack's parent map along so another clone can rebuild
230            // it with `git stk repair --from-remote`.
231            stack::publish_metadata(&remote);
232        }
233    }
234
235    let (provider, review_provider) = detect_review_provider()?;
236    let mut summary = SubmitSummary::default();
237
238    let mut created = Vec::new();
239    for (branch, parent) in &branch_parents {
240        let action = submit_branch(review_provider.as_ref(), branch, parent, dry_run, draft)?;
241        if action == SubmitAction::Created {
242            created.push(branch.clone());
243        }
244        summary.record(action);
245    }
246
247    // Seed freshly created reviews from the repo's PR template before the
248    // managed sections go in, so our content joins it rather than replacing it.
249    // Without a user description the template is wrapped in the managed
250    // description block; the branch that gets `--desc` keeps the template
251    // freeform above a seam so the description reads as a distinct block below.
252    let desc_target = desc.as_ref().map(|_| desc_branch.as_str());
253    crate::notes::seed_template_notes(
254        review_provider.as_ref(),
255        provider.kind,
256        &created,
257        desc_target,
258        dry_run,
259    )?;
260
261    // Flip drafts in scope to ready for review (the escape hatch for
262    // stk.submitDraft users).
263    if ready {
264        for branch in &branches {
265            let Some(review) = review_provider.review_for_branch(branch)? else {
266                continue;
267            };
268            if review.branch != *branch || !review.draft {
269                continue;
270            }
271            if dry_run {
272                anstream::println!("would mark {} ready", review.id);
273                continue;
274            }
275            let output = review_provider.mark_ready(&review)?;
276            anstream::println!("marked {} ready", review.id);
277            if !output.is_empty() {
278                println!("{output}");
279            }
280        }
281    }
282
283    // A renamed branch's fresh review now exists, so retire the review the old
284    // name still heads. Only handle this when the ledger prune below actually
285    // runs (stack-wide submit): the marker is the sole signal that identifies
286    // the stale row across every other overview, so closing and clearing it in
287    // a single-branch submit - which never prunes - would orphan those rows
288    // permanently. Left set, the marker waits for a later `submit --stack`.
289    let renamed: Vec<(String, String)> = if submit_stack || downstack {
290        branch_parents
291            .iter()
292            .filter_map(|(branch, _)| {
293                stack::renamed_from(branch)
294                    .ok()
295                    .flatten()
296                    .map(|old| (branch.clone(), old))
297            })
298            .collect()
299    } else {
300        Vec::new()
301    };
302    // Track which markers are safe to drop: those whose old review was
303    // actually retired (or had nothing to retire). A declined close keeps its
304    // marker so a later submit re-offers the reconciliation.
305    let mut reconciled: Vec<&str> = Vec::new();
306    for (branch, old) in &renamed {
307        if close_superseded_review(review_provider.as_ref(), old, dry_run)? {
308            reconciled.push(branch);
309        }
310    }
311
312    // After every review exists, write the description, link any issue the
313    // branch name references, then (in stack mode) write the stack overview
314    // into each body.
315    if let Some(desc) = desc {
316        crate::notes::update_description_note(
317            review_provider.as_ref(),
318            &desc_branch,
319            &desc,
320            dry_run,
321        )?;
322    }
323    crate::notes::update_closes_notes(review_provider.as_ref(), &branches, dry_run)?;
324    if submit_stack || downstack {
325        crate::notes::update_stack_notes(
326            review_provider.as_ref(),
327            &branch_parents,
328            dry_run,
329            rebuild_overview,
330        )?;
331    }
332
333    // The ledger has now pruned the superseded entries, so drop the markers -
334    // but only for reviews that were retired, not ones the user kept.
335    if !dry_run {
336        for branch in &reconciled {
337            stack::clear_renamed_from(branch)?;
338        }
339    }
340
341    anstream::println!(
342        "{}",
343        style::success(&format!(
344            "submit complete: {} created, {} updated, {} skipped",
345            summary.created, summary.updated, summary.skipped
346        ))
347    );
348    Ok(())
349}
350
351/// Retire the open review still heading a renamed-away branch. The fresh
352/// review already exists, so closing here never leaves the work without one.
353/// Prompts (default yes; a non-interactive run proceeds) before closing.
354///
355/// Returns whether the supersession was reconciled: `true` when the old review
356/// was closed or there was nothing to close, `false` when the user declined -
357/// so the caller keeps the rename marker for a later submit to re-offer.
358fn close_superseded_review(
359    review_provider: &dyn ReviewProvider,
360    old: &str,
361    dry_run: bool,
362) -> Result<bool> {
363    let Some(review) = review_provider.review_for_branch(old)? else {
364        return Ok(true);
365    };
366    if review.branch != *old {
367        return Ok(true);
368    }
369
370    if dry_run {
371        anstream::println!("would close superseded review {} for {old}", review.id);
372        return Ok(true);
373    }
374    if !crate::prompt::confirm_default_yes(&format!(
375        "close the replaced review {} for {old} and delete its branch? [Y/n] ",
376        review.id
377    ))? {
378        anstream::println!("kept review {} for {old}", review.id);
379        return Ok(false);
380    }
381
382    review_provider.close_review(&review, true)?;
383    anstream::println!("closed superseded review {} for {old}", review.id);
384    Ok(true)
385}
386
387fn branch_parents(branches: &[String]) -> Result<Vec<(String, String)>> {
388    let mut branch_parents = Vec::new();
389    for branch in branches {
390        let Some(parent) = stack::parent_of(branch)? else {
391            bail!("{branch} has no stack parent; run `git stk adopt` or `git stk sync` first");
392        };
393        branch_parents.push((branch.to_owned(), parent));
394    }
395    Ok(branch_parents)
396}
397
398fn submit_branch(
399    review_provider: &dyn ReviewProvider,
400    branch: &str,
401    parent: &str,
402    dry_run: bool,
403    draft: bool,
404) -> Result<SubmitAction> {
405    if let Some(review) = review_provider.review_for_branch(branch)? {
406        if review.base == parent {
407            if dry_run {
408                anstream::println!(
409                    "would skip {} -> {} ({})",
410                    review.branch,
411                    review.base,
412                    review.id
413                );
414            } else {
415                anstream::println!(
416                    "{}",
417                    style::dim(&format!(
418                        "{} already targets {} ({})",
419                        review.branch, review.base, review.id
420                    ))
421                );
422            }
423            return Ok(SubmitAction::Skipped);
424        }
425
426        let output = if dry_run {
427            String::new()
428        } else {
429            review_provider.update_review_base(&review, parent)?
430        };
431        anstream::println!(
432            "{} {} -> {} {}",
433            if dry_run { "would update" } else { "updated" },
434            style::branch(&review.branch),
435            style::branch(parent),
436            style::dim(&format!("({})", review.id))
437        );
438        if !output.is_empty() {
439            println!("{output}");
440        }
441    } else {
442        let output = if dry_run {
443            String::new()
444        } else {
445            review_provider.create_review(branch, parent, draft)?
446        };
447        anstream::println!(
448            "{} {} -> {}",
449            if dry_run { "would create" } else { "created" },
450            style::branch(branch),
451            style::branch(parent)
452        );
453        if !output.is_empty() {
454            println!("{output}");
455        }
456        return Ok(SubmitAction::Created);
457    }
458
459    Ok(SubmitAction::Updated)
460}
461
462#[derive(Debug, Default)]
463struct SubmitSummary {
464    created: usize,
465    updated: usize,
466    skipped: usize,
467}
468
469impl SubmitSummary {
470    fn record(&mut self, action: SubmitAction) {
471        match action {
472            SubmitAction::Created => self.created += 1,
473            SubmitAction::Updated => self.updated += 1,
474            SubmitAction::Skipped => self.skipped += 1,
475        }
476    }
477}
478
479#[derive(Debug, Clone, Copy, Eq, PartialEq)]
480enum SubmitAction {
481    Created,
482    Updated,
483    Skipped,
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    fn home() -> Option<PathBuf> {
491        Some(PathBuf::from("/home/dev"))
492    }
493
494    #[test]
495    fn expand_tilde_resolves_a_bare_tilde_and_subpaths() {
496        assert_eq!(
497            expand_tilde_with(PathBuf::from("~"), home()),
498            PathBuf::from("/home/dev")
499        );
500        assert_eq!(
501            expand_tilde_with(PathBuf::from("~/notes/pr.md"), home()),
502            PathBuf::from("/home/dev/notes/pr.md")
503        );
504    }
505
506    #[test]
507    fn expand_tilde_leaves_other_paths_untouched() {
508        // Absolute, relative, `~user`, and an embedded (non-leading) tilde all
509        // pass through unchanged.
510        for raw in ["/etc/pr.md", "notes/pr.md", "~alice/pr.md", "docs/~x.md"] {
511            assert_eq!(
512                expand_tilde_with(PathBuf::from(raw), home()),
513                PathBuf::from(raw)
514            );
515        }
516    }
517
518    #[test]
519    fn expand_tilde_passes_through_when_home_is_unset() {
520        assert_eq!(
521            expand_tilde_with(PathBuf::from("~/pr.md"), None),
522            PathBuf::from("~/pr.md")
523        );
524    }
525
526    #[cfg(windows)]
527    #[test]
528    fn expand_tilde_accepts_a_backslash_on_windows() {
529        assert_eq!(
530            expand_tilde_with(PathBuf::from(r"~\notes\pr.md"), home()),
531            PathBuf::from("/home/dev").join(r"notes\pr.md")
532        );
533    }
534}