Skip to main content

git_stk/stack/
restack.rs

1//! The rebase engine: restack a whole stack parent-first, persisting enough
2//! state across conflicts for `continue`/`abort` to resume or unwind.
3
4use 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    // Restack the stack containing the current branch, from anywhere in it:
31    // anchor on the bottom of its own line, then rebase that subtree
32    // parent-first. Anchoring on the line base rather than the trunk leaves
33    // sibling stacks that merely share the trunk alone - rebasing and
34    // force-pushing those would touch work this restack was never asked about.
35    let base = line_base(&current)?;
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    // Update the trunk from the remote first so branches rebase onto its
44    // latest tip; otherwise warn when a base the stack sits on has moved on the
45    // remote, so "up to date" is never read off a stale local trunk.
46    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    // Before anything is mutated - the snapshot, the diverged-remote
56    // cherry-picks, the first rebase. A branch held elsewhere cannot be rebased,
57    // and finding out mid-loop leaves the stack half-rewritten.
58    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    // Pull in any commits the remote branches have but the local stack lacks
67    // before the rebase loop, so descendants replay onto the reconciled tips
68    // and the later force-push lease matches instead of looping on "run sync".
69    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
75/// Branches in the restack set whose review is itself locked by a merge queue /
76/// merge train. Resolves the provider best-effort - no remote, or an
77/// unrecognized host, means no provider and so nothing frozen, which is exactly
78/// right for a purely local restack. [`with_frozen_ancestors`] then widens this
79/// to the branches that must move with them.
80fn 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
87/// Widen the directly-queued set to every branch *below* a queued one in the
88/// restack set. A queued review is computed (and merged) against its base, so
89/// rebasing or force-pushing any ancestor would move that base out from under
90/// the frozen tip and invalidate the queue entry. Freezing therefore propagates
91/// down the parent chain to the line base; descendants need no such treatment,
92/// since their (frozen) parent does not move and they stay up to date.
93fn 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(&current) {
103            // Stop at the line base (parent outside the set), and short-circuit
104            // when a shared ancestor was already frozen by an earlier branch.
105            if !in_set.contains(parent.as_str()) || !frozen.insert(parent.clone()) {
106                break;
107            }
108            current = parent.clone();
109        }
110    }
111    frozen
112}
113
114/// The line printed for a branch held out of the restack because a review in
115/// its stack sits in a merge queue / merge train - either this branch's own, or
116/// a descendant's, whose base this branch must not move.
117fn 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
125/// The plan, read-only: which branches would rebase and which already sit
126/// on their parents.
127fn 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
186/// Refuse the restack if a branch it would rebase is checked out in another
187/// worktree. Two failures this heads off: git refuses to rebase such a branch
188/// outright, aborting the run with earlier branches already rewritten; and
189/// `git rebase --update-refs` *silently* skips refs it cannot move, which would
190/// leave a parent behind while its child is rewritten and quietly break the
191/// ancestry the stack depends on.
192fn 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    // Which branches the loop will actually rebase. `branches` is ordered
204    // parent-first, so a parent's fate is known before its children are asked.
205    // `already_moving` seeds branches whose rebase is underway but whose ref has
206    // not caught up yet - a paused rebase holds the work in detached HEAD, so
207    // their children would otherwise read as up to date and escape the check.
208    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        // A branch whose parent is moving will be rebased even if it sits on
218        // that parent's tip right now, so "up to date" only settles it when the
219        // parent stays put.
220        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    // Re-running from the holding worktree only helps when that worktree is the
234    // only one in the way *and* nothing checked out here needs rebasing.
235    // Otherwise the two worktrees hold each other's branches and the advice
236    // sends the user in a circle.
237    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
249/// The refusal, with a remedy that holds in every layout. Detaching is what this
250/// leads with: `git worktree remove` cannot free the main worktree, and moving
251/// the restack into the holding worktree only works in the single-blocker case,
252/// so neither is safe to offer unconditionally.
253fn 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
285/// Sitting exactly on the parent tip with a fresh fork point: nothing to do.
286fn 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
294/// Fast-forward the trunk from the remote before restacking. Fetching the
295/// branch in place (rather than the whole remote) keeps it cheap; on the trunk
296/// itself a plain fast-forward pull does the same. A missing remote is a no-op,
297/// not an error - there is simply nothing to pull.
298fn 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
327/// Warn when a base the stack rebases onto - the trunk, or any parent outside
328/// the restack set - is behind its remote-tracking branch. Without this, a
329/// branch sitting exactly on a stale local base reads as "up to date" while the
330/// base on the remote has moved on. Best-effort: no remote, or no
331/// remote-tracking ref to compare against, means nothing to warn about.
332fn 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
364/// Before the rebase-and-force-push, reconcile any branch whose remote tip
365/// carries commits the local branch lacks - a commit made straight on the
366/// host's web UI, a committed review suggestion, a bot's edit. A blind
367/// force-push would drop them and `--force-with-lease` rightly refuses, which
368/// left `sync` looping on "the remote has moved on - run sync" with no way to
369/// pull those commits in. Offer to cherry-pick them onto the local branch; the
370/// rebase loop that follows replays descendants onto the reconciled tip, and
371/// the push lease then matches.
372///
373/// Declining that offers the other half - discarding them, which the same
374/// rebase-and-lease-push then carries out by overwriting the remote. Declining
375/// both stops, because the third option is to change nothing and push anyway,
376/// and that is the one this exists to prevent.
377///
378/// Only runs when we are about to push (a no-push restack leaves the remote
379/// untouched, so a divergence is not yet fatal) and a remote exists. Under
380/// `--dry-run` it reports without fetching or changing anything.
381fn 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    // Frozen branches are held back from the push, so their remote is not
396    // touched and any divergence there is not this run's problem.
397    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    // restack/sync only fetched the trunk, so origin/<branch> can be stale
407    // here; refresh the stack branches' tracking refs so the check (and the
408    // lease that follows) see the true remote. A dry run must touch nothing,
409    // so it compares against whatever is already known locally.
410    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        // No tracking ref means the branch was never pushed - nothing upstream
418        // to reconcile against.
419        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        // Commits the remote has and we do not, but nothing in them that the
427        // local branch lacks. A squash merge is the usual way this happens: it
428        // lands the parent's commits as one whose patch id matches none of
429        // them, so `--cherry-pick` cannot see they are gone on purpose, and
430        // the restack that dropped them was right. There is nothing to
431        // incorporate, so do not ask.
432        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        // Declining used to end the run, leaving the only way forward a
469        // command the user had to assemble themselves - and the one that reads
470        // as the more dangerous of the two. Offer it instead: the restack that
471        // follows pushes with `--force-with-lease`, so answering yes here is
472        // exactly "discard them", and the lease still refuses if the remote
473        // moves again between now and then.
474        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    // Cherry-pick oldest-first onto each diverged branch. The branch's relation
485    // to its parent is unchanged, so the rebase loop leaves it "up to date" and
486    // keeps the picked commits, while its descendants rebase onto the new tip.
487    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    // State on file with no rebase behind it: the run failed before any rebase
523    // started. Clear it here rather than failing on git's "no rebase in
524    // progress" - a leftover file also blocks `git stk undo`.
525    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    // Same escape hatch as `continue`: with no rebase to unwind, aborting means
569    // dropping the leftover state so the stack is usable again.
570    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        // Replay only the branch's own commits, from its current fork point, so
617        // commits already upstream - landed via squash or rebase merges, or
618        // trunk commits behind a stale recorded base - are not repeated. With
619        // no fork point to anchor on, fall back to a plain rebase.
620        let base = fork_point(branch, parent)?;
621
622        // Already sitting exactly on the parent tip with a fresh fork point:
623        // skip the rebase entirely. (git rebase --update-refs would otherwise
624        // replay and rewrite identical commits with new hashes.)
625        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            // A rebase that never started is not a conflict: `continue` and
654            // `abort` would both fail on it, and recording state would only
655            // block `undo` too. Let the failure stand on its own.
656            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
688/// After every branch has been rebased: push the rewritten branches, or print
689/// the exact command so stale remote PR diffs are a copy-paste away from fixed.
690/// Frozen branches (in a merge queue / merge train) are held back from the
691/// push - pushing them would be rejected (GitHub) or drop them from the queue
692/// (GitLab) - so only their pushable siblings are sent.
693fn 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        // Only the branches that actually landed: a branch enqueued between the
712        // freeze check and the push is held back, warned about, and dropped here
713        // so the "pushed ..." line never contradicts that warning.
714        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            // Keep the shared parent map in step with the pushed branches.
723            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    /// Every branch in the interrupted restack, so the post-restack push (or
766    /// push hint) can cover branches rebased before the conflict too.
767    all: Vec<String>,
768    /// Branches frozen by a merge queue / merge train, so the resumed restack
769    /// keeps skipping them and the final push keeps holding them back.
770    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
866/// Whether a restack is paused on a conflict, awaiting continue/abort. Requires
867/// a live rebase, not just state on file: a run that failed before any rebase
868/// started leaves state behind, and treating that as "in progress" would wedge
869/// `undo` as well as `continue`/`abort`.
870pub(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    /// `main -> a -> b -> c`, as the restack records it: each branch's parent
879    /// is the one below it. `main` (the trunk) is outside the restack set.
880    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        // b is in the queue; a (its base) must not move, or b's queue entry
895        // goes stale. c, above b, is left to its no-op rebase on a frozen b.
896        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        // The common case: the bottom of the stack is queued, so there is no
904        // ancestor in the set to carry the freeze to.
905        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        // Restacking only the b..c subtree: a is the line base and not in the
913        // set, so freezing c must not try to reach past it to main.
914        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}