Skip to content

Commit f56fade

Browse files
committed
Add baseline tests for tree-merges along with first plain merge.
That way, Git can indicate what we need to match, and we have enough infrastructure to perform simple merges (seemingly) correctly.
1 parent 3a4421c commit f56fade

File tree

10 files changed

+498
-16
lines changed

10 files changed

+498
-16
lines changed

Cargo.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

gix-merge/Cargo.toml

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,33 +15,34 @@ workspace = true
1515
doctest = false
1616

1717
[features]
18-
default = ["blob"]
19-
## Enable diffing of blobs using imara-diff, which also allows for a generic rewrite tracking implementation.
20-
blob = ["dep:imara-diff", "dep:gix-filter", "dep:gix-worktree", "dep:gix-path", "dep:gix-fs", "dep:gix-command", "dep:gix-tempfile", "dep:gix-trace", "dep:gix-quote"]
2118
## Data structures implement `serde::Serialize` and `serde::Deserialize`.
2219
serde = ["dep:serde", "gix-hash/serde", "gix-object/serde"]
2320

2421
[dependencies]
2522
gix-hash = { version = "^0.14.2", path = "../gix-hash" }
2623
gix-object = { version = "^0.44.0", path = "../gix-object" }
27-
gix-filter = { version = "^0.13.0", path = "../gix-filter", optional = true }
28-
gix-worktree = { version = "^0.36.0", path = "../gix-worktree", default-features = false, features = ["attributes"], optional = true }
29-
gix-command = { version = "^0.3.9", path = "../gix-command", optional = true }
30-
gix-path = { version = "^0.10.11", path = "../gix-path", optional = true }
31-
gix-fs = { version = "^0.11.3", path = "../gix-fs", optional = true }
32-
gix-tempfile = { version = "^14.0.0", path = "../gix-tempfile", optional = true }
33-
gix-trace = { version = "^0.1.10", path = "../gix-trace", optional = true }
34-
gix-quote = { version = "^0.4.12", path = "../gix-quote", optional = true }
24+
gix-filter = { version = "^0.13.0", path = "../gix-filter" }
25+
gix-worktree = { version = "^0.36.0", path = "../gix-worktree", default-features = false, features = ["attributes"] }
26+
gix-command = { version = "^0.3.9", path = "../gix-command" }
27+
gix-path = { version = "^0.10.11", path = "../gix-path" }
28+
gix-fs = { version = "^0.11.3", path = "../gix-fs" }
29+
gix-tempfile = { version = "^14.0.0", path = "../gix-tempfile" }
30+
gix-trace = { version = "^0.1.10", path = "../gix-trace" }
31+
gix-quote = { version = "^0.4.12", path = "../gix-quote" }
32+
gix-revision = { version = "^0.29.0", path = "../gix-revision", default-features = false, features = ["merge_base"] }
33+
gix-revwalk = { version = "^0.15.0", path = "../gix-revwalk" }
34+
gix-diff = { version = "^0.46.0", path = "../gix-diff", default-features = false, features = ["blob"] }
3535

3636
thiserror = "1.0.63"
37-
imara-diff = { version = "0.1.7", optional = true }
37+
imara-diff = { version = "0.1.7" }
3838
bstr = { version = "1.5.0", default-features = false }
3939
serde = { version = "1.0.114", optional = true, default-features = false, features = ["derive"] }
4040

4141
document-features = { version = "0.2.0", optional = true }
4242

4343
[dev-dependencies]
4444
gix-testtools = { path = "../tests/tools" }
45+
gix-odb = { path = "../gix-odb" }
4546
pretty_assertions = "1.4.0"
4647

4748
[package.metadata.docs.rs]

