1#![allow(clippy::missing_errors_doc)]
30
31use std::collections::BTreeMap;
32#[cfg(not(kani))]
33use std::fs;
34use std::path::{Path, PathBuf};
35#[cfg(not(kani))]
36use std::process::Command;
37
38#[cfg(not(kani))]
39use tempfile::Builder;
40
41use crate::model::conflict::{
42 AtomEdit, ConflictAtom, ConflictReason as ModelConflictReason, Region,
43};
44use crate::model::types::WorkspaceId;
45
46#[cfg(feature = "ast-merge")]
47use super::ast_merge::{AstMergeConfig, AstMergeResult, try_ast_merge_with_config};
48
49use super::build::ResolvedChange;
50use super::partition::{PartitionResult, PathEntry};
51use super::types::ChangeKind;
52
53#[derive(Clone, Debug, PartialEq, Eq)]
55pub enum ConflictReason {
56 AddAddDifferent,
59 ModifyDelete,
61 Diff3Conflict,
63 MissingBase,
65 MissingContent,
67}
68
69impl std::fmt::Display for ConflictReason {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 match self {
72 Self::AddAddDifferent => write!(f, "add/add with different content"),
73 Self::ModifyDelete => write!(f, "modify/delete conflict"),
74 Self::Diff3Conflict => write!(f, "overlapping edits (diff3 conflict)"),
75 Self::MissingBase => write!(f, "base content missing"),
76 Self::MissingContent => write!(f, "entry missing file content"),
77 }
78 }
79}
80
81#[derive(Clone, Debug, PartialEq, Eq)]
83pub struct ConflictSide {
84 pub workspace_id: WorkspaceId,
86 pub kind: ChangeKind,
88 pub content: Option<Vec<u8>>,
90}
91
92#[derive(Clone, Debug, PartialEq, Eq)]
94pub struct ConflictRecord {
95 pub path: PathBuf,
97 pub base: Option<Vec<u8>>,
99 pub sides: Vec<ConflictSide>,
101 pub reason: ConflictReason,
103 pub atoms: Vec<ConflictAtom>,
109}
110
111#[derive(Clone, Debug, PartialEq, Eq)]
122pub enum MergeOutcome<C> {
123 Delete,
125 Upsert(C),
127 Conflict(ConflictReason),
129}
130
131#[derive(Clone, Debug, PartialEq, Eq)]
133pub enum Diff3Result<C> {
134 Clean(C),
136 Conflict,
138}
139
140pub fn resolve_entries<C, E, F>(
157 kinds: &[ChangeKind],
158 contents: &[Option<C>],
159 base: Option<&C>,
160 diff3: F,
161) -> Result<MergeOutcome<C>, E>
162where
163 C: Eq + Clone,
164 F: Fn(&C, &C, &C) -> Result<Diff3Result<C>, E>,
165{
166 assert_eq!(kinds.len(), contents.len(), "kinds and contents must have same length");
167
168 let classification = {
170 let all_have_content = kinds.iter().zip(contents.iter()).all(|(k, c)| {
171 matches!(k, ChangeKind::Deleted) || c.is_some()
172 });
173
174 let non_delete_contents: Vec<&C> = contents.iter().filter_map(|c| c.as_ref()).collect();
176 let all_content_equal = if non_delete_contents.len() >= 2 {
177 non_delete_contents.windows(2).all(|w| w[0] == w[1])
178 } else {
179 true
180 };
181
182 classify_shared_path(kinds, all_have_content, all_content_equal, base.is_some())
183 };
184
185 match classification {
187 SharedClassification::ResolvedDelete => return Ok(MergeOutcome::Delete),
188 SharedClassification::ResolvedIdentical => {
189 let content = contents.iter().find_map(|c| c.as_ref()).unwrap().clone();
191 return Ok(MergeOutcome::Upsert(content));
192 }
193 SharedClassification::ConflictModifyDelete => {
194 return Ok(MergeOutcome::Conflict(ConflictReason::ModifyDelete));
195 }
196 SharedClassification::ConflictMissingContent => {
197 return Ok(MergeOutcome::Conflict(ConflictReason::MissingContent));
198 }
199 SharedClassification::ConflictAddAddDifferent => {
200 return Ok(MergeOutcome::Conflict(ConflictReason::AddAddDifferent));
201 }
202 SharedClassification::ConflictMissingBase => {
203 return Ok(MergeOutcome::Conflict(ConflictReason::MissingBase));
204 }
205 SharedClassification::NeedsDiff3 => {} }
207
208 let base = base.expect("NeedsDiff3 implies base is present");
210 let variants: Vec<&C> = contents.iter().filter_map(|c| c.as_ref()).collect();
211
212 let mut merged = variants[0].clone();
213 for next in &variants[1..] {
214 if merged == **next {
215 continue;
216 }
217 match diff3(base, &merged, next)? {
218 Diff3Result::Clean(out) => {
219 merged = out;
220 }
221 Diff3Result::Conflict => {
222 return Ok(MergeOutcome::Conflict(ConflictReason::Diff3Conflict));
223 }
224 }
225 }
226
227 Ok(MergeOutcome::Upsert(merged))
228}
229
230#[derive(Clone, Copy, Debug, PartialEq, Eq)]
237pub enum SharedClassification {
238 ResolvedDelete,
240 ResolvedIdentical,
242 ConflictModifyDelete,
244 ConflictMissingContent,
246 ConflictAddAddDifferent,
248 ConflictMissingBase,
250 NeedsDiff3,
252}
253
254impl SharedClassification {
255 #[must_use]
257 pub const fn is_definite(self) -> bool {
258 !matches!(self, Self::NeedsDiff3)
259 }
260
261 #[must_use]
263 pub const fn is_conflict(self) -> bool {
264 matches!(
265 self,
266 Self::ConflictModifyDelete
267 | Self::ConflictMissingContent
268 | Self::ConflictAddAddDifferent
269 | Self::ConflictMissingBase
270 )
271 }
272}
273
274pub fn classify_shared_path(
287 kinds: &[ChangeKind],
288 all_have_content: bool,
289 all_content_equal: bool,
290 has_base: bool,
291) -> SharedClassification {
292 if kinds.iter().all(|k| matches!(k, ChangeKind::Deleted)) {
294 return SharedClassification::ResolvedDelete;
295 }
296
297 let has_delete = kinds.iter().any(|k| matches!(k, ChangeKind::Deleted));
299 if has_delete {
300 return SharedClassification::ConflictModifyDelete;
301 }
302
303 if !all_have_content {
305 return SharedClassification::ConflictMissingContent;
306 }
307
308 if all_content_equal {
310 return SharedClassification::ResolvedIdentical;
311 }
312
313 if !has_base {
315 if kinds.iter().all(|k| matches!(k, ChangeKind::Added)) {
316 return SharedClassification::ConflictAddAddDifferent;
317 }
318 return SharedClassification::ConflictMissingBase;
319 }
320
321 SharedClassification::NeedsDiff3
323}
324
325#[derive(Clone, Debug, PartialEq, Eq)]
327pub struct ResolveResult {
328 pub resolved: Vec<ResolvedChange>,
330 pub conflicts: Vec<ConflictRecord>,
332}
333
334impl ResolveResult {
335 #[must_use]
337 pub const fn is_clean(&self) -> bool {
338 self.conflicts.is_empty()
339 }
340}
341
342#[derive(Debug)]
344pub enum ResolveError {
345 Io(std::io::Error),
347 GitCommand {
349 command: String,
351 stderr: String,
353 exit_code: Option<i32>,
355 },
356}
357
358impl std::fmt::Display for ResolveError {
359 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
360 match self {
361 Self::Io(e) => write!(f, "I/O error: {e}"),
362 Self::GitCommand {
363 command,
364 stderr,
365 exit_code,
366 } => {
367 write!(f, "`{command}` failed")?;
368 if let Some(code) = exit_code {
369 write!(f, " (exit {code})")?;
370 }
371 if !stderr.is_empty() {
372 write!(f, ": {stderr}")?;
373 }
374 Ok(())
375 }
376 }
377 }
378}
379
380impl std::error::Error for ResolveError {
381 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
382 if let Self::Io(e) = self {
383 Some(e)
384 } else {
385 None
386 }
387 }
388}
389
390impl From<std::io::Error> for ResolveError {
391 fn from(value: std::io::Error) -> Self {
392 Self::Io(value)
393 }
394}
395
396pub fn resolve_partition(
406 partition: &PartitionResult,
407 base_contents: &BTreeMap<PathBuf, Vec<u8>>,
408) -> Result<ResolveResult, ResolveError> {
409 let mut resolved: Vec<ResolvedChange> = Vec::new();
410 let mut conflicts: Vec<ConflictRecord> = Vec::new();
411
412 for (path, entry) in &partition.unique {
414 if entry.is_deletion() {
415 resolved.push(ResolvedChange::Delete { path: path.clone() });
416 continue;
417 }
418
419 match &entry.content {
420 Some(content) => resolved.push(ResolvedChange::Upsert {
421 path: path.clone(),
422 content: content.clone(),
423 }),
424 None => conflicts.push(ConflictRecord {
425 path: path.clone(),
426 base: base_contents.get(path).cloned(),
427 sides: vec![ConflictSide {
428 workspace_id: entry.workspace_id.clone(),
429 kind: entry.kind.clone(),
430 content: None,
431 }],
432 reason: ConflictReason::MissingContent,
433 atoms: vec![],
434 }),
435 }
436 }
437
438 for (path, entries) in &partition.shared {
440 let base = base_contents.get(path).cloned();
441 match resolve_shared_path(path, entries, base.as_deref())? {
442 SharedOutcome::Resolved(change) => resolved.push(change),
443 SharedOutcome::Conflict(conflict) => conflicts.push(conflict),
444 }
445 }
446
447 resolved.sort_by(|a, b| a.path().cmp(b.path()));
448 conflicts.sort_by(|a, b| a.path.cmp(&b.path));
449
450 Ok(ResolveResult {
451 resolved,
452 conflicts,
453 })
454}
455
456#[cfg(feature = "ast-merge")]
468pub fn resolve_partition_with_ast(
469 partition: &PartitionResult,
470 base_contents: &BTreeMap<PathBuf, Vec<u8>>,
471 ast_config: &AstMergeConfig,
472) -> Result<ResolveResult, ResolveError> {
473 let mut resolved: Vec<ResolvedChange> = Vec::new();
474 let mut conflicts: Vec<ConflictRecord> = Vec::new();
475
476 for (path, entry) in &partition.unique {
478 if entry.is_deletion() {
479 resolved.push(ResolvedChange::Delete { path: path.clone() });
480 continue;
481 }
482
483 match &entry.content {
484 Some(content) => resolved.push(ResolvedChange::Upsert {
485 path: path.clone(),
486 content: content.clone(),
487 }),
488 None => conflicts.push(ConflictRecord {
489 path: path.clone(),
490 base: base_contents.get(path).cloned(),
491 sides: vec![ConflictSide {
492 workspace_id: entry.workspace_id.clone(),
493 kind: entry.kind.clone(),
494 content: None,
495 }],
496 reason: ConflictReason::MissingContent,
497 atoms: vec![],
498 }),
499 }
500 }
501
502 for (path, entries) in &partition.shared {
504 let base = base_contents.get(path).cloned();
505 match resolve_shared_path_with_ast(path, entries, base.as_deref(), ast_config)? {
506 SharedOutcome::Resolved(change) => resolved.push(change),
507 SharedOutcome::Conflict(conflict) => conflicts.push(conflict),
508 }
509 }
510
511 resolved.sort_by(|a, b| a.path().cmp(b.path()));
512 conflicts.sort_by(|a, b| a.path.cmp(&b.path));
513
514 Ok(ResolveResult {
515 resolved,
516 conflicts,
517 })
518}
519
520enum SharedOutcome {
521 Resolved(ResolvedChange),
522 Conflict(ConflictRecord),
523}
524
525fn resolve_shared_path(
526 path: &Path,
527 entries: &[PathEntry],
528 base: Option<&[u8]>,
529) -> Result<SharedOutcome, ResolveError> {
530 let blob_eq = all_blobs_equal(entries);
534
535 let kinds: Vec<ChangeKind> = entries.iter().map(|e| e.kind.clone()).collect();
537 let contents: Vec<Option<Vec<u8>>> = entries.iter().map(|e| e.content.clone()).collect();
538
539 let effective_contents = if blob_eq && contents.iter().filter(|c| c.is_some()).count() >= 2 {
543 let first = contents.iter().find_map(|c| c.as_ref()).cloned();
545 contents
546 .iter()
547 .map(|c| if c.is_some() { first.clone() } else { None })
548 .collect::<Vec<_>>()
549 } else {
550 contents
551 };
552
553 let outcome = resolve_entries(
556 &kinds,
557 &effective_contents,
558 base.map(|b| b.to_vec()).as_ref(),
559 |base_c, ours_c, theirs_c| -> Result<Diff3Result<Vec<u8>>, ResolveError> {
560 match diff3_merge_bytes(base_c, ours_c, theirs_c)? {
562 Diff3Outcome::Clean(out) => Ok(Diff3Result::Clean(out)),
563 Diff3Outcome::Conflict { .. } => {
564 if let Some(retried) =
565 retry_with_shifted_alignment(base_c, ours_c, theirs_c)?
566 {
567 Ok(Diff3Result::Clean(retried))
568 } else {
569 Ok(Diff3Result::Conflict)
570 }
571 }
572 }
573 },
574 )?;
575
576 match outcome {
578 MergeOutcome::Delete => Ok(SharedOutcome::Resolved(ResolvedChange::Delete {
579 path: path.to_path_buf(),
580 })),
581 MergeOutcome::Upsert(content) => Ok(SharedOutcome::Resolved(ResolvedChange::Upsert {
582 path: path.to_path_buf(),
583 content,
584 })),
585 MergeOutcome::Conflict(reason) => {
586 let base_for_record = if matches!(
587 reason,
588 ConflictReason::AddAddDifferent | ConflictReason::MissingBase
589 ) {
590 None
591 } else {
592 base
593 };
594 let atoms = if matches!(reason, ConflictReason::Diff3Conflict) {
599 recover_diff3_atoms(entries, base)
600 } else {
601 vec![]
602 };
603 Ok(SharedOutcome::Conflict(conflict_record(
604 path,
605 entries,
606 base_for_record,
607 reason,
608 atoms,
609 )))
610 }
611 }
612}
613
614#[cfg(feature = "ast-merge")]
619#[allow(clippy::too_many_lines)]
620fn resolve_shared_path_with_ast(
621 path: &Path,
622 entries: &[PathEntry],
623 base: Option<&[u8]>,
624 ast_config: &AstMergeConfig,
625) -> Result<SharedOutcome, ResolveError> {
626 if entries.iter().all(PathEntry::is_deletion) {
628 return Ok(SharedOutcome::Resolved(ResolvedChange::Delete {
629 path: path.to_path_buf(),
630 }));
631 }
632
633 let has_delete = entries.iter().any(PathEntry::is_deletion);
635 let has_non_delete = entries.iter().any(|e| !e.is_deletion());
636 if has_delete && has_non_delete {
637 return Ok(SharedOutcome::Conflict(conflict_record(
638 path,
639 entries,
640 base,
641 ConflictReason::ModifyDelete,
642 vec![],
643 )));
644 }
645
646 let mut variants: Vec<Vec<u8>> = Vec::with_capacity(entries.len());
648 for entry in entries {
649 let Some(content) = &entry.content else {
650 return Ok(SharedOutcome::Conflict(conflict_record(
651 path,
652 entries,
653 base,
654 ConflictReason::MissingContent,
655 vec![],
656 )));
657 };
658 variants.push(content.clone());
659 }
660
661 if all_blobs_equal(entries) || all_equal(&variants) {
663 return Ok(SharedOutcome::Resolved(ResolvedChange::Upsert {
664 path: path.to_path_buf(),
665 content: variants[0].clone(),
666 }));
667 }
668
669 let Some(base_bytes) = base else {
671 let reason = if entries.iter().all(|e| matches!(e.kind, ChangeKind::Added)) {
672 ConflictReason::AddAddDifferent
673 } else {
674 ConflictReason::MissingBase
675 };
676 return Ok(SharedOutcome::Conflict(conflict_record(
677 path,
678 entries,
679 None,
680 reason,
681 vec![],
682 )));
683 };
684
685 let mut merged = variants[0].clone();
687 let mut ours_ws_label: String = entries[0].workspace_id.to_string();
688 let mut diff3_conflict: Option<(Vec<u8>, String, String)> = None;
689
690 for (i, next) in variants[1..].iter().enumerate() {
691 if merged == *next {
692 let theirs_ws = &entries[i + 1].workspace_id;
693 ours_ws_label = format!("{ours_ws_label}+{theirs_ws}");
694 continue;
695 }
696
697 let theirs_ws_label = entries[i + 1].workspace_id.to_string();
698
699 match diff3_merge_bytes(base_bytes, &merged, next)? {
700 Diff3Outcome::Clean(out) => {
701 merged = out;
702 ours_ws_label = format!("{ours_ws_label}+{theirs_ws_label}");
703 }
704 Diff3Outcome::Conflict { marker_output } => {
705 diff3_conflict = Some((marker_output, ours_ws_label, theirs_ws_label));
706 break;
707 }
708 }
709 }
710
711 if diff3_conflict.is_none() {
713 return Ok(SharedOutcome::Resolved(ResolvedChange::Upsert {
714 path: path.to_path_buf(),
715 content: merged,
716 }));
717 }
718
719 if let Some(lang) = ast_config.is_enabled_for(path) {
721 let ast_variants: Vec<_> = entries
722 .iter()
723 .zip(variants.iter())
724 .map(|(entry, content)| (entry.workspace_id.clone(), content.clone()))
725 .collect();
726
727 match try_ast_merge_with_config(base_bytes, &ast_variants, lang, ast_config) {
728 AstMergeResult::Clean(ast_merged) => {
729 return Ok(SharedOutcome::Resolved(ResolvedChange::Upsert {
730 path: path.to_path_buf(),
731 content: ast_merged,
732 }));
733 }
734 AstMergeResult::Conflict { atoms } => {
735 return Ok(SharedOutcome::Conflict(conflict_record(
737 path,
738 entries,
739 Some(base_bytes),
740 ConflictReason::Diff3Conflict,
741 atoms,
742 )));
743 }
744 AstMergeResult::Unsupported => {
745 }
747 }
748 }
749
750 let (marker_output, ours_label, theirs_label) = diff3_conflict.unwrap();
752 let atoms = parse_diff3_atoms(&marker_output, &ours_label, &theirs_label);
753 Ok(SharedOutcome::Conflict(conflict_record(
754 path,
755 entries,
756 Some(base_bytes),
757 ConflictReason::Diff3Conflict,
758 atoms,
759 )))
760}
761
762fn recover_diff3_atoms(entries: &[PathEntry], base: Option<&[u8]>) -> Vec<ConflictAtom> {
768 let base_bytes = match base {
769 Some(b) => b,
770 None => return vec![],
771 };
772 let variants: Vec<&[u8]> = entries
773 .iter()
774 .filter_map(|e| e.content.as_deref())
775 .collect();
776 if variants.len() < 2 {
777 return vec![];
778 }
779
780 let mut merged = variants[0].to_vec();
781 let mut ours_label = entries[0].workspace_id.to_string();
782
783 for (i, next) in variants[1..].iter().enumerate() {
784 if merged == *next {
785 let theirs_ws = &entries[i + 1].workspace_id;
786 ours_label = format!("{ours_label}+{theirs_ws}");
787 continue;
788 }
789
790 let theirs_label = entries[i + 1].workspace_id.to_string();
791
792 match diff3_merge_bytes(base_bytes, &merged, next) {
793 Ok(Diff3Outcome::Clean(out)) => {
794 merged = out;
795 ours_label = format!("{ours_label}+{theirs_label}");
796 }
797 Ok(Diff3Outcome::Conflict { marker_output }) => {
798 return parse_diff3_atoms(&marker_output, &ours_label, &theirs_label);
799 }
800 Err(_) => return vec![],
801 }
802 }
803 vec![]
804}
805
806fn all_equal(contents: &[Vec<u8>]) -> bool {
807 contents
808 .split_first()
809 .is_none_or(|(first, rest)| rest.iter().all(|c| c == first))
810}
811
812fn all_blobs_equal(entries: &[PathEntry]) -> bool {
819 let mut iter = entries.iter();
820 let Some(first) = iter.next() else {
821 return true;
822 };
823 let Some(ref first_blob) = first.blob else {
824 return false;
825 };
826 iter.all(|e| e.blob.as_ref() == Some(first_blob))
827}
828
829fn conflict_record(
830 path: &Path,
831 entries: &[PathEntry],
832 base: Option<&[u8]>,
833 reason: ConflictReason,
834 atoms: Vec<ConflictAtom>,
835) -> ConflictRecord {
836 ConflictRecord {
837 path: path.to_path_buf(),
838 base: base.map(std::borrow::ToOwned::to_owned),
839 sides: entries
840 .iter()
841 .map(|entry| ConflictSide {
842 workspace_id: entry.workspace_id.clone(),
843 kind: entry.kind.clone(),
844 content: entry.content.clone(),
845 })
846 .collect(),
847 reason,
848 atoms,
849 }
850}
851
852enum Diff3Outcome {
854 Clean(Vec<u8>),
856 Conflict { marker_output: Vec<u8> },
858}
859
860#[cfg(kani)]
872fn diff3_merge_bytes(
873 _base: &[u8],
874 ours: &[u8],
875 theirs: &[u8],
876) -> Result<Diff3Outcome, ResolveError> {
877 if ours == theirs {
878 Ok(Diff3Outcome::Clean(ours.to_vec()))
879 } else {
880 Ok(Diff3Outcome::Conflict {
881 marker_output: Vec::new(),
882 })
883 }
884}
885
886#[cfg(not(kani))]
893fn diff3_merge_bytes(
894 base: &[u8],
895 ours: &[u8],
896 theirs: &[u8],
897) -> Result<Diff3Outcome, ResolveError> {
898 let tmp_dir = Builder::new().prefix("maw-resolve-diff3").tempdir()?;
902
903 let ours_path = tmp_dir.path().join("ours.tmp");
904 let base_path = tmp_dir.path().join("base.tmp");
905 let theirs_path = tmp_dir.path().join("theirs.tmp");
906
907 fs::write(&ours_path, ours)?;
908 fs::write(&base_path, base)?;
909 fs::write(&theirs_path, theirs)?;
910
911 let output = Command::new("git")
912 .arg("merge-file")
913 .arg("-p")
914 .arg("--diff3")
915 .arg(&ours_path)
916 .arg(&base_path)
917 .arg(&theirs_path)
918 .output()?;
919
920 match output.status.code() {
923 Some(0) => Ok(Diff3Outcome::Clean(output.stdout)),
924 Some(n) if n > 0 => Ok(Diff3Outcome::Conflict {
928 marker_output: output.stdout,
929 }),
930 code => Err(ResolveError::GitCommand {
931 command: "git merge-file -p --diff3 <ours> <base> <theirs>".to_owned(),
932 stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
933 exit_code: code,
934 }),
935 }
936}
937
938fn retry_with_shifted_alignment(
945 base: &[u8],
946 ours: &[u8],
947 theirs: &[u8],
948) -> Result<Option<Vec<u8>>, ResolveError> {
949 let normalized_ours = normalize_shifted_blocks(base, ours);
950 let normalized_theirs = normalize_shifted_blocks(base, theirs);
951
952 if normalized_ours.is_none() && normalized_theirs.is_none() {
954 return Ok(None);
955 }
956
957 let ours_aligned = normalized_ours.as_deref().unwrap_or(ours);
958 let theirs_aligned = normalized_theirs.as_deref().unwrap_or(theirs);
959
960 match diff3_merge_bytes(base, ours_aligned, theirs_aligned)? {
961 Diff3Outcome::Clean(out) => Ok(Some(out)),
962 Diff3Outcome::Conflict { .. } => Ok(None),
963 }
964}
965
966#[allow(clippy::option_if_let_else)]
976fn normalize_shifted_blocks(base: &[u8], variant: &[u8]) -> Option<Vec<u8>> {
977 let base_text = std::str::from_utf8(base).ok()?;
978 let variant_text = std::str::from_utf8(variant).ok()?;
979
980 let base_blocks = split_blocks(base_text);
981 let variant_blocks = split_blocks(variant_text);
982
983 if base_blocks.len() < 2 || variant_blocks.len() < 2 {
984 return None;
985 }
986
987 let mut base_positions: BTreeMap<String, Vec<usize>> = BTreeMap::new();
988 for (idx, block) in base_blocks.iter().enumerate() {
989 base_positions
990 .entry(block_signature(block))
991 .or_default()
992 .push(idx);
993 }
994
995 let mut variant_positions: BTreeMap<String, Vec<usize>> = BTreeMap::new();
996 for (idx, block) in variant_blocks.iter().enumerate() {
997 variant_positions
998 .entry(block_signature(block))
999 .or_default()
1000 .push(idx);
1001 }
1002
1003 let mut anchors: Vec<Option<usize>> = vec![None; variant_blocks.len()];
1004 for (idx, block) in variant_blocks.iter().enumerate() {
1005 let signature = block_signature(block);
1006 let Some(base_pos) = base_positions.get(&signature) else {
1007 continue;
1008 };
1009 let Some(var_pos) = variant_positions.get(&signature) else {
1010 continue;
1011 };
1012
1013 if base_pos.len() == 1 && var_pos.len() == 1 {
1014 anchors[idx] = Some(base_pos[0]);
1015 }
1016 }
1017
1018 let moved_anchor_count = anchors
1019 .iter()
1020 .enumerate()
1021 .filter(|(idx, anchor)| anchor.is_some_and(|a| a != *idx))
1022 .count();
1023 if moved_anchor_count == 0 {
1024 return None;
1025 }
1026
1027 let mut prev_anchor: Vec<Option<usize>> = vec![None; anchors.len()];
1028 let mut last: Option<usize> = None;
1029 for (idx, anchor) in anchors.iter().enumerate() {
1030 if let Some(a) = anchor {
1031 last = Some(*a);
1032 }
1033 prev_anchor[idx] = last;
1034 }
1035
1036 let mut next_anchor: Vec<Option<usize>> = vec![None; anchors.len()];
1037 let mut next: Option<usize> = None;
1038 for (idx, anchor) in anchors.iter().enumerate().rev() {
1039 if let Some(a) = anchor {
1040 next = Some(*a);
1041 }
1042 next_anchor[idx] = next;
1043 }
1044
1045 let mut ranked: Vec<(i64, usize, &str)> = Vec::with_capacity(variant_blocks.len());
1046 for (idx, block) in variant_blocks.iter().enumerate() {
1047 let rank = anchors[idx].map_or_else(
1048 || {
1049 prev_anchor[idx].map_or_else(
1050 || {
1051 next_anchor[idx].map_or_else(
1052 || {
1053 usize_to_i64(base_blocks.len())
1054 .saturating_mul(4)
1055 .saturating_add(usize_to_i64(idx))
1056 },
1057 |next| usize_to_i64(next).saturating_mul(4).saturating_add(1),
1058 )
1059 },
1060 |prev| usize_to_i64(prev).saturating_mul(4).saturating_add(3),
1061 )
1062 },
1063 |base_idx| usize_to_i64(base_idx).saturating_mul(4).saturating_add(2),
1064 );
1065 ranked.push((rank, idx, block.as_str()));
1066 }
1067
1068 ranked.sort_by_key(|(rank, idx, _)| (*rank, *idx));
1069
1070 let mut normalized = String::new();
1071 for (_, _, block) in ranked {
1072 normalized.push_str(block);
1073 }
1074
1075 if normalized.as_bytes() == variant {
1076 None
1077 } else {
1078 Some(normalized.into_bytes())
1079 }
1080}
1081
1082fn block_signature(block: &str) -> String {
1083 block.trim_end().to_owned()
1084}
1085
1086fn usize_to_i64(value: usize) -> i64 {
1087 i64::try_from(value).unwrap_or(i64::MAX)
1088}
1089
1090fn usize_to_u32(value: usize) -> u32 {
1091 u32::try_from(value).unwrap_or(u32::MAX)
1092}
1093
1094fn split_blocks(text: &str) -> Vec<String> {
1096 let mut out = Vec::new();
1097 let mut current = String::new();
1098
1099 for segment in text.split_inclusive('\n') {
1100 let is_blank = segment.trim().is_empty();
1101 current.push_str(segment);
1102
1103 if is_blank {
1104 out.push(std::mem::take(&mut current));
1105 }
1106 }
1107
1108 if !current.is_empty() {
1109 out.push(current);
1110 }
1111
1112 out
1113}
1114
1115#[derive(Clone, Copy, PartialEq)]
1143enum Diff3ParseState {
1144 Context,
1145 Ours,
1146 Base,
1147 Theirs,
1148}
1149
1150#[must_use]
1151pub fn parse_diff3_atoms(
1152 marker_output: &[u8],
1153 ws_ours: &str,
1154 ws_theirs: &str,
1155) -> Vec<ConflictAtom> {
1156 let text = String::from_utf8_lossy(marker_output);
1157 let lines: Vec<&str> = text.lines().collect();
1158
1159 let mut state = Diff3ParseState::Context;
1160 let mut base_line: u32 = 1;
1163
1164 let mut block_base_start: u32 = 1;
1166 let mut ours_lines: Vec<&str> = Vec::new();
1167 let mut base_lines: Vec<&str> = Vec::new();
1168 let mut theirs_lines: Vec<&str> = Vec::new();
1169
1170 let mut atoms: Vec<ConflictAtom> = Vec::new();
1171
1172 for line in &lines {
1173 if line.starts_with("<<<<<<<") {
1174 state = Diff3ParseState::Ours;
1176 block_base_start = base_line;
1177 ours_lines.clear();
1178 base_lines.clear();
1179 theirs_lines.clear();
1180 } else if line.starts_with("|||||||") && state == Diff3ParseState::Ours {
1181 state = Diff3ParseState::Base;
1183 } else if *line == "=======" && state == Diff3ParseState::Base {
1184 state = Diff3ParseState::Theirs;
1186 } else if line.starts_with(">>>>>>>") && state == Diff3ParseState::Theirs {
1187 let base_len = usize_to_u32(base_lines.len());
1189 let base_region = Region::lines(block_base_start, block_base_start + base_len);
1193
1194 let description = if base_len == 0 {
1195 format!("Both sides inserted content at line {block_base_start}")
1196 } else {
1197 format!(
1198 "Both sides edited lines {}..{}",
1199 block_base_start,
1200 block_base_start + base_len
1201 )
1202 };
1203
1204 let ours_region = Region::lines(
1208 block_base_start,
1209 block_base_start + usize_to_u32(ours_lines.len()),
1210 );
1211 let theirs_region = Region::lines(
1212 block_base_start,
1213 block_base_start + usize_to_u32(theirs_lines.len()),
1214 );
1215
1216 let edits = vec![
1217 AtomEdit::new(ws_ours, ours_region, ours_lines.join("\n")),
1218 AtomEdit::new(ws_theirs, theirs_region, theirs_lines.join("\n")),
1219 ];
1220
1221 atoms.push(ConflictAtom::new(
1222 base_region,
1223 edits,
1224 ModelConflictReason::OverlappingLineEdits { description },
1225 ));
1226
1227 base_line += base_len;
1229 state = Diff3ParseState::Context;
1230 } else {
1231 match state {
1233 Diff3ParseState::Context => base_line += 1,
1234 Diff3ParseState::Ours => ours_lines.push(line),
1235 Diff3ParseState::Base => base_lines.push(line),
1236 Diff3ParseState::Theirs => theirs_lines.push(line),
1237 }
1238 }
1239 }
1240
1241 atoms
1242}
1243
1244#[cfg(test)]
1245#[allow(clippy::all, clippy::pedantic, clippy::nursery)]
1246mod tests {
1247 use std::collections::BTreeMap;
1248 use std::path::PathBuf;
1249
1250 use super::*;
1251 use crate::merge::partition::PartitionResult;
1252 use crate::merge::types::ChangeKind;
1253 use crate::model::types::WorkspaceId;
1254
1255 fn ws(name: &str) -> WorkspaceId {
1256 WorkspaceId::new(name).unwrap()
1257 }
1258
1259 fn entry(name: &str, kind: ChangeKind, content: Option<&[u8]>) -> PathEntry {
1260 PathEntry::new(ws(name), kind, content.map(std::borrow::ToOwned::to_owned))
1261 }
1262
1263 fn shared_only(path: &str, entries: Vec<PathEntry>) -> PartitionResult {
1264 PartitionResult {
1265 unique: vec![],
1266 shared: vec![(PathBuf::from(path), entries)],
1267 }
1268 }
1269
1270 fn upsert_content(result: &ResolveResult) -> &[u8] {
1271 match &result.resolved[0] {
1272 ResolvedChange::Upsert { content, .. } => content,
1273 ResolvedChange::Delete { .. } => panic!("expected upsert"),
1274 }
1275 }
1276
1277 #[test]
1278 fn hash_equality_short_circuits_identical_changes() {
1279 let partition = shared_only(
1280 "same.txt",
1281 vec![
1282 entry("ws-a", ChangeKind::Modified, Some(b"identical\n")),
1283 entry("ws-b", ChangeKind::Modified, Some(b"identical\n")),
1284 entry("ws-c", ChangeKind::Modified, Some(b"identical\n")),
1285 ],
1286 );
1287
1288 let mut base = BTreeMap::new();
1289 base.insert(PathBuf::from("same.txt"), b"old\n".to_vec());
1290
1291 let result = resolve_partition(&partition, &base).unwrap();
1292 assert!(result.is_clean());
1293 assert_eq!(result.resolved.len(), 1);
1294 assert_eq!(upsert_content(&result), b"identical\n");
1295 }
1296
1297 #[test]
1298 fn diff3_resolves_non_overlapping_edits() {
1299 let partition = shared_only(
1300 "doc.txt",
1301 vec![
1302 entry("ws-a", ChangeKind::Modified, Some(b"A\nb\nc\n")),
1303 entry("ws-b", ChangeKind::Modified, Some(b"a\nb\nC\n")),
1304 ],
1305 );
1306
1307 let mut base = BTreeMap::new();
1308 base.insert(PathBuf::from("doc.txt"), b"a\nb\nc\n".to_vec());
1309
1310 let result = resolve_partition(&partition, &base).unwrap();
1311 assert!(result.is_clean());
1312 assert_eq!(result.resolved.len(), 1);
1313 assert_eq!(upsert_content(&result), b"A\nb\nC\n");
1314 }
1315
1316 #[test]
1317 fn overlapping_edits_produce_conflict() {
1318 let partition = shared_only(
1319 "doc.txt",
1320 vec![
1321 entry("ws-a", ChangeKind::Modified, Some(b"a\nB1\nc\n")),
1322 entry("ws-b", ChangeKind::Modified, Some(b"a\nB2\nc\n")),
1323 ],
1324 );
1325
1326 let mut base = BTreeMap::new();
1327 base.insert(PathBuf::from("doc.txt"), b"a\nb\nc\n".to_vec());
1328
1329 let result = resolve_partition(&partition, &base).unwrap();
1330 assert_eq!(result.resolved.len(), 0);
1331 assert_eq!(result.conflicts.len(), 1);
1332 assert_eq!(result.conflicts[0].reason, ConflictReason::Diff3Conflict);
1333 }
1334
1335 #[test]
1336 fn add_add_different_without_base_conflicts() {
1337 let partition = shared_only(
1338 "new.txt",
1339 vec![
1340 entry("ws-a", ChangeKind::Added, Some(b"hello\n")),
1341 entry("ws-b", ChangeKind::Added, Some(b"world\n")),
1342 ],
1343 );
1344
1345 let base = BTreeMap::new();
1346
1347 let result = resolve_partition(&partition, &base).unwrap();
1348 assert_eq!(result.resolved.len(), 0);
1349 assert_eq!(result.conflicts.len(), 1);
1350 assert_eq!(result.conflicts[0].reason, ConflictReason::AddAddDifferent);
1351 }
1352
1353 #[test]
1354 fn modify_delete_conflicts() {
1355 let partition = shared_only(
1356 "file.txt",
1357 vec![
1358 entry("ws-a", ChangeKind::Modified, Some(b"new\n")),
1359 entry("ws-b", ChangeKind::Deleted, None),
1360 ],
1361 );
1362
1363 let mut base = BTreeMap::new();
1364 base.insert(PathBuf::from("file.txt"), b"old\n".to_vec());
1365
1366 let result = resolve_partition(&partition, &base).unwrap();
1367 assert_eq!(result.resolved.len(), 0);
1368 assert_eq!(result.conflicts.len(), 1);
1369 assert_eq!(result.conflicts[0].reason, ConflictReason::ModifyDelete);
1370 }
1371
1372 #[test]
1373 fn delete_delete_resolves_to_single_delete() {
1374 let partition = shared_only(
1375 "gone.txt",
1376 vec![
1377 entry("ws-a", ChangeKind::Deleted, None),
1378 entry("ws-b", ChangeKind::Deleted, None),
1379 ],
1380 );
1381
1382 let mut base = BTreeMap::new();
1383 base.insert(PathBuf::from("gone.txt"), b"old\n".to_vec());
1384
1385 let result = resolve_partition(&partition, &base).unwrap();
1386 assert!(result.is_clean());
1387 assert_eq!(result.resolved.len(), 1);
1388 match &result.resolved[0] {
1389 ResolvedChange::Delete { path } => assert_eq!(path, &PathBuf::from("gone.txt")),
1390 ResolvedChange::Upsert { .. } => panic!("expected delete"),
1391 }
1392 }
1393
1394 #[test]
1395 fn k3_merge_resolves_deterministically() {
1396 let base_text = b"1\n-\n-\n-\n-\n2\n-\n-\n-\n-\n3\n";
1399 let partition = shared_only(
1400 "k3.txt",
1401 vec![
1402 entry(
1403 "ws-a",
1404 ChangeKind::Modified,
1405 Some(b"A1\n-\n-\n-\n-\n2\n-\n-\n-\n-\n3\n"),
1406 ),
1407 entry(
1408 "ws-b",
1409 ChangeKind::Modified,
1410 Some(b"1\n-\n-\n-\n-\nB2\n-\n-\n-\n-\n3\n"),
1411 ),
1412 entry(
1413 "ws-c",
1414 ChangeKind::Modified,
1415 Some(b"1\n-\n-\n-\n-\n2\n-\n-\n-\n-\nC3\n"),
1416 ),
1417 ],
1418 );
1419
1420 let mut base = BTreeMap::new();
1421 base.insert(PathBuf::from("k3.txt"), base_text.to_vec());
1422
1423 let result = resolve_partition(&partition, &base).unwrap();
1424 assert!(result.is_clean());
1425 assert_eq!(
1426 upsert_content(&result),
1427 b"A1\n-\n-\n-\n-\nB2\n-\n-\n-\n-\nC3\n"
1428 );
1429 }
1430
1431 #[test]
1432 fn k5_merge_resolves_deterministically() {
1433 let base_text = b"1\n-\n-\n-\n-\n2\n-\n-\n-\n-\n3\n-\n-\n-\n-\n4\n-\n-\n-\n-\n5\n";
1436 let partition = shared_only(
1437 "k5.txt",
1438 vec![
1439 entry(
1440 "ws-0",
1441 ChangeKind::Modified,
1442 Some(b"A\n-\n-\n-\n-\n2\n-\n-\n-\n-\n3\n-\n-\n-\n-\n4\n-\n-\n-\n-\n5\n"),
1443 ),
1444 entry(
1445 "ws-1",
1446 ChangeKind::Modified,
1447 Some(b"1\n-\n-\n-\n-\nB\n-\n-\n-\n-\n3\n-\n-\n-\n-\n4\n-\n-\n-\n-\n5\n"),
1448 ),
1449 entry(
1450 "ws-2",
1451 ChangeKind::Modified,
1452 Some(b"1\n-\n-\n-\n-\n2\n-\n-\n-\n-\nC\n-\n-\n-\n-\n4\n-\n-\n-\n-\n5\n"),
1453 ),
1454 entry(
1455 "ws-3",
1456 ChangeKind::Modified,
1457 Some(b"1\n-\n-\n-\n-\n2\n-\n-\n-\n-\n3\n-\n-\n-\n-\nD\n-\n-\n-\n-\n5\n"),
1458 ),
1459 entry(
1460 "ws-4",
1461 ChangeKind::Modified,
1462 Some(b"1\n-\n-\n-\n-\n2\n-\n-\n-\n-\n3\n-\n-\n-\n-\n4\n-\n-\n-\n-\nE\n"),
1463 ),
1464 ],
1465 );
1466
1467 let mut base = BTreeMap::new();
1468 base.insert(PathBuf::from("k5.txt"), base_text.to_vec());
1469
1470 let result = resolve_partition(&partition, &base).unwrap();
1471 assert!(result.is_clean());
1472 assert_eq!(
1473 upsert_content(&result),
1474 b"A\n-\n-\n-\n-\nB\n-\n-\n-\n-\nC\n-\n-\n-\n-\nD\n-\n-\n-\n-\nE\n"
1475 );
1476 }
1477
1478 #[test]
1479 fn shifted_function_move_resolves_after_alignment_retry() {
1480 let base_text = b"fn one() {\n}\n\nfn two() {\n}\n\nfn three() {\n}\n";
1481 let moved = b"fn three() {\n}\n\nfn one() {\n}\n\nfn two() {\n}\n";
1483 let edited = b"fn one() {\n}\n\nfn two() {\n println!(\"2\");\n}\n\nfn three() {\n}\n";
1485
1486 match diff3_merge_bytes(base_text, moved, edited).unwrap() {
1488 Diff3Outcome::Conflict { .. } => {}
1489 Diff3Outcome::Clean(_) => {
1490 panic!("fixture should conflict before shifted alignment retry")
1491 }
1492 }
1493
1494 let partition = shared_only(
1495 "src/lib.rs",
1496 vec![
1497 entry("ws-a", ChangeKind::Modified, Some(moved)),
1498 entry("ws-b", ChangeKind::Modified, Some(edited)),
1499 ],
1500 );
1501 let mut base = BTreeMap::new();
1502 base.insert(PathBuf::from("src/lib.rs"), base_text.to_vec());
1503
1504 let result = resolve_partition(&partition, &base).unwrap();
1505 assert!(
1506 result.is_clean(),
1507 "alignment retry should auto-resolve moved block"
1508 );
1509
1510 let merged = String::from_utf8(upsert_content(&result).to_vec()).unwrap();
1511 assert!(merged.contains("println!(\"2\")"));
1512 assert!(merged.contains("fn three()"));
1513 }
1514
1515 #[test]
1516 fn shifted_block_normalization_handles_inserted_block_context() {
1517 let base = b"fn one() {\n println!(\"1\");\n}\n\nfn two() {\n println!(\"2\");\n}\n";
1518 let variant = b"fn two() {\n println!(\"2\");\n}\n\nfn helper() {\n println!(\"h\");\n}\n\nfn one() {\n println!(\"1\");\n}\n";
1519
1520 let normalized = normalize_shifted_blocks(base, variant).expect("expected normalization");
1521 let normalized_text = String::from_utf8(normalized).unwrap();
1522
1523 let one_pos = normalized_text.find("fn one()").unwrap();
1525 let two_pos = normalized_text.find("fn two()").unwrap();
1526 assert!(
1527 one_pos < two_pos,
1528 "fn one should appear before fn two after normalization"
1529 );
1530 assert!(normalized_text.contains("fn helper()"));
1532 }
1533
1534 #[test]
1535 fn alignment_retry_improves_resolution_over_bare_diff3_fixture_set() {
1536 let base = b"fn one() {\n}\n\nfn two() {\n}\n\nfn three() {\n}\n";
1537
1538 let fixtures: Vec<(&[u8], &[u8])> = vec![
1539 (
1541 b"fn three() {\n}\n\nfn one() {\n}\n\nfn two() {\n}\n",
1542 b"fn one() {\n}\n\nfn two() {\n println!(\"2\");\n}\n\nfn three() {\n}\n",
1543 ),
1544 (
1546 b"fn one() {\n println!(\"A\");\n}\n\nfn two() {\n}\n\nfn three() {\n}\n",
1547 b"fn one() {\n}\n\nfn two() {\n println!(\"B\");\n}\n\nfn three() {\n}\n",
1548 ),
1549 (
1551 b"fn one() {\n}\n\nfn two() {\n println!(\"same\");\n}\n\nfn three() {\n}\n",
1552 b"fn one() {\n}\n\nfn two() {\n println!(\"same\");\n}\n\nfn three() {\n}\n",
1553 ),
1554 ];
1555
1556 let mut bare_clean = 0usize;
1557 let mut aligned_clean = 0usize;
1558
1559 for (ours, theirs) in fixtures {
1560 if matches!(
1561 diff3_merge_bytes(base, ours, theirs).unwrap(),
1562 Diff3Outcome::Clean(_)
1563 ) {
1564 bare_clean += 1;
1565 }
1566 if retry_with_shifted_alignment(base, ours, theirs)
1567 .unwrap()
1568 .is_some()
1569 || matches!(
1570 diff3_merge_bytes(base, ours, theirs).unwrap(),
1571 Diff3Outcome::Clean(_)
1572 )
1573 {
1574 aligned_clean += 1;
1575 }
1576 }
1577
1578 assert!(
1579 aligned_clean > bare_clean,
1580 "shifted alignment retry should improve clean-merge count over bare diff3"
1581 );
1582 }
1583
1584 #[test]
1585 fn unique_and_shared_results_are_path_sorted() {
1586 let partition = PartitionResult {
1587 unique: vec![(
1588 PathBuf::from("z.txt"),
1589 entry("ws-z", ChangeKind::Added, Some(b"z\n")),
1590 )],
1591 shared: vec![(
1592 PathBuf::from("a.txt"),
1593 vec![
1594 entry("ws-a", ChangeKind::Modified, Some(b"A\n")),
1595 entry("ws-b", ChangeKind::Modified, Some(b"A\n")),
1596 ],
1597 )],
1598 };
1599
1600 let mut base = BTreeMap::new();
1601 base.insert(PathBuf::from("a.txt"), b"old\n".to_vec());
1602
1603 let result = resolve_partition(&partition, &base).unwrap();
1604 let paths: Vec<_> = result
1605 .resolved
1606 .iter()
1607 .map(|change| change.path().clone())
1608 .collect();
1609
1610 assert_eq!(paths, vec![PathBuf::from("a.txt"), PathBuf::from("z.txt")]);
1611 }
1612
1613 #[test]
1619 fn overlapping_edits_produce_conflict_with_atoms() {
1620 let partition = shared_only(
1622 "doc.txt",
1623 vec![
1624 entry("ws-a", ChangeKind::Modified, Some(b"a\nB1\nc\n")),
1625 entry("ws-b", ChangeKind::Modified, Some(b"a\nB2\nc\n")),
1626 ],
1627 );
1628
1629 let mut base = BTreeMap::new();
1630 base.insert(PathBuf::from("doc.txt"), b"a\nb\nc\n".to_vec());
1631
1632 let result = resolve_partition(&partition, &base).unwrap();
1633 assert_eq!(result.conflicts.len(), 1);
1634 let record = &result.conflicts[0];
1635 assert_eq!(record.reason, ConflictReason::Diff3Conflict);
1636
1637 assert_eq!(
1639 record.atoms.len(),
1640 1,
1641 "expected 1 atom, got {:?}",
1642 record.atoms
1643 );
1644
1645 let atom = &record.atoms[0];
1646 assert_eq!(
1649 atom.base_region,
1650 crate::model::conflict::Region::lines(2, 3),
1651 "atom base_region should cover the conflicted base line"
1652 );
1653
1654 assert_eq!(atom.edits.len(), 2);
1656
1657 let ws_labels: Vec<&str> = atom.edits.iter().map(|e| e.workspace.as_str()).collect();
1659 assert!(
1660 ws_labels.contains(&"ws-a"),
1661 "expected ws-a in edits: {ws_labels:?}"
1662 );
1663 assert!(
1664 ws_labels.contains(&"ws-b"),
1665 "expected ws-b in edits: {ws_labels:?}"
1666 );
1667
1668 let content_a = atom.edits.iter().find(|e| e.workspace == "ws-a").unwrap();
1670 let content_b = atom.edits.iter().find(|e| e.workspace == "ws-b").unwrap();
1671 assert_eq!(content_a.content, "B1");
1672 assert_eq!(content_b.content, "B2");
1673 }
1674
1675 #[test]
1677 fn diff3_atoms_have_correct_line_ranges() {
1678 let base = b"line1\nline2\nold3\nold4\nline5\n";
1681 let ours = b"line1\nline2\nnew3a\nnew4a\nline5\n";
1682 let theirs = b"line1\nline2\nnew3b\nnew4b\nline5\n";
1683
1684 let partition = shared_only(
1685 "src.txt",
1686 vec![
1687 entry("ws-a", ChangeKind::Modified, Some(ours)),
1688 entry("ws-b", ChangeKind::Modified, Some(theirs)),
1689 ],
1690 );
1691
1692 let mut base_map = BTreeMap::new();
1693 base_map.insert(PathBuf::from("src.txt"), base.to_vec());
1694
1695 let result = resolve_partition(&partition, &base_map).unwrap();
1696 assert_eq!(result.conflicts.len(), 1);
1697 let record = &result.conflicts[0];
1698 assert_eq!(record.atoms.len(), 1);
1699
1700 let atom = &record.atoms[0];
1701 assert_eq!(
1704 atom.base_region,
1705 crate::model::conflict::Region::lines(3, 5),
1706 "atom should cover base lines 3-4; got {:?}",
1707 atom.base_region
1708 );
1709
1710 assert_eq!(
1712 atom.reason.variant_name(),
1713 "overlapping_line_edits",
1714 "reason should be overlapping_line_edits"
1715 );
1716 }
1717
1718 #[test]
1720 fn multiple_conflicts_in_same_file_produce_multiple_atoms() {
1721 let base = b"ctx\na\nctx\nctx\nctx\nb\nctx\n";
1725 let ours = b"ctx\nA1\nctx\nctx\nctx\nB1\nctx\n";
1727 let theirs = b"ctx\nA2\nctx\nctx\nctx\nB2\nctx\n";
1728
1729 let partition = shared_only(
1730 "multi.txt",
1731 vec![
1732 entry("ws-a", ChangeKind::Modified, Some(ours)),
1733 entry("ws-b", ChangeKind::Modified, Some(theirs)),
1734 ],
1735 );
1736
1737 let mut base_map = BTreeMap::new();
1738 base_map.insert(PathBuf::from("multi.txt"), base.to_vec());
1739
1740 let result = resolve_partition(&partition, &base_map).unwrap();
1741 assert_eq!(result.conflicts.len(), 1);
1742 let record = &result.conflicts[0];
1743
1744 assert_eq!(
1746 record.atoms.len(),
1747 2,
1748 "expected 2 atoms (one per conflict hunk), got {:?}",
1749 record.atoms
1750 );
1751
1752 let regions: Vec<_> = record.atoms.iter().map(|a| &a.base_region).collect();
1754 assert_ne!(
1755 regions[0], regions[1],
1756 "atoms should cover different base regions"
1757 );
1758 }
1759
1760 #[test]
1762 fn non_diff3_conflicts_have_empty_atoms() {
1763 let partition = shared_only(
1765 "new.txt",
1766 vec![
1767 entry("ws-a", ChangeKind::Added, Some(b"hello\n")),
1768 entry("ws-b", ChangeKind::Added, Some(b"world\n")),
1769 ],
1770 );
1771 let result = resolve_partition(&partition, &BTreeMap::new()).unwrap();
1772 assert_eq!(
1773 result.conflicts[0].atoms.len(),
1774 0,
1775 "add/add should have no atoms"
1776 );
1777
1778 let partition2 = shared_only(
1780 "gone.txt",
1781 vec![
1782 entry("ws-a", ChangeKind::Modified, Some(b"new\n")),
1783 entry("ws-b", ChangeKind::Deleted, None),
1784 ],
1785 );
1786 let mut base = BTreeMap::new();
1787 base.insert(PathBuf::from("gone.txt"), b"old\n".to_vec());
1788 let result2 = resolve_partition(&partition2, &base).unwrap();
1789 assert_eq!(
1790 result2.conflicts[0].atoms.len(),
1791 0,
1792 "modify/delete should have no atoms"
1793 );
1794 }
1795
1796 #[test]
1802 fn parse_diff3_atoms_single_block() {
1803 let marker_output =
1806 b"a\n<<<<<<< ours.tmp\nB1\n||||||| base.tmp\nb\n=======\nB2\n>>>>>>> theirs.tmp\nc\n";
1807
1808 let atoms = parse_diff3_atoms(marker_output, "alice", "bob");
1809 assert_eq!(atoms.len(), 1, "expected 1 atom");
1810
1811 let atom = &atoms[0];
1812 assert_eq!(
1815 atom.base_region,
1816 crate::model::conflict::Region::lines(2, 3)
1817 );
1818 assert_eq!(atom.edits.len(), 2);
1819
1820 let alice = atom.edits.iter().find(|e| e.workspace == "alice").unwrap();
1821 let bob = atom.edits.iter().find(|e| e.workspace == "bob").unwrap();
1822 assert_eq!(alice.content, "B1");
1823 assert_eq!(bob.content, "B2");
1824
1825 assert_eq!(atom.reason.variant_name(), "overlapping_line_edits");
1826 }
1827
1828 #[test]
1830 fn parse_diff3_atoms_multiple_blocks() {
1831 let marker_output = concat!(
1834 "ctx\n",
1835 "<<<<<<< ours\n",
1836 "A1\n",
1837 "||||||| base\n",
1838 "a\n",
1839 "=======\n",
1840 "A2\n",
1841 ">>>>>>> theirs\n",
1842 "ctx\n",
1843 "ctx\n",
1844 "ctx\n",
1845 "<<<<<<< ours\n",
1846 "B1\n",
1847 "||||||| base\n",
1848 "b\n",
1849 "=======\n",
1850 "B2\n",
1851 ">>>>>>> theirs\n",
1852 "ctx\n",
1853 )
1854 .as_bytes();
1855
1856 let atoms = parse_diff3_atoms(marker_output, "ws-a", "ws-b");
1857 assert_eq!(atoms.len(), 2, "expected 2 atoms");
1858
1859 assert_eq!(
1861 atoms[0].base_region,
1862 crate::model::conflict::Region::lines(2, 3)
1863 );
1864 assert_eq!(
1865 atoms[0]
1866 .edits
1867 .iter()
1868 .find(|e| e.workspace == "ws-a")
1869 .unwrap()
1870 .content,
1871 "A1"
1872 );
1873 assert_eq!(
1874 atoms[0]
1875 .edits
1876 .iter()
1877 .find(|e| e.workspace == "ws-b")
1878 .unwrap()
1879 .content,
1880 "A2"
1881 );
1882
1883 assert_eq!(
1887 atoms[1].base_region,
1888 crate::model::conflict::Region::lines(6, 7)
1889 );
1890 assert_eq!(
1891 atoms[1]
1892 .edits
1893 .iter()
1894 .find(|e| e.workspace == "ws-a")
1895 .unwrap()
1896 .content,
1897 "B1"
1898 );
1899 assert_eq!(
1900 atoms[1]
1901 .edits
1902 .iter()
1903 .find(|e| e.workspace == "ws-b")
1904 .unwrap()
1905 .content,
1906 "B2"
1907 );
1908 }
1909
1910 #[test]
1912 fn parse_diff3_atoms_no_conflicts_returns_empty() {
1913 let clean_output = b"line one\nline two\nline three\n";
1914 let atoms = parse_diff3_atoms(clean_output, "ws-a", "ws-b");
1915 assert!(atoms.is_empty(), "clean output should produce no atoms");
1916 }
1917
1918 #[test]
1920 fn diff3_atoms_carry_workspace_labels_k2() {
1921 let partition = shared_only(
1922 "doc.txt",
1923 vec![
1924 entry("alice", ChangeKind::Modified, Some(b"a\nALICE\nc\n")),
1925 entry("bob", ChangeKind::Modified, Some(b"a\nBOB\nc\n")),
1926 ],
1927 );
1928
1929 let mut base = BTreeMap::new();
1930 base.insert(PathBuf::from("doc.txt"), b"a\norig\nc\n".to_vec());
1931
1932 let result = resolve_partition(&partition, &base).unwrap();
1933 assert_eq!(result.conflicts.len(), 1);
1934 let atoms = &result.conflicts[0].atoms;
1935 assert_eq!(atoms.len(), 1);
1936
1937 let edit_ws: Vec<&str> = atoms[0]
1938 .edits
1939 .iter()
1940 .map(|e| e.workspace.as_str())
1941 .collect();
1942 assert!(
1944 edit_ws.contains(&"alice"),
1945 "alice should appear as an edit workspace; got {edit_ws:?}"
1946 );
1947 assert!(
1948 edit_ws.contains(&"bob"),
1949 "bob should appear as an edit workspace; got {edit_ws:?}"
1950 );
1951 }
1952
1953 fn entry_with_blob(
1959 name: &str,
1960 kind: ChangeKind,
1961 content: Option<&[u8]>,
1962 blob_hex: &str,
1963 ) -> PathEntry {
1964 let blob = crate::model::types::GitOid::new(blob_hex).ok();
1965 PathEntry::with_identity(
1966 ws(name),
1967 kind,
1968 content.map(std::borrow::ToOwned::to_owned),
1969 None,
1970 blob,
1971 )
1972 }
1973
1974 #[test]
1977 fn blob_oid_equality_short_circuits_without_byte_compare() {
1978 let same_blob = "a".repeat(40);
1979 let partition = shared_only(
1980 "file.txt",
1981 vec![
1982 entry_with_blob("ws-a", ChangeKind::Modified, Some(b"content\n"), &same_blob),
1983 entry_with_blob("ws-b", ChangeKind::Modified, Some(b"content\n"), &same_blob),
1984 ],
1985 );
1986
1987 let mut base = BTreeMap::new();
1988 base.insert(PathBuf::from("file.txt"), b"old\n".to_vec());
1989
1990 let result = resolve_partition(&partition, &base).unwrap();
1991 assert!(result.is_clean(), "same blob OID should resolve cleanly");
1992 assert_eq!(result.resolved.len(), 1);
1993 assert_eq!(upsert_content(&result), b"content\n");
1994 }
1995
1996 #[test]
1998 fn blob_oid_equality_k3_all_same() {
1999 let same_blob = "b".repeat(40);
2000 let partition = shared_only(
2001 "shared.rs",
2002 vec![
2003 entry_with_blob(
2004 "ws-a",
2005 ChangeKind::Modified,
2006 Some(b"fn f() {}\n"),
2007 &same_blob,
2008 ),
2009 entry_with_blob(
2010 "ws-b",
2011 ChangeKind::Modified,
2012 Some(b"fn f() {}\n"),
2013 &same_blob,
2014 ),
2015 entry_with_blob(
2016 "ws-c",
2017 ChangeKind::Modified,
2018 Some(b"fn f() {}\n"),
2019 &same_blob,
2020 ),
2021 ],
2022 );
2023
2024 let base = BTreeMap::new();
2025 let result = resolve_partition(&partition, &base).unwrap();
2026 assert!(result.is_clean());
2027 assert_eq!(upsert_content(&result), b"fn f() {}\n");
2028 }
2029
2030 #[test]
2032 fn different_blob_oids_fall_through_to_diff3() {
2033 let partition = shared_only(
2034 "diff.txt",
2035 vec![
2036 entry_with_blob(
2037 "ws-a",
2038 ChangeKind::Modified,
2039 Some(b"A\nb\nc\n"),
2040 &"a".repeat(40),
2041 ),
2042 entry_with_blob(
2043 "ws-b",
2044 ChangeKind::Modified,
2045 Some(b"a\nb\nC\n"),
2046 &"b".repeat(40),
2047 ),
2048 ],
2049 );
2050
2051 let mut base = BTreeMap::new();
2052 base.insert(PathBuf::from("diff.txt"), b"a\nb\nc\n".to_vec());
2053
2054 let result = resolve_partition(&partition, &base).unwrap();
2055 assert!(
2057 result.is_clean(),
2058 "non-overlapping edits should auto-resolve"
2059 );
2060 }
2061
2062 #[test]
2064 fn missing_blob_oid_falls_back_to_byte_equality() {
2065 let partition = shared_only(
2067 "mixed.txt",
2068 vec![
2069 entry_with_blob(
2070 "ws-a",
2071 ChangeKind::Modified,
2072 Some(b"same content\n"),
2073 &"c".repeat(40),
2074 ),
2075 entry("ws-b", ChangeKind::Modified, Some(b"same content\n")),
2077 ],
2078 );
2079
2080 let base = BTreeMap::new();
2081 let result = resolve_partition(&partition, &base).unwrap();
2082 assert!(
2084 result.is_clean(),
2085 "byte equality should resolve cleanly when blob OID missing"
2086 );
2087 assert_eq!(upsert_content(&result), b"same content\n");
2088 }
2089
2090 #[test]
2092 fn all_blobs_equal_single_entry() {
2093 let entries = vec![entry_with_blob(
2094 "ws-a",
2095 ChangeKind::Modified,
2096 Some(b"x\n"),
2097 &"d".repeat(40),
2098 )];
2099 assert!(all_blobs_equal(&entries));
2102 }
2103
2104 #[test]
2106 fn all_blobs_equal_missing_one_blob_returns_false() {
2107 let entries = vec![
2108 entry_with_blob("ws-a", ChangeKind::Modified, Some(b"x\n"), &"e".repeat(40)),
2109 entry("ws-b", ChangeKind::Modified, Some(b"x\n")), ];
2111 assert!(!all_blobs_equal(&entries));
2112 }
2113
2114 #[test]
2116 fn all_blobs_equal_different_blobs_returns_false() {
2117 let entries = vec![
2118 entry_with_blob("ws-a", ChangeKind::Modified, Some(b"x\n"), &"f".repeat(40)),
2119 entry_with_blob("ws-b", ChangeKind::Modified, Some(b"y\n"), &"0".repeat(40)),
2120 ];
2121 assert!(!all_blobs_equal(&entries));
2122 }
2123
2124 #[cfg(feature = "ast-merge")]
2129 mod ast_resolve_tests {
2130 use super::*;
2131 use crate::merge::ast_merge::AstMergeConfig;
2132 use crate::merge::resolve::resolve_partition_with_ast;
2133
2134 fn shared_rs(path: &str, entries: Vec<PathEntry>) -> PartitionResult {
2135 PartitionResult {
2136 unique: vec![],
2137 shared: vec![(PathBuf::from(path), entries)],
2138 }
2139 }
2140
2141 #[test]
2146 fn ast_resolves_different_functions_where_diff3_fails() {
2147 let base = b"fn foo() {\n old_a();\n}\nfn bar() {\n old_b();\n}\n";
2150 let ws_a = b"fn foo() {\n new_a();\n}\nfn bar() {\n old_b();\n}\n";
2151 let ws_b = b"fn foo() {\n old_a();\n}\nfn bar() {\n new_b();\n}\n";
2152
2153 let partition = shared_rs(
2155 "src/lib.rs",
2156 vec![
2157 entry("ws-a", ChangeKind::Modified, Some(ws_a)),
2158 entry("ws-b", ChangeKind::Modified, Some(ws_b)),
2159 ],
2160 );
2161 let mut base_map = BTreeMap::new();
2162 base_map.insert(PathBuf::from("src/lib.rs"), base.to_vec());
2163
2164 let plain_result = resolve_partition(&partition, &base_map).unwrap();
2165
2166 let ast_config = AstMergeConfig::all_languages();
2168 let ast_result =
2169 resolve_partition_with_ast(&partition, &base_map, &ast_config).unwrap();
2170
2171 if !plain_result.is_clean() {
2172 assert!(
2174 ast_result.is_clean(),
2175 "AST merge should resolve what diff3 could not: conflicts={:?}",
2176 ast_result.conflicts
2177 );
2178 let merged = match &ast_result.resolved[0] {
2179 ResolvedChange::Upsert { content, .. } => content,
2180 _ => panic!("expected upsert"),
2181 };
2182 let merged_str = std::str::from_utf8(merged).unwrap();
2183 assert!(
2184 merged_str.contains("new_a"),
2185 "merged should contain ws-a's foo change"
2186 );
2187 assert!(
2188 merged_str.contains("new_b"),
2189 "merged should contain ws-b's bar change"
2190 );
2191 }
2192 }
2194
2195 #[test]
2197 fn ast_conflict_has_ast_node_regions() {
2198 let base = b"fn process() {\n step_1();\n step_2();\n}\n";
2199 let ws_a = b"fn process() {\n step_1_v1();\n step_2();\n}\n";
2200 let ws_b = b"fn process() {\n step_1();\n step_2_v2();\n}\n";
2201
2202 let partition = shared_rs(
2203 "src/processor.rs",
2204 vec![
2205 entry("ws-a", ChangeKind::Modified, Some(ws_a)),
2206 entry("ws-b", ChangeKind::Modified, Some(ws_b)),
2207 ],
2208 );
2209 let mut base_map = BTreeMap::new();
2210 base_map.insert(PathBuf::from("src/processor.rs"), base.to_vec());
2211
2212 let ast_config = AstMergeConfig::all_languages();
2213 let result = resolve_partition_with_ast(&partition, &base_map, &ast_config).unwrap();
2214
2215 assert_eq!(result.conflicts.len(), 1);
2217 let record = &result.conflicts[0];
2218 assert!(
2220 !record.atoms.is_empty(),
2221 "conflict should have atoms from AST merge"
2222 );
2223 let atom = &record.atoms[0];
2224 assert!(
2225 matches!(&atom.base_region, Region::AstNode { node_kind, name, .. }
2226 if node_kind == "function_item" && name.as_deref() == Some("process")),
2227 "atom should reference function_item `process`, got: {:?}",
2228 atom.base_region
2229 );
2230 }
2231
2232 #[test]
2234 fn ast_merge_disabled_falls_through_to_diff3() {
2235 let base = b"fn foo() {\n old_a();\n}\nfn bar() {\n old_b();\n}\n";
2236 let ws_a = b"fn foo() {\n new_a();\n}\nfn bar() {\n old_b();\n}\n";
2237 let ws_b = b"fn foo() {\n old_a();\n}\nfn bar() {\n new_b();\n}\n";
2238
2239 let partition = shared_rs(
2240 "src/lib.rs",
2241 vec![
2242 entry("ws-a", ChangeKind::Modified, Some(ws_a)),
2243 entry("ws-b", ChangeKind::Modified, Some(ws_b)),
2244 ],
2245 );
2246 let mut base_map = BTreeMap::new();
2247 base_map.insert(PathBuf::from("src/lib.rs"), base.to_vec());
2248
2249 let no_ast_config = AstMergeConfig::default();
2251 let result = resolve_partition_with_ast(&partition, &base_map, &no_ast_config).unwrap();
2252
2253 let plain_result = resolve_partition(&partition, &base_map).unwrap();
2255 assert_eq!(result.is_clean(), plain_result.is_clean());
2256 }
2257
2258 #[test]
2260 fn ast_merge_skipped_for_unsupported_extension() {
2261 let base = b"line1\nline2\nline3\n";
2262 let ws_a = b"LINE1\nline2\nline3\n";
2263 let ws_b = b"line1\nline2\nLINE3\n";
2264
2265 let partition = shared_rs(
2266 "data.json",
2267 vec![
2268 entry("ws-a", ChangeKind::Modified, Some(ws_a)),
2269 entry("ws-b", ChangeKind::Modified, Some(ws_b)),
2270 ],
2271 );
2272 let mut base_map = BTreeMap::new();
2273 base_map.insert(PathBuf::from("data.json"), base.to_vec());
2274
2275 let ast_config = AstMergeConfig::all_languages();
2276 let result = resolve_partition_with_ast(&partition, &base_map, &ast_config).unwrap();
2277 let plain_result = resolve_partition(&partition, &base_map).unwrap();
2278 assert_eq!(result.is_clean(), plain_result.is_clean());
2279 }
2280 }
2281}