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    if dry_run {
56        reconcile_diverged_remotes(&branches, &frozen, push, true)?;
57        return print_restack_plan(&branches, &parents, &frozen, update_refs, push);
58    }
59
60    super::snapshot("restack");
61    // Pull in any commits the remote branches have but the local stack lacks
62    // before the rebase loop, so descendants replay onto the reconciled tips
63    // and the later force-push lease matches instead of looping on "run sync".
64    reconcile_diverged_remotes(&branches, &frozen, push, false)?;
65    clear_state()?;
66    let all = branches.clone();
67    restack_branches(branches, &parents, &frozen, update_refs, push, &all)
68}
69
70/// Branches in the restack set whose review is itself locked by a merge queue /
71/// merge train. Resolves the provider best-effort - no remote, or an
72/// unrecognized host, means no provider and so nothing frozen, which is exactly
73/// right for a purely local restack. [`with_frozen_ancestors`] then widens this
74/// to the branches that must move with them.
75fn frozen_branches(branches: &[String]) -> BTreeSet<String> {
76    let Ok((_, provider)) = detect_review_provider() else {
77        return BTreeSet::new();
78    };
79    provider.enqueued_branches(branches).unwrap_or_default()
80}
81
82/// Widen the directly-queued set to every branch *below* a queued one in the
83/// restack set. A queued review is computed (and merged) against its base, so
84/// rebasing or force-pushing any ancestor would move that base out from under
85/// the frozen tip and invalidate the queue entry. Freezing therefore propagates
86/// down the parent chain to the line base; descendants need no such treatment,
87/// since their (frozen) parent does not move and they stay up to date.
88fn with_frozen_ancestors(
89    queued: BTreeSet<String>,
90    branches: &[String],
91    parents: &BTreeMap<String, String>,
92) -> BTreeSet<String> {
93    let in_set: BTreeSet<&str> = branches.iter().map(String::as_str).collect();
94    let mut frozen = queued.clone();
95    for branch in &queued {
96        let mut current = branch.clone();
97        while let Some(parent) = parents.get(&current) {
98            // Stop at the line base (parent outside the set), and short-circuit
99            // when a shared ancestor was already frozen by an earlier branch.
100            if !in_set.contains(parent.as_str()) || !frozen.insert(parent.clone()) {
101                break;
102            }
103            current = parent.clone();
104        }
105    }
106    frozen
107}
108
109/// The line printed for a branch held out of the restack because a review in
110/// its stack sits in a merge queue / merge train - either this branch's own, or
111/// a descendant's, whose base this branch must not move.
112fn frozen_note(branch: &str) -> String {
113    format!(
114        "{} {}: not rebased or pushed (a branch in this stack is in a merge queue; dequeue it to continue)",
115        style::warn("frozen"),
116        style::branch(branch),
117    )
118}
119
120/// The plan, read-only: which branches would rebase and which already sit
121/// on their parents.
122fn print_restack_plan(
123    branches: &[String],
124    parents: &BTreeMap<String, String>,
125    frozen: &BTreeSet<String>,
126    update_refs: bool,
127    push: bool,
128) -> Result<()> {
129    for branch in branches {
130        if frozen.contains(branch) {
131            anstream::println!("{}", frozen_note(branch));
132            continue;
133        }
134
135        let Some(parent) = parents.get(branch) else {
136            bail!("{branch} has no stack parent");
137        };
138
139        if up_to_date(branch, parent)? {
140            anstream::println!(
141                "{} already up to date with {}",
142                style::branch(branch),
143                style::branch(parent)
144            );
145        } else {
146            anstream::println!(
147                "would rebase {} onto {}{}",
148                style::branch(branch),
149                style::branch(parent),
150                if update_refs {
151                    " with --update-refs"
152                } else {
153                    ""
154                }
155            );
156        }
157    }
158
159    if push {
160        let pushable: Vec<&str> = branches
161            .iter()
162            .filter(|branch| !frozen.contains(*branch))
163            .map(String::as_str)
164            .collect();
165        if pushable.is_empty() {
166            anstream::println!(
167                "{}",
168                style::dim("nothing to push: every branch is in a merge queue")
169            );
170        } else {
171            anstream::println!(
172                "would push {} to {}",
173                style::branch(&pushable.join(" ")),
174                settings::remote()?
175            );
176        }
177    }
178    Ok(())
179}
180
181/// Sitting exactly on the parent tip with a fresh fork point: nothing to do.
182fn up_to_date(branch: &str, parent: &str) -> Result<bool> {
183    let parent_tip = git::rev_parse(parent)?;
184    Ok(
185        fork_point(branch, parent)?.as_deref() == Some(parent_tip.as_str())
186            && git::is_ancestor(parent, branch).unwrap_or(false),
187    )
188}
189
190/// Fast-forward the trunk from the remote before restacking. Fetching the
191/// branch in place (rather than the whole remote) keeps it cheap; on the trunk
192/// itself a plain fast-forward pull does the same. A missing remote is a no-op,
193/// not an error - there is simply nothing to pull.
194fn fetch_trunk(dry_run: bool) -> Result<()> {
195    let Some(trunk) = super::trunk_branch(&git::local_branches()?) else {
196        return Ok(());
197    };
198    let remote = settings::remote()?;
199    if git::remote_url(&remote)?.is_none() {
200        anstream::println!(
201            "{}",
202            style::dim(&format!("no remote {remote}; skipped fetch"))
203        );
204        return Ok(());
205    }
206    if dry_run {
207        anstream::println!("would fetch {} from {remote}", style::branch(&trunk));
208        return Ok(());
209    }
210    if git::current_branch()? == trunk {
211        git::pull_ff_only()?;
212    } else {
213        git::fetch_branch(&remote, &trunk)?;
214    }
215    anstream::println!("fetched {} from {remote}", style::branch(&trunk));
216    Ok(())
217}
218
219/// Warn when a base the stack rebases onto - the trunk, or any parent outside
220/// the restack set - is behind its remote-tracking branch. Without this, a
221/// branch sitting exactly on a stale local base reads as "up to date" while the
222/// base on the remote has moved on. Best-effort: no remote, or no
223/// remote-tracking ref to compare against, means nothing to warn about.
224fn warn_bases_behind_remote(branches: &[String], parents: &BTreeMap<String, String>) -> Result<()> {
225    let remote = settings::remote()?;
226    if git::remote_url(&remote)?.is_none() {
227        return Ok(());
228    }
229
230    let in_stack: BTreeSet<&String> = branches.iter().collect();
231    let external: BTreeSet<&String> = branches
232        .iter()
233        .filter_map(|branch| parents.get(branch))
234        .filter(|parent| !in_stack.contains(parent))
235        .collect();
236
237    for base in external {
238        let tracking = format!("{remote}/{base}");
239        if git::rev_parse(&tracking).is_err() {
240            continue;
241        }
242        let behind = git::commits_behind(base, &tracking).unwrap_or(0);
243        if behind > 0 {
244            anstream::eprintln!(
245                "{}",
246                style::warn(&format!(
247                    "{base} is {behind} commit{} behind {tracking}; run `git stk restack --fetch` or `git stk sync` to update it first",
248                    if behind == 1 { "" } else { "s" }
249                ))
250            );
251        }
252    }
253    Ok(())
254}
255
256/// Before the rebase-and-force-push, reconcile any branch whose remote tip
257/// carries commits the local branch lacks - a commit made straight on the
258/// host's web UI, a committed review suggestion, a bot's edit. A blind
259/// force-push would drop them and `--force-with-lease` rightly refuses, which
260/// left `sync` looping on "the remote has moved on - run sync" with no way to
261/// pull those commits in. Offer to cherry-pick them onto the local branch; the
262/// rebase loop that follows replays descendants onto the reconciled tip, and
263/// the push lease then matches.
264///
265/// Only runs when we are about to push (a no-push restack leaves the remote
266/// untouched, so a divergence is not yet fatal) and a remote exists. Under
267/// `--dry-run` it reports without fetching or changing anything.
268fn reconcile_diverged_remotes(
269    branches: &[String],
270    frozen: &BTreeSet<String>,
271    push: bool,
272    dry_run: bool,
273) -> Result<()> {
274    if !push {
275        return Ok(());
276    }
277    let remote = settings::remote()?;
278    if git::remote_url(&remote)?.is_none() {
279        return Ok(());
280    }
281
282    // Frozen branches are held back from the push, so their remote is not
283    // touched and any divergence there is not this run's problem.
284    let pushable: Vec<String> = branches
285        .iter()
286        .filter(|branch| !frozen.contains(*branch))
287        .cloned()
288        .collect();
289    if pushable.is_empty() {
290        return Ok(());
291    }
292
293    // restack/sync only fetched the trunk, so origin/<branch> can be stale
294    // here; refresh the stack branches' tracking refs so the check (and the
295    // lease that follows) see the true remote. A dry run must touch nothing,
296    // so it compares against whatever is already known locally.
297    if !dry_run {
298        git::fetch_tracking(&remote, &pushable)?;
299    }
300
301    let mut diverged: Vec<(String, Vec<(String, String)>)> = Vec::new();
302    for branch in &pushable {
303        let tracking = format!("{remote}/{branch}");
304        // No tracking ref means the branch was never pushed - nothing upstream
305        // to reconcile against.
306        if git::rev_parse(&tracking).is_err() {
307            continue;
308        }
309        let extra = git::remote_only_commits(branch, &tracking)?;
310        if !extra.is_empty() {
311            diverged.push((branch.clone(), extra));
312        }
313    }
314
315    if diverged.is_empty() {
316        return Ok(());
317    }
318
319    for (branch, commits) in &diverged {
320        anstream::eprintln!(
321            "{}",
322            style::warn(&format!(
323                "{remote}/{branch} has {} commit{} not in your local {branch}:",
324                commits.len(),
325                if commits.len() == 1 { "" } else { "s" },
326            ))
327        );
328        for (sha, subject) in commits {
329            anstream::eprintln!("  {} {subject}", style::dim(sha));
330        }
331    }
332
333    if dry_run {
334        anstream::println!(
335            "{}",
336            style::dim("would offer to cherry-pick these into your local branches before pushing")
337        );
338        return Ok(());
339    }
340
341    if !prompt::confirm("cherry-pick these into your local branches before pushing? [y/N] ")? {
342        bail!(
343            "remote branches have commits not in your local stack\n\
344             incorporate them (`git switch <branch> && git cherry-pick <sha>`) and re-run, \
345             or discard them with `git push --force {remote} <branch>`"
346        );
347    }
348
349    // Cherry-pick oldest-first onto each diverged branch. The branch's relation
350    // to its parent is unchanged, so the rebase loop leaves it "up to date" and
351    // keeps the picked commits, while its descendants rebase onto the new tip.
352    let start = git::current_branch()?;
353    for (branch, commits) in &diverged {
354        git::checkout(branch)?;
355        for (sha, _) in commits {
356            if let Err(error) = git::cherry_pick(sha) {
357                anstream::eprintln!(
358                    "{}",
359                    style::warn(&format!("conflict cherry-picking {sha} onto {branch}"))
360                );
361                eprintln!("resolve conflicts, run `git cherry-pick --continue`, then re-run");
362                eprintln!("or run `git cherry-pick --abort` to bail out");
363                return Err(error);
364            }
365        }
366    }
367    git::checkout(&start)?;
368    anstream::println!(
369        "{}",
370        style::success(&format!(
371            "incorporated remote commits into {}",
372            diverged
373                .iter()
374                .map(|(branch, _)| branch.as_str())
375                .collect::<Vec<_>>()
376                .join(" ")
377        ))
378    );
379    Ok(())
380}
381
382pub fn continue_restack() -> Result<()> {
383    let Some(state) = RestackState::read()? else {
384        bail!("no interrupted restack found");
385    };
386
387    if let Err(error) = git::rebase_continue() {
388        anstream::eprintln!("{}", style::warn("restack still has conflicts"));
389        eprintln!("resolve conflicts, then run `git stk continue`");
390        eprintln!("or run `git stk abort`");
391        return Err(error);
392    }
393
394    record_base(&state.branch, &state.parent);
395
396    let frozen: BTreeSet<String> = state.frozen.iter().cloned().collect();
397    if state.remaining.is_empty() {
398        clear_state()?;
399        finish_restack(&state.all, &frozen, state.push)?;
400        return Ok(());
401    }
402
403    let parents = parent_map()?;
404    restack_branches(
405        state.remaining,
406        &parents,
407        &frozen,
408        state.update_refs,
409        state.push,
410        &state.all,
411    )
412}
413
414pub fn abort_restack() -> Result<()> {
415    git::rebase_abort()?;
416    clear_state()?;
417    anstream::println!("restack aborted");
418    Ok(())
419}
420
421fn restack_order(current: &str, parents: &BTreeMap<String, String>) -> Vec<String> {
422    let children = children_map(parents);
423    let mut branches = Vec::new();
424
425    if parents.contains_key(current) {
426        branches.push(current.to_owned());
427    }
428
429    let mut visited = BTreeSet::from([current.to_owned()]);
430    collect_descendants(current, &children, &mut branches, &mut visited);
431    branches
432}
433
434fn restack_branches(
435    branches: Vec<String>,
436    parents: &BTreeMap<String, String>,
437    frozen: &BTreeSet<String>,
438    update_refs: bool,
439    push: bool,
440    all: &[String],
441) -> Result<()> {
442    for (index, branch) in branches.iter().enumerate() {
443        if frozen.contains(branch) {
444            anstream::println!("{}", frozen_note(branch));
445            continue;
446        }
447
448        let Some(parent) = parents.get(branch) else {
449            bail!("{branch} has no stack parent");
450        };
451
452        // Replay only the branch's own commits, from its current fork point, so
453        // commits already upstream - landed via squash or rebase merges, or
454        // trunk commits behind a stale recorded base - are not repeated. With
455        // no fork point to anchor on, fall back to a plain rebase.
456        let base = fork_point(branch, parent)?;
457
458        // Already sitting exactly on the parent tip with a fresh fork point:
459        // skip the rebase entirely. (git rebase --update-refs would otherwise
460        // replay and rewrite identical commits with new hashes.)
461        if up_to_date(branch, parent)? {
462            anstream::println!(
463                "{} already up to date with {}",
464                style::branch(branch),
465                style::branch(parent)
466            );
467            continue;
468        }
469
470        if update_refs {
471            anstream::println!(
472                "rebasing {} onto {} with --update-refs",
473                style::branch(branch),
474                style::branch(parent)
475            );
476        } else {
477            anstream::println!(
478                "rebasing {} onto {}",
479                style::branch(branch),
480                style::branch(parent)
481            );
482        }
483        let rebase_result = match &base {
484            Some(base) => git::rebase_onto(parent, base, branch, update_refs),
485            None => git::rebase(parent, branch, update_refs),
486        };
487
488        if let Err(error) = rebase_result {
489            let remaining = branches[index + 1..].to_vec();
490            RestackState {
491                branch: branch.to_owned(),
492                parent: parent.to_owned(),
493                remaining,
494                update_refs,
495                push,
496                all: all.to_vec(),
497                frozen: frozen.iter().cloned().collect(),
498            }
499            .write()?;
500
501            anstream::eprintln!(
502                "{}",
503                style::warn(&format!("conflict while rebasing {branch} onto {parent}"))
504            );
505            eprintln!("resolve conflicts, then run `git stk continue`");
506            eprintln!("or run `git stk abort`");
507            return Err(error);
508        }
509
510        record_base(branch, parent);
511    }
512
513    clear_state()?;
514    finish_restack(all, frozen, push)
515}
516
517/// After every branch has been rebased: push the rewritten branches, or print
518/// the exact command so stale remote PR diffs are a copy-paste away from fixed.
519/// Frozen branches (in a merge queue / merge train) are held back from the
520/// push - pushing them would be rejected (GitHub) or drop them from the queue
521/// (GitLab) - so only their pushable siblings are sent.
522fn finish_restack(branches: &[String], frozen: &BTreeSet<String>, push: bool) -> Result<()> {
523    anstream::println!("{}", style::success("restack complete"));
524
525    let remote = settings::remote()?;
526    let pushable: Vec<String> = branches
527        .iter()
528        .filter(|branch| !frozen.contains(*branch))
529        .cloned()
530        .collect();
531    if pushable.is_empty() {
532        anstream::println!(
533            "{}",
534            style::dim("nothing to push: every branch is in a merge queue")
535        );
536        return Ok(());
537    }
538
539    if push {
540        // Only the branches that actually landed: a branch enqueued between the
541        // freeze check and the push is held back, warned about, and dropped here
542        // so the "pushed ..." line never contradicts that warning.
543        let pushed = git::push_force_with_lease(&remote, &pushable)?;
544        if pushed.is_empty() {
545            anstream::println!(
546                "{}",
547                style::dim("nothing pushed: every branch is in a merge queue")
548            );
549        } else {
550            anstream::println!("pushed {} to {remote}", style::branch(&pushed.join(" ")));
551            // Keep the shared parent map in step with the pushed branches.
552            super::publish_metadata(&remote);
553        }
554    } else {
555        anstream::println!("remote branches may be stale; push them with:");
556        anstream::println!(
557            "{}",
558            style::dim(&format!(
559                "  git push --force-with-lease {remote} {}",
560                pushable.join(" ")
561            ))
562        );
563    }
564    Ok(())
565}
566
567fn resolve_update_refs(mode: UpdateRefsMode) -> Result<bool> {
568    match mode {
569        UpdateRefsMode::Config => {
570            let configured = git::config_get_bool(settings::UPDATE_REFS_KEY)?.unwrap_or(false);
571            if configured && !git::supports_rebase_update_refs()? {
572                eprintln!("stk.updateRefs is true, but this Git does not support --update-refs");
573                return Ok(false);
574            }
575            Ok(configured)
576        }
577        UpdateRefsMode::Enabled => {
578            if !git::supports_rebase_update_refs()? {
579                bail!("--update-refs was requested, but this Git does not support it");
580            }
581            Ok(true)
582        }
583        UpdateRefsMode::Disabled => Ok(false),
584    }
585}
586
587#[derive(Debug, Eq, PartialEq)]
588struct RestackState {
589    branch: String,
590    parent: String,
591    remaining: Vec<String>,
592    update_refs: bool,
593    push: bool,
594    /// Every branch in the interrupted restack, so the post-restack push (or
595    /// push hint) can cover branches rebased before the conflict too.
596    all: Vec<String>,
597    /// Branches frozen by a merge queue / merge train, so the resumed restack
598    /// keeps skipping them and the final push keeps holding them back.
599    frozen: Vec<String>,
600}
601
602impl RestackState {
603    fn read() -> Result<Option<Self>> {
604        let path = state_path()?;
605        if !path.exists() {
606            return Ok(None);
607        }
608
609        let contents = fs::read_to_string(&path)
610            .with_context(|| format!("failed to read {}", path.display()))?;
611        let mut branch = None;
612        let mut parent = None;
613        let mut remaining = Vec::new();
614        let mut update_refs = false;
615        let mut push = false;
616        let mut all = Vec::new();
617        let mut frozen = Vec::new();
618
619        for line in contents.lines() {
620            if let Some(value) = line.strip_prefix("branch=") {
621                branch = Some(value.to_owned());
622            } else if let Some(value) = line.strip_prefix("parent=") {
623                parent = Some(value.to_owned());
624            } else if let Some(value) = line.strip_prefix("updateRefs=") {
625                update_refs = value == "true";
626            } else if let Some(value) = line.strip_prefix("push=") {
627                push = value == "true";
628            } else if let Some(value) = line.strip_prefix("remaining=") {
629                remaining = value
630                    .split('\t')
631                    .filter(|branch| !branch.is_empty())
632                    .map(str::to_owned)
633                    .collect();
634            } else if let Some(value) = line.strip_prefix("all=") {
635                all = value
636                    .split('\t')
637                    .filter(|branch| !branch.is_empty())
638                    .map(str::to_owned)
639                    .collect();
640            } else if let Some(value) = line.strip_prefix("frozen=") {
641                frozen = value
642                    .split('\t')
643                    .filter(|branch| !branch.is_empty())
644                    .map(str::to_owned)
645                    .collect();
646            }
647        }
648
649        let Some(branch) = branch else {
650            bail!("restack state is missing current branch");
651        };
652        let Some(parent) = parent else {
653            bail!("restack state is missing parent branch");
654        };
655
656        Ok(Some(Self {
657            branch,
658            parent,
659            remaining,
660            update_refs,
661            push,
662            all,
663            frozen,
664        }))
665    }
666
667    fn write(&self) -> Result<()> {
668        let path = state_path()?;
669        let contents = format!(
670            "branch={}\nparent={}\nupdateRefs={}\npush={}\nremaining={}\nall={}\nfrozen={}\n",
671            self.branch,
672            self.parent,
673            self.update_refs,
674            self.push,
675            self.remaining.join("\t"),
676            self.all.join("\t"),
677            self.frozen.join("\t")
678        );
679        fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))
680    }
681}
682
683fn clear_state() -> Result<()> {
684    let path = state_path()?;
685    if path.exists() {
686        fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
687    }
688    Ok(())
689}
690
691fn state_path() -> Result<PathBuf> {
692    Ok(PathBuf::from(git::git_path(STATE_FILE)?))
693}
694
695/// Whether a restack is paused on a conflict, awaiting continue/abort.
696pub(super) fn in_progress() -> bool {
697    state_path().map(|path| path.exists()).unwrap_or(false)
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703
704    /// `main -> a -> b -> c`, as the restack records it: each branch's parent
705    /// is the one below it. `main` (the trunk) is outside the restack set.
706    fn linear_parents() -> BTreeMap<String, String> {
707        BTreeMap::from([
708            ("a".to_owned(), "main".to_owned()),
709            ("b".to_owned(), "a".to_owned()),
710            ("c".to_owned(), "b".to_owned()),
711        ])
712    }
713
714    fn set(branches: &[&str]) -> BTreeSet<String> {
715        branches.iter().map(|b| (*b).to_owned()).collect()
716    }
717
718    #[test]
719    fn a_queued_middle_branch_freezes_everything_below_it() {
720        // b is in the queue; a (its base) must not move, or b's queue entry
721        // goes stale. c, above b, is left to its no-op rebase on a frozen b.
722        let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
723        let frozen = with_frozen_ancestors(set(&["b"]), &branches, &linear_parents());
724        assert_eq!(frozen, set(&["a", "b"]));
725    }
726
727    #[test]
728    fn a_queued_bottom_branch_freezes_only_itself() {
729        // The common case: the bottom of the stack is queued, so there is no
730        // ancestor in the set to carry the freeze to.
731        let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
732        let frozen = with_frozen_ancestors(set(&["a"]), &branches, &linear_parents());
733        assert_eq!(frozen, set(&["a"]));
734    }
735
736    #[test]
737    fn freeze_stops_at_the_line_base_not_the_trunk() {
738        // Restacking only the b..c subtree: a is the line base and not in the
739        // set, so freezing c must not try to reach past it to main.
740        let branches = vec!["b".to_owned(), "c".to_owned()];
741        let frozen = with_frozen_ancestors(set(&["c"]), &branches, &linear_parents());
742        assert_eq!(frozen, set(&["b", "c"]));
743    }
744
745    #[test]
746    fn nothing_queued_freezes_nothing() {
747        let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
748        let frozen = with_frozen_ancestors(BTreeSet::new(), &branches, &linear_parents());
749        assert!(frozen.is_empty());
750    }
751}