Skip to main content

git_stk/
providers.rs

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        // gh pr list only returns open pull requests by default; check merged
111        // ones too so cleanup can see landed reviews.
112        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        // glab mr list only returns open merge requests by default; check
160        // merged ones too so cleanup can see landed reviews.
161        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    // Push after stack validation but before any provider calls: creating a
364    // review requires the branch to exist remotely, and -u --force-with-lease
365    // covers both first pushes and safely updating rebased branches.
366    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    // After every review exists, write the stack overview into each body.
395    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
410/// Maintain a stack overview in every review body: the full PR list
411/// leaf-first, the trunk at the bottom, and a pointing emoji marking the
412/// review being viewed. Lives between marker comments so resubmits replace
413/// it in place, and self-repairs if the markers were hand-edited away.
414fn update_stack_notes(
415    review_provider: &dyn ReviewProvider,
416    branch_parents: &[(String, String)],
417    dry_run: bool,
418) -> Result<()> {
419    // The bottom branch's parent is the base the whole stack sits on.
420    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                // Without every review the overview would be wrong for all of
430                // them (dry runs never created the missing ones).
431                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, &note);
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
461/// Render the overview for one review: every PR in the stack leaf-first as a
462/// linked bullet, a pointer on the review being viewed, the trunk in
463/// backticks at the bottom, and a footer crediting the tool.
464fn 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
486/// Replace the marker-delimited stack note in a review body, appending it at
487/// the end. Damaged markup (orphaned or reordered markers, duplicates) is
488/// stripped first, so the section self-repairs on the next submit.
489fn 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
500/// Remove every well-formed marker section and any orphaned markers.
501fn 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    // Collapse the blank-line craters left behind by removed sections.
518    while result.contains("\n\n\n") {
519        result = result.replace("\n\n\n", "\n\n");
520    }
521    result
522}
523
524/// Print the stack in a copy-paste markdown format for sharing with
525/// reviewers: a summary line, then the PRs as an ordered bottom-to-top list
526/// (merge order) with title, link, and state. Degrades to plain branch names
527/// when reviews or the provider CLI are unavailable.
528pub fn list_markdown() -> Result<()> {
529    let current = git::current_branch()?;
530    let root = stack::stack_root(&current)?;
531    let branches: Vec<String> = stack::branch_and_descendants(&root)?
532        .into_iter()
533        .skip(1) // the root is the base, not part of the stack
534        .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
573/// One-line stack summary, e.g. "3 PRs, base `main`, 2 open / 1 merged".
574fn 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
618/// Rebuild or verify local stack metadata. For branches missing a parent,
619/// try the provider's review base first, then nearest-ancestor inference.
620/// For branches with a parent, verify it exists and the recorded fork point
621/// is still valid, re-deriving it when stale.
622pub fn repair(dry_run: bool) -> Result<()> {
623    let branches = git::local_branches()?;
624    let trunk = stack::trunk_branch(&branches);
625
626    // Provider lookup is best effort: repair must work without a remote or
627    // an authenticated gh/glab.
628    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
738/// Find the nearest other local branch whose tip is a strict ancestor of
739/// `branch` - the best guess at its stack parent.
740fn 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        // Equal tips (e.g. a just-created branch) leave the direction
750        // ambiguous, so they are not usable candidates.
751        if other_tip != tip && git::is_ancestor(other, branch)? {
752            candidates.push((other.clone(), other_tip));
753        }
754    }
755
756    // Keep only the nearest candidates: drop any that are ancestors of
757    // another candidate (i.e. further from the branch).
758    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, &current_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                    // Record the fork point off the merged branch before
837                    // retargeting, so the next restack replays only the
838                    // child's own commits even after a squash merge.
839                    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}