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