diff --git a/gitoxide-core/src/repository/merge/commit.rs b/gitoxide-core/src/repository/merge/commit.rs new file mode 100644 index 00000000000..a50876ac3f4 --- /dev/null +++ b/gitoxide-core/src/repository/merge/commit.rs @@ -0,0 +1,88 @@ +use crate::OutputFormat; +use anyhow::{anyhow, bail, Context}; +use gix::bstr::BString; +use gix::bstr::ByteSlice; +use gix::merge::tree::UnresolvedConflict; +use gix::prelude::Write; + +use super::tree::Options; + +#[allow(clippy::too_many_arguments)] +pub fn commit( + mut repo: gix::Repository, + out: &mut dyn std::io::Write, + err: &mut dyn std::io::Write, + ours: BString, + theirs: BString, + Options { + format, + file_favor, + in_memory, + }: Options, +) -> anyhow::Result<()> { + if format != OutputFormat::Human { + bail!("JSON output isn't implemented yet"); + } + repo.object_cache_size_if_unset(repo.compute_object_cache_size_for_tree_diffs(&**repo.index_or_empty()?)); + if in_memory { + repo.objects.enable_object_memory(); + } + let (ours_ref, ours_id) = refname_and_commit(&repo, ours)?; + let (theirs_ref, theirs_id) = refname_and_commit(&repo, theirs)?; + + let options = repo.tree_merge_options()?.with_file_favor(file_favor); + let ours_id_str = ours_id.to_string(); + let theirs_id_str = theirs_id.to_string(); + let labels = gix::merge::blob::builtin_driver::text::Labels { + ancestor: None, + current: ours_ref + .as_ref() + .map_or(ours_id_str.as_str().into(), |n| n.as_bstr()) + .into(), + other: theirs_ref + .as_ref() + .map_or(theirs_id_str.as_str().into(), |n| n.as_bstr()) + .into(), + }; + let res = repo + .merge_commits(ours_id, theirs_id, labels, options.into())? + .tree_merge; + let has_conflicts = res.conflicts.is_empty(); + let has_unresolved_conflicts = res.has_unresolved_conflicts(UnresolvedConflict::Renames); + { + let _span = gix::trace::detail!("Writing merged tree"); + let mut written = 0; + let tree_id = res + .tree + .detach() + .write(|tree| { + written += 1; + repo.write(tree) + }) + .map_err(|err| anyhow!("{err}"))?; + writeln!(out, "{tree_id} (wrote {written} trees)")?; + } + + if !has_conflicts { + writeln!(err, "{} possibly resolved conflicts", res.conflicts.len())?; + } + if has_unresolved_conflicts { + bail!("Tree conflicted") + } + Ok(()) +} + +fn refname_and_commit( + repo: &gix::Repository, + revspec: BString, +) -> anyhow::Result<(Option, gix::hash::ObjectId)> { + let spec = repo.rev_parse(revspec.as_bstr())?; + let commit_id = spec + .single() + .context("Expected revspec to expand to a single rev only")? + .object()? + .peel_to_commit()? + .id; + let refname = spec.first_reference().map(|r| r.name.shorten().as_bstr().to_owned()); + Ok((refname, commit_id)) +} diff --git a/gitoxide-core/src/repository/merge/mod.rs b/gitoxide-core/src/repository/merge/mod.rs index 6acb97bba28..e8724b57d65 100644 --- a/gitoxide-core/src/repository/merge/mod.rs +++ b/gitoxide-core/src/repository/merge/mod.rs @@ -3,3 +3,6 @@ pub use file::file; pub mod tree; pub use tree::function::tree; + +mod commit; +pub use commit::commit; diff --git a/gix-merge/src/commit.rs b/gix-merge/src/commit.rs deleted file mode 100644 index 94097ec7004..00000000000 --- a/gix-merge/src/commit.rs +++ /dev/null @@ -1,227 +0,0 @@ -/// The error returned by [`commit()`](crate::commit()). -#[derive(Debug, thiserror::Error)] -#[allow(missing_docs)] -pub enum Error { - #[error(transparent)] - MergeBase(#[from] gix_revision::merge_base::Error), - #[error(transparent)] - MergeTree(#[from] crate::tree::Error), - #[error("Failed to write tree for merged merge-base or virtual commit")] - WriteObject(gix_object::write::Error), - #[error("No common ancestor between {our_commit_id} and {their_commit_id}")] - NoMergeBase { - /// The commit on our side that was to be merged. - our_commit_id: gix_hash::ObjectId, - /// The commit on their side that was to be merged. - their_commit_id: gix_hash::ObjectId, - }, - #[error( - "Conflicts occurred when trying to resolve multiple merge-bases by merging them. This is most certainly a bug." - )] - VirtualMergeBaseConflict, - #[error("Could not find ancestor, our or their commit to extract tree from")] - FindCommit(#[from] gix_object::find::existing_object::Error), -} - -/// A way to configure [`commit()`](crate::commit()). -#[derive(Default, Debug, Clone)] -pub struct Options { - /// If `true`, merging unrelated commits is allowed, with the merge-base being assumed as empty tree. - pub allow_missing_merge_base: bool, - /// Options to define how trees should be merged. - pub tree_merge: crate::tree::Options, - /// If `true`, do not merge multiple merge-bases into one. Instead, just use the first one. - // TODO: test - #[doc(alias = "no_recursive", alias = "git2")] - pub use_first_merge_base: bool, -} - -/// The result of [`commit()`](crate::commit()). -#[derive(Clone)] -pub struct Outcome<'a> { - /// The outcome of the actual tree-merge. - pub tree_merge: crate::tree::Outcome<'a>, - /// The tree id of the base commit we used. This is either… - /// * the single merge-base we found - /// * the first of multiple merge-bases if [`use_first_merge_base`](Options::use_first_merge_base) was `true`. - /// * the merged tree of all merge-bases, which then isn't linked to an actual commit. - /// * an empty tree, if [`allow_missing_merge_base`](Options::allow_missing_merge_base) is enabled. - pub merge_base_tree_id: gix_hash::ObjectId, - /// The object ids of all the commits which were found to be merge-bases, or `None` if there was no merge-base. - pub merge_bases: Option>, - /// A list of virtual commits that were created to merge multiple merge-bases into one. - /// As they are not reachable by anything they will be garbage collected, but knowing them provides options. - pub virtual_merge_bases: Vec, -} - -pub(super) mod function { - use crate::blob::builtin_driver; - use crate::commit::{Error, Options}; - use crate::tree::UnresolvedConflict; - use gix_object::FindExt; - use std::borrow::Cow; - - /// Like [`tree()`](crate::tree()), but it takes only two commits, `our_commit` and `their_commit` to automatically - /// compute the merge-bases among them. - /// If there are multiple merge bases, these will be auto-merged into one, recursively, if - /// [`allow_missing_merge_base`](Options::allow_missing_merge_base) is `true`. - /// - /// `labels` are names where [`current`](crate::blob::builtin_driver::text::Labels::current) is a name for `our_commit` - /// and [`other`](crate::blob::builtin_driver::text::Labels::other) is a name for `their_commit`. - /// If [`ancestor`](crate::blob::builtin_driver::text::Labels::ancestor) is unset, it will be set by us based on the - /// merge-bases of `our_commit` and `their_commit`. - /// - /// The `graph` is used to find the merge-base between `our_commit` and `their_commit`, and can also act as cache - /// to speed up subsequent merge-base queries. - /// - /// Use `abbreviate_hash(id)` to shorten the given `id` according to standard git shortening rules. It's used in case - /// the ancestor-label isn't explicitly set so that the merge base label becomes the shortened `id`. - /// Note that it's a dyn closure only to make it possible to recursively call this function in case of multiple merge-bases. - /// - /// `write_object` is used only if it's allowed to merge multiple merge-bases into one, and if there - /// are multiple merge bases, and to write merged buffers as blobs. - /// - /// ### Performance - /// - /// Note that `objects` *should* have an object cache to greatly accelerate tree-retrieval. - /// - /// ### Notes - /// - /// When merging merge-bases recursively, the options are adjusted automatically to act like Git, i.e. merge binary - /// blobs and resolve with *ours*, while resorting to using the base/ancestor in case of unresolvable conflicts. - /// - /// ### Deviation - /// - /// * It's known that certain conflicts around symbolic links can be auto-resolved. We don't have an option for this - /// at all, yet, primarily as Git seems to not implement the *ours*/*theirs* choice in other places even though it - /// reasonably could. So we leave it to the caller to continue processing the returned tree at will. - #[allow(clippy::too_many_arguments)] - pub fn commit<'objects>( - our_commit: gix_hash::ObjectId, - their_commit: gix_hash::ObjectId, - labels: builtin_driver::text::Labels<'_>, - graph: &mut gix_revwalk::Graph<'_, '_, gix_revwalk::graph::Commit>, - diff_resource_cache: &mut gix_diff::blob::Platform, - blob_merge: &mut crate::blob::Platform, - objects: &'objects (impl gix_object::FindObjectOrHeader + gix_object::Write), - abbreviate_hash: &mut dyn FnMut(&gix_hash::oid) -> String, - options: Options, - ) -> Result, Error> { - let merge_bases = gix_revision::merge_base(our_commit, &[their_commit], graph)?; - let mut virtual_merge_bases = Vec::new(); - let mut state = gix_diff::tree::State::default(); - let mut commit_to_tree = - |commit_id: gix_hash::ObjectId| objects.find_commit(&commit_id, &mut state.buf1).map(|c| c.tree()); - - let (merge_base_tree_id, ancestor_name): (_, Cow<'_, str>) = match merge_bases.clone() { - Some(base_commit) if base_commit.len() == 1 => { - (commit_to_tree(base_commit[0])?, abbreviate_hash(&base_commit[0]).into()) - } - Some(mut base_commits) => { - let virtual_base_tree = if options.use_first_merge_base { - let first = *base_commits.first().expect("if Some() there is at least one."); - commit_to_tree(first)? - } else { - let mut merged_commit_id = base_commits.pop().expect("at least one base"); - let mut options = options.clone(); - options.tree_merge.allow_lossy_resolution = true; - options.tree_merge.blob_merge.is_virtual_ancestor = true; - options.tree_merge.blob_merge.text.conflict = builtin_driver::text::Conflict::ResolveWithOurs; - let favor_ancestor = Some(builtin_driver::binary::ResolveWith::Ancestor); - options.tree_merge.blob_merge.resolve_binary_with = favor_ancestor; - options.tree_merge.symlink_conflicts = favor_ancestor; - let labels = builtin_driver::text::Labels { - current: Some("Temporary merge branch 1".into()), - other: Some("Temporary merge branch 2".into()), - ..labels - }; - while let Some(next_commit_id) = base_commits.pop() { - options.tree_merge.marker_size_multiplier += 1; - let mut out = commit( - merged_commit_id, - next_commit_id, - labels, - graph, - diff_resource_cache, - blob_merge, - objects, - abbreviate_hash, - options.clone(), - )?; - // This shouldn't happen, but if for some buggy reason it does, we rather bail. - if out - .tree_merge - .has_unresolved_conflicts(UnresolvedConflict::ConflictMarkers) - { - return Err(Error::VirtualMergeBaseConflict); - } - let merged_tree_id = out - .tree_merge - .tree - .write(|tree| objects.write(tree)) - .map_err(Error::WriteObject)?; - - merged_commit_id = - create_virtual_commit(objects, merged_commit_id, next_commit_id, merged_tree_id)?; - - virtual_merge_bases.extend(out.virtual_merge_bases); - virtual_merge_bases.push(merged_commit_id); - } - commit_to_tree(merged_commit_id)? - }; - (virtual_base_tree, "merged common ancestors".into()) - } - None => { - if options.allow_missing_merge_base { - (gix_hash::ObjectId::empty_tree(our_commit.kind()), "empty tree".into()) - } else { - return Err(Error::NoMergeBase { - our_commit_id: our_commit, - their_commit_id: their_commit, - }); - } - } - }; - - let mut labels = labels; // TODO(borrowchk): this re-assignment shouldn't be needed. - if labels.ancestor.is_none() { - labels.ancestor = Some(ancestor_name.as_ref().into()); - } - - let our_tree_id = objects.find_commit(&our_commit, &mut state.buf1)?.tree(); - let their_tree_id = objects.find_commit(&their_commit, &mut state.buf1)?.tree(); - - let outcome = crate::tree( - &merge_base_tree_id, - &our_tree_id, - &their_tree_id, - labels, - objects, - |buf| objects.write_buf(gix_object::Kind::Blob, buf), - &mut state, - diff_resource_cache, - blob_merge, - options.tree_merge, - )?; - - Ok(super::Outcome { - tree_merge: outcome, - merge_bases, - merge_base_tree_id, - virtual_merge_bases, - }) - } - - fn create_virtual_commit( - objects: &(impl gix_object::Find + gix_object::Write), - parent_a: gix_hash::ObjectId, - parent_b: gix_hash::ObjectId, - tree_id: gix_hash::ObjectId, - ) -> Result { - let mut buf = Vec::new(); - let mut commit: gix_object::Commit = objects.find_commit(&parent_a, &mut buf)?.into(); - commit.parents = vec![parent_a, parent_b].into(); - commit.tree = tree_id; - objects.write(&commit).map_err(Error::WriteObject) - } -} diff --git a/gix-merge/src/commit/function.rs b/gix-merge/src/commit/function.rs new file mode 100644 index 00000000000..940369f8333 --- /dev/null +++ b/gix-merge/src/commit/function.rs @@ -0,0 +1,124 @@ +use crate::blob::builtin_driver; +use crate::commit::{Error, Options}; +use gix_object::FindExt; +use std::borrow::Cow; + +/// Like [`tree()`](crate::tree()), but it takes only two commits, `our_commit` and `their_commit` to automatically +/// compute the merge-bases among them. +/// If there are multiple merge bases, these will be auto-merged into one, recursively, if +/// [`allow_missing_merge_base`](Options::allow_missing_merge_base) is `true`. +/// +/// `labels` are names where [`current`](crate::blob::builtin_driver::text::Labels::current) is a name for `our_commit` +/// and [`other`](crate::blob::builtin_driver::text::Labels::other) is a name for `their_commit`. +/// If [`ancestor`](crate::blob::builtin_driver::text::Labels::ancestor) is unset, it will be set by us based on the +/// merge-bases of `our_commit` and `their_commit`. +/// +/// The `graph` is used to find the merge-base between `our_commit` and `their_commit`, and can also act as cache +/// to speed up subsequent merge-base queries. +/// +/// Use `abbreviate_hash(id)` to shorten the given `id` according to standard git shortening rules. It's used in case +/// the ancestor-label isn't explicitly set so that the merge base label becomes the shortened `id`. +/// Note that it's a dyn closure only to make it possible to recursively call this function in case of multiple merge-bases. +/// +/// `write_object` is used only if it's allowed to merge multiple merge-bases into one, and if there +/// are multiple merge bases, and to write merged buffers as blobs. +/// +/// ### Performance +/// +/// Note that `objects` *should* have an object cache to greatly accelerate tree-retrieval. +/// +/// ### Notes +/// +/// When merging merge-bases recursively, the options are adjusted automatically to act like Git, i.e. merge binary +/// blobs and resolve with *ours*, while resorting to using the base/ancestor in case of unresolvable conflicts. +/// +/// ### Deviation +/// +/// * It's known that certain conflicts around symbolic links can be auto-resolved. We don't have an option for this +/// at all, yet, primarily as Git seems to not implement the *ours*/*theirs* choice in other places even though it +/// reasonably could. So we leave it to the caller to continue processing the returned tree at will. +#[allow(clippy::too_many_arguments)] +pub fn commit<'objects>( + our_commit: gix_hash::ObjectId, + their_commit: gix_hash::ObjectId, + labels: builtin_driver::text::Labels<'_>, + graph: &mut gix_revwalk::Graph<'_, '_, gix_revwalk::graph::Commit>, + diff_resource_cache: &mut gix_diff::blob::Platform, + blob_merge: &mut crate::blob::Platform, + objects: &'objects (impl gix_object::FindObjectOrHeader + gix_object::Write), + abbreviate_hash: &mut dyn FnMut(&gix_hash::oid) -> String, + options: Options, +) -> Result, Error> { + let merge_bases = gix_revision::merge_base(our_commit, &[their_commit], graph)?; + let mut virtual_merge_bases = Vec::new(); + let mut state = gix_diff::tree::State::default(); + let mut commit_to_tree = + |commit_id: gix_hash::ObjectId| objects.find_commit(&commit_id, &mut state.buf1).map(|c| c.tree()); + + let (merge_base_tree_id, ancestor_name): (_, Cow<'_, str>) = match merge_bases.clone() { + Some(base_commit) if base_commit.len() == 1 => { + (commit_to_tree(base_commit[0])?, abbreviate_hash(&base_commit[0]).into()) + } + Some(mut base_commits) => { + let virtual_base_tree = if options.use_first_merge_base { + let first = base_commits.first().expect("if Some() there is at least one."); + commit_to_tree(*first)? + } else { + let first = base_commits.pop().expect("at least two"); + let second = base_commits.pop().expect("at least one left"); + let out = crate::commit::virtual_merge_base( + first, + second, + base_commits, + graph, + diff_resource_cache, + blob_merge, + objects, + abbreviate_hash, + options.tree_merge.clone(), + )?; + virtual_merge_bases = out.virtual_merge_bases; + out.tree_id + }; + (virtual_base_tree, "merged common ancestors".into()) + } + None => { + if options.allow_missing_merge_base { + (gix_hash::ObjectId::empty_tree(our_commit.kind()), "empty tree".into()) + } else { + return Err(Error::NoMergeBase { + our_commit_id: our_commit, + their_commit_id: their_commit, + }); + } + } + }; + + let mut labels = labels; // TODO(borrowchk): this re-assignment shouldn't be needed. + if labels.ancestor.is_none() { + labels.ancestor = Some(ancestor_name.as_ref().into()); + } + + let our_tree_id = objects.find_commit(&our_commit, &mut state.buf1)?.tree(); + let their_tree_id = objects.find_commit(&their_commit, &mut state.buf1)?.tree(); + + let outcome = crate::tree( + &merge_base_tree_id, + &our_tree_id, + &their_tree_id, + labels, + objects, + |buf| objects.write_buf(gix_object::Kind::Blob, buf), + &mut state, + diff_resource_cache, + blob_merge, + options.tree_merge, + )?; + + Ok(super::Outcome { + tree_merge: outcome, + merge_bases, + merge_base_tree_id, + virtual_merge_bases, + }) +} diff --git a/gix-merge/src/commit/mod.rs b/gix-merge/src/commit/mod.rs new file mode 100644 index 00000000000..4039f00d93c --- /dev/null +++ b/gix-merge/src/commit/mod.rs @@ -0,0 +1,59 @@ +/// The error returned by [`commit()`](crate::commit()). +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum Error { + #[error("Failed to obtain the merge base between the two commits to be merged")] + MergeBase(#[from] gix_revision::merge_base::Error), + #[error(transparent)] + VirtualMergeBase(#[from] virtual_merge_base::Error), + #[error(transparent)] + MergeTree(#[from] crate::tree::Error), + #[error("No common ancestor between {our_commit_id} and {their_commit_id}")] + NoMergeBase { + /// The commit on our side that was to be merged. + our_commit_id: gix_hash::ObjectId, + /// The commit on their side that was to be merged. + their_commit_id: gix_hash::ObjectId, + }, + #[error("Could not find ancestor, our or their commit to extract tree from")] + FindCommit(#[from] gix_object::find::existing_object::Error), +} + +/// A way to configure [`commit()`](crate::commit()). +#[derive(Default, Debug, Clone)] +pub struct Options { + /// If `true`, merging unrelated commits is allowed, with the merge-base being assumed as empty tree. + pub allow_missing_merge_base: bool, + /// Options to define how trees should be merged. + pub tree_merge: crate::tree::Options, + /// If `true`, do not merge multiple merge-bases into one. Instead, just use the first one. + // TODO: test + #[doc(alias = "no_recursive", alias = "git2")] + pub use_first_merge_base: bool, +} + +/// The result of [`commit()`](crate::commit()). +#[derive(Clone)] +pub struct Outcome<'a> { + /// The outcome of the actual tree-merge. + pub tree_merge: crate::tree::Outcome<'a>, + /// The tree id of the base commit we used. This is either… + /// * the single merge-base we found + /// * the first of multiple merge-bases if [`use_first_merge_base`](Options::use_first_merge_base) was `true`. + /// * the merged tree of all merge-bases, which then isn't linked to an actual commit. + /// * an empty tree, if [`allow_missing_merge_base`](Options::allow_missing_merge_base) is enabled. + pub merge_base_tree_id: gix_hash::ObjectId, + /// The object ids of all the commits which were found to be merge-bases, or `None` if there was no merge-base. + pub merge_bases: Option>, + /// A list of virtual commits that were created to merge multiple merge-bases into one, the last one being + /// the one we used as merge-base for the merge. + /// As they are not reachable by anything they will be garbage collected, but knowing them provides options. + /// Would be empty if no virtual commit was needed at all as there was only a single merge-base. + /// Otherwise, the last commit id is the one with the `merge_base_tree_id`. + pub virtual_merge_bases: Vec, +} + +pub(super) mod function; + +pub mod virtual_merge_base; +pub use virtual_merge_base::function::virtual_merge_base; diff --git a/gix-merge/src/commit/virtual_merge_base.rs b/gix-merge/src/commit/virtual_merge_base.rs new file mode 100644 index 00000000000..29009d3d11a --- /dev/null +++ b/gix-merge/src/commit/virtual_merge_base.rs @@ -0,0 +1,133 @@ +/// The outcome produced by [`commit::merge_base()`](crate::commit::virtual_merge_base()). +pub struct Outcome { + /// The commit ids of all the virtual merge bases we have produced in the process of recursively merging the merge-bases. + /// As they have been written to the object database, they are still available until they are garbage collected. + /// The last one is the most recently produced and the one returned as `commit_id`. + /// This is never empty. + pub virtual_merge_bases: Vec, + /// The id of the commit that was created to hold the merged tree. + pub commit_id: gix_hash::ObjectId, + /// The hash of the merged tree. + pub tree_id: gix_hash::ObjectId, +} + +/// The error returned by [`commit::merge_base()`](crate::commit::virtual_merge_base()). +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum Error { + #[error(transparent)] + MergeTree(#[from] crate::tree::Error), + #[error("Failed to write tree for merged merge-base or virtual commit")] + WriteObject(gix_object::write::Error), + #[error( + "Conflicts occurred when trying to resolve multiple merge-bases by merging them. This is most certainly a bug." + )] + VirtualMergeBaseConflict, + #[error("Could not find commit to use as basis for a virtual commit")] + FindCommit(#[from] gix_object::find::existing_object::Error), +} + +pub(super) mod function { + use super::Error; + use crate::blob::builtin_driver; + use crate::tree::UnresolvedConflict; + use gix_object::FindExt; + + /// Create a single virtual merge-base by merging `first_commit`, `second_commit` and `others` into one. + /// Note that `first_commit` and `second_commit` are expected to have been popped off `others`, so `first_commit` + /// was the last provided merge-base of function that provides multiple merge-bases for a pair of commits. + /// + /// The parameters `graph`, `diff_resource_cache`, `blob_merge`, `objects`, `abbreviate_hash` and `options` are passed + /// directly to [`tree()`](crate::tree()) for merging the trees of two merge-bases at a time. + /// Note that most of `options` are overwritten to match the requirements of a merge-base merge. + #[allow(clippy::too_many_arguments)] + pub fn virtual_merge_base<'objects>( + first_commit: gix_hash::ObjectId, + second_commit: gix_hash::ObjectId, + mut others: Vec, + graph: &mut gix_revwalk::Graph<'_, '_, gix_revwalk::graph::Commit>, + diff_resource_cache: &mut gix_diff::blob::Platform, + blob_merge: &mut crate::blob::Platform, + objects: &'objects (impl gix_object::FindObjectOrHeader + gix_object::Write), + abbreviate_hash: &mut dyn FnMut(&gix_hash::oid) -> String, + mut options: crate::tree::Options, + ) -> Result { + let mut merged_commit_id = first_commit; + others.push(second_commit); + + options.allow_lossy_resolution = true; + options.blob_merge.is_virtual_ancestor = true; + options.blob_merge.text.conflict = builtin_driver::text::Conflict::ResolveWithOurs; + let favor_ancestor = Some(builtin_driver::binary::ResolveWith::Ancestor); + options.blob_merge.resolve_binary_with = favor_ancestor; + options.symlink_conflicts = favor_ancestor; + let labels = builtin_driver::text::Labels { + current: Some("Temporary merge branch 1".into()), + other: Some("Temporary merge branch 2".into()), + ancestor: None, + }; + let mut virtual_merge_bases = Vec::new(); + let mut tree_id = None; + while let Some(next_commit_id) = others.pop() { + options.marker_size_multiplier += 1; + let mut out = crate::commit( + merged_commit_id, + next_commit_id, + labels, + graph, + diff_resource_cache, + blob_merge, + objects, + abbreviate_hash, + crate::commit::Options { + allow_missing_merge_base: false, + tree_merge: options.clone(), + use_first_merge_base: false, + }, + )?; + // This shouldn't happen, but if for some buggy reason it does, we rather bail. + if out + .tree_merge + .has_unresolved_conflicts(UnresolvedConflict::ConflictMarkers) + { + return Err(Error::VirtualMergeBaseConflict.into()); + } + let merged_tree_id = out + .tree_merge + .tree + .write(|tree| objects.write(tree)) + .map_err(Error::WriteObject)?; + + tree_id = Some(merged_tree_id); + merged_commit_id = create_virtual_commit(objects, merged_commit_id, next_commit_id, merged_tree_id)?; + + virtual_merge_bases.extend(out.virtual_merge_bases); + virtual_merge_bases.push(merged_commit_id); + } + + Ok(super::Outcome { + virtual_merge_bases, + commit_id: merged_commit_id, + tree_id: tree_id.map_or_else( + || { + let mut buf = Vec::new(); + objects.find_commit(&merged_commit_id, &mut buf).map(|c| c.tree()) + }, + Ok, + )?, + }) + } + + fn create_virtual_commit( + objects: &(impl gix_object::Find + gix_object::Write), + parent_a: gix_hash::ObjectId, + parent_b: gix_hash::ObjectId, + tree_id: gix_hash::ObjectId, + ) -> Result { + let mut buf = Vec::new(); + let mut commit: gix_object::Commit = objects.find_commit(&parent_a, &mut buf)?.into(); + commit.parents = vec![parent_a, parent_b].into(); + commit.tree = tree_id; + objects.write(&commit).map_err(Error::WriteObject) + } +} diff --git a/gix/src/merge.rs b/gix/src/merge.rs index 2f634fc1b77..978ce0a8003 100644 --- a/gix/src/merge.rs +++ b/gix/src/merge.rs @@ -2,6 +2,108 @@ pub use gix_merge as plumbing; pub use gix_merge::blob; +/// +pub mod virtual_merge_base { + use crate::Id; + + /// The outcome produced by [`Repository::virtual_merge_base()`](crate::Repository::virtual_merge_base()). + pub struct Outcome<'repo> { + /// The commit ids of all the virtual merge bases we have produced in the process of recursively merging the merge-bases. + /// As they have been written to the object database, they are still available until they are garbage collected. + /// The last one is the most recently produced and the one returned as `commit_id`. + /// If this list is empty, this means that there was only one merge-base, which itself is already suitable the final merge-base. + pub virtual_merge_bases: Vec>, + /// The id of the commit that was created to hold the merged tree. + pub commit_id: Id<'repo>, + /// The hash of the merged tree. + pub tree_id: Id<'repo>, + } +} + +/// +pub mod commit { + /// The outcome produced by [`Repository::merge_commits()`](crate::Repository::merge_commits()). + #[derive(Clone)] + pub struct Outcome<'a> { + /// The outcome of the actual tree-merge, with the tree editor to write to obtain the actual tree id. + pub tree_merge: crate::merge::tree::Outcome<'a>, + /// The tree id of the base commit we used. This is either… + /// * the single merge-base we found + /// * the first of multiple merge-bases if [Options::with_use_first_merge_base()] was `true`. + /// * the merged tree of all merge-bases, which then isn't linked to an actual commit. + /// * an empty tree, if [Options::with_allow_missing_merge_base()] is enabled. + pub merge_base_tree_id: gix_hash::ObjectId, + /// The object ids of all the commits which were found to be merge-bases, or `None` if there was no merge-base. + pub merge_bases: Option>, + /// A list of virtual commits that were created to merge multiple merge-bases into one, the last one being + /// the one we used as merge-base for the merge. + /// As they are not reachable by anything they will be garbage collected, but knowing them provides options. + /// Would be empty if no virtual commit was needed at all as there was only a single merge-base. + /// Otherwise, the last commit id is the one with the `merge_base_tree_id`. + pub virtual_merge_bases: Vec, + } + + /// A way to configure [`Repository::merge_commits()`](crate::Repository::merge_commits()). + #[derive(Default, Debug, Clone)] + pub struct Options { + allow_missing_merge_base: bool, + tree_merge: crate::merge::tree::Options, + use_first_merge_base: bool, + } + + impl From for Options { + fn from(value: gix_merge::tree::Options) -> Self { + Options { + tree_merge: value.into(), + use_first_merge_base: false, + allow_missing_merge_base: false, + } + } + } + + impl From for Options { + fn from(value: crate::merge::tree::Options) -> Self { + Options { + tree_merge: value, + use_first_merge_base: false, + allow_missing_merge_base: false, + } + } + } + + impl From for gix_merge::commit::Options { + fn from( + Options { + allow_missing_merge_base, + tree_merge, + use_first_merge_base, + }: Options, + ) -> Self { + gix_merge::commit::Options { + allow_missing_merge_base, + tree_merge: tree_merge.into(), + use_first_merge_base, + } + } + } + + /// Builder + impl Options { + /// If `true`, merging unrelated commits is allowed, with the merge-base being assumed as empty tree. + pub fn with_allow_missing_merge_base(mut self, allow_missing_merge_base: bool) -> Self { + self.allow_missing_merge_base = allow_missing_merge_base; + self + } + + /// If `true`, do not merge multiple merge-bases into one. Instead, just use the first one. + #[doc(alias = "no_recursive", alias = "git2")] + pub fn with_use_first_merge_base(mut self, use_first_merge_base: bool) -> Self { + self.use_first_merge_base = use_first_merge_base; + self + } + } +} + /// pub mod tree { use gix_merge::blob::builtin_driver; diff --git a/gix/src/repository/merge.rs b/gix/src/repository/merge.rs index 7b5e18297a2..72f38795bd1 100644 --- a/gix/src/repository/merge.rs +++ b/gix/src/repository/merge.rs @@ -1,6 +1,10 @@ use crate::config::cache::util::ApplyLeniencyDefault; use crate::config::tree; -use crate::repository::{blob_merge_options, merge_resource_cache, merge_trees, tree_merge_options}; +use crate::prelude::ObjectIdExt; +use crate::repository::{ + blob_merge_options, merge_commits, merge_resource_cache, merge_trees, tree_merge_options, virtual_merge_base, + virtual_merge_base_with_graph, +}; use crate::Repository; use gix_merge::blob::builtin_driver::text; use gix_object::Write; @@ -102,7 +106,7 @@ impl Repository { } /// Merge `our_tree` and `their_tree` together, assuming they have the same `ancestor_tree`, to yield a new tree - /// which is provided as [tree editor](gix_object::tree::Editor) to inspect and finalize results at will. + /// which is provided as [tree editor](crate::object::tree::Editor) to inspect and finalize results at will. /// No change to the worktree or index is made, but objects may be written to the object database as merge results /// are stored. /// If these changes should not be observable outside of this instance, consider [enabling object memory](Self::with_object_memory). @@ -115,7 +119,7 @@ impl Repository { /// /// ### Performance /// - /// It's highly recommended to [set an object cache](crate::Repository::compute_object_cache_size_for_tree_diffs) + /// It's highly recommended to [set an object cache](Repository::compute_object_cache_size_for_tree_diffs) /// to avoid extracting the same object multiple times. pub fn merge_trees( &self, @@ -155,4 +159,136 @@ impl Repository { failed_on_first_unresolved_conflict, }) } + + /// Merge `our_commit` and `their_commit` together to yield a new tree which is provided as [tree editor](crate::object::tree::Editor) + /// to inspect and finalize results at will. The merge-base will be determined automatically between both commits, along with special + /// handling in case there are multiple merge-bases. + /// No change to the worktree or index is made, but objects may be written to the object database as merge results + /// are stored. + /// If these changes should not be observable outside of this instance, consider [enabling object memory](Self::with_object_memory). + /// + /// `labels` are typically chosen to identify the refs or names for `our_commit` and `their_commit`, with the ancestor being set + /// automatically as part of the merge-base handling. + /// + /// `options` should be initialized with [`Repository::tree_merge_options().into()`](Self::tree_merge_options()). + /// + /// ### Performance + /// + /// It's highly recommended to [set an object cache](Repository::compute_object_cache_size_for_tree_diffs) + /// to avoid extracting the same object multiple times. + pub fn merge_commits( + &self, + our_commit: impl Into, + their_commit: impl Into, + labels: gix_merge::blob::builtin_driver::text::Labels<'_>, + options: crate::merge::commit::Options, + ) -> Result, merge_commits::Error> { + let mut diff_cache = self.diff_resource_cache_for_tree_diff()?; + let mut blob_merge = self.merge_resource_cache(Default::default())?; + let commit_graph = self.commit_graph_if_enabled()?; + let mut graph = self.revision_graph(commit_graph.as_ref()); + let gix_merge::commit::Outcome { + tree_merge: + gix_merge::tree::Outcome { + tree, + conflicts, + failed_on_first_unresolved_conflict, + }, + merge_base_tree_id, + merge_bases, + virtual_merge_bases, + } = gix_merge::commit( + our_commit.into(), + their_commit.into(), + labels, + &mut graph, + &mut diff_cache, + &mut blob_merge, + self, + &mut |id| id.to_owned().attach(self).shorten_or_id().to_string(), + options.into(), + )?; + + let validate = self.config.protect_options()?; + let tree_merge = crate::merge::tree::Outcome { + tree: crate::object::tree::Editor { + inner: tree, + validate, + repo: self, + }, + conflicts, + failed_on_first_unresolved_conflict, + }; + Ok(crate::merge::commit::Outcome { + tree_merge, + merge_base_tree_id, + merge_bases, + virtual_merge_bases, + }) + } + + /// Create a single virtual merge-base by merging all `merge_bases` into one. + /// If the list is empty, an error will be returned as the histories are then unrelated. + /// If there is only one commit in the list, it is returned directly with this case clearly marked in the outcome. + /// + /// Note that most of `options` are overwritten to match the requirements of a merge-base merge, but they can be useful + /// to control the diff algorithm or rewrite tracking, for example. + // TODO: test + pub fn virtual_merge_base( + &self, + merge_bases: impl IntoIterator>, + options: crate::merge::tree::Options, + ) -> Result, virtual_merge_base::Error> { + let commit_graph = self.commit_graph_if_enabled()?; + let mut graph = self.revision_graph(commit_graph.as_ref()); + Ok(self.virtual_merge_base_with_graph(merge_bases, &mut graph, options)?) + } + + /// Like [`Self::virtual_merge_base()`], but also allows to reuse a `graph` for faster merge-base calculation, + /// particularly if `graph` was used to find the `merge_bases`. + pub fn virtual_merge_base_with_graph( + &self, + merge_bases: impl IntoIterator>, + graph: &mut gix_revwalk::Graph<'_, '_, gix_revwalk::graph::Commit>, + options: crate::merge::tree::Options, + ) -> Result, virtual_merge_base_with_graph::Error> { + let mut merge_bases: Vec<_> = merge_bases.into_iter().map(Into::into).collect(); + let first = merge_bases + .pop() + .ok_or(virtual_merge_base_with_graph::Error::MissingCommit)?; + let Some(second) = merge_bases.pop() else { + let tree_id = self.find_commit(first)?.tree_id()?; + let commit_id = first.attach(self); + return Ok(crate::merge::virtual_merge_base::Outcome { + virtual_merge_bases: Vec::new(), + commit_id, + tree_id, + }); + }; + + let mut diff_cache = self.diff_resource_cache_for_tree_diff()?; + let mut blob_merge = self.merge_resource_cache(Default::default())?; + + let gix_merge::commit::virtual_merge_base::Outcome { + virtual_merge_bases, + commit_id, + tree_id, + } = gix_merge::commit::virtual_merge_base( + first, + second, + merge_bases, + graph, + &mut diff_cache, + &mut blob_merge, + self, + &mut |id| id.to_owned().attach(self).shorten_or_id().to_string(), + options.into(), + )?; + + Ok(crate::merge::virtual_merge_base::Outcome { + virtual_merge_bases: virtual_merge_bases.into_iter().map(|id| id.attach(self)).collect(), + commit_id: commit_id.attach(self), + tree_id: tree_id.attach(self), + }) + } } diff --git a/gix/src/repository/mod.rs b/gix/src/repository/mod.rs index 1eeb0b36226..069ce119b07 100644 --- a/gix/src/repository/mod.rs +++ b/gix/src/repository/mod.rs @@ -130,6 +130,62 @@ pub mod merge_trees { } } +/// +#[cfg(feature = "merge")] +pub mod merge_commits { + /// The error returned by [Repository::merge_commits()](crate::Repository::merge_commits()). + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + pub enum Error { + #[error(transparent)] + OpenCommitGraph(#[from] super::commit_graph_if_enabled::Error), + #[error(transparent)] + MergeResourceCache(#[from] super::merge_resource_cache::Error), + #[error(transparent)] + DiffResourceCache(#[from] super::diff_resource_cache::Error), + #[error(transparent)] + CommitMerge(#[from] gix_merge::commit::Error), + #[error(transparent)] + ValidationOptions(#[from] crate::config::boolean::Error), + } +} + +/// +#[cfg(feature = "merge")] +pub mod virtual_merge_base { + /// The error returned by [Repository::virtual_merge_base()](crate::Repository::virtual_merge_base()). + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + pub enum Error { + #[error(transparent)] + OpenCommitGraph(#[from] super::commit_graph_if_enabled::Error), + #[error(transparent)] + VirtualMergeBase(#[from] super::virtual_merge_base_with_graph::Error), + } +} + +/// +#[cfg(feature = "merge")] +pub mod virtual_merge_base_with_graph { + /// The error returned by [Repository::virtual_merge_base_with_graph()](crate::Repository::virtual_merge_base_with_graph()). + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + pub enum Error { + #[error("Not commit was provided as merge-base")] + MissingCommit, + #[error(transparent)] + MergeResourceCache(#[from] super::merge_resource_cache::Error), + #[error(transparent)] + DiffResourceCache(#[from] super::diff_resource_cache::Error), + #[error(transparent)] + CommitMerge(#[from] gix_merge::commit::Error), + #[error(transparent)] + FindCommit(#[from] crate::object::find::existing::with_conversion::Error), + #[error(transparent)] + DecodeCommit(#[from] gix_object::decode::Error), + } +} + /// #[cfg(feature = "merge")] pub mod tree_merge_options { diff --git a/src/plumbing/main.rs b/src/plumbing/main.rs index daaa0677197..409ea6a10b8 100644 --- a/src/plumbing/main.rs +++ b/src/plumbing/main.rs @@ -200,6 +200,33 @@ pub fn main() -> Result<()> { ) }, ), + merge::SubCommands::Commit { + in_memory, + file_favor, + ours, + theirs, + } => prepare_and_run( + "merge-commit", + trace, + verbose, + progress, + progress_keep_open, + None, + move |_progress, out, err| { + core::repository::merge::commit( + repository(Mode::Lenient)?, + out, + err, + ours, + theirs, + core::repository::merge::tree::Options { + format, + file_favor: file_favor.map(Into::into), + in_memory, + }, + ) + }, + ), }, Subcommands::MergeBase(crate::plumbing::options::merge_base::Command { first, others }) => prepare_and_run( "merge-base", diff --git a/src/plumbing/options/mod.rs b/src/plumbing/options/mod.rs index 6d46d546cc2..546b02c1622 100644 --- a/src/plumbing/options/mod.rs +++ b/src/plumbing/options/mod.rs @@ -428,7 +428,25 @@ pub mod merge { #[clap(value_name = "BASE", value_parser = crate::shared::AsBString)] base: BString, /// A revspec to their treeish. + #[clap(value_name = "THEIRS", value_parser = crate::shared::AsBString)] + theirs: BString, + }, + /// Merge a commits by specifying ours, and theirs, writing the tree to the object database. + Commit { + /// Keep all objects to be written in memory to avoid any disk IO. + /// + /// Note that the resulting tree won't be available or inspectable. + #[clap(long, short = 'm')] + in_memory: bool, + /// Decide how to resolve content conflicts in files. If unset, write conflict markers and fail. + #[clap(long, short = 'f')] + file_favor: Option, + + /// A revspec to our committish. #[clap(value_name = "OURS", value_parser = crate::shared::AsBString)] + ours: BString, + /// A revspec to their committish. + #[clap(value_name = "THEIRS", value_parser = crate::shared::AsBString)] theirs: BString, }, }