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, ReviewState, 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    /// Request reviews from these users or teams on every submitted review
60    /// (comma-separated, or repeat the flag). A leading `@` is optional and
61    /// stripped, so `@foo,@bar` and `foo,bar` mean the same. GitHub/Gitea team
62    /// reviewers use the `org/team` form (`@my-org/backend`).
63    #[arg(long, value_name = "CSV", value_delimiter = ',')]
64    reviewers: Vec<String>,
65    /// Create new reviews as drafts.
66    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_draft")]
67    draft: bool,
68    /// Create new reviews ready for review, overriding stk.submitDraft.
69    #[arg(long, action = ArgAction::SetTrue)]
70    no_draft: bool,
71    /// Mark the submitted branches' existing draft reviews as ready.
72    #[arg(long, action = ArgAction::SetTrue, conflicts_with = "draft")]
73    ready: bool,
74    /// Rebuild each review's stack overview from the live stack plus merged
75    /// history, dropping closed or orphaned rows that drifted in. Stack mode.
76    #[arg(long, action = ArgAction::SetTrue)]
77    rebuild_overview: bool,
78}
79
80impl Run for Submit {
81    fn run(self) -> Result<()> {
82        // Stack mode: --stack forces it on; --no-stack or an explicit branch
83        // forces it off; otherwise stk.submitStack decides.
84        let submit_stack = if self.stack {
85            true
86        } else if self.no_stack || self.branch.is_some() {
87            false
88        } else {
89            settings::bool_setting(settings::SUBMIT_STACK_KEY)?
90        };
91
92        // Draft mode: --draft forces it on, --no-draft off; otherwise
93        // stk.submitDraft decides.
94        let draft = if self.draft {
95            true
96        } else if self.no_draft {
97            false
98        } else {
99            settings::bool_setting(settings::SUBMIT_DRAFT_KEY)?
100        };
101
102        // A file source resolves to the same description string; clap's
103        // conflicts_with guarantees at most one of the two is set.
104        let desc = match self.desc_file {
105            Some(path) => {
106                let path = expand_tilde(path);
107                let raw = std::fs::read_to_string(&path).with_context(|| {
108                    format!("failed to read description file {}", path.display())
109                })?;
110                Some(raw.trim().to_owned())
111            }
112            None => self.desc,
113        };
114
115        submit(SubmitOptions {
116            branch: self.branch,
117            submit_stack,
118            downstack: self.downstack,
119            dry_run: self.dry_run,
120            push_mode: PushMode::from_flags(self.push, self.no_push),
121            desc,
122            reviewers: normalize_reviewers(&self.reviewers),
123            draft,
124            ready: self.ready,
125            rebuild_overview: self.rebuild_overview,
126        })
127    }
128}
129
130/// Clean a raw `--reviewers` list: trim each entry, drop one optional leading
131/// `@` (so `@foo` and `foo` are the same), discard blanks, and de-duplicate
132/// while preserving order. A team keeps its `org/team` form - only the `@`
133/// prefix is stripped, never the slash. GitHub's Copilot reviewer is the one
134/// login that *needs* its `@` (`@copilot`), so it is preserved (and a bare
135/// `copilot` is canonicalized to it) rather than stripped like a username.
136fn normalize_reviewers(raw: &[String]) -> Vec<String> {
137    let mut reviewers: Vec<String> = Vec::new();
138    for entry in raw {
139        let trimmed = entry.trim();
140        let stripped = trimmed.strip_prefix('@').unwrap_or(trimmed).trim();
141        let name = if stripped.eq_ignore_ascii_case("copilot") {
142            "@copilot"
143        } else {
144            stripped
145        };
146        if name.is_empty() || reviewers.iter().any(|seen| seen == name) {
147            continue;
148        }
149        reviewers.push(name.to_owned());
150    }
151    reviewers
152}
153
154/// The resolved inputs for [`submit`] - one bundle instead of nine positional
155/// arguments. `Submit::run` resolves the flag/config defaults and fills it.
156pub struct SubmitOptions {
157    pub branch: Option<String>,
158    pub submit_stack: bool,
159    pub downstack: bool,
160    pub dry_run: bool,
161    pub push_mode: crate::cli::PushMode,
162    pub desc: Option<String>,
163    pub reviewers: Vec<String>,
164    pub draft: bool,
165    pub ready: bool,
166    pub rebuild_overview: bool,
167}
168
169/// Expand a leading `~` in a `--desc-file` path to the user's home, since the
170/// path reaches us literally when the shell did not expand it (quoted, or
171/// handed over by a script or agent). Cross-platform: `HOME` on Unix (and
172/// Git Bash/WSL), falling back to `USERPROFILE` on native Windows.
173fn expand_tilde(path: PathBuf) -> PathBuf {
174    let home = env::var_os("HOME")
175        .or_else(|| env::var_os("USERPROFILE"))
176        .map(PathBuf::from);
177    expand_tilde_with(path, home)
178}
179
180fn expand_tilde_with(path: PathBuf, home: Option<PathBuf>) -> PathBuf {
181    let Some(rest) = path.to_str().and_then(|text| text.strip_prefix('~')) else {
182        return path;
183    };
184    // Only bare `~` and `~/...` (or `~\...` on Windows) expand; a `~user` form
185    // would need a passwd lookup we do not do, so leave it untouched.
186    let mut chars = rest.chars();
187    let tail = match chars.next() {
188        None => "",
189        Some(separator) if std::path::is_separator(separator) => chars.as_str(),
190        Some(_) => return path,
191    };
192    let Some(home) = home else {
193        return path;
194    };
195    if tail.is_empty() {
196        home
197    } else {
198        home.join(tail)
199    }
200}
201
202pub fn submit(options: SubmitOptions) -> Result<()> {
203    let SubmitOptions {
204        branch,
205        submit_stack,
206        downstack,
207        dry_run,
208        push_mode,
209        desc,
210        reviewers,
211        draft,
212        ready,
213        rebuild_overview,
214    } = options;
215
216    let branch = branch.map_or_else(git::current_branch, Ok)?;
217    // The description targets this branch's review even in stack mode.
218    let desc_branch = branch.clone();
219
220    let branches = if downstack {
221        // Bottom of the stack through the current branch: anything above is
222        // work in progress that stays local.
223        stack::path_from_root(&branch)?
224    } else if submit_stack {
225        // The stack containing the current branch: its own line, bottom
226        // through current and out to its descendants. Sibling stacks that
227        // merely share the trunk are left for their own submit.
228        stack::stack_line(&branch)?
229    } else {
230        vec![branch.clone()]
231    };
232
233    // The trunk is never part of a stack, so a stack-wide submit from it has
234    // nothing of its own to submit (its descendants are sibling stacks). Say so
235    // plainly rather than pushing an empty set or sweeping every stack.
236    if submit_stack || downstack {
237        let trunk = stack::trunk_branch(&git::local_branches()?);
238        if Some(&branch) == trunk.as_ref() {
239            if stack::children_of(&branch)?.is_empty() {
240                bail!("no stacked branches to submit");
241            }
242            bail!("you are on the trunk ({branch}); check out a stacked branch first");
243        }
244    }
245
246    let branch_parents = branch_parents(&branches)?;
247
248    // Push after stack validation but before any provider calls: creating a
249    // review requires the branch to exist remotely, and -u --force-with-lease
250    // covers both first pushes and safely updating rebased branches.
251    let push = settings::push_enabled(push_mode, settings::PUSH_ON_SUBMIT_KEY)?;
252    if push {
253        let remote = settings::remote()?;
254        if dry_run {
255            anstream::println!(
256                "would push {} to {remote}",
257                style::branch(&branches.join(" "))
258            );
259        } else {
260            git::push_set_upstream_force_with_lease(&remote, &branches)?;
261            anstream::println!("pushed {} to {remote}", style::branch(&branches.join(" ")));
262            // Carry the stack's parent map along so another clone can rebuild
263            // it with `git stk repair --from-remote`.
264            stack::publish_metadata(&remote);
265        }
266    }
267
268    let (provider, review_provider) = detect_review_provider()?;
269    let mut summary = SubmitSummary::default();
270
271    let mut created = Vec::new();
272    for (branch, parent) in &branch_parents {
273        let action = submit_branch(review_provider.as_ref(), branch, parent, dry_run, draft)?;
274        if action == SubmitAction::Created {
275            created.push(branch.clone());
276        }
277        summary.record(action);
278    }
279
280    // Seed freshly created reviews from the repo's PR template before the
281    // managed sections go in, so our content joins it rather than replacing it.
282    // Without a user description the template is wrapped in the managed
283    // description block; the branch that gets `--desc` keeps the template
284    // freeform above a seam so the description reads as a distinct block below.
285    let desc_target = desc.as_ref().map(|_| desc_branch.as_str());
286    crate::notes::seed_template_notes(
287        review_provider.as_ref(),
288        provider.kind,
289        &created,
290        desc_target,
291        dry_run,
292    )?;
293
294    // Flip drafts in scope to ready for review (the escape hatch for
295    // stk.submitDraft users).
296    if ready {
297        for branch in &branches {
298            let Some(review) = review_provider.review_for_branch(branch)? else {
299                continue;
300            };
301            if review.branch != *branch || !review.draft {
302                continue;
303            }
304            if dry_run {
305                anstream::println!("would mark {} ready", review.id);
306                continue;
307            }
308            let output = review_provider.mark_ready(&review)?;
309            anstream::println!("marked {} ready", review.id);
310            if !output.is_empty() {
311                println!("{output}");
312            }
313        }
314    }
315
316    // A renamed branch's fresh review now exists, so retire the review the old
317    // name still heads. Only handle this when the ledger prune below actually
318    // runs (stack-wide submit): the marker is the sole signal that identifies
319    // the stale row across every other overview, so closing and clearing it in
320    // a single-branch submit - which never prunes - would orphan those rows
321    // permanently. Left set, the marker waits for a later `submit --stack`.
322    let renamed: Vec<(String, String)> = if submit_stack || downstack {
323        branch_parents
324            .iter()
325            .filter_map(|(branch, _)| {
326                stack::renamed_from(branch)
327                    .ok()
328                    .flatten()
329                    .map(|old| (branch.clone(), old))
330            })
331            .collect()
332    } else {
333        Vec::new()
334    };
335    // Track which markers are safe to drop: those whose old review was
336    // actually retired (or had nothing to retire). A declined close keeps its
337    // marker so a later submit re-offers the reconciliation.
338    let mut reconciled: Vec<&str> = Vec::new();
339    for (branch, old) in &renamed {
340        if close_superseded_review(review_provider.as_ref(), old, dry_run)? {
341            reconciled.push(branch);
342        }
343    }
344
345    // After every review exists, write the description, link any issue the
346    // branch name references, then (in stack mode) write the stack overview
347    // into each body.
348    if let Some(desc) = desc {
349        crate::notes::update_description_note(
350            review_provider.as_ref(),
351            &desc_branch,
352            &desc,
353            dry_run,
354        )?;
355    }
356    crate::notes::update_closes_notes(review_provider.as_ref(), &branches, dry_run)?;
357    if submit_stack || downstack {
358        crate::notes::update_stack_notes(
359            review_provider.as_ref(),
360            &branch_parents,
361            dry_run,
362            rebuild_overview,
363        )?;
364    }
365    apply_reviewers(review_provider.as_ref(), &branches, &reviewers, dry_run)?;
366
367    // The ledger has now pruned the superseded entries, so drop the markers -
368    // but only for reviews that were retired, not ones the user kept.
369    if !dry_run {
370        for branch in &reconciled {
371            stack::clear_renamed_from(branch)?;
372        }
373    }
374
375    anstream::println!(
376        "{}",
377        style::success(&format!(
378            "submit complete: {} created, {} updated, {} skipped",
379            summary.created, summary.updated, summary.skipped
380        ))
381    );
382    Ok(())
383}
384
385/// Retire the open review still heading a renamed-away branch. The fresh
386/// review already exists, so closing here never leaves the work without one.
387/// Prompts (default yes; a non-interactive run proceeds) before closing.
388///
389/// Returns whether the supersession was reconciled: `true` when the old review
390/// was closed or there was nothing to close, `false` when the user declined -
391/// so the caller keeps the rename marker for a later submit to re-offer.
392fn close_superseded_review(
393    review_provider: &dyn ReviewProvider,
394    old: &str,
395    dry_run: bool,
396) -> Result<bool> {
397    let Some(review) = review_provider.review_for_branch(old)? else {
398        return Ok(true);
399    };
400    if review.branch != *old {
401        return Ok(true);
402    }
403
404    if dry_run {
405        anstream::println!("would close superseded review {} for {old}", review.id);
406        return Ok(true);
407    }
408    if !crate::prompt::confirm_default_yes(&format!(
409        "close the replaced review {} for {old} and delete its branch? [Y/n] ",
410        review.id
411    ))? {
412        anstream::println!("kept review {} for {old}", review.id);
413        return Ok(false);
414    }
415
416    review_provider.close_review(&review, true)?;
417    anstream::println!("closed superseded review {} for {old}", review.id);
418    Ok(true)
419}
420
421/// Request reviews from `reviewers` on every submitted branch's review. A
422/// merged review is skipped - there is nothing left to review - and a branch
423/// whose review is missing (or heads a different branch) is passed over with a
424/// note, mirroring the description and closes steps. No-op with no reviewers.
425fn apply_reviewers(
426    review_provider: &dyn ReviewProvider,
427    branches: &[String],
428    reviewers: &[String],
429    dry_run: bool,
430) -> Result<()> {
431    if reviewers.is_empty() {
432        return Ok(());
433    }
434    let list = reviewers.join(", ");
435    for branch in branches {
436        let Some(review) = review_provider.review_for_branch(branch)? else {
437            // On a dry run the review was likely never created; for real the
438            // submit just failed to produce one, which deserves a mention.
439            if dry_run {
440                anstream::println!("would request reviews from {list} for {branch}");
441            } else {
442                anstream::println!("skipped reviewers: no review found for {branch}");
443            }
444            continue;
445        };
446        if review.branch != *branch || review.state == ReviewState::Merged {
447            continue;
448        }
449        if dry_run {
450            anstream::println!("would request reviews from {list} in {}", review.id);
451            continue;
452        }
453        let output = review_provider.request_reviewers(&review, reviewers)?;
454        anstream::println!("requested reviews from {list} in {}", review.id);
455        if !output.is_empty() {
456            println!("{output}");
457        }
458    }
459    Ok(())
460}
461
462fn branch_parents(branches: &[String]) -> Result<Vec<(String, String)>> {
463    let mut branch_parents = Vec::new();
464    for branch in branches {
465        let Some(parent) = stack::parent_of(branch)? else {
466            bail!("{branch} has no stack parent; run `git stk adopt` or `git stk sync` first");
467        };
468        branch_parents.push((branch.to_owned(), parent));
469    }
470    Ok(branch_parents)
471}
472
473fn submit_branch(
474    review_provider: &dyn ReviewProvider,
475    branch: &str,
476    parent: &str,
477    dry_run: bool,
478    draft: bool,
479) -> Result<SubmitAction> {
480    if let Some(review) = review_provider.review_for_branch(branch)? {
481        if review.base == parent {
482            if dry_run {
483                anstream::println!(
484                    "would skip {} -> {} ({})",
485                    review.branch,
486                    review.base,
487                    review.id
488                );
489            } else {
490                anstream::println!(
491                    "{}",
492                    style::dim(&format!(
493                        "{} already targets {} ({})",
494                        review.branch, review.base, review.id
495                    ))
496                );
497            }
498            return Ok(SubmitAction::Skipped);
499        }
500
501        let output = if dry_run {
502            String::new()
503        } else {
504            review_provider.update_review_base(&review, parent)?
505        };
506        anstream::println!(
507            "{} {} -> {} {}",
508            if dry_run { "would update" } else { "updated" },
509            style::branch(&review.branch),
510            style::branch(parent),
511            style::dim(&format!("({})", review.id))
512        );
513        if !output.is_empty() {
514            println!("{output}");
515        }
516    } else {
517        let output = if dry_run {
518            String::new()
519        } else {
520            review_provider.create_review(branch, parent, draft)?
521        };
522        anstream::println!(
523            "{} {} -> {}",
524            if dry_run { "would create" } else { "created" },
525            style::branch(branch),
526            style::branch(parent)
527        );
528        if !output.is_empty() {
529            println!("{output}");
530        }
531        return Ok(SubmitAction::Created);
532    }
533
534    Ok(SubmitAction::Updated)
535}
536
537#[derive(Debug, Default)]
538struct SubmitSummary {
539    created: usize,
540    updated: usize,
541    skipped: usize,
542}
543
544impl SubmitSummary {
545    fn record(&mut self, action: SubmitAction) {
546        match action {
547            SubmitAction::Created => self.created += 1,
548            SubmitAction::Updated => self.updated += 1,
549            SubmitAction::Skipped => self.skipped += 1,
550        }
551    }
552}
553
554#[derive(Debug, Clone, Copy, Eq, PartialEq)]
555enum SubmitAction {
556    Created,
557    Updated,
558    Skipped,
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564
565    fn home() -> Option<PathBuf> {
566        Some(PathBuf::from("/home/dev"))
567    }
568
569    #[test]
570    fn expand_tilde_resolves_a_bare_tilde_and_subpaths() {
571        assert_eq!(
572            expand_tilde_with(PathBuf::from("~"), home()),
573            PathBuf::from("/home/dev")
574        );
575        assert_eq!(
576            expand_tilde_with(PathBuf::from("~/notes/pr.md"), home()),
577            PathBuf::from("/home/dev/notes/pr.md")
578        );
579    }
580
581    #[test]
582    fn expand_tilde_leaves_other_paths_untouched() {
583        // Absolute, relative, `~user`, and an embedded (non-leading) tilde all
584        // pass through unchanged.
585        for raw in ["/etc/pr.md", "notes/pr.md", "~alice/pr.md", "docs/~x.md"] {
586            assert_eq!(
587                expand_tilde_with(PathBuf::from(raw), home()),
588                PathBuf::from(raw)
589            );
590        }
591    }
592
593    #[test]
594    fn expand_tilde_passes_through_when_home_is_unset() {
595        assert_eq!(
596            expand_tilde_with(PathBuf::from("~/pr.md"), None),
597            PathBuf::from("~/pr.md")
598        );
599    }
600
601    fn reviewers(raw: &[&str]) -> Vec<String> {
602        normalize_reviewers(
603            &raw.iter()
604                .map(|entry| (*entry).to_owned())
605                .collect::<Vec<_>>(),
606        )
607    }
608
609    #[test]
610    fn normalize_reviewers_strips_at_and_trims() {
611        // A leading `@` is optional, so both spellings normalize alike.
612        assert_eq!(reviewers(&["@foo", "@bar"]), vec!["foo", "bar"]);
613        assert_eq!(reviewers(&["foo", "bar"]), vec!["foo", "bar"]);
614        assert_eq!(reviewers(&[" @foo ", "  bar"]), vec!["foo", "bar"]);
615    }
616
617    #[test]
618    fn normalize_reviewers_keeps_team_paths_but_drops_the_at() {
619        // Only the `@` prefix is stripped; the `org/team` slug stays intact.
620        assert_eq!(
621            reviewers(&["@my-org/backend", "acme/team"]),
622            vec!["my-org/backend", "acme/team"]
623        );
624    }
625
626    #[test]
627    fn normalize_reviewers_drops_blanks_and_dedupes_in_order() {
628        assert_eq!(
629            reviewers(&["foo", "", "  ", "@foo", "bar", "@bar"]),
630            vec!["foo", "bar"]
631        );
632    }
633
634    #[test]
635    fn normalize_reviewers_preserves_the_copilot_at_prefix() {
636        // gh needs the literal `@copilot`; a bare `copilot` canonicalizes to it,
637        // and both spellings collapse to one entry.
638        assert_eq!(reviewers(&["@copilot"]), vec!["@copilot"]);
639        assert_eq!(reviewers(&["copilot"]), vec!["@copilot"]);
640        assert_eq!(reviewers(&["@Copilot", "copilot"]), vec!["@copilot"]);
641    }
642
643    #[cfg(windows)]
644    #[test]
645    fn expand_tilde_accepts_a_backslash_on_windows() {
646        assert_eq!(
647            expand_tilde_with(PathBuf::from(r"~\notes\pr.md"), home()),
648            PathBuf::from("/home/dev").join(r"notes\pr.md")
649        );
650    }
651}