Files
sausage/userland/git/src/merge.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

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)
}