1use 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 let base = line_base(¤t)?;
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 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
62fn 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
105fn 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
113fn 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
120fn 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
149fn 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 let base = valid_base(branch)?;
253
254 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
312fn 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 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 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
452pub(super) fn in_progress() -> bool {
454 state_path().map(|path| path.exists()).unwrap_or(false)
455}