1use std::{
5 collections::{BTreeMap, BTreeSet},
6 fs,
7 path::{Path, PathBuf},
8};
9
10use anyhow::{Context, Result, bail};
11
12use super::{children_map, collect_descendants, fork_point, line_base, parent_map, record_base};
13use crate::cli::{FetchMode, PushMode, UpdateRefsMode};
14use crate::git;
15use crate::prompt;
16use crate::providers::detect_review_provider;
17use crate::settings;
18use crate::style;
19
20const STATE_FILE: &str = "stack-state";
21
22pub fn restack(
23 fetch_mode: FetchMode,
24 update_refs_mode: UpdateRefsMode,
25 push_mode: PushMode,
26 dry_run: bool,
27) -> Result<()> {
28 let current = git::current_branch()?;
29 let parents = parent_map()?;
30 let base = line_base(¤t)?;
36 let branches = restack_order(&base, &parents);
37
38 if branches.is_empty() {
39 anstream::println!("{}", style::dim("nothing to restack"));
40 return Ok(());
41 }
42
43 if settings::fetch_enabled(fetch_mode)? {
47 fetch_trunk(dry_run)?;
48 }
49 warn_bases_behind_remote(&branches, &parents)?;
50
51 let update_refs = resolve_update_refs(update_refs_mode)?;
52 let push = settings::push_enabled(push_mode, settings::PUSH_ON_RESTACK_KEY)?;
53 let frozen = with_frozen_ancestors(frozen_branches(&branches), &branches, &parents);
54
55 ensure_no_worktree_blocks(&branches, &parents, &frozen, &BTreeSet::new())?;
59
60 if dry_run {
61 reconcile_diverged_remotes(&branches, &frozen, push, true)?;
62 return print_restack_plan(&branches, &parents, &frozen, update_refs, push);
63 }
64
65 super::snapshot("restack");
66 reconcile_diverged_remotes(&branches, &frozen, push, false)?;
70 clear_state()?;
71 let all = branches.clone();
72 restack_branches(branches, &parents, &frozen, update_refs, push, &all)
73}
74
75fn frozen_branches(branches: &[String]) -> BTreeSet<String> {
81 let Ok((_, provider)) = detect_review_provider() else {
82 return BTreeSet::new();
83 };
84 provider.enqueued_branches(branches).unwrap_or_default()
85}
86
87fn with_frozen_ancestors(
94 queued: BTreeSet<String>,
95 branches: &[String],
96 parents: &BTreeMap<String, String>,
97) -> BTreeSet<String> {
98 let in_set: BTreeSet<&str> = branches.iter().map(String::as_str).collect();
99 let mut frozen = queued.clone();
100 for branch in &queued {
101 let mut current = branch.clone();
102 while let Some(parent) = parents.get(¤t) {
103 if !in_set.contains(parent.as_str()) || !frozen.insert(parent.clone()) {
106 break;
107 }
108 current = parent.clone();
109 }
110 }
111 frozen
112}
113
114fn frozen_note(branch: &str) -> String {
118 format!(
119 "{} {}: not rebased or pushed (a branch in this stack is in a merge queue; dequeue it to continue)",
120 style::warn("frozen"),
121 style::branch(branch),
122 )
123}
124
125fn print_restack_plan(
128 branches: &[String],
129 parents: &BTreeMap<String, String>,
130 frozen: &BTreeSet<String>,
131 update_refs: bool,
132 push: bool,
133) -> Result<()> {
134 for branch in branches {
135 if frozen.contains(branch) {
136 anstream::println!("{}", frozen_note(branch));
137 continue;
138 }
139
140 let Some(parent) = parents.get(branch) else {
141 bail!("{branch} has no stack parent");
142 };
143
144 if up_to_date(branch, parent)? {
145 anstream::println!(
146 "{} already up to date with {}",
147 style::branch(branch),
148 style::branch(parent)
149 );
150 } else {
151 anstream::println!(
152 "would rebase {} onto {}{}",
153 style::branch(branch),
154 style::branch(parent),
155 if update_refs {
156 " with --update-refs"
157 } else {
158 ""
159 }
160 );
161 }
162 }
163
164 if push {
165 let pushable: Vec<&str> = branches
166 .iter()
167 .filter(|branch| !frozen.contains(*branch))
168 .map(String::as_str)
169 .collect();
170 if pushable.is_empty() {
171 anstream::println!(
172 "{}",
173 style::dim("nothing to push: every branch is in a merge queue")
174 );
175 } else {
176 anstream::println!(
177 "would push {} to {}",
178 style::branch(&pushable.join(" ")),
179 settings::remote()?
180 );
181 }
182 }
183 Ok(())
184}
185
186fn ensure_no_worktree_blocks(
193 branches: &[String],
194 parents: &BTreeMap<String, String>,
195 frozen: &BTreeSet<String>,
196 already_moving: &BTreeSet<String>,
197) -> Result<()> {
198 let held = git::worktree_branches()?;
199 if held.is_empty() {
200 return Ok(());
201 }
202
203 let mut rebasing: BTreeSet<&str> = already_moving.iter().map(String::as_str).collect();
209 let mut blocked = Vec::new();
210 for branch in branches {
211 if frozen.contains(branch) {
212 continue;
213 }
214 let Some(parent) = parents.get(branch) else {
215 continue;
216 };
217 if !rebasing.contains(parent.as_str()) && up_to_date(branch, parent)? {
221 continue;
222 }
223 rebasing.insert(branch.as_str());
224 if let Some((_, path)) = held.iter().find(|(name, _)| name == branch) {
225 blocked.push((branch.clone(), path.clone()));
226 }
227 }
228
229 if blocked.is_empty() {
230 return Ok(());
231 }
232
233 let held_by = git::distinct_paths(blocked.iter().map(|(_, path)| path.as_path()));
238 let here_moves = git::current_branch()
239 .ok()
240 .is_some_and(|branch| rebasing.contains(branch.as_str()));
241 let delegate = match held_by.as_slice() {
242 [only] if !here_moves => Some(only.as_path()),
243 _ => None,
244 };
245
246 bail!(worktree_block_message(&blocked, &held_by, delegate));
247}
248
249fn worktree_block_message(
254 blocked: &[(String, PathBuf)],
255 held_by: &[PathBuf],
256 delegate: Option<&Path>,
257) -> String {
258 let mut message =
259 String::from("restack would rebase branches checked out in other worktrees:\n");
260 for (branch, path) in blocked {
261 message.push_str(&format!(" {branch} in {}\n", git::describe_worktree(path)));
262 }
263 message.push_str("git cannot rebase a branch another worktree holds. Free ");
264 message.push_str(if held_by.len() == 1 { "it" } else { "each one" });
265 message.push_str(" by detaching there:\n");
266 for path in held_by {
267 message.push_str(&format!(" {}\n", git::detach_command(path)));
268 }
269 message.push_str("then check ");
270 message.push_str(if blocked.len() == 1 {
271 "the branch"
272 } else {
273 "those branches"
274 });
275 message.push_str(" out again once the restack finishes");
276 if let Some(path) = delegate {
277 message.push_str(&format!(
278 ",\nor run the restack from {} instead",
279 git::display_path(path)
280 ));
281 }
282 message
283}
284
285fn up_to_date(branch: &str, parent: &str) -> Result<bool> {
287 let parent_tip = git::rev_parse(parent)?;
288 Ok(
289 fork_point(branch, parent)?.as_deref() == Some(parent_tip.as_str())
290 && git::is_ancestor(parent, branch).unwrap_or(false),
291 )
292}
293
294fn fetch_trunk(dry_run: bool) -> Result<()> {
299 let Some(trunk) = super::trunk_branch(&git::local_branches()?) else {
300 return Ok(());
301 };
302 let remote = settings::remote()?;
303 if git::remote_url(&remote)?.is_none() {
304 anstream::println!(
305 "{}",
306 style::dim(&format!("no remote {remote}; skipped fetch"))
307 );
308 return Ok(());
309 }
310 if super::trunk_held_elsewhere(&trunk)? {
311 return Ok(());
312 }
313
314 if dry_run {
315 anstream::println!("would fetch {} from {remote}", style::branch(&trunk));
316 return Ok(());
317 }
318 if git::current_branch()? == trunk {
319 git::pull_ff_only()?;
320 } else {
321 git::fetch_branch(&remote, &trunk)?;
322 }
323 anstream::println!("fetched {} from {remote}", style::branch(&trunk));
324 Ok(())
325}
326
327fn warn_bases_behind_remote(branches: &[String], parents: &BTreeMap<String, String>) -> Result<()> {
333 let remote = settings::remote()?;
334 if git::remote_url(&remote)?.is_none() {
335 return Ok(());
336 }
337
338 let in_stack: BTreeSet<&String> = branches.iter().collect();
339 let external: BTreeSet<&String> = branches
340 .iter()
341 .filter_map(|branch| parents.get(branch))
342 .filter(|parent| !in_stack.contains(parent))
343 .collect();
344
345 for base in external {
346 let tracking = format!("{remote}/{base}");
347 if git::rev_parse(&tracking).is_err() {
348 continue;
349 }
350 let behind = git::commits_behind(base, &tracking).unwrap_or(0);
351 if behind > 0 {
352 anstream::eprintln!(
353 "{}",
354 style::warn(&format!(
355 "{base} is {behind} commit{} behind {tracking}; run `git stk restack --fetch` or `git stk sync` to update it first",
356 if behind == 1 { "" } else { "s" }
357 ))
358 );
359 }
360 }
361 Ok(())
362}
363
364fn reconcile_diverged_remotes(
382 branches: &[String],
383 frozen: &BTreeSet<String>,
384 push: bool,
385 dry_run: bool,
386) -> Result<()> {
387 if !push {
388 return Ok(());
389 }
390 let remote = settings::remote()?;
391 if git::remote_url(&remote)?.is_none() {
392 return Ok(());
393 }
394
395 let pushable: Vec<String> = branches
398 .iter()
399 .filter(|branch| !frozen.contains(*branch))
400 .cloned()
401 .collect();
402 if pushable.is_empty() {
403 return Ok(());
404 }
405
406 if !dry_run {
411 git::fetch_tracking(&remote, &pushable)?;
412 }
413
414 let mut diverged: Vec<(String, Vec<(String, String)>)> = Vec::new();
415 for branch in &pushable {
416 let tracking = format!("{remote}/{branch}");
417 if git::rev_parse(&tracking).is_err() {
420 continue;
421 }
422 let extra = git::remote_only_commits(branch, &tracking)?;
423 if extra.is_empty() {
424 continue;
425 }
426 if git::merge_adds_nothing(branch, &tracking)? {
433 continue;
434 }
435 diverged.push((branch.clone(), extra));
436 }
437
438 if diverged.is_empty() {
439 return Ok(());
440 }
441
442 for (branch, commits) in &diverged {
443 anstream::eprintln!(
444 "{}",
445 style::warn(&format!(
446 "{remote}/{branch} has {} commit{} not in your local {branch}:",
447 commits.len(),
448 if commits.len() == 1 { "" } else { "s" },
449 ))
450 );
451 for (sha, subject) in commits {
452 anstream::eprintln!(" {} {subject}", style::dim(sha));
453 }
454 }
455
456 if dry_run {
457 anstream::println!(
458 "{}",
459 style::dim(
460 "would offer to cherry-pick these into your local branches before pushing, \
461 or to discard them and overwrite the remote",
462 )
463 );
464 return Ok(());
465 }
466
467 if !prompt::confirm("cherry-pick these into your local branches before pushing? [y/N] ")? {
468 if prompt::confirm("discard them and overwrite the remote branches instead? [y/N] ")? {
475 return Ok(());
476 }
477 bail!(
478 "remote branches have commits not in your local stack\n\
479 incorporate them (`git switch <branch> && git cherry-pick <sha>`) and re-run, \
480 or discard them with `git push --force-with-lease {remote} <branch>`"
481 );
482 }
483
484 let start = git::current_branch()?;
488 for (branch, commits) in &diverged {
489 git::checkout(branch)?;
490 for (sha, _) in commits {
491 if let Err(error) = git::cherry_pick(sha) {
492 anstream::eprintln!(
493 "{}",
494 style::warn(&format!("conflict cherry-picking {sha} onto {branch}"))
495 );
496 eprintln!("resolve conflicts, run `git cherry-pick --continue`, then re-run");
497 eprintln!("or run `git cherry-pick --abort` to bail out");
498 return Err(error);
499 }
500 }
501 }
502 git::checkout(&start)?;
503 anstream::println!(
504 "{}",
505 style::success(&format!(
506 "incorporated remote commits into {}",
507 diverged
508 .iter()
509 .map(|(branch, _)| branch.as_str())
510 .collect::<Vec<_>>()
511 .join(" ")
512 ))
513 );
514 Ok(())
515}
516
517pub fn continue_restack() -> Result<()> {
518 let Some(state) = RestackState::read()? else {
519 bail!("no interrupted restack found");
520 };
521
522 if !git::rebase_in_progress() {
526 clear_state()?;
527 bail!(
528 "no rebase is in progress, so there is nothing to continue\n\
529 cleared the leftover restack state; re-run `git stk restack` to pick up where it stopped"
530 );
531 }
532
533 ensure_no_worktree_blocks(
534 &state.remaining,
535 &parent_map()?,
536 &state.frozen.iter().cloned().collect(),
537 &BTreeSet::from([state.branch.clone()]),
538 )?;
539
540 if let Err(error) = git::rebase_continue() {
541 anstream::eprintln!("{}", style::warn("restack still has conflicts"));
542 eprintln!("resolve conflicts, then run `git stk continue`");
543 eprintln!("or run `git stk abort`");
544 return Err(error);
545 }
546
547 record_base(&state.branch, &state.parent);
548
549 let frozen: BTreeSet<String> = state.frozen.iter().cloned().collect();
550 if state.remaining.is_empty() {
551 clear_state()?;
552 finish_restack(&state.all, &frozen, state.push)?;
553 return Ok(());
554 }
555
556 let parents = parent_map()?;
557 restack_branches(
558 state.remaining,
559 &parents,
560 &frozen,
561 state.update_refs,
562 state.push,
563 &state.all,
564 )
565}
566
567pub fn abort_restack() -> Result<()> {
568 if !git::rebase_in_progress() {
571 if RestackState::read()?.is_none() {
572 bail!("no restack to abort");
573 }
574 clear_state()?;
575 anstream::println!("cleared leftover restack state; no rebase was in progress");
576 return Ok(());
577 }
578
579 git::rebase_abort()?;
580 clear_state()?;
581 anstream::println!("restack aborted");
582 Ok(())
583}
584
585fn restack_order(current: &str, parents: &BTreeMap<String, String>) -> Vec<String> {
586 let children = children_map(parents);
587 let mut branches = Vec::new();
588
589 if parents.contains_key(current) {
590 branches.push(current.to_owned());
591 }
592
593 let mut visited = BTreeSet::from([current.to_owned()]);
594 collect_descendants(current, &children, &mut branches, &mut visited);
595 branches
596}
597
598fn restack_branches(
599 branches: Vec<String>,
600 parents: &BTreeMap<String, String>,
601 frozen: &BTreeSet<String>,
602 update_refs: bool,
603 push: bool,
604 all: &[String],
605) -> Result<()> {
606 for (index, branch) in branches.iter().enumerate() {
607 if frozen.contains(branch) {
608 anstream::println!("{}", frozen_note(branch));
609 continue;
610 }
611
612 let Some(parent) = parents.get(branch) else {
613 bail!("{branch} has no stack parent");
614 };
615
616 let base = fork_point(branch, parent)?;
621
622 if up_to_date(branch, parent)? {
626 anstream::println!(
627 "{} already up to date with {}",
628 style::branch(branch),
629 style::branch(parent)
630 );
631 continue;
632 }
633
634 if update_refs {
635 anstream::println!(
636 "rebasing {} onto {} with --update-refs",
637 style::branch(branch),
638 style::branch(parent)
639 );
640 } else {
641 anstream::println!(
642 "rebasing {} onto {}",
643 style::branch(branch),
644 style::branch(parent)
645 );
646 }
647 let rebase_result = match &base {
648 Some(base) => git::rebase_onto(parent, base, branch, update_refs),
649 None => git::rebase(parent, branch, update_refs),
650 };
651
652 if let Err(error) = rebase_result {
653 if !git::rebase_in_progress() {
657 return Err(error);
658 }
659
660 let remaining = branches[index + 1..].to_vec();
661 RestackState {
662 branch: branch.to_owned(),
663 parent: parent.to_owned(),
664 remaining,
665 update_refs,
666 push,
667 all: all.to_vec(),
668 frozen: frozen.iter().cloned().collect(),
669 }
670 .write()?;
671
672 anstream::eprintln!(
673 "{}",
674 style::warn(&format!("conflict while rebasing {branch} onto {parent}"))
675 );
676 eprintln!("resolve conflicts, then run `git stk continue`");
677 eprintln!("or run `git stk abort`");
678 return Err(error);
679 }
680
681 record_base(branch, parent);
682 }
683
684 clear_state()?;
685 finish_restack(all, frozen, push)
686}
687
688fn finish_restack(branches: &[String], frozen: &BTreeSet<String>, push: bool) -> Result<()> {
694 anstream::println!("{}", style::success("restack complete"));
695
696 let remote = settings::remote()?;
697 let pushable: Vec<String> = branches
698 .iter()
699 .filter(|branch| !frozen.contains(*branch))
700 .cloned()
701 .collect();
702 if pushable.is_empty() {
703 anstream::println!(
704 "{}",
705 style::dim("nothing to push: every branch is in a merge queue")
706 );
707 return Ok(());
708 }
709
710 if push {
711 let pushed = git::push_force_with_lease(&remote, &pushable)?;
715 if pushed.is_empty() {
716 anstream::println!(
717 "{}",
718 style::dim("nothing pushed: every branch is in a merge queue")
719 );
720 } else {
721 anstream::println!("pushed {} to {remote}", style::branch(&pushed.join(" ")));
722 super::publish_metadata(&remote);
724 }
725 } else {
726 anstream::println!("remote branches may be stale; push them with:");
727 anstream::println!(
728 "{}",
729 style::dim(&format!(
730 " git push --force-with-lease {remote} {}",
731 pushable.join(" ")
732 ))
733 );
734 }
735 Ok(())
736}
737
738fn resolve_update_refs(mode: UpdateRefsMode) -> Result<bool> {
739 match mode {
740 UpdateRefsMode::Config => {
741 let configured = git::config_get_bool(settings::UPDATE_REFS_KEY)?.unwrap_or(false);
742 if configured && !git::supports_rebase_update_refs()? {
743 eprintln!("stk.updateRefs is true, but this Git does not support --update-refs");
744 return Ok(false);
745 }
746 Ok(configured)
747 }
748 UpdateRefsMode::Enabled => {
749 if !git::supports_rebase_update_refs()? {
750 bail!("--update-refs was requested, but this Git does not support it");
751 }
752 Ok(true)
753 }
754 UpdateRefsMode::Disabled => Ok(false),
755 }
756}
757
758#[derive(Debug, Eq, PartialEq)]
759struct RestackState {
760 branch: String,
761 parent: String,
762 remaining: Vec<String>,
763 update_refs: bool,
764 push: bool,
765 all: Vec<String>,
768 frozen: Vec<String>,
771}
772
773impl RestackState {
774 fn read() -> Result<Option<Self>> {
775 let path = state_path()?;
776 if !path.exists() {
777 return Ok(None);
778 }
779
780 let contents = fs::read_to_string(&path)
781 .with_context(|| format!("failed to read {}", path.display()))?;
782 let mut branch = None;
783 let mut parent = None;
784 let mut remaining = Vec::new();
785 let mut update_refs = false;
786 let mut push = false;
787 let mut all = Vec::new();
788 let mut frozen = Vec::new();
789
790 for line in contents.lines() {
791 if let Some(value) = line.strip_prefix("branch=") {
792 branch = Some(value.to_owned());
793 } else if let Some(value) = line.strip_prefix("parent=") {
794 parent = Some(value.to_owned());
795 } else if let Some(value) = line.strip_prefix("updateRefs=") {
796 update_refs = value == "true";
797 } else if let Some(value) = line.strip_prefix("push=") {
798 push = value == "true";
799 } else if let Some(value) = line.strip_prefix("remaining=") {
800 remaining = value
801 .split('\t')
802 .filter(|branch| !branch.is_empty())
803 .map(str::to_owned)
804 .collect();
805 } else if let Some(value) = line.strip_prefix("all=") {
806 all = value
807 .split('\t')
808 .filter(|branch| !branch.is_empty())
809 .map(str::to_owned)
810 .collect();
811 } else if let Some(value) = line.strip_prefix("frozen=") {
812 frozen = value
813 .split('\t')
814 .filter(|branch| !branch.is_empty())
815 .map(str::to_owned)
816 .collect();
817 }
818 }
819
820 let Some(branch) = branch else {
821 bail!("restack state is missing current branch");
822 };
823 let Some(parent) = parent else {
824 bail!("restack state is missing parent branch");
825 };
826
827 Ok(Some(Self {
828 branch,
829 parent,
830 remaining,
831 update_refs,
832 push,
833 all,
834 frozen,
835 }))
836 }
837
838 fn write(&self) -> Result<()> {
839 let path = state_path()?;
840 let contents = format!(
841 "branch={}\nparent={}\nupdateRefs={}\npush={}\nremaining={}\nall={}\nfrozen={}\n",
842 self.branch,
843 self.parent,
844 self.update_refs,
845 self.push,
846 self.remaining.join("\t"),
847 self.all.join("\t"),
848 self.frozen.join("\t")
849 );
850 fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))
851 }
852}
853
854fn clear_state() -> Result<()> {
855 let path = state_path()?;
856 if path.exists() {
857 fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
858 }
859 Ok(())
860}
861
862fn state_path() -> Result<PathBuf> {
863 Ok(PathBuf::from(git::git_path(STATE_FILE)?))
864}
865
866pub(super) fn in_progress() -> bool {
871 state_path().map(|path| path.exists()).unwrap_or(false) && git::rebase_in_progress()
872}
873
874#[cfg(test)]
875mod tests {
876 use super::*;
877
878 fn linear_parents() -> BTreeMap<String, String> {
881 BTreeMap::from([
882 ("a".to_owned(), "main".to_owned()),
883 ("b".to_owned(), "a".to_owned()),
884 ("c".to_owned(), "b".to_owned()),
885 ])
886 }
887
888 fn set(branches: &[&str]) -> BTreeSet<String> {
889 branches.iter().map(|b| (*b).to_owned()).collect()
890 }
891
892 #[test]
893 fn a_queued_middle_branch_freezes_everything_below_it() {
894 let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
897 let frozen = with_frozen_ancestors(set(&["b"]), &branches, &linear_parents());
898 assert_eq!(frozen, set(&["a", "b"]));
899 }
900
901 #[test]
902 fn a_queued_bottom_branch_freezes_only_itself() {
903 let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
906 let frozen = with_frozen_ancestors(set(&["a"]), &branches, &linear_parents());
907 assert_eq!(frozen, set(&["a"]));
908 }
909
910 #[test]
911 fn freeze_stops_at_the_line_base_not_the_trunk() {
912 let branches = vec!["b".to_owned(), "c".to_owned()];
915 let frozen = with_frozen_ancestors(set(&["c"]), &branches, &linear_parents());
916 assert_eq!(frozen, set(&["b", "c"]));
917 }
918
919 #[test]
920 fn nothing_queued_freezes_nothing() {
921 let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
922 let frozen = with_frozen_ancestors(BTreeSet::new(), &branches, &linear_parents());
923 assert!(frozen.is_empty());
924 }
925}