Skip to main content

git_stk/stack/
nav.rs

1//! Moving around the stack and printing it.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use anyhow::{Result, bail};
6
7use super::{
8    children_map, children_of, current_stack_branches, parent_map, parent_of, root_for,
9    trunk_branch,
10};
11use crate::git;
12use crate::prompt;
13use crate::providers::ReviewAnnotation;
14use crate::style;
15
16/// Offer a numbered pick of `children`; None when nothing was chosen
17/// (non-interactive stdin, or an invalid answer).
18fn pick_child(title: &str, children: &[String]) -> anyhow::Result<Option<String>> {
19    let painted: Vec<String> = children
20        .iter()
21        .map(|child| style::paint(style::BRANCH, child))
22        .collect();
23    Ok(prompt::pick(title, &painted)?.map(|index| children[index].clone()))
24}
25
26pub fn print_parent(branch: Option<&str>) -> Result<()> {
27    let branch = branch
28        .map(str::to_owned)
29        .map_or_else(git::current_branch, Ok)?;
30    match parent_of(&branch)? {
31        Some(parent) => println!("{parent}"),
32        None => bail!("{branch} has no stack parent"),
33    }
34    Ok(())
35}
36
37pub fn print_children(branch: Option<&str>) -> Result<()> {
38    let branch = branch
39        .map(str::to_owned)
40        .map_or_else(git::current_branch, Ok)?;
41    for child in children_of(&branch)? {
42        println!("{child}");
43    }
44    Ok(())
45}
46
47pub fn checkout_parent() -> Result<()> {
48    let current = git::current_branch()?;
49    let Some(parent) = parent_of(&current)? else {
50        bail!("{current} has no stack parent");
51    };
52
53    git::checkout(&parent)
54}
55
56pub fn checkout_child(branch: Option<&str>) -> Result<()> {
57    let current = git::current_branch()?;
58    let children = children_of(&current)?;
59    let child = match (branch, children.as_slice()) {
60        (Some(branch), _) => {
61            if children.iter().any(|child| child == branch) {
62                branch.to_owned()
63            } else {
64                bail!("{branch} is not a stack child of {current}");
65            }
66        }
67        (None, [child]) => child.to_owned(),
68        (None, []) => bail!("{current} has no stack children"),
69        (None, _) => {
70            match pick_child(
71                &format!("{current} has multiple stack children:"),
72                &children,
73            )? {
74                Some(child) => child,
75                None => bail!("choose one with `git stk up <branch>`"),
76            }
77        }
78    };
79
80    git::checkout(&child)
81}
82
83/// Check out the leaf of the current stack, following single children. A
84/// fork is ambiguous, like `up` without a branch.
85pub fn checkout_top() -> Result<()> {
86    let current = git::current_branch()?;
87    let mut top = current.clone();
88    loop {
89        let children = children_of(&top)?;
90        match children.as_slice() {
91            [] => break,
92            [child] => top = child.clone(),
93            // A pick resolves the fork and the climb continues from there.
94            _ => match pick_child(&format!("{top} has multiple stack children:"), &children)? {
95                Some(child) => top = child,
96                None => bail!("walk up from {top} with `git stk up <branch>`"),
97            },
98        }
99    }
100
101    if top == current {
102        if children_of(&current)?.is_empty() && parent_of(&current)?.is_none() {
103            bail!("{current} is not in a stack");
104        }
105        anstream::println!("{current} is already at the top of the stack");
106        return Ok(());
107    }
108    git::checkout(&top)
109}
110
111/// Check out the bottom of the current stack: the branch just above the
112/// trunk. From the trunk itself, a single stacked child is unambiguous.
113pub fn checkout_bottom() -> Result<()> {
114    let current = git::current_branch()?;
115    let trunk = trunk_branch(&git::local_branches()?);
116
117    let bottom = if Some(&current) == trunk.as_ref() {
118        let children = children_of(&current)?;
119        match children.as_slice() {
120            [child] => child.clone(),
121            [] => bail!("{current} has no stacked branches"),
122            _ => {
123                match pick_child(
124                    &format!("{current} has multiple stack children:"),
125                    &children,
126                )? {
127                    Some(child) => child,
128                    None => bail!("choose one with `git stk up <branch>`"),
129                }
130            }
131        }
132    } else {
133        let mut bottom = current.clone();
134        while let Some(parent) = parent_of(&bottom)? {
135            if Some(&parent) == trunk.as_ref() {
136                break;
137            }
138            bottom = parent;
139        }
140        bottom
141    };
142
143    if bottom == current {
144        if parent_of(&current)?.is_none() && children_of(&current)?.is_empty() {
145            bail!("{current} is not in a stack");
146        }
147        anstream::println!("{current} is already at the bottom of the stack");
148        return Ok(());
149    }
150    git::checkout(&bottom)
151}
152
153pub fn print_stack(reviews: &BTreeMap<String, ReviewAnnotation>, commits: bool) -> Result<()> {
154    let current = git::current_branch()?;
155    let parents = parent_map()?;
156    let root = root_for(&current, &parents);
157    let trunk = trunk_branch(&git::local_branches()?);
158
159    // A lone branch (or the bare trunk) is not a stack - say so rather than
160    // drawing a one-node "stack".
161    if parent_of(&current)?.is_none() && children_of(&current)?.is_empty() {
162        anstream::println!("no stacked branches");
163        anstream::println!(
164            "{}",
165            style::dim("create one on top of the current branch with `git stk new <branch>`")
166        );
167        return Ok(());
168    }
169
170    // Scope to the current branch's own line (fork siblings included, stacks
171    // that merely share the trunk excluded), so `list` shows just the stack you
172    // are on; `--all` is for the rest. The trunk stays as the rendered root so
173    // the stack still reads as sitting on its base.
174    let stack: BTreeSet<String> = current_stack_branches(&current)?.into_iter().collect();
175    let children: BTreeMap<String, Vec<String>> = children_map(&parents)
176        .into_iter()
177        .map(|(parent, kids)| {
178            let kept = kids.into_iter().filter(|kid| stack.contains(kid)).collect();
179            (parent, kept)
180        })
181        .collect();
182
183    let sizes = diff_sizes(stack.iter().cloned(), &parents);
184    let ctx = TreeCtx {
185        current: &current,
186        trunk: trunk.as_deref(),
187        children: &children,
188        parents: &parents,
189        reviews,
190        sizes: &sizes,
191        commits,
192        width: term_width(),
193    };
194    let mut lines = Vec::new();
195    collect_tree_lines(&ctx, &root, 0, &mut BTreeSet::new(), &mut lines);
196
197    // Leaf-first, trunk last: the stack reads like a pile sitting on its
198    // base, matching the up/down direction of navigation.
199    for line in lines.iter().rev() {
200        anstream::println!("{line}");
201    }
202
203    for branch in &stack {
204        if let Some(parent) = parents.get(branch)
205            && let Some(hint) = behind_parent_hint(branch, parent)
206        {
207            anstream::println!("{} {hint}", style::paint(style::HINT, "hint:"));
208        }
209    }
210    Ok(())
211}
212
213/// Print every stack, not just the current one, each as its own block separated
214/// by a blank line. Stacks that merely share the trunk are drawn separately -
215/// one per direct trunk child - each repeating the trunk as its base, so they
216/// read as distinct piles rather than one tangled tree. Rootless fragments print
217/// above the trunk-anchored ones. The branch you are on is marked wherever it
218/// appears.
219pub fn print_all_stacks(reviews: &BTreeMap<String, ReviewAnnotation>, commits: bool) -> Result<()> {
220    let current = git::current_branch()?;
221    let parents = parent_map()?;
222    let children = children_map(&parents);
223    let trunk = trunk_branch(&git::local_branches()?);
224
225    // Rootless fragments: stacks not anchored on the trunk, each walked up to
226    // its own topmost ancestor. Lone branches with no parent or children never
227    // enter `parents`, so they are left out.
228    let mut rootless = Vec::new();
229    let mut seen = BTreeSet::new();
230    for branch in parents
231        .iter()
232        .flat_map(|(child, parent)| [child.clone(), parent.clone()])
233    {
234        let root = root_for(&branch, &parents);
235        if Some(root.as_str()) != trunk.as_deref() && seen.insert(root.clone()) {
236            rootless.push(root);
237        }
238    }
239    rootless.sort();
240
241    // Each direct child of the trunk is the base of a distinct stack sharing the
242    // trunk; a fork deeper in a stack shares a real branch, not just the trunk,
243    // so it stays within one block.
244    let trunk_bases: Vec<String> = trunk
245        .as_deref()
246        .and_then(|name| children.get(name))
247        .cloned()
248        .unwrap_or_default();
249
250    if rootless.is_empty() && trunk_bases.is_empty() {
251        anstream::println!("no stacked branches");
252        return Ok(());
253    }
254
255    let sizes = diff_sizes(parents.keys().cloned(), &parents);
256    let width = term_width();
257    let mut first = true;
258    let mut render = |root: &str, block_children: &BTreeMap<String, Vec<String>>| {
259        if !first {
260            anstream::println!();
261        }
262        first = false;
263        let ctx = TreeCtx {
264            current: &current,
265            trunk: trunk.as_deref(),
266            children: block_children,
267            parents: &parents,
268            reviews,
269            sizes: &sizes,
270            commits,
271            width,
272        };
273        let mut lines = Vec::new();
274        collect_tree_lines(&ctx, root, 0, &mut BTreeSet::new(), &mut lines);
275        for line in lines.iter().rev() {
276            anstream::println!("{line}");
277        }
278    };
279
280    // Rootless fragments first, trunk-anchored stacks last so their trunk lines
281    // sit at the bottom of the output.
282    for root in &rootless {
283        render(root, &children);
284    }
285    if let Some(name) = trunk.as_deref() {
286        for base in &trunk_bases {
287            // Restrict the trunk to this one base so the block shows a single
288            // stack sitting on its own trunk line.
289            let mut block_children = children.clone();
290            block_children.insert(name.to_owned(), vec![base.clone()]);
291            render(name, &block_children);
292        }
293    }
294
295    // Behind-parent hints span every stack.
296    for (branch, parent) in &parents {
297        if let Some(hint) = behind_parent_hint(branch, parent) {
298            anstream::println!("{} {hint}", style::paint(style::HINT, "hint:"));
299        }
300    }
301    Ok(())
302}
303
304/// A restack nudge when `branch` is missing commits from its parent's tip.
305/// Local-only; a missing parent yields nothing.
306pub fn behind_parent_hint(branch: &str, parent: &str) -> Option<String> {
307    let behind = git::commits_behind(branch, parent)
308        .ok()
309        .filter(|count| *count > 0)?;
310    Some(format!(
311        "{branch} is {behind} commit{} behind {parent} - run `git stk restack`",
312        if behind == 1 { "" } else { "s" }
313    ))
314}
315
316/// The read-only context for rendering a stack tree, threaded through the
317/// recursion so each call only varies `branch`/`depth` and the accumulators.
318struct TreeCtx<'a> {
319    current: &'a str,
320    trunk: Option<&'a str>,
321    children: &'a BTreeMap<String, Vec<String>>,
322    parents: &'a BTreeMap<String, String>,
323    reviews: &'a BTreeMap<String, ReviewAnnotation>,
324    sizes: &'a BTreeMap<String, (usize, usize)>,
325    /// `--commits`: list each branch's own commits beneath it.
326    commits: bool,
327    /// Terminal width, for truncating commit subjects.
328    width: usize,
329}
330
331/// Terminal width for truncation, defaulting to 80 when not a terminal.
332fn term_width() -> usize {
333    console::Term::stdout()
334        .size_checked()
335        .map_or(80, |(_, cols)| cols as usize)
336}
337
338/// Per-branch diff size (added, deleted lines) against its stack parent, for
339/// each branch that has one. Best effort: a branch with no parent, or whose
340/// diff cannot be read, is left out of the map and simply shows no size.
341fn diff_sizes(
342    branches: impl IntoIterator<Item = String>,
343    parents: &BTreeMap<String, String>,
344) -> BTreeMap<String, (usize, usize)> {
345    let mut sizes = BTreeMap::new();
346    for branch in branches {
347        if let Some(parent) = parents.get(&branch)
348            && let Ok(size) = git::diff_numstat(parent, &branch)
349        {
350            sizes.insert(branch, size);
351        }
352    }
353    sizes
354}
355
356fn collect_tree_lines(
357    ctx: &TreeCtx,
358    branch: &str,
359    depth: usize,
360    seen: &mut BTreeSet<String>,
361    lines: &mut Vec<String>,
362) {
363    // A graphite-style rail: a filled marker on the branch you are on.
364    let mut line = "  ".repeat(depth);
365    if branch == ctx.current {
366        line.push_str(&style::paint(style::CURRENT, &format!("\u{25c9} {branch}")));
367    } else {
368        line.push_str("\u{25cb} ");
369        line.push_str(&style::paint(style::BRANCH, branch));
370    }
371    if Some(branch) == ctx.trunk {
372        line.push_str(&style::paint(style::DIM, " (trunk)"));
373    }
374    // Optional annotations in one paren group: the CI dot and dimmed open
375    // review number, then the diff size against the parent in faded green/red,
376    // like a diff.
377    let mut tags: Vec<String> = Vec::new();
378    if let Some(review) = ctx.reviews.get(branch) {
379        // A queued review shows just the clock - it is waiting to land, so its
380        // (usually pending) CI dot would only be noise alongside it.
381        let marker = if review.queued {
382            crate::providers::QUEUED_MARK
383        } else {
384            review.checks.dot()
385        };
386        tags.push(format!("{marker}{}", style::paint(style::DIM, &review.id)));
387    }
388    // An empty branch (same tip as its parent) shows no size rather than a
389    // noisy "+0/-0".
390    if let Some((added, deleted)) = ctx.sizes.get(branch)
391        && (*added > 0 || *deleted > 0)
392    {
393        tags.push(format!(
394            "{}{}{}",
395            style::paint(style::ADDED, &format!("+{added}")),
396            style::paint(style::DIM, "/"),
397            style::paint(style::REMOVED, &format!("-{deleted}")),
398        ));
399    }
400    if !tags.is_empty() {
401        let separator = style::paint(style::DIM, ", ");
402        line.push_str(&style::paint(style::DIM, " ("));
403        line.push_str(&tags.join(&separator));
404        line.push_str(&style::paint(style::DIM, ")"));
405    }
406
407    // With --commits, list the branch's own commits (parent..branch) under it.
408    // `lines` is printed reversed, so push them here (before the branch line)
409    // oldest-first: the reversal then shows them newest-first - git log order -
410    // directly below the branch. The trunk and parentless roots have no "own"
411    // commits to show.
412    if ctx.commits
413        && Some(branch) != ctx.trunk
414        && let Some(parent) = ctx.parents.get(branch)
415    {
416        let indent = "  ".repeat(depth + 1);
417        match git::log_oneline(&format!("{parent}..{branch}")) {
418            Ok(commits) if !commits.is_empty() => {
419                for (sha, subject) in commits.iter().rev() {
420                    let budget = ctx
421                        .width
422                        .saturating_sub(indent.len() + sha.len() + 2)
423                        .max(16);
424                    let subject = console::truncate_str(subject, budget, "…");
425                    lines.push(format!(
426                        "{indent}{}  {}",
427                        style::paint(style::DIM, sha),
428                        style::paint(style::DIM, &subject)
429                    ));
430                }
431            }
432            Ok(_) => lines.push(format!(
433                "{indent}{}",
434                style::paint(style::DIM, "(no commits)")
435            )),
436            Err(_) => {}
437        }
438    }
439
440    // With --reviews, list the review's tallies under it, like --commits. The
441    // annotation carries a summary only when the flag is set. `lines` is
442    // printed reversed, so push them in reverse display order (see above).
443    if Some(branch) != ctx.trunk
444        && let Some(review) = ctx.reviews.get(branch)
445        && let Some(summary) = &review.summary
446    {
447        let indent = "  ".repeat(depth + 1);
448        let summary_lines = summary.lines();
449        if summary_lines.is_empty() {
450            lines.push(format!(
451                "{indent}{}",
452                style::paint(style::DIM, "(no reviews)")
453            ));
454        } else {
455            for text in summary_lines.iter().rev() {
456                lines.push(format!("{indent}{}", style::paint(style::DIM, text)));
457            }
458        }
459    }
460
461    lines.push(line);
462
463    if !seen.insert(branch.to_owned()) {
464        lines.push(format!("{}<cycle detected>", "  ".repeat(depth + 1)));
465        return;
466    }
467
468    if let Some(branch_children) = ctx.children.get(branch) {
469        for child in branch_children {
470            collect_tree_lines(ctx, child, depth + 1, seen, lines);
471        }
472    }
473}