mirror of
https://github.com/Byron/gitoxide
synced 2025-10-06 01:52:40 +02:00
424 lines
14 KiB
Rust
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));
|
|
}
|