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 if dry_run {
56 reconcile_diverged_remotes(&branches, &frozen, push, true)?;
57 return print_restack_plan(&branches, &parents, &frozen, update_refs, push);
58 }
59
60 super::snapshot("restack");
61 reconcile_diverged_remotes(&branches, &frozen, push, false)?;
65 clear_state()?;
66 let all = branches.clone();
67 restack_branches(branches, &parents, &frozen, update_refs, push, &all)
68}
69
70fn frozen_branches(branches: &[String]) -> BTreeSet<String> {
76 let Ok((_, provider)) = detect_review_provider() else {
77 return BTreeSet::new();
78 };
79 provider.enqueued_branches(branches).unwrap_or_default()
80}
81
82fn with_frozen_ancestors(
89 queued: BTreeSet<String>,
90 branches: &[String],
91 parents: &BTreeMap<String, String>,
92) -> BTreeSet<String> {
93 let in_set: BTreeSet<&str> = branches.iter().map(String::as_str).collect();
94 let mut frozen = queued.clone();
95 for branch in &queued {
96 let mut current = branch.clone();
97 while let Some(parent) = parents.get(¤t) {
98 if !in_set.contains(parent.as_str()) || !frozen.insert(parent.clone()) {
101 break;
102 }
103 current = parent.clone();
104 }
105 }
106 frozen
107}
108
109fn frozen_note(branch: &str) -> String {
113 format!(
114 "{} {}: not rebased or pushed (a branch in this stack is in a merge queue; dequeue it to continue)",
115 style::warn("frozen"),
116 style::branch(branch),
117 )
118}
119
120fn print_restack_plan(
123 branches: &[String],
124 parents: &BTreeMap<String, String>,
125 frozen: &BTreeSet<String>,
126 update_refs: bool,
127 push: bool,
128) -> Result<()> {
129 for branch in branches {
130 if frozen.contains(branch) {
131 anstream::println!("{}", frozen_note(branch));
132 continue;
133 }
134
135 let Some(parent) = parents.get(branch) else {
136 bail!("{branch} has no stack parent");
137 };
138
139 if up_to_date(branch, parent)? {
140 anstream::println!(
141 "{} already up to date with {}",
142 style::branch(branch),
143 style::branch(parent)
144 );
145 } else {
146 anstream::println!(
147 "would rebase {} onto {}{}",
148 style::branch(branch),
149 style::branch(parent),
150 if update_refs {
151 " with --update-refs"
152 } else {
153 ""
154 }
155 );
156 }
157 }
158
159 if push {
160 let pushable: Vec<&str> = branches
161 .iter()
162 .filter(|branch| !frozen.contains(*branch))
163 .map(String::as_str)
164 .collect();
165 if pushable.is_empty() {
166 anstream::println!(
167 "{}",
168 style::dim("nothing to push: every branch is in a merge queue")
169 );
170 } else {
171 anstream::println!(
172 "would push {} to {}",
173 style::branch(&pushable.join(" ")),
174 settings::remote()?
175 );
176 }
177 }
178 Ok(())
179}
180
181fn up_to_date(branch: &str, parent: &str) -> Result<bool> {
183 let parent_tip = git::rev_parse(parent)?;
184 Ok(
185 fork_point(branch, parent)?.as_deref() == Some(parent_tip.as_str())
186 && git::is_ancestor(parent, branch).unwrap_or(false),
187 )
188}
189
190fn fetch_trunk(dry_run: bool) -> Result<()> {
195 let Some(trunk) = super::trunk_branch(&git::local_branches()?) else {
196 return Ok(());
197 };
198 let remote = settings::remote()?;
199 if git::remote_url(&remote)?.is_none() {
200 anstream::println!(
201 "{}",
202 style::dim(&format!("no remote {remote}; skipped fetch"))
203 );
204 return Ok(());
205 }
206 if dry_run {
207 anstream::println!("would fetch {} from {remote}", style::branch(&trunk));
208 return Ok(());
209 }
210 if git::current_branch()? == trunk {
211 git::pull_ff_only()?;
212 } else {
213 git::fetch_branch(&remote, &trunk)?;
214 }
215 anstream::println!("fetched {} from {remote}", style::branch(&trunk));
216 Ok(())
217}
218
219fn warn_bases_behind_remote(branches: &[String], parents: &BTreeMap<String, String>) -> Result<()> {
225 let remote = settings::remote()?;
226 if git::remote_url(&remote)?.is_none() {
227 return Ok(());
228 }
229
230 let in_stack: BTreeSet<&String> = branches.iter().collect();
231 let external: BTreeSet<&String> = branches
232 .iter()
233 .filter_map(|branch| parents.get(branch))
234 .filter(|parent| !in_stack.contains(parent))
235 .collect();
236
237 for base in external {
238 let tracking = format!("{remote}/{base}");
239 if git::rev_parse(&tracking).is_err() {
240 continue;
241 }
242 let behind = git::commits_behind(base, &tracking).unwrap_or(0);
243 if behind > 0 {
244 anstream::eprintln!(
245 "{}",
246 style::warn(&format!(
247 "{base} is {behind} commit{} behind {tracking}; run `git stk restack --fetch` or `git stk sync` to update it first",
248 if behind == 1 { "" } else { "s" }
249 ))
250 );
251 }
252 }
253 Ok(())
254}
255
256fn reconcile_diverged_remotes(
269 branches: &[String],
270 frozen: &BTreeSet<String>,
271 push: bool,
272 dry_run: bool,
273) -> Result<()> {
274 if !push {
275 return Ok(());
276 }
277 let remote = settings::remote()?;
278 if git::remote_url(&remote)?.is_none() {
279 return Ok(());
280 }
281
282 let pushable: Vec<String> = branches
285 .iter()
286 .filter(|branch| !frozen.contains(*branch))
287 .cloned()
288 .collect();
289 if pushable.is_empty() {
290 return Ok(());
291 }
292
293 if !dry_run {
298 git::fetch_tracking(&remote, &pushable)?;
299 }
300
301 let mut diverged: Vec<(String, Vec<(String, String)>)> = Vec::new();
302 for branch in &pushable {
303 let tracking = format!("{remote}/{branch}");
304 if git::rev_parse(&tracking).is_err() {
307 continue;
308 }
309 let extra = git::remote_only_commits(branch, &tracking)?;
310 if !extra.is_empty() {
311 diverged.push((branch.clone(), extra));
312 }
313 }
314
315 if diverged.is_empty() {
316 return Ok(());
317 }
318
319 for (branch, commits) in &diverged {
320 anstream::eprintln!(
321 "{}",
322 style::warn(&format!(
323 "{remote}/{branch} has {} commit{} not in your local {branch}:",
324 commits.len(),
325 if commits.len() == 1 { "" } else { "s" },
326 ))
327 );
328 for (sha, subject) in commits {
329 anstream::eprintln!(" {} {subject}", style::dim(sha));
330 }
331 }
332
333 if dry_run {
334 anstream::println!(
335 "{}",
336 style::dim("would offer to cherry-pick these into your local branches before pushing")
337 );
338 return Ok(());
339 }
340
341 if !prompt::confirm("cherry-pick these into your local branches before pushing? [y/N] ")? {
342 bail!(
343 "remote branches have commits not in your local stack\n\
344 incorporate them (`git switch <branch> && git cherry-pick <sha>`) and re-run, \
345 or discard them with `git push --force {remote} <branch>`"
346 );
347 }
348
349 let start = git::current_branch()?;
353 for (branch, commits) in &diverged {
354 git::checkout(branch)?;
355 for (sha, _) in commits {
356 if let Err(error) = git::cherry_pick(sha) {
357 anstream::eprintln!(
358 "{}",
359 style::warn(&format!("conflict cherry-picking {sha} onto {branch}"))
360 );
361 eprintln!("resolve conflicts, run `git cherry-pick --continue`, then re-run");
362 eprintln!("or run `git cherry-pick --abort` to bail out");
363 return Err(error);
364 }
365 }
366 }
367 git::checkout(&start)?;
368 anstream::println!(
369 "{}",
370 style::success(&format!(
371 "incorporated remote commits into {}",
372 diverged
373 .iter()
374 .map(|(branch, _)| branch.as_str())
375 .collect::<Vec<_>>()
376 .join(" ")
377 ))
378 );
379 Ok(())
380}
381
382pub fn continue_restack() -> Result<()> {
383 let Some(state) = RestackState::read()? else {
384 bail!("no interrupted restack found");
385 };
386
387 if let Err(error) = git::rebase_continue() {
388 anstream::eprintln!("{}", style::warn("restack still has conflicts"));
389 eprintln!("resolve conflicts, then run `git stk continue`");
390 eprintln!("or run `git stk abort`");
391 return Err(error);
392 }
393
394 record_base(&state.branch, &state.parent);
395
396 let frozen: BTreeSet<String> = state.frozen.iter().cloned().collect();
397 if state.remaining.is_empty() {
398 clear_state()?;
399 finish_restack(&state.all, &frozen, state.push)?;
400 return Ok(());
401 }
402
403 let parents = parent_map()?;
404 restack_branches(
405 state.remaining,
406 &parents,
407 &frozen,
408 state.update_refs,
409 state.push,
410 &state.all,
411 )
412}
413
414pub fn abort_restack() -> Result<()> {
415 git::rebase_abort()?;
416 clear_state()?;
417 anstream::println!("restack aborted");
418 Ok(())
419}
420
421fn restack_order(current: &str, parents: &BTreeMap<String, String>) -> Vec<String> {
422 let children = children_map(parents);
423 let mut branches = Vec::new();
424
425 if parents.contains_key(current) {
426 branches.push(current.to_owned());
427 }
428
429 let mut visited = BTreeSet::from([current.to_owned()]);
430 collect_descendants(current, &children, &mut branches, &mut visited);
431 branches
432}
433
434fn restack_branches(
435 branches: Vec<String>,
436 parents: &BTreeMap<String, String>,
437 frozen: &BTreeSet<String>,
438 update_refs: bool,
439 push: bool,
440 all: &[String],
441) -> Result<()> {
442 for (index, branch) in branches.iter().enumerate() {
443 if frozen.contains(branch) {
444 anstream::println!("{}", frozen_note(branch));
445 continue;
446 }
447
448 let Some(parent) = parents.get(branch) else {
449 bail!("{branch} has no stack parent");
450 };
451
452 let base = fork_point(branch, parent)?;
457
458 if up_to_date(branch, parent)? {
462 anstream::println!(
463 "{} already up to date with {}",
464 style::branch(branch),
465 style::branch(parent)
466 );
467 continue;
468 }
469
470 if update_refs {
471 anstream::println!(
472 "rebasing {} onto {} with --update-refs",
473 style::branch(branch),
474 style::branch(parent)
475 );
476 } else {
477 anstream::println!(
478 "rebasing {} onto {}",
479 style::branch(branch),
480 style::branch(parent)
481 );
482 }
483 let rebase_result = match &base {
484 Some(base) => git::rebase_onto(parent, base, branch, update_refs),
485 None => git::rebase(parent, branch, update_refs),
486 };
487
488 if let Err(error) = rebase_result {
489 let remaining = branches[index + 1..].to_vec();
490 RestackState {
491 branch: branch.to_owned(),
492 parent: parent.to_owned(),
493 remaining,
494 update_refs,
495 push,
496 all: all.to_vec(),
497 frozen: frozen.iter().cloned().collect(),
498 }
499 .write()?;
500
501 anstream::eprintln!(
502 "{}",
503 style::warn(&format!("conflict while rebasing {branch} onto {parent}"))
504 );
505 eprintln!("resolve conflicts, then run `git stk continue`");
506 eprintln!("or run `git stk abort`");
507 return Err(error);
508 }
509
510 record_base(branch, parent);
511 }
512
513 clear_state()?;
514 finish_restack(all, frozen, push)
515}
516
517fn finish_restack(branches: &[String], frozen: &BTreeSet<String>, push: bool) -> Result<()> {
523 anstream::println!("{}", style::success("restack complete"));
524
525 let remote = settings::remote()?;
526 let pushable: Vec<String> = branches
527 .iter()
528 .filter(|branch| !frozen.contains(*branch))
529 .cloned()
530 .collect();
531 if pushable.is_empty() {
532 anstream::println!(
533 "{}",
534 style::dim("nothing to push: every branch is in a merge queue")
535 );
536 return Ok(());
537 }
538
539 if push {
540 let pushed = git::push_force_with_lease(&remote, &pushable)?;
544 if pushed.is_empty() {
545 anstream::println!(
546 "{}",
547 style::dim("nothing pushed: every branch is in a merge queue")
548 );
549 } else {
550 anstream::println!("pushed {} to {remote}", style::branch(&pushed.join(" ")));
551 super::publish_metadata(&remote);
553 }
554 } else {
555 anstream::println!("remote branches may be stale; push them with:");
556 anstream::println!(
557 "{}",
558 style::dim(&format!(
559 " git push --force-with-lease {remote} {}",
560 pushable.join(" ")
561 ))
562 );
563 }
564 Ok(())
565}
566
567fn resolve_update_refs(mode: UpdateRefsMode) -> Result<bool> {
568 match mode {
569 UpdateRefsMode::Config => {
570 let configured = git::config_get_bool(settings::UPDATE_REFS_KEY)?.unwrap_or(false);
571 if configured && !git::supports_rebase_update_refs()? {
572 eprintln!("stk.updateRefs is true, but this Git does not support --update-refs");
573 return Ok(false);
574 }
575 Ok(configured)
576 }
577 UpdateRefsMode::Enabled => {
578 if !git::supports_rebase_update_refs()? {
579 bail!("--update-refs was requested, but this Git does not support it");
580 }
581 Ok(true)
582 }
583 UpdateRefsMode::Disabled => Ok(false),
584 }
585}
586
587#[derive(Debug, Eq, PartialEq)]
588struct RestackState {
589 branch: String,
590 parent: String,
591 remaining: Vec<String>,
592 update_refs: bool,
593 push: bool,
594 all: Vec<String>,
597 frozen: Vec<String>,
600}
601
602impl RestackState {
603 fn read() -> Result<Option<Self>> {
604 let path = state_path()?;
605 if !path.exists() {
606 return Ok(None);
607 }
608
609 let contents = fs::read_to_string(&path)
610 .with_context(|| format!("failed to read {}", path.display()))?;
611 let mut branch = None;
612 let mut parent = None;
613 let mut remaining = Vec::new();
614 let mut update_refs = false;
615 let mut push = false;
616 let mut all = Vec::new();
617 let mut frozen = Vec::new();
618
619 for line in contents.lines() {
620 if let Some(value) = line.strip_prefix("branch=") {
621 branch = Some(value.to_owned());
622 } else if let Some(value) = line.strip_prefix("parent=") {
623 parent = Some(value.to_owned());
624 } else if let Some(value) = line.strip_prefix("updateRefs=") {
625 update_refs = value == "true";
626 } else if let Some(value) = line.strip_prefix("push=") {
627 push = value == "true";
628 } else if let Some(value) = line.strip_prefix("remaining=") {
629 remaining = value
630 .split('\t')
631 .filter(|branch| !branch.is_empty())
632 .map(str::to_owned)
633 .collect();
634 } else if let Some(value) = line.strip_prefix("all=") {
635 all = value
636 .split('\t')
637 .filter(|branch| !branch.is_empty())
638 .map(str::to_owned)
639 .collect();
640 } else if let Some(value) = line.strip_prefix("frozen=") {
641 frozen = value
642 .split('\t')
643 .filter(|branch| !branch.is_empty())
644 .map(str::to_owned)
645 .collect();
646 }
647 }
648
649 let Some(branch) = branch else {
650 bail!("restack state is missing current branch");
651 };
652 let Some(parent) = parent else {
653 bail!("restack state is missing parent branch");
654 };
655
656 Ok(Some(Self {
657 branch,
658 parent,
659 remaining,
660 update_refs,
661 push,
662 all,
663 frozen,
664 }))
665 }
666
667 fn write(&self) -> Result<()> {
668 let path = state_path()?;
669 let contents = format!(
670 "branch={}\nparent={}\nupdateRefs={}\npush={}\nremaining={}\nall={}\nfrozen={}\n",
671 self.branch,
672 self.parent,
673 self.update_refs,
674 self.push,
675 self.remaining.join("\t"),
676 self.all.join("\t"),
677 self.frozen.join("\t")
678 );
679 fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))
680 }
681}
682
683fn clear_state() -> Result<()> {
684 let path = state_path()?;
685 if path.exists() {
686 fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
687 }
688 Ok(())
689}
690
691fn state_path() -> Result<PathBuf> {
692 Ok(PathBuf::from(git::git_path(STATE_FILE)?))
693}
694
695pub(super) fn in_progress() -> bool {
697 state_path().map(|path| path.exists()).unwrap_or(false)
698}
699
700#[cfg(test)]
701mod tests {
702 use super::*;
703
704 fn linear_parents() -> BTreeMap<String, String> {
707 BTreeMap::from([
708 ("a".to_owned(), "main".to_owned()),
709 ("b".to_owned(), "a".to_owned()),
710 ("c".to_owned(), "b".to_owned()),
711 ])
712 }
713
714 fn set(branches: &[&str]) -> BTreeSet<String> {
715 branches.iter().map(|b| (*b).to_owned()).collect()
716 }
717
718 #[test]
719 fn a_queued_middle_branch_freezes_everything_below_it() {
720 let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
723 let frozen = with_frozen_ancestors(set(&["b"]), &branches, &linear_parents());
724 assert_eq!(frozen, set(&["a", "b"]));
725 }
726
727 #[test]
728 fn a_queued_bottom_branch_freezes_only_itself() {
729 let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
732 let frozen = with_frozen_ancestors(set(&["a"]), &branches, &linear_parents());
733 assert_eq!(frozen, set(&["a"]));
734 }
735
736 #[test]
737 fn freeze_stops_at_the_line_base_not_the_trunk() {
738 let branches = vec!["b".to_owned(), "c".to_owned()];
741 let frozen = with_frozen_ancestors(set(&["c"]), &branches, &linear_parents());
742 assert_eq!(frozen, set(&["b", "c"]));
743 }
744
745 #[test]
746 fn nothing_queued_freezes_nothing() {
747 let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
748 let frozen = with_frozen_ancestors(BTreeSet::new(), &branches, &linear_parents());
749 assert!(frozen.is_empty());
750 }
751}