1use std::{fmt, process::Command};
2
3use anyhow::{Context, Result, anyhow, bail};
4use serde_json::Value;
5
6use crate::{git, stack};
7
8const PROVIDER_KEY: &str = "stk.provider";
9const REMOTE_KEY: &str = "stk.remote";
10const PUSH_ON_SUBMIT_KEY: &str = "stk.pushOnSubmit";
11const DEFAULT_REMOTE: &str = "origin";
12
13#[derive(Debug, Clone, Copy, Eq, PartialEq)]
14pub enum ProviderKind {
15 GitHub,
16 GitLab,
17}
18
19impl ProviderKind {
20 fn parse(value: &str) -> Option<Self> {
21 match value.to_ascii_lowercase().as_str() {
22 "github" | "gh" => Some(Self::GitHub),
23 "gitlab" | "glab" => Some(Self::GitLab),
24 _ => None,
25 }
26 }
27}
28
29impl fmt::Display for ProviderKind {
30 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31 match self {
32 Self::GitHub => write!(formatter, "github"),
33 Self::GitLab => write!(formatter, "gitlab"),
34 }
35 }
36}
37
38#[derive(Debug, Eq, PartialEq)]
39pub struct DetectedProvider {
40 pub kind: ProviderKind,
41 pub source: ProviderSource,
42}
43
44#[derive(Debug, Eq, PartialEq)]
45pub enum ProviderSource {
46 Config,
47 Remote { remote: String, url: String },
48}
49
50impl fmt::Display for ProviderSource {
51 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
52 match self {
53 Self::Config => write!(formatter, "config"),
54 Self::Remote { remote, url } => write!(formatter, "remote {remote} ({url})"),
55 }
56 }
57}
58
59#[derive(Debug, Eq, PartialEq)]
60pub enum ReviewState {
61 Open,
62 Merged,
63 Closed,
64 Unknown(String),
65}
66
67#[derive(Debug, Eq, PartialEq)]
68pub struct ReviewRequest {
69 pub id: String,
70 pub branch: String,
71 pub base: String,
72 pub state: ReviewState,
73 pub url: String,
74 pub title: String,
75}
76
77pub trait ReviewProvider {
78 fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>>;
79
80 fn create_review(&self, branch: &str, base: &str) -> Result<String>;
81
82 fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String>;
83
84 fn review_body(&self, review: &ReviewRequest) -> Result<String>;
85
86 fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String>;
87}
88
89struct GitHubProvider;
90
91struct GitLabProvider;
92
93impl ReviewProvider for GitHubProvider {
94 fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>> {
95 let output = command_output(
96 "gh",
97 &[
98 "pr",
99 "list",
100 "--head",
101 branch,
102 "--json",
103 "number,state,baseRefName,headRefName,url,title",
104 ],
105 )?;
106 if let Some(review) = parse_github_review(&output)? {
107 return Ok(Some(review));
108 }
109
110 let output = command_output(
113 "gh",
114 &[
115 "pr",
116 "list",
117 "--head",
118 branch,
119 "--state",
120 "merged",
121 "--json",
122 "number,state,baseRefName,headRefName,url,title",
123 ],
124 )?;
125 parse_github_review(&output)
126 }
127
128 fn create_review(&self, branch: &str, base: &str) -> Result<String> {
129 command_output(
130 "gh",
131 &["pr", "create", "--head", branch, "--base", base, "--fill"],
132 )
133 }
134
135 fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String> {
136 command_output("gh", &["pr", "edit", review.id_value(), "--base", base])
137 }
138
139 fn review_body(&self, review: &ReviewRequest) -> Result<String> {
140 let output = command_output("gh", &["pr", "view", review.id_value(), "--json", "body"])?;
141 parse_body_field(&output, "body")
142 }
143
144 fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String> {
145 command_output("gh", &["pr", "edit", review.id_value(), "--body", body])
146 }
147}
148
149impl ReviewProvider for GitLabProvider {
150 fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>> {
151 let output = command_output(
152 "glab",
153 &["mr", "list", "--source-branch", branch, "--output", "json"],
154 )?;
155 if let Some(review) = parse_gitlab_review(&output)? {
156 return Ok(Some(review));
157 }
158
159 let output = command_output(
162 "glab",
163 &[
164 "mr",
165 "list",
166 "--source-branch",
167 branch,
168 "--merged",
169 "--output",
170 "json",
171 ],
172 )?;
173 parse_gitlab_review(&output)
174 }
175
176 fn create_review(&self, branch: &str, base: &str) -> Result<String> {
177 command_output(
178 "glab",
179 &[
180 "mr",
181 "create",
182 "--source-branch",
183 branch,
184 "--target-branch",
185 base,
186 "--fill",
187 ],
188 )
189 }
190
191 fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String> {
192 command_output(
193 "glab",
194 &["mr", "update", review.id_value(), "--target-branch", base],
195 )
196 }
197
198 fn review_body(&self, review: &ReviewRequest) -> Result<String> {
199 let output = command_output(
200 "glab",
201 &["mr", "view", review.id_value(), "--output", "json"],
202 )?;
203 parse_body_field(&output, "description")
204 }
205
206 fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String> {
207 command_output(
208 "glab",
209 &["mr", "update", review.id_value(), "--description", body],
210 )
211 }
212}
213
214fn parse_body_field(output: &str, field: &str) -> Result<String> {
215 let value: serde_json::Value =
216 serde_json::from_str(output).context("failed to parse provider JSON")?;
217 Ok(value
218 .get(field)
219 .and_then(serde_json::Value::as_str)
220 .unwrap_or_default()
221 .to_owned())
222}
223
224pub fn print_provider() -> Result<()> {
225 let provider = detect_provider()?;
226 println!("{} ({})", provider.kind, provider.source);
227 Ok(())
228}
229
230pub fn print_review(branch: Option<&str>) -> Result<()> {
231 let branch = branch
232 .map(str::to_owned)
233 .map_or_else(git::current_branch, Ok)?;
234 let provider = detect_provider()?;
235 let review_provider = review_provider(provider.kind);
236
237 let Some(review) = review_provider.review_for_branch(&branch)? else {
238 bail!("no {} review found for {branch}", provider.kind);
239 };
240
241 println!(
242 "{} {} -> {} {} {}",
243 review.id, review.branch, review.base, review.state, review.url
244 );
245 Ok(())
246}
247
248pub fn print_status(branch: Option<&str>) -> Result<()> {
249 let branch = branch
250 .map(str::to_owned)
251 .map_or_else(git::current_branch, Ok)?;
252 let parent = stack::parent_for_branch(&branch)?;
253 let children = stack::children_for_branch(&branch)?;
254
255 println!("branch: {branch}");
256 match parent.as_deref() {
257 Some(parent) => println!("parent: {parent}"),
258 None => println!("parent: none"),
259 }
260 if children.is_empty() {
261 println!("children: none");
262 } else {
263 println!("children: {}", children.join(", "));
264 }
265
266 let provider = detect_provider()?;
267 println!("provider: {} ({})", provider.kind, provider.source);
268 let review_provider = review_provider(provider.kind);
269
270 let Some(review) = review_provider.review_for_branch(&branch)? else {
271 println!("review: none");
272 return Ok(());
273 };
274
275 println!(
276 "review: {} {} {} -> {}",
277 review.id, review.state, review.branch, review.base
278 );
279 println!("url: {}", review.url);
280
281 if let Some(parent) = parent
282 && parent != review.base
283 {
284 println!(
285 "warning: review base is {}, local parent is {}",
286 review.base, parent
287 );
288 }
289
290 Ok(())
291}
292
293pub fn sync_stack(branch: Option<&str>, dry_run: bool) -> Result<()> {
294 let branches = match branch {
295 Some(branch) => vec![branch.to_owned()],
296 None => git::local_branches()?,
297 };
298
299 let provider = detect_provider()?;
300 let review_provider = review_provider(provider.kind);
301 let mut synced = 0;
302 let mut skipped = 0;
303
304 for branch in branches {
305 let Some(review) = review_provider.review_for_branch(&branch)? else {
306 println!("skipped {branch}: no {} review found", provider.kind);
307 skipped += 1;
308 continue;
309 };
310
311 if review.branch != branch {
312 println!(
313 "skipped {branch}: {} review belongs to {}",
314 provider.kind, review.branch
315 );
316 skipped += 1;
317 continue;
318 }
319
320 if review.branch == review.base {
321 bail!("refusing to set {branch} as its own stack parent");
322 }
323
324 if !dry_run {
325 git::config_set(&parent_key(&branch), &review.base)?;
326 stack::record_base(&branch, &review.base);
327 }
328 println!(
329 "{} {} -> {} ({})",
330 if dry_run { "would sync" } else { "synced" },
331 review.branch,
332 review.base,
333 review.id
334 );
335 synced += 1;
336 }
337
338 println!(
339 "sync complete: {synced} {}synced, {skipped} skipped",
340 if dry_run { "would be " } else { "" }
341 );
342 Ok(())
343}
344
345pub fn submit(
346 branch: Option<&str>,
347 submit_stack: bool,
348 dry_run: bool,
349 push_mode: crate::cli::PushMode,
350) -> Result<()> {
351 let branch = branch
352 .map(str::to_owned)
353 .map_or_else(git::current_branch, Ok)?;
354
355 let branches = if submit_stack {
356 stack::branch_and_descendants(&branch)?
357 } else {
358 vec![branch]
359 };
360
361 let branch_parents = branch_parents(&branches)?;
362
363 let push = match push_mode {
367 crate::cli::PushMode::Config => git::config_get_bool(PUSH_ON_SUBMIT_KEY)?.unwrap_or(false),
368 crate::cli::PushMode::Enabled => true,
369 crate::cli::PushMode::Disabled => false,
370 };
371 if push {
372 let remote = git::config_get(REMOTE_KEY)?.unwrap_or_else(|| DEFAULT_REMOTE.to_owned());
373 if dry_run {
374 println!("would push {} to {remote}", branches.join(" "));
375 } else {
376 git::push_set_upstream_force_with_lease(&remote, &branches)?;
377 println!("pushed {} to {remote}", branches.join(" "));
378 }
379 }
380
381 let provider = detect_provider()?;
382 let review_provider = review_provider(provider.kind);
383 let mut summary = SubmitSummary::default();
384
385 for (branch, parent) in &branch_parents {
386 summary.record(submit_branch(
387 review_provider.as_ref(),
388 branch,
389 parent,
390 dry_run,
391 )?);
392 }
393
394 if submit_stack {
396 update_stack_notes(review_provider.as_ref(), &branch_parents, dry_run)?;
397 }
398
399 println!(
400 "submit complete: {} created, {} updated, {} skipped",
401 summary.created, summary.updated, summary.skipped
402 );
403 Ok(())
404}
405
406const STACK_NOTE_START: &str = "<!-- git-stk:stack -->";
407const STACK_NOTE_END: &str = "<!-- /git-stk:stack -->";
408const TOOL_URL: &str = "https://github.com/lararosekelley/git-stk";
409
410fn update_stack_notes(
415 review_provider: &dyn ReviewProvider,
416 branch_parents: &[(String, String)],
417 dry_run: bool,
418) -> Result<()> {
419 let Some(trunk) = branch_parents.first().map(|(_, parent)| parent.clone()) else {
421 return Ok(());
422 };
423
424 let mut entries = Vec::new();
425 for (branch, _) in branch_parents {
426 match review_provider.review_for_branch(branch)? {
427 Some(review) if review.branch == *branch => entries.push(review),
428 _ => {
429 if !dry_run {
432 println!("skipped stack notes: no review found for {branch}");
433 }
434 return Ok(());
435 }
436 }
437 }
438
439 for index in 0..entries.len() {
440 let note = build_stack_note(&entries, index, &trunk);
441 let review = &entries[index];
442
443 if dry_run {
444 println!("would update stack note in {}", review.id);
445 continue;
446 }
447
448 let body = review_provider.review_body(review)?;
449 let updated = body_with_stack_note(&body, ¬e);
450 if updated == body {
451 continue;
452 }
453
454 review_provider.update_review_body(review, &updated)?;
455 println!("updated stack note in {}", review.id);
456 }
457
458 Ok(())
459}
460
461fn build_stack_note(entries: &[ReviewRequest], current: usize, trunk: &str) -> String {
465 let mut lines = Vec::new();
466 for (index, entry) in entries.iter().enumerate().rev() {
467 let label = if entry.title.is_empty() {
468 entry.id.clone()
469 } else {
470 format!("{} ({})", entry.title, entry.id)
471 };
472 let mut line = format!("- [{label}]({})", entry.url);
473 if index == current {
474 line.push_str(" \u{1F448}");
475 }
476 lines.push(line);
477 }
478 lines.push(format!("- `{trunk}`"));
479
480 format!(
481 "{}\n\n---\n\nStack managed by [git-stk]({TOOL_URL})",
482 lines.join("\n")
483 )
484}
485
486fn body_with_stack_note(body: &str, note: &str) -> String {
490 let section = format!("{STACK_NOTE_START}\n{note}\n{STACK_NOTE_END}");
491 let cleaned = strip_stack_notes(body);
492
493 if cleaned.trim().is_empty() {
494 section
495 } else {
496 format!("{}\n\n{section}", cleaned.trim_end())
497 }
498}
499
500fn strip_stack_notes(body: &str) -> String {
502 let mut result = body.to_owned();
503
504 while let Some(start) = result.find(STACK_NOTE_START) {
505 match result[start..].find(STACK_NOTE_END) {
506 Some(end_offset) => {
507 let end = start + end_offset + STACK_NOTE_END.len();
508 result.replace_range(start..end, "");
509 }
510 None => result.replace_range(start..start + STACK_NOTE_START.len(), ""),
511 }
512 }
513 while let Some(start) = result.find(STACK_NOTE_END) {
514 result.replace_range(start..start + STACK_NOTE_END.len(), "");
515 }
516
517 while result.contains("\n\n\n") {
519 result = result.replace("\n\n\n", "\n\n");
520 }
521 result
522}
523
524pub fn list_markdown() -> Result<()> {
529 let current = git::current_branch()?;
530 let root = stack::stack_root(¤t)?;
531 let branches: Vec<String> = stack::branch_and_descendants(&root)?
532 .into_iter()
533 .skip(1) .collect();
535
536 if branches.is_empty() {
537 println!("no stacked branches");
538 return Ok(());
539 }
540
541 let review_provider = detect_provider().ok().map(|p| review_provider(p.kind));
542 let entries: Vec<(String, Option<ReviewRequest>)> = branches
543 .iter()
544 .map(|branch| {
545 let review = review_provider
546 .as_ref()
547 .and_then(|rp| rp.review_for_branch(branch).ok().flatten())
548 .filter(|review| review.branch == *branch);
549 (branch.clone(), review)
550 })
551 .collect();
552
553 println!("{}", markdown_summary(&entries, &root));
554 println!();
555 for (index, (branch, review)) in entries.iter().enumerate() {
556 let item = match review {
557 Some(review) => {
558 let label = if review.title.is_empty() {
559 review.id.clone()
560 } else {
561 format!("{} ({})", review.title, review.id)
562 };
563 format!("[{label}]({}) - {}", review.url, review.state)
564 }
565 None => format!("`{branch}` (no review)"),
566 };
567 println!("{}. {item}", index + 1);
568 }
569
570 Ok(())
571}
572
573fn markdown_summary(entries: &[(String, Option<ReviewRequest>)], base: &str) -> String {
575 let total = entries.len();
576 let reviews: Vec<&ReviewRequest> = entries.iter().filter_map(|(_, r)| r.as_ref()).collect();
577
578 let mut summary = if reviews.is_empty() {
579 format!(
580 "{total} branch{}, base `{base}`",
581 if total == 1 { "" } else { "es" }
582 )
583 } else if reviews.len() == total {
584 format!(
585 "{total} PR{}, base `{base}`",
586 if total == 1 { "" } else { "s" }
587 )
588 } else {
589 format!(
590 "{total} branches ({} with reviews), base `{base}`",
591 reviews.len()
592 )
593 };
594
595 if !reviews.is_empty() {
596 let mut counts = Vec::new();
597 for (state, label) in [
598 (ReviewState::Open, "open"),
599 (ReviewState::Merged, "merged"),
600 (ReviewState::Closed, "closed"),
601 ] {
602 let count = reviews
603 .iter()
604 .filter(|review| review.state == state)
605 .count();
606 if count > 0 {
607 counts.push(format!("{count} {label}"));
608 }
609 }
610 if !counts.is_empty() {
611 summary.push_str(&format!(", {}", counts.join(" / ")));
612 }
613 }
614
615 summary
616}
617
618pub fn repair(dry_run: bool) -> Result<()> {
623 let branches = git::local_branches()?;
624 let trunk = stack::trunk_branch(&branches);
625
626 let provider = detect_provider()
629 .ok()
630 .map(|provider| (provider.kind, review_provider(provider.kind)));
631
632 let mut repaired = 0;
633 let mut verified = 0;
634 let mut unresolved = 0;
635
636 for branch in &branches {
637 if Some(branch.as_str()) == trunk.as_deref() {
638 continue;
639 }
640
641 if let Some(parent) = stack::parent_for_branch(branch)? {
642 if !branches.contains(&parent) {
643 println!(
644 "{branch}: parent {parent} does not exist locally; \
645 fix with `git stk adopt` or `git stk detach {branch}`"
646 );
647 unresolved += 1;
648 continue;
649 }
650
651 let base_valid = matches!(
652 stack::base_for_branch(branch)?,
653 Some(base) if git::is_ancestor(&base, branch).unwrap_or(false)
654 );
655 if base_valid {
656 verified += 1;
657 } else {
658 println!(
659 "{branch}: {} fork point from {parent}",
660 if dry_run {
661 "would re-record"
662 } else {
663 "re-recorded"
664 }
665 );
666 if !dry_run {
667 stack::record_base(branch, &parent);
668 }
669 repaired += 1;
670 }
671 continue;
672 }
673
674 let mut found: Option<(String, String)> = None;
675 if let Some((kind, review_provider)) = &provider
676 && let Ok(Some(review)) = review_provider.review_for_branch(branch)
677 && review.branch == *branch
678 && review.base != *branch
679 {
680 if branches.contains(&review.base) {
681 found = Some((review.base.clone(), format!("{kind} review {}", review.id)));
682 } else {
683 println!(
684 "{branch}: review {} targets {}, which is not a local branch",
685 review.id, review.base
686 );
687 }
688 }
689
690 if found.is_none() {
691 match nearest_ancestor_branch(branch, &branches)? {
692 Ancestry::One(parent) => found = Some((parent, "ancestry".to_owned())),
693 Ancestry::None => {
694 println!(
695 "{branch}: no parent found; attach manually with \
696 `git stk adopt {branch} --parent <parent>`"
697 );
698 }
699 Ancestry::Ambiguous(candidates) => {
700 println!(
701 "{branch}: ambiguous parent candidates ({}); attach manually with \
702 `git stk adopt`",
703 candidates.join(", ")
704 );
705 }
706 }
707 }
708
709 match found {
710 Some((parent, source)) => {
711 println!(
712 "{branch}: {} parent {parent} (from {source})",
713 if dry_run { "would set" } else { "set" }
714 );
715 if !dry_run {
716 stack::set_parent_for_branch(branch, &parent)?;
717 stack::record_base(branch, &parent);
718 }
719 repaired += 1;
720 }
721 None => unresolved += 1,
722 }
723 }
724
725 println!(
726 "repair complete: {repaired} {}repaired, {verified} verified, {unresolved} unresolved",
727 if dry_run { "would be " } else { "" }
728 );
729 Ok(())
730}
731
732enum Ancestry {
733 One(String),
734 None,
735 Ambiguous(Vec<String>),
736}
737
738fn nearest_ancestor_branch(branch: &str, branches: &[String]) -> Result<Ancestry> {
741 let tip = git::rev_parse(branch)?;
742
743 let mut candidates: Vec<(String, String)> = Vec::new();
744 for other in branches {
745 if other == branch {
746 continue;
747 }
748 let other_tip = git::rev_parse(other)?;
749 if other_tip != tip && git::is_ancestor(other, branch)? {
752 candidates.push((other.clone(), other_tip));
753 }
754 }
755
756 let nearest: Vec<String> = candidates
759 .iter()
760 .filter(|(candidate, candidate_tip)| {
761 !candidates.iter().any(|(other, other_tip)| {
762 other != candidate
763 && other_tip != candidate_tip
764 && git::is_ancestor(candidate, other).unwrap_or(false)
765 })
766 })
767 .map(|(candidate, _)| candidate.clone())
768 .collect();
769
770 Ok(match nearest.len() {
771 0 => Ancestry::None,
772 1 => Ancestry::One(nearest.into_iter().next().expect("one candidate")),
773 _ => Ancestry::Ambiguous(nearest),
774 })
775}
776
777pub fn cleanup(branch: Option<&str>, dry_run: bool, delete_branch: bool) -> Result<()> {
778 let branch = branch
779 .map(str::to_owned)
780 .map_or_else(git::current_branch, Ok)?;
781 let branches = stack::branch_and_descendants(&branch)?;
782 let current_branch = git::current_branch()?;
783 let provider = detect_provider()?;
784 let review_provider = review_provider(provider.kind);
785 let mut cleaned = 0;
786 let mut skipped = 0;
787
788 for branch in branches {
789 let Some(review) = review_provider.review_for_branch(&branch)? else {
790 println!("skipped {branch}: no {} review found", provider.kind);
791 skipped += 1;
792 continue;
793 };
794
795 if review.state != ReviewState::Merged {
796 println!("skipped {branch}: review {} is {}", review.id, review.state);
797 skipped += 1;
798 continue;
799 }
800
801 cleanup_merged_branch(review_provider.as_ref(), &branch, dry_run)?;
802 cleanup_branch_deletion(&branch, ¤t_branch, dry_run, delete_branch)?;
803 cleaned += 1;
804 }
805
806 println!("cleanup complete: {cleaned} cleaned, {skipped} skipped");
807 Ok(())
808}
809
810fn cleanup_merged_branch(
811 review_provider: &dyn ReviewProvider,
812 branch: &str,
813 dry_run: bool,
814) -> Result<()> {
815 let parent = stack::parent_for_branch(branch)?;
816 let descendants = stack::branch_and_descendants(branch)?;
817 let direct_children: Vec<_> = descendants
818 .into_iter()
819 .skip(1)
820 .filter_map(|child| match stack::parent_for_branch(&child) {
821 Ok(Some(child_parent)) if child_parent == branch => Some(Ok(child)),
822 Ok(_) => None,
823 Err(error) => Some(Err(error)),
824 })
825 .collect::<Result<_>>()?;
826
827 for child in direct_children {
828 match parent.as_deref() {
829 Some(parent) => {
830 println!(
831 "{} retarget {child} -> {parent}",
832 if dry_run { "would" } else { "will" }
833 );
834 update_child_review_base(review_provider, &child, parent, dry_run)?;
835 if !dry_run {
836 if let Ok(base) = git::merge_base(branch, &child) {
840 stack::set_base_for_branch(&child, &base)?;
841 }
842 stack::set_parent_for_branch(&child, parent)?;
843 }
844 }
845 None => {
846 println!("{} detach {child}", if dry_run { "would" } else { "will" });
847 if !dry_run {
848 stack::unset_parent_for_branch(&child)?;
849 stack::unset_base_for_branch(&child)?;
850 }
851 }
852 }
853 }
854
855 println!("{} detach {branch}", if dry_run { "would" } else { "will" });
856 if !dry_run {
857 stack::unset_parent_for_branch(branch)?;
858 stack::unset_base_for_branch(branch)?;
859 }
860
861 Ok(())
862}
863
864fn cleanup_branch_deletion(
865 branch: &str,
866 current_branch: &str,
867 dry_run: bool,
868 delete_branch: bool,
869) -> Result<()> {
870 if !delete_branch {
871 return Ok(());
872 }
873
874 if branch == current_branch {
875 bail!("refusing to delete currently checked out branch {branch}");
876 }
877
878 println!(
879 "{} delete branch {branch}",
880 if dry_run { "would" } else { "will" }
881 );
882 if !dry_run {
883 git::delete_branch(branch)?;
884 }
885
886 Ok(())
887}
888
889fn update_child_review_base(
890 review_provider: &dyn ReviewProvider,
891 child: &str,
892 parent: &str,
893 dry_run: bool,
894) -> Result<()> {
895 let Some(review) = review_provider.review_for_branch(child)? else {
896 return Ok(());
897 };
898
899 if review.state == ReviewState::Merged || review.base == parent {
900 return Ok(());
901 }
902
903 println!(
904 "{} update review {} -> {} ({})",
905 if dry_run { "would" } else { "will" },
906 review.branch,
907 parent,
908 review.id
909 );
910 if !dry_run {
911 let output = review_provider.update_review_base(&review, parent)?;
912 if !output.is_empty() {
913 println!("{output}");
914 }
915 }
916
917 Ok(())
918}
919
920fn branch_parents(branches: &[String]) -> Result<Vec<(String, String)>> {
921 let mut branch_parents = Vec::new();
922 for branch in branches {
923 let Some(parent) = stack::parent_for_branch(branch)? else {
924 bail!("{branch} has no stack parent; run `git stk adopt` or `git stk sync` first");
925 };
926 branch_parents.push((branch.to_owned(), parent));
927 }
928 Ok(branch_parents)
929}
930
931fn submit_branch(
932 review_provider: &dyn ReviewProvider,
933 branch: &str,
934 parent: &str,
935 dry_run: bool,
936) -> Result<SubmitAction> {
937 if let Some(review) = review_provider.review_for_branch(branch)? {
938 if review.base == parent {
939 if dry_run {
940 println!(
941 "would skip {} -> {} ({})",
942 review.branch, review.base, review.id
943 );
944 } else {
945 println!(
946 "{} already targets {} ({})",
947 review.branch, review.base, review.id
948 );
949 }
950 return Ok(SubmitAction::Skipped);
951 }
952
953 let output = if dry_run {
954 String::new()
955 } else {
956 review_provider.update_review_base(&review, parent)?
957 };
958 println!(
959 "{} {} -> {} ({})",
960 if dry_run { "would update" } else { "updated" },
961 review.branch,
962 parent,
963 review.id
964 );
965 if !output.is_empty() {
966 println!("{output}");
967 }
968 } else {
969 let output = if dry_run {
970 String::new()
971 } else {
972 review_provider.create_review(branch, parent)?
973 };
974 println!(
975 "{} {branch} -> {parent}",
976 if dry_run { "would create" } else { "created" }
977 );
978 if !output.is_empty() {
979 println!("{output}");
980 }
981 return Ok(SubmitAction::Created);
982 }
983
984 Ok(SubmitAction::Updated)
985}
986
987#[derive(Debug, Default)]
988struct SubmitSummary {
989 created: usize,
990 updated: usize,
991 skipped: usize,
992}
993
994impl SubmitSummary {
995 fn record(&mut self, action: SubmitAction) {
996 match action {
997 SubmitAction::Created => self.created += 1,
998 SubmitAction::Updated => self.updated += 1,
999 SubmitAction::Skipped => self.skipped += 1,
1000 }
1001 }
1002}
1003
1004#[derive(Debug, Clone, Copy, Eq, PartialEq)]
1005enum SubmitAction {
1006 Created,
1007 Updated,
1008 Skipped,
1009}
1010
1011pub fn detect_provider() -> Result<DetectedProvider> {
1012 if let Some(value) = git::config_get(PROVIDER_KEY)? {
1013 let Some(kind) = ProviderKind::parse(&value) else {
1014 bail!("unsupported stk.provider value {value:?}; expected github or gitlab");
1015 };
1016
1017 return Ok(DetectedProvider {
1018 kind,
1019 source: ProviderSource::Config,
1020 });
1021 }
1022
1023 let remote = git::config_get(REMOTE_KEY)?.unwrap_or_else(|| DEFAULT_REMOTE.to_owned());
1024 let Some(url) = git::remote_url(&remote)? else {
1025 bail!("could not detect provider: remote {remote:?} does not exist");
1026 };
1027
1028 let Some(kind) = detect_provider_from_url(&url) else {
1029 bail!("could not detect provider from remote {remote} ({url})");
1030 };
1031
1032 Ok(DetectedProvider {
1033 kind,
1034 source: ProviderSource::Remote { remote, url },
1035 })
1036}
1037
1038fn detect_provider_from_url(url: &str) -> Option<ProviderKind> {
1039 let normalized = url.to_ascii_lowercase();
1040
1041 if normalized.contains("github.com:") || normalized.contains("github.com/") {
1042 Some(ProviderKind::GitHub)
1043 } else if normalized.contains("gitlab.com:") || normalized.contains("gitlab.com/") {
1044 Some(ProviderKind::GitLab)
1045 } else {
1046 None
1047 }
1048}
1049
1050fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
1051 match kind {
1052 ProviderKind::GitHub => Box::new(GitHubProvider),
1053 ProviderKind::GitLab => Box::new(GitLabProvider),
1054 }
1055}
1056
1057fn command_output(program: &str, args: &[&str]) -> Result<String> {
1058 let output = Command::new(program)
1059 .args(args)
1060 .output()
1061 .with_context(|| format!("failed to run {program}"))?;
1062
1063 if output.status.success() {
1064 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
1065 } else {
1066 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1067 if stderr.is_empty() {
1068 Err(anyhow!("{program} exited with status {}", output.status))
1069 } else {
1070 Err(anyhow!("{program} failed: {stderr}"))
1071 }
1072 }
1073}
1074
1075fn parse_github_review(output: &str) -> Result<Option<ReviewRequest>> {
1076 let Some(review) = first_json_item(output)? else {
1077 return Ok(None);
1078 };
1079
1080 Ok(Some(ReviewRequest {
1081 id: format!("#{}", required_string(&review, &["number"])?),
1082 branch: required_string(&review, &["headRefName"])?,
1083 base: required_string(&review, &["baseRefName"])?,
1084 state: parse_state(&required_string(&review, &["state"])?),
1085 url: required_string(&review, &["url"])?,
1086 title: optional_string(&review, "title"),
1087 }))
1088}
1089
1090fn parse_gitlab_review(output: &str) -> Result<Option<ReviewRequest>> {
1091 let Some(review) = first_json_item(output)? else {
1092 return Ok(None);
1093 };
1094
1095 Ok(Some(ReviewRequest {
1096 id: format!("!{}", required_string(&review, &["iid", "id"])?),
1097 branch: required_string(&review, &["source_branch", "sourceBranch"])?,
1098 base: required_string(&review, &["target_branch", "targetBranch"])?,
1099 state: parse_state(&required_string(&review, &["state"])?),
1100 url: required_string(&review, &["web_url", "webUrl", "url"])?,
1101 title: optional_string(&review, "title"),
1102 }))
1103}
1104
1105fn optional_string(value: &Value, key: &str) -> String {
1106 value
1107 .get(key)
1108 .and_then(Value::as_str)
1109 .unwrap_or_default()
1110 .to_owned()
1111}
1112
1113fn first_json_item(output: &str) -> Result<Option<Value>> {
1114 let value: Value = serde_json::from_str(output).context("failed to parse provider JSON")?;
1115 match value {
1116 Value::Array(items) => Ok(items.into_iter().next()),
1117 Value::Object(_) => Ok(Some(value)),
1118 _ => bail!("provider JSON must be an object or array"),
1119 }
1120}
1121
1122fn required_string(value: &Value, keys: &[&str]) -> Result<String> {
1123 for key in keys {
1124 if let Some(field) = value.get(*key) {
1125 if let Some(value) = field.as_str() {
1126 return Ok(value.to_owned());
1127 }
1128 if let Some(value) = field.as_i64() {
1129 return Ok(value.to_string());
1130 }
1131 if let Some(value) = field.as_u64() {
1132 return Ok(value.to_string());
1133 }
1134 }
1135 }
1136
1137 bail!(
1138 "provider JSON missing required field: {}",
1139 keys.join(" or ")
1140 )
1141}
1142
1143fn parse_state(state: &str) -> ReviewState {
1144 match state.to_ascii_lowercase().as_str() {
1145 "open" | "opened" => ReviewState::Open,
1146 "merged" => ReviewState::Merged,
1147 "closed" => ReviewState::Closed,
1148 _ => ReviewState::Unknown(state.to_owned()),
1149 }
1150}
1151
1152fn parent_key(branch: &str) -> String {
1153 format!("branch.{branch}.stkParent")
1154}
1155
1156impl fmt::Display for ReviewState {
1157 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1158 match self {
1159 Self::Open => write!(formatter, "open"),
1160 Self::Merged => write!(formatter, "merged"),
1161 Self::Closed => write!(formatter, "closed"),
1162 Self::Unknown(state) => write!(formatter, "{state}"),
1163 }
1164 }
1165}
1166
1167impl ReviewRequest {
1168 fn id_value(&self) -> &str {
1169 self.id
1170 .strip_prefix('#')
1171 .or_else(|| self.id.strip_prefix('!'))
1172 .unwrap_or(&self.id)
1173 }
1174}
1175
1176#[cfg(test)]
1177mod tests {
1178 use super::*;
1179
1180 #[test]
1181 fn parse_github_review_reads_first_array_item() {
1182 let review = parse_github_review(
1183 r#"[{"number":12,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12"}]"#,
1184 )
1185 .expect("parse review")
1186 .expect("review exists");
1187
1188 assert_eq!(
1189 review,
1190 ReviewRequest {
1191 id: "#12".to_owned(),
1192 branch: "feature/a".to_owned(),
1193 base: "main".to_owned(),
1194 state: ReviewState::Open,
1195 url: "https://github.com/owner/repo/pull/12".to_owned(),
1196 title: String::new(),
1197 }
1198 );
1199 }
1200
1201 #[test]
1202 fn parse_gitlab_review_reads_snake_case_fields() {
1203 let review = parse_gitlab_review(
1204 r#"[{"iid":34,"state":"merged","target_branch":"feature/a","source_branch":"feature/b","web_url":"https://gitlab.com/owner/repo/-/merge_requests/34"}]"#,
1205 )
1206 .expect("parse review")
1207 .expect("review exists");
1208
1209 assert_eq!(
1210 review,
1211 ReviewRequest {
1212 id: "!34".to_owned(),
1213 branch: "feature/b".to_owned(),
1214 base: "feature/a".to_owned(),
1215 state: ReviewState::Merged,
1216 url: "https://gitlab.com/owner/repo/-/merge_requests/34".to_owned(),
1217 title: String::new(),
1218 }
1219 );
1220 }
1221
1222 #[test]
1223 fn parse_gitlab_review_reads_camel_case_fields() {
1224 let review = parse_gitlab_review(
1225 r#"[{"id":34,"state":"closed","targetBranch":"feature/a","sourceBranch":"feature/b","webUrl":"https://gitlab.com/owner/repo/-/merge_requests/34"}]"#,
1226 )
1227 .expect("parse review")
1228 .expect("review exists");
1229
1230 assert_eq!(review.id, "!34");
1231 assert_eq!(review.branch, "feature/b");
1232 assert_eq!(review.base, "feature/a");
1233 assert_eq!(review.state, ReviewState::Closed);
1234 assert_eq!(
1235 review.url,
1236 "https://gitlab.com/owner/repo/-/merge_requests/34"
1237 );
1238 }
1239
1240 #[test]
1241 fn parse_review_accepts_object_output() {
1242 let review = parse_github_review(
1243 r#"{"number":12,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12"}"#,
1244 )
1245 .expect("parse review")
1246 .expect("review exists");
1247
1248 assert_eq!(review.id, "#12");
1249 }
1250
1251 #[test]
1252 fn parse_review_empty_array_returns_none() {
1253 assert_eq!(parse_github_review("[]").expect("parse review"), None);
1254 assert_eq!(parse_gitlab_review("[]").expect("parse review"), None);
1255 }
1256
1257 #[test]
1258 fn parse_review_errors_on_missing_required_field() {
1259 let error = parse_github_review(
1260 r#"[{"number":12,"state":"OPEN","baseRefName":"main","url":"https://github.com/owner/repo/pull/12"}]"#,
1261 )
1262 .expect_err("missing head branch should fail");
1263
1264 assert!(
1265 error
1266 .to_string()
1267 .contains("provider JSON missing required field: headRefName"),
1268 "unexpected error: {error:#}"
1269 );
1270 }
1271
1272 #[test]
1273 fn parse_review_preserves_unknown_state() {
1274 let review = parse_github_review(
1275 r#"[{"number":12,"state":"READY_FOR_REVIEW","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12"}]"#,
1276 )
1277 .expect("parse review")
1278 .expect("review exists");
1279
1280 assert_eq!(
1281 review.state,
1282 ReviewState::Unknown("READY_FOR_REVIEW".to_owned())
1283 );
1284 }
1285
1286 fn review(id: &str, title: &str, url: &str) -> ReviewRequest {
1287 ReviewRequest {
1288 id: id.to_owned(),
1289 branch: String::new(),
1290 base: String::new(),
1291 state: ReviewState::Open,
1292 url: url.to_owned(),
1293 title: title.to_owned(),
1294 }
1295 }
1296
1297 #[test]
1298 fn build_stack_note_lists_stack_leaf_first_with_pointer_and_trunk() {
1299 let entries = vec![
1300 review("#12", "Bottom change", "https://example.com/12"),
1301 review("#13", "Top change", "https://example.com/13"),
1302 ];
1303
1304 let note = build_stack_note(&entries, 0, "main");
1305 assert_eq!(
1306 note,
1307 "- [Top change (#13)](https://example.com/13)\n\
1308 - [Bottom change (#12)](https://example.com/12) \u{1F448}\n\
1309 - `main`\n\n\
1310 ---\n\n\
1311 Stack managed by [git-stk](https://github.com/lararosekelley/git-stk)"
1312 );
1313 }
1314
1315 #[test]
1316 fn build_stack_note_falls_back_to_id_without_title() {
1317 let entries = vec![review("#12", "", "https://example.com/12")];
1318 let note = build_stack_note(&entries, 0, "main");
1319 assert!(note.contains("- [#12](https://example.com/12) \u{1F448}"));
1320 }
1321
1322 #[test]
1323 fn body_with_stack_note_appends_to_existing_body() {
1324 let updated = body_with_stack_note("Some PR description.\n", "stack list");
1325 assert_eq!(
1326 updated,
1327 "Some PR description.\n\n<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
1328 );
1329 }
1330
1331 #[test]
1332 fn body_with_stack_note_fills_empty_body() {
1333 let updated = body_with_stack_note("", "stack list");
1334 assert_eq!(
1335 updated,
1336 "<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
1337 );
1338 }
1339
1340 #[test]
1341 fn body_with_stack_note_replaces_existing_note() {
1342 let body = "Intro.\n\n<!-- git-stk:stack -->\nold list\n<!-- /git-stk:stack -->\n\nOutro.";
1343 let updated = body_with_stack_note(body, "new list");
1344 assert_eq!(
1345 updated,
1346 "Intro.\n\nOutro.\n\n<!-- git-stk:stack -->\nnew list\n<!-- /git-stk:stack -->"
1347 );
1348 }
1349
1350 #[test]
1351 fn body_with_stack_note_is_idempotent() {
1352 let body = body_with_stack_note("Description.", "stack list");
1353 assert_eq!(body_with_stack_note(&body, "stack list"), body);
1354 }
1355
1356 #[test]
1357 fn body_with_stack_note_repairs_orphaned_start_marker() {
1358 let body = "Intro.\n\n<!-- git-stk:stack -->\nleftover text";
1359 let updated = body_with_stack_note(body, "fresh list");
1360 assert_eq!(
1361 updated,
1362 "Intro.\n\nleftover text\n\n<!-- git-stk:stack -->\nfresh list\n<!-- /git-stk:stack -->"
1363 );
1364 }
1365
1366 #[test]
1367 fn body_with_stack_note_repairs_orphaned_end_marker() {
1368 let body = "Intro.\nstray\n<!-- /git-stk:stack -->\nOutro.";
1369 let updated = body_with_stack_note(body, "fresh list");
1370 assert!(updated.matches("<!-- git-stk:stack -->").count() == 1);
1371 assert!(updated.matches("<!-- /git-stk:stack -->").count() == 1);
1372 assert!(updated.contains("Intro.\nstray"));
1373 assert!(updated.ends_with("<!-- /git-stk:stack -->"));
1374 }
1375
1376 #[test]
1377 fn body_with_stack_note_repairs_reversed_and_duplicate_markers() {
1378 let body = "<!-- /git-stk:stack -->\nA\n<!-- git-stk:stack -->\nB\n\
1379 <!-- git-stk:stack -->\nC\n<!-- /git-stk:stack -->\nD";
1380 let updated = body_with_stack_note(body, "fresh list");
1381 assert_eq!(updated.matches("<!-- git-stk:stack -->").count(), 1);
1382 assert_eq!(updated.matches("<!-- /git-stk:stack -->").count(), 1);
1383 assert!(updated.contains("fresh list"));
1384 assert!(updated.ends_with("<!-- /git-stk:stack -->"));
1385 }
1386
1387 #[test]
1388 fn parse_body_field_reads_field_and_defaults_empty() {
1389 assert_eq!(
1390 parse_body_field(r#"{"body":"hello"}"#, "body").expect("parse body"),
1391 "hello"
1392 );
1393 assert_eq!(
1394 parse_body_field(r#"{"description":null}"#, "description").expect("parse body"),
1395 ""
1396 );
1397 assert_eq!(parse_body_field(r#"{}"#, "body").expect("parse body"), "");
1398 }
1399}