gix-merge/src/commit.rs

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/// The error returned by [`commit()`](crate::commit()).
2+
#[derive(Debug, thiserror::Error)]
3+
#[allow(missing_docs)]
4+
pub enum Error {
5+
#[error(transparent)]
6+
MergeBase(#[from] gix_revision::merge_base::Error),
7+
#[error(transparent)]
8+
MergeTree(#[from] crate::tree::Error),
9+
#[error("No common ancestor between {our_commit_id} and {their_commit_id}")]
10+
NoMergeBase {
11+
/// The commit on our side that was to be merged.
12+
our_commit_id: gix_hash::ObjectId,
13+
/// The commit on their side that was to be merged.
14+
their_commit_id: gix_hash::ObjectId,
15+
},
16+
#[error("Could not find ancestor, our or their commit to extract tree from")]
17+
FindCommit(#[from] gix_object::find::existing_object::Error),
18+
}
19+
20+
/// A way to configure [`commit()`](crate::commit()).
21+
#[derive(Default, Debug, Copy, Clone)]
22+
pub struct Options {
23+
/// If `true`, merging unrelated commits is allowed, with the merge-base being assumed as empty tree.
24+
pub allow_missing_merge_base: bool,
25+
/// Options to define how trees should be merged.
26+
pub tree_merge: crate::tree::Options,
27+
/// Options to define how to merge blobs.
28+
///
29+
/// Note that these are temporarily overwritten if multiple merge-bases are merged into one.
30+
pub blob_merge: crate::blob::platform::merge::Options,
31+
}
32+
33+
pub(super) mod function {
34+
use crate::commit::{Error, Options};
35+
use gix_object::FindExt;
36+
37+
/// Like [`tree()`](crate::tree()), but it takes only two commits, `our_commit` and `their_commit` to automatically
38+
/// compute the merge-bases among them.
39+
/// If there are multiple merge bases, these will be auto-merged into one, recursively, if
40+
/// [`allow_missing_merge_base`](Options::allow_missing_merge_base) is `true`.
41+
///
42+
/// `labels` are names where [`current`](crate::blob::builtin_driver::text::Labels::current) is a name for `our_commit`
43+
/// and [`other`](crate::blob::builtin_driver::text::Labels::other) is a name for `their_commit`.
44+
/// If [`ancestor`](crate::blob::builtin_driver::text::Labels::ancestor) is unset, it will be set by us based on the
45+
/// merge-bases of `our_commit` and `their_commit`.
46+
///
47+
/// The `graph` is used to find the merge-base between `our_commit` and `their_commit`, and can also act as cache
48+
/// to speed up subsequent merge-base queries.
49+
///
50+
/// ### Performance
51+
///
52+
/// Note that `objects` *should* have an object cache to greatly accelerate tree-retrieval.
53+
pub fn commit<'a>(
54+
our_commit: gix_hash::ObjectId,
55+
their_commit: gix_hash::ObjectId,
56+
mut labels: crate::blob::builtin_driver::text::Labels<'_>,
57+
graph: &mut gix_revwalk::Graph<'_, '_, gix_revwalk::graph::Commit<gix_revision::merge_base::Flags>>,
58+
diff_resource_cache: &mut gix_diff::blob::Platform,
59+
blob_merge: &mut crate::blob::Platform,
60+
objects: &impl gix_object::FindObjectOrHeader,
61+
options: Options,
62+
) -> Result<crate::tree::Outcome<'a>, Error> {
63+
let merge_bases_commit_ids = gix_revision::merge_base(our_commit, &[their_commit], graph)?;
64+
let (merge_base_commit_id, ancestor_name) = match merge_bases_commit_ids {
65+
Some(base_commit) if base_commit.len() == 1 => (base_commit[0], None),
66+
Some(_base_commits) => {
67+
todo!("merge multiple bases into one");
68+
(our_commit.kind().null(), Some("merged common ancestors".into()))
69+
}
70+
None => {
71+
if options.allow_missing_merge_base {
72+
(
73+
gix_hash::ObjectId::empty_tree(our_commit.kind()),
74+
Some("empty tree".into()),
75+
)
76+
} else {
77+
return Err(Error::NoMergeBase {
78+
our_commit_id: our_commit,
79+
their_commit_id: their_commit,
80+
});
81+
}
82+
}
83+
};
84+
if labels.ancestor.is_none() {
85+
labels.ancestor = ancestor_name;
86+
}
87+
88+
let mut state = gix_diff::tree::State::default();
89+
let merge_base_tree_id = objects.find_commit(&merge_base_commit_id, &mut state.buf1)?.tree();
90+
let our_tree_id = objects.find_commit(&our_commit, &mut state.buf1)?.tree();
91+
let their_tree_id = objects.find_commit(&their_commit, &mut state.buf1)?.tree();
92+
93+
Ok(crate::tree(
94+
&merge_base_tree_id,
95+
&our_tree_id,
96+
&their_tree_id,
97+
labels,
98+
objects,
99+
&mut state,
100+
diff_resource_cache,
101+
blob_merge,
102+
options.tree_merge,
103+
)?)
104+
}
105+
}

gix-merge/src/lib.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,10 @@
22
#![forbid(unsafe_code)]
33

44
///
5-
#[cfg(feature = "blob")]
65
pub mod blob;
6+
///
7+
pub mod commit;
8+
pub use commit::function::commit;
9+
///
10+
pub mod tree;
11+
pub use tree::function::tree;

gix-merge/src/tree.rs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
use gix_diff::Rewrites;
2+
3+
/// The error returned by [`tree()`](crate::tree()).
4+
#[derive(Debug, thiserror::Error)]
5+
#[allow(missing_docs)]
6+
pub enum Error {
7+
#[error("Could not find ancestor, our or their tree to get started")]
8+
FindTree(#[from] gix_object::find::existing_object::Error),
9+
#[error("Could not find ancestor, our or their tree to get started")]
10+
FindTree2(#[from] gix_object::find::existing_iter::Error),
11+
#[error("Failed to diff our side or their side")]
12+
DiffTree(#[from] gix_diff::tree_with_rewrites::Error),
13+
}
14+
15+
/// The outcome produced by [`tree()`](crate::tree()).
16+
pub struct Outcome<'a> {
17+
/// The ready-made (but unwritten) tree if `conflicts` is empty, or the best-possible tree when facing `conflicts`.
18+
///
19+
/// The tree may contain blobs with conflict markers, and will be missing directories or files that were conflicting
20+
/// without a resolution strategy.
21+
tree: gix_object::tree::Editor<'a>,
22+
/// The set of conflicts we encountered. Can be empty to indicate there was no conflict.
23+
conflicts: Vec<Conflict>,
24+
}
25+
26+
/// A description of a conflict (i.e. merge issue without an auto-resolution) as seen during a [tree-merge](crate::tree()).
27+
pub struct Conflict;
28+
29+
/// A way to configure [`tree()`](crate::tree()).
30+
#[derive(Default, Debug, Copy, Clone)]
31+
pub struct Options {
32+
/// If not `None`, rename tracking will be performed when determining the changes of each side of the merge.
33+
pub rewrites: Option<Rewrites>,
34+
// TODO(Perf) add a flag to allow parallelizing the tree-diff itself.
35+
}
36+
37+
pub(super) mod function {
38+
use crate::tree::{Error, Options, Outcome};
39+
use gix_diff::tree::recorder::Location;
40+
use gix_object::FindExt;
41+
use std::convert::Infallible;
42+
43+
/// Perform a merge between `our_tree` and `their_tree`, using `base_tree` as merge-base.
44+
/// Note that `base_tree` can be an empty tree to indicate 'no common ancestor between the two sides'.
45+
///
46+
/// `labels` are relevant for text-merges and will be shown in conflicts.
47+
/// `objects` provides access to trees when diffing them.
48+
/// `diff_state` is state used for diffing trees.
49+
/// `diff_resource_cache` is used for similarity checks.
50+
/// `blob_merge` is a pre-configured platform to merge any content.
51+
/// `options` are used to affect how the merge is performed.
52+
///
53+
/// ### Performance
54+
///
55+
/// Note that `objects` *should* have an object cache to greatly accelerate tree-retrieval.
56+
pub fn tree<'a>(
57+
base_tree: &gix_hash::oid,
58+
our_tree: &gix_hash::oid,
59+
their_tree: &gix_hash::oid,
60+
labels: crate::blob::builtin_driver::text::Labels<'_>,
61+
objects: &impl gix_object::FindObjectOrHeader,
62+
diff_state: &mut gix_diff::tree::State,
63+
diff_resource_cache: &mut gix_diff::blob::Platform,
64+
blob_merge: &mut crate::blob::Platform,
65+
options: Options,
66+
) -> Result<Outcome<'a>, Error> {
67+
let (mut base_buf, mut side_buf) = (Vec::new(), Vec::new());
68+
let ancestor_tree = objects.find_tree_iter(base_tree, &mut base_buf)?;
69+
let our_tree = objects.find_tree_iter(our_tree, &mut side_buf)?;
70+
71+
let mut our_changes = Vec::new();
72+
gix_diff::tree_with_rewrites(
73+
ancestor_tree,
74+
our_tree,
75+
diff_resource_cache,
76+
diff_state,
77+
objects,
78+
|change| -> Result<_, Infallible> {
79+
our_changes.push(change.into_owned());
80+
Ok(gix_diff::tree_with_rewrites::Action::Continue)
81+
},
82+
gix_diff::tree_with_rewrites::Options {
83+
location: Some(Location::Path),
84+
rewrites: options.rewrites,
85+
},
86+
)?;
87+
88+
let mut their_changes = Vec::new();
89+
let their_tree = objects.find_tree_iter(their_tree, &mut side_buf)?;
90+
gix_diff::tree_with_rewrites(
91+
ancestor_tree,
92+
their_tree,
93+
diff_resource_cache,
94+
diff_state,
95+
objects,
96+
|change| -> Result<_, Infallible> {
97+
their_changes.push(change.into_owned());
98+
Ok(gix_diff::tree_with_rewrites::Action::Continue)
99+
},
100+
gix_diff::tree_with_rewrites::Options {
101+
location: Some(Location::Path),
102+
rewrites: options.rewrites,
103+
},
104+
)?;
105+
106+
dbg!(&our_changes, &their_changes);
107+
let mut editor = gix_object::tree::Editor::new(
108+
gix_object::TreeRef::from_bytes(&base_buf)
109+
.expect("ancestor was decoded before")
110+
.into(),
111+
&objects,
112+
base_tree.kind(),
113+
);
114+
todo!()
115+
}
116+
}
66.5 KB
Binary file not shown.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
#!/usr/bin/env bash
2+
set -eu -o pipefail
3+
4+
function tick () {
5+
if test -z "${tick+set}"
6+
then
7+
tick=1112911993
8+
else
9+
tick=$(($tick + 60))
10+
fi
11+
GIT_COMMITTER_DATE="$tick -0700"
12+
GIT_AUTHOR_DATE="$tick -0700"
13+
export GIT_COMMITTER_DATE GIT_AUTHOR_DATE
14+
}
15+
16+
function write_lines () {
17+
printf "%s\n" "$@"
18+
}
19+
20+
function baseline () (
21+
local dir=${1:?the directory to enter}
22+
local output_name=${2:?the basename of the output of the merge}
23+
local our_committish=${3:?our side from which a commit can be derived}
24+
local their_committish=${4:?Their side from which a commit can be derived}
25+
26+
cd "$dir"
27+
local our_commit_id
28+
local their_commit_id
29+
30+
our_commit_id="$(git rev-parse "$our_committish")"
31+
their_commit_id="$(git rev-parse "$their_committish")"
32+
33+
local merge_info="${output_name}.merge-info"
34+
git merge-tree -z --write-tree "$our_commit_id" "$their_commit_id" > "$merge_info" || :
35+
echo "$dir" "$our_commit_id" "$their_commit_id" "$merge_info" >> ../baseline.cases
36+
)
37+
38+
git init simple
39+
(cd simple
40+
rm -Rf .git/hooks
41+
write_lines 1 2 3 4 5 >numbers
42+
echo hello >greeting
43+
echo foo >whatever
44+
git add numbers greeting whatever
45+
tick
46+
git commit -m initial
47+
48+
git branch side1
49+
git branch side2
50+
git branch side3
51+
git branch side4
52+
53+
git checkout side1
54+
write_lines 1 2 3 4 5 6 >numbers
55+
echo hi >greeting
56+
echo bar >whatever
57+
git add numbers greeting whatever
58+
tick
59+
git commit -m modify-stuff
60+
61+
git checkout side2
62+
write_lines 0 1 2 3 4 5 >numbers
63+
echo yo >greeting
64+
git rm whatever
65+
mkdir whatever
66+
>whatever/empty
67+
git add numbers greeting whatever/empty
68+
tick
69+
git commit -m other-modifications
70+
71+
git checkout side3
72+
git mv numbers sequence
73+
tick
74+
git commit -m rename-numbers
75+
76+
git checkout side4
77+
write_lines 0 1 2 3 4 5 >numbers
78+
echo yo >greeting
79+
git add numbers greeting
80+
tick
81+
git commit -m other-content-modifications
82+
83+
git switch --orphan unrelated
84+
>something-else
85+
git add something-else
86+
tick
87+
git commit -m first-commit
88+
)
89+
90+
baseline simple without-conflict side1 side3

gix-merge/tests/merge/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
extern crate core;
22

3-
#[cfg(feature = "blob")]
43
mod blob;
4+
mod tree;
55

66
pub use gix_testtools::Result;

0 commit comments

Comments
 (0)