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 repair(dry_run: bool) -> Result<()> {
529 let branches = git::local_branches()?;
530 let trunk = stack::trunk_branch(&branches);
531
532 let provider = detect_provider()
535 .ok()
536 .map(|provider| (provider.kind, review_provider(provider.kind)));
537
538 let mut repaired = 0;
539 let mut verified = 0;
540 let mut unresolved = 0;
541
542 for branch in &branches {
543 if Some(branch.as_str()) == trunk.as_deref() {
544 continue;
545 }
546
547 if let Some(parent) = stack::parent_for_branch(branch)? {
548 if !branches.contains(&parent) {
549 println!(
550 "{branch}: parent {parent} does not exist locally; \
551 fix with `git stk adopt` or `git stk detach {branch}`"
552 );
553 unresolved += 1;
554 continue;
555 }
556
557 let base_valid = matches!(
558 stack::base_for_branch(branch)?,
559 Some(base) if git::is_ancestor(&base, branch).unwrap_or(false)
560 );
561 if base_valid {
562 verified += 1;
563 } else {
564 println!(
565 "{branch}: {} fork point from {parent}",
566 if dry_run {
567 "would re-record"
568 } else {
569 "re-recorded"
570 }
571 );
572 if !dry_run {
573 stack::record_base(branch, &parent);
574 }
575 repaired += 1;
576 }
577 continue;
578 }
579
580 let mut found: Option<(String, String)> = None;
581 if let Some((kind, review_provider)) = &provider
582 && let Ok(Some(review)) = review_provider.review_for_branch(branch)
583 && review.branch == *branch
584 && review.base != *branch
585 {
586 if branches.contains(&review.base) {
587 found = Some((review.base.clone(), format!("{kind} review {}", review.id)));
588 } else {
589 println!(
590 "{branch}: review {} targets {}, which is not a local branch",
591 review.id, review.base
592 );
593 }
594 }
595
596 if found.is_none() {
597 match nearest_ancestor_branch(branch, &branches)? {
598 Ancestry::One(parent) => found = Some((parent, "ancestry".to_owned())),
599 Ancestry::None => {
600 println!(
601 "{branch}: no parent found; attach manually with \
602 `git stk adopt {branch} --parent <parent>`"
603 );
604 }
605 Ancestry::Ambiguous(candidates) => {
606 println!(
607 "{branch}: ambiguous parent candidates ({}); attach manually with \
608 `git stk adopt`",
609 candidates.join(", ")
610 );
611 }
612 }
613 }
614
615 match found {
616 Some((parent, source)) => {
617 println!(
618 "{branch}: {} parent {parent} (from {source})",
619 if dry_run { "would set" } else { "set" }
620 );
621 if !dry_run {
622 stack::set_parent_for_branch(branch, &parent)?;
623 stack::record_base(branch, &parent);
624 }
625 repaired += 1;
626 }
627 None => unresolved += 1,
628 }
629 }
630
631 println!(
632 "repair complete: {repaired} {}repaired, {verified} verified, {unresolved} unresolved",
633 if dry_run { "would be " } else { "" }
634 );
635 Ok(())
636}
637
638enum Ancestry {
639 One(String),
640 None,
641 Ambiguous(Vec<String>),
642}
643
644fn nearest_ancestor_branch(branch: &str, branches: &[String]) -> Result<Ancestry> {
647 let tip = git::rev_parse(branch)?;
648
649 let mut candidates: Vec<(String, String)> = Vec::new();
650 for other in branches {
651 if other == branch {
652 continue;
653 }
654 let other_tip = git::rev_parse(other)?;
655 if other_tip != tip && git::is_ancestor(other, branch)? {
658 candidates.push((other.clone(), other_tip));
659 }
660 }
661
662 let nearest: Vec<String> = candidates
665 .iter()
666 .filter(|(candidate, candidate_tip)| {
667 !candidates.iter().any(|(other, other_tip)| {
668 other != candidate
669 && other_tip != candidate_tip
670 && git::is_ancestor(candidate, other).unwrap_or(false)
671 })
672 })
673 .map(|(candidate, _)| candidate.clone())
674 .collect();
675
676 Ok(match nearest.len() {
677 0 => Ancestry::None,
678 1 => Ancestry::One(nearest.into_iter().next().expect("one candidate")),
679 _ => Ancestry::Ambiguous(nearest),
680 })
681}
682
683pub fn cleanup(branch: Option<&str>, dry_run: bool, delete_branch: bool) -> Result<()> {
684 let branch = branch
685 .map(str::to_owned)
686 .map_or_else(git::current_branch, Ok)?;
687 let branches = stack::branch_and_descendants(&branch)?;
688 let current_branch = git::current_branch()?;
689 let provider = detect_provider()?;
690 let review_provider = review_provider(provider.kind);
691 let mut cleaned = 0;
692 let mut skipped = 0;
693
694 for branch in branches {
695 let Some(review) = review_provider.review_for_branch(&branch)? else {
696 println!("skipped {branch}: no {} review found", provider.kind);
697 skipped += 1;
698 continue;
699 };
700
701 if review.state != ReviewState::Merged {
702 println!("skipped {branch}: review {} is {}", review.id, review.state);
703 skipped += 1;
704 continue;
705 }
706
707 cleanup_merged_branch(review_provider.as_ref(), &branch, dry_run)?;
708 cleanup_branch_deletion(&branch, ¤t_branch, dry_run, delete_branch)?;
709 cleaned += 1;
710 }
711
712 println!("cleanup complete: {cleaned} cleaned, {skipped} skipped");
713 Ok(())
714}
715
716fn cleanup_merged_branch(
717 review_provider: &dyn ReviewProvider,
718 branch: &str,
719 dry_run: bool,
720) -> Result<()> {
721 let parent = stack::parent_for_branch(branch)?;
722 let descendants = stack::branch_and_descendants(branch)?;
723 let direct_children: Vec<_> = descendants
724 .into_iter()
725 .skip(1)
726 .filter_map(|child| match stack::parent_for_branch(&child) {
727 Ok(Some(child_parent)) if child_parent == branch => Some(Ok(child)),
728 Ok(_) => None,
729 Err(error) => Some(Err(error)),
730 })
731 .collect::<Result<_>>()?;
732
733 for child in direct_children {
734 match parent.as_deref() {
735 Some(parent) => {
736 println!(
737 "{} retarget {child} -> {parent}",
738 if dry_run { "would" } else { "will" }
739 );
740 update_child_review_base(review_provider, &child, parent, dry_run)?;
741 if !dry_run {
742 if let Ok(base) = git::merge_base(branch, &child) {
746 stack::set_base_for_branch(&child, &base)?;
747 }
748 stack::set_parent_for_branch(&child, parent)?;
749 }
750 }
751 None => {
752 println!("{} detach {child}", if dry_run { "would" } else { "will" });
753 if !dry_run {
754 stack::unset_parent_for_branch(&child)?;
755 stack::unset_base_for_branch(&child)?;
756 }
757 }
758 }
759 }
760
761 println!("{} detach {branch}", if dry_run { "would" } else { "will" });
762 if !dry_run {
763 stack::unset_parent_for_branch(branch)?;
764 stack::unset_base_for_branch(branch)?;
765 }
766
767 Ok(())
768}
769
770fn cleanup_branch_deletion(
771 branch: &str,
772 current_branch: &str,
773 dry_run: bool,
774 delete_branch: bool,
775) -> Result<()> {
776 if !delete_branch {
777 return Ok(());
778 }
779
780 if branch == current_branch {
781 bail!("refusing to delete currently checked out branch {branch}");
782 }
783
784 println!(
785 "{} delete branch {branch}",
786 if dry_run { "would" } else { "will" }
787 );
788 if !dry_run {
789 git::delete_branch(branch)?;
790 }
791
792 Ok(())
793}
794
795fn update_child_review_base(
796 review_provider: &dyn ReviewProvider,
797 child: &str,
798 parent: &str,
799 dry_run: bool,
800) -> Result<()> {
801 let Some(review) = review_provider.review_for_branch(child)? else {
802 return Ok(());
803 };
804
805 if review.state == ReviewState::Merged || review.base == parent {
806 return Ok(());
807 }
808
809 println!(
810 "{} update review {} -> {} ({})",
811 if dry_run { "would" } else { "will" },
812 review.branch,
813 parent,
814 review.id
815 );
816 if !dry_run {
817 let output = review_provider.update_review_base(&review, parent)?;
818 if !output.is_empty() {
819 println!("{output}");
820 }
821 }
822
823 Ok(())
824}
825
826fn branch_parents(branches: &[String]) -> Result<Vec<(String, String)>> {
827 let mut branch_parents = Vec::new();
828 for branch in branches {
829 let Some(parent) = stack::parent_for_branch(branch)? else {
830 bail!("{branch} has no stack parent; run `git stk adopt` or `git stk sync` first");
831 };
832 branch_parents.push((branch.to_owned(), parent));
833 }
834 Ok(branch_parents)
835}
836
837fn submit_branch(
838 review_provider: &dyn ReviewProvider,
839 branch: &str,
840 parent: &str,
841 dry_run: bool,
842) -> Result<SubmitAction> {
843 if let Some(review) = review_provider.review_for_branch(branch)? {
844 if review.base == parent {
845 if dry_run {
846 println!(
847 "would skip {} -> {} ({})",
848 review.branch, review.base, review.id
849 );
850 } else {
851 println!(
852 "{} already targets {} ({})",
853 review.branch, review.base, review.id
854 );
855 }
856 return Ok(SubmitAction::Skipped);
857 }
858
859 let output = if dry_run {
860 String::new()
861 } else {
862 review_provider.update_review_base(&review, parent)?
863 };
864 println!(
865 "{} {} -> {} ({})",
866 if dry_run { "would update" } else { "updated" },
867 review.branch,
868 parent,
869 review.id
870 );
871 if !output.is_empty() {
872 println!("{output}");
873 }
874 } else {
875 let output = if dry_run {
876 String::new()
877 } else {
878 review_provider.create_review(branch, parent)?
879 };
880 println!(
881 "{} {branch} -> {parent}",
882 if dry_run { "would create" } else { "created" }
883 );
884 if !output.is_empty() {
885 println!("{output}");
886 }
887 return Ok(SubmitAction::Created);
888 }
889
890 Ok(SubmitAction::Updated)
891}
892
893#[derive(Debug, Default)]
894struct SubmitSummary {
895 created: usize,
896 updated: usize,
897 skipped: usize,
898}
899
900impl SubmitSummary {
901 fn record(&mut self, action: SubmitAction) {
902 match action {
903 SubmitAction::Created => self.created += 1,
904 SubmitAction::Updated => self.updated += 1,
905 SubmitAction::Skipped => self.skipped += 1,
906 }
907 }
908}
909
910#[derive(Debug, Clone, Copy, Eq, PartialEq)]
911enum SubmitAction {
912 Created,
913 Updated,
914 Skipped,
915}
916
917pub fn detect_provider() -> Result<DetectedProvider> {
918 if let Some(value) = git::config_get(PROVIDER_KEY)? {
919 let Some(kind) = ProviderKind::parse(&value) else {
920 bail!("unsupported stk.provider value {value:?}; expected github or gitlab");
921 };
922
923 return Ok(DetectedProvider {
924 kind,
925 source: ProviderSource::Config,
926 });
927 }
928
929 let remote = git::config_get(REMOTE_KEY)?.unwrap_or_else(|| DEFAULT_REMOTE.to_owned());
930 let Some(url) = git::remote_url(&remote)? else {
931 bail!("could not detect provider: remote {remote:?} does not exist");
932 };
933
934 let Some(kind) = detect_provider_from_url(&url) else {
935 bail!("could not detect provider from remote {remote} ({url})");
936 };
937
938 Ok(DetectedProvider {
939 kind,
940 source: ProviderSource::Remote { remote, url },
941 })
942}
943
944fn detect_provider_from_url(url: &str) -> Option<ProviderKind> {
945 let normalized = url.to_ascii_lowercase();
946
947 if normalized.contains("github.com:") || normalized.contains("github.com/") {
948 Some(ProviderKind::GitHub)
949 } else if normalized.contains("gitlab.com:") || normalized.contains("gitlab.com/") {
950 Some(ProviderKind::GitLab)
951 } else {
952 None
953 }
954}
955
956fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
957 match kind {
958 ProviderKind::GitHub => Box::new(GitHubProvider),
959 ProviderKind::GitLab => Box::new(GitLabProvider),
960 }
961}
962
963fn command_output(program: &str, args: &[&str]) -> Result<String> {
964 let output = Command::new(program)
965 .args(args)
966 .output()
967 .with_context(|| format!("failed to run {program}"))?;
968
969 if output.status.success() {
970 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
971 } else {
972 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
973 if stderr.is_empty() {
974 Err(anyhow!("{program} exited with status {}", output.status))
975 } else {
976 Err(anyhow!("{program} failed: {stderr}"))
977 }
978 }
979}
980
981fn parse_github_review(output: &str) -> Result<Option<ReviewRequest>> {
982 let Some(review) = first_json_item(output)? else {
983 return Ok(None);
984 };
985
986 Ok(Some(ReviewRequest {
987 id: format!("#{}", required_string(&review, &["number"])?),
988 branch: required_string(&review, &["headRefName"])?,
989 base: required_string(&review, &["baseRefName"])?,
990 state: parse_state(&required_string(&review, &["state"])?),
991 url: required_string(&review, &["url"])?,
992 title: optional_string(&review, "title"),
993 }))
994}
995
996fn parse_gitlab_review(output: &str) -> Result<Option<ReviewRequest>> {
997 let Some(review) = first_json_item(output)? else {
998 return Ok(None);
999 };
1000
1001 Ok(Some(ReviewRequest {
1002 id: format!("!{}", required_string(&review, &["iid", "id"])?),
1003 branch: required_string(&review, &["source_branch", "sourceBranch"])?,
1004 base: required_string(&review, &["target_branch", "targetBranch"])?,
1005 state: parse_state(&required_string(&review, &["state"])?),
1006 url: required_string(&review, &["web_url", "webUrl", "url"])?,
1007 title: optional_string(&review, "title"),
1008 }))
1009}
1010
1011fn optional_string(value: &Value, key: &str) -> String {
1012 value
1013 .get(key)
1014 .and_then(Value::as_str)
1015 .unwrap_or_default()
1016 .to_owned()
1017}
1018
1019fn first_json_item(output: &str) -> Result<Option<Value>> {
1020 let value: Value = serde_json::from_str(output).context("failed to parse provider JSON")?;
1021 match value {
1022 Value::Array(items) => Ok(items.into_iter().next()),
1023 Value::Object(_) => Ok(Some(value)),
1024 _ => bail!("provider JSON must be an object or array"),
1025 }
1026}
1027
1028fn required_string(value: &Value, keys: &[&str]) -> Result<String> {
1029 for key in keys {
1030 if let Some(field) = value.get(*key) {
1031 if let Some(value) = field.as_str() {
1032 return Ok(value.to_owned());
1033 }
1034 if let Some(value) = field.as_i64() {
1035 return Ok(value.to_string());
1036 }
1037 if let Some(value) = field.as_u64() {
1038 return Ok(value.to_string());
1039 }
1040 }
1041 }
1042
1043 bail!(
1044 "provider JSON missing required field: {}",
1045 keys.join(" or ")
1046 )
1047}
1048
1049fn parse_state(state: &str) -> ReviewState {
1050 match state.to_ascii_lowercase().as_str() {
1051 "open" | "opened" => ReviewState::Open,
1052 "merged" => ReviewState::Merged,
1053 "closed" => ReviewState::Closed,
1054 _ => ReviewState::Unknown(state.to_owned()),
1055 }
1056}
1057
1058fn parent_key(branch: &str) -> String {
1059 format!("branch.{branch}.stkParent")
1060}
1061
1062impl fmt::Display for ReviewState {
1063 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1064 match self {
1065 Self::Open => write!(formatter, "open"),
1066 Self::Merged => write!(formatter, "merged"),
1067 Self::Closed => write!(formatter, "closed"),
1068 Self::Unknown(state) => write!(formatter, "{state}"),
1069 }
1070 }
1071}
1072
1073impl ReviewRequest {
1074 fn id_value(&self) -> &str {
1075 self.id
1076 .strip_prefix('#')
1077 .or_else(|| self.id.strip_prefix('!'))
1078 .unwrap_or(&self.id)
1079 }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084 use super::*;
1085
1086 #[test]
1087 fn parse_github_review_reads_first_array_item() {
1088 let review = parse_github_review(
1089 r#"[{"number":12,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12"}]"#,
1090 )
1091 .expect("parse review")
1092 .expect("review exists");
1093
1094 assert_eq!(
1095 review,
1096 ReviewRequest {
1097 id: "#12".to_owned(),
1098 branch: "feature/a".to_owned(),
1099 base: "main".to_owned(),
1100 state: ReviewState::Open,
1101 url: "https://github.com/owner/repo/pull/12".to_owned(),
1102 title: String::new(),
1103 }
1104 );
1105 }
1106
1107 #[test]
1108 fn parse_gitlab_review_reads_snake_case_fields() {
1109 let review = parse_gitlab_review(
1110 r#"[{"iid":34,"state":"merged","target_branch":"feature/a","source_branch":"feature/b","web_url":"https://gitlab.com/owner/repo/-/merge_requests/34"}]"#,
1111 )
1112 .expect("parse review")
1113 .expect("review exists");
1114
1115 assert_eq!(
1116 review,
1117 ReviewRequest {
1118 id: "!34".to_owned(),
1119 branch: "feature/b".to_owned(),
1120 base: "feature/a".to_owned(),
1121 state: ReviewState::Merged,
1122 url: "https://gitlab.com/owner/repo/-/merge_requests/34".to_owned(),
1123 title: String::new(),
1124 }
1125 );
1126 }
1127
1128 #[test]
1129 fn parse_gitlab_review_reads_camel_case_fields() {
1130 let review = parse_gitlab_review(
1131 r#"[{"id":34,"state":"closed","targetBranch":"feature/a","sourceBranch":"feature/b","webUrl":"https://gitlab.com/owner/repo/-/merge_requests/34"}]"#,
1132 )
1133 .expect("parse review")
1134 .expect("review exists");
1135
1136 assert_eq!(review.id, "!34");
1137 assert_eq!(review.branch, "feature/b");
1138 assert_eq!(review.base, "feature/a");
1139 assert_eq!(review.state, ReviewState::Closed);
1140 assert_eq!(
1141 review.url,
1142 "https://gitlab.com/owner/repo/-/merge_requests/34"
1143 );
1144 }
1145
1146 #[test]
1147 fn parse_review_accepts_object_output() {
1148 let review = parse_github_review(
1149 r#"{"number":12,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12"}"#,
1150 )
1151 .expect("parse review")
1152 .expect("review exists");
1153
1154 assert_eq!(review.id, "#12");
1155 }
1156
1157 #[test]
1158 fn parse_review_empty_array_returns_none() {
1159 assert_eq!(parse_github_review("[]").expect("parse review"), None);
1160 assert_eq!(parse_gitlab_review("[]").expect("parse review"), None);
1161 }
1162
1163 #[test]
1164 fn parse_review_errors_on_missing_required_field() {
1165 let error = parse_github_review(
1166 r#"[{"number":12,"state":"OPEN","baseRefName":"main","url":"https://github.com/owner/repo/pull/12"}]"#,
1167 )
1168 .expect_err("missing head branch should fail");
1169
1170 assert!(
1171 error
1172 .to_string()
1173 .contains("provider JSON missing required field: headRefName"),
1174 "unexpected error: {error:#}"
1175 );
1176 }
1177
1178 #[test]
1179 fn parse_review_preserves_unknown_state() {
1180 let review = parse_github_review(
1181 r#"[{"number":12,"state":"READY_FOR_REVIEW","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12"}]"#,
1182 )
1183 .expect("parse review")
1184 .expect("review exists");
1185
1186 assert_eq!(
1187 review.state,
1188 ReviewState::Unknown("READY_FOR_REVIEW".to_owned())
1189 );
1190 }
1191
1192 fn review(id: &str, title: &str, url: &str) -> ReviewRequest {
1193 ReviewRequest {
1194 id: id.to_owned(),
1195 branch: String::new(),
1196 base: String::new(),
1197 state: ReviewState::Open,
1198 url: url.to_owned(),
1199 title: title.to_owned(),
1200 }
1201 }
1202
1203 #[test]
1204 fn build_stack_note_lists_stack_leaf_first_with_pointer_and_trunk() {
1205 let entries = vec![
1206 review("#12", "Bottom change", "https://example.com/12"),
1207 review("#13", "Top change", "https://example.com/13"),
1208 ];
1209
1210 let note = build_stack_note(&entries, 0, "main");
1211 assert_eq!(
1212 note,
1213 "- [Top change (#13)](https://example.com/13)\n\
1214 - [Bottom change (#12)](https://example.com/12) \u{1F448}\n\
1215 - `main`\n\n\
1216 ---\n\n\
1217 Stack managed by [git-stk](https://github.com/lararosekelley/git-stk)"
1218 );
1219 }
1220
1221 #[test]
1222 fn build_stack_note_falls_back_to_id_without_title() {
1223 let entries = vec![review("#12", "", "https://example.com/12")];
1224 let note = build_stack_note(&entries, 0, "main");
1225 assert!(note.contains("- [#12](https://example.com/12) \u{1F448}"));
1226 }
1227
1228 #[test]
1229 fn body_with_stack_note_appends_to_existing_body() {
1230 let updated = body_with_stack_note("Some PR description.\n", "stack list");
1231 assert_eq!(
1232 updated,
1233 "Some PR description.\n\n<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
1234 );
1235 }
1236
1237 #[test]
1238 fn body_with_stack_note_fills_empty_body() {
1239 let updated = body_with_stack_note("", "stack list");
1240 assert_eq!(
1241 updated,
1242 "<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
1243 );
1244 }
1245
1246 #[test]
1247 fn body_with_stack_note_replaces_existing_note() {
1248 let body = "Intro.\n\n<!-- git-stk:stack -->\nold list\n<!-- /git-stk:stack -->\n\nOutro.";
1249 let updated = body_with_stack_note(body, "new list");
1250 assert_eq!(
1251 updated,
1252 "Intro.\n\nOutro.\n\n<!-- git-stk:stack -->\nnew list\n<!-- /git-stk:stack -->"
1253 );
1254 }
1255
1256 #[test]
1257 fn body_with_stack_note_is_idempotent() {
1258 let body = body_with_stack_note("Description.", "stack list");
1259 assert_eq!(body_with_stack_note(&body, "stack list"), body);
1260 }
1261
1262 #[test]
1263 fn body_with_stack_note_repairs_orphaned_start_marker() {
1264 let body = "Intro.\n\n<!-- git-stk:stack -->\nleftover text";
1265 let updated = body_with_stack_note(body, "fresh list");
1266 assert_eq!(
1267 updated,
1268 "Intro.\n\nleftover text\n\n<!-- git-stk:stack -->\nfresh list\n<!-- /git-stk:stack -->"
1269 );
1270 }
1271
1272 #[test]
1273 fn body_with_stack_note_repairs_orphaned_end_marker() {
1274 let body = "Intro.\nstray\n<!-- /git-stk:stack -->\nOutro.";
1275 let updated = body_with_stack_note(body, "fresh list");
1276 assert!(updated.matches("<!-- git-stk:stack -->").count() == 1);
1277 assert!(updated.matches("<!-- /git-stk:stack -->").count() == 1);
1278 assert!(updated.contains("Intro.\nstray"));
1279 assert!(updated.ends_with("<!-- /git-stk:stack -->"));
1280 }
1281
1282 #[test]
1283 fn body_with_stack_note_repairs_reversed_and_duplicate_markers() {
1284 let body = "<!-- /git-stk:stack -->\nA\n<!-- git-stk:stack -->\nB\n\
1285 <!-- git-stk:stack -->\nC\n<!-- /git-stk:stack -->\nD";
1286 let updated = body_with_stack_note(body, "fresh list");
1287 assert_eq!(updated.matches("<!-- git-stk:stack -->").count(), 1);
1288 assert_eq!(updated.matches("<!-- /git-stk:stack -->").count(), 1);
1289 assert!(updated.contains("fresh list"));
1290 assert!(updated.ends_with("<!-- /git-stk:stack -->"));
1291 }
1292
1293 #[test]
1294 fn parse_body_field_reads_field_and_defaults_empty() {
1295 assert_eq!(
1296 parse_body_field(r#"{"body":"hello"}"#, "body").expect("parse body"),
1297 "hello"
1298 );
1299 assert_eq!(
1300 parse_body_field(r#"{"description":null}"#, "description").expect("parse body"),
1301 ""
1302 );
1303 assert_eq!(parse_body_field(r#"{}"#, "body").expect("parse body"), "");
1304 }
1305}