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::providers::detect_review_provider;
16use crate::settings;
17use crate::style;
18
19const STATE_FILE: &str = "stack-state";
20
21pub fn restack(
22 fetch_mode: FetchMode,
23 update_refs_mode: UpdateRefsMode,
24 push_mode: PushMode,
25 dry_run: bool,
26) -> Result<()> {
27 let current = git::current_branch()?;
28 let parents = parent_map()?;
29 let base = line_base(¤t)?;
35 let branches = restack_order(&base, &parents);
36
37 if branches.is_empty() {
38 anstream::println!("{}", style::dim("nothing to restack"));
39 return Ok(());
40 }
41
42 if settings::fetch_enabled(fetch_mode)? {
46 fetch_trunk(dry_run)?;
47 }
48 warn_bases_behind_remote(&branches, &parents)?;
49
50 let update_refs = resolve_update_refs(update_refs_mode)?;
51 let push = settings::push_enabled(push_mode, settings::PUSH_ON_RESTACK_KEY)?;
52 let frozen = with_frozen_ancestors(frozen_branches(&branches), &branches, &parents);
53
54 if dry_run {
55 return print_restack_plan(&branches, &parents, &frozen, update_refs, push);
56 }
57
58 super::snapshot("restack");
59 clear_state()?;
60 let all = branches.clone();
61 restack_branches(branches, &parents, &frozen, update_refs, push, &all)
62}
63
64fn frozen_branches(branches: &[String]) -> BTreeSet<String> {
70 let Ok((_, provider)) = detect_review_provider() else {
71 return BTreeSet::new();
72 };
73 provider.enqueued_branches(branches).unwrap_or_default()
74}
75
76fn with_frozen_ancestors(
83 queued: BTreeSet<String>,
84 branches: &[String],
85 parents: &BTreeMap<String, String>,
86) -> BTreeSet<String> {
87 let in_set: BTreeSet<&str> = branches.iter().map(String::as_str).collect();
88 let mut frozen = queued.clone();
89 for branch in &queued {
90 let mut current = branch.clone();
91 while let Some(parent) = parents.get(¤t) {
92 if !in_set.contains(parent.as_str()) || !frozen.insert(parent.clone()) {
95 break;
96 }
97 current = parent.clone();
98 }
99 }
100 frozen
101}
102
103fn frozen_note(branch: &str) -> String {
107 format!(
108 "{} {}: not rebased or pushed (a branch in this stack is in a merge queue; dequeue it to continue)",
109 style::warn("frozen"),
110 style::branch(branch),
111 )
112}
113
114fn print_restack_plan(
117 branches: &[String],
118 parents: &BTreeMap<String, String>,
119 frozen: &BTreeSet<String>,
120 update_refs: bool,
121 push: bool,
122) -> Result<()> {
123 for branch in branches {
124 if frozen.contains(branch) {
125 anstream::println!("{}", frozen_note(branch));
126 continue;
127 }
128
129 let Some(parent) = parents.get(branch) else {
130 bail!("{branch} has no stack parent");
131 };
132
133 if up_to_date(branch, parent)? {
134 anstream::println!(
135 "{} already up to date with {}",
136 style::branch(branch),
137 style::branch(parent)
138 );
139 } else {
140 anstream::println!(
141 "would rebase {} onto {}{}",
142 style::branch(branch),
143 style::branch(parent),
144 if update_refs {
145 " with --update-refs"
146 } else {
147 ""
148 }
149 );
150 }
151 }
152
153 if push {
154 let pushable: Vec<&str> = branches
155 .iter()
156 .filter(|branch| !frozen.contains(*branch))
157 .map(String::as_str)
158 .collect();
159 if pushable.is_empty() {
160 anstream::println!(
161 "{}",
162 style::dim("nothing to push: every branch is in a merge queue")
163 );
164 } else {
165 anstream::println!(
166 "would push {} to {}",
167 style::branch(&pushable.join(" ")),
168 settings::remote()?
169 );
170 }
171 }
172 Ok(())
173}
174
175fn valid_base(branch: &str) -> Result<Option<String>> {
177 Ok(match base_of(branch)? {
178 Some(base) if git::is_ancestor(&base, branch).unwrap_or(false) => Some(base),
179 _ => None,
180 })
181}
182
183fn up_to_date(branch: &str, parent: &str) -> Result<bool> {
185 let parent_tip = git::rev_parse(parent)?;
186 Ok(valid_base(branch)?.as_deref() == Some(parent_tip.as_str())
187 && git::is_ancestor(parent, branch).unwrap_or(false))
188}
189
190fn fetch_trunk(dry_run: bool) -> Result<()> {
195 let Some(trunk) = super::trunk_branch(&git::local_branches()?) else {
196 return Ok(());
197 };
198 let remote = settings::remote()?;
199 if git::remote_url(&remote)?.is_none() {
200 anstream::println!(
201 "{}",
202 style::dim(&format!("no remote {remote}; skipped fetch"))
203 );
204 return Ok(());
205 }
206 if dry_run {
207 anstream::println!("would fetch {} from {remote}", style::branch(&trunk));
208 return Ok(());
209 }
210 if git::current_branch()? == trunk {
211 git::pull_ff_only()?;
212 } else {
213 git::fetch_branch(&remote, &trunk)?;
214 }
215 anstream::println!("fetched {} from {remote}", style::branch(&trunk));
216 Ok(())
217}
218
219fn warn_bases_behind_remote(branches: &[String], parents: &BTreeMap<String, String>) -> Result<()> {
225 let remote = settings::remote()?;
226 if git::remote_url(&remote)?.is_none() {
227 return Ok(());
228 }
229
230 let in_stack: BTreeSet<&String> = branches.iter().collect();
231 let external: BTreeSet<&String> = branches
232 .iter()
233 .filter_map(|branch| parents.get(branch))
234 .filter(|parent| !in_stack.contains(parent))
235 .collect();
236
237 for base in external {
238 let tracking = format!("{remote}/{base}");
239 if git::rev_parse(&tracking).is_err() {
240 continue;
241 }
242 let behind = git::commits_behind(base, &tracking).unwrap_or(0);
243 if behind > 0 {
244 anstream::eprintln!(
245 "{}",
246 style::warn(&format!(
247 "{base} is {behind} commit{} behind {tracking}; run `git stk restack --fetch` or `git stk sync` to update it first",
248 if behind == 1 { "" } else { "s" }
249 ))
250 );
251 }
252 }
253 Ok(())
254}
255
256pub fn continue_restack() -> Result<()> {
257 let Some(state) = RestackState::read()? else {
258 bail!("no interrupted restack found");
259 };
260
261 if let Err(error) = git::rebase_continue() {
262 anstream::eprintln!("{}", style::warn("restack still has conflicts"));
263 eprintln!("resolve conflicts, then run `git stk continue`");
264 eprintln!("or run `git stk abort`");
265 return Err(error);
266 }
267
268 record_base(&state.branch, &state.parent);
269
270 let frozen: BTreeSet<String> = state.frozen.iter().cloned().collect();
271 if state.remaining.is_empty() {
272 clear_state()?;
273 finish_restack(&state.all, &frozen, state.push)?;
274 return Ok(());
275 }
276
277 let parents = parent_map()?;
278 restack_branches(
279 state.remaining,
280 &parents,
281 &frozen,
282 state.update_refs,
283 state.push,
284 &state.all,
285 )
286}
287
288pub fn abort_restack() -> Result<()> {
289 git::rebase_abort()?;
290 clear_state()?;
291 anstream::println!("restack aborted");
292 Ok(())
293}
294
295fn restack_order(current: &str, parents: &BTreeMap<String, String>) -> Vec<String> {
296 let children = children_map(parents);
297 let mut branches = Vec::new();
298
299 if parents.contains_key(current) {
300 branches.push(current.to_owned());
301 }
302
303 let mut visited = BTreeSet::from([current.to_owned()]);
304 collect_descendants(current, &children, &mut branches, &mut visited);
305 branches
306}
307
308fn restack_branches(
309 branches: Vec<String>,
310 parents: &BTreeMap<String, String>,
311 frozen: &BTreeSet<String>,
312 update_refs: bool,
313 push: bool,
314 all: &[String],
315) -> Result<()> {
316 for (index, branch) in branches.iter().enumerate() {
317 if frozen.contains(branch) {
318 anstream::println!("{}", frozen_note(branch));
319 continue;
320 }
321
322 let Some(parent) = parents.get(branch) else {
323 bail!("{branch} has no stack parent");
324 };
325
326 let base = valid_base(branch)?;
331
332 if up_to_date(branch, parent)? {
336 anstream::println!(
337 "{} already up to date with {}",
338 style::branch(branch),
339 style::branch(parent)
340 );
341 continue;
342 }
343
344 if update_refs {
345 anstream::println!(
346 "rebasing {} onto {} with --update-refs",
347 style::branch(branch),
348 style::branch(parent)
349 );
350 } else {
351 anstream::println!(
352 "rebasing {} onto {}",
353 style::branch(branch),
354 style::branch(parent)
355 );
356 }
357 let rebase_result = match &base {
358 Some(base) => git::rebase_onto(parent, base, branch, update_refs),
359 None => git::rebase(parent, branch, update_refs),
360 };
361
362 if let Err(error) = rebase_result {
363 let remaining = branches[index + 1..].to_vec();
364 RestackState {
365 branch: branch.to_owned(),
366 parent: parent.to_owned(),
367 remaining,
368 update_refs,
369 push,
370 all: all.to_vec(),
371 frozen: frozen.iter().cloned().collect(),
372 }
373 .write()?;
374
375 anstream::eprintln!(
376 "{}",
377 style::warn(&format!("conflict while rebasing {branch} onto {parent}"))
378 );
379 eprintln!("resolve conflicts, then run `git stk continue`");
380 eprintln!("or run `git stk abort`");
381 return Err(error);
382 }
383
384 record_base(branch, parent);
385 }
386
387 clear_state()?;
388 finish_restack(all, frozen, push)
389}
390
391fn finish_restack(branches: &[String], frozen: &BTreeSet<String>, push: bool) -> Result<()> {
397 anstream::println!("{}", style::success("restack complete"));
398
399 let remote = settings::remote()?;
400 let pushable: Vec<String> = branches
401 .iter()
402 .filter(|branch| !frozen.contains(*branch))
403 .cloned()
404 .collect();
405 if pushable.is_empty() {
406 anstream::println!(
407 "{}",
408 style::dim("nothing to push: every branch is in a merge queue")
409 );
410 return Ok(());
411 }
412
413 if push {
414 let pushed = git::push_force_with_lease(&remote, &pushable)?;
418 if pushed.is_empty() {
419 anstream::println!(
420 "{}",
421 style::dim("nothing pushed: every branch is in a merge queue")
422 );
423 } else {
424 anstream::println!("pushed {} to {remote}", style::branch(&pushed.join(" ")));
425 super::publish_metadata(&remote);
427 }
428 } else {
429 anstream::println!("remote branches may be stale; push them with:");
430 anstream::println!(
431 "{}",
432 style::dim(&format!(
433 " git push --force-with-lease {remote} {}",
434 pushable.join(" ")
435 ))
436 );
437 }
438 Ok(())
439}
440
441fn resolve_update_refs(mode: UpdateRefsMode) -> Result<bool> {
442 match mode {
443 UpdateRefsMode::Config => {
444 let configured = git::config_get_bool(settings::UPDATE_REFS_KEY)?.unwrap_or(false);
445 if configured && !git::supports_rebase_update_refs()? {
446 eprintln!("stk.updateRefs is true, but this Git does not support --update-refs");
447 return Ok(false);
448 }
449 Ok(configured)
450 }
451 UpdateRefsMode::Enabled => {
452 if !git::supports_rebase_update_refs()? {
453 bail!("--update-refs was requested, but this Git does not support it");
454 }
455 Ok(true)
456 }
457 UpdateRefsMode::Disabled => Ok(false),
458 }
459}
460
461#[derive(Debug, Eq, PartialEq)]
462struct RestackState {
463 branch: String,
464 parent: String,
465 remaining: Vec<String>,
466 update_refs: bool,
467 push: bool,
468 all: Vec<String>,
471 frozen: Vec<String>,
474}
475
476impl RestackState {
477 fn read() -> Result<Option<Self>> {
478 let path = state_path()?;
479 if !path.exists() {
480 return Ok(None);
481 }
482
483 let contents = fs::read_to_string(&path)
484 .with_context(|| format!("failed to read {}", path.display()))?;
485 let mut branch = None;
486 let mut parent = None;
487 let mut remaining = Vec::new();
488 let mut update_refs = false;
489 let mut push = false;
490 let mut all = Vec::new();
491 let mut frozen = Vec::new();
492
493 for line in contents.lines() {
494 if let Some(value) = line.strip_prefix("branch=") {
495 branch = Some(value.to_owned());
496 } else if let Some(value) = line.strip_prefix("parent=") {
497 parent = Some(value.to_owned());
498 } else if let Some(value) = line.strip_prefix("updateRefs=") {
499 update_refs = value == "true";
500 } else if let Some(value) = line.strip_prefix("push=") {
501 push = value == "true";
502 } else if let Some(value) = line.strip_prefix("remaining=") {
503 remaining = value
504 .split('\t')
505 .filter(|branch| !branch.is_empty())
506 .map(str::to_owned)
507 .collect();
508 } else if let Some(value) = line.strip_prefix("all=") {
509 all = value
510 .split('\t')
511 .filter(|branch| !branch.is_empty())
512 .map(str::to_owned)
513 .collect();
514 } else if let Some(value) = line.strip_prefix("frozen=") {
515 frozen = value
516 .split('\t')
517 .filter(|branch| !branch.is_empty())
518 .map(str::to_owned)
519 .collect();
520 }
521 }
522
523 let Some(branch) = branch else {
524 bail!("restack state is missing current branch");
525 };
526 let Some(parent) = parent else {
527 bail!("restack state is missing parent branch");
528 };
529
530 Ok(Some(Self {
531 branch,
532 parent,
533 remaining,
534 update_refs,
535 push,
536 all,
537 frozen,
538 }))
539 }
540
541 fn write(&self) -> Result<()> {
542 let path = state_path()?;
543 let contents = format!(
544 "branch={}\nparent={}\nupdateRefs={}\npush={}\nremaining={}\nall={}\nfrozen={}\n",
545 self.branch,
546 self.parent,
547 self.update_refs,
548 self.push,
549 self.remaining.join("\t"),
550 self.all.join("\t"),
551 self.frozen.join("\t")
552 );
553 fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))
554 }
555}
556
557fn clear_state() -> Result<()> {
558 let path = state_path()?;
559 if path.exists() {
560 fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
561 }
562 Ok(())
563}
564
565fn state_path() -> Result<PathBuf> {
566 Ok(PathBuf::from(git::git_path(STATE_FILE)?))
567}
568
569pub(super) fn in_progress() -> bool {
571 state_path().map(|path| path.exists()).unwrap_or(false)
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577
578 fn linear_parents() -> BTreeMap<String, String> {
581 BTreeMap::from([
582 ("a".to_owned(), "main".to_owned()),
583 ("b".to_owned(), "a".to_owned()),
584 ("c".to_owned(), "b".to_owned()),
585 ])
586 }
587
588 fn set(branches: &[&str]) -> BTreeSet<String> {
589 branches.iter().map(|b| (*b).to_owned()).collect()
590 }
591
592 #[test]
593 fn a_queued_middle_branch_freezes_everything_below_it() {
594 let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
597 let frozen = with_frozen_ancestors(set(&["b"]), &branches, &linear_parents());
598 assert_eq!(frozen, set(&["a", "b"]));
599 }
600
601 #[test]
602 fn a_queued_bottom_branch_freezes_only_itself() {
603 let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
606 let frozen = with_frozen_ancestors(set(&["a"]), &branches, &linear_parents());
607 assert_eq!(frozen, set(&["a"]));
608 }
609
610 #[test]
611 fn freeze_stops_at_the_line_base_not_the_trunk() {
612 let branches = vec!["b".to_owned(), "c".to_owned()];
615 let frozen = with_frozen_ancestors(set(&["c"]), &branches, &linear_parents());
616 assert_eq!(frozen, set(&["b", "c"]));
617 }
618
619 #[test]
620 fn nothing_queued_freezes_nothing() {
621 let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
622 let frozen = with_frozen_ancestors(BTreeSet::new(), &branches, &linear_parents());
623 assert!(frozen.is_empty());
624 }
625}