//! 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 ''. pub message: Option, /// Fast-forward policy, default allow. pub ff: Option, /// Stop before creating a merge commit (does not stop a fast-forward). pub no_commit: Option, } /// 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, } /// 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, /// Checkout feature limitations. pub warnings: Vec, } fn result( repo: &gix::Repository, fast_forward: bool, already: bool, pending: bool, conflicts: Vec, warnings: Vec, ) -> Result { 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 { 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 { 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::>().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> { 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, new: gix::ObjectId, ) -> Result> { 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 { // 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 { 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 { 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 { 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 { 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) }