Skip to main content

git_stk/commands/
status.rs

1use anyhow::Result;
2use clap_complete::engine::ArgValueCompleter;
3
4use crate::commands::Run;
5use crate::completions;
6use crate::providers::{BaseGap, CheckStatus, ReviewState, detect_review_provider};
7use crate::style;
8use crate::{git, stack};
9
10/// Print local and remote stack status for a branch.
11#[derive(Debug, clap::Args)]
12pub struct Status {
13    /// Branch to report on (defaults to the current branch).
14    #[arg(add = ArgValueCompleter::new(completions::branch_candidates))]
15    branch: Option<String>,
16}
17
18impl Run for Status {
19    fn run(self) -> Result<()> {
20        print_status(self.branch.as_deref())
21    }
22}
23
24pub fn print_status(branch: Option<&str>) -> Result<()> {
25    let branch = branch
26        .map(str::to_owned)
27        .map_or_else(git::current_branch, Ok)?;
28    // Marker-aware, like every other reader: a base with a stray `stkParent`
29    // has no parent for any purpose, and reporting one here would produce a
30    // "run `git stk restack`" hint that `restack` cannot act on.
31    let is_base = stack::is_floor(&branch)?;
32    let parent = stack::stacked_parent_of(&branch)?;
33    let children = stack::children_of(&branch)?;
34
35    anstream::println!("branch: {}", style::paint(style::CURRENT, &branch));
36    // Where the branch actually lives, when that is not here. Best effort: a
37    // failed listing just omits the line.
38    if let Some(path) = git::worktree_holding(&branch).ok().flatten() {
39        anstream::println!("worktree: {}", git::display_path(&path));
40    }
41    match parent.as_deref() {
42        Some(parent) => anstream::println!("parent: {}", style::paint(style::BRANCH, parent)),
43        // A recorded base has no parent by design, not by omission - say which,
44        // so it is not read as a branch whose metadata went missing.
45        None if is_base => anstream::println!("parent: none (this stack's base)"),
46        None => anstream::println!("parent: none"),
47    }
48    if children.is_empty() {
49        anstream::println!("children: none");
50    } else {
51        let children: Vec<String> = children
52            .iter()
53            .map(|child| style::paint(style::BRANCH, child))
54            .collect();
55        anstream::println!("children: {}", children.join(", "));
56    }
57
58    // Provider state is best-effort: a repo with no remote (or no provider
59    // configured) still shows its local stack rather than hard-failing.
60    let detected = detect_review_provider().ok();
61    let review = match &detected {
62        Some((provider, review_provider)) => {
63            anstream::println!("provider: {} ({})", provider.kind, provider.source);
64            // Closed-inclusive: a review closed without merging is part of the
65            // branch's story, not "no review".
66            let review = review_provider.review_for_branch_including_closed(&branch)?;
67            match &review {
68                Some(review) => {
69                    // The queue state, the CI rollup, and the platform stack
70                    // in one ask. Each provider takes its own cheapest route:
71                    // GitHub folds all three into the query `list` already
72                    // makes, everyone else keeps the per-call path. Best
73                    // effort throughout - a failed lookup just omits a marker.
74                    let annotation = review_provider.annotate_review(review, false).ok();
75
76                    // A queued review shows just the clock (it is waiting to
77                    // land); otherwise the CI dot.
78                    let queued = annotation.as_ref().is_some_and(|found| found.queued);
79                    let marker = if queued {
80                        crate::providers::QUEUED_MARK
81                    } else {
82                        annotation
83                            .as_ref()
84                            .map_or(CheckStatus::None, |found| found.checks)
85                            .dot()
86                    };
87                    anstream::println!(
88                        "review: {marker}{} {} {} -> {}",
89                        review.id,
90                        style::state(&review.state),
91                        style::paint(style::BRANCH, &review.branch),
92                        style::paint(style::BRANCH, &review.base)
93                    );
94                    anstream::println!("url: {}", style::paint(style::DIM, &review.url));
95                    // The platform's own stack, when it holds this review -
96                    // which is what makes GitHub, not git-stk, the one that
97                    // merges and retargets it. Position and size come from
98                    // whichever source answered, never one from each.
99                    if let Some(at) = annotation.as_ref().and_then(|found| found.stack) {
100                        anstream::println!(
101                            "stack: {} stack {} ({} of {})",
102                            provider.kind,
103                            at.number,
104                            at.position,
105                            at.size
106                        );
107                    }
108
109                    // A base and a local parent that disagree while the
110                    // platform's stack can still close the gap is a chain
111                    // part-way through unwinding, not a fault - and `submit`,
112                    // which the warning names, refuses a review in a stack.
113                    if let Some(parent) = parent.as_deref()
114                        && parent != review.base
115                    {
116                        // Asked only on a disagreement, which is rare: the
117                        // lookup costs a call, and the annotation cannot
118                        // answer it.
119                        match review_provider.base_gap(review, parent).unwrap_or(None) {
120                            Some(BaseGap::Platform) => anstream::println!(
121                                "{}",
122                                style::paint(
123                                    style::DIM,
124                                    &format!(
125                                        "review base is {}, local parent is {parent} - \
126                                         the platform retargets it as the layer below lands",
127                                        review.base
128                                    )
129                                )
130                            ),
131                            Some(BaseGap::Sync) => anstream::println!(
132                                "{} review base is {}, local parent is {parent} - the \
133                                 platform moved it when {parent} landed; run `git stk sync`",
134                                style::paint(style::WARN, "warning:"),
135                                review.base
136                            ),
137                            Some(BaseGap::Neither) => anstream::println!(
138                                "{} review base is {}, local parent is {parent} - the \
139                                 platform will not move it there and refuses a change by \
140                                 hand; run `git stk unstack`, then \
141                                 `git stk submit`",
142                                style::paint(style::WARN, "warning:"),
143                                review.base
144                            ),
145                            None => anstream::println!(
146                                "{} review base is {}, local parent is {parent} - run `git stk submit`",
147                                style::paint(style::WARN, "warning:"),
148                                review.base
149                            ),
150                        }
151                    }
152                }
153                None => anstream::println!("review: none"),
154            }
155            review
156        }
157        None => {
158            anstream::println!("{}", style::dim("provider: not detected (no review info)"));
159            None
160        }
161    };
162
163    // Teach the loop: the next command, derived from review states and
164    // local drift. A sync covers the restack, so the nudges don't stack.
165    let mut hints = Vec::new();
166    if is_base {
167        hints.push(format!(
168            "{branch} is this stack's base, so nothing rebases, submits, or lands it - \
169             `git stk detach {branch}` if it should be"
170        ));
171    }
172    match &review {
173        // `sync` and `cleanup` both skip a base on purpose, so the usual
174        // remedies can never be satisfied here. Say what actually happened
175        // rather than reprint a dead end every run.
176        Some(review)
177            if is_base && matches!(review.state, ReviewState::Merged | ReviewState::Closed) =>
178        {
179            hints.push(format!(
180                "review {} is {} - git-stk leaves a stack's base alone, so this is yours to \
181                 finish; `git stk detach {branch}` first if it should be managed",
182                review.id,
183                style::state(&review.state)
184            ));
185        }
186        Some(review) if review.state == ReviewState::Merged => {
187            hints.push(format!(
188                "review {} is merged - run `git stk sync`",
189                review.id
190            ));
191        }
192        Some(review) if review.state == ReviewState::Closed => {
193            hints.push(format!(
194                "review {} was closed without merging - `git stk submit` opens a new review",
195                review.id
196            ));
197        }
198        _ => {}
199    }
200    if let Some(parent) = parent.as_deref() {
201        if let Some((_, review_provider)) = &detected {
202            match review_provider.review_for_branch_including_closed(parent) {
203                Ok(Some(parent_review)) if parent_review.branch == parent => {
204                    // `sync` skips a base before `landing_for`, so it never
205                    // retargets a layer off one - pointing there would reprint
206                    // every run. Name the re-root instead.
207                    let parent_is_base = stack::is_floor(parent)?;
208                    match parent_review.state {
209                        // Only the states `landing_for` would have acted on:
210                        // `Unknown(_)` covers things like GitLab's `locked`,
211                        // which is still running, and printed nothing before.
212                        _ if parent_is_base
213                            && matches!(
214                                parent_review.state,
215                                ReviewState::Merged | ReviewState::Closed
216                            ) =>
217                        {
218                            hints.push(format!(
219                                "parent review {} is {} - {parent} is this stack's base, so \
220                                 git-stk does not retarget off it; re-root with \
221                                 `git stk adopt {branch} --parent <parent>`",
222                                parent_review.id,
223                                style::state(&parent_review.state)
224                            ));
225                        }
226                        ReviewState::Merged => hints.push(format!(
227                            "parent review {} is merged - run `git stk sync`",
228                            parent_review.id
229                        )),
230                        ReviewState::Closed => hints.push(format!(
231                            "parent review {} was closed without merging - \
232                             retarget with `git stk adopt {branch} --parent <parent>`",
233                            parent_review.id
234                        )),
235                        _ => {}
236                    }
237                }
238                _ => {}
239            }
240        }
241
242        if hints.is_empty()
243            && let Some(hint) = stack::behind_parent_hint(&branch, parent)
244        {
245            hints.push(hint);
246        }
247    }
248    for hint in hints {
249        anstream::println!("{} {hint}", style::paint(style::HINT, "hint:"));
250    }
251
252    Ok(())
253}