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
98pub fn checkout_parent(output: NavOutput) -> Result<()> {
99 let current = git::current_branch()?;
100 let Some(parent) = parent_of(¤t)? else {
101 bail!("{current} has no stack parent");
102 };
103
104 navigate_to(&parent, output)
105}
106
107pub fn checkout_child(branch: Option<&str>, output: NavOutput) -> Result<()> {
108 let current = git::current_branch()?;
109 let children = children_of(¤t)?;
110 let child = match (branch, children.as_slice()) {
111 (Some(branch), _) => {
112 if children.iter().any(|child| child == branch) {
113 branch.to_owned()
114 } else {
115 bail!("{branch} is not a stack child of {current}");
116 }
117 }
118 (None, [child]) => child.to_owned(),
119 (None, []) => bail!("{current} has no stack children"),
120 (None, _) => {
121 match pick_child(
122 &format!("{current} has multiple stack children:"),
123 &children,
124 )? {
125 Some(child) => child,
126 None => bail!("choose one with `git stk up <branch>`"),
127 }
128 }
129 };
130
131 navigate_to(&child, output)
132}
133
134pub fn checkout_top(output: NavOutput) -> Result<()> {
137 let current = git::current_branch()?;
138 let mut top = current.clone();
139 loop {
140 let children = children_of(&top)?;
141 match children.as_slice() {
142 [] => break,
143 [child] => top = child.clone(),
144 _ => match pick_child(&format!("{top} has multiple stack children:"), &children)? {
146 Some(child) => top = child,
147 None => bail!("walk up from {top} with `git stk up <branch>`"),
148 },
149 }
150 }
151
152 if top == current {
153 if children_of(¤t)?.is_empty() && parent_of(¤t)?.is_none() {
154 bail!("{current} is not in a stack");
155 }
156 stay_put(
157 &format!("{current} is already at the top of the stack"),
158 output,
159 );
160 return Ok(());
161 }
162 navigate_to(&top, output)
163}
164
165pub fn checkout_bottom(output: NavOutput) -> Result<()> {
168 let current = git::current_branch()?;
169 let trunk = trunk_branch(&git::local_branches()?);
170
171 let bottom = if Some(¤t) == trunk.as_ref() {
172 let children = children_of(¤t)?;
173 match children.as_slice() {
174 [child] => child.clone(),
175 [] => bail!("{current} has no stacked branches"),
176 _ => {
177 match pick_child(
178 &format!("{current} has multiple stack children:"),
179 &children,
180 )? {
181 Some(child) => child,
182 None => bail!("choose one with `git stk up <branch>`"),
183 }
184 }
185 }
186 } else {
187 let mut bottom = current.clone();
188 while let Some(parent) = parent_of(&bottom)? {
189 if Some(&parent) == trunk.as_ref() {
190 break;
191 }
192 bottom = parent;
193 }
194 bottom
195 };
196
197 if bottom == current {
198 if parent_of(¤t)?.is_none() && children_of(¤t)?.is_empty() {
199 bail!("{current} is not in a stack");
200 }
201 stay_put(
202 &format!("{current} is already at the bottom of the stack"),
203 output,
204 );
205 return Ok(());
206 }
207 navigate_to(&bottom, output)
208}
209
210pub fn print_stack(reviews: &BTreeMap<String, ReviewAnnotation>, commits: bool) -> Result<()> {
211 let current = git::current_branch()?;
212 let parents = parent_map()?;
213 let root = root_for(¤t, &parents);
214 let trunk = trunk_branch(&git::local_branches()?);
215
216 if parent_of(¤t)?.is_none() && children_of(¤t)?.is_empty() {
219 anstream::println!("no stacked branches");
220 anstream::println!(
221 "{}",
222 style::dim("create one on top of the current branch with `git stk new <branch>`")
223 );
224 return Ok(());
225 }
226
227 let stack: BTreeSet<String> = current_stack_branches(¤t)?.into_iter().collect();
232 let children: BTreeMap<String, Vec<String>> = children_map(&parents)
233 .into_iter()
234 .map(|(parent, kids)| {
235 let kept = kids.into_iter().filter(|kid| stack.contains(kid)).collect();
236 (parent, kept)
237 })
238 .collect();
239
240 let sizes = diff_sizes(stack.iter().cloned(), &parents);
241 let worktrees = worktree_map();
242 let ctx = TreeCtx {
243 current: ¤t,
244 trunk: trunk.as_deref(),
245 children: &children,
246 parents: &parents,
247 reviews,
248 sizes: &sizes,
249 worktrees: &worktrees,
250 commits,
251 width: term_width(),
252 };
253 let mut lines = Vec::new();
254 collect_tree_lines(&ctx, &root, 0, &mut BTreeSet::new(), &mut lines);
255
256 for line in lines.iter().rev() {
259 anstream::println!("{line}");
260 }
261
262 for branch in &stack {
263 if let Some(parent) = parents.get(branch)
264 && let Some(hint) = behind_parent_hint(branch, parent)
265 {
266 anstream::println!("{} {hint}", style::paint(style::HINT, "hint:"));
267 }
268 }
269 Ok(())
270}
271
272pub fn print_all_stacks(reviews: &BTreeMap<String, ReviewAnnotation>, commits: bool) -> Result<()> {
279 let current = git::current_branch()?;
280 let parents = parent_map()?;
281 let children = children_map(&parents);
282 let trunk = trunk_branch(&git::local_branches()?);
283
284 let mut rootless = Vec::new();
288 let mut seen = BTreeSet::new();
289 for branch in parents
290 .iter()
291 .flat_map(|(child, parent)| [child.clone(), parent.clone()])
292 {
293 let root = root_for(&branch, &parents);
294 if Some(root.as_str()) != trunk.as_deref() && seen.insert(root.clone()) {
295 rootless.push(root);
296 }
297 }
298 rootless.sort();
299
300 let trunk_bases: Vec<String> = trunk
304 .as_deref()
305 .and_then(|name| children.get(name))
306 .cloned()
307 .unwrap_or_default();
308
309 if rootless.is_empty() && trunk_bases.is_empty() {
310 anstream::println!("no stacked branches");
311 return Ok(());
312 }
313
314 let sizes = diff_sizes(parents.keys().cloned(), &parents);
315 let worktrees = worktree_map();
316 let width = term_width();
317 let mut first = true;
318 let mut render = |root: &str, block_children: &BTreeMap<String, Vec<String>>| {
319 if !first {
320 anstream::println!();
321 }
322 first = false;
323 let ctx = TreeCtx {
324 current: ¤t,
325 trunk: trunk.as_deref(),
326 children: block_children,
327 parents: &parents,
328 reviews,
329 sizes: &sizes,
330 worktrees: &worktrees,
331 commits,
332 width,
333 };
334 let mut lines = Vec::new();
335 collect_tree_lines(&ctx, root, 0, &mut BTreeSet::new(), &mut lines);
336 for line in lines.iter().rev() {
337 anstream::println!("{line}");
338 }
339 };
340
341 for root in &rootless {
344 render(root, &children);
345 }
346 if let Some(name) = trunk.as_deref() {
347 for base in &trunk_bases {
348 let mut block_children = children.clone();
351 block_children.insert(name.to_owned(), vec![base.clone()]);
352 render(name, &block_children);
353 }
354 }
355
356 for (branch, parent) in &parents {
358 if let Some(hint) = behind_parent_hint(branch, parent) {
359 anstream::println!("{} {hint}", style::paint(style::HINT, "hint:"));
360 }
361 }
362 Ok(())
363}
364
365pub fn behind_parent_hint(branch: &str, parent: &str) -> Option<String> {
368 let behind = git::commits_behind(branch, parent)
369 .ok()
370 .filter(|count| *count > 0)?;
371 Some(format!(
372 "{branch} is {behind} commit{} behind {parent} - run `git stk restack`",
373 if behind == 1 { "" } else { "s" }
374 ))
375}
376
377struct TreeCtx<'a> {
380 current: &'a str,
381 trunk: Option<&'a str>,
382 children: &'a BTreeMap<String, Vec<String>>,
383 parents: &'a BTreeMap<String, String>,
384 reviews: &'a BTreeMap<String, ReviewAnnotation>,
385 sizes: &'a BTreeMap<String, (usize, usize)>,
386 worktrees: &'a BTreeMap<String, std::path::PathBuf>,
390 commits: bool,
392 width: usize,
394}
395
396fn worktree_map() -> BTreeMap<String, std::path::PathBuf> {
399 git::worktree_branches()
400 .unwrap_or_default()
401 .into_iter()
402 .collect()
403}
404
405fn term_width() -> usize {
407 console::Term::stdout()
408 .size_checked()
409 .map_or(80, |(_, cols)| cols as usize)
410}
411
412fn diff_sizes(
416 branches: impl IntoIterator<Item = String>,
417 parents: &BTreeMap<String, String>,
418) -> BTreeMap<String, (usize, usize)> {
419 let mut sizes = BTreeMap::new();
420 for branch in branches {
421 if let Some(parent) = parents.get(&branch)
422 && let Ok(size) = git::diff_numstat(parent, &branch)
423 {
424 sizes.insert(branch, size);
425 }
426 }
427 sizes
428}
429
430fn collect_tree_lines(
431 ctx: &TreeCtx,
432 branch: &str,
433 depth: usize,
434 seen: &mut BTreeSet<String>,
435 lines: &mut Vec<String>,
436) {
437 let mut line = " ".repeat(depth);
439 if branch == ctx.current {
440 line.push_str(&style::paint(style::CURRENT, &format!("\u{25c9} {branch}")));
441 } else {
442 line.push_str("\u{25cb} ");
443 line.push_str(&style::paint(style::BRANCH, branch));
444 }
445 if Some(branch) == ctx.trunk {
446 line.push_str(&style::paint(style::DIM, " (trunk)"));
447 }
448 let mut tags: Vec<String> = Vec::new();
452 if let Some(review) = ctx.reviews.get(branch) {
453 let marker = if review.queued {
456 crate::providers::QUEUED_MARK
457 } else {
458 review.checks.dot()
459 };
460 tags.push(format!("{marker}{}", style::paint(style::DIM, &review.id)));
461 }
462 if let Some((added, deleted)) = ctx.sizes.get(branch)
465 && (*added > 0 || *deleted > 0)
466 {
467 tags.push(format!(
468 "{}{}{}",
469 style::paint(style::ADDED, &format!("+{added}")),
470 style::paint(style::DIM, "/"),
471 style::paint(style::REMOVED, &format!("-{deleted}")),
472 ));
473 }
474 if !tags.is_empty() {
475 let separator = style::paint(style::DIM, ", ");
476 line.push_str(&style::paint(style::DIM, " ("));
477 line.push_str(&tags.join(&separator));
478 line.push_str(&style::paint(style::DIM, ")"));
479 }
480 if let Some(path) = ctx.worktrees.get(branch) {
483 line.push_str(&style::paint(
484 style::DIM,
485 &format!(" {}", git::display_path(path)),
486 ));
487 }
488
489 if ctx.commits
495 && Some(branch) != ctx.trunk
496 && let Some(parent) = ctx.parents.get(branch)
497 {
498 let indent = " ".repeat(depth + 1);
499 match git::log_oneline(&format!("{parent}..{branch}")) {
500 Ok(commits) if !commits.is_empty() => {
501 for (sha, subject) in commits.iter().rev() {
502 let budget = ctx
503 .width
504 .saturating_sub(indent.len() + sha.len() + 2)
505 .max(16);
506 let subject = console::truncate_str(subject, budget, "…");
507 lines.push(format!(
508 "{indent}{} {}",
509 style::paint(style::DIM, sha),
510 style::paint(style::DIM, &subject)
511 ));
512 }
513 }
514 Ok(_) => lines.push(format!(
515 "{indent}{}",
516 style::paint(style::DIM, "(no commits)")
517 )),
518 Err(_) => {}
519 }
520 }
521
522 if Some(branch) != ctx.trunk
526 && let Some(review) = ctx.reviews.get(branch)
527 && let Some(summary) = &review.summary
528 {
529 let indent = " ".repeat(depth + 1);
530 let summary_lines = summary.lines();
531 if summary_lines.is_empty() {
532 lines.push(format!(
533 "{indent}{}",
534 style::paint(style::DIM, "(no reviews)")
535 ));
536 } else {
537 for text in summary_lines.iter().rev() {
538 lines.push(format!("{indent}{}", style::paint(style::DIM, text)));
539 }
540 }
541 }
542
543 lines.push(line);
544
545 if !seen.insert(branch.to_owned()) {
546 lines.push(format!("{}<cycle detected>", " ".repeat(depth + 1)));
547 return;
548 }
549
550 if let Some(branch_children) = ctx.children.get(branch) {
551 for child in branch_children {
552 collect_tree_lines(ctx, child, depth + 1, seen, lines);
553 }
554 }
555}