use std::collections::{BTreeMap, HashMap};
use crate::diff::merge_trees::{DirectoryRenameConflict, TreeMergeOutput};
use crate::index::Index;
pub use crate::state::{
operation_alternatives, operation_next_steps, operation_next_steps_empty, status_operation,
InProgressOperation, NextStep, Operation,
};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
#[cfg_attr(
feature = "serde-models",
serde(tag = "kind", rename_all = "snake_case")
)]
pub enum ConflictKind {
BothModified,
BothAdded,
BothDeleted,
AddedByUs,
AddedByThem,
DeletedByUs,
DeletedByThem,
RenameRename(Box<RenamedPaths>),
DirectoryRenameMoved(Box<MovedPath>),
DirectoryRenameSplit,
DirectoryRenameBlocked(Box<BlockedMove>),
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
pub struct MovedPath {
pub original_path: String,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
pub struct BlockedMove {
pub sources: Vec<String>,
pub existing: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
pub struct RenamedPaths {
pub base_path: String,
pub ours_path: String,
pub theirs_path: String,
}
impl ConflictKind {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::BothModified => "both_modified",
Self::BothAdded => "both_added",
Self::BothDeleted => "both_deleted",
Self::AddedByUs => "added_by_us",
Self::AddedByThem => "added_by_them",
Self::DeletedByUs => "deleted_by_us",
Self::DeletedByThem => "deleted_by_them",
Self::RenameRename(_) => "rename_rename",
Self::DirectoryRenameMoved(_) => "directory_rename_moved",
Self::DirectoryRenameSplit => "directory_rename_split",
Self::DirectoryRenameBlocked(_) => "directory_rename_blocked",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
pub struct Conflict {
pub path: String,
#[cfg_attr(feature = "serde-models", serde(flatten))]
pub kind: ConflictKind,
}
#[must_use]
pub fn conflict_kind_from_stagemask(stagemask: u8) -> Option<ConflictKind> {
Some(match stagemask {
0b001 => ConflictKind::BothDeleted,
0b010 => ConflictKind::AddedByUs,
0b011 => ConflictKind::DeletedByThem,
0b100 => ConflictKind::AddedByThem,
0b101 => ConflictKind::DeletedByUs,
0b110 => ConflictKind::BothAdded,
0b111 => ConflictKind::BothModified,
_ => return None,
})
}
#[must_use]
pub fn conflicts_from_index(index: &Index) -> Vec<Conflict> {
let mut masks: BTreeMap<&[u8], u8> = BTreeMap::new();
for entry in index.entries() {
let stage = entry.stage();
if (1..=3).contains(&stage) {
*masks.entry(entry.path.as_slice()).or_default() |= 1 << (stage - 1);
}
}
masks
.into_iter()
.filter_map(|(path, mask)| {
Some(Conflict {
path: crate::repo_relpath::storage_string(path),
kind: conflict_kind_from_stagemask(mask)?,
})
})
.collect()
}
#[must_use]
pub fn conflicts_from_tree_merge(merged: &TreeMergeOutput) -> Vec<Conflict> {
use crate::repo_relpath::storage_string;
let mut known_kinds: HashMap<&[u8], ConflictKind> = HashMap::new();
let mut conflicts: Vec<(&[u8], ConflictKind)> = Vec::new();
for conflict in &merged.directory_rename_conflicts {
let path = conflict.path();
match conflict {
DirectoryRenameConflict::Moved { from, .. } => {
known_kinds.insert(
path,
ConflictKind::DirectoryRenameMoved(Box::new(MovedPath {
original_path: storage_string(from),
})),
);
}
DirectoryRenameConflict::Split { .. } => {
conflicts.push((path, ConflictKind::DirectoryRenameSplit));
}
DirectoryRenameConflict::Blocked {
sources, existing, ..
} => conflicts.push((
path,
ConflictKind::DirectoryRenameBlocked(Box::new(BlockedMove {
sources: sources.iter().map(|s| storage_string(s)).collect(),
existing: *existing,
})),
)),
}
}
for rr in &merged.rename_conflicts {
let kind = ConflictKind::RenameRename(Box::new(RenamedPaths {
base_path: storage_string(&rr.base_path),
ours_path: storage_string(&rr.ours_path),
theirs_path: storage_string(&rr.theirs_path),
}));
for path in [&rr.base_path, &rr.ours_path, &rr.theirs_path] {
known_kinds.insert(path.as_slice(), kind.clone());
}
}
for path in tree_merge_conflict_paths(merged) {
let kind = known_kinds.get(path).cloned().unwrap_or_else(|| {
let mask = (1..=3u8)
.filter(|&stage| merged.index.get(path, stage).is_some())
.fold(0u8, |mask, stage| mask | 1 << (stage - 1));
conflict_kind_from_stagemask(mask).unwrap_or(ConflictKind::BothModified)
});
conflicts.push((path, kind));
}
conflicts.sort_by(|a, b| a.0.cmp(b.0));
conflicts
.into_iter()
.map(|(path, kind)| Conflict {
path: storage_string(path),
kind,
})
.collect()
}
#[must_use]
pub fn tree_merge_has_conflicts(merged: &TreeMergeOutput) -> bool {
!merged.conflict_content.is_empty()
|| !merged.directory_rename_conflicts.is_empty()
|| merged.index.entries().iter().any(|e| e.stage() != 0)
}
#[must_use]
pub fn next_step_grit_commands(steps: &[NextStep]) -> Vec<String> {
steps.iter().filter_map(NextStep::grit_command).collect()
}
fn tree_merge_conflict_paths(merged: &TreeMergeOutput) -> Vec<&[u8]> {
let mut paths: Vec<&[u8]> = merged
.conflict_content
.keys()
.map(Vec::as_slice)
.chain(
merged
.index
.entries()
.iter()
.filter(|e| e.stage() != 0)
.map(|e| e.path.as_slice()),
)
.collect();
paths.sort_unstable();
paths.dedup();
paths
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
use crate::index::{Index, IndexEntry};
use crate::merge_trees::TreeMergeOutput;
use std::collections::{BTreeMap, HashSet};
fn index_entry(path: &str, stage: u8) -> IndexEntry {
let mut entry = IndexEntry {
ctime_sec: 0,
ctime_nsec: 0,
mtime_sec: 0,
mtime_nsec: 0,
dev: 0,
ino: 0,
mode: crate::index::MODE_REGULAR,
uid: 0,
gid: 0,
size: 0,
oid: crate::diff::zero_oid(),
flags: 0,
flags_extended: None,
path: path.as_bytes().to_vec(),
base_index_pos: 0,
};
entry.set_stage(stage);
entry
}
#[test]
fn conflict_kind_from_stagemask_covers_every_unmerged_combination() {
let cases = [
(0b001, ConflictKind::BothDeleted),
(0b010, ConflictKind::AddedByUs),
(0b011, ConflictKind::DeletedByThem),
(0b100, ConflictKind::AddedByThem),
(0b101, ConflictKind::DeletedByUs),
(0b110, ConflictKind::BothAdded),
(0b111, ConflictKind::BothModified),
];
for (mask, kind) in cases {
assert_eq!(conflict_kind_from_stagemask(mask), Some(kind), "{mask:#b}");
}
}
#[test]
fn conflict_kind_from_stagemask_rejects_masks_without_unmerged_stages() {
assert_eq!(conflict_kind_from_stagemask(0), None);
assert_eq!(conflict_kind_from_stagemask(0b1000), None);
assert_eq!(conflict_kind_from_stagemask(0xff), None);
}
#[test]
fn conflict_kind_as_str_is_stable_snake_case() {
let cases = [
(ConflictKind::BothModified, "both_modified"),
(ConflictKind::BothAdded, "both_added"),
(ConflictKind::BothDeleted, "both_deleted"),
(ConflictKind::AddedByUs, "added_by_us"),
(ConflictKind::AddedByThem, "added_by_them"),
(ConflictKind::DeletedByUs, "deleted_by_us"),
(ConflictKind::DeletedByThem, "deleted_by_them"),
(
ConflictKind::RenameRename(Box::new(RenamedPaths {
base_path: "a".into(),
ours_path: "b".into(),
theirs_path: "c".into(),
})),
"rename_rename",
),
];
for (kind, name) in cases {
assert_eq!(kind.as_str(), name);
}
}
#[test]
fn conflict_kind_stays_two_words() {
assert!(std::mem::size_of::<ConflictKind>() <= 2 * std::mem::size_of::<usize>());
}
#[test]
fn conflicts_from_index_groups_stages_per_path_in_byte_order() {
let mut index = Index::new();
index.add_or_replace(index_entry("z", 1));
index.add_or_replace(index_entry("z", 3));
index.add_or_replace(index_entry("clean", 0));
index.add_or_replace(index_entry("a", 2));
index.add_or_replace(index_entry("a", 3));
let conflicts = conflicts_from_index(&index);
assert_eq!(
conflicts,
vec![
Conflict {
path: "a".into(),
kind: ConflictKind::BothAdded,
},
Conflict {
path: "z".into(),
kind: ConflictKind::DeletedByUs,
},
]
);
}
#[test]
fn conflicts_from_index_non_utf8_path_uses_storage_string() {
let mut index = Index::new();
for stage in 1..=3 {
let mut entry = index_entry("", stage);
entry.path = vec![0xff, b'a'];
index.add_or_replace(entry);
}
let conflicts = conflicts_from_index(&index);
assert_eq!(conflicts.len(), 1);
assert_eq!(conflicts[0].kind, ConflictKind::BothModified);
assert_eq!(
crate::repo_relpath::storage_bytes(&conflicts[0].path).as_ref(),
[0xff, b'a']
);
}
#[test]
fn conflicts_from_tree_merge_reports_rename_rename_on_all_three_paths() {
let mut index = Index::new();
index.add_or_replace(index_entry("a", 1));
index.add_or_replace(index_entry("b", 2));
index.add_or_replace(index_entry("c", 3));
index.add_or_replace(index_entry("d", 1));
index.add_or_replace(index_entry("d", 2));
let merged = TreeMergeOutput {
index,
conflict_content: BTreeMap::new(),
result_tree: crate::diff::zero_oid(),
touched_paths: HashSet::new(),
rename_conflicts: vec![crate::merge_trees::RenameRenameConflict {
base_path: b"a".to_vec(),
ours_path: b"b".to_vec(),
theirs_path: b"c".to_vec(),
}],
directory_rename_conflicts: Vec::new(),
};
let kind = ConflictKind::RenameRename(Box::new(RenamedPaths {
base_path: "a".into(),
ours_path: "b".into(),
theirs_path: "c".into(),
}));
let conflicts = conflicts_from_tree_merge(&merged);
let got: Vec<(&str, &ConflictKind)> = conflicts
.iter()
.map(|c| (c.path.as_str(), &c.kind))
.collect();
assert_eq!(
got,
vec![
("a", &kind),
("b", &kind),
("c", &kind),
("d", &ConflictKind::DeletedByThem),
]
);
}
#[test]
fn conflicts_from_tree_merge_keeps_non_utf8_directory_rename_paths() {
use crate::merge_trees::{DirectoryRenameConflict, MergeSide};
use crate::repo_relpath::storage_string;
let from = b"a/n\xff".to_vec();
let to = b"b/n\xff".to_vec();
let mut row = index_entry("x", 3);
row.path = to.clone();
let mut index = Index::new();
index.add_or_replace(row);
let merged = TreeMergeOutput {
index,
conflict_content: BTreeMap::new(),
result_tree: crate::diff::zero_oid(),
touched_paths: HashSet::new(),
rename_conflicts: Vec::new(),
directory_rename_conflicts: vec![
DirectoryRenameConflict::Moved {
from: from.clone(),
to: to.clone(),
added_by: MergeSide::Theirs,
},
DirectoryRenameConflict::Split {
dir: b"c\xfe".to_vec(),
renamed_by: MergeSide::Ours,
},
],
};
assert_eq!(
conflicts_from_tree_merge(&merged),
vec![
Conflict {
path: storage_string(&to),
kind: ConflictKind::DirectoryRenameMoved(Box::new(MovedPath {
original_path: storage_string(&from),
})),
},
Conflict {
path: storage_string(b"c\xfe"),
kind: ConflictKind::DirectoryRenameSplit,
},
]
);
}
#[test]
fn conflicts_from_tree_merge_unions_markers_and_unmerged_index() {
let mut index = Index::new();
index.add_or_replace(index_entry("b.txt", 1));
index.add_or_replace(index_entry("b.txt", 2));
let mut conflict_content = BTreeMap::new();
conflict_content.insert(b"a.txt".to_vec(), crate::diff::zero_oid());
let merged = TreeMergeOutput {
index,
conflict_content,
result_tree: crate::diff::zero_oid(),
touched_paths: HashSet::new(),
rename_conflicts: Vec::new(),
directory_rename_conflicts: Vec::new(),
};
let conflicts = conflicts_from_tree_merge(&merged);
assert_eq!(conflicts.len(), 2);
assert_eq!(conflicts[0].path, "a.txt");
assert_eq!(conflicts[0].kind, ConflictKind::BothModified);
assert_eq!(conflicts[1].path, "b.txt");
assert_eq!(conflicts[1].kind, ConflictKind::DeletedByThem);
}
#[test]
fn next_step_grit_command_for_each_operation() {
use crate::state::InProgressOperation;
let cases: &[(NextStep, &str)] = &[
(
NextStep::Abort(InProgressOperation::Merge),
"grit merge --abort",
),
(
NextStep::Continue(InProgressOperation::Merge),
"grit merge --continue",
),
(
NextStep::Abort(InProgressOperation::CherryPick),
"grit pick --abort",
),
(
NextStep::Continue(InProgressOperation::CherryPick),
"grit pick --continue",
),
(
NextStep::Skip(InProgressOperation::CherryPick),
"grit pick --skip",
),
(
NextStep::Abort(InProgressOperation::Revert),
"grit revert --abort",
),
(
NextStep::Continue(InProgressOperation::Revert),
"grit revert --continue",
),
(
NextStep::Skip(InProgressOperation::Revert),
"grit revert --skip",
),
];
for (step, expected) in cases {
assert_eq!(step.grit_command().as_deref(), Some(*expected), "{step:?}");
assert_eq!(step.command().as_deref(), Some(*expected), "{step:?}");
assert_eq!(step.git_command(), None, "{step:?}");
}
}
#[test]
fn continue_with_git_renders_only_a_git_command() {
use crate::state::InProgressOperation;
let cases: &[(InProgressOperation, Option<&str>)] = &[
(InProgressOperation::Merge, Some("git merge --continue")),
(InProgressOperation::Rebase, Some("git rebase --continue")),
(
InProgressOperation::RebaseInteractive,
Some("git rebase --continue"),
),
(
InProgressOperation::CherryPick,
Some("git cherry-pick --continue"),
),
(InProgressOperation::Revert, Some("git revert --continue")),
(InProgressOperation::Am, Some("git am --continue")),
(InProgressOperation::Bisect, None),
];
for (kind, expected) in cases {
let step = NextStep::ContinueWithGit(*kind);
assert_eq!(step.grit_command(), None, "{step:?}");
assert_eq!(step.git_command().as_deref(), *expected, "{step:?}");
assert_eq!(step.command().as_deref(), *expected, "{step:?}");
}
assert_eq!(
NextStep::Abort(InProgressOperation::RebaseInteractive)
.command()
.as_deref(),
Some("grit rebase --abort")
);
}
}