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