Skip to main content

maw/merge/
resolve.rs

1//! RESOLVE step of the N-way merge pipeline.
2//!
3//! Resolves paths touched by multiple workspaces (the `shared` output from
4//! [`crate::merge::partition`]) into:
5//!
6//! - a single [`ResolvedChange`] when we can resolve automatically, or
7//! - a structured [`ConflictRecord`] when human intervention is needed.
8//!
9//! Resolution strategy (Phase 1):
10//!
11//! 1. **Hash equality**: if all non-deletion variants are byte-identical,
12//!    short-circuit.
13//! 2. **Special cases**:
14//!    - delete/delete => resolved delete
15//!    - modify/delete => conflict
16//!    - add/add (different content, no base) => conflict
17//! 3. **diff3**: for non-identical content with a known base, run deterministic
18//!    line merge via `git merge-file -p --diff3`.
19//!    - K=2: one diff3 merge
20//!    - K>2: deterministic sequential merges against the same base, in sorted
21//!      workspace order.
22//! 4. **Shifted-code alignment retry**: if diff3 conflicts, detect moved blocks,
23//!    normalize variant block positions back toward base ordering, and retry
24//!    diff3 once before declaring conflict.
25//!
26//! The function returns both successful resolutions and conflicts so callers can
27//! either proceed directly to BUILD or surface rich conflict diagnostics.
28
29#![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/// Why a shared path could not be auto-resolved.
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub enum ConflictReason {
56    /// Two or more workspaces added the same path with different content and
57    /// there is no base content to merge against.
58    AddAddDifferent,
59    /// Some workspaces deleted a file while others modified/added it.
60    ModifyDelete,
61    /// diff3 detected overlapping edits.
62    Diff3Conflict,
63    /// Non-add shared edits were present but base content was unavailable.
64    MissingBase,
65    /// A non-deletion entry was missing file content.
66    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/// One side of a conflict record.
82#[derive(Clone, Debug, PartialEq, Eq)]
83pub struct ConflictSide {
84    /// Workspace that produced this side.
85    pub workspace_id: WorkspaceId,
86    /// Change kind from that workspace.
87    pub kind: ChangeKind,
88    /// New content (`None` for deletions).
89    pub content: Option<Vec<u8>>,
90}
91
92/// Structured conflict information for one path.
93#[derive(Clone, Debug, PartialEq, Eq)]
94pub struct ConflictRecord {
95    /// Path relative to repo root.
96    pub path: PathBuf,
97    /// Base content from epoch (if file existed).
98    pub base: Option<Vec<u8>>,
99    /// All workspace sides for this path, sorted by workspace ID.
100    pub sides: Vec<ConflictSide>,
101    /// Conflict classification.
102    pub reason: ConflictReason,
103    /// Localized conflict atoms extracted from diff3 conflict markers.
104    ///
105    /// Non-empty for `Diff3Conflict` records where region-level localization
106    /// was successfully extracted. Empty for other conflict reasons (add/add,
107    /// modify/delete) or when the diff3 output could not be parsed.
108    pub atoms: Vec<ConflictAtom>,
109}
110
111// ---------------------------------------------------------------------------
112// Generic merge algebra (Kani-provable)
113// ---------------------------------------------------------------------------
114
115/// Outcome of resolving a shared path's entries through the full merge algebra.
116///
117/// This is the return type of [`resolve_entries`], the generic function that
118/// contains the complete classification + k-way diff3 fold logic. It uses
119/// the content type `C` directly, avoiding any Path/ConflictRecord/ConflictAtom
120/// dependencies that would pull in heavy types.
121#[derive(Clone, Debug, PartialEq, Eq)]
122pub enum MergeOutcome<C> {
123    /// All entries are deletions → resolved delete.
124    Delete,
125    /// All entries agree on content, or diff3 merged cleanly → resolved upsert.
126    Upsert(C),
127    /// Entries could not be auto-merged → conflict with reason.
128    Conflict(ConflictReason),
129}
130
131/// Diff3 result for the generic merge algebra.
132#[derive(Clone, Debug, PartialEq, Eq)]
133pub enum Diff3Result<C> {
134    /// Clean merge.
135    Clean(C),
136    /// Conflicting merge.
137    Conflict,
138}
139
140/// Resolve a set of shared-path entries through the full merge algebra.
141///
142/// This is the generic core of the resolve pipeline. It contains the complete
143/// classification decision tree AND the k-way diff3 fold, parameterized by:
144///
145/// - `C`: content type (`Vec<u8>` in production, `u8` under Kani)
146/// - `diff3`: a function `(base, ours, theirs) → Result<Diff3Result<C>>` that
147///   merges two variants against a base
148///
149/// Production [`resolve_shared_path`] calls this with `Vec<u8>` content and
150/// the real `git merge-file` diff3. Kani proofs call it with `u8` content and
151/// a deterministic stub.
152///
153/// The function does not allocate PathBuf, build ConflictRecord, format
154/// workspace labels, or do any I/O. The caller maps the [`MergeOutcome`]
155/// to concrete output types.
156pub 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    // Step 1: classification (same logic as classify_shared_path).
169    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        // Gather non-None contents for equality check.
175        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    // Step 2: map definite classifications to outcomes.
186    match classification {
187        SharedClassification::ResolvedDelete => return Ok(MergeOutcome::Delete),
188        SharedClassification::ResolvedIdentical => {
189            // Return the first non-None content.
190            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 => {} // fall through
206    }
207
208    // Step 3: k-way diff3 fold.
209    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/// Pre-diff3 classification of a shared path.
231///
232/// This is the pure decision tree extracted from [`resolve_shared_path`]. It
233/// determines the merge outcome category without performing any I/O, allocation,
234/// or subprocess calls. The Kani proof harnesses verify algebraic properties
235/// (commutativity, idempotence, conflict monotonicity) directly on this function.
236#[derive(Clone, Copy, Debug, PartialEq, Eq)]
237pub enum SharedClassification {
238    /// All workspaces deleted the same path → resolved delete.
239    ResolvedDelete,
240    /// All non-delete variants have identical content → resolved upsert.
241    ResolvedIdentical,
242    /// Mix of delete and non-delete changes → conflict.
243    ConflictModifyDelete,
244    /// A non-delete entry has no content → conflict.
245    ConflictMissingContent,
246    /// All adds, different content, no base → conflict.
247    ConflictAddAddDifferent,
248    /// Different content, no base, not all adds → conflict.
249    ConflictMissingBase,
250    /// Different content with base available → needs diff3.
251    NeedsDiff3,
252}
253
254impl SharedClassification {
255    /// Whether this classification produces a definite outcome (not NeedsDiff3).
256    #[must_use]
257    pub const fn is_definite(self) -> bool {
258        !matches!(self, Self::NeedsDiff3)
259    }
260
261    /// Whether this classification is a conflict.
262    #[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
274/// Classify a shared path based on change kinds, content equality, and base
275/// presence.
276///
277/// This mirrors the decision tree in [`resolve_shared_path`] exactly:
278///
279/// 1. All deletions → [`ResolvedDelete`](SharedClassification::ResolvedDelete)
280/// 2. Mixed delete + non-delete → [`ConflictModifyDelete`](SharedClassification::ConflictModifyDelete)
281/// 3. Missing content on a non-delete → [`ConflictMissingContent`](SharedClassification::ConflictMissingContent)
282/// 4. All content equal → [`ResolvedIdentical`](SharedClassification::ResolvedIdentical)
283/// 5. No base, all adds → [`ConflictAddAddDifferent`](SharedClassification::ConflictAddAddDifferent)
284/// 6. No base, not all adds → [`ConflictMissingBase`](SharedClassification::ConflictMissingBase)
285/// 7. Has base → [`NeedsDiff3`](SharedClassification::NeedsDiff3)
286pub fn classify_shared_path(
287    kinds: &[ChangeKind],
288    all_have_content: bool,
289    all_content_equal: bool,
290    has_base: bool,
291) -> SharedClassification {
292    // Step 1: all deletions.
293    if kinds.iter().all(|k| matches!(k, ChangeKind::Deleted)) {
294        return SharedClassification::ResolvedDelete;
295    }
296
297    // Step 2: mixed delete + non-delete.
298    let has_delete = kinds.iter().any(|k| matches!(k, ChangeKind::Deleted));
299    if has_delete {
300        return SharedClassification::ConflictModifyDelete;
301    }
302
303    // Step 3: missing content on a non-delete entry.
304    if !all_have_content {
305        return SharedClassification::ConflictMissingContent;
306    }
307
308    // Step 4: all content equal (hash equality short-circuit).
309    if all_content_equal {
310        return SharedClassification::ResolvedIdentical;
311    }
312
313    // Step 5/6: no base.
314    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    // Step 7: different content, base available.
322    SharedClassification::NeedsDiff3
323}
324
325/// Output of the RESOLVE phase.
326#[derive(Clone, Debug, PartialEq, Eq)]
327pub struct ResolveResult {
328    /// Changes that were resolved automatically and can be fed to BUILD.
329    pub resolved: Vec<ResolvedChange>,
330    /// Paths that still need manual resolution.
331    pub conflicts: Vec<ConflictRecord>,
332}
333
334impl ResolveResult {
335    /// Returns `true` if there are no conflicts.
336    #[must_use]
337    pub const fn is_clean(&self) -> bool {
338        self.conflicts.is_empty()
339    }
340}
341
342/// Errors from invoking external diff3 tooling.
343#[derive(Debug)]
344pub enum ResolveError {
345    /// I/O while writing temp files or spawning commands.
346    Io(std::io::Error),
347    /// `git merge-file` failed unexpectedly.
348    GitCommand {
349        /// Command line summary.
350        command: String,
351        /// Trimmed stderr.
352        stderr: String,
353        /// Exit code if available.
354        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
396/// Resolve all paths in a partition result.
397///
398/// `base_contents` maps file paths to epoch-base content for files that existed
399/// at the merge base. Missing entries mean the path did not exist in the base.
400///
401/// Determinism:
402/// - shared entries are expected to be sorted by path + workspace ID by
403///   `partition_by_path`
404/// - output `resolved` and `conflicts` are sorted by path
405pub 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    // Unique paths: direct passthrough to BUILD changes.
413    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    // Shared paths: apply hash-equality / diff3 strategy.
439    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/// Resolve all paths in a partition result with AST-aware merge support.
457///
458/// Like [`resolve_partition`] but with an additional AST merge configuration.
459/// When AST merge is enabled for a language and diff3 fails, the AST merge
460/// layer is tried before emitting a conflict.
461///
462/// The merge pipeline order for shared paths is:
463/// 1. Hash equality
464/// 2. diff3 line merge
465/// 3. AST-aware merge (if enabled for the file's language)
466/// 4. Emit structured conflict
467#[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    // Unique paths: same as resolve_partition.
477    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    // Shared paths: apply hash-equality / diff3 / AST merge strategy.
503    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    // Use blob OID equality as a fast path before falling into the generic
531    // algebra. The generic resolve_entries only sees content bytes, so we
532    // check OID equality here and feed the result through.
533    let blob_eq = all_blobs_equal(entries);
534
535    // Build inputs for the generic algebra.
536    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    // If blob OIDs all match, override content equality to avoid byte comparison.
540    // We do this by checking here and, if true, substituting identical content
541    // so the generic function sees equal values.
542    let effective_contents = if blob_eq && contents.iter().filter(|c| c.is_some()).count() >= 2 {
543        // All blobs equal — make the generic function see identical content.
544        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    // Run through the generic merge algebra (classification + k-way fold).
554    // This is the function Kani verifies.
555    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            // Try diff3, then shifted-alignment retry.
561            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    // Map the generic outcome to concrete types with path/conflict details.
577    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            // For Diff3Conflict, we lost the marker output / atoms by going
595            // through the generic path. Re-run diff3 to recover them for the
596            // conflict record. This only happens on the conflict path, so the
597            // cost is negligible.
598            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/// Resolve a shared path with AST-aware merge as fallback after diff3.
615///
616/// Pipeline: hash eq → diff3 → AST merge → conflict.
617/// If AST merge is not enabled for this path's language, falls back to diff3 conflict.
618#[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    // delete/delete[/...] => resolved delete
627    if entries.iter().all(PathEntry::is_deletion) {
628        return Ok(SharedOutcome::Resolved(ResolvedChange::Delete {
629            path: path.to_path_buf(),
630        }));
631    }
632
633    // Any deletion mixed with non-deletion => modify/delete conflict.
634    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    // Remaining cases are all non-deletions; gather bytes.
647    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    // Hash equality short-circuit.
662    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    // Without base, differing non-delete variants are add/add.
670    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    // Try diff3 first.
686    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 succeeded, return the merge.
712    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    // diff3 failed. Try AST merge if enabled for this language.
720    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                // Use AST conflict atoms instead of diff3 atoms for better diagnostics.
736                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                // Fall through to diff3 conflict.
746            }
747        }
748    }
749
750    // Fall back to diff3 conflict.
751    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
762/// Re-run the k-way diff3 fold to recover conflict markers and parse atoms.
763///
764/// Called only on the conflict path when `resolve_entries` returns
765/// `MergeOutcome::Conflict(Diff3Conflict)`. The generic function doesn't
766/// carry marker output, so we re-run diff3 here to get it.
767fn 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
812/// Returns `true` if **all** entries carry a blob OID and all OIDs are equal.
813///
814/// When this function returns `true`, all entries have identical content by
815/// git's content-addressed guarantee — no byte comparison is needed. If any
816/// entry is missing a blob OID, returns `false` and the caller falls back to
817/// [`all_equal`] (byte comparison).
818fn 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
852/// Outcome of a single diff3 merge attempt.
853enum Diff3Outcome {
854    /// Clean merge — result bytes.
855    Clean(Vec<u8>),
856    /// Conflicting merge — the raw diff3 marker output (stdout from git merge-file exit 1).
857    Conflict { marker_output: Vec<u8> },
858}
859
860/// Run `git merge-file -p --diff3` for one 3-way merge.
861///
862/// Returns:
863/// - `Ok(Diff3Outcome::Clean(bytes))` for clean merge (exit 0)
864/// - `Ok(Diff3Outcome::Conflict { marker_output })` for conflicts (exit 1)
865/// - `Err` for command/runtime failures
866///
867/// Under Kani, subprocess execution is not possible. The stub returns `Clean`
868/// when inputs are byte-equal and `Conflict` otherwise — sufficient for
869/// verifying merge algebra properties (commutativity, idempotence, conflict
870/// monotonicity) without the real diff3 implementation.
871#[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/// Run `git merge-file -p --diff3` for one 3-way merge.
887///
888/// Returns:
889/// - `Ok(Diff3Outcome::Clean(bytes))` for clean merge (exit 0)
890/// - `Ok(Diff3Outcome::Conflict { marker_output })` for conflicts (exit 1)
891/// - `Err` for command/runtime failures
892#[cfg(not(kani))]
893fn diff3_merge_bytes(
894    base: &[u8],
895    ours: &[u8],
896    theirs: &[u8],
897) -> Result<Diff3Outcome, ResolveError> {
898    // We intentionally use temp files + git merge-file instead of adding a new
899    // diff3 crate dependency. BUILD/COMMIT already shell out to git, and this
900    // keeps behavior aligned with git's merge semantics.
901    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    // TempDir is dropped automatically here, cleaning up the directory.
921
922    match output.status.code() {
923        Some(0) => Ok(Diff3Outcome::Clean(output.stdout)),
924        // git merge-file exits with the number of conflict hunks (≥1) when
925        // there are conflicts. Any positive exit code means "conflict output in
926        // stdout with diff3 markers". Negative codes or None indicate an error.
927        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
938/// Retry a diff3 merge after normalizing shifted block positions.
939///
940/// Returns:
941/// - `Ok(Some(merged_bytes))` if normalization enabled a clean merge
942/// - `Ok(None)` if no normalization opportunity was detected, or retry still conflicts
943/// - `Err(..)` if invoking diff3 fails
944fn 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 neither side could be normalized, don't retry.
953    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/// Detect moved/shifted paragraph-like blocks and reorder them toward base order.
967///
968/// Heuristic details:
969/// - Split into blocks separated by one or more blank lines.
970/// - Hash each block and anchor blocks that are unique in both base and variant.
971/// - Assign each variant block a sortable rank derived from nearest anchored base blocks.
972/// - Reassemble blocks in ranked order.
973///
974/// This is O(b log b) where b = number of blocks in the file.
975#[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
1094/// Split text into paragraph-like blocks separated by blank lines.
1095fn 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/// Parse diff3 conflict marker output and extract [`ConflictAtom`]s.
1116///
1117/// Each conflict block in the diff3 output (delimited by `<<<<<<<`, `|||||||`,
1118/// `=======`, `>>>>>>>`) is converted into one [`ConflictAtom`] with:
1119/// - `base_region`: the line range in the base file covered by this block
1120/// - `edits`: two [`AtomEdit`]s — one per workspace side
1121/// - `reason`: [`ModelConflictReason::OverlappingLineEdits`]
1122///
1123/// Base line positions are computed by counting context lines and completed
1124/// base sections as they appear in the output.
1125///
1126/// # Marker format (`git merge-file --diff3`)
1127///
1128/// ```text
1129/// <context lines>
1130/// <<<<<<< ours
1131/// <ours content>
1132/// ||||||| base
1133/// <base content>
1134/// =======
1135/// <theirs content>
1136/// >>>>>>> theirs
1137/// <context lines>
1138/// ```
1139///
1140/// `ws_ours` and `ws_theirs` are the workspace ID strings used to label each
1141/// side's [`AtomEdit`].
1142#[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    // 1-indexed position in the base file, advancing as we consume context
1161    // and completed base sections.
1162    let mut base_line: u32 = 1;
1163
1164    // Per-block accumulators.
1165    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            // Start of a new conflict block.
1175            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            // Transition: ours → base section.
1182            state = Diff3ParseState::Base;
1183        } else if *line == "=======" && state == Diff3ParseState::Base {
1184            // Transition: base → theirs section.
1185            state = Diff3ParseState::Theirs;
1186        } else if line.starts_with(">>>>>>>") && state == Diff3ParseState::Theirs {
1187            // End of conflict block — build the atom.
1188            let base_len = usize_to_u32(base_lines.len());
1189            // The base section covers [block_base_start, block_base_start + base_len).
1190            // If the base section is empty (pure insertion conflict), the region
1191            // is a zero-length marker at the insertion point.
1192            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            // For the AtomEdit regions we use the base region as an approximation.
1205            // Exact workspace-version line numbers would require tracking per-file
1206            // offsets across multiple conflict blocks, which is Phase 2 work.
1207            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            // Advance base_line past the base section just consumed.
1228            base_line += base_len;
1229            state = Diff3ParseState::Context;
1230        } else {
1231            // Accumulate or count lines based on current state.
1232            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        // Each change is separated by 4+ unchanged context lines so git
1397        // merge-file treats them as independent hunks.
1398        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        // Each change is separated by 4+ unchanged context lines so git
1434        // merge-file treats them as independent hunks.
1435        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        // ws-a moved `three` to the top.
1482        let moved = b"fn three() {\n}\n\nfn one() {\n}\n\nfn two() {\n}\n";
1483        // ws-b edited `two` in place.
1484        let edited = b"fn one() {\n}\n\nfn two() {\n    println!(\"2\");\n}\n\nfn three() {\n}\n";
1485
1486        // Bare diff3 conflicts on this shifted-code fixture.
1487        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        // Anchored functions should be restored to base-relative order.
1524        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        // Inserted helper block should be preserved.
1531        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            // Move + in-place edit: bare diff3 conflict, alignment retry should resolve.
1540            (
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            // Non-overlapping edits are already clean under bare diff3.
1545            (
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            // Identical edits are clean in both paths.
1550            (
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    // -----------------------------------------------------------------------
1614    // ConflictAtom extraction tests (bd-15yn.3 acceptance criteria)
1615    // -----------------------------------------------------------------------
1616
1617    /// Overlapping edits on the same line produce a `ConflictRecord` with ≥1 atoms.
1618    #[test]
1619    fn overlapping_edits_produce_conflict_with_atoms() {
1620        // Base: "a\nb\nc\n" (3 lines). ws-a changes line 2 to B1, ws-b to B2.
1621        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        // Must have exactly one ConflictAtom for the single conflicting hunk.
1638        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        // The base line that conflicted is line 2 ("b").
1647        // Region::lines uses exclusive end, so lines(2, 3) = line 2 only.
1648        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        // Atom must have two edits (one per workspace).
1655        assert_eq!(atom.edits.len(), 2);
1656
1657        // Edits carry the correct workspace labels.
1658        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        // Edits carry the correct content.
1669        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    /// `ConflictAtoms` match the actual conflicting line region in the base file.
1676    #[test]
1677    fn diff3_atoms_have_correct_line_ranges() {
1678        // Base: 5 lines. Lines 3-4 are the conflict zone.
1679        // ws-a and ws-b both edit lines 3-4 differently.
1680        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        // The conflicting region in base spans lines 3 and 4 (1-indexed).
1702        // Region::lines uses exclusive end: lines(3, 5) = lines 3..5 = lines 3 and 4.
1703        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        // The reason must be OverlappingLineEdits.
1711        assert_eq!(
1712            atom.reason.variant_name(),
1713            "overlapping_line_edits",
1714            "reason should be overlapping_line_edits"
1715        );
1716    }
1717
1718    /// Multiple conflict blocks in the same file produce one atom per block.
1719    #[test]
1720    fn multiple_conflicts_in_same_file_produce_multiple_atoms() {
1721        // Base: 5 lines. Lines 2 and 4 are each independently conflicted.
1722        // Need enough context (≥3 lines) between hunks for git merge-file to
1723        // treat them as separate hunks.
1724        let base = b"ctx\na\nctx\nctx\nctx\nb\nctx\n";
1725        // ws-a edits lines 2 and 6 (1-indexed); ws-b edits same lines differently.
1726        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        // Two separate conflict hunks → two atoms.
1745        assert_eq!(
1746            record.atoms.len(),
1747            2,
1748            "expected 2 atoms (one per conflict hunk), got {:?}",
1749            record.atoms
1750        );
1751
1752        // Atoms should be for distinct base line ranges.
1753        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    /// Non-diff3 conflicts (add/add, modify/delete) have empty atoms.
1761    #[test]
1762    fn non_diff3_conflicts_have_empty_atoms() {
1763        // add/add
1764        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        // modify/delete
1779        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    // -----------------------------------------------------------------------
1797    // parse_diff3_atoms unit tests
1798    // -----------------------------------------------------------------------
1799
1800    /// Parser handles a single conflict block with correct workspace labels.
1801    #[test]
1802    fn parse_diff3_atoms_single_block() {
1803        // Simulated diff3 output for base="b\n", ours="B1\n", theirs="B2\n"
1804        // with context "a\n" before and "c\n" after.
1805        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        // "a\n" is context (line 1 in base). The base section "b\n" is line 2.
1813        // Region: lines(2, 3) = just line 2.
1814        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    /// Parser extracts one atom per conflict block when multiple blocks appear.
1829    #[test]
1830    fn parse_diff3_atoms_multiple_blocks() {
1831        // Two conflict blocks separated by 3+ context lines.
1832        // Base: ctx(1), a(2), ctx(3), ctx(4), ctx(5), b(6), ctx(7)
1833        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        // First block: base section = "a" at line 2 (after 1 context line).
1860        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        // Second block: base section = "b" at line 6
1884        // After block 1: base_line = 2 + 1(base-len) = 3
1885        // ctx(3) → 4, ctx(4) → 5, ctx(5) → 6: block_base_start = 6
1886        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    /// Parser returns empty vec for marker output with no conflict blocks.
1911    #[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    /// K=2 workspace labels appear correctly in atom edits.
1919    #[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        // "alice" is first in lexicographic order (ours), "bob" is theirs.
1943        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    // -----------------------------------------------------------------------
1954    // Phase 3: blob OID equality short-circuit
1955    // -----------------------------------------------------------------------
1956
1957    /// Helper: build a `PathEntry` with a blob OID for hash-equality tests.
1958    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    /// When all entries share the same blob OID, the resolve should short-circuit
1975    /// immediately (no byte comparison or diff3 needed).
1976    #[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    /// Blob OID equality works for K=3 (all three have the same blob).
1997    #[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    /// If blob OIDs differ, fall through to byte comparison / diff3.
2031    #[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        // Non-overlapping edits should still auto-resolve via diff3.
2056        assert!(
2057            result.is_clean(),
2058            "non-overlapping edits should auto-resolve"
2059        );
2060    }
2061
2062    /// If one entry is missing a blob OID, fall back to byte comparison.
2063    #[test]
2064    fn missing_blob_oid_falls_back_to_byte_equality() {
2065        // ws-a has a blob OID, ws-b does not — same content.
2066        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                // No blob OID — falls back to byte comparison.
2076                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        // Byte equality still resolves cleanly.
2083        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    /// `all_blobs_equal` returns `true` for a single-entry slice.
2091    #[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        // Single-entry shared path: should be caught earlier by partition,
2100        // but verify the helper handles edge case gracefully.
2101        assert!(all_blobs_equal(&entries));
2102    }
2103
2104    /// `all_blobs_equal` returns `false` when any entry has no blob OID.
2105    #[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")), // no blob
2110        ];
2111        assert!(!all_blobs_equal(&entries));
2112    }
2113
2114    /// `all_blobs_equal` returns `false` when OIDs differ.
2115    #[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    // -----------------------------------------------------------------------
2125    // AST-enhanced resolve pipeline integration tests
2126    // -----------------------------------------------------------------------
2127
2128    #[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        /// AST merge resolves overlapping-line edits in different functions.
2142        ///
2143        /// diff3 reports a conflict because lines overlap (adjacent changes),
2144        /// but AST merge sees the edits are in different `function_items`.
2145        #[test]
2146        fn ast_resolves_different_functions_where_diff3_fails() {
2147            // Two functions back-to-back with no separating context.
2148            // diff3 will conflict because the changes are too close together.
2149            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            // First verify: plain resolve_partition conflicts.
2154            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            // Now try with AST merge enabled.
2167            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                // diff3 conflicted — AST merge should resolve it.
2173                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            // If diff3 resolved cleanly (enough context), AST merge should also resolve cleanly.
2193        }
2194
2195        /// AST merge produces conflict atoms with `AstNode` regions when same function is modified.
2196        #[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            // Both ws-a and ws-b modify the same function — should conflict.
2216            assert_eq!(result.conflicts.len(), 1);
2217            let record = &result.conflicts[0];
2218            // Should have AST-level atoms.
2219            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        /// AST merge is not used when disabled in config.
2233        #[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            // With no languages enabled, AST merge should not be tried.
2250            let no_ast_config = AstMergeConfig::default();
2251            let result = resolve_partition_with_ast(&partition, &base_map, &no_ast_config).unwrap();
2252
2253            // Should get the same result as plain resolve_partition.
2254            let plain_result = resolve_partition(&partition, &base_map).unwrap();
2255            assert_eq!(result.is_clean(), plain_result.is_clean());
2256        }
2257
2258        /// AST merge is not used for unsupported file extensions.
2259        #[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}