Files
sausage/userland/git/src/rebase.rs
T
surma 4b27e9afd5 userland/git: git-compatible CLI on v2's gix code (T10)
Local workflow, smart-HTTP clone/fetch/push and public HTTPS clone/fetch match
native git in stdout and exit codes. Only WASI imports (wasi:http, exit-with-code).
2026-11-06 16:07:57 +00:00

582 lines
20 KiB
Rust

//! 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<String>,
/// Short or full local branch name; defaults to current branch (not detached HEAD).
pub branch: Option<String>,
}
/// 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<String>,
/// Checkout limitations, including preserved abort obstructions.
pub warnings: Vec<String>,
/// Commit created from a staged conflict resolution during this call.
pub resolved: Option<String>,
}
#[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<String>,
branch: String,
orig_tip: String,
tip: String,
todo: Vec<String>,
done: Vec<String>,
current: Option<String>,
work_tree: String,
orig_index: Option<String>,
phase: Phase,
already: bool,
fast_forward: bool,
}
fn oid(s: &str) -> Result<gix::ObjectId> {
gix::ObjectId::from_hex(s.as_bytes()).map_err(|e| error("rebase state OID", e))
}
fn tree(repo: &gix::Repository, id: gix::ObjectId) -> Result<gix::ObjectId> {
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<State> {
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<Vec<String>> {
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::<BTreeSet<_>>()
.into_iter()
.collect())
}
fn result(
repo: &gix::Repository,
s: &State,
pending: bool,
warnings: Vec<String>,
) -> Result<RebaseResult> {
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<String> {
// 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<RebaseResult> {
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<String> {
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<String>) -> Result<RebaseResult> {
verify_refs(repo, &s)?;
while let Some(current) = s.todo.first().cloned() {
let commit = repo
.find_commit(oid(&current)?)
.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(&current)?)?,
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, &current, &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<String>) -> Result<RebaseResult> {
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<gix::ObjectId> {
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<RebaseResult> {
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<RebaseResult> {
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)
}