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