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::providers::ReviewAnnotation;
14use crate::style;
15
16fn pick_child(title: &str, children: &[String]) -> anyhow::Result<Option<String>> {
19 let painted: Vec<String> = children
20 .iter()
21 .map(|child| style::paint(style::BRANCH, child))
22 .collect();
23 Ok(prompt::pick(title, &painted)?.map(|index| children[index].clone()))
24}
25
26pub fn print_parent(branch: Option<&str>) -> Result<()> {
27 let branch = branch
28 .map(str::to_owned)
29 .map_or_else(git::current_branch, Ok)?;
30 match parent_of(&branch)? {
31 Some(parent) => println!("{parent}"),
32 None => bail!("{branch} has no stack parent"),
33 }
34 Ok(())
35}
36
37pub fn print_children(branch: Option<&str>) -> Result<()> {
38 let branch = branch
39 .map(str::to_owned)
40 .map_or_else(git::current_branch, Ok)?;
41 for child in children_of(&branch)? {
42 println!("{child}");
43 }
44 Ok(())
45}
46
47pub fn checkout_parent() -> Result<()> {
48 let current = git::current_branch()?;
49 let Some(parent) = parent_of(¤t)? else {
50 bail!("{current} has no stack parent");
51 };
52
53 git::checkout(&parent)
54}
55
56pub fn checkout_child(branch: Option<&str>) -> Result<()> {
57 let current = git::current_branch()?;
58 let children = children_of(¤t)?;
59 let child = match (branch, children.as_slice()) {
60 (Some(branch), _) => {
61 if children.iter().any(|child| child == branch) {
62 branch.to_owned()
63 } else {
64 bail!("{branch} is not a stack child of {current}");
65 }
66 }
67 (None, [child]) => child.to_owned(),
68 (None, []) => bail!("{current} has no stack children"),
69 (None, _) => {
70 match pick_child(
71 &format!("{current} has multiple stack children:"),
72 &children,
73 )? {
74 Some(child) => child,
75 None => bail!("choose one with `git stk up <branch>`"),
76 }
77 }
78 };
79
80 git::checkout(&child)
81}
82
83pub fn checkout_top() -> Result<()> {
86 let current = git::current_branch()?;
87 let mut top = current.clone();
88 loop {
89 let children = children_of(&top)?;
90 match children.as_slice() {
91 [] => break,
92 [child] => top = child.clone(),
93 _ => match pick_child(&format!("{top} has multiple stack children:"), &children)? {
95 Some(child) => top = child,
96 None => bail!("walk up from {top} with `git stk up <branch>`"),
97 },
98 }
99 }
100
101 if top == current {
102 if children_of(¤t)?.is_empty() && parent_of(¤t)?.is_none() {
103 bail!("{current} is not in a stack");
104 }
105 anstream::println!("{current} is already at the top of the stack");
106 return Ok(());
107 }
108 git::checkout(&top)
109}
110
111pub fn checkout_bottom() -> Result<()> {
114 let current = git::current_branch()?;
115 let trunk = trunk_branch(&git::local_branches()?);
116
117 let bottom = if Some(¤t) == trunk.as_ref() {
118 let children = children_of(¤t)?;
119 match children.as_slice() {
120 [child] => child.clone(),
121 [] => bail!("{current} has no stacked branches"),
122 _ => {
123 match pick_child(
124 &format!("{current} has multiple stack children:"),
125 &children,
126 )? {
127 Some(child) => child,
128 None => bail!("choose one with `git stk up <branch>`"),
129 }
130 }
131 }
132 } else {
133 let mut bottom = current.clone();
134 while let Some(parent) = parent_of(&bottom)? {
135 if Some(&parent) == trunk.as_ref() {
136 break;
137 }
138 bottom = parent;
139 }
140 bottom
141 };
142
143 if bottom == current {
144 if parent_of(¤t)?.is_none() && children_of(¤t)?.is_empty() {
145 bail!("{current} is not in a stack");
146 }
147 anstream::println!("{current} is already at the bottom of the stack");
148 return Ok(());
149 }
150 git::checkout(&bottom)
151}
152
153pub fn print_stack(reviews: &BTreeMap<String, ReviewAnnotation>, commits: bool) -> Result<()> {
154 let current = git::current_branch()?;
155 let parents = parent_map()?;
156 let root = root_for(¤t, &parents);
157 let trunk = trunk_branch(&git::local_branches()?);
158
159 if parent_of(¤t)?.is_none() && children_of(¤t)?.is_empty() {
162 anstream::println!("no stacked branches");
163 anstream::println!(
164 "{}",
165 style::dim("create one on top of the current branch with `git stk new <branch>`")
166 );
167 return Ok(());
168 }
169
170 let stack: BTreeSet<String> = current_stack_branches(¤t)?.into_iter().collect();
175 let children: BTreeMap<String, Vec<String>> = children_map(&parents)
176 .into_iter()
177 .map(|(parent, kids)| {
178 let kept = kids.into_iter().filter(|kid| stack.contains(kid)).collect();
179 (parent, kept)
180 })
181 .collect();
182
183 let sizes = diff_sizes(stack.iter().cloned(), &parents);
184 let ctx = TreeCtx {
185 current: ¤t,
186 trunk: trunk.as_deref(),
187 children: &children,
188 parents: &parents,
189 reviews,
190 sizes: &sizes,
191 commits,
192 width: term_width(),
193 };
194 let mut lines = Vec::new();
195 collect_tree_lines(&ctx, &root, 0, &mut BTreeSet::new(), &mut lines);
196
197 for line in lines.iter().rev() {
200 anstream::println!("{line}");
201 }
202
203 for branch in &stack {
204 if let Some(parent) = parents.get(branch)
205 && let Some(hint) = behind_parent_hint(branch, parent)
206 {
207 anstream::println!("{} {hint}", style::paint(style::HINT, "hint:"));
208 }
209 }
210 Ok(())
211}
212
213pub fn print_all_stacks(reviews: &BTreeMap<String, ReviewAnnotation>, commits: bool) -> Result<()> {
220 let current = git::current_branch()?;
221 let parents = parent_map()?;
222 let children = children_map(&parents);
223 let trunk = trunk_branch(&git::local_branches()?);
224
225 let mut rootless = Vec::new();
229 let mut seen = BTreeSet::new();
230 for branch in parents
231 .iter()
232 .flat_map(|(child, parent)| [child.clone(), parent.clone()])
233 {
234 let root = root_for(&branch, &parents);
235 if Some(root.as_str()) != trunk.as_deref() && seen.insert(root.clone()) {
236 rootless.push(root);
237 }
238 }
239 rootless.sort();
240
241 let trunk_bases: Vec<String> = trunk
245 .as_deref()
246 .and_then(|name| children.get(name))
247 .cloned()
248 .unwrap_or_default();
249
250 if rootless.is_empty() && trunk_bases.is_empty() {
251 anstream::println!("no stacked branches");
252 return Ok(());
253 }
254
255 let sizes = diff_sizes(parents.keys().cloned(), &parents);
256 let width = term_width();
257 let mut first = true;
258 let mut render = |root: &str, block_children: &BTreeMap<String, Vec<String>>| {
259 if !first {
260 anstream::println!();
261 }
262 first = false;
263 let ctx = TreeCtx {
264 current: ¤t,
265 trunk: trunk.as_deref(),
266 children: block_children,
267 parents: &parents,
268 reviews,
269 sizes: &sizes,
270 commits,
271 width,
272 };
273 let mut lines = Vec::new();
274 collect_tree_lines(&ctx, root, 0, &mut BTreeSet::new(), &mut lines);
275 for line in lines.iter().rev() {
276 anstream::println!("{line}");
277 }
278 };
279
280 for root in &rootless {
283 render(root, &children);
284 }
285 if let Some(name) = trunk.as_deref() {
286 for base in &trunk_bases {
287 let mut block_children = children.clone();
290 block_children.insert(name.to_owned(), vec![base.clone()]);
291 render(name, &block_children);
292 }
293 }
294
295 for (branch, parent) in &parents {
297 if let Some(hint) = behind_parent_hint(branch, parent) {
298 anstream::println!("{} {hint}", style::paint(style::HINT, "hint:"));
299 }
300 }
301 Ok(())
302}
303
304pub fn behind_parent_hint(branch: &str, parent: &str) -> Option<String> {
307 let behind = git::commits_behind(branch, parent)
308 .ok()
309 .filter(|count| *count > 0)?;
310 Some(format!(
311 "{branch} is {behind} commit{} behind {parent} - run `git stk restack`",
312 if behind == 1 { "" } else { "s" }
313 ))
314}
315
316struct TreeCtx<'a> {
319 current: &'a str,
320 trunk: Option<&'a str>,
321 children: &'a BTreeMap<String, Vec<String>>,
322 parents: &'a BTreeMap<String, String>,
323 reviews: &'a BTreeMap<String, ReviewAnnotation>,
324 sizes: &'a BTreeMap<String, (usize, usize)>,
325 commits: bool,
327 width: usize,
329}
330
331fn term_width() -> usize {
333 console::Term::stdout()
334 .size_checked()
335 .map_or(80, |(_, cols)| cols as usize)
336}
337
338fn diff_sizes(
342 branches: impl IntoIterator<Item = String>,
343 parents: &BTreeMap<String, String>,
344) -> BTreeMap<String, (usize, usize)> {
345 let mut sizes = BTreeMap::new();
346 for branch in branches {
347 if let Some(parent) = parents.get(&branch)
348 && let Ok(size) = git::diff_numstat(parent, &branch)
349 {
350 sizes.insert(branch, size);
351 }
352 }
353 sizes
354}
355
356fn collect_tree_lines(
357 ctx: &TreeCtx,
358 branch: &str,
359 depth: usize,
360 seen: &mut BTreeSet<String>,
361 lines: &mut Vec<String>,
362) {
363 let mut line = " ".repeat(depth);
365 if branch == ctx.current {
366 line.push_str(&style::paint(style::CURRENT, &format!("\u{25c9} {branch}")));
367 } else {
368 line.push_str("\u{25cb} ");
369 line.push_str(&style::paint(style::BRANCH, branch));
370 }
371 if Some(branch) == ctx.trunk {
372 line.push_str(&style::paint(style::DIM, " (trunk)"));
373 }
374 let mut tags: Vec<String> = Vec::new();
378 if let Some(review) = ctx.reviews.get(branch) {
379 let marker = if review.queued {
382 crate::providers::QUEUED_MARK
383 } else {
384 review.checks.dot()
385 };
386 tags.push(format!("{marker}{}", style::paint(style::DIM, &review.id)));
387 }
388 if let Some((added, deleted)) = ctx.sizes.get(branch)
391 && (*added > 0 || *deleted > 0)
392 {
393 tags.push(format!(
394 "{}{}{}",
395 style::paint(style::ADDED, &format!("+{added}")),
396 style::paint(style::DIM, "/"),
397 style::paint(style::REMOVED, &format!("-{deleted}")),
398 ));
399 }
400 if !tags.is_empty() {
401 let separator = style::paint(style::DIM, ", ");
402 line.push_str(&style::paint(style::DIM, " ("));
403 line.push_str(&tags.join(&separator));
404 line.push_str(&style::paint(style::DIM, ")"));
405 }
406
407 if ctx.commits
413 && Some(branch) != ctx.trunk
414 && let Some(parent) = ctx.parents.get(branch)
415 {
416 let indent = " ".repeat(depth + 1);
417 match git::log_oneline(&format!("{parent}..{branch}")) {
418 Ok(commits) if !commits.is_empty() => {
419 for (sha, subject) in commits.iter().rev() {
420 let budget = ctx
421 .width
422 .saturating_sub(indent.len() + sha.len() + 2)
423 .max(16);
424 let subject = console::truncate_str(subject, budget, "…");
425 lines.push(format!(
426 "{indent}{} {}",
427 style::paint(style::DIM, sha),
428 style::paint(style::DIM, &subject)
429 ));
430 }
431 }
432 Ok(_) => lines.push(format!(
433 "{indent}{}",
434 style::paint(style::DIM, "(no commits)")
435 )),
436 Err(_) => {}
437 }
438 }
439
440 if Some(branch) != ctx.trunk
444 && let Some(review) = ctx.reviews.get(branch)
445 && let Some(summary) = &review.summary
446 {
447 let indent = " ".repeat(depth + 1);
448 let summary_lines = summary.lines();
449 if summary_lines.is_empty() {
450 lines.push(format!(
451 "{indent}{}",
452 style::paint(style::DIM, "(no reviews)")
453 ));
454 } else {
455 for text in summary_lines.iter().rev() {
456 lines.push(format!("{indent}{}", style::paint(style::DIM, text)));
457 }
458 }
459 }
460
461 lines.push(line);
462
463 if !seen.insert(branch.to_owned()) {
464 lines.push(format!("{}<cycle detected>", " ".repeat(depth + 1)));
465 return;
466 }
467
468 if let Some(branch_children) = ctx.children.get(branch) {
469 for child in branch_children {
470 collect_tree_lines(ctx, child, depth + 1, seen, lines);
471 }
472 }
473}