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, label, 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                    labeled_with_size(review, branch),
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!(
104                    "{number}. {} - {}",
105                    labeled_with_size(review, branch),
106                    review.state
107                );
108                println!("   {}", review.url);
109            }
110            (Format::Plain, None) => println!("{number}. {branch} (no review)"),
111        }
112    }
113
114    Ok(())
115}
116
117/// The review label with the branch's diff size folded into the id's
118/// parentheses after a comma - e.g. `Title (#12, +9/-0)` - mirroring the tree.
119/// The size is dropped when zero or unavailable, leaving the plain label.
120fn labeled_with_size(review: &ReviewRequest, branch: &str) -> String {
121    let id = match branch_diff_size(branch) {
122        Some((added, deleted)) => format!("{}, +{added}/-{deleted}", review.id),
123        None => review.id.clone(),
124    };
125    label(&review.title, &id)
126}
127
128/// A branch's diff size against its stack parent, dropped when zero or
129/// unavailable - the same count the `git stk list` tree shows.
130fn branch_diff_size(branch: &str) -> Option<(usize, usize)> {
131    let parent = stack::parent_of(branch).ok().flatten()?;
132    let (added, deleted) = git::diff_numstat(&parent, branch).ok()?;
133    (added > 0 || deleted > 0).then_some((added, deleted))
134}
135
136/// One-line stack summary, e.g. "3 PRs, base `main`, 2 open / 1 merged"
137/// (the base is unquoted in plain format).
138fn summary(entries: &[(String, Option<ReviewRequest>)], base: &str, format: Format) -> String {
139    let total = entries.len();
140    let reviews: Vec<&ReviewRequest> = entries.iter().filter_map(|(_, r)| r.as_ref()).collect();
141    let base = match format {
142        Format::Markdown => format!("`{base}`"),
143        Format::Plain => base.to_owned(),
144    };
145
146    let mut summary = if reviews.is_empty() {
147        format!(
148            "{total} branch{}, base {base}",
149            if total == 1 { "" } else { "es" }
150        )
151    } else if reviews.len() == total {
152        format!(
153            "{total} PR{}, base {base}",
154            if total == 1 { "" } else { "s" }
155        )
156    } else {
157        format!(
158            "{total} branches ({} with reviews), base {base}",
159            reviews.len()
160        )
161    };
162
163    if !reviews.is_empty() {
164        let mut counts = Vec::new();
165        for (state, label) in [
166            (ReviewState::Open, "open"),
167            (ReviewState::Merged, "merged"),
168            (ReviewState::Closed, "closed"),
169        ] {
170            let count = reviews
171                .iter()
172                .filter(|review| review.state == state)
173                .count();
174            if count > 0 {
175                counts.push(format!("{count} {label}"));
176            }
177        }
178        if !counts.is_empty() {
179            summary.push_str(&format!(", {}", counts.join(" / ")));
180        }
181    }
182
183    summary
184}