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    branch_and_descendants, children_map, children_of, 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        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        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>) -> Result<()> {
153    let current = git::current_branch()?;
154    let parents = parent_map()?;
155    let root = root_for(&current, &parents);
156    let children = children_map(&parents);
157    let trunk = trunk_branch(&git::local_branches()?);
158
159    let mut lines = Vec::new();
160    collect_tree_lines(
161        &root,
162        &current,
163        trunk.as_deref(),
164        &children,
165        reviews,
166        0,
167        &mut BTreeSet::new(),
168        &mut lines,
169    );
170
171    // Leaf-first, trunk last: the stack reads like a pile sitting on its
172    // base, matching the up/down direction of navigation.
173    for line in lines.iter().rev() {
174        anstream::println!("{line}");
175    }
176
177    for branch in branch_and_descendants(&root)? {
178        if let Some(parent) = parents.get(&branch)
179            && let Some(hint) = behind_parent_hint(&branch, parent)
180        {
181            anstream::println!("{} {hint}", style::paint(style::HINT, "hint:"));
182        }
183    }
184    Ok(())
185}
186
187/// Print every stack, not just the current one: the trunk-rooted forest with
188/// a single trunk line at the bottom, and each rootless fragment as its own
189/// tree above it. The branch you are on is marked wherever it appears.
190pub fn print_all_stacks(reviews: &BTreeMap<String, String>) -> Result<()> {
191    let current = git::current_branch()?;
192    let parents = parent_map()?;
193    let children = children_map(&parents);
194    let trunk = trunk_branch(&git::local_branches()?);
195
196    // The root of every stack: each branch with a stack relationship, walked
197    // up to its topmost ancestor. Trunk-anchored stacks all resolve to the
198    // trunk; rootless fragments resolve to their own top. Lone branches with
199    // no parent or children never enter `parents`, so they are left out.
200    let mut roots = Vec::new();
201    let mut seen = BTreeSet::new();
202    for branch in parents
203        .iter()
204        .flat_map(|(child, parent)| [child.clone(), parent.clone()])
205    {
206        let root = root_for(&branch, &parents);
207        if seen.insert(root.clone()) {
208            roots.push(root);
209        }
210    }
211
212    if roots.is_empty() {
213        println!("no stacked branches");
214        return Ok(());
215    }
216
217    // Render the trunk-rooted forest last so its trunk line sits at the very
218    // bottom; rootless fragments stack above it.
219    roots.sort_by_key(|root| Some(root.as_str()) == trunk.as_deref());
220
221    for (index, root) in roots.iter().enumerate() {
222        if index > 0 {
223            anstream::println!();
224        }
225        let mut lines = Vec::new();
226        collect_tree_lines(
227            root,
228            &current,
229            trunk.as_deref(),
230            &children,
231            reviews,
232            0,
233            &mut BTreeSet::new(),
234            &mut lines,
235        );
236        for line in lines.iter().rev() {
237            anstream::println!("{line}");
238        }
239    }
240
241    // Behind-parent hints span every stack.
242    for (branch, parent) in &parents {
243        if let Some(hint) = behind_parent_hint(branch, parent) {
244            anstream::println!("{} {hint}", style::paint(style::HINT, "hint:"));
245        }
246    }
247    Ok(())
248}
249
250/// A restack nudge when `branch` is missing commits from its parent's tip.
251/// Local-only; a missing parent yields nothing.
252pub fn behind_parent_hint(branch: &str, parent: &str) -> Option<String> {
253    let behind = git::commits_behind(branch, parent)
254        .ok()
255        .filter(|count| *count > 0)?;
256    Some(format!(
257        "{branch} is {behind} commit{} behind {parent} - run `git stk restack`",
258        if behind == 1 { "" } else { "s" }
259    ))
260}
261
262#[allow(clippy::too_many_arguments)]
263fn collect_tree_lines(
264    branch: &str,
265    current: &str,
266    trunk: Option<&str>,
267    children: &BTreeMap<String, Vec<String>>,
268    reviews: &BTreeMap<String, String>,
269    depth: usize,
270    seen: &mut BTreeSet<String>,
271    lines: &mut Vec<String>,
272) {
273    // A graphite-style rail: a filled marker on the branch you are on.
274    let mut line = "  ".repeat(depth);
275    if branch == current {
276        line.push_str(&style::paint(style::CURRENT, &format!("\u{25c9} {branch}")));
277    } else {
278        line.push_str("\u{25cb} ");
279        line.push_str(&style::paint(style::BRANCH, branch));
280    }
281    if Some(branch) == trunk {
282        line.push_str(&style::paint(style::DIM, " (trunk)"));
283    }
284    // The branch's open review number, when one is known.
285    if let Some(id) = reviews.get(branch) {
286        line.push_str(&style::paint(style::DIM, &format!(" ({id})")));
287    }
288    lines.push(line);
289
290    if !seen.insert(branch.to_owned()) {
291        lines.push(format!("{}<cycle detected>", "  ".repeat(depth + 1)));
292        return;
293    }
294
295    if let Some(branch_children) = children.get(branch) {
296        for child in branch_children {
297            collect_tree_lines(
298                child,
299                current,
300                trunk,
301                children,
302                reviews,
303                depth + 1,
304                seen,
305                lines,
306            );
307        }
308    }
309}