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    // Scope to the current branch's own line, like the tree view: sibling
70    // stacks that only share the trunk are left out. The base (trunk, or an
71    // unanchored root branch) is the summary's base, not a row.
72    let branches: Vec<String> = stack::current_stack_branches(&current)?
73        .into_iter()
74        .filter(|branch| *branch != root)
75        .collect();
76
77    if branches.is_empty() {
78        println!("no stacked branches");
79        return Ok(());
80    }
81
82    let review_provider = detect_review_provider().ok().map(|(_, client)| client);
83    let entries: Vec<(String, Option<ReviewRequest>)> = branches
84        .iter()
85        .map(|branch| {
86            let review = review_provider
87                .as_ref()
88                .and_then(|rp| owned_review_for_branch(&**rp, branch).ok().flatten());
89            (branch.clone(), review)
90        })
91        .collect();
92
93    println!("{}", summary(&entries, &root, format));
94    println!();
95    for (index, (branch, review)) in entries.iter().enumerate() {
96        let number = index + 1;
97        match (format, review) {
98            (Format::Markdown, Some(review)) => {
99                println!(
100                    "{number}. [{}]({}) - {}",
101                    labeled_with_size(review, branch),
102                    review.url,
103                    review.state
104                );
105            }
106            (Format::Markdown, None) => println!("{number}. `{branch}` (no review)"),
107            // The bare URL on its own line is what chat apps auto-link.
108            (Format::Plain, Some(review)) => {
109                println!(
110                    "{number}. {} - {}",
111                    labeled_with_size(review, branch),
112                    review.state
113                );
114                println!("   {}", review.url);
115            }
116            (Format::Plain, None) => println!("{number}. {branch} (no review)"),
117        }
118    }
119
120    Ok(())
121}
122
123/// The review label with the branch's diff size folded into the id's
124/// parentheses after a comma - e.g. `Title (#12, +9/-0)` - mirroring the tree.
125/// The size is dropped when zero or unavailable, leaving the plain label.
126fn labeled_with_size(review: &ReviewRequest, branch: &str) -> String {
127    let id = match branch_diff_size(branch) {
128        Some((added, deleted)) => format!("{}, +{added}/-{deleted}", review.id),
129        None => review.id.clone(),
130    };
131    label(&review.title, &id)
132}
133
134/// A branch's diff size against its stack parent, dropped when zero or
135/// unavailable - the same count the `git stk list` tree shows.
136fn branch_diff_size(branch: &str) -> Option<(usize, usize)> {
137    let parent = stack::parent_of(branch).ok().flatten()?;
138    let (added, deleted) = git::diff_numstat(&parent, branch).ok()?;
139    (added > 0 || deleted > 0).then_some((added, deleted))
140}
141
142/// One-line stack summary, e.g. "3 PRs, base `main`, 2 open / 1 merged"
143/// (the base is unquoted in plain format).
144fn summary(entries: &[(String, Option<ReviewRequest>)], base: &str, format: Format) -> String {
145    let total = entries.len();
146    let reviews: Vec<&ReviewRequest> = entries.iter().filter_map(|(_, r)| r.as_ref()).collect();
147    let base = match format {
148        Format::Markdown => format!("`{base}`"),
149        Format::Plain => base.to_owned(),
150    };
151
152    let mut summary = if reviews.is_empty() {
153        format!(
154            "{total} branch{}, base {base}",
155            if total == 1 { "" } else { "es" }
156        )
157    } else if reviews.len() == total {
158        format!(
159            "{total} PR{}, base {base}",
160            if total == 1 { "" } else { "s" }
161        )
162    } else {
163        format!(
164            "{total} branches ({} with reviews), base {base}",
165            reviews.len()
166        )
167    };
168
169    if !reviews.is_empty() {
170        let mut counts = Vec::new();
171        for (state, label) in [
172            (ReviewState::Open, "open"),
173            (ReviewState::Merged, "merged"),
174            (ReviewState::Closed, "closed"),
175        ] {
176            let count = reviews
177                .iter()
178                .filter(|review| review.state == state)
179                .count();
180            if count > 0 {
181                counts.push(format!("{count} {label}"));
182            }
183        }
184        if !counts.is_empty() {
185            summary.push_str(&format!(", {}", counts.join(" / ")));
186        }
187    }
188
189    summary
190}