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::{collections::BTreeMap, fs, path::PathBuf};
5
6use anyhow::{Context, Result, bail};
7
8use super::{base_of, children_map, collect_descendants, parent_map, record_base, root_for};
9use crate::cli::{PushMode, UpdateRefsMode};
10use crate::git;
11use crate::settings;
12use crate::style;
13
14const STATE_FILE: &str = "stack-state";
15
16pub fn restack(update_refs_mode: UpdateRefsMode, push_mode: PushMode, dry_run: bool) -> Result<()> {
17    let current = git::current_branch()?;
18    let parents = parent_map()?;
19    // Restack the whole stack containing the current branch, from anywhere
20    // in it: walk to the root, then rebase its descendants parent-first.
21    let root = root_for(&current, &parents);
22    let branches = restack_order(&root, &parents);
23
24    if branches.is_empty() {
25        anstream::println!("{}", style::dim("nothing to restack"));
26        return Ok(());
27    }
28
29    let update_refs = resolve_update_refs(update_refs_mode)?;
30    let push = settings::push_enabled(push_mode, settings::PUSH_ON_RESTACK_KEY)?;
31
32    if dry_run {
33        return print_restack_plan(&branches, &parents, update_refs, push);
34    }
35
36    super::snapshot("restack");
37    clear_state()?;
38    let all = branches.clone();
39    restack_branches(branches, &parents, update_refs, push, &all)
40}
41
42/// The plan, read-only: which branches would rebase and which already sit
43/// on their parents.
44fn print_restack_plan(
45    branches: &[String],
46    parents: &BTreeMap<String, String>,
47    update_refs: bool,
48    push: bool,
49) -> Result<()> {
50    for branch in branches {
51        let Some(parent) = parents.get(branch) else {
52            bail!("{branch} has no stack parent");
53        };
54
55        if up_to_date(branch, parent)? {
56            anstream::println!(
57                "{} already up to date with {}",
58                style::branch(branch),
59                style::branch(parent)
60            );
61        } else {
62            anstream::println!(
63                "would rebase {} onto {}{}",
64                style::branch(branch),
65                style::branch(parent),
66                if update_refs {
67                    " with --update-refs"
68                } else {
69                    ""
70                }
71            );
72        }
73    }
74
75    if push {
76        anstream::println!(
77            "would push {} to {}",
78            style::branch(&branches.join(" ")),
79            settings::remote()?
80        );
81    }
82    Ok(())
83}
84
85/// The recorded fork point, when it is still an ancestor of the branch.
86fn valid_base(branch: &str) -> Result<Option<String>> {
87    Ok(match base_of(branch)? {
88        Some(base) if git::is_ancestor(&base, branch).unwrap_or(false) => Some(base),
89        _ => None,
90    })
91}
92
93/// Sitting exactly on the parent tip with a fresh fork point: nothing to do.
94fn up_to_date(branch: &str, parent: &str) -> Result<bool> {
95    let parent_tip = git::rev_parse(parent)?;
96    Ok(valid_base(branch)?.as_deref() == Some(parent_tip.as_str())
97        && git::is_ancestor(parent, branch).unwrap_or(false))
98}
99
100pub fn continue_restack() -> Result<()> {
101    let Some(state) = RestackState::read()? else {
102        bail!("no interrupted restack found");
103    };
104
105    if let Err(error) = git::rebase_continue() {
106        anstream::eprintln!("{}", style::warn("restack still has conflicts"));
107        eprintln!("resolve conflicts, then run `git stk continue`");
108        eprintln!("or run `git stk abort`");
109        return Err(error);
110    }
111
112    record_base(&state.branch, &state.parent);
113
114    if state.remaining.is_empty() {
115        clear_state()?;
116        finish_restack(&state.all, state.push)?;
117        return Ok(());
118    }
119
120    let parents = parent_map()?;
121    restack_branches(
122        state.remaining,
123        &parents,
124        state.update_refs,
125        state.push,
126        &state.all,
127    )
128}
129
130pub fn abort_restack() -> Result<()> {
131    git::rebase_abort()?;
132    clear_state()?;
133    println!("restack aborted");
134    Ok(())
135}
136
137fn restack_order(current: &str, parents: &BTreeMap<String, String>) -> Vec<String> {
138    let children = children_map(parents);
139    let mut branches = Vec::new();
140
141    if parents.contains_key(current) {
142        branches.push(current.to_owned());
143    }
144
145    collect_descendants(current, &children, &mut branches);
146    branches
147}
148
149fn restack_branches(
150    branches: Vec<String>,
151    parents: &BTreeMap<String, String>,
152    update_refs: bool,
153    push: bool,
154    all: &[String],
155) -> Result<()> {
156    for (index, branch) in branches.iter().enumerate() {
157        let Some(parent) = parents.get(branch) else {
158            bail!("{branch} has no stack parent");
159        };
160
161        // Replay only the commits after the recorded fork point so commits
162        // that landed upstream via squash or rebase merges are not repeated.
163        // A base that is no longer an ancestor (stale or garbage) falls back
164        // to a plain rebase.
165        let base = valid_base(branch)?;
166
167        // Already sitting exactly on the parent tip with a fresh fork point:
168        // skip the rebase entirely. (git rebase --update-refs would otherwise
169        // replay and rewrite identical commits with new hashes.)
170        if up_to_date(branch, parent)? {
171            anstream::println!(
172                "{} already up to date with {}",
173                style::branch(branch),
174                style::branch(parent)
175            );
176            continue;
177        }
178
179        if update_refs {
180            anstream::println!(
181                "rebasing {} onto {} with --update-refs",
182                style::branch(branch),
183                style::branch(parent)
184            );
185        } else {
186            anstream::println!(
187                "rebasing {} onto {}",
188                style::branch(branch),
189                style::branch(parent)
190            );
191        }
192        let rebase_result = match &base {
193            Some(base) => git::rebase_onto(parent, base, branch, update_refs),
194            None => git::rebase(parent, branch, update_refs),
195        };
196
197        if let Err(error) = rebase_result {
198            let remaining = branches[index + 1..].to_vec();
199            RestackState {
200                branch: branch.to_owned(),
201                parent: parent.to_owned(),
202                remaining,
203                update_refs,
204                push,
205                all: all.to_vec(),
206            }
207            .write()?;
208
209            anstream::eprintln!(
210                "{}",
211                style::warn(&format!("conflict while rebasing {branch} onto {parent}"))
212            );
213            eprintln!("resolve conflicts, then run `git stk continue`");
214            eprintln!("or run `git stk abort`");
215            return Err(error);
216        }
217
218        record_base(branch, parent);
219    }
220
221    clear_state()?;
222    finish_restack(all, push)
223}
224
225/// After every branch has been rebased: push the rewritten branches, or print
226/// the exact command so stale remote PR diffs are a copy-paste away from fixed.
227fn finish_restack(branches: &[String], push: bool) -> Result<()> {
228    anstream::println!("{}", style::success("restack complete"));
229
230    let remote = settings::remote()?;
231    if push {
232        git::push_force_with_lease(&remote, branches)?;
233        anstream::println!("pushed {} to {remote}", style::branch(&branches.join(" ")));
234    } else {
235        println!("remote branches may be stale; push them with:");
236        anstream::println!(
237            "{}",
238            style::dim(&format!(
239                "  git push --force-with-lease {remote} {}",
240                branches.join(" ")
241            ))
242        );
243    }
244    Ok(())
245}
246
247fn resolve_update_refs(mode: UpdateRefsMode) -> Result<bool> {
248    match mode {
249        UpdateRefsMode::Config => {
250            let configured = git::config_get_bool(settings::UPDATE_REFS_KEY)?.unwrap_or(false);
251            if configured && !git::supports_rebase_update_refs()? {
252                eprintln!("stk.updateRefs is true, but this Git does not support --update-refs");
253                return Ok(false);
254            }
255            Ok(configured)
256        }
257        UpdateRefsMode::Enabled => {
258            if !git::supports_rebase_update_refs()? {
259                bail!("--update-refs was requested, but this Git does not support it");
260            }
261            Ok(true)
262        }
263        UpdateRefsMode::Disabled => Ok(false),
264    }
265}
266
267#[derive(Debug, Eq, PartialEq)]
268struct RestackState {
269    branch: String,
270    parent: String,
271    remaining: Vec<String>,
272    update_refs: bool,
273    push: bool,
274    /// Every branch in the interrupted restack, so the post-restack push (or
275    /// push hint) can cover branches rebased before the conflict too.
276    all: Vec<String>,
277}
278
279impl RestackState {
280    fn read() -> Result<Option<Self>> {
281        let path = state_path()?;
282        if !path.exists() {
283            return Ok(None);
284        }
285
286        let contents = fs::read_to_string(&path)
287            .with_context(|| format!("failed to read {}", path.display()))?;
288        let mut branch = None;
289        let mut parent = None;
290        let mut remaining = Vec::new();
291        let mut update_refs = false;
292        let mut push = false;
293        let mut all = Vec::new();
294
295        for line in contents.lines() {
296            if let Some(value) = line.strip_prefix("branch=") {
297                branch = Some(value.to_owned());
298            } else if let Some(value) = line.strip_prefix("parent=") {
299                parent = Some(value.to_owned());
300            } else if let Some(value) = line.strip_prefix("updateRefs=") {
301                update_refs = value == "true";
302            } else if let Some(value) = line.strip_prefix("push=") {
303                push = value == "true";
304            } else if let Some(value) = line.strip_prefix("remaining=") {
305                remaining = value
306                    .split('\t')
307                    .filter(|branch| !branch.is_empty())
308                    .map(str::to_owned)
309                    .collect();
310            } else if let Some(value) = line.strip_prefix("all=") {
311                all = value
312                    .split('\t')
313                    .filter(|branch| !branch.is_empty())
314                    .map(str::to_owned)
315                    .collect();
316            }
317        }
318
319        let Some(branch) = branch else {
320            bail!("restack state is missing current branch");
321        };
322        let Some(parent) = parent else {
323            bail!("restack state is missing parent branch");
324        };
325
326        Ok(Some(Self {
327            branch,
328            parent,
329            remaining,
330            update_refs,
331            push,
332            all,
333        }))
334    }
335
336    fn write(&self) -> Result<()> {
337        let path = state_path()?;
338        let contents = format!(
339            "branch={}\nparent={}\nupdateRefs={}\npush={}\nremaining={}\nall={}\n",
340            self.branch,
341            self.parent,
342            self.update_refs,
343            self.push,
344            self.remaining.join("\t"),
345            self.all.join("\t")
346        );
347        fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))
348    }
349}
350
351fn clear_state() -> Result<()> {
352    let path = state_path()?;
353    if path.exists() {
354        fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
355    }
356    Ok(())
357}
358
359fn state_path() -> Result<PathBuf> {
360    Ok(PathBuf::from(git::git_path(STATE_FILE)?))
361}
362
363/// Whether a restack is paused on a conflict, awaiting continue/abort.
364pub(super) fn in_progress() -> bool {
365    state_path().map(|path| path.exists()).unwrap_or(false)
366}