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    /// List each branch's own commits (short SHA + subject) beneath it.
48    #[arg(long, conflicts_with = "format")]
49    commits: bool,
50}
51
52impl Run for List {
53    fn run(self) -> Result<()> {
54        match (self.format, self.all) {
55            (Some(format), _) => list_formatted(format),
56            (None, true) => crate::stack::print_all_stacks(&review_numbers(), self.commits),
57            (None, false) => crate::stack::print_stack(&review_numbers(), self.commits),
58        }
59    }
60}
61
62/// Print the stack as a copy-paste summary for sharing: a summary line, then
63/// the PRs bottom-to-top (merge order) with title, link/url, and state.
64/// Degrades to plain branch names when reviews or the provider CLI are
65/// unavailable.
66pub fn list_formatted(format: Format) -> Result<()> {
67    let current = git::current_branch()?;
68    let root = stack::stack_root(&current)?;
69    let branches: Vec<String> = stack::branch_and_descendants(&root)?
70        .into_iter()
71        .skip(1) // the root is the base, not part of the stack
72        .collect();
73
74    if branches.is_empty() {
75        println!("no stacked branches");
76        return Ok(());
77    }
78
79    let review_provider = detect_review_provider().ok().map(|(_, client)| client);
80    let entries: Vec<(String, Option<ReviewRequest>)> = branches
81        .iter()
82        .map(|branch| {
83            let review = review_provider
84                .as_ref()
85                .and_then(|rp| owned_review_for_branch(&**rp, branch).ok().flatten());
86            (branch.clone(), review)
87        })
88        .collect();
89
90    println!("{}", summary(&entries, &root, format));
91    println!();
92    for (index, (branch, review)) in entries.iter().enumerate() {
93        let number = index + 1;
94        match (format, review) {
95            (Format::Markdown, Some(review)) => {
96                println!(
97                    "{number}. [{}]({}) - {}",
98                    labeled_with_size(review, branch),
99                    review.url,
100                    review.state
101                );
102            }
103            (Format::Markdown, None) => println!("{number}. `{branch}` (no review)"),
104            // The bare URL on its own line is what chat apps auto-link.
105            (Format::Plain, Some(review)) => {
106                println!(
107                    "{number}. {} - {}",
108                    labeled_with_size(review, branch),
109                    review.state
110                );
111                println!("   {}", review.url);
112            }
113            (Format::Plain, None) => println!("{number}. {branch} (no review)"),
114        }
115    }
116
117    Ok(())
118}
119
120/// The review label with the branch's diff size folded into the id's
121/// parentheses after a comma - e.g. `Title (#12, +9/-0)` - mirroring the tree.
122/// The size is dropped when zero or unavailable, leaving the plain label.
123fn labeled_with_size(review: &ReviewRequest, branch: &str) -> String {
124    let id = match branch_diff_size(branch) {
125        Some((added, deleted)) => format!("{}, +{added}/-{deleted}", review.id),
126        None => review.id.clone(),
127    };
128    label(&review.title, &id)
129}
130
131/// A branch's diff size against its stack parent, dropped when zero or
132/// unavailable - the same count the `git stk list` tree shows.
133fn branch_diff_size(branch: &str) -> Option<(usize, usize)> {
134    let parent = stack::parent_of(branch).ok().flatten()?;
135    let (added, deleted) = git::diff_numstat(&parent, branch).ok()?;
136    (added > 0 || deleted > 0).then_some((added, deleted))
137}
138
139/// One-line stack summary, e.g. "3 PRs, base `main`, 2 open / 1 merged"
140/// (the base is unquoted in plain format).
141fn summary(entries: &[(String, Option<ReviewRequest>)], base: &str, format: Format) -> String {
142    let total = entries.len();
143    let reviews: Vec<&ReviewRequest> = entries.iter().filter_map(|(_, r)| r.as_ref()).collect();
144    let base = match format {
145        Format::Markdown => format!("`{base}`"),
146        Format::Plain => base.to_owned(),
147    };
148
149    let mut summary = if reviews.is_empty() {
150        format!(
151            "{total} branch{}, base {base}",
152            if total == 1 { "" } else { "es" }
153        )
154    } else if reviews.len() == total {
155        format!(
156            "{total} PR{}, base {base}",
157            if total == 1 { "" } else { "s" }
158        )
159    } else {
160        format!(
161            "{total} branches ({} with reviews), base {base}",
162            reviews.len()
163        )
164    };
165
166    if !reviews.is_empty() {
167        let mut counts = Vec::new();
168        for (state, label) in [
169            (ReviewState::Open, "open"),
170            (ReviewState::Merged, "merged"),
171            (ReviewState::Closed, "closed"),
172        ] {
173            let count = reviews
174                .iter()
175                .filter(|review| review.state == state)
176                .count();
177            if count > 0 {
178                counts.push(format!("{count} {label}"));
179            }
180        }
181        if !counts.is_empty() {
182            summary.push_str(&format!(", {}", counts.join(" / ")));
183        }
184    }
185
186    summary
187}