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