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#[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 = 'd')]
48 desc: Option<String>,
49 #[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 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_draft")]
61 draft: bool,
62 #[arg(long, action = ArgAction::SetTrue)]
64 no_draft: bool,
65 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "draft")]
67 ready: bool,
68 #[arg(long, action = ArgAction::SetTrue)]
71 rebuild_overview: bool,
72}
73
74impl Run for Submit {
75 fn run(self) -> Result<()> {
76 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 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 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
123pub 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
137fn 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 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 let desc_branch = branch.clone();
186
187 let branches = if downstack {
188 stack::path_from_root(&branch)?
191 } else if submit_stack {
192 stack::stack_line(&branch)?
196 } else {
197 vec![branch.clone()]
198 };
199
200 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 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 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 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 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 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 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 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 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
351fn 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 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}