use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::{Path, PathBuf},
};
use anyhow::{Context, Result, bail};
use super::{children_map, collect_descendants, fork_point, line_base, parent_map, record_base};
use crate::cli::{FetchMode, PushMode, UpdateRefsMode};
use crate::git;
use crate::prompt;
use crate::providers::detect_review_provider;
use crate::settings;
use crate::style;
const STATE_FILE: &str = "stack-state";
pub fn restack(
fetch_mode: FetchMode,
update_refs_mode: UpdateRefsMode,
push_mode: PushMode,
dry_run: bool,
) -> Result<()> {
let current = git::current_branch()?;
let parents = parent_map()?;
let base = line_base(¤t)?;
let branches = restack_order(&base, &parents);
if branches.is_empty() {
anstream::println!("{}", style::dim("nothing to restack"));
return Ok(());
}
if settings::fetch_enabled(fetch_mode)? {
fetch_trunk(dry_run)?;
}
warn_bases_behind_remote(&branches, &parents)?;
let update_refs = resolve_update_refs(update_refs_mode)?;
let push = settings::push_enabled(push_mode, settings::PUSH_ON_RESTACK_KEY)?;
let frozen = with_frozen_ancestors(frozen_branches(&branches), &branches, &parents);
ensure_no_worktree_blocks(&branches, &parents, &frozen, &BTreeSet::new())?;
if dry_run {
reconcile_diverged_remotes(&branches, &frozen, push, true)?;
return print_restack_plan(&branches, &parents, &frozen, update_refs, push);
}
super::snapshot("restack");
reconcile_diverged_remotes(&branches, &frozen, push, false)?;
clear_state()?;
let all = branches.clone();
restack_branches(branches, &parents, &frozen, update_refs, push, &all)
}
fn frozen_branches(branches: &[String]) -> BTreeSet<String> {
let Ok((_, provider)) = detect_review_provider() else {
return BTreeSet::new();
};
provider.enqueued_branches(branches).unwrap_or_default()
}
fn with_frozen_ancestors(
queued: BTreeSet<String>,
branches: &[String],
parents: &BTreeMap<String, String>,
) -> BTreeSet<String> {
let in_set: BTreeSet<&str> = branches.iter().map(String::as_str).collect();
let mut frozen = queued.clone();
for branch in &queued {
let mut current = branch.clone();
while let Some(parent) = parents.get(¤t) {
if !in_set.contains(parent.as_str()) || !frozen.insert(parent.clone()) {
break;
}
current = parent.clone();
}
}
frozen
}
fn frozen_note(branch: &str) -> String {
format!(
"{} {}: not rebased or pushed (a branch in this stack is in a merge queue; dequeue it to continue)",
style::warn("frozen"),
style::branch(branch),
)
}
fn print_restack_plan(
branches: &[String],
parents: &BTreeMap<String, String>,
frozen: &BTreeSet<String>,
update_refs: bool,
push: bool,
) -> Result<()> {
for branch in branches {
if frozen.contains(branch) {
anstream::println!("{}", frozen_note(branch));
continue;
}
let Some(parent) = parents.get(branch) else {
bail!("{branch} has no stack parent");
};
if up_to_date(branch, parent)? {
anstream::println!(
"{} already up to date with {}",
style::branch(branch),
style::branch(parent)
);
} else {
anstream::println!(
"would rebase {} onto {}{}",
style::branch(branch),
style::branch(parent),
if update_refs {
" with --update-refs"
} else {
""
}
);
}
}
if push {
let pushable: Vec<&str> = branches
.iter()
.filter(|branch| !frozen.contains(*branch))
.map(String::as_str)
.collect();
if pushable.is_empty() {
anstream::println!(
"{}",
style::dim("nothing to push: every branch is in a merge queue")
);
} else {
anstream::println!(
"would push {} to {}",
style::branch(&pushable.join(" ")),
settings::remote()?
);
}
}
Ok(())
}
fn ensure_no_worktree_blocks(
branches: &[String],
parents: &BTreeMap<String, String>,
frozen: &BTreeSet<String>,
already_moving: &BTreeSet<String>,
) -> Result<()> {
let held = git::worktree_branches()?;
if held.is_empty() {
return Ok(());
}
let mut rebasing: BTreeSet<&str> = already_moving.iter().map(String::as_str).collect();
let mut blocked = Vec::new();
for branch in branches {
if frozen.contains(branch) {
continue;
}
let Some(parent) = parents.get(branch) else {
continue;
};
if !rebasing.contains(parent.as_str()) && up_to_date(branch, parent)? {
continue;
}
rebasing.insert(branch.as_str());
if let Some((_, path)) = held.iter().find(|(name, _)| name == branch) {
blocked.push((branch.clone(), path.clone()));
}
}
if blocked.is_empty() {
return Ok(());
}
let held_by = git::distinct_paths(blocked.iter().map(|(_, path)| path.as_path()));
let here_moves = git::current_branch()
.ok()
.is_some_and(|branch| rebasing.contains(branch.as_str()));
let delegate = match held_by.as_slice() {
[only] if !here_moves => Some(only.as_path()),
_ => None,
};
bail!(worktree_block_message(&blocked, &held_by, delegate));
}
fn worktree_block_message(
blocked: &[(String, PathBuf)],
held_by: &[PathBuf],
delegate: Option<&Path>,
) -> String {
let mut message =
String::from("restack would rebase branches checked out in other worktrees:\n");
for (branch, path) in blocked {
message.push_str(&format!(" {branch} in {}\n", git::describe_worktree(path)));
}
message.push_str("git cannot rebase a branch another worktree holds. Free ");
message.push_str(if held_by.len() == 1 { "it" } else { "each one" });
message.push_str(" by detaching there:\n");
for path in held_by {
message.push_str(&format!(" {}\n", git::detach_command(path)));
}
message.push_str("then check ");
message.push_str(if blocked.len() == 1 {
"the branch"
} else {
"those branches"
});
message.push_str(" out again once the restack finishes");
if let Some(path) = delegate {
message.push_str(&format!(
",\nor run the restack from {} instead",
git::display_path(path)
));
}
message
}
fn up_to_date(branch: &str, parent: &str) -> Result<bool> {
let parent_tip = git::rev_parse(parent)?;
Ok(
fork_point(branch, parent)?.as_deref() == Some(parent_tip.as_str())
&& git::is_ancestor(parent, branch).unwrap_or(false),
)
}
fn fetch_trunk(dry_run: bool) -> Result<()> {
let Some(trunk) = super::trunk_branch(&git::local_branches()?) else {
return Ok(());
};
let remote = settings::remote()?;
if git::remote_url(&remote)?.is_none() {
anstream::println!(
"{}",
style::dim(&format!("no remote {remote}; skipped fetch"))
);
return Ok(());
}
if super::trunk_held_elsewhere(&trunk)? {
return Ok(());
}
if dry_run {
anstream::println!("would fetch {} from {remote}", style::branch(&trunk));
return Ok(());
}
if git::current_branch()? == trunk {
git::pull_ff_only()?;
} else {
git::fetch_branch(&remote, &trunk)?;
}
anstream::println!("fetched {} from {remote}", style::branch(&trunk));
Ok(())
}
fn warn_bases_behind_remote(branches: &[String], parents: &BTreeMap<String, String>) -> Result<()> {
let remote = settings::remote()?;
if git::remote_url(&remote)?.is_none() {
return Ok(());
}
let in_stack: BTreeSet<&String> = branches.iter().collect();
let external: BTreeSet<&String> = branches
.iter()
.filter_map(|branch| parents.get(branch))
.filter(|parent| !in_stack.contains(parent))
.collect();
for base in external {
let tracking = format!("{remote}/{base}");
if git::rev_parse(&tracking).is_err() {
continue;
}
let behind = git::commits_behind(base, &tracking).unwrap_or(0);
if behind > 0 {
anstream::eprintln!(
"{}",
style::warn(&format!(
"{base} is {behind} commit{} behind {tracking}; run `git stk restack --fetch` or `git stk sync` to update it first",
if behind == 1 { "" } else { "s" }
))
);
}
}
Ok(())
}
fn reconcile_diverged_remotes(
branches: &[String],
frozen: &BTreeSet<String>,
push: bool,
dry_run: bool,
) -> Result<()> {
if !push {
return Ok(());
}
let remote = settings::remote()?;
if git::remote_url(&remote)?.is_none() {
return Ok(());
}
let pushable: Vec<String> = branches
.iter()
.filter(|branch| !frozen.contains(*branch))
.cloned()
.collect();
if pushable.is_empty() {
return Ok(());
}
if !dry_run {
git::fetch_tracking(&remote, &pushable)?;
}
let mut diverged: Vec<(String, Vec<(String, String)>)> = Vec::new();
for branch in &pushable {
let tracking = format!("{remote}/{branch}");
if git::rev_parse(&tracking).is_err() {
continue;
}
let extra = git::remote_only_commits(branch, &tracking)?;
if extra.is_empty() {
continue;
}
if git::merge_adds_nothing(branch, &tracking)? {
continue;
}
diverged.push((branch.clone(), extra));
}
if diverged.is_empty() {
return Ok(());
}
for (branch, commits) in &diverged {
anstream::eprintln!(
"{}",
style::warn(&format!(
"{remote}/{branch} has {} commit{} not in your local {branch}:",
commits.len(),
if commits.len() == 1 { "" } else { "s" },
))
);
for (sha, subject) in commits {
anstream::eprintln!(" {} {subject}", style::dim(sha));
}
}
if dry_run {
anstream::println!(
"{}",
style::dim(
"would offer to cherry-pick these into your local branches before pushing, \
or to discard them and overwrite the remote",
)
);
return Ok(());
}
if !prompt::confirm("cherry-pick these into your local branches before pushing? [y/N] ")? {
if prompt::confirm("discard them and overwrite the remote branches instead? [y/N] ")? {
return Ok(());
}
bail!(
"remote branches have commits not in your local stack\n\
incorporate them (`git switch <branch> && git cherry-pick <sha>`) and re-run, \
or discard them with `git push --force-with-lease {remote} <branch>`"
);
}
let start = git::current_branch()?;
for (branch, commits) in &diverged {
git::checkout(branch)?;
for (sha, _) in commits {
if let Err(error) = git::cherry_pick(sha) {
anstream::eprintln!(
"{}",
style::warn(&format!("conflict cherry-picking {sha} onto {branch}"))
);
eprintln!("resolve conflicts, run `git cherry-pick --continue`, then re-run");
eprintln!("or run `git cherry-pick --abort` to bail out");
return Err(error);
}
}
}
git::checkout(&start)?;
anstream::println!(
"{}",
style::success(&format!(
"incorporated remote commits into {}",
diverged
.iter()
.map(|(branch, _)| branch.as_str())
.collect::<Vec<_>>()
.join(" ")
))
);
Ok(())
}
pub fn continue_restack() -> Result<()> {
let Some(state) = RestackState::read()? else {
bail!("no interrupted restack found");
};
if !git::rebase_in_progress() {
clear_state()?;
bail!(
"no rebase is in progress, so there is nothing to continue\n\
cleared the leftover restack state; re-run `git stk restack` to pick up where it stopped"
);
}
ensure_no_worktree_blocks(
&state.remaining,
&parent_map()?,
&state.frozen.iter().cloned().collect(),
&BTreeSet::from([state.branch.clone()]),
)?;
if let Err(error) = git::rebase_continue() {
anstream::eprintln!("{}", style::warn("restack still has conflicts"));
eprintln!("resolve conflicts, then run `git stk continue`");
eprintln!("or run `git stk abort`");
return Err(error);
}
record_base(&state.branch, &state.parent);
let frozen: BTreeSet<String> = state.frozen.iter().cloned().collect();
if state.remaining.is_empty() {
clear_state()?;
finish_restack(&state.all, &frozen, state.push)?;
return Ok(());
}
let parents = parent_map()?;
restack_branches(
state.remaining,
&parents,
&frozen,
state.update_refs,
state.push,
&state.all,
)
}
pub fn abort_restack() -> Result<()> {
if !git::rebase_in_progress() {
if RestackState::read()?.is_none() {
bail!("no restack to abort");
}
clear_state()?;
anstream::println!("cleared leftover restack state; no rebase was in progress");
return Ok(());
}
git::rebase_abort()?;
clear_state()?;
anstream::println!("restack aborted");
Ok(())
}
fn restack_order(current: &str, parents: &BTreeMap<String, String>) -> Vec<String> {
let children = children_map(parents);
let mut branches = Vec::new();
if parents.contains_key(current) {
branches.push(current.to_owned());
}
let mut visited = BTreeSet::from([current.to_owned()]);
collect_descendants(current, &children, &mut branches, &mut visited);
branches
}
fn restack_branches(
branches: Vec<String>,
parents: &BTreeMap<String, String>,
frozen: &BTreeSet<String>,
update_refs: bool,
push: bool,
all: &[String],
) -> Result<()> {
for (index, branch) in branches.iter().enumerate() {
if frozen.contains(branch) {
anstream::println!("{}", frozen_note(branch));
continue;
}
let Some(parent) = parents.get(branch) else {
bail!("{branch} has no stack parent");
};
let base = fork_point(branch, parent)?;
if up_to_date(branch, parent)? {
anstream::println!(
"{} already up to date with {}",
style::branch(branch),
style::branch(parent)
);
continue;
}
if update_refs {
anstream::println!(
"rebasing {} onto {} with --update-refs",
style::branch(branch),
style::branch(parent)
);
} else {
anstream::println!(
"rebasing {} onto {}",
style::branch(branch),
style::branch(parent)
);
}
let rebase_result = match &base {
Some(base) => git::rebase_onto(parent, base, branch, update_refs),
None => git::rebase(parent, branch, update_refs),
};
if let Err(error) = rebase_result {
if !git::rebase_in_progress() {
return Err(error);
}
let remaining = branches[index + 1..].to_vec();
RestackState {
branch: branch.to_owned(),
parent: parent.to_owned(),
remaining,
update_refs,
push,
all: all.to_vec(),
frozen: frozen.iter().cloned().collect(),
}
.write()?;
anstream::eprintln!(
"{}",
style::warn(&format!("conflict while rebasing {branch} onto {parent}"))
);
eprintln!("resolve conflicts, then run `git stk continue`");
eprintln!("or run `git stk abort`");
return Err(error);
}
record_base(branch, parent);
}
clear_state()?;
finish_restack(all, frozen, push)
}
fn finish_restack(branches: &[String], frozen: &BTreeSet<String>, push: bool) -> Result<()> {
anstream::println!("{}", style::success("restack complete"));
let remote = settings::remote()?;
let pushable: Vec<String> = branches
.iter()
.filter(|branch| !frozen.contains(*branch))
.cloned()
.collect();
if pushable.is_empty() {
anstream::println!(
"{}",
style::dim("nothing to push: every branch is in a merge queue")
);
return Ok(());
}
if push {
let pushed = git::push_force_with_lease(&remote, &pushable)?;
if pushed.is_empty() {
anstream::println!(
"{}",
style::dim("nothing pushed: every branch is in a merge queue")
);
} else {
anstream::println!("pushed {} to {remote}", style::branch(&pushed.join(" ")));
super::publish_metadata(&remote);
}
} else {
anstream::println!("remote branches may be stale; push them with:");
anstream::println!(
"{}",
style::dim(&format!(
" git push --force-with-lease {remote} {}",
pushable.join(" ")
))
);
}
Ok(())
}
fn resolve_update_refs(mode: UpdateRefsMode) -> Result<bool> {
match mode {
UpdateRefsMode::Config => {
let configured = git::config_get_bool(settings::UPDATE_REFS_KEY)?.unwrap_or(false);
if configured && !git::supports_rebase_update_refs()? {
eprintln!("stk.updateRefs is true, but this Git does not support --update-refs");
return Ok(false);
}
Ok(configured)
}
UpdateRefsMode::Enabled => {
if !git::supports_rebase_update_refs()? {
bail!("--update-refs was requested, but this Git does not support it");
}
Ok(true)
}
UpdateRefsMode::Disabled => Ok(false),
}
}
#[derive(Debug, Eq, PartialEq)]
struct RestackState {
branch: String,
parent: String,
remaining: Vec<String>,
update_refs: bool,
push: bool,
all: Vec<String>,
frozen: Vec<String>,
}
impl RestackState {
fn read() -> Result<Option<Self>> {
let path = state_path()?;
if !path.exists() {
return Ok(None);
}
let contents = fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let mut branch = None;
let mut parent = None;
let mut remaining = Vec::new();
let mut update_refs = false;
let mut push = false;
let mut all = Vec::new();
let mut frozen = Vec::new();
for line in contents.lines() {
if let Some(value) = line.strip_prefix("branch=") {
branch = Some(value.to_owned());
} else if let Some(value) = line.strip_prefix("parent=") {
parent = Some(value.to_owned());
} else if let Some(value) = line.strip_prefix("updateRefs=") {
update_refs = value == "true";
} else if let Some(value) = line.strip_prefix("push=") {
push = value == "true";
} else if let Some(value) = line.strip_prefix("remaining=") {
remaining = value
.split('\t')
.filter(|branch| !branch.is_empty())
.map(str::to_owned)
.collect();
} else if let Some(value) = line.strip_prefix("all=") {
all = value
.split('\t')
.filter(|branch| !branch.is_empty())
.map(str::to_owned)
.collect();
} else if let Some(value) = line.strip_prefix("frozen=") {
frozen = value
.split('\t')
.filter(|branch| !branch.is_empty())
.map(str::to_owned)
.collect();
}
}
let Some(branch) = branch else {
bail!("restack state is missing current branch");
};
let Some(parent) = parent else {
bail!("restack state is missing parent branch");
};
Ok(Some(Self {
branch,
parent,
remaining,
update_refs,
push,
all,
frozen,
}))
}
fn write(&self) -> Result<()> {
let path = state_path()?;
let contents = format!(
"branch={}\nparent={}\nupdateRefs={}\npush={}\nremaining={}\nall={}\nfrozen={}\n",
self.branch,
self.parent,
self.update_refs,
self.push,
self.remaining.join("\t"),
self.all.join("\t"),
self.frozen.join("\t")
);
fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))
}
}
fn clear_state() -> Result<()> {
let path = state_path()?;
if path.exists() {
fs::remove_file(&path).with_context(|| format!("failed to remove {}", path.display()))?;
}
Ok(())
}
fn state_path() -> Result<PathBuf> {
Ok(PathBuf::from(git::git_path(STATE_FILE)?))
}
pub(super) fn in_progress() -> bool {
state_path().map(|path| path.exists()).unwrap_or(false) && git::rebase_in_progress()
}
#[cfg(test)]
mod tests {
use super::*;
fn linear_parents() -> BTreeMap<String, String> {
BTreeMap::from([
("a".to_owned(), "main".to_owned()),
("b".to_owned(), "a".to_owned()),
("c".to_owned(), "b".to_owned()),
])
}
fn set(branches: &[&str]) -> BTreeSet<String> {
branches.iter().map(|b| (*b).to_owned()).collect()
}
#[test]
fn a_queued_middle_branch_freezes_everything_below_it() {
let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
let frozen = with_frozen_ancestors(set(&["b"]), &branches, &linear_parents());
assert_eq!(frozen, set(&["a", "b"]));
}
#[test]
fn a_queued_bottom_branch_freezes_only_itself() {
let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
let frozen = with_frozen_ancestors(set(&["a"]), &branches, &linear_parents());
assert_eq!(frozen, set(&["a"]));
}
#[test]
fn freeze_stops_at_the_line_base_not_the_trunk() {
let branches = vec!["b".to_owned(), "c".to_owned()];
let frozen = with_frozen_ancestors(set(&["c"]), &branches, &linear_parents());
assert_eq!(frozen, set(&["b", "c"]));
}
#[test]
fn nothing_queued_freezes_nothing() {
let branches = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
let frozen = with_frozen_ancestors(BTreeSet::new(), &branches, &linear_parents());
assert!(frozen.is_empty());
}
}