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