Skip to content

Commit 7a81060

Browse files
committed
Finish the entire merge implementation and cover everything with tests.
1 parent 2e56298 commit 7a81060

File tree

7 files changed

+465
-122
lines changed

7 files changed

+465
-122
lines changed

Cargo.lock

Lines changed: 1 addition & 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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ document-features = { version = "0.2.0", optional = true }
4343
[dev-dependencies]
4444
gix-testtools = { path = "../tests/tools" }
4545
gix-odb = { path = "../gix-odb" }
46+
gix-utils = { version = "^0.1.12", path = "../gix-utils" }
4647
pretty_assertions = "1.4.0"
4748

4849
[package.metadata.docs.rs]

gix-merge/src/commit.rs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,12 @@ pub enum Error {
1818
}
1919

2020
/// A way to configure [`commit()`](crate::commit()).
21-
#[derive(Default, Debug, Copy, Clone)]
21+
#[derive(Default, Debug, Clone)]
2222
pub struct Options {
2323
/// If `true`, merging unrelated commits is allowed, with the merge-base being assumed as empty tree.
2424
pub allow_missing_merge_base: bool,
2525
/// Options to define how trees should be merged.
2626
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,
3127
}
3228

3329
pub(super) mod function {
@@ -51,16 +47,20 @@ pub(super) mod function {
5147
///
5248
/// Note that `objects` *should* have an object cache to greatly accelerate tree-retrieval.
5349
#[allow(clippy::too_many_arguments)]
54-
pub fn commit<'objects>(
50+
pub fn commit<'objects, E>(
5551
our_commit: gix_hash::ObjectId,
5652
their_commit: gix_hash::ObjectId,
5753
mut labels: crate::blob::builtin_driver::text::Labels<'_>,
5854
graph: &mut gix_revwalk::Graph<'_, '_, gix_revwalk::graph::Commit<gix_revision::merge_base::Flags>>,
5955
diff_resource_cache: &mut gix_diff::blob::Platform,
6056
blob_merge: &mut crate::blob::Platform,
6157
objects: &'objects impl gix_object::FindObjectOrHeader,
58+
write_blob_to_odb: impl FnMut(&[u8]) -> Result<gix_hash::ObjectId, E>,
6259
options: Options,
63-
) -> Result<crate::tree::Outcome<'objects>, Error> {
60+
) -> Result<crate::tree::Outcome<'objects>, Error>
61+
where
62+
E: Into<Box<dyn std::error::Error + Send + Sync + 'static>>,
63+
{
6464
let merge_bases_commit_ids = gix_revision::merge_base(our_commit, &[their_commit], graph)?;
6565
let (merge_base_commit_id, ancestor_name) = match merge_bases_commit_ids {
6666
Some(base_commit) if base_commit.len() == 1 => (base_commit[0], None),
@@ -97,6 +97,7 @@ pub(super) mod function {
9797
&their_tree_id,
9898
labels,
9999
objects,
100+
write_blob_to_odb,
100101
&mut state,
101102
diff_resource_cache,
102103
blob_merge,

gix-merge/src/tree.rs

Lines changed: 261 additions & 94 deletions
Large diffs are not rendered by default.
Binary file not shown.

gix-merge/tests/fixtures/tree-baseline.sh

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ function baseline () (
3131
their_commit_id="$(git rev-parse "$their_committish")"
3232

3333
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
34+
git merge-tree -z --write-tree "$our_committish" "$their_committish" > "$merge_info" || :
35+
echo "$dir" "$our_commit_id" "$our_committish" "$their_commit_id" "$their_committish" "$merge_info" >> ../baseline.cases
3636
)
3737

3838
git init simple
@@ -88,3 +88,4 @@ git init simple
8888
)
8989

9090
baseline simple without-conflict side1 side3
91+
baseline simple various-conflicts side1 side2

gix-merge/tests/merge/tree.rs

Lines changed: 191 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ fn run_baseline() -> crate::Result {
99
root,
1010
odb,
1111
our_commit_id,
12+
our_side_name,
1213
their_commit_id,
14+
their_side_name,
1315
merge_info,
1416
case_name,
1517
} in baseline::Expectations::new(&root, &cases)
@@ -25,50 +27,123 @@ fn run_baseline() -> crate::Result {
2527
percentage: Some(0.5),
2628
limit: 0,
2729
}),
30+
blob_merge: Default::default(),
31+
blob_merge_command_ctx: Default::default(),
2832
},
29-
blob_merge: Default::default(),
3033
};
3134
let mut actual = gix_merge::commit(
3235
our_commit_id,
3336
their_commit_id,
3437
gix_merge::blob::builtin_driver::text::Labels {
3538
ancestor: None,
36-
current: Some("ours".into()),
37-
other: Some("theirs".into()),
39+
current: Some(our_side_name.as_str().into()),
40+
other: Some(their_side_name.as_str().into()),
3841
},
3942
&mut graph,
4043
&mut diff_resource_cache,
4144
&mut blob_merge,
4245
&odb,
46+
|content| odb.write_buf(gix_object::Kind::Blob, content),
4347
options,
4448
)?;
4549

