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    /// Show every stack, not just the one you are on.
25    #[arg(long, conflicts_with = "format")]
26    all: bool,
27}
28
29impl Run for List {
30    fn run(self) -> Result<()> {
31        match (self.format, self.all) {
32            (Some(format), _) => list_formatted(format),
33            (None, true) => crate::stack::print_all_stacks(),
34            (None, false) => crate::stack::print_stack(),
35        }
36    }
37}
38
39/// Print the stack as a copy-paste summary for sharing: a summary line, then
40/// the PRs bottom-to-top (merge order) with title, link/url, and state.
41/// Degrades to plain branch names when reviews or the provider CLI are
42/// unavailable.
43pub fn list_formatted(format: Format) -> Result<()> {
44    let current = git::current_branch()?;
45    let root = stack::stack_root(&current)?;
46    let branches: Vec<String> = stack::branch_and_descendants(&root)?
47        .into_iter()
48        .skip(1) // the root is the base, not part of the stack
49        .collect();
50
51    if branches.is_empty() {
52        println!("no stacked branches");
53        return Ok(());
54    }
55
56    let review_provider = detect_provider().ok().map(|p| review_provider(p.kind));
57    let entries: Vec<(String, Option<ReviewRequest>)> = branches
58        .iter()
59        .map(|branch| {
60            let review = review_provider
61                .as_ref()
62                .and_then(|rp| rp.review_for_branch(branch).ok().flatten())
63                .filter(|review| review.branch == *branch);
64            (branch.clone(), review)
65        })
66        .collect();
67
68    println!("{}", summary(&entries, &root, format));
69    println!();
70    for (index, (branch, review)) in entries.iter().enumerate() {
71        let number = index + 1;
72        match (format, review) {
73            (Format::Markdown, Some(review)) => {
74                println!(
75                    "{number}. [{}]({}) - {}",
76                    review.label(),
77                    review.url,
78                    review.state
79                );
80            }
81            (Format::Markdown, None) => println!("{number}. `{branch}` (no review)"),
82            // The bare URL on its own line is what chat apps auto-link.
83            (Format::Plain, Some(review)) => {
84                println!("{number}. {} - {}", review.label(), review.state);
85                println!("   {}", review.url);
86            }
87            (Format::Plain, None) => println!("{number}. {branch} (no review)"),
88        }
89    }
90
91    Ok(())
92}
93
94/// One-line stack summary, e.g. "3 PRs, base `main`, 2 open / 1 merged"
95/// (the base is unquoted in plain format).
96fn summary(entries: &[(String, Option<ReviewRequest>)], base: &str, format: Format) -> String {
97    let total = entries.len();
98    let reviews: Vec<&ReviewRequest> = entries.iter().filter_map(|(_, r)| r.as_ref()).collect();
99    let base = match format {
100        Format::Markdown => format!("`{base}`"),
101        Format::Plain => base.to_owned(),
102    };
103
104    let mut summary = if reviews.is_empty() {
105        format!(
106            "{total} branch{}, base {base}",
107            if total == 1 { "" } else { "es" }
108        )
109    } else if reviews.len() == total {
110        format!(
111            "{total} PR{}, base {base}",
112            if total == 1 { "" } else { "s" }
113        )
114    } else {
115        format!(
116            "{total} branches ({} with reviews), base {base}",
117            reviews.len()
118        )
119    };
120
121    if !reviews.is_empty() {
122        let mut counts = Vec::new();
123        for (state, label) in [
124            (ReviewState::Open, "open"),
125            (ReviewState::Merged, "merged"),
126            (ReviewState::Closed, "closed"),
127        ] {
128            let count = reviews
129                .iter()
130                .filter(|review| review.state == state)
131                .count();
132            if count > 0 {
133                counts.push(format!("{count} {label}"));
134            }
135        }
136        if !counts.is_empty() {
137            summary.push_str(&format!(", {}", counts.join(" / ")));
138        }
139    }
140
141    summary
142}