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/// Only runs when we are about to push (a no-push restack leaves the remote
374/// untouched, so a divergence is not yet fatal) and a remote exists. Under
375/// `--dry-run` it reports without fetching or changing anything.
376fn reconcile_diverged_remotes(
377    branches: &[String],
378    frozen: &BTreeSet<String>,
379    push: bool,
380    dry_run: bool,
381) -> Result<()> {
382    if !push {
383        return Ok(());
384    }
385    let remote = settings::remote()?;
386    if git::remote_url(&remote)?.is_none() {
387        return Ok(());
388    }
389
390    // Frozen branches are held back from the push, so their remote is not
391    // touched and any divergence there is not this run's problem.
392    let pushable: Vec<String> = branches
393        .iter()
394        .filter(|branch| !frozen.contains(*branch))
395        .cloned()
396        .collect();
397    if pushable.is_empty() {
398        return Ok(());
399    }
400
401    // restack/sync only fetched the trunk, so origin/<branch> can be stale
402    // here; refresh the stack branches' tracking refs so the check (and the
403    // lease that follows) see the true remote. A dry run must touch nothing,
404    // so it compares against whatever is already known locally.
405    if !dry_run {
406        git::fetch_tracking(&remote, &pushable)?;
407    }
408
409    let mut diverged: Vec<(String, Vec<(String, String)>)> = Vec::new();
410    for branch in &pushable {
411        let tracking = format!("{remote}/{branch}");
412        // No tracking ref means the branch was never pushed - nothing upstream
413        // to reconcile against.
414        if git::rev_parse(&tracking).is_err() {
415            continue;
416        }
417        let extra = git::remote_only_commits(branch, &tracking)?;
418        if !extra.is_empty() {
419            diverged.push((branch.clone(), extra));
420        }
421    }
422
423    if diverged.is_empty() {
424        return Ok(());
425    }
426
427    for (branch, commits) in &diverged {
428        anstream::eprintln!(
429            "{}",
430            style::warn(&format!(
431                "{remote}/{branch} has {} commit{} not in your local {branch}:",
432                commits.len(),
433                if commits.len() == 1 { "" } else { "s" },
434            ))
435        );
436        for (sha, subject) in commits {
437            anstream::eprintln!("  {} {subject}", style::dim(sha));
438        }
439    }
440
441    if dry_run {
442        anstream::println!(
443            "{}",
444            style::dim("would offer to cherry-pick these into your local branches before pushing")
445        );
446        return Ok(());
447    }
448
449    if !prompt::confirm("cherry-pick these into your local branches before pushing? [y/N] ")? {
450        bail!(
451            "remote branches have commits not in your local stack\n\
452             incorporate them (`git switch <branch> && git cherry-pick <sha>`) and re-run, \
453             or discard them with `git push --force {remote} <branch>`"
454        );
455    }
456
457    // Cherry-pick oldest-first onto each diverged branch. The branch's relation
458    // to its parent is unchanged, so the rebase loop leaves it "up to date" and
459    // keeps the picked commits, while its descendants rebase onto the new tip.
460    let start = git::current_branch()?;
461    for (branch, commits) in &diverged {
462        git::checkout(branch)?;
463        for (sha, _) in commits {
464            if let Err(error) = git::cherry_pick(sha) {
465                anstream::eprintln!(
466                    "{}",
467                    style::warn(&format!("conflict cherry-picking {sha} onto {branch}"))
468                );
469                eprintln!("resolve conflicts, run `git cherry-pick --continue`, then re-run");
470                eprintln!("or run `git cherry-pick --abort` to bail out");
471                return Err(error);
472            }
473        }
474    }
475    git::checkout(&start)?;
476    anstream::println!(
477        "{}",
478        style::success(&format!(
479            "incorporated remote commits into {}",
480            diverged
481                .iter()
482                .map(|(branch, _)| branch.as_str())
483                .collect::<Vec<_>>()
484                .join(" ")
485        ))
486    );
487    Ok(())
488}
489
490pub fn continue_restack() -> Result<()> {
491    let Some(state) = RestackState::read()? else {
492        bail!("no interrupted restack found");
493    };
494
495    // State on file with no rebase behind it: the run failed before any rebase
496    // started. Clear it here rather than failing on git's "no rebase in
497    // progress" - a leftover file also blocks `git stk undo`.
498    if !git::rebase_in_progress() {
499        clear_state()?;
500        bail!(
501            "no rebase is in progress, so there is nothing to continue\n\
502             cleared the leftover restack state; re-run `git stk restack` to pick up where it stopped"
503        );
504    }
505
506    ensure_no_worktree_blocks(
507        &state.remaining,
508        &parent_map()?,
509        &state.frozen.iter().cloned().collect(),
510        &BTreeSet::from([state.branch.clone()]),
511    )?;
512
513    if let Err(error) = git::rebase_continue() {
514        anstream::eprintln!("{}", style::warn("restack still has conflicts"));
515        eprintln!("resolve conflicts, then run `git stk continue`");
516        eprintln!("or run `git stk abort`");
517        return Err(error);
518    }
519
520    record_base(&state.branch, &state.parent);
521
522    let frozen: BTreeSet<String> = state.frozen.iter().cloned().collect();
523    if state.remaining.is_empty() {
524        clear_state()?;
525        finish_restack(&state.all, &frozen, state.push)?;
526        return Ok(());
527    }
528
529    let parents = parent_map()?;
530    restack_branches(
531        state.remaining,
532        &parents,
533        &frozen,
534        state.update_refs,
535        state.push,
536        &state.all,
537    )
538}
539
540pub fn abort_restack() -> Result<()> {
541    // Same escape hatch as `continue`: with no rebase to unwind, aborting means
542    // dropping the leftover state so the stack is usable again.
543    if !git::rebase_in_progress() {
544        if RestackState::read()?.is_none() {
545            bail!("no restack to abort");
546        }
547        clear_state()?;
548        anstream::println!("cleared leftover restack state; no rebase was in progress");
549        return Ok(());
550    }
551
552    git::rebase_abort()?;
553    clear_state()?;
554    anstream::println!("restack aborted");
555    Ok(())
556}
557
558fn restack_order(current: &str, parents: &BTreeMap<String, String>) -> Vec<String> {
559    let children = children_map(parents);
560    let mut branches = Vec::new();
561
562    if parents.contains_key(current) {
563        branches.push(current.to_owned());
564    }
565
566    let mut visited = BTreeSet::from([current.to_owned()]);
567    collect_descendants(current, &children, &mut branches, &mut visited);
568    branches
569}
570
571fn restack_branches(
572    branches: Vec<String>,
573    parents: &BTreeMap<String, String>,
574    frozen: &BTreeSet<String>,
575    update_refs: bool,
576    push: bool,
577    all: &[String],
578) -> Result<()> {
579    for (index, branch) in branches.iter().enumerate() {
580        if frozen.contains(branch) {
581            anstream::println!("{}", frozen_note(branch));
582            continue;
583        }
584
585        let Some(parent) = parents.get(branch) else {
586            bail!("{branch} has no stack parent");
587        };
588
589        // Replay only the branch's own commits, from its current fork point, so
590        // commits already upstream - landed via squash or rebase merges, or
591        // trunk commits behind a stale recorded base - are not repeated. With
592        // no fork point to anchor on, fall back to a plain rebase.
593        let base = fork_point(branch, parent)?;
594
595        // Already sitting exactly on the parent tip with a fresh fork point:
596        // skip the rebase entirely. (git rebase --update-refs would otherwise
597        // replay and rewrite identical commits with new hashes.)
598        if up_to_date(branch, parent)? {
599            anstream::println!(
600                "{} already up to date with {}",
601                style::branch(branch),
602                style::branch(parent)
603            );
604            continue;
605        }
606
607        if update_refs {
608            anstream::println!(
609                "rebasing {} onto {} with --update-refs",
610                style::branch(branch),
611                style::branch(parent)
612            );
613        } else {
614            anstream::println!(
615                "rebasing {} onto {}",
616                style::branch(branch),
617                style::branch(parent)
618            );
619        }
620        let rebase_result = match &base {
621            Some(base) => git::rebase_onto(parent, base, branch, update_refs),
622            None => git::rebase(parent, branch, update_refs),
623        };
624
625        if let Err(error) = rebase_result {
626            // A rebase that never started is not a conflict: `continue` and
627            // `abort` would both fail on it, and recording state would only
628            // block `undo` too. Let the failure stand on its own.
629            if !git::rebase_in_progress() {
630                return Err(error);
631            }
632
633            let remaining = branches[index + 1..].to_vec();
634            RestackState {
635                branch: branch.to_owned(),
636                parent: parent.to_owned(),
637                remaining,
638                update_refs,
639                push,
640                all: all.to_vec(),
641                frozen: frozen.iter().cloned().collect(),
642            }
643            .write()?;
644
645            anstream::eprintln!(
646                "{}",
647                style::warn(&format!("conflict while rebasing {branch} onto {parent}"))
648            );
649            eprintln!("resolve conflicts, then run `git stk continue`");
650            eprintln!("or run `git stk abort`");
651            return Err(error);
652        }
653
654        record_base(branch, parent);
655    }
656
657    clear_state()?;
658    finish_restack(all, frozen, push)
659}
660
661/// After every branch has been rebased: push the rewritten branches, or print
662/// the exact command so stale remote PR diffs are a copy-paste away from fixed.
663/// Frozen branches (in a merge queue / merge train) are held back from the
664/// push - pushing them would be rejected (GitHub) or drop them from the queue
665/// (GitLab) - so only their pushable siblings are sent.
666fn finish_restack(branches: &[String], frozen: &BTreeSet<String>, push: bool) -> Result<()> {
667    anstream::println!("{}", style::success("restack complete"));
668
669    let remote = settings::remote()?;
670    let pushable: Vec<String> = branches
671        .iter()
672        .filter(|branch| !frozen.contains(*branch))
673        .cloned()
674        .collect();
675    if pushable.is_empty() {
676        anstream::println!(
677            "{}",
678            style::dim("nothing to push: every branch is in a merge queue")
679        );
680        return Ok(());
681    }
682
683    if push {
684        // Only the branches that actually landed: a branch enqueued between the
685        // freeze check and the push is held back, warned about, and dropped here
686        // so the "pushed ..." line never contradicts that warning.
687        let pushed = git::push_force_with_lease(&remote, &pushable)?;
688        if pushed.is_empty() {
689            anstream::println!(
690                "{}",
691                style::dim("nothing pushed: every branch is in a merge queue")
692            );
693        } else {
694            anstream::println!("pushed {} to {remote}", style::branch(&pushed.join(" ")));
695            // Keep the shared parent map in step with the pushed branches.
696            super::publish_metadata(&remote);
697        }
698    } else {
699        anstream::println!("remote branches may be stale; push them with:");
700        anstream::println!(
701            "{}",
702            style::dim(&format!(
703                "  git push --force-with-lease {remote} {}",
704                pushable.join(" ")
705            ))
706        );
707    }
708    Ok(())
709}
710
711fn resolve_update_refs(mode: UpdateRefsMode) -> Result<bool> {
712    match mode {
713        UpdateRefsMode::Config => {
714            let configured = git::config_get_bool(settings::UPDATE_REFS_KEY)?.unwrap_or(false);
715            if configured && !git::supports_rebase_update_refs()? {
716                eprintln!("stk.updateRefs is true, but this Git does not support --update-refs");
717                return Ok(false);
718            }
719            Ok(configured)
720        }
721        UpdateRefsMode::Enabled => {
722            if !git::supports_rebase_update_refs()? {
723                bail!("--update-refs was requested, but this Git does not support it");
724            }
725            Ok(true)
726        }
727        UpdateRefsMode::Disabled => Ok(false),
728    }
729}
730
731#[derive(Debug, Eq, PartialEq)]
732struct RestackState {
733    branch: String,
734    parent: String,
735    remaining: Vec<String>,
736    update_refs: bool,
737    push: bool,
738    /// Every branch in the interrupted restack, so the post-restack push (or
739    /// push hint) can cover branches rebased before the conflict too.
740    all: Vec<String>,
741    /// Branches frozen by a merge queue / merge train, so the resumed restack
742    /// keeps skipping them and the final push keeps holding them back.
743    frozen: Vec<String>,
744}
745
746impl RestackState {
747    fn read() -> Result<Option<Self>> {
748        let path = state_path()?;
749        if !path.exists() {
750            return Ok(None);
751        }
752
753        let contents = fs::read_to_string(&path)
754            .with_context(|| format!("failed to read {}", path.display()))?;
755        let mut branch = None;
756        let mut parent = None;
757        let mut remaining = Vec::new();
758        let mut update_refs = false;
759        let mut push = false;
760        let mut all = Vec::new();
761        let mut frozen = Vec::new();
762
763        for line in contents.lines() {
764            if let Some(value) = line.strip_prefix("branch=") {
765                branch = Some(value.to_owned());
766            } else if let Some(value) = line.strip_prefix("parent=") {
767                parent = Some(value.to_owned());
768            } else if let Some(value) = line.strip_prefix("updateRefs=") {
769                update_refs = value == "true";
770            } else if let Some(value) = line.strip_prefix("push=") {
771                push = value == "true";
772            } else if let Some(value) = line.strip_prefix("remaining=") {
773                remaining = value
774                    .split('\t')
775                    .filter(|branch| !branch.is_empty())
776                    .map(str::to_owned)
777                    .collect();
778            } else if let Some(value) = line.strip_prefix("all=") {
779                all = value
780                    .split('\t')
781                    .filter(|branch| !branch.is_empty())
782                    .map(str::to_owned)
783                    .collect();
784            } else if let Some(value) = line.strip_prefix("frozen=") {
785                frozen = value
786                    .split('\t')
787                    .filter(|branch| !branch.is_empty())
788                    .map(str::to_owned)
789                    .collect();
790            }
791        }
792
793        let Some(branch) = branch else {
794            bail!("restack state is missing current branch");
795        };
796        let Some(parent) = parent else {
797            bail!("restack state is missing parent branch");
798        };
799
800        Ok(Some(Self {
801            branch,
802            parent,
803            remaining,
804            update_refs,
805            push,
806            all,
807            frozen,
808        }))
809    }
810
811    fn write(&self) -> Result<()> {
812        let path = state_path()?;
813        let contents = format!(
814            "branch={}\nparent={}\nupdateRefs={}\npush={}\nremaining={}\nall={}\nfrozen={}\n",
815            self.branch,
816            self.parent,
817            self.update_refs,
818            self.push,
819            self.remaining.join("\t"),
820            self.all.join("\t"),
821            self.frozen.join("\t")
822        );
823        fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))
824    }
825}
826
827fn clear_state() -> Result<()> {
828    let path = state_path()?;
829    if path.exists() {
830        fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
831    }
832    Ok(())
833}
834
835fn state_path() -> Result<PathBuf> {
836    Ok(PathBuf::from(git::git_path(STATE_FILE)?))
837}
838
839/// Whether a restack is paused on a conflict, awaiting continue/abort. Requires
840/// a live rebase, not just state on file: a run that failed before any rebase
841/// started leaves state behind, and treating that as "in progress" would wedge
842/// `undo` as well as `continue`/`abort`.
843pub(super) fn in_progress() -> bool {
844    state_path().map(|path| path.exists()).unwrap_or(false) && git::rebase_in_progress()
845}
846
847#[cfg(test)]
848mod tests {
849    use super::*;
850
851    /// `main -> a -> b -> c`, as the restack records it: each branch's parent
852    /// is the one below it. `main` (the trunk) is outside the restack set.
853    fn linear_parents() -> BTreeMap<String, String> {
854        BTreeMap::from([
855            ("a".to_owned(), "main".to_owned()),
856            ("b".to_owned(), "a".to_owned()),
857            ("c".to_owned(), "b".to_owned()),
858        ])
859    }
860
861    fn set(branches: &[&str]) -> BTreeSet<String> {
862        branches.iter().map(|b| (*b).to_owned()).collect()
863    }
864
865    #[test]
866    fn a_queued_middle_branch_freezes_everything_below_it() {
867        // b is in the queue; a (its base) must not move, or b's queue entry
868        // goes stale. c, above b, is left to its no-op rebase on a frozen b.
869        let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
870        let frozen = with_frozen_ancestors(set(&["b"]), &branches, &linear_parents());
871        assert_eq!(frozen, set(&["a", "b"]));
872    }
873
874    #[test]
875    fn a_queued_bottom_branch_freezes_only_itself() {
876        // The common case: the bottom of the stack is queued, so there is no
877        // ancestor in the set to carry the freeze to.
878        let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
879        let frozen = with_frozen_ancestors(set(&["a"]), &branches, &linear_parents());
880        assert_eq!(frozen, set(&["a"]));
881    }
882
883    #[test]
884    fn freeze_stops_at_the_line_base_not_the_trunk() {
885        // Restacking only the b..c subtree: a is the line base and not in the
886        // set, so freezing c must not try to reach past it to main.
887        let branches = vec!["b".to_owned(), "c".to_owned()];
888        let frozen = with_frozen_ancestors(set(&["c"]), &branches, &linear_parents());
889        assert_eq!(frozen, set(&["b", "c"]));
890    }
891
892    #[test]
893    fn nothing_queued_freezes_nothing() {
894        let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
895        let frozen = with_frozen_ancestors(BTreeSet::new(), &branches, &linear_parents());
896        assert!(frozen.is_empty());
897    }
898}