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
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum NavOutput {
19 Announce,
21 Path,
24}
25
26fn 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 println!(".");
52 Ok(())
53}
54
55fn 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
67fn 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
98fn branches(count: usize) -> String {
100 format!("{count} branch{}", if count == 1 { "" } else { "es" })
101}
102
103pub 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
123pub 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(¤t)?;
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
160pub 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 _ => 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(¤t)?.is_empty() && parent_of(¤t)?.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
191pub 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(¤t) == trunk.as_ref() {
198 let children = children_of(¤t)?;
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(¤t)?.is_none() && children_of(¤t)?.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(¤t, &parents);
240 let trunk = trunk_branch(&git::local_branches()?);
241
242 if parent_of(¤t)?.is_none() && children_of(¤t)?.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 let stack: BTreeSet<String> = current_stack_branches(¤t)?.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: ¤t,
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 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
298pub 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 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 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: ¤t,
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 for root in &rootless {
370 render(root, &children);
371 }
372 if let Some(name) = trunk.as_deref() {
373 for base in &trunk_bases {
374 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 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
391pub 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
403struct 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 worktrees: &'a BTreeMap<String, std::path::PathBuf>,
416 commits: bool,
418 width: usize,
420}
421
422fn worktree_map() -> BTreeMap<String, std::path::PathBuf> {
425 git::worktree_branches()
426 .unwrap_or_default()
427 .into_iter()
428 .collect()
429}
430
431fn term_width() -> usize {
433 console::Term::stdout()
434 .size_checked()
435 .map_or(80, |(_, cols)| cols as usize)
436}
437
438fn 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 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 let mut tags: Vec<String> = Vec::new();
478 if let Some(review) = ctx.reviews.get(branch) {
479 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 if let Some(stack) = review.stack {
490 tags.push(style::paint(
491 style::DIM,
492 &format!(
493 "{}{}/{}",
494 crate::providers::STACKED_MARK,
495 stack.position,
496 stack.size
497 ),
498 ));
499 }
500 }
501 if let Some((added, deleted)) = ctx.sizes.get(branch)
504 && (*added > 0 || *deleted > 0)
505 {
506 tags.push(format!(
507 "{}{}{}",
508 style::paint(style::ADDED, &format!("+{added}")),
509 style::paint(style::DIM, "/"),
510 style::paint(style::REMOVED, &format!("-{deleted}")),
511 ));
512 }
513 if !tags.is_empty() {
514 let separator = style::paint(style::DIM, ", ");
515 line.push_str(&style::paint(style::DIM, " ("));
516 line.push_str(&tags.join(&separator));
517 line.push_str(&style::paint(style::DIM, ")"));
518 }
519 if let Some(path) = ctx.worktrees.get(branch) {
522 line.push_str(&style::paint(
523 style::DIM,
524 &format!(" {}", git::display_path(path)),
525 ));
526 }
527
528 if ctx.commits
534 && Some(branch) != ctx.trunk
535 && let Some(parent) = ctx.parents.get(branch)
536 {
537 let indent = " ".repeat(depth + 1);
538 match git::log_oneline(&format!("{parent}..{branch}")) {
539 Ok(commits) if !commits.is_empty() => {
540 for (sha, subject) in commits.iter().rev() {
541 let budget = ctx
542 .width
543 .saturating_sub(indent.len() + sha.len() + 2)
544 .max(16);
545 let subject = console::truncate_str(subject, budget, "…");
546 lines.push(format!(
547 "{indent}{} {}",
548 style::paint(style::DIM, sha),
549 style::paint(style::DIM, &subject)
550 ));
551 }
552 }
553 Ok(_) => lines.push(format!(
554 "{indent}{}",
555 style::paint(style::DIM, "(no commits)")
556 )),
557 Err(_) => {}
558 }
559 }
560
561 if Some(branch) != ctx.trunk
565 && let Some(review) = ctx.reviews.get(branch)
566 && let Some(summary) = &review.summary
567 {
568 let indent = " ".repeat(depth + 1);
569 let summary_lines = summary.lines();
570 if summary_lines.is_empty() {
571 lines.push(format!(
572 "{indent}{}",
573 style::paint(style::DIM, "(no reviews)")
574 ));
575 } else {
576 for text in summary_lines.iter().rev() {
577 lines.push(format!("{indent}{}", style::paint(style::DIM, text)));
578 }
579 }
580 }
581
582 lines.push(line);
583
584 if !seen.insert(branch.to_owned()) {
585 lines.push(format!("{}<cycle detected>", " ".repeat(depth + 1)));
586 return;
587 }
588
589 if let Some(branch_children) = ctx.children.get(branch) {
590 for child in branch_children {
591 collect_tree_lines(ctx, child, depth + 1, seen, lines);
592 }
593 }
594}