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