1use std::collections::{BTreeMap, HashMap};
24
25use crate::diff::merge_trees::{DirectoryRenameConflict, TreeMergeOutput};
26use crate::index::Index;
27
28pub use crate::state::{
29 operation_alternatives, operation_next_steps, operation_next_steps_empty, status_operation,
30 InProgressOperation, NextStep, Operation,
31};
32
33#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
39#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
40#[cfg_attr(
41 feature = "serde-models",
42 serde(tag = "kind", rename_all = "snake_case")
43)]
44pub enum ConflictKind {
45 BothModified,
47 BothAdded,
49 BothDeleted,
51 AddedByUs,
53 AddedByThem,
55 DeletedByUs,
57 DeletedByThem,
59 RenameRename(Box<RenamedPaths>),
66 DirectoryRenameMoved(Box<MovedPath>),
73 DirectoryRenameSplit,
77 DirectoryRenameBlocked(Box<BlockedMove>),
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
84#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
85pub struct MovedPath {
86 pub original_path: String,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
92#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
93pub struct BlockedMove {
94 pub sources: Vec<String>,
96 pub existing: bool,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
103#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
104pub struct RenamedPaths {
105 pub base_path: String,
107 pub ours_path: String,
109 pub theirs_path: String,
111}
112
113impl ConflictKind {
114 #[must_use]
120 pub fn as_str(&self) -> &'static str {
121 match self {
122 Self::BothModified => "both_modified",
123 Self::BothAdded => "both_added",
124 Self::BothDeleted => "both_deleted",
125 Self::AddedByUs => "added_by_us",
126 Self::AddedByThem => "added_by_them",
127 Self::DeletedByUs => "deleted_by_us",
128 Self::DeletedByThem => "deleted_by_them",
129 Self::RenameRename(_) => "rename_rename",
130 Self::DirectoryRenameMoved(_) => "directory_rename_moved",
131 Self::DirectoryRenameSplit => "directory_rename_split",
132 Self::DirectoryRenameBlocked(_) => "directory_rename_blocked",
133 }
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Hash)]
140#[cfg_attr(feature = "serde-models", derive(serde::Serialize))]
141pub struct Conflict {
142 pub path: String,
144 #[cfg_attr(feature = "serde-models", serde(flatten))]
146 pub kind: ConflictKind,
147}
148
149#[must_use]
159pub fn conflict_kind_from_stagemask(stagemask: u8) -> Option<ConflictKind> {
160 Some(match stagemask {
161 0b001 => ConflictKind::BothDeleted,
162 0b010 => ConflictKind::AddedByUs,
163 0b011 => ConflictKind::DeletedByThem,
164 0b100 => ConflictKind::AddedByThem,
165 0b101 => ConflictKind::DeletedByUs,
166 0b110 => ConflictKind::BothAdded,
167 0b111 => ConflictKind::BothModified,
168 _ => return None,
169 })
170}
171
172#[must_use]
179pub fn conflicts_from_index(index: &Index) -> Vec<Conflict> {
180 let mut masks: BTreeMap<&[u8], u8> = BTreeMap::new();
181 for entry in index.entries() {
182 let stage = entry.stage();
183 if (1..=3).contains(&stage) {
184 *masks.entry(entry.path.as_slice()).or_default() |= 1 << (stage - 1);
185 }
186 }
187 masks
188 .into_iter()
189 .filter_map(|(path, mask)| {
190 Some(Conflict {
191 path: crate::repo_relpath::storage_string(path),
192 kind: conflict_kind_from_stagemask(mask)?,
193 })
194 })
195 .collect()
196}
197
198#[must_use]
208pub fn conflicts_from_tree_merge(merged: &TreeMergeOutput) -> Vec<Conflict> {
209 use crate::repo_relpath::storage_string;
210
211 let mut known_kinds: HashMap<&[u8], ConflictKind> = HashMap::new();
212 let mut conflicts: Vec<(&[u8], ConflictKind)> = Vec::new();
213 for conflict in &merged.directory_rename_conflicts {
214 let path = conflict.path();
215 match conflict {
216 DirectoryRenameConflict::Moved { from, .. } => {
217 known_kinds.insert(
218 path,
219 ConflictKind::DirectoryRenameMoved(Box::new(MovedPath {
220 original_path: storage_string(from),
221 })),
222 );
223 }
224 DirectoryRenameConflict::Split { .. } => {
225 conflicts.push((path, ConflictKind::DirectoryRenameSplit));
226 }
227 DirectoryRenameConflict::Blocked {
228 sources, existing, ..
229 } => conflicts.push((
230 path,
231 ConflictKind::DirectoryRenameBlocked(Box::new(BlockedMove {
232 sources: sources.iter().map(|s| storage_string(s)).collect(),
233 existing: *existing,
234 })),
235 )),
236 }
237 }
238 for rr in &merged.rename_conflicts {
239 let kind = ConflictKind::RenameRename(Box::new(RenamedPaths {
240 base_path: storage_string(&rr.base_path),
241 ours_path: storage_string(&rr.ours_path),
242 theirs_path: storage_string(&rr.theirs_path),
243 }));
244 for path in [&rr.base_path, &rr.ours_path, &rr.theirs_path] {
245 known_kinds.insert(path.as_slice(), kind.clone());
246 }
247 }
248 for path in tree_merge_conflict_paths(merged) {
249 let kind = known_kinds.get(path).cloned().unwrap_or_else(|| {
250 let mask = (1..=3u8)
251 .filter(|&stage| merged.index.get(path, stage).is_some())
252 .fold(0u8, |mask, stage| mask | 1 << (stage - 1));
253 conflict_kind_from_stagemask(mask).unwrap_or(ConflictKind::BothModified)
254 });
255 conflicts.push((path, kind));
256 }
257 conflicts.sort_by(|a, b| a.0.cmp(b.0));
258 conflicts
259 .into_iter()
260 .map(|(path, kind)| Conflict {
261 path: storage_string(path),
262 kind,
263 })
264 .collect()
265}
266
267#[must_use]
270pub fn tree_merge_has_conflicts(merged: &TreeMergeOutput) -> bool {
271 !merged.conflict_content.is_empty()
272 || !merged.directory_rename_conflicts.is_empty()
273 || merged.index.entries().iter().any(|e| e.stage() != 0)
274}
275
276#[must_use]
278pub fn next_step_grit_commands(steps: &[NextStep]) -> Vec<String> {
279 steps.iter().filter_map(NextStep::grit_command).collect()
280}
281
282fn tree_merge_conflict_paths(merged: &TreeMergeOutput) -> Vec<&[u8]> {
284 let mut paths: Vec<&[u8]> = merged
285 .conflict_content
286 .keys()
287 .map(Vec::as_slice)
288 .chain(
289 merged
290 .index
291 .entries()
292 .iter()
293 .filter(|e| e.stage() != 0)
294 .map(|e| e.path.as_slice()),
295 )
296 .collect();
297 paths.sort_unstable();
298 paths.dedup();
299 paths
300}
301
302#[cfg(test)]
303mod tests {
304 #![allow(clippy::unwrap_used)]
305
306 use super::*;
307 use crate::index::{Index, IndexEntry};
308 use crate::merge_trees::TreeMergeOutput;
309 use std::collections::{BTreeMap, HashSet};
310
311 fn index_entry(path: &str, stage: u8) -> IndexEntry {
312 let mut entry = IndexEntry {
313 ctime_sec: 0,
314 ctime_nsec: 0,
315 mtime_sec: 0,
316 mtime_nsec: 0,
317 dev: 0,
318 ino: 0,
319 mode: crate::index::MODE_REGULAR,
320 uid: 0,
321 gid: 0,
322 size: 0,
323 oid: crate::diff::zero_oid(),
324 flags: 0,
325 flags_extended: None,
326 path: path.as_bytes().to_vec(),
327 base_index_pos: 0,
328 };
329 entry.set_stage(stage);
330 entry
331 }
332
333 #[test]
334 fn conflict_kind_from_stagemask_covers_every_unmerged_combination() {
335 let cases = [
336 (0b001, ConflictKind::BothDeleted),
337 (0b010, ConflictKind::AddedByUs),
338 (0b011, ConflictKind::DeletedByThem),
339 (0b100, ConflictKind::AddedByThem),
340 (0b101, ConflictKind::DeletedByUs),
341 (0b110, ConflictKind::BothAdded),
342 (0b111, ConflictKind::BothModified),
343 ];
344 for (mask, kind) in cases {
345 assert_eq!(conflict_kind_from_stagemask(mask), Some(kind), "{mask:#b}");
346 }
347 }
348
349 #[test]
350 fn conflict_kind_from_stagemask_rejects_masks_without_unmerged_stages() {
351 assert_eq!(conflict_kind_from_stagemask(0), None);
352 assert_eq!(conflict_kind_from_stagemask(0b1000), None);
353 assert_eq!(conflict_kind_from_stagemask(0xff), None);
354 }
355
356 #[test]
357 fn conflict_kind_as_str_is_stable_snake_case() {
358 let cases = [
359 (ConflictKind::BothModified, "both_modified"),
360 (ConflictKind::BothAdded, "both_added"),
361 (ConflictKind::BothDeleted, "both_deleted"),
362 (ConflictKind::AddedByUs, "added_by_us"),
363 (ConflictKind::AddedByThem, "added_by_them"),
364 (ConflictKind::DeletedByUs, "deleted_by_us"),
365 (ConflictKind::DeletedByThem, "deleted_by_them"),
366 (
367 ConflictKind::RenameRename(Box::new(RenamedPaths {
368 base_path: "a".into(),
369 ours_path: "b".into(),
370 theirs_path: "c".into(),
371 })),
372 "rename_rename",
373 ),
374 ];
375 for (kind, name) in cases {
376 assert_eq!(kind.as_str(), name);
377 }
378 }
379
380 #[test]
381 fn conflict_kind_stays_two_words() {
382 assert!(std::mem::size_of::<ConflictKind>() <= 2 * std::mem::size_of::<usize>());
383 }
384
385 #[test]
386 fn conflicts_from_index_groups_stages_per_path_in_byte_order() {
387 let mut index = Index::new();
388 index.add_or_replace(index_entry("z", 1));
389 index.add_or_replace(index_entry("z", 3));
390 index.add_or_replace(index_entry("clean", 0));
391 index.add_or_replace(index_entry("a", 2));
392 index.add_or_replace(index_entry("a", 3));
393 let conflicts = conflicts_from_index(&index);
394 assert_eq!(
395 conflicts,
396 vec![
397 Conflict {
398 path: "a".into(),
399 kind: ConflictKind::BothAdded,
400 },
401 Conflict {
402 path: "z".into(),
403 kind: ConflictKind::DeletedByUs,
404 },
405 ]
406 );
407 }
408
409 #[test]
410 fn conflicts_from_index_non_utf8_path_uses_storage_string() {
411 let mut index = Index::new();
412 for stage in 1..=3 {
413 let mut entry = index_entry("", stage);
414 entry.path = vec![0xff, b'a'];
415 index.add_or_replace(entry);
416 }
417 let conflicts = conflicts_from_index(&index);
418 assert_eq!(conflicts.len(), 1);
419 assert_eq!(conflicts[0].kind, ConflictKind::BothModified);
420 assert_eq!(
421 crate::repo_relpath::storage_bytes(&conflicts[0].path).as_ref(),
422 [0xff, b'a']
423 );
424 }
425
426 #[test]
427 fn conflicts_from_tree_merge_reports_rename_rename_on_all_three_paths() {
428 let mut index = Index::new();
429 index.add_or_replace(index_entry("a", 1));
430 index.add_or_replace(index_entry("b", 2));
431 index.add_or_replace(index_entry("c", 3));
432 index.add_or_replace(index_entry("d", 1));
433 index.add_or_replace(index_entry("d", 2));
434 let merged = TreeMergeOutput {
435 index,
436 conflict_content: BTreeMap::new(),
437 result_tree: crate::diff::zero_oid(),
438 touched_paths: HashSet::new(),
439 rename_conflicts: vec![crate::merge_trees::RenameRenameConflict {
440 base_path: b"a".to_vec(),
441 ours_path: b"b".to_vec(),
442 theirs_path: b"c".to_vec(),
443 }],
444 directory_rename_conflicts: Vec::new(),
445 };
446 let kind = ConflictKind::RenameRename(Box::new(RenamedPaths {
447 base_path: "a".into(),
448 ours_path: "b".into(),
449 theirs_path: "c".into(),
450 }));
451 let conflicts = conflicts_from_tree_merge(&merged);
452 let got: Vec<(&str, &ConflictKind)> = conflicts
453 .iter()
454 .map(|c| (c.path.as_str(), &c.kind))
455 .collect();
456 assert_eq!(
457 got,
458 vec![
459 ("a", &kind),
460 ("b", &kind),
461 ("c", &kind),
462 ("d", &ConflictKind::DeletedByThem),
463 ]
464 );
465 }
466
467 #[test]
468 fn conflicts_from_tree_merge_keeps_non_utf8_directory_rename_paths() {
469 use crate::merge_trees::{DirectoryRenameConflict, MergeSide};
470 use crate::repo_relpath::storage_string;
471
472 let from = b"a/n\xff".to_vec();
473 let to = b"b/n\xff".to_vec();
474 let mut row = index_entry("x", 3);
475 row.path = to.clone();
476 let mut index = Index::new();
477 index.add_or_replace(row);
478 let merged = TreeMergeOutput {
479 index,
480 conflict_content: BTreeMap::new(),
481 result_tree: crate::diff::zero_oid(),
482 touched_paths: HashSet::new(),
483 rename_conflicts: Vec::new(),
484 directory_rename_conflicts: vec![
485 DirectoryRenameConflict::Moved {
486 from: from.clone(),
487 to: to.clone(),
488 added_by: MergeSide::Theirs,
489 },
490 DirectoryRenameConflict::Split {
491 dir: b"c\xfe".to_vec(),
492 renamed_by: MergeSide::Ours,
493 },
494 ],
495 };
496 assert_eq!(
497 conflicts_from_tree_merge(&merged),
498 vec![
499 Conflict {
500 path: storage_string(&to),
501 kind: ConflictKind::DirectoryRenameMoved(Box::new(MovedPath {
502 original_path: storage_string(&from),
503 })),
504 },
505 Conflict {
506 path: storage_string(b"c\xfe"),
507 kind: ConflictKind::DirectoryRenameSplit,
508 },
509 ]
510 );
511 }
512
513 #[test]
514 fn conflicts_from_tree_merge_unions_markers_and_unmerged_index() {
515 let mut index = Index::new();
516 index.add_or_replace(index_entry("b.txt", 1));
517 index.add_or_replace(index_entry("b.txt", 2));
518 let mut conflict_content = BTreeMap::new();
519 conflict_content.insert(b"a.txt".to_vec(), crate::diff::zero_oid());
520 let merged = TreeMergeOutput {
521 index,
522 conflict_content,
523 result_tree: crate::diff::zero_oid(),
524 touched_paths: HashSet::new(),
525 rename_conflicts: Vec::new(),
526 directory_rename_conflicts: Vec::new(),
527 };
528 let conflicts = conflicts_from_tree_merge(&merged);
529 assert_eq!(conflicts.len(), 2);
530 assert_eq!(conflicts[0].path, "a.txt");
531 assert_eq!(conflicts[0].kind, ConflictKind::BothModified);
532 assert_eq!(conflicts[1].path, "b.txt");
533 assert_eq!(conflicts[1].kind, ConflictKind::DeletedByThem);
534 }
535
536 #[test]
537 fn next_step_grit_command_for_each_operation() {
538 use crate::state::InProgressOperation;
539
540 let cases: &[(NextStep, &str)] = &[
541 (
542 NextStep::Abort(InProgressOperation::Merge),
543 "grit merge --abort",
544 ),
545 (
546 NextStep::Continue(InProgressOperation::Merge),
547 "grit merge --continue",
548 ),
549 (
550 NextStep::Abort(InProgressOperation::CherryPick),
551 "grit pick --abort",
552 ),
553 (
554 NextStep::Continue(InProgressOperation::CherryPick),
555 "grit pick --continue",
556 ),
557 (
558 NextStep::Skip(InProgressOperation::CherryPick),
559 "grit pick --skip",
560 ),
561 (
562 NextStep::Abort(InProgressOperation::Revert),
563 "grit revert --abort",
564 ),
565 (
566 NextStep::Continue(InProgressOperation::Revert),
567 "grit revert --continue",
568 ),
569 (
570 NextStep::Skip(InProgressOperation::Revert),
571 "grit revert --skip",
572 ),
573 ];
574 for (step, expected) in cases {
575 assert_eq!(step.grit_command().as_deref(), Some(*expected), "{step:?}");
576 assert_eq!(step.command().as_deref(), Some(*expected), "{step:?}");
577 assert_eq!(step.git_command(), None, "{step:?}");
578 }
579 }
580
581 #[test]
582 fn continue_with_git_renders_only_a_git_command() {
583 use crate::state::InProgressOperation;
584
585 let cases: &[(InProgressOperation, Option<&str>)] = &[
586 (InProgressOperation::Merge, Some("git merge --continue")),
587 (InProgressOperation::Rebase, Some("git rebase --continue")),
588 (
589 InProgressOperation::RebaseInteractive,
590 Some("git rebase --continue"),
591 ),
592 (
593 InProgressOperation::CherryPick,
594 Some("git cherry-pick --continue"),
595 ),
596 (InProgressOperation::Revert, Some("git revert --continue")),
597 (InProgressOperation::Am, Some("git am --continue")),
598 (InProgressOperation::Bisect, None),
599 ];
600 for (kind, expected) in cases {
601 let step = NextStep::ContinueWithGit(*kind);
602 assert_eq!(step.grit_command(), None, "{step:?}");
603 assert_eq!(step.git_command().as_deref(), *expected, "{step:?}");
604 assert_eq!(step.command().as_deref(), *expected, "{step:?}");
605 }
606 assert_eq!(
607 NextStep::Abort(InProgressOperation::RebaseInteractive)
608 .command()
609 .as_deref(),
610 Some("grit rebase --abort")
611 );
612 }
613}