46-
match merge_info {
47-
Ok(expected_tree_id) => {
48-
let actual_id = actual.tree.write(|tree| odb.write(tree))?;
49-
assert_eq!(actual_id, expected_tree_id, "{case_name}: merged tree mismatch");
50-
}
51-
Err(_conflicts) => {
52-
todo!("compare conflicts")
53-
}
50+
let actual_id = actual.tree.write(|tree| odb.write(tree))?;
51+
assert_eq!(actual_id, merge_info.merged_tree, "{case_name}: merged tree mismatch");
52+
if let Some(conflicts) = merge_info.conflicts {
53+
dbg!(&conflicts, &merge_info.information);
54+
todo!("compare merge conflict information")
5455
}
5556
}
5657

5758
Ok(())
5859
}
5960

61+
// TODO: make sure everything is read eventually, even if only to improve debug messages in case of failure.
62+
#[allow(dead_code)]
6063
mod baseline {
64+
use gix_object::tree::EntryMode;
6165
use gix_worktree::stack::state::attributes;
6266
use std::path::{Path, PathBuf};
6367

64-
pub struct Conflict;
68+
/// An entry in the conflict
69+
#[derive(Debug)]
70+
pub struct Entry {
71+
/// The relative path in the repository
72+
pub location: String,
73+
/// The content id.
74+
pub id: gix_hash::ObjectId,
75+
/// The kind of entry.
76+
pub mode: EntryMode,
77+
}
78+
79+
/// Keep track of all the sides of a conflict. Some might not be set to indicate removal, including the ancestor.
80+
#[derive(Default, Debug)]
81+
pub struct Conflict {
82+
pub ancestor: Option<Entry>,
83+
pub ours: Option<Entry>,
84+
pub theirs: Option<Entry>,
85+
}
86+
87+
#[derive(Debug)]
88+
pub enum ConflictKind {
89+
/// The conflict was resolved by automatically merging the content.
90+
AutoMerging,
91+
/// The content could not be resolved so it's conflicting.
92+
ConflictContents,
93+
/// Directory in theirs in the way of our file
94+
ConflictDirectoryBlocksFile,
95+
/// Modified in ours but deleted in theirs
96+
ConflictModifyDelete,
97+
}
98+
99+
/// More loosely structured information about the `Conflict`.
100+
#[derive(Debug)]
101+
pub struct ConflictInfo {
102+
/// All the paths involved in the informational message
103+
pub paths: Vec<String>,
104+
/// The type of the conflict, further described in `message`.
105+
pub kind: ConflictKind,
106+
/// An arbitrary message formed from paths and kind
107+
pub message: String,
108+
}
109+
110+
impl Conflict {
111+
fn any_location(&self) -> Option<&str> {
112+
self.ancestor
113+
.as_ref()
114+
.or(self.ours.as_ref())
115+
.or(self.theirs.as_ref())
116+
.map(|a| a.location.as_str())
117+
}
118+
fn storage_for(&mut self, side: Side, location: &str) -> Option<&mut Option<Entry>> {
119+
let current_location = self.any_location();
120+
let location_is_same = current_location.is_none() || current_location == Some(location);
121+
let side = match side {
122+
Side::Ancestor => &mut self.ancestor,
123+
Side::Ours => &mut self.ours,
124+
Side::Theirs => &mut self.theirs,
125+
};
126+
(!side.is_some() && location_is_same).then_some(side)
127+
}
128+
}
129+
130+
pub struct MergeInfo {
131+
/// The hash of the merged tree - it may contain intermediate files if the merge didn't succeed entirely.
132+
pub merged_tree: gix_hash::ObjectId,
133+
/// If there were conflicts, this is the conflicting paths.
134+
pub conflicts: Option<Vec<Conflict>>,
135+
/// Structured details which to some extent can be compared to our own conflict information.
136+
pub information: Vec<ConflictInfo>,
137+
}
65138

66139
pub struct Expectation {
67140
pub root: PathBuf,
68141
pub odb: gix_odb::memory::Proxy<gix_odb::Handle>,
69142
pub our_commit_id: gix_hash::ObjectId,
143+
pub our_side_name: String,
70144
pub their_commit_id: gix_hash::ObjectId,
71-
pub merge_info: Result<gix_hash::ObjectId, Conflict>,
145+
pub their_side_name: String,
146+
pub merge_info: MergeInfo,
72147
pub case_name: String,
73148
}
74149

@@ -92,8 +167,21 @@ mod baseline {
92167
fn next(&mut self) -> Option<Self::Item> {
93168
let line = self.lines.next()?;
94169
let mut tokens = line.split(' ');
95-
let (Some(subdir), Some(our_commit_id), Some(their_commit_id), Some(merge_info_filename)) =
96-
(tokens.next(), tokens.next(), tokens.next(), tokens.next())
170+
let (
171+
Some(subdir),
172+
Some(our_commit_id),
173+
Some(our_side_name),
174+
Some(their_commit_id),
175+
Some(their_side_name),
176+
Some(merge_info_filename),
177+
) = (
178+
tokens.next(),
179+
tokens.next(),
180+
tokens.next(),
181+
tokens.next(),
182+
tokens.next(),
183+
tokens.next(),
184+
)
97185
else {
98186
unreachable!("invalid line: {line:?}")
99187
};
@@ -109,7 +197,9 @@ mod baseline {
109197
root: subdir_path,
110198
odb: objects,
111199
our_commit_id,
200+
our_side_name: our_side_name.to_owned(),
112201
their_commit_id,
202+
their_side_name: their_side_name.to_owned(),
113203
merge_info,
114204
case_name: format!(
115205
"{subdir}-{}",
@@ -122,11 +212,93 @@ mod baseline {
122212
}
123213
}
124214

125-
fn parse_merge_info(content: String) -> Result<gix_hash::ObjectId, Conflict> {
126-
let mut lines = content.split('\0').filter(|t| !t.is_empty());
215+
fn parse_merge_info(content: String) -> MergeInfo {
216+
let mut lines = content.split('\0').filter(|t| !t.is_empty()).peekable();
127217
let tree_id = gix_hash::ObjectId::from_hex(lines.next().unwrap().as_bytes()).unwrap();
128-
assert_eq!(lines.next(), None, "TODO: implement multi-line answer");
129-
Ok(tree_id)
218+
let mut out = MergeInfo {
219+
merged_tree: tree_id,
220+
conflicts: None,
221+
information: Vec::new(),
222+
};
223+
224+
let mut conflicts = Vec::new();
225+
let mut conflict = Conflict::default();
226+
while let Some(line) = lines.peek() {
227+
let (entry, side) = match parse_conflict_file_info(line) {
228+
Some(t) => t,
229+
None => break,
230+
};
231+
lines.next();
232+
let field = match conflict.storage_for(side, &entry.location) {
233+
None => {
234+
conflicts.push(conflict);
235+
conflict = Conflict::default();
236+
conflict
237+
.storage_for(side, &entry.location)
238+
.expect("always available for new side")
239+
}
240+
Some(field) => field,
241+
};
242+
*field = Some(entry);
243+
}
244+
245+
while lines.peek().is_some() {
246+
out.information
247+
.push(parse_info(&mut lines).expect("if there are lines, it should be valid info"));
248+
}
249+
assert_eq!(lines.next(), None, "TODO: conflict messages");
250+
out.conflicts = (!conflicts.is_empty()).then_some(conflicts);
251+
out
252+
}
253+
254+
#[derive(Copy, Clone)]
255+
enum Side {
256+
Ancestor,
257+
Ours,
258+
Theirs,
259+
}
260+
261+
fn parse_conflict_file_info(line: &str) -> Option<(Entry, Side)> {
262+
let (info, path) = line.split_at(line.find('\t')?);
263+
let mut tokens = info.split(' ');
264+
let (oct_mode, hex_id, stage) = (
265+
tokens.next().expect("mode"),
266+
tokens.next().expect("id"),
267+
tokens.next().expect("stage"),
268+
);
269+
assert_eq!(
270+
tokens.next(),
271+
None,
272+
"info line not understood, expected three fields only"
273+
);
274+
Some((
275+
Entry {
276+
location: path.to_owned(),
277+
id: gix_hash::ObjectId::from_hex(hex_id.as_bytes()).unwrap(),
278+
mode: EntryMode(gix_utils::btoi::to_signed_with_radix::<usize>(oct_mode.as_bytes(), 8).unwrap() as u16),
279+
},
280+
match stage {
281+
"1" => Side::Ancestor,
282+
"2" => Side::Ours,
283+
"3" => Side::Theirs,
284+
invalid => panic!("{invalid} is an unexpected side"),
285+
},
286+
))
287+
}
288+
289+
fn parse_info<'a>(mut lines: impl Iterator<Item = &'a str>) -> Option<ConflictInfo> {
290+
let num_paths: usize = lines.next()?.parse().ok()?;
291+
let paths: Vec<_> = lines.by_ref().take(num_paths).map(ToOwned::to_owned).collect();
292+
let kind = match lines.next()? {
293+
"Auto-merging" => ConflictKind::AutoMerging,
294+
"CONFLICT (contents)" => ConflictKind::ConflictContents,
295+
"CONFLICT (file/directory)" => ConflictKind::ConflictDirectoryBlocksFile,
296+
"CONFLICT (modify/delete)" => ConflictKind::ConflictModifyDelete,
297+
conflict_type => panic!("Unkonwn conflict type: {conflict_type}"),
298+
};
299+
let message = lines.next()?.to_owned();
300+
dbg!(&kind, &message);
301+
Some(ConflictInfo { paths, kind, message })
130302
}
131303

132304
pub fn new_platform(

0 commit comments

Comments
 (0)