//! Linear rebase sequencer. Every entry point is called under the common .git lock. //! //! Own persistent format (NOT Git's sequencer format): rebase-merge/sausage.json, //! version 1. Full hex OIDs; onto/orig_head/orig_tip/branch are immutable; todo is //! oldest-first, done lists consumed original commits (including skipped/empty), //! current is the conflicted original commit. tip is the unpublished replay tip. //! work_tree records the last materialized tree; orig_index is base64 of the exact //! original index. phase checkpoints precede worktree/ref writes. Atomic JSON rename //! makes each pick restartable; refs stay original until Finish. New fields must //! default; unknown versions refuse. Real git is neither used nor expected to read it. use crate::Result; use crate::{branches, checkout, merge, repo::error, staging}; use base64::{Engine, engine::general_purpose::STANDARD}; use gix::bstr::ByteSlice; use std::{collections::BTreeSet, fs}; /// Replay a local branch's linear commits onto another revision. #[derive(serde::Serialize, serde::Deserialize)] pub struct RebaseParams { /// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash). pub repo: String, /// New base; defaults to the selected branch's configured upstream. pub onto: Option, /// Short or full local branch name; defaults to current branch (not detached HEAD). pub branch: Option, } /// Rebase progress. HEAD and the branch ref remain original until completion. #[derive(serde::Serialize, serde::Deserialize)] pub struct RebaseResult { /// HEAD after this call (original while pending). pub head: crate::HeadInfo, /// Unpublished replay tip while pending; final branch tip otherwise. pub tip: String, /// True when continue, skip or abort is needed. pub pending: bool, /// True when the selected branch already contains the requested base. pub already_up_to_date: bool, /// True when no replay was required and the branch advanced to onto. pub fast_forward: bool, /// Sorted unresolved paths in stages 1/2/3. pub conflicts: Vec, /// Checkout limitations, including preserved abort obstructions. pub warnings: Vec, /// Commit created from a staged conflict resolution during this call. pub resolved: Option, } #[derive(serde::Serialize, serde::Deserialize, PartialEq, Eq)] enum Phase { Replay, CheckoutConflict, Conflict, Reset, Finish, } #[derive(serde::Serialize, serde::Deserialize)] struct State { version: u32, onto: String, orig_head: String, orig_branch: Option, branch: String, orig_tip: String, tip: String, todo: Vec, done: Vec, current: Option, work_tree: String, orig_index: Option, phase: Phase, already: bool, fast_forward: bool, } fn oid(s: &str) -> Result { gix::ObjectId::from_hex(s.as_bytes()).map_err(|e| error("rebase state OID", e)) } fn tree(repo: &gix::Repository, id: gix::ObjectId) -> Result { Ok(repo .find_commit(id) .map_err(|e| error("rebase commit", e))? .tree_id() .map_err(|e| error("rebase tree", e))? .detach()) } fn state_path(repo: &gix::Repository) -> std::path::PathBuf { repo.git_dir().join("rebase-merge/sausage.json") } fn save(repo: &gix::Repository, s: &State) -> Result<()> { let bytes = serde_json::to_vec(s).map_err(|e| error("rebase state", e))?; let tmp = repo.git_dir().join("rebase-merge/sausage.json.tmp"); fs::write(&tmp, bytes).map_err(|e| error("rebase state write", e))?; fs::rename(tmp, state_path(repo)).map_err(|e| error("rebase state rename", e)) } fn load(repo: &gix::Repository) -> Result { let bytes = fs::read(state_path(repo)) .map_err(|e| error("rebase: no readable Sausage rebase state", e))?; let s: State = serde_json::from_slice(&bytes).map_err(|e| error("rebase state", e))?; if s.version != 1 { return Err(error("rebase state", "unsupported version")); } Ok(s) } fn clear(repo: &gix::Repository) -> Result<()> { fs::remove_file(state_path(repo)).map_err(|e| error("rebase cleanup", e))?; let tmp = repo.git_dir().join("rebase-merge/sausage.json.tmp"); if tmp.exists() { fs::remove_file(tmp).map_err(|e| error("rebase cleanup", e))?; } // Never recursively remove someone else's sequencer files. fs::remove_dir(repo.git_dir().join("rebase-merge")).map_err(|e| error("rebase cleanup", e)) } fn conflicts(repo: &gix::Repository) -> Result> { let index = repo .index_or_empty() .map_err(|e| error("rebase index", e))?; Ok(index .entries() .iter() .filter(|e| e.stage_raw() != 0) .map(|e| e.path(&index).to_str_lossy().into_owned()) .collect::>() .into_iter() .collect()) } fn result( repo: &gix::Repository, s: &State, pending: bool, warnings: Vec, ) -> Result { Ok(RebaseResult { head: crate::head_info(repo)?, tip: s.tip.clone(), pending, already_up_to_date: s.already, fast_forward: s.fast_forward, conflicts: if pending { conflicts(repo)? } else { vec![] }, warnings, resolved: None, }) } fn index_lock(repo: &gix::Repository) -> Result<()> { if repo.git_dir().join("index.lock").exists() { return Err(error("rebase", "index.lock already exists")); } Ok(()) } fn verify_refs(repo: &gix::Repository, s: &State) -> Result<()> { let head = crate::head_info(repo)?; if head.id.as_deref() != Some(&s.orig_head) || head.branch != s.orig_branch || branches::resolve(repo, &s.branch)? != oid(&s.orig_tip)? { return Err(error( "rebase", "HEAD or branch changed since rebase began; refusing to overwrite refs", )); } Ok(()) } fn upstream(repo: &gix::Repository, branch: &str) -> Result { // gix's remote-tracking lookup doesn't map local (remote=".") upstreams. let short = branch .strip_prefix("refs/heads/") .ok_or_else(|| error("rebase upstream", "expected local branch"))?; let config = repo.config_snapshot(); if config .string(format!("branch.{short}.remote").as_str()) .is_some_and(|s| s.as_bstr() == b".".as_bstr()) { return config .string(format!("branch.{short}.merge").as_str()) .map(|s| s.to_str_lossy().into_owned()) .ok_or_else(|| error("rebase", "no upstream merge branch configured; supply onto")); } repo.find_reference(branch) .map_err(|e| error("rebase branch", e))? .remote_tracking_ref_name(gix::remote::Direction::Fetch) .transpose() .map_err(|e| error("rebase upstream", e))? .map(|n| n.as_bstr().to_str_lossy().into_owned()) .ok_or_else(|| { error( "rebase", "no upstream configured; supply onto or configure branch upstream", ) }) } pub fn start(repo: &gix::Repository, p: RebaseParams) -> Result { let local = merge::merge_repository(repo)?; let repo = &local; merge::ensure_idle(repo)?; merge::clean(repo)?; let head = crate::head_info(repo)?; let orig_head = head .id .ok_or_else(|| error("rebase", "unborn HEAD is unsupported"))?; let branch = p .branch .map(|b| { if b.starts_with("refs/heads/") { b } else { format!("refs/heads/{b}") } }) .or_else(|| head.branch.clone()) .ok_or_else(|| error("rebase", "detached HEAD; supply a local branch"))?; let _: gix::refs::FullName = branch .as_str() .try_into() .map_err(|e| error("rebase branch", e))?; if !branch.starts_with("refs/heads/") { return Err(error("rebase", "expected local branch")); } let orig = branches::resolve(repo, &branch)?; let onto = branches::resolve( repo, &match p.onto { Some(r) => r, None => upstream(repo, &branch)?, }, )?; let base = repo .merge_base(orig, onto) .map_err(|e| error("rebase merge-base", e))? .ok_or_else(|| error("rebase", "unrelated histories are unsupported"))? .detach(); let mut cursor = orig; let mut todo = Vec::new(); while cursor != base { let c = repo .find_commit(cursor) .map_err(|e| error("rebase range", e))?; let parents: Vec<_> = c.parent_ids().map(|id| id.detach()).collect(); if parents.len() != 1 { return Err(error( "rebase", "linear history only: merge commit in replay range (no rebase-merges)", )); } todo.push(cursor.to_string()); cursor = parents[0]; } todo.reverse(); let already = base == onto; if already && head.branch.as_deref() == Some(&branch) { return Ok(RebaseResult { head: crate::head_info(repo)?, tip: orig.to_string(), pending: false, already_up_to_date: true, fast_forward: false, conflicts: vec![], warnings: vec![], resolved: None, }); } if already { todo.clear(); } if !todo.is_empty() { staging::signature(None, None)?; } // Preflight reflog configuration before materializing anything. repo.committer() .transpose() .map_err(|e| error("rebase identity", e))?; let orig_index = match fs::read(repo.git_dir().join("index")) { Ok(b) => Some(STANDARD.encode(b)), Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, Err(e) => return Err(error("rebase snapshot index", e)), }; let s = State { version: 1, onto: onto.to_string(), orig_head: orig_head.clone(), orig_branch: head.branch, branch, orig_tip: orig.to_string(), tip: if already { orig } else { onto }.to_string(), todo, done: vec![], current: None, work_tree: tree(repo, oid(&orig_head)?)?.to_string(), orig_index, phase: Phase::Replay, already, fast_forward: !already && base == orig, }; fs::create_dir(repo.git_dir().join("rebase-merge")) .map_err(|e| error("rebase state directory", e))?; save(repo, &s)?; run(repo, s, vec![]) } fn pick( repo: &gix::Repository, original: &str, tip: &str, new_tree: gix::ObjectId, ) -> Result { if new_tree == tree(repo, oid(tip)?)? { return Ok(tip.to_owned()); } let original = repo .find_commit(oid(original)?) .map_err(|e| error("rebase original", e))?; let decoded = original.decode().map_err(|e| error("rebase original", e))?; let author = decoded .author() .map_err(|e| error("rebase author", e))? .to_owned() .map_err(|e| error("rebase author", e))?; let object = gix::objs::Commit { tree: new_tree, parents: vec![oid(tip)?].into_iter().collect(), author, committer: staging::signature(None, None)?, encoding: decoded.encoding.map(ToOwned::to_owned), message: decoded.message.to_owned(), extra_headers: vec![], }; Ok(repo .write_object(&object) .map_err(|e| error("rebase commit write", e))? .to_string()) } fn consume(s: &mut State) { if !s.todo.is_empty() { s.done.push(s.todo.remove(0)); } s.current = None; } fn run(repo: &gix::Repository, mut s: State, mut warnings: Vec) -> Result { verify_refs(repo, &s)?; while let Some(current) = s.todo.first().cloned() { let commit = repo .find_commit(oid(¤t)?) .map_err(|e| error("rebase pick", e))?; let parent = commit .parent_ids() .next() .ok_or_else(|| error("rebase pick", "root commit in range"))? .detach(); let labels = gix::merge::blob::builtin_driver::text::Labels { ancestor: None, current: Some("ours".into()), other: Some("theirs".into()), }; let mut outcome = repo .merge_trees( tree(repo, parent)?, tree(repo, oid(&s.tip)?)?, tree(repo, oid(¤t)?)?, labels, repo.tree_merge_options() .map_err(|e| error("rebase options", e))?, ) .map_err(|e| error("rebase merge trees", e))?; let unresolved = outcome.has_unresolved_conflicts(Default::default()); let merged = outcome .tree .write() .map_err(|e| error("rebase tree write", e))? .detach(); let mut index = gix::index::State::from_tree(&merged, &repo.objects, Default::default()) .map_err(|e| error("rebase index", e))?; outcome.index_changed_after_applying_conflicts( &mut index, Default::default(), gix::merge::tree::apply_index_entries::RemovalMode::Prune, ); index.sort_entries(); let has_conflicts = index.entries().iter().any(|e| e.stage_raw() != 0); if unresolved && !has_conflicts { return Err(error( "rebase", "gix conflict without index stages; refusing unsafe replay", )); } if has_conflicts { s.current = Some(current); s.phase = Phase::CheckoutConflict; save(repo, &s)?; warnings.extend(checkout::write_tree_to_worktree( repo, Some(oid(&s.work_tree)?), merged, false, )?); staging::publish(repo, index)?; s.work_tree = merged.to_string(); s.phase = Phase::Conflict; save(repo, &s)?; return result(repo, &s, true, warnings); } s.tip = pick(repo, ¤t, &s.tip, merged)?; consume(&mut s); save(repo, &s)?; } s.phase = Phase::Finish; save(repo, &s)?; finish(repo, s, warnings) } fn finish(repo: &gix::Repository, s: State, mut warnings: Vec) -> Result { verify_refs(repo, &s)?; let target = tree(repo, oid(&s.tip)?)?; // A process may exit after checkout publishes its index but before the ref CAS. // In that case the worktree is already final; do not compare it to the old tree. if resolved_tree(repo)? != target { warnings.extend(checkout::write_tree_to_worktree( repo, Some(oid(&s.work_tree)?), target, false, )?); } use gix::refs::{ Target, transaction::{Change, PreviousValue, RefEdit}, }; let old_head = match &s.orig_branch { Some(b) => Target::Symbolic(b.as_str().try_into().map_err(|e| error("rebase HEAD", e))?), None => Target::Object(oid(&s.orig_head)?), }; warnings.extend(branches::edit_refs( repo, vec![ RefEdit { name: s .branch .as_str() .try_into() .map_err(|e| error("rebase branch", e))?, deref: false, change: Change::Update { expected: PreviousValue::MustExistAndMatch(Target::Object(oid(&s.orig_tip)?)), new: Target::Object(oid(&s.tip)?), log: Default::default(), }, }, RefEdit { name: "HEAD".try_into().map_err(|e| error("rebase HEAD", e))?, deref: false, change: Change::Update { expected: PreviousValue::MustExistAndMatch(old_head), new: Target::Symbolic( s.branch .as_str() .try_into() .map_err(|e| error("rebase branch", e))?, ), log: Default::default(), }, }, ], )?); clear(repo)?; result(repo, &s, false, warnings) } /// Check only unstaged changes: HEAD intentionally still names the original branch tip. fn resolved_tree(repo: &gix::Repository) -> Result { let index = repo .index_or_empty() .map_err(|e| error("rebase index", e))?; let tree = staging::tree_from_index(repo, &index)?; let status = crate::changes::status(repo)?; if !status.unstaged.is_empty() { return Err(error( "rebase continue", "unstaged changes; stage the resolved paths first", )); } Ok(tree) } pub fn resume(repo: &gix::Repository, skip: bool) -> Result { index_lock(repo)?; let local = merge::merge_repository(repo)?; let repo = &local; let mut s = load(repo)?; // Ref transaction completed but cleanup was interrupted: do not replay twice. let head = crate::head_info(repo)?; if s.phase == Phase::Finish && head.branch.as_deref() == Some(&s.branch) && head.id.as_deref() == Some(&s.tip) && branches::resolve(repo, &s.branch)? == oid(&s.tip)? { clear(repo)?; return result(repo, &s, false, vec![]); } verify_refs(repo, &s)?; if s.phase == Phase::CheckoutConflict { return Err(error( "rebase", "interrupted conflict publication; use rebase_abort (resolution index may be incomplete)", )); } if skip && s.phase != Phase::Conflict { return Err(error("rebase skip", "no conflicted pick to skip")); } let mut warnings = vec![]; let mut resolved = None; if s.phase == Phase::Conflict { if skip { consume(&mut s); s.phase = Phase::Reset; save(repo, &s)?; } else { let merged = resolved_tree(repo)?; let current = s .current .as_deref() .ok_or_else(|| error("rebase state", "missing current pick"))?; s.tip = pick(repo, current, &s.tip, merged)?; resolved = Some(s.tip.clone()); s.work_tree = merged.to_string(); consume(&mut s); s.phase = Phase::Replay; save(repo, &s)?; } } if s.phase == Phase::Reset { let target = tree(repo, oid(&s.tip)?)?; warnings.extend(checkout::write_tree_to_worktree( repo, Some(oid(&s.work_tree)?), target, true, )?); s.work_tree = target.to_string(); s.phase = Phase::Replay; save(repo, &s)?; } if s.phase == Phase::Finish { return finish(repo, s, warnings); } if resolved_tree(repo)? != oid(&s.work_tree)? { return Err(error( "rebase", "index changed during interrupted replay; abort or restore the index before continuing", )); } let mut result = run(repo, s, warnings)?; result.resolved = resolved; Ok(result) } pub fn abort(repo: &gix::Repository) -> Result { index_lock(repo)?; let s = load(repo)?; verify_refs(repo, &s)?; let warnings = checkout::abort_to_tree(repo, tree(repo, oid(&s.orig_head)?)?)?; if let Some(bytes) = &s.orig_index { use std::io::Write; let bytes = STANDARD .decode(bytes) .map_err(|e| error("rebase original index", e))?; let lock = repo.git_dir().join("index.lock"); let mut file = fs::OpenOptions::new() .write(true) .create_new(true) .open(&lock) .map_err(|e| error("rebase index lock", e))?; let write = file.write_all(&bytes).and_then(|_| file.flush()); drop(file); if let Err(e) = write { let _ = fs::remove_file(&lock); return Err(error("rebase index restore", e)); } fs::rename(&lock, repo.git_dir().join("index")) .map_err(|e| error("rebase index restore", e))?; } else { match fs::remove_file(repo.git_dir().join("index")) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(error("rebase index restore", e)), } } clear(repo)?; let mut r = result(repo, &s, false, warnings)?; r.tip = s.orig_tip; Ok(r) }