1
1
mirror of https://github.com/Byron/gitoxide synced 2025-10-06 01:52:40 +02:00
Files
gitoxide/gix-merge/tests/merge/tree/baseline.rs
2025-04-05 09:51:01 +08:00

424 lines
14 KiB
Rust

use bstr::{BStr, ByteSlice};
use gix_hash::ObjectId;
use gix_merge::blob::builtin_driver::text::ConflictStyle;
use gix_object::tree::EntryMode;
use gix_object::FindExt;
use std::path::{Path, PathBuf};
/// An entry in the conflict
#[derive(Debug, Eq, PartialEq)]
pub struct Entry {
/// The relative path in the repository
pub location: String,
/// The content id.
pub id: gix_hash::ObjectId,
/// The kind of entry.
pub mode: EntryMode,
}
/// Keep track of all the sides of a conflict. Some might not be set to indicate removal, including the ancestor.
#[derive(Default, Debug, Eq, PartialEq)]
pub struct Conflict {
pub ancestor: Option<Entry>,
pub ours: Option<Entry>,
pub theirs: Option<Entry>,
}
#[derive(Debug)]
pub enum ConflictKind {
/// The conflict was resolved by automatically merging the content.
AutoMerging,
/// The content could not be resolved so it's conflicting.
ConflictContents,
/// Directory in theirs in the way of our file.
ConflictDirectoryBlocksFile,
/// Modified in ours but deleted in theirs.
ConflictModifyDelete,
/// Modified in ours but parent directory renamed in theirs.
DirectoryRenamedWithModificationInside,
/// Added files differ in mode.
DistinctModes,
/// The same file was renamed to different destinations.
RenameRename,
/// Deleted in ours with a new file added, renamed to new file in theirs with original content.
RenameAddDelete,
/// Two binary files were changed in different ways, which can never be merged (without a merge-driver)
Binary,
}
/// More loosely structured information about the `Conflict`.
#[derive(Debug)]
#[allow(dead_code)] // used only for debugging
pub struct ConflictInfo {
/// All the paths involved in the informational message
pub paths: Vec<String>,
/// The type of the conflict, further described in `message`.
pub kind: ConflictKind,
/// An arbitrary message formed from paths and kind
pub message: String,
}
impl Conflict {
fn any_location(&self) -> Option<&str> {
self.ancestor
.as_ref()
.or(self.ours.as_ref())
.or(self.theirs.as_ref())
.map(|a| a.location.as_str())
}
fn storage_for(&mut self, side: Side, location: &str) -> Option<&mut Option<Entry>> {
let current_location = self.any_location();
let location_is_same = current_location.is_none() || current_location == Some(location);
let side = match side {
Side::Ancestor => &mut self.ancestor,
Side::Ours => &mut self.ours,
Side::Theirs => &mut self.theirs,
};
(!side.is_some() && location_is_same).then_some(side)
}
}
pub struct MergeInfo {
/// The hash of the merged tree - it may contain intermediate files if the merge didn't succeed entirely.
pub merged_tree: gix_hash::ObjectId,
/// If there were conflicts, this is the conflicting paths.
pub conflicts: Option<Vec<Conflict>>,
/// Structured details which to some extent can be compared to our own conflict information.
pub information: Vec<ConflictInfo>,
}
pub struct Expectation {
pub root: PathBuf,
pub conflict_style: gix_merge::blob::builtin_driver::text::ConflictStyle,
pub odb: gix_odb::memory::Proxy<gix_odb::Handle>,
pub our_commit_id: gix_hash::ObjectId,
pub our_side_name: String,
pub their_commit_id: gix_hash::ObjectId,
pub their_side_name: String,
pub merge_info: MergeInfo,
pub case_name: String,
pub deviation: Option<Deviation>,
}
/// Git doesn't provide the same result.
pub struct Deviation {
/// Tells us the reason for expecting a difference compared to the Git result.
pub message: String,
/// The tree we wish to see, it's hand-crafted directly in the test as Git can't provide the baseline here.
pub expected_tree_id: gix_hash::ObjectId,
}
pub struct Expectations<'a> {
root: &'a Path,
lines: std::str::Lines<'a>,
}
impl<'a> Expectations<'a> {
pub fn new(root: &'a Path, cases: &'a str) -> Self {
Expectations {
root,
lines: cases.lines(),
}
}
}
impl Iterator for Expectations<'_> {
type Item = Expectation;
fn next(&mut self) -> Option<Self::Item> {
let line = self.lines.next()?;
let mut tokens = line.split(' ');
let (
Some(subdir),
Some(conflict_style_name),
Some(our_commit_id),
Some(our_side_name),
Some(their_commit_id),
Some(their_side_name),
Some(merge_info_filename),
Some(expected_custom_tree),
) = (
tokens.next(),
tokens.next(),
tokens.next(),
tokens.next(),
tokens.next(),
tokens.next(),
tokens.next(),
tokens.next(),
)
else {
unreachable!("invalid line: {line:?}")
};
let deviation = (!expected_custom_tree.starts_with("expected")).then(|| {
let expected_tree_id =
gix_hash::ObjectId::from_hex(expected_custom_tree.as_bytes()).unwrap_or_else(|err| {
panic!("valid tree id in hex for the expected tree: '{expected_custom_tree}': {err} {line}")
});
let message = tokens.collect::<Vec<_>>().join(" ").trim().to_owned();
Deviation {
message,
expected_tree_id,
}
});
let subdir_path = self.root.join(subdir);
let conflict_style = match conflict_style_name {
"merge" => ConflictStyle::Merge,
"diff3" => ConflictStyle::Diff3,
unknown => unreachable!("Unknown conflict style: '{unknown}'"),
};
let odb = gix_odb::at(subdir_path.join(".git/objects")).expect("object dir exists");
let objects = gix_odb::memory::Proxy::new(odb, gix_hash::Kind::Sha1);
let our_commit_id = gix_hash::ObjectId::from_hex(our_commit_id.as_bytes()).unwrap();
let their_commit_id = gix_hash::ObjectId::from_hex(their_commit_id.as_bytes()).unwrap();
let merge_info = parse_merge_info(std::fs::read_to_string(subdir_path.join(merge_info_filename)).unwrap());
Some(Expectation {
root: subdir_path,
conflict_style,
odb: objects,
our_commit_id,
our_side_name: our_side_name.to_owned(),
their_commit_id,
their_side_name: their_side_name.to_owned(),
merge_info,
case_name: format!(
"{subdir}-{}",
merge_info_filename
.split('.')
.next()
.expect("extension after single dot")
),
deviation,
})
}
}
fn parse_merge_info(content: String) -> MergeInfo {
let mut lines = content.split('\0').filter(|t| !t.is_empty()).peekable();
let tree_id = gix_hash::ObjectId::from_hex(lines.next().unwrap().as_bytes()).unwrap();
let mut out = MergeInfo {
merged_tree: tree_id,
conflicts: None,
information: Vec::new(),
};
let mut conflicts = Vec::new();
let mut conflict = Conflict::default();
while let Some(line) = lines.peek() {
let (entry, side) = match parse_conflict_file_info(line) {
Some(t) => t,
None => break,
};
lines.next();
let field = match conflict.storage_for(side, &entry.location) {
None => {
conflicts.push(conflict);
conflict = Conflict::default();
conflict
.storage_for(side, &entry.location)
.expect("always available for new side")
}
Some(field) => field,
};
*field = Some(entry);
}
if conflict.any_location().is_some() && conflicts.last() != Some(&conflict) {
conflicts.push(conflict);
}
while lines.peek().is_some() {
out.information
.push(parse_info(&mut lines).expect("if there are lines, it should be valid info"));
}
assert_eq!(lines.next(), None, "TODO: conflict messages");
out.conflicts = (!conflicts.is_empty()).then_some(conflicts);
out
}
#[derive(Copy, Clone)]
enum Side {
Ancestor,
Ours,
Theirs,
}
fn parse_conflict_file_info(line: &str) -> Option<(Entry, Side)> {
let (info, mut path) = line.split_at(line.find('\t')?);
path = &path[1..];
let mut tokens = info.split(' ');
let (oct_mode, hex_id, stage) = (
tokens.next().expect("mode"),
tokens.next().expect("id"),
tokens.next().expect("stage"),
);
assert_eq!(
tokens.next(),
None,
"info line not understood, expected three fields only"
);
Some((
Entry {
location: path.to_owned(),
id: gix_hash::ObjectId::from_hex(hex_id.as_bytes()).unwrap(),
mode: EntryMode::try_from(oct_mode.as_bytes()).unwrap(),
},
match stage {
"1" => Side::Ancestor,
"2" => Side::Ours,
"3" => Side::Theirs,
invalid => panic!("{invalid} is an unexpected side"),
},
))
}
fn parse_info<'a>(mut lines: impl Iterator<Item = &'a str>) -> Option<ConflictInfo> {
let num_paths: usize = lines.next()?.parse().ok()?;
let paths: Vec<_> = lines.by_ref().take(num_paths).map(ToOwned::to_owned).collect();
let kind = match lines.next()? {
"Auto-merging" => ConflictKind::AutoMerging,
"CONFLICT (contents)" => ConflictKind::ConflictContents,
"CONFLICT (file/directory)" => ConflictKind::ConflictDirectoryBlocksFile,
"CONFLICT (modify/delete)" => ConflictKind::ConflictModifyDelete,
"CONFLICT (directory rename suggested)" => ConflictKind::DirectoryRenamedWithModificationInside,
"CONFLICT (distinct modes)" => ConflictKind::DistinctModes,
"CONFLICT (rename/rename)" => ConflictKind::RenameRename,
"CONFLICT (rename/delete)" => ConflictKind::RenameAddDelete,
"CONFLICT (binary)" => ConflictKind::Binary,
conflict_type => panic!("Unkonwn conflict type: {conflict_type}"),
};
let message = lines.next()?.to_owned();
Some(ConflictInfo { paths, kind, message })
}
#[derive(Debug, PartialEq, Eq)]
pub struct DebugIndexEntry<'a> {
path: &'a BStr,
id: gix_hash::ObjectId,
mode: gix_index::entry::Mode,
stage: gix_index::entry::Stage,
}
pub fn clear_entries(state: &gix_index::State) -> Vec<DebugIndexEntry<'_>> {
state
.entries()
.iter()
.map(|entry| {
let path = entry.path(state);
DebugIndexEntry {
path,
id: entry.id,
mode: entry.mode,
stage: entry.stage(),
}
})
.collect()
}
pub fn visualize_tree(
id: &gix_hash::oid,
odb: &impl gix_object::Find,
name_and_mode: Option<(&BStr, EntryMode)>,
) -> termtree::Tree<String> {
fn short_id(id: &gix_hash::oid) -> String {
id.to_string()[..7].to_string()
}
let entry_name = |id: &gix_hash::oid, name: Option<(&BStr, EntryMode)>| -> String {
let mut buf = Vec::new();
match name {
None => short_id(id),
Some((name, mode)) => {
format!(
"{name}:{mode}{} {}",
short_id(id),
match odb.find_blob(id, &mut buf) {
Ok(blob) => format!("{:?}", blob.data.as_bstr()),
Err(_) => "".into(),
},
mode = if mode.is_tree() {
"".into()
} else {
format!("{mode:o}:")
}
)
}
}
};
let mut tree = termtree::Tree::new(entry_name(id, name_and_mode));
let mut buf = Vec::new();
for entry in odb.find_tree(id, &mut buf).unwrap().entries {
if entry.mode.is_tree() {
tree.push(visualize_tree(entry.oid, odb, Some((entry.filename, entry.mode))));
} else {
tree.push(entry_name(entry.oid, Some((entry.filename, entry.mode))));
}
}
tree
}
pub fn show_diff_and_fail(
case_name: &str,
actual_id: ObjectId,
actual: &gix_merge::tree::Outcome<'_>,
expected: &MergeInfo,
odb: &gix_odb::memory::Proxy<gix_odb::Handle>,
) {
pretty_assertions::assert_str_eq!(
visualize_tree(&actual_id, odb, None).to_string(),
visualize_tree(&expected.merged_tree, odb, None).to_string(),
"{case_name}: merged tree mismatch\n{:#?}\n{:#?}\n{case_name}",
actual.conflicts,
expected.information
);
}
pub fn show_diff_trees_and_fail(
case_name: &str,
actual_id: ObjectId,
actual: &gix_merge::tree::Outcome<'_>,
expected_tree_id: gix_hash::ObjectId,
additional_information: &str,
odb: &gix_odb::memory::Proxy<gix_odb::Handle>,
) {
pretty_assertions::assert_str_eq!(
visualize_tree(&actual_id, odb, None).to_string(),
visualize_tree(&expected_tree_id, odb, None).to_string(),
"{case_name}: merged tree mismatch\n{:#?}\n{additional_information}\n{case_name}",
actual.conflicts,
);
}
pub(crate) fn apply_git_index_entries(conflicts: &[Conflict], state: &mut gix_index::State) {
let len = state.entries().len();
for Conflict { ours, theirs, ancestor } in conflicts {
for (entry, stage) in [
ancestor.as_ref().map(|e| (e, gix_index::entry::Stage::Base)),
ours.as_ref().map(|e| (e, gix_index::entry::Stage::Ours)),
theirs.as_ref().map(|e| (e, gix_index::entry::Stage::Theirs)),
]
.into_iter()
.flatten()
{
if let Some(pos) = state.entry_index_by_path_and_stage_bounded(
entry.location.as_str().into(),
gix_index::entry::Stage::Unconflicted,
len,
) {
state.entries_mut()[pos].flags.insert(gix_index::entry::Flags::REMOVE);
}
state.dangerously_push_entry(
Default::default(),
entry.id,
stage.into(),
entry.mode.into(),
entry.location.as_str().into(),
);
}
}
state.sort_entries();
state.remove_entries(|_, _, e| e.flags.contains(gix_index::entry::Flags::REMOVE));
}