Skip to main content

git_stk/commands/
list.rs

1use std::collections::BTreeMap;
2
3use anyhow::Result;
4use clap::ValueEnum;
5
6use crate::commands::Run;
7use crate::providers::{
8    ReviewRequest, ReviewState, detect_review_provider, owned_review_for_branch,
9};
10use crate::{git, stack};
11
12/// Branch -> open-review id (e.g. `#12`), in one provider call. Best effort:
13/// an absent or failing provider (offline, no gh/glab) yields an empty map, so
14/// the tree still prints, just without numbers.
15fn review_numbers() -> BTreeMap<String, String> {
16    let Some((_, provider)) = detect_review_provider().ok() else {
17        return BTreeMap::new();
18    };
19    let Ok(reviews) = provider.open_reviews() else {
20        return BTreeMap::new();
21    };
22    reviews
23        .into_iter()
24        .map(|review| (review.branch, review.id))
25        .collect()
26}
27
28/// A shareable rendering of the stack.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
30pub enum Format {
31    /// Markdown links - perfect in tracking issues and PR comments.
32    Markdown,
33    /// Plain text with bare URLs, for anywhere that does not render markdown
34    /// links from a paste (e.g. Slack).
35    Plain,
36}
37
38/// Print the current stack.
39#[derive(Debug, clap::Args)]
40pub struct List {
41    /// Render a shareable summary instead of the tree.
42    #[arg(long, value_enum)]
43    format: Option<Format>,
44    /// Show every stack, not just the one you are on.
45    #[arg(long, conflicts_with = "format")]
46    all: bool,
47}
48
49impl Run for List {
50    fn run(self) -> Result<()> {
51        match (self.format, self.all) {
52            (Some(format), _) => list_formatted(format),
53            (None, true) => crate::stack::print_all_stacks(&review_numbers()),
54            (None, false) => crate::stack::print_stack(&review_numbers()),
55        }
56    }
57}
58
59/// Print the stack as a copy-paste summary for sharing: a summary line, then
60/// the PRs bottom-to-top (merge order) with title, link/url, and state.
61/// Degrades to plain branch names when reviews or the provider CLI are
62/// unavailable.
63pub fn list_formatted(format: Format) -> Result<()> {
64    let current = git::current_branch()?;
65    let root = stack::stack_root(&current)?;
66    let branches: Vec<String> = stack::branch_and_descendants(&root)?
67        .into_iter()
68        .skip(1) // the root is the base, not part of the stack
69        .collect();
70
71    if branches.is_empty() {
72        println!("no stacked branches");
73        return Ok(());
74    }
75
76    let review_provider = detect_review_provider().ok().map(|(_, client)| client);
77    let entries: Vec<(String, Option<ReviewRequest>)> = branches
78        .iter()
79        .map(|branch| {
80            let review = review_provider
81                .as_ref()
82                .and_then(|rp| owned_review_for_branch(&**rp, branch).ok().flatten());
83            (branch.clone(), review)
84        })
85        .collect();
86
87    println!("{}", summary(&entries, &root, format));
88    println!();
89    for (index, (branch, review)) in entries.iter().enumerate() {
90        let number = index + 1;
91        match (format, review) {
92            (Format::Markdown, Some(review)) => {
93                println!(
94                    "{number}. [{}]({}) - {}",
95                    review.label(),
96                    review.url,
97                    review.state
98                );
99            }
100            (Format::Markdown, None) => println!("{number}. `{branch}` (no review)"),
101            // The bare URL on its own line is what chat apps auto-link.
102            (Format::Plain, Some(review)) => {
103                println!("{number}. {} - {}", review.label(), review.state);
104                println!("   {}", review.url);
105            }
106            (Format::Plain, None) => println!("{number}. {branch} (no review)"),
107        }
108    }
109
110    Ok(())
111}
112
113/// One-line stack summary, e.g. "3 PRs, base `main`, 2 open / 1 merged"
114/// (the base is unquoted in plain format).
115fn summary(entries: &[(String, Option<ReviewRequest>)], base: &str, format: Format) -> String {
116    let total = entries.len();
117    let reviews: Vec<&ReviewRequest> = entries.iter().filter_map(|(_, r)| r.as_ref()).collect();
118    let base = match format {
119        Format::Markdown => format!("`{base}`"),
120        Format::Plain => base.to_owned(),
121    };
122
123    let mut summary = if reviews.is_empty() {
124        format!(
125            "{total} branch{}, base {base}",
126            if total == 1 { "" } else { "es" }
127        )
128    } else if reviews.len() == total {
129        format!(
130            "{total} PR{}, base {base}",
131            if total == 1 { "" } else { "s" }
132        )
133    } else {
134        format!(
135            "{total} branches ({} with reviews), base {base}",
136            reviews.len()
137        )
138    };
139
140    if !reviews.is_empty() {
141        let mut counts = Vec::new();
142        for (state, label) in [
143            (ReviewState::Open, "open"),
144            (ReviewState::Merged, "merged"),
145            (ReviewState::Closed, "closed"),
146        ] {
147            let count = reviews
148                .iter()
149                .filter(|review| review.state == state)
150                .count();
151            if count > 0 {
152                counts.push(format!("{count} {label}"));
153            }
154        }
155        if !counts.is_empty() {
156            summary.push_str(&format!(", {}", counts.join(" / ")));
157        }
158    }
159
160    summary
161}