1use std::{
5 collections::{BTreeMap, BTreeSet},
6 fs,
7 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 mut message =
234 String::from("restack would rebase branches checked out in other worktrees:\n");
235 for (branch, path) in &blocked {
236 message.push_str(&format!(" {branch} in {}\n", git::display_path(path)));
237 }
238 message.push_str(
239 "git cannot rebase a branch another worktree holds; free them with \
240 `git worktree remove <path>`, or run the restack from that worktree",
241 );
242 bail!(message);
243}
244
245fn up_to_date(branch: &str, parent: &str) -> Result<bool> {
247 let parent_tip = git::rev_parse(parent)?;
248 Ok(
249 fork_point(branch, parent)?.as_deref() == Some(parent_tip.as_str())
250 && git::is_ancestor(parent, branch).unwrap_or(false),
251 )
252}
253
254fn fetch_trunk(dry_run: bool) -> Result<()> {
259 let Some(trunk) = super::trunk_branch(&git::local_branches()?) else {
260 return Ok(());
261 };
262 let remote = settings::remote()?;
263 if git::remote_url(&remote)?.is_none() {
264 anstream::println!(
265 "{}",
266 style::dim(&format!("no remote {remote}; skipped fetch"))
267 );
268 return Ok(());
269 }
270 if super::trunk_held_elsewhere(&trunk)? {
271 return Ok(());
272 }
273
274 if dry_run {
275 anstream::println!("would fetch {} from {remote}", style::branch(&trunk));
276 return Ok(());
277 }
278 if git::current_branch()? == trunk {
279 git::pull_ff_only()?;
280 } else {
281 git::fetch_branch(&remote, &trunk)?;
282 }
283 anstream::println!("fetched {} from {remote}", style::branch(&trunk));
284 Ok(())
285}
286
287fn warn_bases_behind_remote(branches: &[String], parents: &BTreeMap<String, String>) -> Result<()> {
293 let remote = settings::remote()?;
294 if git::remote_url(&remote)?.is_none() {
295 return Ok(());
296 }
297
298 let in_stack: BTreeSet<&String> = branches.iter().collect();
299 let external: BTreeSet<&String> = branches
300 .iter()
301 .filter_map(|branch| parents.get(branch))
302 .filter(|parent| !in_stack.contains(parent))
303 .collect();
304
305 for base in external {
306 let tracking = format!("{remote}/{base}");
307 if git::rev_parse(&tracking).is_err() {
308 continue;
309 }
310 let behind = git::commits_behind(base, &tracking).unwrap_or(0);
311 if behind > 0 {
312 anstream::eprintln!(
313 "{}",
314 style::warn(&format!(
315 "{base} is {behind} commit{} behind {tracking}; run `git stk restack --fetch` or `git stk sync` to update it first",
316 if behind == 1 { "" } else { "s" }
317 ))
318 );
319 }
320 }
321 Ok(())
322}
323
324fn reconcile_diverged_remotes(
337 branches: &[String],
338 frozen: &BTreeSet<String>,
339 push: bool,
340 dry_run: bool,
341) -> Result<()> {
342 if !push {
343 return Ok(());
344 }
345 let remote = settings::remote()?;
346 if git::remote_url(&remote)?.is_none() {
347 return Ok(());
348 }
349
350 let pushable: Vec<String> = branches
353 .iter()
354 .filter(|branch| !frozen.contains(*branch))
355 .cloned()
356 .collect();
357 if pushable.is_empty() {
358 return Ok(());
359 }
360
361 if !dry_run {
366 git::fetch_tracking(&remote, &pushable)?;
367 }
368
369 let mut diverged: Vec<(String, Vec<(String, String)>)> = Vec::new();
370 for branch in &pushable {
371 let tracking = format!("{remote}/{branch}");
372 if git::rev_parse(&tracking).is_err() {
375 continue;
376 }
377 let extra = git::remote_only_commits(branch, &tracking)?;
378 if !extra.is_empty() {
379 diverged.push((branch.clone(), extra));
380 }
381 }
382
383 if diverged.is_empty() {
384 return Ok(());
385 }
386
387 for (branch, commits) in &diverged {
388 anstream::eprintln!(
389 "{}",
390 style::warn(&format!(
391 "{remote}/{branch} has {} commit{} not in your local {branch}:",
392 commits.len(),
393 if commits.len() == 1 { "" } else { "s" },
394 ))
395 );
396 for (sha, subject) in commits {
397 anstream::eprintln!(" {} {subject}", style::dim(sha));
398 }
399 }
400
401 if dry_run {
402 anstream::println!(
403 "{}",
404 style::dim("would offer to cherry-pick these into your local branches before pushing")
405 );
406 return Ok(());
407 }
408
409 if !prompt::confirm("cherry-pick these into your local branches before pushing? [y/N] ")? {
410 bail!(
411 "remote branches have commits not in your local stack\n\
412 incorporate them (`git switch <branch> && git cherry-pick <sha>`) and re-run, \
413 or discard them with `git push --force {remote} <branch>`"
414 );
415 }
416
417 let start = git::current_branch()?;
421 for (branch, commits) in &diverged {
422 git::checkout(branch)?;
423 for (sha, _) in commits {
424 if let Err(error) = git::cherry_pick(sha) {
425 anstream::eprintln!(
426 "{}",
427 style::warn(&format!("conflict cherry-picking {sha} onto {branch}"))
428 );
429 eprintln!("resolve conflicts, run `git cherry-pick --continue`, then re-run");
430 eprintln!("or run `git cherry-pick --abort` to bail out");
431 return Err(error);
432 }
433 }
434 }
435 git::checkout(&start)?;
436 anstream::println!(
437 "{}",
438 style::success(&format!(
439 "incorporated remote commits into {}",
440 diverged
441 .iter()
442 .map(|(branch, _)| branch.as_str())
443 .collect::<Vec<_>>()
444 .join(" ")
445 ))
446 );
447 Ok(())
448}
449
450pub fn continue_restack() -> Result<()> {
451 let Some(state) = RestackState::read()? else {
452 bail!("no interrupted restack found");
453 };
454
455 if !git::rebase_in_progress() {
459 clear_state()?;
460 bail!(
461 "no rebase is in progress, so there is nothing to continue\n\
462 cleared the leftover restack state; re-run `git stk restack` to pick up where it stopped"
463 );
464 }
465
466 ensure_no_worktree_blocks(
467 &state.remaining,
468 &parent_map()?,
469 &state.frozen.iter().cloned().collect(),
470 &BTreeSet::from([state.branch.clone()]),
471 )?;
472
473 if let Err(error) = git::rebase_continue() {
474 anstream::eprintln!("{}", style::warn("restack still has conflicts"));
475 eprintln!("resolve conflicts, then run `git stk continue`");
476 eprintln!("or run `git stk abort`");
477 return Err(error);
478 }
479
480 record_base(&state.branch, &state.parent);
481
482 let frozen: BTreeSet<String> = state.frozen.iter().cloned().collect();
483 if state.remaining.is_empty() {
484 clear_state()?;
485 finish_restack(&state.all, &frozen, state.push)?;
486 return Ok(());
487 }
488
489 let parents = parent_map()?;
490 restack_branches(
491 state.remaining,
492 &parents,
493 &frozen,
494 state.update_refs,
495 state.push,
496 &state.all,
497 )
498}
499
500pub fn abort_restack() -> Result<()> {
501 if !git::rebase_in_progress() {
504 if RestackState::read()?.is_none() {
505 bail!("no restack to abort");
506 }
507 clear_state()?;
508 anstream::println!("cleared leftover restack state; no rebase was in progress");
509 return Ok(());
510 }
511
512 git::rebase_abort()?;
513 clear_state()?;
514 anstream::println!("restack aborted");
515 Ok(())
516}
517
518fn restack_order(current: &str, parents: &BTreeMap<String, String>) -> Vec<String> {
519 let children = children_map(parents);
520 let mut branches = Vec::new();
521
522 if parents.contains_key(current) {
523 branches.push(current.to_owned());
524 }
525
526 let mut visited = BTreeSet::from([current.to_owned()]);
527 collect_descendants(current, &children, &mut branches, &mut visited);
528 branches
529}
530
531fn restack_branches(
532 branches: Vec<String>,
533 parents: &BTreeMap<String, String>,
534 frozen: &BTreeSet<String>,
535 update_refs: bool,
536 push: bool,
537 all: &[String],
538) -> Result<()> {
539 for (index, branch) in branches.iter().enumerate() {
540 if frozen.contains(branch) {
541 anstream::println!("{}", frozen_note(branch));
542 continue;
543 }
544
545 let Some(parent) = parents.get(branch) else {
546 bail!("{branch} has no stack parent");
547 };
548
549 let base = fork_point(branch, parent)?;
554
555 if up_to_date(branch, parent)? {
559 anstream::println!(
560 "{} already up to date with {}",
561 style::branch(branch),
562 style::branch(parent)
563 );
564 continue;
565 }
566
567 if update_refs {
568 anstream::println!(
569 "rebasing {} onto {} with --update-refs",
570 style::branch(branch),
571 style::branch(parent)
572 );
573 } else {
574 anstream::println!(
575 "rebasing {} onto {}",
576 style::branch(branch),
577 style::branch(parent)
578 );
579 }
580 let rebase_result = match &base {
581 Some(base) => git::rebase_onto(parent, base, branch, update_refs),
582 None => git::rebase(parent, branch, update_refs),
583 };
584
585 if let Err(error) = rebase_result {
586 if !git::rebase_in_progress() {
590 return Err(error);
591 }
592
593 let remaining = branches[index + 1..].to_vec();
594 RestackState {
595 branch: branch.to_owned(),
596 parent: parent.to_owned(),
597 remaining,
598 update_refs,
599 push,
600 all: all.to_vec(),
601 frozen: frozen.iter().cloned().collect(),
602 }
603 .write()?;
604
605 anstream::eprintln!(
606 "{}",
607 style::warn(&format!("conflict while rebasing {branch} onto {parent}"))
608 );
609 eprintln!("resolve conflicts, then run `git stk continue`");
610 eprintln!("or run `git stk abort`");
611 return Err(error);
612 }
613
614 record_base(branch, parent);
615 }
616
617 clear_state()?;
618 finish_restack(all, frozen, push)
619}
620
621fn finish_restack(branches: &[String], frozen: &BTreeSet<String>, push: bool) -> Result<()> {
627 anstream::println!("{}", style::success("restack complete"));
628
629 let remote = settings::remote()?;
630 let pushable: Vec<String> = branches
631 .iter()
632 .filter(|branch| !frozen.contains(*branch))
633 .cloned()
634 .collect();
635 if pushable.is_empty() {
636 anstream::println!(
637 "{}",
638 style::dim("nothing to push: every branch is in a merge queue")
639 );
640 return Ok(());
641 }
642
643 if push {
644 let pushed = git::push_force_with_lease(&remote, &pushable)?;
648 if pushed.is_empty() {
649 anstream::println!(
650 "{}",
651 style::dim("nothing pushed: every branch is in a merge queue")
652 );
653 } else {
654 anstream::println!("pushed {} to {remote}", style::branch(&pushed.join(" ")));
655 super::publish_metadata(&remote);
657 }
658 } else {
659 anstream::println!("remote branches may be stale; push them with:");
660 anstream::println!(
661 "{}",
662 style::dim(&format!(
663 " git push --force-with-lease {remote} {}",
664 pushable.join(" ")
665 ))
666 );
667 }
668 Ok(())
669}
670
671fn resolve_update_refs(mode: UpdateRefsMode) -> Result<bool> {
672 match mode {
673 UpdateRefsMode::Config => {
674 let configured = git::config_get_bool(settings::UPDATE_REFS_KEY)?.unwrap_or(false);
675 if configured && !git::supports_rebase_update_refs()? {
676 eprintln!("stk.updateRefs is true, but this Git does not support --update-refs");
677 return Ok(false);
678 }
679 Ok(configured)
680 }
681 UpdateRefsMode::Enabled => {
682 if !git::supports_rebase_update_refs()? {
683 bail!("--update-refs was requested, but this Git does not support it");
684 }
685 Ok(true)
686 }
687 UpdateRefsMode::Disabled => Ok(false),
688 }
689}
690
691#[derive(Debug, Eq, PartialEq)]
692struct RestackState {
693 branch: String,
694 parent: String,
695 remaining: Vec<String>,
696 update_refs: bool,
697 push: bool,
698 all: Vec<String>,
701 frozen: Vec<String>,
704}
705
706impl RestackState {
707 fn read() -> Result<Option<Self>> {
708 let path = state_path()?;
709 if !path.exists() {
710 return Ok(None);
711 }
712
713 let contents = fs::read_to_string(&path)
714 .with_context(|| format!("failed to read {}", path.display()))?;
715 let mut branch = None;
716 let mut parent = None;
717 let mut remaining = Vec::new();
718 let mut update_refs = false;
719 let mut push = false;
720 let mut all = Vec::new();
721 let mut frozen = Vec::new();
722
723 for line in contents.lines() {
724 if let Some(value) = line.strip_prefix("branch=") {
725 branch = Some(value.to_owned());
726 } else if let Some(value) = line.strip_prefix("parent=") {
727 parent = Some(value.to_owned());
728 } else if let Some(value) = line.strip_prefix("updateRefs=") {
729 update_refs = value == "true";
730 } else if let Some(value) = line.strip_prefix("push=") {
731 push = value == "true";
732 } else if let Some(value) = line.strip_prefix("remaining=") {
733 remaining = value
734 .split('\t')
735 .filter(|branch| !branch.is_empty())
736 .map(str::to_owned)
737 .collect();
738 } else if let Some(value) = line.strip_prefix("all=") {
739 all = value
740 .split('\t')
741 .filter(|branch| !branch.is_empty())
742 .map(str::to_owned)
743 .collect();
744 } else if let Some(value) = line.strip_prefix("frozen=") {
745 frozen = value
746 .split('\t')
747 .filter(|branch| !branch.is_empty())
748 .map(str::to_owned)
749 .collect();
750 }
751 }
752
753 let Some(branch) = branch else {
754 bail!("restack state is missing current branch");
755 };
756 let Some(parent) = parent else {
757 bail!("restack state is missing parent branch");
758 };
759
760 Ok(Some(Self {
761 branch,
762 parent,
763 remaining,
764 update_refs,
765 push,
766 all,
767 frozen,
768 }))
769 }
770
771 fn write(&self) -> Result<()> {
772 let path = state_path()?;
773 let contents = format!(
774 "branch={}\nparent={}\nupdateRefs={}\npush={}\nremaining={}\nall={}\nfrozen={}\n",
775 self.branch,
776 self.parent,
777 self.update_refs,
778 self.push,
779 self.remaining.join("\t"),
780 self.all.join("\t"),
781 self.frozen.join("\t")
782 );
783 fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))
784 }
785}
786
787fn clear_state() -> Result<()> {
788 let path = state_path()?;
789 if path.exists() {
790 fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
791 }
792 Ok(())
793}
794
795fn state_path() -> Result<PathBuf> {
796 Ok(PathBuf::from(git::git_path(STATE_FILE)?))
797}
798
799pub(super) fn in_progress() -> bool {
804 state_path().map(|path| path.exists()).unwrap_or(false) && git::rebase_in_progress()
805}
806
807#[cfg(test)]
808mod tests {
809 use super::*;
810
811 fn linear_parents() -> BTreeMap<String, String> {
814 BTreeMap::from([
815 ("a".to_owned(), "main".to_owned()),
816 ("b".to_owned(), "a".to_owned()),
817 ("c".to_owned(), "b".to_owned()),
818 ])
819 }
820
821 fn set(branches: &[&str]) -> BTreeSet<String> {
822 branches.iter().map(|b| (*b).to_owned()).collect()
823 }
824
825 #[test]
826 fn a_queued_middle_branch_freezes_everything_below_it() {
827 let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
830 let frozen = with_frozen_ancestors(set(&["b"]), &branches, &linear_parents());
831 assert_eq!(frozen, set(&["a", "b"]));
832 }
833
834 #[test]
835 fn a_queued_bottom_branch_freezes_only_itself() {
836 let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
839 let frozen = with_frozen_ancestors(set(&["a"]), &branches, &linear_parents());
840 assert_eq!(frozen, set(&["a"]));
841 }
842
843 #[test]
844 fn freeze_stops_at_the_line_base_not_the_trunk() {
845 let branches = vec!["b".to_owned(), "c".to_owned()];
848 let frozen = with_frozen_ancestors(set(&["c"]), &branches, &linear_parents());
849 assert_eq!(frozen, set(&["b", "c"]));
850 }
851
852 #[test]
853 fn nothing_queued_freezes_nothing() {
854 let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
855 let frozen = with_frozen_ancestors(BTreeSet::new(), &branches, &linear_parents());
856 assert!(frozen.is_empty());
857 }
858}