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).
408 lines
14 KiB
Rust
408 lines
14 KiB
Rust
//! Merge workflow around gix's recursive commit/tree merge. Callers hold .git lock.
|
|
use crate::Result;
|
|
use crate::{branches, checkout, repo::error, staging};
|
|
use gix::bstr::ByteSlice;
|
|
use std::{collections::BTreeSet, fs};
|
|
|
|
/// Fast-forward policy.
|
|
#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum FastForward {
|
|
/// Advance HEAD directly when possible, otherwise perform a three-way merge.
|
|
Allow,
|
|
/// Refuse divergent histories instead of making a merge commit.
|
|
Only,
|
|
/// Make a two-parent commit even when a fast-forward is possible.
|
|
Never,
|
|
}
|
|
/// Merge a single commit into HEAD.
|
|
#[derive(serde::Serialize, serde::Deserialize)]
|
|
pub struct MergeParams {
|
|
/// 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,
|
|
/// Revision to merge.
|
|
pub rev: String,
|
|
/// Merge message; defaults to Merge '<rev>'.
|
|
pub message: Option<String>,
|
|
/// Fast-forward policy, default allow.
|
|
pub ff: Option<FastForward>,
|
|
/// Stop before creating a merge commit (does not stop a fast-forward).
|
|
pub no_commit: Option<bool>,
|
|
}
|
|
/// Resume a merge after git.add resolves every conflict.
|
|
#[derive(serde::Serialize, serde::Deserialize)]
|
|
pub struct ContinueParams {
|
|
/// 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,
|
|
/// Override MERGE_MSG.
|
|
pub message: Option<String>,
|
|
}
|
|
/// Merge outcome.
|
|
#[derive(serde::Serialize, serde::Deserialize)]
|
|
pub struct MergeResult {
|
|
/// HEAD after the operation.
|
|
pub head: crate::HeadInfo,
|
|
/// True when HEAD was advanced without creating a merge commit.
|
|
pub fast_forward: bool,
|
|
/// True when the requested commit was already reachable.
|
|
pub already_up_to_date: bool,
|
|
/// True when merge_continue or merge_abort is needed.
|
|
pub pending: bool,
|
|
/// Sorted unresolved index paths; stage 1=base, 2=ours, 3=theirs.
|
|
pub conflicts: Vec<String>,
|
|
/// Checkout feature limitations.
|
|
pub warnings: Vec<String>,
|
|
}
|
|
fn result(
|
|
repo: &gix::Repository,
|
|
fast_forward: bool,
|
|
already: bool,
|
|
pending: bool,
|
|
conflicts: Vec<String>,
|
|
warnings: Vec<String>,
|
|
) -> Result<MergeResult> {
|
|
Ok(MergeResult {
|
|
head: crate::head_info(repo)?,
|
|
fast_forward,
|
|
already_up_to_date: already,
|
|
pending,
|
|
conflicts,
|
|
warnings,
|
|
})
|
|
}
|
|
pub fn ensure_idle(repo: &gix::Repository) -> Result<()> {
|
|
for name in [
|
|
"MERGE_HEAD",
|
|
"CHERRY_PICK_HEAD",
|
|
"REVERT_HEAD",
|
|
"rebase-merge",
|
|
"rebase-apply",
|
|
] {
|
|
if repo.git_dir().join(name).exists() {
|
|
return Err(error(
|
|
"merge",
|
|
format!("{name} exists; finish or abort the current operation first"),
|
|
));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
fn tree(repo: &gix::Repository, id: gix::ObjectId) -> Result<gix::ObjectId> {
|
|
Ok(repo
|
|
.find_commit(id)
|
|
.map_err(|e| error("merge commit", e))?
|
|
.tree_id()
|
|
.map_err(|e| error("merge tree", e))?
|
|
.detach())
|
|
}
|
|
fn reachable(repo: &gix::Repository, tip: gix::ObjectId, ancestor: gix::ObjectId) -> Result<bool> {
|
|
let walk = repo
|
|
.find_commit(tip)
|
|
.map_err(|e| error("merge ancestry", e))?
|
|
.ancestors()
|
|
.use_commit_graph(false)
|
|
.all()
|
|
.map_err(|e| error("merge ancestry", e))?;
|
|
for entry in walk {
|
|
if entry.map_err(|e| error("merge ancestry", e))?.id == ancestor {
|
|
return Ok(true);
|
|
}
|
|
}
|
|
Ok(false)
|
|
}
|
|
pub(crate) fn clean(repo: &gix::Repository) -> Result<()> {
|
|
check_index_lock(repo)?;
|
|
let s = crate::changes::status(repo)?;
|
|
let paths: BTreeSet<_> = s
|
|
.staged
|
|
.into_iter()
|
|
.chain(s.unstaged)
|
|
.flat_map(|c| std::iter::once(c.path).chain(c.old_path))
|
|
.chain(s.conflicted)
|
|
.collect();
|
|
if !paths.is_empty() {
|
|
return Err(error(
|
|
"merge dirty worktree/index",
|
|
paths.into_iter().collect::<Vec<_>>().join(", "),
|
|
));
|
|
}
|
|
// Untracked files may remain; the checkout engine preflights all obstructions, including ignored files.
|
|
Ok(())
|
|
}
|
|
fn validate_message(message: &str) -> Result<()> {
|
|
if message.trim().is_empty() || message.contains('\0') {
|
|
return Err(error(
|
|
"merge",
|
|
"message must be nonblank and contain no NUL",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
fn save(
|
|
repo: &gix::Repository,
|
|
ours: gix::ObjectId,
|
|
theirs: gix::ObjectId,
|
|
message: &str,
|
|
ff: FastForward,
|
|
) -> Result<()> {
|
|
for (name, bytes) in [
|
|
("ORIG_HEAD", format!("{ours}\n")),
|
|
("MERGE_MSG", message.to_owned()),
|
|
(
|
|
"MERGE_MODE",
|
|
if ff == FastForward::Never {
|
|
"no-ff".into()
|
|
} else {
|
|
String::new()
|
|
},
|
|
),
|
|
("MERGE_HEAD", format!("{theirs}\n")),
|
|
] {
|
|
fs::write(repo.git_dir().join(name), bytes).map_err(|e| error("merge state", e))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
pub fn clear(repo: &gix::Repository) -> Result<()> {
|
|
for name in ["MERGE_HEAD", "MERGE_MSG", "MERGE_MODE"] {
|
|
match fs::remove_file(repo.git_dir().join(name)) {
|
|
Ok(()) => {}
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
Err(e) => return Err(error("merge cleanup", e)),
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
pub fn pending_parent(repo: &gix::Repository) -> Result<Option<gix::ObjectId>> {
|
|
let bytes = match fs::read_to_string(repo.git_dir().join("MERGE_HEAD")) {
|
|
Ok(bytes) => bytes,
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
|
Err(e) => return Err(error("merge state", e)),
|
|
};
|
|
let ids: Vec<_> = bytes.lines().collect();
|
|
if ids.len() != 1 {
|
|
return Err(error(
|
|
"merge state",
|
|
"expected one MERGE_HEAD; octopus merges unsupported",
|
|
));
|
|
}
|
|
let id = gix::ObjectId::from_hex(ids[0].as_bytes()).map_err(|e| error("MERGE_HEAD", e))?;
|
|
repo.find_commit(id).map_err(|e| error("MERGE_HEAD", e))?;
|
|
Ok(Some(id))
|
|
}
|
|
fn advance(
|
|
repo: &gix::Repository,
|
|
old: Option<gix::ObjectId>,
|
|
new: gix::ObjectId,
|
|
) -> Result<Vec<String>> {
|
|
use gix::refs::{
|
|
Target,
|
|
transaction::{Change, PreviousValue, RefEdit},
|
|
};
|
|
// Identity checked by caller before any worktree mutation.
|
|
branches::edit_refs(
|
|
repo,
|
|
vec![RefEdit {
|
|
name: "HEAD".try_into().map_err(|e| error("merge HEAD", e))?,
|
|
deref: true,
|
|
change: Change::Update {
|
|
expected: old
|
|
.map(|id| PreviousValue::MustExistAndMatch(Target::Object(id)))
|
|
.unwrap_or(PreviousValue::MustNotExist),
|
|
new: Target::Object(new),
|
|
log: Default::default(),
|
|
},
|
|
}],
|
|
)
|
|
}
|
|
pub fn merge(repo: &gix::Repository, p: MergeParams) -> Result<MergeResult> {
|
|
// In-memory policy only; never rewrite the person's .git/config. No mmap,
|
|
// renormalization/eol filters, or external merge-driver processes on WASI.
|
|
let local = merge_repository(repo)?;
|
|
let repo = &local;
|
|
ensure_idle(repo)?;
|
|
clean(repo)?;
|
|
let theirs = branches::resolve(repo, &p.rev)?;
|
|
merge_inner(repo, p, theirs)
|
|
}
|
|
/// Object-only merge policy shared with the rebase sequencer. Never rewrites config.
|
|
pub(crate) fn merge_repository(repo: &gix::Repository) -> Result<gix::Repository> {
|
|
let mut local = repo.clone();
|
|
{
|
|
let mut config = local.config_snapshot_mut();
|
|
config
|
|
.set_raw_value("core.commitGraph", "false")
|
|
.map_err(|e| error("merge config", e))?;
|
|
config
|
|
.set_raw_value("merge.renormalize", "false")
|
|
.map_err(|e| error("merge config", e))?;
|
|
config
|
|
.set_raw_value("merge.default", "text")
|
|
.map_err(|e| error("merge config", e))?;
|
|
let names: Vec<_> = config
|
|
.sections_by_name("merge")
|
|
.into_iter()
|
|
.flatten()
|
|
.filter_map(|s| s.header().subsection_name().map(ToOwned::to_owned))
|
|
.collect();
|
|
for name in names {
|
|
while config.remove_section("merge", name.as_bstr()).is_some() {}
|
|
}
|
|
config.commit().map_err(|e| error("merge config", e))?;
|
|
}
|
|
Ok(local)
|
|
}
|
|
fn merge_inner(
|
|
repo: &gix::Repository,
|
|
p: MergeParams,
|
|
theirs: gix::ObjectId,
|
|
) -> Result<MergeResult> {
|
|
let ours = repo
|
|
.head()
|
|
.map_err(|e| error("merge HEAD", e))?
|
|
.id()
|
|
.map(|id| id.detach());
|
|
let ff = p.ff.unwrap_or(FastForward::Allow);
|
|
let their_tree = tree(repo, theirs)?;
|
|
if let Some(ours) = ours {
|
|
if reachable(repo, ours, theirs)? {
|
|
return result(repo, false, true, false, vec![], vec![]);
|
|
}
|
|
}
|
|
let can_ff = match ours {
|
|
Some(ours) => reachable(repo, theirs, ours)?,
|
|
None => true,
|
|
};
|
|
if ff == FastForward::Only && !can_ff {
|
|
return Err(error(
|
|
"merge ff-only",
|
|
"not possible: histories have diverged",
|
|
));
|
|
}
|
|
if can_ff && (ff != FastForward::Never || ours.is_none()) {
|
|
repo.committer()
|
|
.transpose()
|
|
.map_err(|e| error("merge reflog identity", e))?;
|
|
let mut warnings = checkout::write_tree_to_worktree(
|
|
repo,
|
|
ours.map(|id| tree(repo, id)).transpose()?,
|
|
their_tree,
|
|
false,
|
|
)?;
|
|
if let Some(id) = ours {
|
|
fs::write(repo.git_dir().join("ORIG_HEAD"), format!("{id}\n"))
|
|
.map_err(|e| error("merge ORIG_HEAD", e))?;
|
|
}
|
|
warnings.extend(advance(repo, ours, theirs)?);
|
|
return result(repo, true, false, false, vec![], warnings);
|
|
}
|
|
let ours = ours.ok_or_else(|| error("merge", "unborn HEAD"))?;
|
|
let message = p
|
|
.message
|
|
.unwrap_or_else(|| format!("Merge '{}'\n", p.rev.replace(['\n', '\r'], " ")));
|
|
validate_message(&message)?;
|
|
// Resolve identity before changing files. no_commit/conflicts may be continued after configuring identity.
|
|
if !p.no_commit.unwrap_or(false) {
|
|
staging::signature(None, None)?;
|
|
}
|
|
let labels = gix::merge::blob::builtin_driver::text::Labels {
|
|
ancestor: None,
|
|
current: Some("ours".into()),
|
|
other: Some("theirs".into()),
|
|
};
|
|
let mut outcome = repo
|
|
.merge_commits(
|
|
ours,
|
|
theirs,
|
|
labels,
|
|
repo.tree_merge_options()
|
|
.map_err(|e| error("merge options", e))?
|
|
.into(),
|
|
)
|
|
.map_err(|e| error("merge three-way", e))?
|
|
.tree_merge;
|
|
let unresolved = outcome.has_unresolved_conflicts(Default::default());
|
|
let merged = outcome
|
|
.tree
|
|
.write()
|
|
.map_err(|e| error("merge tree write", e))?
|
|
.detach();
|
|
let mut state = gix::index::State::from_tree(&merged, &repo.objects, Default::default())
|
|
.map_err(|e| error("merge index", e))?;
|
|
outcome.index_changed_after_applying_conflicts(
|
|
&mut state,
|
|
Default::default(),
|
|
gix::merge::tree::apply_index_entries::RemovalMode::Prune,
|
|
);
|
|
state.sort_entries();
|
|
let conflicts: BTreeSet<_> = state
|
|
.entries()
|
|
.iter()
|
|
.filter(|e| e.stage_raw() != 0)
|
|
.map(|e| e.path(&state).to_str_lossy().into_owned())
|
|
.collect();
|
|
if unresolved && conflicts.is_empty() {
|
|
return Err(error(
|
|
"merge",
|
|
"gix reported a conflict without index stages; cannot safely publish this merge",
|
|
));
|
|
}
|
|
// The result tree contains gix-generated marker blobs and evasively renamed paths.
|
|
let warnings = checkout::write_tree_to_worktree(repo, Some(tree(repo, ours)?), merged, false)?;
|
|
save(repo, ours, theirs, &message, ff)?;
|
|
staging::publish(repo, state)?;
|
|
if !conflicts.is_empty() || p.no_commit.unwrap_or(false) {
|
|
return result(
|
|
repo,
|
|
false,
|
|
false,
|
|
true,
|
|
conflicts.into_iter().collect(),
|
|
warnings,
|
|
);
|
|
}
|
|
continue_merge(
|
|
repo,
|
|
ContinueParams {
|
|
repo: p.repo,
|
|
message: Some(message),
|
|
},
|
|
)?;
|
|
result(repo, false, false, false, vec![], warnings)
|
|
}
|
|
pub fn continue_merge(repo: &gix::Repository, p: ContinueParams) -> Result<staging::CommitResult> {
|
|
pending_parent(repo)?.ok_or_else(|| error("merge continue", "no merge in progress"))?;
|
|
let message = match p.message {
|
|
Some(m) => m,
|
|
None => fs::read_to_string(repo.git_dir().join("MERGE_MSG"))
|
|
.map_err(|e| error("MERGE_MSG", e))?,
|
|
};
|
|
staging::commit(
|
|
repo,
|
|
staging::CommitParams {
|
|
repo: p.repo,
|
|
message,
|
|
author_name: None,
|
|
author_email: None,
|
|
all: None,
|
|
amend: None,
|
|
allow_empty: Some(true),
|
|
},
|
|
)
|
|
}
|
|
fn check_index_lock(repo: &gix::Repository) -> Result<()> {
|
|
if repo.git_dir().join("index.lock").exists() {
|
|
return Err(error("merge index", "index.lock already exists"));
|
|
}
|
|
Ok(())
|
|
}
|
|
pub fn abort(repo: &gix::Repository) -> Result<MergeResult> {
|
|
check_index_lock(repo)?;
|
|
if !repo.git_dir().join("MERGE_HEAD").exists() {
|
|
return Err(error("merge abort", "no merge in progress"));
|
|
}
|
|
let head = branches::resolve(repo, "HEAD")?;
|
|
let warnings = checkout::abort_to_tree(repo, tree(repo, head)?)?;
|
|
clear(repo)?;
|
|
result(repo, false, false, false, vec![], warnings)
|
|
}
|