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).
582 lines
20 KiB
Rust
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(¤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<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)
|
|
}
|