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::{BaseGap, NativeStack, ReviewProvider, ReviewState, detect_review_provider};
12use crate::settings;
13use crate::style;
14use crate::{git, stack};
15
16#[derive(Debug, clap::Args)]
18pub struct Submit {
19 #[arg(add = ArgValueCompleter::new(completions::branch_candidates))]
21 branch: Option<String>,
22 #[arg(long, short = 'n', action = ArgAction::SetTrue)]
24 dry_run: bool,
25 #[arg(long, conflicts_with = "branch")]
27 stack: bool,
28 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "stack")]
30 no_stack: bool,
31 #[arg(
34 long,
35 action = ArgAction::SetTrue,
36 conflicts_with_all = ["branch", "stack", "no_stack"],
37 )]
38 downstack: bool,
39 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_push")]
41 push: bool,
42 #[arg(long, action = ArgAction::SetTrue)]
44 no_push: bool,
45 #[arg(long, short = 't', value_name = "TEXT")]
48 title: Option<String>,
49 #[arg(long, short = 'd')]
52 desc: Option<String>,
53 #[arg(
57 long = "desc-file",
58 value_name = "PATH",
59 value_hint = clap::ValueHint::FilePath,
60 conflicts_with = "desc",
61 )]
62 desc_file: Option<PathBuf>,
63 #[arg(long, value_name = "CSV", value_delimiter = ',')]
68 reviewers: Vec<String>,
69 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_draft")]
71 draft: bool,
72 #[arg(long, action = ArgAction::SetTrue)]
74 no_draft: bool,
75 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "draft")]
77 ready: bool,
78 #[arg(long, action = ArgAction::SetTrue)]
81 rebuild_overview: bool,
82}
83
84impl Run for Submit {
85 fn run(self) -> Result<()> {
86 let submit_stack = if self.stack {
89 true
90 } else if self.no_stack || self.branch.is_some() {
91 false
92 } else {
93 settings::bool_setting(settings::SUBMIT_STACK_KEY)?
94 };
95
96 let draft = if self.draft {
99 true
100 } else if self.no_draft {
101 false
102 } else {
103 settings::bool_setting(settings::SUBMIT_DRAFT_KEY)?
104 };
105
106 let desc = match self.desc_file {
109 Some(path) => {
110 let path = expand_tilde(path);
111 let raw = std::fs::read_to_string(&path).with_context(|| {
112 format!("failed to read description file {}", path.display())
113 })?;
114 Some(raw.trim().to_owned())
115 }
116 None => self.desc,
117 };
118
119 let title = match self.title {
122 Some(title) if title.trim().is_empty() => bail!("--title cannot be empty"),
123 Some(title) => Some(title.trim().to_owned()),
124 None => None,
125 };
126
127 submit(SubmitOptions {
128 branch: self.branch,
129 submit_stack,
130 downstack: self.downstack,
131 dry_run: self.dry_run,
132 push_mode: PushMode::from_flags(self.push, self.no_push),
133 title,
134 desc,
135 reviewers: normalize_reviewers(&self.reviewers),
136 draft,
137 ready: self.ready,
138 rebuild_overview: self.rebuild_overview,
139 })
140 }
141}
142
143fn normalize_reviewers(raw: &[String]) -> Vec<String> {
150 let mut reviewers: Vec<String> = Vec::new();
151 for entry in raw {
152 let trimmed = entry.trim();
153 let stripped = trimmed.strip_prefix('@').unwrap_or(trimmed).trim();
154 let name = if stripped.eq_ignore_ascii_case("copilot") {
155 "@copilot"
156 } else {
157 stripped
158 };
159 if name.is_empty() || reviewers.iter().any(|seen| seen == name) {
160 continue;
161 }
162 reviewers.push(name.to_owned());
163 }
164 reviewers
165}
166
167pub struct SubmitOptions {
170 pub branch: Option<String>,
171 pub submit_stack: bool,
172 pub downstack: bool,
173 pub dry_run: bool,
174 pub push_mode: crate::cli::PushMode,
175 pub title: Option<String>,
176 pub desc: Option<String>,
177 pub reviewers: Vec<String>,
178 pub draft: bool,
179 pub ready: bool,
180 pub rebuild_overview: bool,
181}
182
183fn expand_tilde(path: PathBuf) -> PathBuf {
188 let home = env::var_os("HOME")
189 .or_else(|| env::var_os("USERPROFILE"))
190 .map(PathBuf::from);
191 expand_tilde_with(path, home)
192}
193
194fn expand_tilde_with(path: PathBuf, home: Option<PathBuf>) -> PathBuf {
195 let Some(rest) = path.to_str().and_then(|text| text.strip_prefix('~')) else {
196 return path;
197 };
198 let mut chars = rest.chars();
201 let tail = match chars.next() {
202 None => "",
203 Some(separator) if std::path::is_separator(separator) => chars.as_str(),
204 Some(_) => return path,
205 };
206 let Some(home) = home else {
207 return path;
208 };
209 if tail.is_empty() {
210 home
211 } else {
212 home.join(tail)
213 }
214}
215
216pub fn submit(options: SubmitOptions) -> Result<()> {
217 let SubmitOptions {
218 branch,
219 submit_stack,
220 downstack,
221 dry_run,
222 push_mode,
223 title,
224 desc,
225 reviewers,
226 draft,
227 ready,
228 rebuild_overview,
229 } = options;
230
231 let branch = branch.map_or_else(git::current_branch, Ok)?;
232 let target_branch = branch.clone();
234
235 let mut branches = if downstack {
236 stack::path_from_root(&branch)?
239 } else if submit_stack {
240 stack::stack_line(&branch)?
244 } else {
245 vec![branch.clone()]
246 };
247
248 if submit_stack || downstack {
252 let trunk = stack::trunk_branch(&git::local_branches()?);
253 if Some(&branch) == trunk.as_ref() {
254 if !stack::has_stacked_branches()? {
255 bail!("no stacked branches to submit");
256 }
257 bail!("you are on the trunk ({branch}); check out a stacked branch first");
258 }
259 }
260
261 let mut base = None;
267 if submit_stack || downstack {
268 base = stack::unanchored_base(&branches)?;
269 let unmarked_base = base.is_none()
274 && branches.len() == 1
275 && stack::parent_of(&branches[0])?.is_none()
276 && !stack::children_of(&branches[0])?.is_empty();
277
278 if let Some(found) = &base {
279 branches.retain(|branch| branch != found);
280 }
281
282 if unmarked_base || (base.is_some() && branches.is_empty()) {
286 let name = base.as_deref().unwrap_or_else(|| branches[0].as_str());
287 return Err(base_has_nothing_to_submit(name)?);
288 }
289
290 if let Some(found) = &base {
291 anstream::println!(
292 "{}",
293 style::dim(&format!("{found} is this stack's base; not submitted"))
294 );
295 }
296 }
297
298 let target_in_scope = branches.contains(&target_branch);
302
303 let branch_parents = branch_parents(&branches)?;
304
305 let push = settings::push_enabled(push_mode, settings::PUSH_ON_SUBMIT_KEY)?;
309
310 if let Some(base) = &base
316 && push
317 {
318 let remote = settings::remote()?;
319 if !git::remote_has_branch(&remote, base)? {
320 let lowest = &branches[0];
325 bail!(
326 "{base} is this stack's base, but {remote} has no such branch; \
327 push {base} to {remote} first, or re-root the stack with \
328 `git stk adopt {lowest} --parent <parent>`"
329 );
330 }
331 }
332 if push {
333 let remote = settings::remote()?;
334 if dry_run {
335 anstream::println!(
336 "would push {} to {remote}",
337 style::branch(&branches.join(" "))
338 );
339 } else {
340 git::push_set_upstream_force_with_lease(&remote, &branches)?;
341 anstream::println!("pushed {} to {remote}", style::branch(&branches.join(" ")));
342 stack::publish_metadata(&remote);
345 }
346 }
347
348 let (provider, review_provider) = detect_review_provider()?;
349 let mut summary = SubmitSummary::default();
350
351 let mut created = Vec::new();
352 for (branch, parent) in &branch_parents {
353 let branch_title = title.as_deref().filter(|_| *branch == target_branch);
356 let action = submit_branch(
357 review_provider.as_ref(),
358 branch,
359 parent,
360 dry_run,
361 draft,
362 branch_title,
363 )?;
364 if action == SubmitAction::Created {
365 created.push(branch.clone());
366 }
367 summary.record(action);
368 }
369
370 let desc_target = desc.as_ref().map(|_| target_branch.as_str());
376 crate::notes::seed_template_notes(
377 review_provider.as_ref(),
378 provider.kind,
379 &created,
380 desc_target,
381 dry_run,
382 )?;
383
384 if ready {
387 for branch in &branches {
388 let Some(review) = review_provider.review_for_branch(branch)? else {
389 continue;
390 };
391 if review.branch != *branch || !review.draft {
392 continue;
393 }
394 if dry_run {
395 anstream::println!("would mark {} ready", review.id);
396 continue;
397 }
398 let output = review_provider.mark_ready(&review)?;
399 anstream::println!("marked {} ready", review.id);
400 if !output.is_empty() {
401 println!("{output}");
402 }
403 }
404 }
405
406 let renamed: Vec<(String, String)> = if submit_stack || downstack {
413 branch_parents
414 .iter()
415 .filter_map(|(branch, _)| {
416 stack::renamed_from(branch)
417 .ok()
418 .flatten()
419 .map(|old| (branch.clone(), old))
420 })
421 .collect()
422 } else {
423 Vec::new()
424 };
425 let mut reconciled: Vec<&str> = Vec::new();
429 for (branch, old) in &renamed {
430 if close_superseded_review(review_provider.as_ref(), old, dry_run)? {
431 reconciled.push(branch);
432 }
433 }
434
435 if let Some(title) = &title {
439 if !target_in_scope {
440 anstream::println!("skipped title: {target_branch} is this stack's base");
441 } else if !created.contains(&target_branch) {
442 apply_title(review_provider.as_ref(), &target_branch, title, dry_run)?;
445 }
446 }
447 if let Some(desc) = desc {
448 if target_in_scope {
449 crate::notes::update_description_note(
450 review_provider.as_ref(),
451 &target_branch,
452 &desc,
453 dry_run,
454 )?;
455 } else {
456 anstream::println!("skipped description: {target_branch} is this stack's base");
457 }
458 }
459 crate::notes::update_closes_notes(review_provider.as_ref(), &branches, dry_run)?;
460 if submit_stack || downstack {
461 crate::notes::update_stack_notes(
462 review_provider.as_ref(),
463 &branch_parents,
464 dry_run,
465 rebuild_overview,
466 )?;
467 }
468 if submit_stack || downstack {
469 register_native_stack(review_provider.as_ref(), &branches, dry_run)?;
470 }
471 apply_reviewers(review_provider.as_ref(), &branches, &reviewers, dry_run)?;
472
473 if !dry_run {
476 for branch in &reconciled {
477 stack::clear_renamed_from(branch)?;
478 }
479 }
480
481 anstream::println!(
482 "{}",
483 style::success(&format!(
484 "submit complete: {} created, {} updated, {} skipped",
485 summary.created, summary.updated, summary.skipped
486 ))
487 );
488 Ok(())
489}
490
491fn close_superseded_review(
499 review_provider: &dyn ReviewProvider,
500 old: &str,
501 dry_run: bool,
502) -> Result<bool> {
503 let Some(review) = review_provider.review_for_branch(old)? else {
504 return Ok(true);
505 };
506 if review.branch != *old {
507 return Ok(true);
508 }
509
510 if dry_run {
511 anstream::println!("would close superseded review {} for {old}", review.id);
512 return Ok(true);
513 }
514 if !crate::prompt::confirm_default_yes(&format!(
515 "close the replaced review {} for {old} and delete its branch? [Y/n] ",
516 review.id
517 ))? {
518 anstream::println!("kept review {} for {old}", review.id);
519 return Ok(false);
520 }
521
522 review_provider.close_review(&review, true)?;
523 anstream::println!("closed superseded review {} for {old}", review.id);
524 Ok(true)
525}
526
527fn apply_title(
531 review_provider: &dyn ReviewProvider,
532 branch: &str,
533 title: &str,
534 dry_run: bool,
535) -> Result<()> {
536 let Some(review) = review_provider.review_for_branch(branch)? else {
537 if dry_run {
538 anstream::println!("would set the title on the review for {branch}");
539 } else {
540 anstream::println!("skipped title: no review found for {branch}");
541 }
542 return Ok(());
543 };
544 if review.branch != branch {
545 anstream::println!(
546 "skipped title: review {} belongs to {}",
547 review.id,
548 review.branch
549 );
550 return Ok(());
551 }
552 if dry_run {
553 anstream::println!("would set the title in {}", review.id);
554 return Ok(());
555 }
556
557 let output = review_provider.update_review_title(&review, title)?;
558 anstream::println!("set title in {}", review.id);
559 if !output.is_empty() {
560 println!("{output}");
561 }
562 Ok(())
563}
564
565fn apply_reviewers(
570 review_provider: &dyn ReviewProvider,
571 branches: &[String],
572 reviewers: &[String],
573 dry_run: bool,
574) -> Result<()> {
575 if reviewers.is_empty() {
576 return Ok(());
577 }
578 let list = reviewers.join(", ");
579 for branch in branches {
580 let Some(review) = review_provider.review_for_branch(branch)? else {
581 if dry_run {
584 anstream::println!("would request reviews from {list} for {branch}");
585 } else {
586 anstream::println!("skipped reviewers: no review found for {branch}");
587 }
588 continue;
589 };
590 if review.branch != *branch || review.state == ReviewState::Merged {
591 continue;
592 }
593 if dry_run {
594 anstream::println!("would request reviews from {list} in {}", review.id);
595 continue;
596 }
597 let output = review_provider.request_reviewers(&review, reviewers)?;
598 anstream::println!("requested reviews from {list} in {}", review.id);
599 if !output.is_empty() {
600 println!("{output}");
601 }
602 }
603 Ok(())
604}
605
606fn base_has_nothing_to_submit(branch: &str) -> Result<anyhow::Error> {
611 if stack::children_of(branch)?.is_empty() {
612 return Ok(anyhow::anyhow!(
613 "{branch} is this stack's base, and nothing is stacked on it"
614 ));
615 }
616 Ok(anyhow::anyhow!(
620 "{branch} is this stack's base; there is nothing below it to submit - \
621 run `git stk submit --stack` from {branch} to submit the branches above it"
622 ))
623}
624
625fn register_native_stack(
634 review_provider: &dyn ReviewProvider,
635 branches: &[String],
636 dry_run: bool,
637) -> Result<()> {
638 if branches.is_empty() {
639 return Ok(());
640 }
641 if !review_provider.registers_stacks() {
647 return Ok(());
648 }
649 let existing = branches
654 .iter()
655 .find_map(|branch| review_provider.native_stack_for(branch).ok().flatten());
656
657 let mut reviews = Vec::with_capacity(branches.len());
658 for branch in branches {
659 let Some(review) = review_provider.review_for_branch(branch)? else {
660 if !dry_run {
663 anstream::println!("skipped stack registration: no review found for {branch}");
664 }
665 return Ok(());
666 };
667 if review.branch != *branch {
668 return Ok(());
669 }
670 reviews.push(review.id);
671 }
672
673 if dry_run {
674 if let Some(action) = would_register(review_provider, &reviews, existing.as_ref()) {
676 anstream::println!("{action}");
677 }
678 return Ok(());
679 }
680
681 match review_provider.register_stack(&reviews, existing.as_ref()) {
682 Ok(Some(line)) => anstream::println!("{line}"),
683 Ok(None) => {}
684 Err(error) => anstream::println!(
685 "{}",
686 style::warn(&format!("stack registration failed: {error}"))
687 ),
688 }
689 Ok(())
690}
691
692fn would_register(
696 review_provider: &dyn ReviewProvider,
697 reviews: &[String],
698 existing: Option<&NativeStack>,
699) -> Option<String> {
700 if !review_provider.registers_stacks() {
701 return None;
702 }
703 match crate::providers::plan_stack_registration(reviews, existing)? {
704 crate::providers::StackPlan::Register(reviews) => {
705 Some(format!("would register {} as a stack", reviews.join(" ")))
706 }
707 crate::providers::StackPlan::Extend { number, fresh } => Some(format!(
708 "would extend stack {number} with {}",
709 fresh.join(" ")
710 )),
711 crate::providers::StackPlan::Mismatch { number } => Some(format!(
712 "would leave stack {number} as recorded: it no longer matches this stack"
713 )),
714 }
715}
716
717fn branch_parents(branches: &[String]) -> Result<Vec<(String, String)>> {
718 let mut branch_parents = Vec::new();
719 for branch in branches {
720 if Some(branch) == stack::trunk_branch(&git::local_branches()?).as_ref() {
726 if !git::current_branch().is_ok_and(|current| current == *branch) {
731 bail!(
732 "{branch} is the trunk, so it is never part of a stack - \
733 name a stacked branch instead"
734 );
735 }
736 if !stack::has_stacked_branches()? {
737 bail!("no stacked branches to submit");
738 }
739 bail!("you are on the trunk ({branch}); check out a stacked branch first");
740 }
741
742 let is_base = stack::is_floor(branch)?
749 || (stack::parent_of(branch)?.is_none() && !stack::children_of(branch)?.is_empty());
750 if is_base {
751 return Err(base_has_nothing_to_submit(branch)?);
752 }
753
754 let Some(parent) = stack::parent_of(branch)? else {
755 bail!(
759 "{branch} has no stack parent; attach it with \
760 `git stk adopt {branch} --parent <parent>`, \
761 or rebuild its metadata with `git stk repair`"
762 );
763 };
764 branch_parents.push((branch.to_owned(), parent));
765 }
766 Ok(branch_parents)
767}
768
769fn submit_branch(
770 review_provider: &dyn ReviewProvider,
771 branch: &str,
772 parent: &str,
773 dry_run: bool,
774 draft: bool,
775 title: Option<&str>,
776) -> Result<SubmitAction> {
777 if let Some(review) = review_provider.review_for_branch(branch)? {
778 if review.base == parent {
779 if dry_run {
780 anstream::println!(
781 "would skip {} -> {} ({})",
782 review.branch,
783 review.base,
784 review.id
785 );
786 } else {
787 anstream::println!(
788 "{}",
789 style::dim(&format!(
790 "{} already targets {} ({})",
791 review.branch, review.base, review.id
792 ))
793 );
794 }
795 return Ok(SubmitAction::Skipped);
796 }
797
798 match review_provider.base_gap(&review, parent)? {
801 Some(BaseGap::Platform) => {
802 anstream::println!(
803 "{}",
804 style::dim(&format!(
805 "{} targets {} and is in a stack; the platform moves it as the stack lands",
806 review.id, review.base
807 ))
808 );
809 return Ok(SubmitAction::Skipped);
810 }
811 Some(BaseGap::Sync) => {
812 anstream::println!(
813 "{}",
814 style::warn(&format!(
815 "{} already targets {} - the platform moved it when {parent} landed; \
816 run `git stk sync` to catch the local stack up",
817 review.id, review.base
818 ))
819 );
820 return Ok(SubmitAction::Skipped);
821 }
822 Some(BaseGap::Neither) => {
823 anstream::println!(
824 "{}",
825 style::warn(&format!(
826 "{} targets {} but should target {parent}, and its stack will not \
827 move it there - the platform refuses a change by hand too; \
828 run `git stk unstack` and submit again",
829 review.id, review.base
830 ))
831 );
832 return Ok(SubmitAction::Skipped);
833 }
834 None => {}
835 }
836
837 let output = if dry_run {
838 String::new()
839 } else {
840 review_provider.update_review_base(&review, parent)?
841 };
842 anstream::println!(
843 "{} {} -> {} {}",
844 if dry_run { "would update" } else { "updated" },
845 style::branch(&review.branch),
846 style::branch(parent),
847 style::dim(&format!("({})", review.id))
848 );
849 if !output.is_empty() {
850 println!("{output}");
851 }
852 } else {
853 let output = if dry_run {
854 String::new()
855 } else {
856 review_provider.create_review(branch, parent, draft, title)?
857 };
858 anstream::println!(
859 "{} {} -> {}{}",
860 if dry_run { "would create" } else { "created" },
861 style::branch(branch),
862 style::branch(parent),
863 title.map_or_else(String::new, |title| format!(" titled \"{title}\""))
864 );
865 if !output.is_empty() {
866 println!("{output}");
867 }
868 return Ok(SubmitAction::Created);
869 }
870
871 Ok(SubmitAction::Updated)
872}
873
874#[derive(Debug, Default)]
875struct SubmitSummary {
876 created: usize,
877 updated: usize,
878 skipped: usize,
879}
880
881impl SubmitSummary {
882 fn record(&mut self, action: SubmitAction) {
883 match action {
884 SubmitAction::Created => self.created += 1,
885 SubmitAction::Updated => self.updated += 1,
886 SubmitAction::Skipped => self.skipped += 1,
887 }
888 }
889}
890
891#[derive(Debug, Clone, Copy, Eq, PartialEq)]
892enum SubmitAction {
893 Created,
894 Updated,
895 Skipped,
896}
897
898#[cfg(test)]
899mod tests {
900 use super::*;
901
902 fn home() -> Option<PathBuf> {
903 Some(PathBuf::from("/home/dev"))
904 }
905
906 #[test]
907 fn expand_tilde_resolves_a_bare_tilde_and_subpaths() {
908 assert_eq!(
909 expand_tilde_with(PathBuf::from("~"), home()),
910 PathBuf::from("/home/dev")
911 );
912 assert_eq!(
913 expand_tilde_with(PathBuf::from("~/notes/pr.md"), home()),
914 PathBuf::from("/home/dev/notes/pr.md")
915 );
916 }
917
918 #[test]
919 fn expand_tilde_leaves_other_paths_untouched() {
920 for raw in ["/etc/pr.md", "notes/pr.md", "~alice/pr.md", "docs/~x.md"] {
923 assert_eq!(
924 expand_tilde_with(PathBuf::from(raw), home()),
925 PathBuf::from(raw)
926 );
927 }
928 }
929
930 #[test]
931 fn expand_tilde_passes_through_when_home_is_unset() {
932 assert_eq!(
933 expand_tilde_with(PathBuf::from("~/pr.md"), None),
934 PathBuf::from("~/pr.md")
935 );
936 }
937
938 fn reviewers(raw: &[&str]) -> Vec<String> {
939 normalize_reviewers(
940 &raw.iter()
941 .map(|entry| (*entry).to_owned())
942 .collect::<Vec<_>>(),
943 )
944 }
945
946 #[test]
947 fn normalize_reviewers_strips_at_and_trims() {
948 assert_eq!(reviewers(&["@foo", "@bar"]), vec!["foo", "bar"]);
950 assert_eq!(reviewers(&["foo", "bar"]), vec!["foo", "bar"]);
951 assert_eq!(reviewers(&[" @foo ", " bar"]), vec!["foo", "bar"]);
952 }
953
954 #[test]
955 fn normalize_reviewers_keeps_team_paths_but_drops_the_at() {
956 assert_eq!(
958 reviewers(&["@my-org/backend", "acme/team"]),
959 vec!["my-org/backend", "acme/team"]
960 );
961 }
962
963 #[test]
964 fn normalize_reviewers_drops_blanks_and_dedupes_in_order() {
965 assert_eq!(
966 reviewers(&["foo", "", " ", "@foo", "bar", "@bar"]),
967 vec!["foo", "bar"]
968 );
969 }
970
971 #[test]
972 fn normalize_reviewers_preserves_the_copilot_at_prefix() {
973 assert_eq!(reviewers(&["@copilot"]), vec!["@copilot"]);
976 assert_eq!(reviewers(&["copilot"]), vec!["@copilot"]);
977 assert_eq!(reviewers(&["@Copilot", "copilot"]), vec!["@copilot"]);
978 }
979
980 #[cfg(windows)]
981 #[test]
982 fn expand_tilde_accepts_a_backslash_on_windows() {
983 assert_eq!(
984 expand_tilde_with(PathBuf::from(r"~\notes\pr.md"), home()),
985 PathBuf::from("/home/dev").join(r"notes\pr.md")
986 );
987 }
988}