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