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