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