Skip to main content

git_stk/commands/
list.rs

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