1use 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
15fn 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(¤t)? 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(¤t)?;
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
82pub 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 _ => 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(¤t)?.is_empty() && parent_of(¤t)?.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
110pub fn checkout_bottom() -> Result<()> {
113 let current = git::current_branch()?;
114 let trunk = trunk_branch(&git::local_branches()?);
115
116 let bottom = if Some(¤t) == trunk.as_ref() {
117 let children = children_of(¤t)?;
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(¤t)?.is_none() && children_of(¤t)?.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(¤t, &parents);
156 let trunk = trunk_branch(&git::local_branches()?);
157
158 if parent_of(¤t)?.is_none() && children_of(¤t)?.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 let stack: BTreeSet<String> = current_stack_branches(¤t)?.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: ¤t,
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 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
212pub 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 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 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: ¤t,
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 for root in &rootless {
282 render(root, &children);
283 }
284 if let Some(name) = trunk.as_deref() {
285 for base in &trunk_bases {
286 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 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
303pub 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
315struct 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: bool,
326 width: usize,
328}
329
330fn term_width() -> usize {
332 console::Term::stdout()
333 .size_checked()
334 .map_or(80, |(_, cols)| cols as usize)
335}
336
337fn 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 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 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 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 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}