Skip to main content

git_stk/commands/
list.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use anyhow::Result;
4use clap::ValueEnum;
5
6use crate::commands::Run;
7use crate::providers::{
8    ReviewAnnotation, ReviewRequest, ReviewState, detect_review_provider, label,
9    owned_review_for_branch,
10};
11use crate::{git, stack};
12
13/// Branch -> its open-review annotation (id, CI status, queue state, and -
14/// with `detail` - review tallies), scoped to the `listed` branches the tree
15/// draws so the provider never queries every open PR in the repo. The provider
16/// batches this as tightly as it can (GitHub: one GraphQL call). Best effort:
17/// an absent or failing provider (offline, no gh/glab) yields an empty map, so
18/// the tree still prints, just without annotations.
19fn review_annotations(
20    listed: &BTreeSet<String>,
21    detail: bool,
22) -> BTreeMap<String, ReviewAnnotation> {
23    let Some((_, provider)) = detect_review_provider().ok() else {
24        return BTreeMap::new();
25    };
26    let branches: Vec<String> = listed.iter().cloned().collect();
27    provider
28        .annotate_branches(&branches, detail)
29        .unwrap_or_default()
30}
31
32/// A shareable rendering of the stack.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
34pub enum Format {
35    /// Markdown links - perfect in tracking issues and PR comments.
36    Markdown,
37    /// Plain text with bare URLs, for anywhere that does not render markdown
38    /// links from a paste (e.g. Slack).
39    Plain,
40}
41
42/// Print the current stack.
43#[derive(Debug, clap::Args)]
44pub struct List {
45    /// Render a shareable summary instead of the tree.
46    #[arg(long, value_enum)]
47    format: Option<Format>,
48    /// Show every stack, not just the one you are on.
49    #[arg(long, conflicts_with = "format")]
50    all: bool,
51    /// List each branch's own commits (short SHA + subject) beneath it.
52    #[arg(long, conflicts_with = "format")]
53    commits: bool,
54    /// List each review's approvals, comments, and requested changes beneath it.
55    #[arg(long, conflicts_with_all = ["format", "commits", "local"])]
56    reviews: bool,
57    /// Skip all provider lookups: draw the tree from local metadata only, with
58    /// no review numbers, CI status, or queue info. Never touches the network.
59    #[arg(long, conflicts_with_all = ["format", "reviews"])]
60    local: bool,
61}
62
63impl Run for List {
64    fn run(self) -> Result<()> {
65        if let Some(format) = self.format {
66            return list_formatted(format);
67        }
68        // --local draws from git metadata alone; otherwise annotate, scoping the
69        // provider lookups to just the branches this tree draws.
70        let annotations = if self.local {
71            BTreeMap::new()
72        } else {
73            review_annotations(&stack::listed_branches(self.all)?, self.reviews)
74        };
75        if self.all {
76            stack::print_all_stacks(&annotations, self.commits)
77        } else {
78            stack::print_stack(&annotations, self.commits)
79        }
80    }
81}
82
83/// Print the stack as a copy-paste summary for sharing: a summary line, then
84/// the PRs bottom-to-top (merge order) with title, link/url, and state.
85/// Degrades to plain branch names when reviews or the provider CLI are
86/// unavailable.
87pub fn list_formatted(format: Format) -> Result<()> {
88    let current = git::current_branch()?;
89    let root = stack::stack_root(&current)?;
90    // Scope to the current branch's own line, like the tree view: sibling
91    // stacks that only share the trunk are left out. The base (trunk, or an
92    // unanchored root branch) is the summary's base, not a row.
93    let branches: Vec<String> = stack::current_stack_branches(&current)?
94        .into_iter()
95        .filter(|branch| *branch != root)
96        .collect();
97
98    if branches.is_empty() {
99        println!("no stacked branches");
100        return Ok(());
101    }
102
103    let review_provider = detect_review_provider().ok().map(|(_, client)| client);
104    let entries: Vec<(String, Option<ReviewRequest>)> = branches
105        .iter()
106        .map(|branch| {
107            let review = review_provider
108                .as_ref()
109                .and_then(|rp| owned_review_for_branch(&**rp, branch).ok().flatten());
110            (branch.clone(), review)
111        })
112        .collect();
113
114    println!("{}", summary(&entries, &root, format));
115    println!();
116    for (index, (branch, review)) in entries.iter().enumerate() {
117        let number = index + 1;
118        match (format, review) {
119            (Format::Markdown, Some(review)) => {
120                println!(
121                    "{number}. [{}]({}) - {}",
122                    labeled_with_size(review, branch),
123                    review.url,
124                    review.state
125                );
126            }
127            (Format::Markdown, None) => println!("{number}. `{branch}` (no review)"),
128            // The bare URL on its own line is what chat apps auto-link.
129            (Format::Plain, Some(review)) => {
130                println!(
131                    "{number}. {} - {}",
132                    labeled_with_size(review, branch),
133                    review.state
134                );
135                println!("   {}", review.url);
136            }
137            (Format::Plain, None) => println!("{number}. {branch} (no review)"),
138        }
139    }
140
141    Ok(())
142}
143
144/// The review label with the branch's diff size folded into the id's
145/// parentheses after a comma - e.g. `Title (#12, +9/-0)` - mirroring the tree.
146/// The size is dropped when zero or unavailable, leaving the plain label.
147fn labeled_with_size(review: &ReviewRequest, branch: &str) -> String {
148    let id = match branch_diff_size(branch) {
149        Some((added, deleted)) => format!("{}, +{added}/-{deleted}", review.id),
150        None => review.id.clone(),
151    };
152    label(&review.title, &id)
153}
154
155/// A branch's diff size against its stack parent, dropped when zero or
156/// unavailable - the same count the `git stk list` tree shows.
157fn branch_diff_size(branch: &str) -> Option<(usize, usize)> {
158    let parent = stack::parent_of(branch).ok().flatten()?;
159    let (added, deleted) = git::diff_numstat(&parent, branch).ok()?;
160    (added > 0 || deleted > 0).then_some((added, deleted))
161}
162
163/// One-line stack summary, e.g. "3 PRs, base `main`, 2 open / 1 merged"
164/// (the base is unquoted in plain format).
165fn summary(entries: &[(String, Option<ReviewRequest>)], base: &str, format: Format) -> String {
166    let total = entries.len();
167    let reviews: Vec<&ReviewRequest> = entries.iter().filter_map(|(_, r)| r.as_ref()).collect();
168    let base = match format {
169        Format::Markdown => format!("`{base}`"),
170        Format::Plain => base.to_owned(),
171    };
172
173    let mut summary = if reviews.is_empty() {
174        format!(
175            "{total} branch{}, base {base}",
176            if total == 1 { "" } else { "es" }
177        )
178    } else if reviews.len() == total {
179        format!(
180            "{total} PR{}, base {base}",
181            if total == 1 { "" } else { "s" }
182        )
183    } else {
184        format!(
185            "{total} branches ({} with reviews), base {base}",
186            reviews.len()
187        )
188    };
189
190    if !reviews.is_empty() {
191        let mut counts = Vec::new();
192        for (state, label) in [
193            (ReviewState::Open, "open"),
194            (ReviewState::Merged, "merged"),
195            (ReviewState::Closed, "closed"),
196        ] {
197            let count = reviews
198                .iter()
199                .filter(|review| review.state == state)
200                .count();
201            if count > 0 {
202                counts.push(format!("{count} {label}"));
203            }
204        }
205        if !counts.is_empty() {
206            summary.push_str(&format!(", {}", counts.join(" / ")));
207        }
208    }
209
210    summary
211}