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::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    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
245/// Sitting exactly on the parent tip with a fresh fork point: nothing to do.
246fn 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
254/// Fast-forward the trunk from the remote before restacking. Fetching the
255/// branch in place (rather than the whole remote) keeps it cheap; on the trunk
256/// itself a plain fast-forward pull does the same. A missing remote is a no-op,
257/// not an error - there is simply nothing to pull.
258fn 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
287/// Warn when a base the stack rebases onto - the trunk, or any parent outside
288/// the restack set - is behind its remote-tracking branch. Without this, a
289/// branch sitting exactly on a stale local base reads as "up to date" while the
290/// base on the remote has moved on. Best-effort: no remote, or no
291/// remote-tracking ref to compare against, means nothing to warn about.
292fn 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
324/// Before the rebase-and-force-push, reconcile any branch whose remote tip
325/// carries commits the local branch lacks - a commit made straight on the
326/// host's web UI, a committed review suggestion, a bot's edit. A blind
327/// force-push would drop them and `--force-with-lease` rightly refuses, which
328/// left `sync` looping on "the remote has moved on - run sync" with no way to
329/// pull those commits in. Offer to cherry-pick them onto the local branch; the
330/// rebase loop that follows replays descendants onto the reconciled tip, and
331/// the push lease then matches.
332///
333/// Only runs when we are about to push (a no-push restack leaves the remote
334/// untouched, so a divergence is not yet fatal) and a remote exists. Under
335/// `--dry-run` it reports without fetching or changing anything.
336fn 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    // Frozen branches are held back from the push, so their remote is not
351    // touched and any divergence there is not this run's problem.
352    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    // restack/sync only fetched the trunk, so origin/<branch> can be stale
362    // here; refresh the stack branches' tracking refs so the check (and the
363    // lease that follows) see the true remote. A dry run must touch nothing,
364    // so it compares against whatever is already known locally.
365    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        // No tracking ref means the branch was never pushed - nothing upstream
373        // to reconcile against.
374        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    // Cherry-pick oldest-first onto each diverged branch. The branch's relation
418    // to its parent is unchanged, so the rebase loop leaves it "up to date" and
419    // keeps the picked commits, while its descendants rebase onto the new tip.
420    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    // State on file with no rebase behind it: the run failed before any rebase
456    // started. Clear it here rather than failing on git's "no rebase in
457    // progress" - a leftover file also blocks `git stk undo`.
458    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    // Same escape hatch as `continue`: with no rebase to unwind, aborting means
502    // dropping the leftover state so the stack is usable again.
503    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        // Replay only the branch's own commits, from its current fork point, so
550        // commits already upstream - landed via squash or rebase merges, or
551        // trunk commits behind a stale recorded base - are not repeated. With
552        // no fork point to anchor on, fall back to a plain rebase.
553        let base = fork_point(branch, parent)?;
554
555        // Already sitting exactly on the parent tip with a fresh fork point:
556        // skip the rebase entirely. (git rebase --update-refs would otherwise
557        // replay and rewrite identical commits with new hashes.)
558        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            // A rebase that never started is not a conflict: `continue` and
587            // `abort` would both fail on it, and recording state would only
588            // block `undo` too. Let the failure stand on its own.
589            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
621/// After every branch has been rebased: push the rewritten branches, or print
622/// the exact command so stale remote PR diffs are a copy-paste away from fixed.
623/// Frozen branches (in a merge queue / merge train) are held back from the
624/// push - pushing them would be rejected (GitHub) or drop them from the queue
625/// (GitLab) - so only their pushable siblings are sent.
626fn 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        // Only the branches that actually landed: a branch enqueued between the
645        // freeze check and the push is held back, warned about, and dropped here
646        // so the "pushed ..." line never contradicts that warning.
647        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            // Keep the shared parent map in step with the pushed branches.
656            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    /// Every branch in the interrupted restack, so the post-restack push (or
699    /// push hint) can cover branches rebased before the conflict too.
700    all: Vec<String>,
701    /// Branches frozen by a merge queue / merge train, so the resumed restack
702    /// keeps skipping them and the final push keeps holding them back.
703    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
799/// Whether a restack is paused on a conflict, awaiting continue/abort. Requires
800/// a live rebase, not just state on file: a run that failed before any rebase
801/// started leaves state behind, and treating that as "in progress" would wedge
802/// `undo` as well as `continue`/`abort`.
803pub(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    /// `main -> a -> b -> c`, as the restack records it: each branch's parent
812    /// is the one below it. `main` (the trunk) is outside the restack set.
813    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        // b is in the queue; a (its base) must not move, or b's queue entry
828        // goes stale. c, above b, is left to its no-op rebase on a frozen b.
829        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        // The common case: the bottom of the stack is queued, so there is no
837        // ancestor in the set to carry the freeze to.
838        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        // Restacking only the b..c subtree: a is the line base and not in the
846        // set, so freezing c must not try to reach past it to main.
847        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}