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::providers::detect_review_provider;
16use crate::settings;
17use crate::style;
18
19const STATE_FILE: &str = "stack-state";
20
21pub fn restack(
22    fetch_mode: FetchMode,
23    update_refs_mode: UpdateRefsMode,
24    push_mode: PushMode,
25    dry_run: bool,
26) -> Result<()> {
27    let current = git::current_branch()?;
28    let parents = parent_map()?;
29    // Restack the stack containing the current branch, from anywhere in it:
30    // anchor on the bottom of its own line, then rebase that subtree
31    // parent-first. Anchoring on the line base rather than the trunk leaves
32    // sibling stacks that merely share the trunk alone - rebasing and
33    // force-pushing those would touch work this restack was never asked about.
34    let base = line_base(&current)?;
35    let branches = restack_order(&base, &parents);
36
37    if branches.is_empty() {
38        anstream::println!("{}", style::dim("nothing to restack"));
39        return Ok(());
40    }
41
42    // Update the trunk from the remote first so branches rebase onto its
43    // latest tip; otherwise warn when a base the stack sits on has moved on the
44    // remote, so "up to date" is never read off a stale local trunk.
45    if settings::fetch_enabled(fetch_mode)? {
46        fetch_trunk(dry_run)?;
47    }
48    warn_bases_behind_remote(&branches, &parents)?;
49
50    let update_refs = resolve_update_refs(update_refs_mode)?;
51    let push = settings::push_enabled(push_mode, settings::PUSH_ON_RESTACK_KEY)?;
52    let frozen = with_frozen_ancestors(frozen_branches(&branches), &branches, &parents);
53
54    if dry_run {
55        return print_restack_plan(&branches, &parents, &frozen, update_refs, push);
56    }
57
58    super::snapshot("restack");
59    clear_state()?;
60    let all = branches.clone();
61    restack_branches(branches, &parents, &frozen, update_refs, push, &all)
62}
63
64/// Branches in the restack set whose review is itself locked by a merge queue /
65/// merge train. Resolves the provider best-effort - no remote, or an
66/// unrecognized host, means no provider and so nothing frozen, which is exactly
67/// right for a purely local restack. [`with_frozen_ancestors`] then widens this
68/// to the branches that must move with them.
69fn frozen_branches(branches: &[String]) -> BTreeSet<String> {
70    let Ok((_, provider)) = detect_review_provider() else {
71        return BTreeSet::new();
72    };
73    provider.enqueued_branches(branches).unwrap_or_default()
74}
75
76/// Widen the directly-queued set to every branch *below* a queued one in the
77/// restack set. A queued review is computed (and merged) against its base, so
78/// rebasing or force-pushing any ancestor would move that base out from under
79/// the frozen tip and invalidate the queue entry. Freezing therefore propagates
80/// down the parent chain to the line base; descendants need no such treatment,
81/// since their (frozen) parent does not move and they stay up to date.
82fn with_frozen_ancestors(
83    queued: BTreeSet<String>,
84    branches: &[String],
85    parents: &BTreeMap<String, String>,
86) -> BTreeSet<String> {
87    let in_set: BTreeSet<&str> = branches.iter().map(String::as_str).collect();
88    let mut frozen = queued.clone();
89    for branch in &queued {
90        let mut current = branch.clone();
91        while let Some(parent) = parents.get(&current) {
92            // Stop at the line base (parent outside the set), and short-circuit
93            // when a shared ancestor was already frozen by an earlier branch.
94            if !in_set.contains(parent.as_str()) || !frozen.insert(parent.clone()) {
95                break;
96            }
97            current = parent.clone();
98        }
99    }
100    frozen
101}
102
103/// The line printed for a branch held out of the restack because a review in
104/// its stack sits in a merge queue / merge train - either this branch's own, or
105/// a descendant's, whose base this branch must not move.
106fn frozen_note(branch: &str) -> String {
107    format!(
108        "{} {}: not rebased or pushed (a branch in this stack is in a merge queue; dequeue it to continue)",
109        style::warn("frozen"),
110        style::branch(branch),
111    )
112}
113
114/// The plan, read-only: which branches would rebase and which already sit
115/// on their parents.
116fn print_restack_plan(
117    branches: &[String],
118    parents: &BTreeMap<String, String>,
119    frozen: &BTreeSet<String>,
120    update_refs: bool,
121    push: bool,
122) -> Result<()> {
123    for branch in branches {
124        if frozen.contains(branch) {
125            anstream::println!("{}", frozen_note(branch));
126            continue;
127        }
128
129        let Some(parent) = parents.get(branch) else {
130            bail!("{branch} has no stack parent");
131        };
132
133        if up_to_date(branch, parent)? {
134            anstream::println!(
135                "{} already up to date with {}",
136                style::branch(branch),
137                style::branch(parent)
138            );
139        } else {
140            anstream::println!(
141                "would rebase {} onto {}{}",
142                style::branch(branch),
143                style::branch(parent),
144                if update_refs {
145                    " with --update-refs"
146                } else {
147                    ""
148                }
149            );
150        }
151    }
152
153    if push {
154        let pushable: Vec<&str> = branches
155            .iter()
156            .filter(|branch| !frozen.contains(*branch))
157            .map(String::as_str)
158            .collect();
159        if pushable.is_empty() {
160            anstream::println!(
161                "{}",
162                style::dim("nothing to push: every branch is in a merge queue")
163            );
164        } else {
165            anstream::println!(
166                "would push {} to {}",
167                style::branch(&pushable.join(" ")),
168                settings::remote()?
169            );
170        }
171    }
172    Ok(())
173}
174
175/// Sitting exactly on the parent tip with a fresh fork point: nothing to do.
176fn up_to_date(branch: &str, parent: &str) -> Result<bool> {
177    let parent_tip = git::rev_parse(parent)?;
178    Ok(
179        fork_point(branch, parent)?.as_deref() == Some(parent_tip.as_str())
180            && git::is_ancestor(parent, branch).unwrap_or(false),
181    )
182}
183
184/// Fast-forward the trunk from the remote before restacking. Fetching the
185/// branch in place (rather than the whole remote) keeps it cheap; on the trunk
186/// itself a plain fast-forward pull does the same. A missing remote is a no-op,
187/// not an error - there is simply nothing to pull.
188fn fetch_trunk(dry_run: bool) -> Result<()> {
189    let Some(trunk) = super::trunk_branch(&git::local_branches()?) else {
190        return Ok(());
191    };
192    let remote = settings::remote()?;
193    if git::remote_url(&remote)?.is_none() {
194        anstream::println!(
195            "{}",
196            style::dim(&format!("no remote {remote}; skipped fetch"))
197        );
198        return Ok(());
199    }
200    if dry_run {
201        anstream::println!("would fetch {} from {remote}", style::branch(&trunk));
202        return Ok(());
203    }
204    if git::current_branch()? == trunk {
205        git::pull_ff_only()?;
206    } else {
207        git::fetch_branch(&remote, &trunk)?;
208    }
209    anstream::println!("fetched {} from {remote}", style::branch(&trunk));
210    Ok(())
211}
212
213/// Warn when a base the stack rebases onto - the trunk, or any parent outside
214/// the restack set - is behind its remote-tracking branch. Without this, a
215/// branch sitting exactly on a stale local base reads as "up to date" while the
216/// base on the remote has moved on. Best-effort: no remote, or no
217/// remote-tracking ref to compare against, means nothing to warn about.
218fn warn_bases_behind_remote(branches: &[String], parents: &BTreeMap<String, String>) -> Result<()> {
219    let remote = settings::remote()?;
220    if git::remote_url(&remote)?.is_none() {
221        return Ok(());
222    }
223
224    let in_stack: BTreeSet<&String> = branches.iter().collect();
225    let external: BTreeSet<&String> = branches
226        .iter()
227        .filter_map(|branch| parents.get(branch))
228        .filter(|parent| !in_stack.contains(parent))
229        .collect();
230
231    for base in external {
232        let tracking = format!("{remote}/{base}");
233        if git::rev_parse(&tracking).is_err() {
234            continue;
235        }
236        let behind = git::commits_behind(base, &tracking).unwrap_or(0);
237        if behind > 0 {
238            anstream::eprintln!(
239                "{}",
240                style::warn(&format!(
241                    "{base} is {behind} commit{} behind {tracking}; run `git stk restack --fetch` or `git stk sync` to update it first",
242                    if behind == 1 { "" } else { "s" }
243                ))
244            );
245        }
246    }
247    Ok(())
248}
249
250pub fn continue_restack() -> Result<()> {
251    let Some(state) = RestackState::read()? else {
252        bail!("no interrupted restack found");
253    };
254
255    if let Err(error) = git::rebase_continue() {
256        anstream::eprintln!("{}", style::warn("restack still has conflicts"));
257        eprintln!("resolve conflicts, then run `git stk continue`");
258        eprintln!("or run `git stk abort`");
259        return Err(error);
260    }
261
262    record_base(&state.branch, &state.parent);
263
264    let frozen: BTreeSet<String> = state.frozen.iter().cloned().collect();
265    if state.remaining.is_empty() {
266        clear_state()?;
267        finish_restack(&state.all, &frozen, state.push)?;
268        return Ok(());
269    }
270
271    let parents = parent_map()?;
272    restack_branches(
273        state.remaining,
274        &parents,
275        &frozen,
276        state.update_refs,
277        state.push,
278        &state.all,
279    )
280}
281
282pub fn abort_restack() -> Result<()> {
283    git::rebase_abort()?;
284    clear_state()?;
285    anstream::println!("restack aborted");
286    Ok(())
287}
288
289fn restack_order(current: &str, parents: &BTreeMap<String, String>) -> Vec<String> {
290    let children = children_map(parents);
291    let mut branches = Vec::new();
292
293    if parents.contains_key(current) {
294        branches.push(current.to_owned());
295    }
296
297    let mut visited = BTreeSet::from([current.to_owned()]);
298    collect_descendants(current, &children, &mut branches, &mut visited);
299    branches
300}
301
302fn restack_branches(
303    branches: Vec<String>,
304    parents: &BTreeMap<String, String>,
305    frozen: &BTreeSet<String>,
306    update_refs: bool,
307    push: bool,
308    all: &[String],
309) -> Result<()> {
310    for (index, branch) in branches.iter().enumerate() {
311        if frozen.contains(branch) {
312            anstream::println!("{}", frozen_note(branch));
313            continue;
314        }
315
316        let Some(parent) = parents.get(branch) else {
317            bail!("{branch} has no stack parent");
318        };
319
320        // Replay only the branch's own commits, from its current fork point, so
321        // commits already upstream - landed via squash or rebase merges, or
322        // trunk commits behind a stale recorded base - are not repeated. With
323        // no fork point to anchor on, fall back to a plain rebase.
324        let base = fork_point(branch, parent)?;
325
326        // Already sitting exactly on the parent tip with a fresh fork point:
327        // skip the rebase entirely. (git rebase --update-refs would otherwise
328        // replay and rewrite identical commits with new hashes.)
329        if up_to_date(branch, parent)? {
330            anstream::println!(
331                "{} already up to date with {}",
332                style::branch(branch),
333                style::branch(parent)
334            );
335            continue;
336        }
337
338        if update_refs {
339            anstream::println!(
340                "rebasing {} onto {} with --update-refs",
341                style::branch(branch),
342                style::branch(parent)
343            );
344        } else {
345            anstream::println!(
346                "rebasing {} onto {}",
347                style::branch(branch),
348                style::branch(parent)
349            );
350        }
351        let rebase_result = match &base {
352            Some(base) => git::rebase_onto(parent, base, branch, update_refs),
353            None => git::rebase(parent, branch, update_refs),
354        };
355
356        if let Err(error) = rebase_result {
357            let remaining = branches[index + 1..].to_vec();
358            RestackState {
359                branch: branch.to_owned(),
360                parent: parent.to_owned(),
361                remaining,
362                update_refs,
363                push,
364                all: all.to_vec(),
365                frozen: frozen.iter().cloned().collect(),
366            }
367            .write()?;
368
369            anstream::eprintln!(
370                "{}",
371                style::warn(&format!("conflict while rebasing {branch} onto {parent}"))
372            );
373            eprintln!("resolve conflicts, then run `git stk continue`");
374            eprintln!("or run `git stk abort`");
375            return Err(error);
376        }
377
378        record_base(branch, parent);
379    }
380
381    clear_state()?;
382    finish_restack(all, frozen, push)
383}
384
385/// After every branch has been rebased: push the rewritten branches, or print
386/// the exact command so stale remote PR diffs are a copy-paste away from fixed.
387/// Frozen branches (in a merge queue / merge train) are held back from the
388/// push - pushing them would be rejected (GitHub) or drop them from the queue
389/// (GitLab) - so only their pushable siblings are sent.
390fn finish_restack(branches: &[String], frozen: &BTreeSet<String>, push: bool) -> Result<()> {
391    anstream::println!("{}", style::success("restack complete"));
392
393    let remote = settings::remote()?;
394    let pushable: Vec<String> = branches
395        .iter()
396        .filter(|branch| !frozen.contains(*branch))
397        .cloned()
398        .collect();
399    if pushable.is_empty() {
400        anstream::println!(
401            "{}",
402            style::dim("nothing to push: every branch is in a merge queue")
403        );
404        return Ok(());
405    }
406
407    if push {
408        // Only the branches that actually landed: a branch enqueued between the
409        // freeze check and the push is held back, warned about, and dropped here
410        // so the "pushed ..." line never contradicts that warning.
411        let pushed = git::push_force_with_lease(&remote, &pushable)?;
412        if pushed.is_empty() {
413            anstream::println!(
414                "{}",
415                style::dim("nothing pushed: every branch is in a merge queue")
416            );
417        } else {
418            anstream::println!("pushed {} to {remote}", style::branch(&pushed.join(" ")));
419            // Keep the shared parent map in step with the pushed branches.
420            super::publish_metadata(&remote);
421        }
422    } else {
423        anstream::println!("remote branches may be stale; push them with:");
424        anstream::println!(
425            "{}",
426            style::dim(&format!(
427                "  git push --force-with-lease {remote} {}",
428                pushable.join(" ")
429            ))
430        );
431    }
432    Ok(())
433}
434
435fn resolve_update_refs(mode: UpdateRefsMode) -> Result<bool> {
436    match mode {
437        UpdateRefsMode::Config => {
438            let configured = git::config_get_bool(settings::UPDATE_REFS_KEY)?.unwrap_or(false);
439            if configured && !git::supports_rebase_update_refs()? {
440                eprintln!("stk.updateRefs is true, but this Git does not support --update-refs");
441                return Ok(false);
442            }
443            Ok(configured)
444        }
445        UpdateRefsMode::Enabled => {
446            if !git::supports_rebase_update_refs()? {
447                bail!("--update-refs was requested, but this Git does not support it");
448            }
449            Ok(true)
450        }
451        UpdateRefsMode::Disabled => Ok(false),
452    }
453}
454
455#[derive(Debug, Eq, PartialEq)]
456struct RestackState {
457    branch: String,
458    parent: String,
459    remaining: Vec<String>,
460    update_refs: bool,
461    push: bool,
462    /// Every branch in the interrupted restack, so the post-restack push (or
463    /// push hint) can cover branches rebased before the conflict too.
464    all: Vec<String>,
465    /// Branches frozen by a merge queue / merge train, so the resumed restack
466    /// keeps skipping them and the final push keeps holding them back.
467    frozen: Vec<String>,
468}
469
470impl RestackState {
471    fn read() -> Result<Option<Self>> {
472        let path = state_path()?;
473        if !path.exists() {
474            return Ok(None);
475        }
476
477        let contents = fs::read_to_string(&path)
478            .with_context(|| format!("failed to read {}", path.display()))?;
479        let mut branch = None;
480        let mut parent = None;
481        let mut remaining = Vec::new();
482        let mut update_refs = false;
483        let mut push = false;
484        let mut all = Vec::new();
485        let mut frozen = Vec::new();
486
487        for line in contents.lines() {
488            if let Some(value) = line.strip_prefix("branch=") {
489                branch = Some(value.to_owned());
490            } else if let Some(value) = line.strip_prefix("parent=") {
491                parent = Some(value.to_owned());
492            } else if let Some(value) = line.strip_prefix("updateRefs=") {
493                update_refs = value == "true";
494            } else if let Some(value) = line.strip_prefix("push=") {
495                push = value == "true";
496            } else if let Some(value) = line.strip_prefix("remaining=") {
497                remaining = value
498                    .split('\t')
499                    .filter(|branch| !branch.is_empty())
500                    .map(str::to_owned)
501                    .collect();
502            } else if let Some(value) = line.strip_prefix("all=") {
503                all = value
504                    .split('\t')
505                    .filter(|branch| !branch.is_empty())
506                    .map(str::to_owned)
507                    .collect();
508            } else if let Some(value) = line.strip_prefix("frozen=") {
509                frozen = value
510                    .split('\t')
511                    .filter(|branch| !branch.is_empty())
512                    .map(str::to_owned)
513                    .collect();
514            }
515        }
516
517        let Some(branch) = branch else {
518            bail!("restack state is missing current branch");
519        };
520        let Some(parent) = parent else {
521            bail!("restack state is missing parent branch");
522        };
523
524        Ok(Some(Self {
525            branch,
526            parent,
527            remaining,
528            update_refs,
529            push,
530            all,
531            frozen,
532        }))
533    }
534
535    fn write(&self) -> Result<()> {
536        let path = state_path()?;
537        let contents = format!(
538            "branch={}\nparent={}\nupdateRefs={}\npush={}\nremaining={}\nall={}\nfrozen={}\n",
539            self.branch,
540            self.parent,
541            self.update_refs,
542            self.push,
543            self.remaining.join("\t"),
544            self.all.join("\t"),
545            self.frozen.join("\t")
546        );
547        fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))
548    }
549}
550
551fn clear_state() -> Result<()> {
552    let path = state_path()?;
553    if path.exists() {
554        fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
555    }
556    Ok(())
557}
558
559fn state_path() -> Result<PathBuf> {
560    Ok(PathBuf::from(git::git_path(STATE_FILE)?))
561}
562
563/// Whether a restack is paused on a conflict, awaiting continue/abort.
564pub(super) fn in_progress() -> bool {
565    state_path().map(|path| path.exists()).unwrap_or(false)
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    /// `main -> a -> b -> c`, as the restack records it: each branch's parent
573    /// is the one below it. `main` (the trunk) is outside the restack set.
574    fn linear_parents() -> BTreeMap<String, String> {
575        BTreeMap::from([
576            ("a".to_owned(), "main".to_owned()),
577            ("b".to_owned(), "a".to_owned()),
578            ("c".to_owned(), "b".to_owned()),
579        ])
580    }
581
582    fn set(branches: &[&str]) -> BTreeSet<String> {
583        branches.iter().map(|b| (*b).to_owned()).collect()
584    }
585
586    #[test]
587    fn a_queued_middle_branch_freezes_everything_below_it() {
588        // b is in the queue; a (its base) must not move, or b's queue entry
589        // goes stale. c, above b, is left to its no-op rebase on a frozen b.
590        let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
591        let frozen = with_frozen_ancestors(set(&["b"]), &branches, &linear_parents());
592        assert_eq!(frozen, set(&["a", "b"]));
593    }
594
595    #[test]
596    fn a_queued_bottom_branch_freezes_only_itself() {
597        // The common case: the bottom of the stack is queued, so there is no
598        // ancestor in the set to carry the freeze to.
599        let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
600        let frozen = with_frozen_ancestors(set(&["a"]), &branches, &linear_parents());
601        assert_eq!(frozen, set(&["a"]));
602    }
603
604    #[test]
605    fn freeze_stops_at_the_line_base_not_the_trunk() {
606        // Restacking only the b..c subtree: a is the line base and not in the
607        // set, so freezing c must not try to reach past it to main.
608        let branches = vec!["b".to_owned(), "c".to_owned()];
609        let frozen = with_frozen_ancestors(set(&["c"]), &branches, &linear_parents());
610        assert_eq!(frozen, set(&["b", "c"]));
611    }
612
613    #[test]
614    fn nothing_queued_freezes_nothing() {
615        let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
616        let frozen = with_frozen_ancestors(BTreeSet::new(), &branches, &linear_parents());
617        assert!(frozen.is_empty());
618    }
619}