1use std::{
2 collections::{BTreeMap, BTreeSet},
3 fs,
4 path::PathBuf,
5};
6
7use anyhow::{Context, Result, bail};
8
9use crate::cli::{PushMode, UpdateRefsMode};
10use crate::git;
11use crate::settings;
12
13const PARENT_KEY: &str = "stkParent";
14const BASE_KEY: &str = "stkBase";
15const STATE_FILE: &str = "stack-state";
16
17pub fn create_branch(branch: &str) -> Result<()> {
18 let parent = git::current_branch()?;
19 git::create_branch(branch)?;
20 set_parent(branch, &parent)?;
21 record_base(branch, &parent);
22 println!("created {branch} with parent {parent}");
23 Ok(())
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 eprintln!("{current} has multiple stack children:");
71 for child in children {
72 eprintln!(" {child}");
73 }
74 bail!("choose one with `git stk up <branch>`");
75 }
76 };
77
78 git::checkout(&child)
79}
80
81pub fn checkout_top() -> Result<()> {
84 let current = git::current_branch()?;
85 let mut top = current.clone();
86 loop {
87 let children = children_of(&top)?;
88 match children.as_slice() {
89 [] => break,
90 [child] => top = child.clone(),
91 _ => {
92 eprintln!("{top} has multiple stack children:");
93 for child in children {
94 eprintln!(" {child}");
95 }
96 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 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 eprintln!("{current} has multiple stack children:");
124 for child in children {
125 eprintln!(" {child}");
126 }
127 bail!("choose one with `git stk up <branch>`");
128 }
129 }
130 } else {
131 let mut bottom = current.clone();
132 while let Some(parent) = parent_of(&bottom)? {
133 if Some(&parent) == trunk.as_ref() {
134 break;
135 }
136 bottom = parent;
137 }
138 bottom
139 };
140
141 if bottom == current {
142 if parent_of(¤t)?.is_none() && children_of(¤t)?.is_empty() {
143 bail!("{current} is not in a stack");
144 }
145 println!("{current} is already at the bottom of the stack");
146 return Ok(());
147 }
148 git::checkout(&bottom)
149}
150
151pub fn print_stack() -> Result<()> {
152 let current = git::current_branch()?;
153 let parents = parent_map()?;
154 let root = root_for(¤t, &parents);
155 let children = children_map(&parents);
156 let trunk = trunk_branch(&git::local_branches()?);
157
158 let mut lines = Vec::new();
159 collect_tree_lines(
160 &root,
161 ¤t,
162 trunk.as_deref(),
163 &children,
164 0,
165 &mut BTreeSet::new(),
166 &mut lines,
167 );
168
169 for line in lines.iter().rev() {
172 println!("{line}");
173 }
174
175 for branch in branch_and_descendants(&root)? {
176 if let Some(parent) = parents.get(&branch)
177 && let Some(hint) = behind_parent_hint(&branch, parent)
178 {
179 println!("hint: {hint}");
180 }
181 }
182 Ok(())
183}
184
185pub fn behind_parent_hint(branch: &str, parent: &str) -> Option<String> {
188 let behind = git::commits_behind(branch, parent)
189 .ok()
190 .filter(|count| *count > 0)?;
191 Some(format!(
192 "{branch} is {behind} commit{} behind {parent} - run `git stk restack`",
193 if behind == 1 { "" } else { "s" }
194 ))
195}
196
197pub fn trunk_branch(branches: &[String]) -> Option<String> {
200 let remote = settings::remote().unwrap_or_else(|_| settings::DEFAULT_REMOTE.to_owned());
201 if let Some(default) = git::remote_default_branch(&remote) {
202 return Some(default);
203 }
204
205 ["main", "master"]
206 .iter()
207 .find(|name| branches.iter().any(|branch| branch == *name))
208 .map(|name| (*name).to_owned())
209}
210
211pub fn adopt_branch(branch: &str, parent: &str) -> Result<()> {
212 if branch == parent {
213 bail!("a branch cannot be its own stack parent");
214 }
215
216 let branches: BTreeSet<_> = git::local_branches()?.into_iter().collect();
217 if !branches.contains(branch) {
218 bail!("branch {branch} does not exist");
219 }
220 if !branches.contains(parent) {
221 bail!("parent branch {parent} does not exist");
222 }
223
224 set_parent(branch, parent)?;
225 record_base(branch, parent);
226 println!("attached {branch} to {parent}");
227 Ok(())
228}
229
230pub fn detach_branch(branch: Option<&str>) -> Result<()> {
231 let branch = branch
232 .map(str::to_owned)
233 .map_or_else(git::current_branch, Ok)?;
234 unset_parent(&branch)?;
235 unset_base(&branch)?;
236 println!("detached {branch}");
237 Ok(())
238}
239
240pub fn rename_branch(old: &str, new: &str) -> Result<()> {
244 let children = children_for_branch(old)?;
245 git::rename_branch(old, new)?;
246 println!("renamed {old} -> {new}");
247
248 for child in &children {
249 set_parent_for_branch(child, new)?;
250 println!("retargeted {child} -> {new}");
251 }
252 Ok(())
253}
254
255pub fn restack(update_refs_mode: UpdateRefsMode, push_mode: PushMode) -> Result<()> {
256 let current = git::current_branch()?;
257 let parents = parent_map()?;
258 let root = root_for(¤t, &parents);
261 let branches = restack_order(&root, &parents);
262
263 if branches.is_empty() {
264 println!("nothing to restack");
265 return Ok(());
266 }
267
268 let update_refs = resolve_update_refs(update_refs_mode)?;
269 let push = settings::push_enabled(push_mode, settings::PUSH_ON_RESTACK_KEY)?;
270
271 clear_state()?;
272 let all = branches.clone();
273 restack_branches(branches, &parents, update_refs, push, &all)
274}
275
276pub fn continue_restack() -> Result<()> {
277 let Some(state) = RestackState::read()? else {
278 bail!("no interrupted restack found");
279 };
280
281 if let Err(error) = git::rebase_continue() {
282 eprintln!("restack still has conflicts");
283 eprintln!("resolve conflicts, then run `git stk continue`");
284 eprintln!("or run `git stk abort`");
285 return Err(error);
286 }
287
288 record_base(&state.branch, &state.parent);
289
290 if state.remaining.is_empty() {
291 clear_state()?;
292 finish_restack(&state.all, state.push)?;
293 return Ok(());
294 }
295
296 let parents = parent_map()?;
297 restack_branches(
298 state.remaining,
299 &parents,
300 state.update_refs,
301 state.push,
302 &state.all,
303 )
304}
305
306pub fn abort_restack() -> Result<()> {
307 git::rebase_abort()?;
308 clear_state()?;
309 println!("restack aborted");
310 Ok(())
311}
312
313pub fn parent_for_branch(branch: &str) -> Result<Option<String>> {
314 parent_of(branch)
315}
316
317pub fn children_for_branch(branch: &str) -> Result<Vec<String>> {
318 children_of(branch)
319}
320
321pub fn set_parent_for_branch(branch: &str, parent: &str) -> Result<()> {
322 set_parent(branch, parent)
323}
324
325pub fn unset_parent_for_branch(branch: &str) -> Result<()> {
326 unset_parent(branch)
327}
328
329pub fn base_for_branch(branch: &str) -> Result<Option<String>> {
330 base_of(branch)
331}
332
333pub fn set_base_for_branch(branch: &str, base: &str) -> Result<()> {
334 git::config_set(&base_key(branch), base)
335}
336
337pub fn unset_base_for_branch(branch: &str) -> Result<()> {
338 unset_base(branch)
339}
340
341pub fn record_base(branch: &str, parent: &str) {
344 if let Ok(base) = git::merge_base(parent, branch) {
345 let _ = git::config_set(&base_key(branch), &base);
346 }
347}
348
349pub fn stack_root(branch: &str) -> Result<String> {
351 let parents = parent_map()?;
352 Ok(root_for(branch, &parents))
353}
354
355pub fn branch_and_descendants(branch: &str) -> Result<Vec<String>> {
356 let parents = parent_map()?;
357 let children = children_map(&parents);
358 let mut branches = vec![branch.to_owned()];
359 collect_descendants(branch, &children, &mut branches);
360 Ok(branches)
361}
362
363fn parent_map() -> Result<BTreeMap<String, String>> {
364 let mut parents = BTreeMap::new();
365 for branch in git::local_branches()? {
366 if let Some(parent) = parent_of(&branch)? {
367 parents.insert(branch, parent);
368 }
369 }
370 Ok(parents)
371}
372
373fn restack_order(current: &str, parents: &BTreeMap<String, String>) -> Vec<String> {
374 let children = children_map(parents);
375 let mut branches = Vec::new();
376
377 if parents.contains_key(current) {
378 branches.push(current.to_owned());
379 }
380
381 collect_descendants(current, &children, &mut branches);
382 branches
383}
384
385fn collect_descendants(
386 branch: &str,
387 children: &BTreeMap<String, Vec<String>>,
388 branches: &mut Vec<String>,
389) {
390 if let Some(branch_children) = children.get(branch) {
391 for child in branch_children {
392 branches.push(child.to_owned());
393 collect_descendants(child, children, branches);
394 }
395 }
396}
397
398fn restack_branches(
399 branches: Vec<String>,
400 parents: &BTreeMap<String, String>,
401 update_refs: bool,
402 push: bool,
403 all: &[String],
404) -> Result<()> {
405 for (index, branch) in branches.iter().enumerate() {
406 let Some(parent) = parents.get(branch) else {
407 bail!("{branch} has no stack parent");
408 };
409
410 let base = match base_of(branch)? {
415 Some(base) if git::is_ancestor(&base, branch).unwrap_or(false) => Some(base),
416 _ => None,
417 };
418
419 let parent_tip = git::rev_parse(parent)?;
423 if base.as_deref() == Some(parent_tip.as_str())
424 && git::is_ancestor(parent, branch).unwrap_or(false)
425 {
426 println!("{branch} already up to date with {parent}");
427 continue;
428 }
429
430 if update_refs {
431 println!("rebasing {branch} onto {parent} with --update-refs");
432 } else {
433 println!("rebasing {branch} onto {parent}");
434 }
435 let rebase_result = match &base {
436 Some(base) => git::rebase_onto(parent, base, branch, update_refs),
437 None => git::rebase(parent, branch, update_refs),
438 };
439
440 if let Err(error) = rebase_result {
441 let remaining = branches[index + 1..].to_vec();
442 RestackState {
443 branch: branch.to_owned(),
444 parent: parent.to_owned(),
445 remaining,
446 update_refs,
447 push,
448 all: all.to_vec(),
449 }
450 .write()?;
451
452 eprintln!("conflict while rebasing {branch} onto {parent}");
453 eprintln!("resolve conflicts, then run `git stk continue`");
454 eprintln!("or run `git stk abort`");
455 return Err(error);
456 }
457
458 record_base(branch, parent);
459 }
460
461 clear_state()?;
462 finish_restack(all, push)
463}
464
465fn finish_restack(branches: &[String], push: bool) -> Result<()> {
468 println!("restack complete");
469
470 let remote = settings::remote()?;
471 if push {
472 git::push_force_with_lease(&remote, branches)?;
473 println!("pushed {} to {remote}", branches.join(" "));
474 } else {
475 println!("remote branches may be stale; push them with:");
476 println!(
477 " git push --force-with-lease {remote} {}",
478 branches.join(" ")
479 );
480 }
481 Ok(())
482}
483
484fn resolve_update_refs(mode: UpdateRefsMode) -> Result<bool> {
485 match mode {
486 UpdateRefsMode::Config => {
487 let configured = git::config_get_bool(settings::UPDATE_REFS_KEY)?.unwrap_or(false);
488 if configured && !git::supports_rebase_update_refs()? {
489 eprintln!("stk.updateRefs is true, but this Git does not support --update-refs");
490 return Ok(false);
491 }
492 Ok(configured)
493 }
494 UpdateRefsMode::Enabled => {
495 if !git::supports_rebase_update_refs()? {
496 bail!("--update-refs was requested, but this Git does not support it");
497 }
498 Ok(true)
499 }
500 UpdateRefsMode::Disabled => Ok(false),
501 }
502}
503
504fn children_of(parent: &str) -> Result<Vec<String>> {
505 Ok(parent_map()?
506 .into_iter()
507 .filter_map(|(branch, branch_parent)| (branch_parent == parent).then_some(branch))
508 .collect())
509}
510
511fn children_map(parents: &BTreeMap<String, String>) -> BTreeMap<String, Vec<String>> {
512 let mut children: BTreeMap<String, Vec<String>> = BTreeMap::new();
513 for (branch, parent) in parents {
514 children
515 .entry(parent.to_owned())
516 .or_default()
517 .push(branch.to_owned());
518 }
519 children
520}
521
522fn root_for(branch: &str, parents: &BTreeMap<String, String>) -> String {
523 let mut root = branch.to_owned();
524 let mut seen = BTreeSet::new();
525
526 while let Some(parent) = parents.get(&root) {
527 if !seen.insert(root.clone()) {
528 break;
529 }
530 root = parent.to_owned();
531 }
532
533 root
534}
535
536#[allow(clippy::too_many_arguments)]
537fn collect_tree_lines(
538 branch: &str,
539 current: &str,
540 trunk: Option<&str>,
541 children: &BTreeMap<String, Vec<String>>,
542 depth: usize,
543 seen: &mut BTreeSet<String>,
544 lines: &mut Vec<String>,
545) {
546 let mut line = format!("{}{}", " ".repeat(depth), branch);
547 if Some(branch) == trunk {
548 line.push_str(" (trunk)");
549 }
550 if branch == current {
551 line.push_str(" *");
552 }
553 lines.push(line);
554
555 if !seen.insert(branch.to_owned()) {
556 lines.push(format!("{}<cycle detected>", " ".repeat(depth + 1)));
557 return;
558 }
559
560 if let Some(branch_children) = children.get(branch) {
561 for child in branch_children {
562 collect_tree_lines(child, current, trunk, children, depth + 1, seen, lines);
563 }
564 }
565}
566
567fn parent_of(branch: &str) -> Result<Option<String>> {
568 git::config_get(&parent_key(branch))
569}
570
571fn base_of(branch: &str) -> Result<Option<String>> {
572 git::config_get(&base_key(branch))
573}
574
575fn set_parent(branch: &str, parent: &str) -> Result<()> {
576 git::config_set(&parent_key(branch), parent)
577}
578
579fn unset_parent(branch: &str) -> Result<()> {
580 git::config_unset(&parent_key(branch))
581}
582
583fn unset_base(branch: &str) -> Result<()> {
584 git::config_unset(&base_key(branch))
585}
586
587fn parent_key(branch: &str) -> String {
588 format!("branch.{branch}.{PARENT_KEY}")
589}
590
591fn base_key(branch: &str) -> String {
592 format!("branch.{branch}.{BASE_KEY}")
593}
594
595#[derive(Debug, Eq, PartialEq)]
596struct RestackState {
597 branch: String,
598 parent: String,
599 remaining: Vec<String>,
600 update_refs: bool,
601 push: bool,
602 all: Vec<String>,
605}
606
607impl RestackState {
608 fn read() -> Result<Option<Self>> {
609 let path = state_path()?;
610 if !path.exists() {
611 return Ok(None);
612 }
613
614 let contents = fs::read_to_string(&path)
615 .with_context(|| format!("failed to read {}", path.display()))?;
616 let mut branch = None;
617 let mut parent = None;
618 let mut remaining = Vec::new();
619 let mut update_refs = false;
620 let mut push = false;
621 let mut all = Vec::new();
622
623 for line in contents.lines() {
624 if let Some(value) = line.strip_prefix("branch=") {
625 branch = Some(value.to_owned());
626 } else if let Some(value) = line.strip_prefix("parent=") {
627 parent = Some(value.to_owned());
628 } else if let Some(value) = line.strip_prefix("updateRefs=") {
629 update_refs = value == "true";
630 } else if let Some(value) = line.strip_prefix("push=") {
631 push = value == "true";
632 } else if let Some(value) = line.strip_prefix("remaining=") {
633 remaining = value
634 .split('\t')
635 .filter(|branch| !branch.is_empty())
636 .map(str::to_owned)
637 .collect();
638 } else if let Some(value) = line.strip_prefix("all=") {
639 all = value
640 .split('\t')
641 .filter(|branch| !branch.is_empty())
642 .map(str::to_owned)
643 .collect();
644 }
645 }
646
647 let Some(branch) = branch else {
648 bail!("restack state is missing current branch");
649 };
650 let Some(parent) = parent else {
651 bail!("restack state is missing parent branch");
652 };
653
654 Ok(Some(Self {
655 branch,
656 parent,
657 remaining,
658 update_refs,
659 push,
660 all,
661 }))
662 }
663
664 fn write(&self) -> Result<()> {
665 let path = state_path()?;
666 let contents = format!(
667 "branch={}\nparent={}\nupdateRefs={}\npush={}\nremaining={}\nall={}\n",
668 self.branch,
669 self.parent,
670 self.update_refs,
671 self.push,
672 self.remaining.join("\t"),
673 self.all.join("\t")
674 );
675 fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))
676 }
677}
678
679fn clear_state() -> Result<()> {
680 let path = state_path()?;
681 if path.exists() {
682 fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
683 }
684 Ok(())
685}
686
687fn state_path() -> Result<PathBuf> {
688 Ok(PathBuf::from(git::git_path(STATE_FILE)?))
689}