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