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::{base_of, children_map, collect_descendants, 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/// The recorded fork point, when it is still an ancestor of the branch.
176fn valid_base(branch: &str) -> Result<Option<String>> {
177    Ok(match base_of(branch)? {
178        Some(base) if git::is_ancestor(&base, branch).unwrap_or(false) => Some(base),
179        _ => None,
180    })
181}
182
183/// Sitting exactly on the parent tip with a fresh fork point: nothing to do.
184fn up_to_date(branch: &str, parent: &str) -> Result<bool> {
185    let parent_tip = git::rev_parse(parent)?;
186    Ok(valid_base(branch)?.as_deref() == Some(parent_tip.as_str())
187        && git::is_ancestor(parent, branch).unwrap_or(false))
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
256pub fn continue_restack() -> Result<()> {
257    let Some(state) = RestackState::read()? else {
258        bail!("no interrupted restack found");
259    };
260
261    if let Err(error) = git::rebase_continue() {
262        anstream::eprintln!("{}", style::warn("restack still has conflicts"));
263        eprintln!("resolve conflicts, then run `git stk continue`");
264        eprintln!("or run `git stk abort`");
265        return Err(error);
266    }
267
268    record_base(&state.branch, &state.parent);
269
270    let frozen: BTreeSet<String> = state.frozen.iter().cloned().collect();
271    if state.remaining.is_empty() {
272        clear_state()?;
273        finish_restack(&state.all, &frozen, state.push)?;
274        return Ok(());
275    }
276
277    let parents = parent_map()?;
278    restack_branches(
279        state.remaining,
280        &parents,
281        &frozen,
282        state.update_refs,
283        state.push,
284        &state.all,
285    )
286}
287
288pub fn abort_restack() -> Result<()> {
289    git::rebase_abort()?;
290    clear_state()?;
291    anstream::println!("restack aborted");
292    Ok(())
293}
294
295fn restack_order(current: &str, parents: &BTreeMap<String, String>) -> Vec<String> {
296    let children = children_map(parents);
297    let mut branches = Vec::new();
298
299    if parents.contains_key(current) {
300        branches.push(current.to_owned());
301    }
302
303    let mut visited = BTreeSet::from([current.to_owned()]);
304    collect_descendants(current, &children, &mut branches, &mut visited);
305    branches
306}
307
308fn restack_branches(
309    branches: Vec<String>,
310    parents: &BTreeMap<String, String>,
311    frozen: &BTreeSet<String>,
312    update_refs: bool,
313    push: bool,
314    all: &[String],
315) -> Result<()> {
316    for (index, branch) in branches.iter().enumerate() {
317        if frozen.contains(branch) {
318            anstream::println!("{}", frozen_note(branch));
319            continue;
320        }
321
322        let Some(parent) = parents.get(branch) else {
323            bail!("{branch} has no stack parent");
324        };
325
326        // Replay only the commits after the recorded fork point so commits
327        // that landed upstream via squash or rebase merges are not repeated.
328        // A base that is no longer an ancestor (stale or garbage) falls back
329        // to a plain rebase.
330        let base = valid_base(branch)?;
331
332        // Already sitting exactly on the parent tip with a fresh fork point:
333        // skip the rebase entirely. (git rebase --update-refs would otherwise
334        // replay and rewrite identical commits with new hashes.)
335        if up_to_date(branch, parent)? {
336            anstream::println!(
337                "{} already up to date with {}",
338                style::branch(branch),
339                style::branch(parent)
340            );
341            continue;
342        }
343
344        if update_refs {
345            anstream::println!(
346                "rebasing {} onto {} with --update-refs",
347                style::branch(branch),
348                style::branch(parent)
349            );
350        } else {
351            anstream::println!(
352                "rebasing {} onto {}",
353                style::branch(branch),
354                style::branch(parent)
355            );
356        }
357        let rebase_result = match &base {
358            Some(base) => git::rebase_onto(parent, base, branch, update_refs),
359            None => git::rebase(parent, branch, update_refs),
360        };
361
362        if let Err(error) = rebase_result {
363            let remaining = branches[index + 1..].to_vec();
364            RestackState {
365                branch: branch.to_owned(),
366                parent: parent.to_owned(),
367                remaining,
368                update_refs,
369                push,
370                all: all.to_vec(),
371                frozen: frozen.iter().cloned().collect(),
372            }
373            .write()?;
374
375            anstream::eprintln!(
376                "{}",
377                style::warn(&format!("conflict while rebasing {branch} onto {parent}"))
378            );
379            eprintln!("resolve conflicts, then run `git stk continue`");
380            eprintln!("or run `git stk abort`");
381            return Err(error);
382        }
383
384        record_base(branch, parent);
385    }
386
387    clear_state()?;
388    finish_restack(all, frozen, push)
389}
390
391/// After every branch has been rebased: push the rewritten branches, or print
392/// the exact command so stale remote PR diffs are a copy-paste away from fixed.
393/// Frozen branches (in a merge queue / merge train) are held back from the
394/// push - pushing them would be rejected (GitHub) or drop them from the queue
395/// (GitLab) - so only their pushable siblings are sent.
396fn finish_restack(branches: &[String], frozen: &BTreeSet<String>, push: bool) -> Result<()> {
397    anstream::println!("{}", style::success("restack complete"));
398
399    let remote = settings::remote()?;
400    let pushable: Vec<String> = branches
401        .iter()
402        .filter(|branch| !frozen.contains(*branch))
403        .cloned()
404        .collect();
405    if pushable.is_empty() {
406        anstream::println!(
407            "{}",
408            style::dim("nothing to push: every branch is in a merge queue")
409        );
410        return Ok(());
411    }
412
413    if push {
414        // Only the branches that actually landed: a branch enqueued between the
415        // freeze check and the push is held back, warned about, and dropped here
416        // so the "pushed ..." line never contradicts that warning.
417        let pushed = git::push_force_with_lease(&remote, &pushable)?;
418        if pushed.is_empty() {
419            anstream::println!(
420                "{}",
421                style::dim("nothing pushed: every branch is in a merge queue")
422            );
423        } else {
424            anstream::println!("pushed {} to {remote}", style::branch(&pushed.join(" ")));
425            // Keep the shared parent map in step with the pushed branches.
426            super::publish_metadata(&remote);
427        }
428    } else {
429        anstream::println!("remote branches may be stale; push them with:");
430        anstream::println!(
431            "{}",
432            style::dim(&format!(
433                "  git push --force-with-lease {remote} {}",
434                pushable.join(" ")
435            ))
436        );
437    }
438    Ok(())
439}
440
441fn resolve_update_refs(mode: UpdateRefsMode) -> Result<bool> {
442    match mode {
443        UpdateRefsMode::Config => {
444            let configured = git::config_get_bool(settings::UPDATE_REFS_KEY)?.unwrap_or(false);
445            if configured && !git::supports_rebase_update_refs()? {
446                eprintln!("stk.updateRefs is true, but this Git does not support --update-refs");
447                return Ok(false);
448            }
449            Ok(configured)
450        }
451        UpdateRefsMode::Enabled => {
452            if !git::supports_rebase_update_refs()? {
453                bail!("--update-refs was requested, but this Git does not support it");
454            }
455            Ok(true)
456        }
457        UpdateRefsMode::Disabled => Ok(false),
458    }
459}
460
461#[derive(Debug, Eq, PartialEq)]
462struct RestackState {
463    branch: String,
464    parent: String,
465    remaining: Vec<String>,
466    update_refs: bool,
467    push: bool,
468    /// Every branch in the interrupted restack, so the post-restack push (or
469    /// push hint) can cover branches rebased before the conflict too.
470    all: Vec<String>,
471    /// Branches frozen by a merge queue / merge train, so the resumed restack
472    /// keeps skipping them and the final push keeps holding them back.
473    frozen: Vec<String>,
474}
475
476impl RestackState {
477    fn read() -> Result<Option<Self>> {
478        let path = state_path()?;
479        if !path.exists() {
480            return Ok(None);
481        }
482
483        let contents = fs::read_to_string(&path)
484            .with_context(|| format!("failed to read {}", path.display()))?;
485        let mut branch = None;
486        let mut parent = None;
487        let mut remaining = Vec::new();
488        let mut update_refs = false;
489        let mut push = false;
490        let mut all = Vec::new();
491        let mut frozen = Vec::new();
492
493        for line in contents.lines() {
494            if let Some(value) = line.strip_prefix("branch=") {
495                branch = Some(value.to_owned());
496            } else if let Some(value) = line.strip_prefix("parent=") {
497                parent = Some(value.to_owned());
498            } else if let Some(value) = line.strip_prefix("updateRefs=") {
499                update_refs = value == "true";
500            } else if let Some(value) = line.strip_prefix("push=") {
501                push = value == "true";
502            } else if let Some(value) = line.strip_prefix("remaining=") {
503                remaining = 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("all=") {
509                all = value
510                    .split('\t')
511                    .filter(|branch| !branch.is_empty())
512                    .map(str::to_owned)
513                    .collect();
514            } else if let Some(value) = line.strip_prefix("frozen=") {
515                frozen = value
516                    .split('\t')
517                    .filter(|branch| !branch.is_empty())
518                    .map(str::to_owned)
519                    .collect();
520            }
521        }
522
523        let Some(branch) = branch else {
524            bail!("restack state is missing current branch");
525        };
526        let Some(parent) = parent else {
527            bail!("restack state is missing parent branch");
528        };
529
530        Ok(Some(Self {
531            branch,
532            parent,
533            remaining,
534            update_refs,
535            push,
536            all,
537            frozen,
538        }))
539    }
540
541    fn write(&self) -> Result<()> {
542        let path = state_path()?;
543        let contents = format!(
544            "branch={}\nparent={}\nupdateRefs={}\npush={}\nremaining={}\nall={}\nfrozen={}\n",
545            self.branch,
546            self.parent,
547            self.update_refs,
548            self.push,
549            self.remaining.join("\t"),
550            self.all.join("\t"),
551            self.frozen.join("\t")
552        );
553        fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))
554    }
555}
556
557fn clear_state() -> Result<()> {
558    let path = state_path()?;
559    if path.exists() {
560        fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
561    }
562    Ok(())
563}
564
565fn state_path() -> Result<PathBuf> {
566    Ok(PathBuf::from(git::git_path(STATE_FILE)?))
567}
568
569/// Whether a restack is paused on a conflict, awaiting continue/abort.
570pub(super) fn in_progress() -> bool {
571    state_path().map(|path| path.exists()).unwrap_or(false)
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    /// `main -> a -> b -> c`, as the restack records it: each branch's parent
579    /// is the one below it. `main` (the trunk) is outside the restack set.
580    fn linear_parents() -> BTreeMap<String, String> {
581        BTreeMap::from([
582            ("a".to_owned(), "main".to_owned()),
583            ("b".to_owned(), "a".to_owned()),
584            ("c".to_owned(), "b".to_owned()),
585        ])
586    }
587
588    fn set(branches: &[&str]) -> BTreeSet<String> {
589        branches.iter().map(|b| (*b).to_owned()).collect()
590    }
591
592    #[test]
593    fn a_queued_middle_branch_freezes_everything_below_it() {
594        // b is in the queue; a (its base) must not move, or b's queue entry
595        // goes stale. c, above b, is left to its no-op rebase on a frozen b.
596        let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
597        let frozen = with_frozen_ancestors(set(&["b"]), &branches, &linear_parents());
598        assert_eq!(frozen, set(&["a", "b"]));
599    }
600
601    #[test]
602    fn a_queued_bottom_branch_freezes_only_itself() {
603        // The common case: the bottom of the stack is queued, so there is no
604        // ancestor in the set to carry the freeze to.
605        let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
606        let frozen = with_frozen_ancestors(set(&["a"]), &branches, &linear_parents());
607        assert_eq!(frozen, set(&["a"]));
608    }
609
610    #[test]
611    fn freeze_stops_at_the_line_base_not_the_trunk() {
612        // Restacking only the b..c subtree: a is the line base and not in the
613        // set, so freezing c must not try to reach past it to main.
614        let branches = vec!["b".to_owned(), "c".to_owned()];
615        let frozen = with_frozen_ancestors(set(&["c"]), &branches, &linear_parents());
616        assert_eq!(frozen, set(&["b", "c"]));
617    }
618
619    #[test]
620    fn nothing_queued_freezes_nothing() {
621        let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
622        let frozen = with_frozen_ancestors(BTreeSet::new(), &branches, &linear_parents());
623        assert!(frozen.is_empty());
624    }
625}