Skip to main content

sley_diff_merge/
merge_trees.rs

1//! Three-way tree merge engine.
2
3use sley_core::{BString, GitError, ObjectFormat, ObjectId, Result};
4use sley_index::{is_gitlink, is_symlink_mode};
5use sley_object::{Commit, EncodedObject, ObjectType, Tree, TreeEntries, TreeEntry};
6use sley_odb::{FileObjectDatabase, ObjectReader, ObjectWriter};
7use std::collections::{BTreeMap, BTreeSet};
8use std::path::Path;
9use std::sync::Arc;
10
11use crate::blob_merge::{
12    merge_blobs, ConflictStyle, MergeBlobOptions, MergeBlobResult, MergeFavor,
13};
14use crate::line_diff::WsIgnore;
15use crate::name_status::{
16    diff_name_status_maps_with_renames, flatten_tree, is_type_change, DiffNameStatusOptions,
17    MergeEntryMap, NameStatus, NameStatusEntry, TrackedEntry, DEFAULT_RENAME_THRESHOLD,
18};
19
20// ===========================================================================
21// Library tree-merge seam (`merge_trees`).
22//
23// This is the single 3-way tree-merge engine that every merge porcelain calls.
24// Before it existed the logic was duplicated across the CLI: `merge-tree
25// --write-tree` had its own copy and `git merge` / `cherry-pick` / `revert`
26// had a second copy. Both copies implemented the identical per-path diff3
27// resolution; the only differences were *rendering* (write-tree emits a tree +
28// stage list + messages; the porcelains stage an index + materialize a
29// worktree). This seam computes the merge once and returns a per-path result
30// rich enough for both renderings, so the resolution lives in exactly one
31// place.
32//
33// The result is byte-identical to the old per-command copies on every cell
34// they already handled (clean merges, content / add-add / modify-delete
35// conflicts, mode merges). On top of that it adds rename-aware resolution: a
36// file renamed on one side and modified on the other follows the rename,
37// gated by [`MergeTreesOptions::detect_renames`] (the classic merge-ort
38// non-recursive rename case).
39// ===========================================================================
40
41/// Options controlling a [`merge_trees`] run.
42pub struct MergeTreesOptions<'a> {
43    /// Conflict-marker label for ours (e.g. a branch name or `HEAD`).
44    pub ours_label: &'a str,
45    /// Conflict-marker label for theirs.
46    pub theirs_label: &'a str,
47    /// Diff3 ancestor label (the `|||||||` side); merge porcelains use
48    /// `"merged common ancestors"`.
49    pub ancestor_label: &'a str,
50    /// `-Xours` / `-Xtheirs` favouring for textual conflicts.
51    pub favor: MergeFavor,
52    /// Optional per-path favor, used only when [`Self::favor`] is
53    /// [`MergeFavor::None`]. Merge porcelains use this for attributes such as
54    /// `merge=union` without changing the command-line `-X` override.
55    pub path_favor: Option<&'a dyn Fn(&[u8]) -> MergeFavor>,
56    /// Optional per-path conflict marker length resolver for attributes such as
57    /// `conflict-marker-size`.
58    pub path_marker_size: Option<&'a dyn Fn(&[u8]) -> usize>,
59    /// Enable rename-aware merging: a file renamed on one side and modified on
60    /// the other follows the rename. When `false`, the merge is purely
61    /// path-keyed (the historical behaviour).
62    pub detect_renames: bool,
63    /// Minimum similarity (`0..=100`) for inexact rename detection.
64    pub rename_threshold: u8,
65    /// Cap on the inexact rename matrix (`merge.renameLimit`/`diff.renameLimit`).
66    /// `0` means unlimited; otherwise inexact detection is skipped when the
67    /// candidate source × destination count exceeds `rename_limit²`.
68    pub rename_limit: usize,
69    /// Directory-rename detection mode. When [`DirectoryRenames::False`], a file
70    /// added on one side under a directory that the *other* side renamed stays
71    /// put. When enabled, such files are re-homed into the renamed directory,
72    /// matching `merge.directoryRenames`. Requires `detect_renames` to have any
73    /// effect (directory renames are inferred from the file renames it finds).
74    pub directory_renames: DirectoryRenames,
75    /// Conflict-marker style for textual conflicts (`merge.conflictStyle`).
76    pub style: ConflictStyle,
77    /// Whitespace-insensitivity for textual 3-way merges, mirroring
78    /// `-Xignore-space-change`/`-Xignore-all-space`/`-Xignore-space-at-eol`.
79    pub ws_ignore: WsIgnore,
80}
81
82/// How directory-rename detection behaves, mirroring git's
83/// `merge.directoryRenames` configuration.
84#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
85pub enum DirectoryRenames {
86    /// Disable directory-rename detection (`merge.directoryRenames=false`).
87    #[default]
88    False,
89    /// Apply directory renames silently (`merge.directoryRenames=true`).
90    True,
91    /// Detect directory renames but treat each re-homed path as a conflict
92    /// requiring confirmation (`merge.directoryRenames=conflict`). git's default.
93    Conflict,
94}
95
96impl Default for MergeTreesOptions<'_> {
97    fn default() -> Self {
98        Self {
99            ours_label: "ours",
100            theirs_label: "theirs",
101            ancestor_label: "merged common ancestors",
102            favor: MergeFavor::None,
103            path_favor: None,
104            path_marker_size: None,
105            detect_renames: false,
106            rename_threshold: DEFAULT_RENAME_THRESHOLD,
107            rename_limit: 0,
108            directory_renames: DirectoryRenames::False,
109            style: ConflictStyle::Merge,
110            ws_ignore: WsIgnore::EMPTY,
111        }
112    }
113}
114
115fn merge_favor_for_path(options: &MergeTreesOptions<'_>, path: &[u8]) -> MergeFavor {
116    if options.favor != MergeFavor::None {
117        return options.favor;
118    }
119    options
120        .path_favor
121        .map(|resolver| resolver(path))
122        .unwrap_or(MergeFavor::None)
123}
124
125fn merge_marker_size_for_path(options: &MergeTreesOptions<'_>, path: &[u8]) -> usize {
126    options
127        .path_marker_size
128        .map(|resolver| resolver(path))
129        .unwrap_or(7)
130}
131
132/// The kind of conflict recorded for a path, used to render the stable
133/// conflict-type token and human message.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub enum MergeConflictKind {
136    /// Both sides changed the file content differently (or both added it with
137    /// differing content — an add/add).
138    Content { add_add: bool },
139    /// The file was deleted on one side and modified on the other.
140    ModifyDelete {
141        /// The side label that deleted the path.
142        deleted_in: String,
143        /// The side label that modified (and thus kept) the path.
144        modified_in: String,
145    },
146    /// A file renamed on one side, with a content conflict against the other
147    /// side's change at the destination.
148    RenameContent {
149        /// The original (pre-rename) path.
150        old_path: Vec<u8>,
151    },
152    /// Two paths were renamed to the same destination, producing a
153    /// rename/rename(2to1) conflict.
154    RenameRenameTwoToOne {
155        /// Ours' pre-destination path.
156        ours_path: Vec<u8>,
157        /// Theirs' pre-destination path.
158        theirs_path: Vec<u8>,
159    },
160    /// One source path was renamed to different destinations on each side,
161    /// producing a rename/rename(1to2) conflict.
162    RenameRenameOneToTwo {
163        /// The pre-rename source path.
164        old_path: Vec<u8>,
165        /// Ours' destination path.
166        ours_path: Vec<u8>,
167        /// Theirs' destination path.
168        theirs_path: Vec<u8>,
169        /// The label for our side.
170        ours_label: String,
171        /// The label for their side.
172        theirs_label: String,
173    },
174    /// An auxiliary higher-stage entry for a rename/rename(1to2) conflict. The
175    /// user-facing message is emitted by [`RenameRenameOneToTwo`].
176    RenameRenameOneToTwoStage,
177    /// A directory was split evenly across multiple destinations, so no
178    /// directory rename could be applied for paths the other side left there.
179    DirRenameSplit {
180        /// The original directory with no unique destination.
181        source_dir: Vec<u8>,
182    },
183    /// A file renamed on one side whose source was deleted on the other side.
184    RenameDelete {
185        /// The pre-rename source path.
186        old_path: Vec<u8>,
187        /// The side label that performed the rename.
188        renamed_in: String,
189        /// The side label that deleted the source.
190        deleted_in: String,
191    },
192    /// A file collides with a directory at the same path in the merged result:
193    /// the directory wins at the original path and the file is moved aside to
194    /// `path~<branch>` (merge-ort's D/F conflict, `unique_path`). git emits
195    /// `CONFLICT (file/directory): directory in the way of <old> from <branch>;
196    /// moving it to <new> instead.`
197    FileDirectory {
198        /// The original (pre-move) path now occupied by the directory.
199        original_path: Vec<u8>,
200        /// The side label whose file was moved aside.
201        moved_from: String,
202    },
203    /// A path was added/renamed under a directory the other side renamed, so the
204    /// merge silently moved it into the renamed directory but, in
205    /// `merge.directoryRenames=conflict` mode, flags it for the user to confirm.
206    /// git emits `CONFLICT (file location): ... suggesting it should perhaps be
207    /// moved to <new_path>.` The tree still contains the re-homed content.
208    DirRenameLocation {
209        /// The pre-re-home path (`old_path` in git's message): where the side
210        /// placed the file before directory-rename detection moved it.
211        old_path: Vec<u8>,
212        /// `Some(source)` when the file was *renamed* into `old_path` by this
213        /// side (git's "renamed to" wording, naming the original `source`);
214        /// `None` when it was a fresh add (git's "added in" wording).
215        renamed_from: Option<Vec<u8>>,
216        /// The side label that added/renamed the file (`branch_with_new_path`).
217        added_in: String,
218        /// The side label that renamed the directory (`branch_with_dir_rename`).
219        dir_renamed_in: String,
220        /// True when the directory rename moved the file back onto its own base
221        /// source path (rename-to-self) and the other side modified that path. The
222        /// `CONFLICT (file location)` message is the same, but git records the
223        /// path UNMERGED (stages 1/2/3) instead of staging the re-homed content
224        /// cleanly: the index writers stage these 1/2/3, not at stage 0.
225        back_to_self: bool,
226    },
227    /// A directory rename would have moved one or more paths onto this path, but
228    /// it is already occupied (a file/dir in the way) or several sources map
229    /// here. git emits `CONFLICT (implicit dir rename): Existing file/dir at
230    /// <path> in the way of implicit directory rename(s) putting the following
231    /// path(s) there: <sources>.` The path keeps its original content; the
232    /// re-homed sources are left where they were.
233    DirRenameImplicitCollision {
234        /// The source path(s) the directory rename tried to move onto this path.
235        sources: Vec<Vec<u8>>,
236    },
237    /// The two sides hold different object types at one path (regular↔symlink,
238    /// regular↔gitlink, symlink↔gitlink). git's `process_entry` (merge-ort.c
239    /// ~4220) renames each *regular-file* side to `path~<branch>` so each type
240    /// can be recorded somewhere, ignoring `-Xours`/`-Xtheirs`, and emits a
241    /// single `CONFLICT (distinct types)` line. (gitlink↔gitlink and
242    /// symlink↔symlink share an `S_IFMT` and never reach this arm.) This kind is
243    /// attached to the leaf that carries the message — the side left at
244    /// `original_path` when only one side moved, else ours; the other renamed
245    /// leaf carries [`DistinctTypesStage`].
246    DistinctTypes {
247        /// The original colliding path (git's message subject and sort key).
248        original_path: Vec<u8>,
249        /// `Some(p)` when ours was renamed aside to `p`; `None` when ours stayed
250        /// at `original_path`.
251        ours_renamed: Option<Vec<u8>>,
252        /// `Some(p)` when theirs was renamed aside to `p`; `None` when theirs
253        /// stayed at `original_path`.
254        theirs_renamed: Option<Vec<u8>>,
255    },
256    /// The non-message-carrying leaf of a [`DistinctTypes`] conflict. The
257    /// user-facing line is emitted once by the [`DistinctTypes`] leaf.
258    DistinctTypesStage,
259}
260
261/// One resolved/conflicted path in the merged tree.
262#[derive(Debug, Clone)]
263pub struct MergedPath {
264    /// Destination path in the merged tree.
265    pub path: Vec<u8>,
266    /// The per-stage (1=base, 2=ours, 3=theirs) entries when conflicted; all
267    /// `None` for a clean resolution.
268    pub stages: MergeStages,
269    /// `Some((mode, oid))` is the final leaf written to the merged tree; `None`
270    /// means the path is absent in the result (a clean delete).
271    pub result: Option<(u32, ObjectId)>,
272    /// When conflicted, the worktree bytes + mode to materialize (content with
273    /// conflict markers, or the surviving side's bytes). `None` for a clean
274    /// path.
275    pub worktree: Option<(u32, Vec<u8>)>,
276    /// `Some(..)` exactly when this path conflicted.
277    pub conflict: Option<MergeConflictKind>,
278    /// True when this path went through a textual 3-way content merge (both
279    /// sides diverged and both were mergeable files). Drives the "Auto-merging
280    /// <path>" informational message, which `git merge-tree` emits for every
281    /// such path — clean or conflicted.
282    pub auto_merged: bool,
283}
284
285impl MergedPath {
286    /// True when this path resolved cleanly (no conflict recorded).
287    pub fn is_clean(&self) -> bool {
288        self.conflict.is_none()
289    }
290}
291
292/// Per-stage higher-order index entries for a conflicted path.
293#[derive(Debug, Clone, Default)]
294pub struct MergeStages {
295    pub base: Option<(u32, ObjectId)>,
296    pub ours: Option<(u32, ObjectId)>,
297    pub theirs: Option<(u32, ObjectId)>,
298}
299
300/// The outcome of a 3-way tree merge: the merged top-level tree plus per-path
301/// detail and a clean/conflicted flag.
302#[derive(Debug, Clone)]
303pub struct MergeTreesResult {
304    /// Object id of the merged top-level tree (always written, even on
305    /// conflict — conflicted blobs go in with their marker content).
306    pub tree: ObjectId,
307    /// Per-path results, sorted by path.
308    pub paths: Vec<MergedPath>,
309    /// False if any path conflicted.
310    pub clean: bool,
311    /// Original paths removed by rename or directory-rename rewrites. These are
312    /// cleanup-only paths for porcelains materializing a conflicted merge; they
313    /// are absent from the merged tree.
314    pub cleanup_paths: Vec<Vec<u8>>,
315    /// Non-conflict informational messages produced while detecting renames.
316    pub info_messages: Vec<MergeInfoMessage>,
317}
318
319impl MergeTreesResult {
320    /// Iterate over the paths that conflicted, in path order.
321    pub fn conflicts(&self) -> impl Iterator<Item = &MergedPath> {
322        self.paths.iter().filter(|entry| entry.conflict.is_some())
323    }
324}
325
326/// Non-conflict merge information that porcelain commands may print.
327#[derive(Debug, Clone, PartialEq, Eq)]
328pub enum MergeInfoMessage {
329    /// A directory rename was skipped because the suggested target directory was
330    /// itself renamed away on this side.
331    DirRenameSkippedDueToRerename {
332        old_dir: Vec<u8>,
333        path: Vec<u8>,
334        new_dir: Vec<u8>,
335    },
336    /// A path was updated due to a directory rename in
337    /// `merge.directoryRenames=true` mode.
338    DirRenameApplied {
339        old_path: Vec<u8>,
340        new_path: Vec<u8>,
341        renamed_from: Option<Vec<u8>>,
342        added_in: String,
343        dir_renamed_in: String,
344    },
345    /// A directory-rename location conflict that overlaps another conflict at
346    /// the same final path, such as a content conflict. The path's primary
347    /// conflict kind remains attached to the path; this carries git's extra
348    /// `CONFLICT (file location)` line.
349    DirRenameLocationConflict {
350        old_path: Vec<u8>,
351        new_path: Vec<u8>,
352        renamed_from: Option<Vec<u8>>,
353        added_in: String,
354        dir_renamed_in: String,
355    },
356    /// A rename/delete conflict whose conflicted destination was later moved
357    /// aside by directory/file conflict handling. The primary per-path conflict
358    /// remains `FileDirectory`; this preserves git's extra rename/delete line.
359    RenameDeleteConflict {
360        old_path: Vec<u8>,
361        new_path: Vec<u8>,
362        renamed_in: String,
363        deleted_in: String,
364    },
365}
366
367
368/// True for a plain file blob (regular or executable) — i.e. a mode whose
369/// content can be textually 3-way merged. Symlinks and gitlinks are excluded.
370pub fn is_mergeable_file_mode(mode: u32) -> bool {
371    mode == 0o100644 || mode == 0o100755
372}
373
374/// 3-way merge of three trees into a single merged tree.
375///
376/// `base` is the common-ancestor tree (`None` for unrelated histories — every
377/// path is then treated as added on both sides). `ours`/`theirs` are the two
378/// sides. Cleanly-merged blob content and the resulting (sub)trees are written
379/// to `db`; the returned [`MergeTreesResult`] carries the merged top-level tree
380/// oid plus per-path detail.
381///
382/// This is the shared engine behind `git merge-tree --write-tree`, `git merge`,
383/// `git cherry-pick`, and `git revert`. It is behaviour-preserving relative to
384/// the per-command copies it replaced, and additionally resolves renames when
385/// [`MergeTreesOptions::detect_renames`] is set.
386pub fn merge_trees(
387    db: &FileObjectDatabase,
388    format: ObjectFormat,
389    base: Option<&ObjectId>,
390    ours: &ObjectId,
391    theirs: &ObjectId,
392    options: &MergeTreesOptions<'_>,
393) -> Result<MergeTreesResult> {
394    let base_map = match base {
395        Some(tree) => flatten_tree(db, format, tree)?,
396        None => MergeEntryMap::new(),
397    };
398    let ours_map = flatten_tree(db, format, ours)?;
399    let theirs_map = flatten_tree(db, format, theirs)?;
400    merge_entry_maps(db, format, &base_map, &ours_map, &theirs_map, options)
401}
402
403/// [`merge_trees`] operating on already-flattened entry maps. The merge
404/// porcelains often hold the flattened maps already (e.g. cherry-pick builds
405/// `theirs` from a picked commit's tree), so this avoids re-reading them.
406pub fn merge_entry_maps(
407    db: &FileObjectDatabase,
408    format: ObjectFormat,
409    base_map: &MergeEntryMap,
410    ours_map: &MergeEntryMap,
411    theirs_map: &MergeEntryMap,
412    options: &MergeTreesOptions<'_>,
413) -> Result<MergeTreesResult> {
414    // Rename-aware step: detect files renamed on exactly one side relative to
415    // base, so a modification on the other side follows the rename. This is the
416    // non-recursive merge-ort rename case. We compute a rewrite map that, for a
417    // one-sided rename old->new, presents the *other* side's `old` content at
418    // `new` (and drops `old`), letting the path-keyed core below do the 3-way
419    // content merge at the destination.
420    let (mut renames, side_renames) = if options.detect_renames {
421        let (renames, ours_side, theirs_side) =
422            detect_merge_renames(db, format, base_map, ours_map, theirs_map, options)?;
423        (renames, Some((ours_side, theirs_side)))
424    } else {
425        (MergeRenames::default(), None)
426    };
427
428    // Build the effective per-side maps with file renames applied.
429    let (mut eff_base, mut eff_ours, mut eff_theirs) =
430        apply_merge_renames(base_map, ours_map, theirs_map, &renames);
431
432    // Directory-rename detection: when one side renamed a whole directory and
433    // the other side added a file under (or renamed a file into) the old
434    // directory, re-home that path into the renamed directory — including
435    // transitive renames (a file the other side renamed into a directory this
436    // side renamed follows on into the final directory). This is the
437    // merge.directoryRenames behaviour, applied as a rewrite of the rename/add
438    // destination paths so every merged path consults directory renames.
439    let mut dir_rename_dirty = false;
440    let mut rehomed_paths: BTreeMap<Vec<u8>, RehomeSides> = BTreeMap::new();
441    let mut dir_rename_two_to_one: Vec<DirRenameTwoToOne> = Vec::new();
442    let mut dir_rename_collisions: Vec<DirRenameCollision> = Vec::new();
443    let mut dir_rename_splits: BTreeSet<Vec<u8>> = BTreeSet::new();
444    let mut dir_rename_back_to_self: BTreeSet<Vec<u8>> = BTreeSet::new();
445    let mut info_messages = Vec::new();
446    let mut cleanup_paths: BTreeSet<Vec<u8>> = renames
447        .dest_to_source
448        .values()
449        .map(|rename| rename.source.clone())
450        .collect();
451    if options.directory_renames != DirectoryRenames::False
452        && let Some((ours_side, theirs_side)) = &side_renames
453    {
454        let dir_renames = compute_directory_renames(ours_map, theirs_map, ours_side, theirs_side);
455        let outcome = apply_directory_renames(
456            base_map,
457            &eff_base,
458            &eff_ours,
459            &eff_theirs,
460            ours_side,
461            theirs_side,
462            &dir_renames,
463            &renames.dest_to_source,
464        );
465        eff_base = outcome.base;
466        eff_ours = outcome.ours;
467        eff_theirs = outcome.theirs;
468        rehomed_paths = outcome.rehomed;
469        dir_rename_collisions = outcome.collisions;
470        dir_rename_splits = outcome.splits;
471        dir_rename_back_to_self = outcome.back_to_self;
472        info_messages = outcome.info_messages;
473        dir_rename_dirty = outcome.dirty;
474        remap_rename_destinations(&mut renames, &rehomed_paths);
475        drop_collapsed_rename_rename_conflicts(&mut renames);
476        dir_rename_two_to_one = collect_dir_rename_two_to_one(&renames, &rehomed_paths);
477    }
478    for info in rehomed_paths
479        .values()
480        .flat_map(|sides| [&sides.ours, &sides.theirs])
481        .flatten()
482    {
483        cleanup_paths.insert(info.old_path.clone());
484    }
485    if options.directory_renames == DirectoryRenames::True {
486        for (dest, sides) in &rehomed_paths {
487            for info in [&sides.ours, &sides.theirs].into_iter().flatten() {
488                let (added_in, dir_renamed_in) = if info.added_on_ours {
489                    (
490                        options.ours_label.to_string(),
491                        options.theirs_label.to_string(),
492                    )
493                } else {
494                    (
495                        options.theirs_label.to_string(),
496                        options.ours_label.to_string(),
497                    )
498                };
499                info_messages.push(MergeInfoMessage::DirRenameApplied {
500                    old_path: info.old_path.clone(),
501                    new_path: dest.clone(),
502                    renamed_from: info.renamed_from.clone(),
503                    added_in,
504                    dir_renamed_in,
505                });
506            }
507        }
508    }
509    // In =conflict mode, every re-homed path is reported as a location conflict
510    // (the tree still gets the re-homed content, but the merge is marked dirty).
511    let dir_rename_conflict_paths: BTreeMap<Vec<u8>, RehomeSides> =
512        if options.directory_renames == DirectoryRenames::Conflict {
513            rehomed_paths.clone()
514        } else {
515            BTreeMap::new()
516        };
517
518    let mut all_paths = BTreeSet::new();
519    all_paths.extend(eff_base.keys().cloned());
520    all_paths.extend(eff_ours.keys().cloned());
521    all_paths.extend(eff_theirs.keys().cloned());
522
523    let mut paths: Vec<MergedPath> = Vec::new();
524    let mut leaves: MergeEntryMap = BTreeMap::new();
525    let mut clean = true;
526
527    for path in all_paths {
528        let base = eff_base.get(&path).cloned();
529        let ours = eff_ours.get(&path).cloned();
530        let theirs = eff_theirs.get(&path).cloned();
531        let rename = renames.dest_to_source.get(&path);
532        let old_path = rename.map(|r| r.source.clone());
533        let favor = merge_favor_for_path(options, &path);
534
535        // Trivial resolutions (identical to the historical per-command logic).
536        if ours == theirs {
537            if let Some(entry) = ours {
538                leaves.insert(path.clone(), entry);
539            }
540            paths.push(clean_path(path, ours));
541            continue;
542        }
543        if ours == base {
544            if let Some(entry) = &theirs {
545                leaves.insert(path.clone(), *entry);
546            }
547            paths.push(clean_path(path, theirs));
548            continue;
549        }
550        if theirs == base {
551            if let Some(entry) = &ours {
552                leaves.insert(path.clone(), *entry);
553            }
554            paths.push(clean_path(path, ours));
555            continue;
556        }
557
558        // Both sides diverged. Decide how to combine.
559        let content_mergeable = matches!(&ours, Some((mode, _)) if is_mergeable_file_mode(*mode))
560            && matches!(&theirs, Some((mode, _)) if is_mergeable_file_mode(*mode))
561            && match &base {
562                Some((mode, _)) => is_mergeable_file_mode(*mode),
563                None => true,
564            };
565
566        if let (true, Some((ours_mode, ours_oid)), Some((theirs_mode, theirs_oid))) =
567            (content_mergeable, &ours, &theirs)
568        {
569            let add_add = base.is_none();
570            let base_bytes = match &base {
571                Some((_, oid)) => merge_blob_bytes(db, oid)?,
572                None => Vec::new(),
573            };
574            let ours_bytes = merge_blob_bytes(db, ours_oid)?;
575            let theirs_bytes = merge_blob_bytes(db, theirs_oid)?;
576            // When this destination came from a one-sided rename, git qualifies
577            // the conflict-marker labels with the per-side path (the renaming
578            // side shows the new path, the other side the old path), e.g.
579            // `<<<<<<< HEAD:old.txt` / `>>>>>>> feature:new.txt`.
580            let rehome = rehomed_paths.get(&path);
581            // git's `merge_3way` qualifies all three labels with their per-side
582            // path (`<name>:<path>`) whenever the three paths are not identical —
583            // pathnames[0] is the base/ancestor path (the rename source). When
584            // they are identical (no rename), it uses the bare names.
585            let (base_label_owned, ours_label, theirs_label) = match rename {
586                Some(MergeRename { source, side }) => {
587                    let (ours_path, theirs_path) = match side {
588                        // theirs renamed -> ours kept the source path.
589                        RenameSide::Theirs => (source.as_slice(), path.as_slice()),
590                        // ours renamed -> theirs kept the source path.
591                        RenameSide::Ours => (path.as_slice(), source.as_slice()),
592                    };
593                    (
594                        qualify_label(options.ancestor_label, source.as_slice()),
595                        qualify_label(options.ours_label, ours_path),
596                        qualify_label(options.theirs_label, theirs_path),
597                    )
598                }
599                None => {
600                    let ours_path = rehome
601                        .and_then(|info| info.ours.as_ref())
602                        .map_or(path.as_slice(), |info| info.old_path.as_slice());
603                    let theirs_path = rehome
604                        .and_then(|info| info.theirs.as_ref())
605                        .map_or(path.as_slice(), |info| info.old_path.as_slice());
606                    if ours_path != path.as_slice() || theirs_path != path.as_slice() {
607                        (
608                            qualify_label(options.ancestor_label, path.as_slice()),
609                            qualify_label(options.ours_label, ours_path),
610                            qualify_label(options.theirs_label, theirs_path),
611                        )
612                    } else {
613                        (
614                            options.ancestor_label.to_string(),
615                            options.ours_label.to_string(),
616                            options.theirs_label.to_string(),
617                        )
618                    }
619                }
620            };
621            let result = merge_blobs(
622                &base_bytes,
623                &ours_bytes,
624                &theirs_bytes,
625                &MergeBlobOptions {
626                    ours_label: &ours_label,
627                    theirs_label: &theirs_label,
628                    base_label: &base_label_owned,
629                    style: options.style,
630                    favor,
631                    ws_ignore: options.ws_ignore,
632                    marker_size: merge_marker_size_for_path(options, &path),
633                },
634            );
635
636            let base_mode = base.as_ref().map(|(mode, _)| *mode);
637            let (resolved_mode, mode_conflict) =
638                merge_file_modes(base_mode, *ours_mode, *theirs_mode);
639
640            if !result.conflicted && !mode_conflict {
641                let oid = db.write_object(EncodedObject::new(ObjectType::Blob, result.content))?;
642                leaves.insert(path.clone(), (resolved_mode, oid));
643                paths.push(clean_path_auto(path, Some((resolved_mode, oid)), true));
644            } else if favor != MergeFavor::None && !mode_conflict {
645                let chosen = if favor == MergeFavor::Ours {
646                    ours
647                } else {
648                    theirs
649                };
650                if let Some(entry) = chosen {
651                    leaves.insert(path.clone(), entry);
652                }
653                paths.push(clean_path_auto(path, chosen, true));
654            } else {
655                clean = false;
656                let oid =
657                    db.write_object(EncodedObject::new(ObjectType::Blob, result.content.clone()))?;
658                leaves.insert(path.clone(), (resolved_mode, oid));
659                let worktree_mode = if *ours_mode == *theirs_mode {
660                    *ours_mode
661                } else {
662                    0o100644
663                };
664                let conflict = if let Some(old) = &old_path {
665                    MergeConflictKind::RenameContent {
666                        old_path: old.clone(),
667                    }
668                } else if add_add {
669                    match rehome.and_then(|info| Some((info.ours.as_ref()?, info.theirs.as_ref()?)))
670                    {
671                        Some((ours_info, theirs_info)) => MergeConflictKind::RenameRenameTwoToOne {
672                            ours_path: ours_info.old_path.clone(),
673                            theirs_path: theirs_info.old_path.clone(),
674                        },
675                        None => MergeConflictKind::Content { add_add },
676                    }
677                } else {
678                    MergeConflictKind::Content { add_add }
679                };
680                paths.push(MergedPath {
681                    path: path.clone(),
682                    stages: stages_for(&base, &ours, &theirs),
683                    result: Some((resolved_mode, oid)),
684                    worktree: Some((worktree_mode, result.content)),
685                    conflict: Some(conflict),
686                    auto_merged: true,
687                });
688            }
689        } else if base.is_some() && (ours.is_none() || theirs.is_none()) {
690            // modify/delete.
691            clean = false;
692            let (deleted_in, modified_in, surviving) = if ours.is_none() {
693                (
694                    options.ours_label.to_string(),
695                    options.theirs_label.to_string(),
696                    theirs,
697                )
698            } else {
699                (
700                    options.theirs_label.to_string(),
701                    options.ours_label.to_string(),
702                    ours,
703                )
704            };
705            let worktree = match &surviving {
706                Some((mode, oid)) => Some((*mode, merge_worktree_bytes(db, *mode, oid)?)),
707                None => None,
708            };
709            if let Some(entry) = surviving {
710                leaves.insert(path.clone(), entry);
711            }
712            paths.push(MergedPath {
713                path: path.clone(),
714                stages: stages_for(&base, &ours, &theirs),
715                result: surviving,
716                worktree,
717                conflict: Some(MergeConflictKind::ModifyDelete {
718                    deleted_in,
719                    modified_in,
720                }),
721                auto_merged: false,
722            });
723        } else if let (Some(&(ours_mode, ours_oid)), Some(&(theirs_mode, theirs_oid))) =
724            (ours.as_ref(), theirs.as_ref())
725            && sley_index::is_symlink_mode(ours_mode)
726            && sley_index::is_symlink_mode(theirs_mode)
727        {
728            // Both sides are symlinks that diverged from the base and from each
729            // other (the trivial oid resolutions above already took the agreeing
730            // cases). A symlink is never textually merged; git's
731            // `handle_content_merge` symlink arm (merge-ort.c) resolves CLEAN to
732            // a side under `-Xours`/`-Xtheirs`, and otherwise records a CONFLICT
733            // carrying ours' target.
734            match favor {
735                MergeFavor::Ours => {
736                    leaves.insert(path.clone(), (ours_mode, ours_oid));
737                    paths.push(clean_path_auto(
738                        path.clone(),
739                        Some((ours_mode, ours_oid)),
740                        false,
741                    ));
742                }
743                MergeFavor::Theirs => {
744                    leaves.insert(path.clone(), (theirs_mode, theirs_oid));
745                    paths.push(clean_path_auto(
746                        path.clone(),
747                        Some((theirs_mode, theirs_oid)),
748                        false,
749                    ));
750                }
751                MergeFavor::None | MergeFavor::Union => {
752                    clean = false;
753                    leaves.insert(path.clone(), (ours_mode, ours_oid));
754                    let worktree =
755                        Some((ours_mode, merge_worktree_bytes(db, ours_mode, &ours_oid)?));
756                    paths.push(MergedPath {
757                        path: path.clone(),
758                        stages: stages_for(&base, &ours, &theirs),
759                        result: Some((ours_mode, ours_oid)),
760                        worktree,
761                        conflict: Some(MergeConflictKind::Content {
762                            add_add: base.is_none(),
763                        }),
764                        auto_merged: false,
765                    });
766                }
767            }
768        } else if let (Some((ours_mode, ours_oid)), Some((theirs_mode, theirs_oid))) =
769            (ours, theirs)
770            && is_type_change(ours_mode, theirs_mode)
771        {
772            // Distinct types at one path: both sides present with different
773            // `S_IFMT` (regular↔symlink, regular↔gitlink, symlink↔gitlink).
774            // Mirror merge-ort's `process_entry`: rename each regular-file side
775            // to `path~<branch>` so each type is recorded somewhere; ignore
776            // `-Xours`/`-Xtheirs`. gitlink↔gitlink and symlink↔symlink share an
777            // `S_IFMT` and are handled by the arms above.
778            clean = false;
779            // git renames the regular-file side(s): only the regular side when
780            // exactly one is regular, both when neither is (symlink↔gitlink).
781            let (rename_ours, rename_theirs) = if is_mergeable_file_mode(ours_mode) {
782                (true, false)
783            } else if is_mergeable_file_mode(theirs_mode) {
784                (false, true)
785            } else {
786                (true, true)
787            };
788            // git keeps the base stage (index stage 1) for a side only when that
789            // side shares the base's file type.
790            let ours_base = base.filter(|(mode, _)| !is_type_change(*mode, ours_mode));
791            let theirs_base = base.filter(|(mode, _)| !is_type_change(*mode, theirs_mode));
792            // Name and reserve ours' aside-path first so the two renamed paths
793            // can never collide (`unique_df_path` consults `leaves`/`paths`).
794            let ours_path = if rename_ours {
795                unique_df_path(&path, options.ours_label, &leaves, &paths)
796            } else {
797                path.clone()
798            };
799            leaves.insert(ours_path.clone(), (ours_mode, ours_oid));
800            let theirs_path = if rename_theirs {
801                unique_df_path(&path, options.theirs_label, &leaves, &paths)
802            } else {
803                path.clone()
804            };
805            leaves.insert(theirs_path.clone(), (theirs_mode, theirs_oid));
806
807            // The message is emitted once, by the leaf left at `original_path`
808            // when only one side moved (matching git's keying), else by ours.
809            let ours_carries_message = !rename_ours || rename_theirs;
810            let distinct = MergeConflictKind::DistinctTypes {
811                original_path: path.clone(),
812                ours_renamed: rename_ours.then(|| ours_path.clone()),
813                theirs_renamed: rename_theirs.then(|| theirs_path.clone()),
814            };
815            let ours_worktree = Some((ours_mode, merge_worktree_bytes(db, ours_mode, &ours_oid)?));
816            paths.push(MergedPath {
817                path: ours_path,
818                stages: MergeStages {
819                    base: ours_base,
820                    ours: Some((ours_mode, ours_oid)),
821                    theirs: None,
822                },
823                result: Some((ours_mode, ours_oid)),
824                worktree: ours_worktree,
825                conflict: Some(if ours_carries_message {
826                    distinct.clone()
827                } else {
828                    MergeConflictKind::DistinctTypesStage
829                }),
830                auto_merged: false,
831            });
832            let theirs_worktree = Some((
833                theirs_mode,
834                merge_worktree_bytes(db, theirs_mode, &theirs_oid)?,
835            ));
836            paths.push(MergedPath {
837                path: theirs_path,
838                stages: MergeStages {
839                    base: theirs_base,
840                    ours: None,
841                    theirs: Some((theirs_mode, theirs_oid)),
842                },
843                result: Some((theirs_mode, theirs_oid)),
844                worktree: theirs_worktree,
845                conflict: Some(if ours_carries_message {
846                    MergeConflictKind::DistinctTypesStage
847                } else {
848                    distinct
849                }),
850                auto_merged: false,
851            });
852        } else {
853            // add/add of non-files, mode changes on same-type entries, etc. Keep
854            // the surviving side's content and record a generic content conflict.
855            clean = false;
856            let add_add = base.is_none();
857            let surviving = ours.or(theirs);
858            let worktree = match &surviving {
859                Some((mode, oid)) => Some((*mode, merge_worktree_bytes(db, *mode, oid)?)),
860                None => None,
861            };
862            if let Some(entry) = surviving {
863                leaves.insert(path.clone(), entry);
864            }
865            paths.push(MergedPath {
866                path: path.clone(),
867                stages: stages_for(&base, &ours, &theirs),
868                result: surviving,
869                worktree,
870                conflict: Some(MergeConflictKind::Content { add_add }),
871                auto_merged: false,
872            });
873        }
874    }
875
876    if !renames.rename_rename_one_to_two.is_empty() {
877        apply_rename_rename_one_to_two_conflicts(
878            db,
879            base_map,
880            &eff_ours,
881            &eff_theirs,
882            &renames.rename_rename_one_to_two,
883            &mut paths,
884            &mut leaves,
885            options,
886        )?;
887        clean = false;
888    }
889
890    if !dir_rename_two_to_one.is_empty() {
891        apply_dir_rename_two_to_one_conflicts(
892            db,
893            &eff_ours,
894            &eff_theirs,
895            &dir_rename_two_to_one,
896            &mut paths,
897            &mut leaves,
898            options,
899        )?;
900        clean = false;
901    }
902
903    // Rename/rename(2to1) and rename/add: two distinct contents collide on one
904    // destination (and the rename source(s) are consumed). Detected from the full
905    // per-side rename sets, applied here so the destination carries both sides'
906    // content-merged stages instead of the path-keyed core's raw add/add.
907    if !renames.rename_rename_two_to_one.is_empty() || !renames.rename_adds.is_empty() {
908        apply_rename_two_to_one_and_add_conflicts(
909            db,
910            base_map,
911            ours_map,
912            theirs_map,
913            &renames,
914            &mut paths,
915            &mut leaves,
916            options,
917        )?;
918        clean = false;
919    }
920
921    // Rename/delete conflicts: a file renamed on one side whose source the other
922    // side deleted. The merge core resolved the destination cleanly (only the
923    // renaming side has it), but git flags this as a conflict — keep the renamed
924    // content in the tree, record higher-order stages, and mark the merge dirty.
925    if !renames.rename_deletes.is_empty() {
926        for (dest, rd) in &renames.rename_deletes {
927            // Skip if another conflict already claimed this destination.
928            let Some(slot) = paths.iter_mut().find(|p| &p.path == dest) else {
929                continue;
930            };
931            if slot.conflict.is_some() {
932                continue;
933            }
934            let base_entry = base_map.get(&rd.source).copied();
935            let renamed_entry = slot.result;
936            // The renamed content sits on the renaming side; the deleting side
937            // contributes no stage at the destination.
938            let (ours_stage, theirs_stage) = match rd.side {
939                RenameSide::Ours => (renamed_entry, None),
940                RenameSide::Theirs => (None, renamed_entry),
941            };
942            let (renamed_in, deleted_in) = match rd.side {
943                RenameSide::Ours => (
944                    options.ours_label.to_string(),
945                    options.theirs_label.to_string(),
946                ),
947                RenameSide::Theirs => (
948                    options.theirs_label.to_string(),
949                    options.ours_label.to_string(),
950                ),
951            };
952            let worktree = match &renamed_entry {
953                Some((mode, oid)) => Some((*mode, merge_worktree_bytes(db, *mode, oid)?)),
954                None => None,
955            };
956            slot.stages = MergeStages {
957                base: base_entry,
958                ours: ours_stage,
959                theirs: theirs_stage,
960            };
961            slot.worktree = worktree;
962            slot.conflict = Some(MergeConflictKind::RenameDelete {
963                old_path: rd.source.clone(),
964                renamed_in,
965                deleted_in,
966            });
967            clean = false;
968        }
969    }
970
971    // Directory-rename outcomes that make the merge dirty. A collision/split
972    // detected while re-homing (two paths onto one destination, an ambiguous
973    // split source, or a file in the way) marks the merge unclean regardless of
974    // mode. In =conflict mode, every silently re-homed path is *also* reported
975    // as a location conflict: the tree keeps the re-homed content but git wants
976    // the user to confirm the suggested move.
977    if dir_rename_dirty {
978        clean = false;
979    }
980    // Implicit-directory-rename collisions (a directory rename would put a path
981    // onto an existing file/dir, or N paths onto one destination). git emits
982    // `CONFLICT (implicit dir rename): Existing file/dir at <dest> in the way ...`
983    // regardless of mode, and the merge is unclean. Attach the conflict to the
984    // blocked destination path (which keeps its original content).
985    for collision in &dir_rename_collisions {
986        clean = false;
987        if let Some(slot) = paths.iter_mut().find(|p| p.path == collision.dest)
988            && slot.conflict.is_none()
989        {
990            slot.conflict = Some(MergeConflictKind::DirRenameImplicitCollision {
991                sources: collision.sources.clone(),
992            });
993        } else if !paths.iter().any(|p| p.path == collision.dest) {
994            paths.push(MergedPath {
995                path: collision.dest.clone(),
996                stages: MergeStages::default(),
997                result: None,
998                worktree: None,
999                conflict: Some(MergeConflictKind::DirRenameImplicitCollision {
1000                    sources: collision.sources.clone(),
1001                }),
1002                auto_merged: false,
1003            });
1004        }
1005    }
1006    for source_dir in &dir_rename_splits {
1007        clean = false;
1008        paths.push(MergedPath {
1009            path: source_dir.clone(),
1010            stages: MergeStages::default(),
1011            result: None,
1012            worktree: None,
1013            conflict: Some(MergeConflictKind::DirRenameSplit {
1014                source_dir: source_dir.clone(),
1015            }),
1016            auto_merged: false,
1017        });
1018    }
1019    if !dir_rename_conflict_paths.is_empty() {
1020        clean = false;
1021        for (dest, infos) in &dir_rename_conflict_paths {
1022            for info in [&infos.ours, &infos.theirs].into_iter().flatten() {
1023                let (added_in, dir_renamed_in) = if info.added_on_ours {
1024                    // The path was added/renamed by ours, into a dir theirs renamed.
1025                    (
1026                        options.ours_label.to_string(),
1027                        options.theirs_label.to_string(),
1028                    )
1029                } else {
1030                    (
1031                        options.theirs_label.to_string(),
1032                        options.ours_label.to_string(),
1033                    )
1034                };
1035                // Rename-to-self via a directory rename (merge-ort 12i2): the
1036                // re-home landed the file back on its own base source path where
1037                // the other side modified it. git records this UNMERGED (UU) even
1038                // though the trivial 3-way at the destination resolves cleanly
1039                // (the renamed side's content equals base). Stage the three
1040                // versions so the index carries the conflict.
1041                let back_to_self = dir_rename_back_to_self.contains(dest);
1042                if let Some(slot) = paths.iter_mut().find(|p| &p.path == dest)
1043                    && slot.conflict.is_none()
1044                {
1045                    if back_to_self {
1046                        slot.stages = MergeStages {
1047                            base: eff_base.get(dest).copied(),
1048                            ours: eff_ours.get(dest).copied(),
1049                            theirs: eff_theirs.get(dest).copied(),
1050                        };
1051                        slot.worktree = match &slot.result {
1052                            Some((mode, oid)) => {
1053                                Some((*mode, merge_worktree_bytes(db, *mode, oid)?))
1054                            }
1055                            None => slot.worktree.clone(),
1056                        };
1057                    }
1058                    slot.conflict = Some(MergeConflictKind::DirRenameLocation {
1059                        old_path: info.old_path.clone(),
1060                        renamed_from: info.renamed_from.clone(),
1061                        added_in,
1062                        dir_renamed_in,
1063                        back_to_self,
1064                    });
1065                } else {
1066                    info_messages.push(MergeInfoMessage::DirRenameLocationConflict {
1067                        old_path: info.old_path.clone(),
1068                        new_path: dest.clone(),
1069                        renamed_from: info.renamed_from.clone(),
1070                        added_in,
1071                        dir_renamed_in,
1072                    });
1073                }
1074            }
1075        }
1076    }
1077
1078    // Directory/file (D/F) conflict resolution (merge-ort `process_entry`): a
1079    // path that ends up as a *file* in the merged result while another result
1080    // path lives *under* it (so the path is simultaneously a directory) cannot
1081    // coexist. git keeps the directory at the original path and moves the file
1082    // aside to `path~<branch>` via `unique_path`, where `<branch>` is the side
1083    // that contributed the file. We resolve this on the flattened `leaves` after
1084    // every per-path decision is made, so renames/dir-renames have settled first.
1085    resolve_directory_file_conflicts(
1086        db,
1087        &mut paths,
1088        &mut leaves,
1089        &mut clean,
1090        &eff_ours,
1091        &eff_theirs,
1092        options,
1093        &mut info_messages,
1094    )?;
1095
1096    let tree = write_merged_tree(db, &leaves)?;
1097
1098    cleanup_paths.retain(|path| !leaves.contains_key(path));
1099
1100    Ok(MergeTreesResult {
1101        tree,
1102        paths,
1103        clean,
1104        cleanup_paths: cleanup_paths.into_iter().collect(),
1105        info_messages,
1106    })
1107}
1108
1109/// Flatten a branch label the way git's `add_flattened_path` does for
1110/// `unique_path`: any `/` in the branch name becomes `_` so the synthesized
1111/// `path~branch` stays a single path component family.
1112fn flatten_branch_label(branch: &str) -> String {
1113    branch.replace('/', "_")
1114}
1115
1116/// Pick a `path~<branch>` name not already present in `leaves` (or claimed by an
1117/// existing `paths` entry), mirroring merge-ort's `unique_path`: start from
1118/// `path~branch`, then append `_0`, `_1`, … on collision.
1119fn unique_df_path(
1120    path: &[u8],
1121    branch: &str,
1122    leaves: &MergeEntryMap,
1123    paths: &[MergedPath],
1124) -> Vec<u8> {
1125    let mut base = path.to_vec();
1126    base.push(b'~');
1127    base.extend_from_slice(flatten_branch_label(branch).as_bytes());
1128    let taken = |candidate: &[u8]| {
1129        leaves.contains_key(candidate) || paths.iter().any(|p| p.path == candidate)
1130    };
1131    if !taken(&base) {
1132        return base;
1133    }
1134    let mut suffix = 0usize;
1135    loop {
1136        let mut candidate = base.clone();
1137        candidate.push(b'_');
1138        candidate.extend_from_slice(suffix.to_string().as_bytes());
1139        if !taken(&candidate) {
1140            return candidate;
1141        }
1142        suffix += 1;
1143    }
1144}
1145
1146/// Resolve directory/file collisions in the merged leaf set. For every file leaf
1147/// whose path is also a directory (some other leaf lives under `path/`), move the
1148/// file to `path~<branch>` and record a [`MergeConflictKind::FileDirectory`].
1149#[allow(clippy::too_many_arguments)]
1150fn resolve_directory_file_conflicts(
1151    db: &FileObjectDatabase,
1152    paths: &mut Vec<MergedPath>,
1153    leaves: &mut MergeEntryMap,
1154    clean: &mut bool,
1155    eff_ours: &MergeEntryMap,
1156    eff_theirs: &MergeEntryMap,
1157    options: &MergeTreesOptions<'_>,
1158    info_messages: &mut Vec<MergeInfoMessage>,
1159) -> Result<()> {
1160    // A path is a "directory" in the result iff some leaf key has it as a strict
1161    // `path/` prefix. Collect every such directory prefix once.
1162    let mut directory_prefixes: BTreeSet<Vec<u8>> = BTreeSet::new();
1163    for key in leaves.keys() {
1164        let mut idx = 0;
1165        while let Some(pos) = key[idx..].iter().position(|b| *b == b'/') {
1166            let end = idx + pos;
1167            directory_prefixes.insert(key[..end].to_vec());
1168            idx = end + 1;
1169        }
1170    }
1171    if directory_prefixes.is_empty() {
1172        return Ok(());
1173    }
1174
1175    // File leaves that collide with a directory of the same name.
1176    let colliding: Vec<Vec<u8>> = leaves
1177        .keys()
1178        .filter(|key| directory_prefixes.contains(*key))
1179        .cloned()
1180        .collect();
1181
1182    for original in colliding {
1183        let Some(entry) = leaves.remove(&original) else {
1184            continue;
1185        };
1186        // The moved-aside file must be materialized in the worktree at its new
1187        // path; read its blob bytes once so the porcelain has worktree content.
1188        let moved_bytes = merge_worktree_bytes(db, entry.0, &entry.1)?;
1189        // Which side contributed the file? git keys off `dirmask`: the file lives
1190        // on the side that is NOT the directory. We read it off the effective side
1191        // maps — whichever side has this path as a plain file. When only theirs has
1192        // it, use the theirs label; otherwise (ours has it, or both do) ours wins,
1193        // matching git's index-1 bias for the moved-aside name.
1194        let ours_has_file = eff_ours.contains_key(&original);
1195        let theirs_has_file = eff_theirs.contains_key(&original);
1196        let from_ours = ours_has_file || !theirs_has_file;
1197        let branch = if from_ours {
1198            options.ours_label
1199        } else {
1200            options.theirs_label
1201        };
1202        let new_path = unique_df_path(&original, branch, leaves, paths);
1203        leaves.insert(new_path.clone(), entry);
1204        *clean = false;
1205
1206        // Relocate the path's MergedPath: update its destination and stamp the D/F
1207        // conflict. If the path had no MergedPath (defensive), synthesize one.
1208        if let Some(slot) = paths.iter_mut().find(|p| p.path == original) {
1209            if let Some(MergeConflictKind::RenameDelete {
1210                old_path,
1211                renamed_in,
1212                deleted_in,
1213            }) = &slot.conflict
1214            {
1215                info_messages.push(MergeInfoMessage::RenameDeleteConflict {
1216                    old_path: old_path.clone(),
1217                    new_path: original.clone(),
1218                    renamed_in: renamed_in.clone(),
1219                    deleted_in: deleted_in.clone(),
1220                });
1221            }
1222            slot.path = new_path.clone();
1223            slot.result = Some(entry);
1224            // Preserve any pre-existing higher-order stages; a clean file leaf has
1225            // none, so seed ours/theirs from the effective maps for `ls-files -u`.
1226            if slot.stages.base.is_none()
1227                && slot.stages.ours.is_none()
1228                && slot.stages.theirs.is_none()
1229            {
1230                slot.stages = MergeStages {
1231                    base: None,
1232                    ours: if from_ours { Some(entry) } else { None },
1233                    theirs: if from_ours { None } else { Some(entry) },
1234                };
1235            }
1236            // Keep the slot's existing `auto_merged`: git only emits
1237            // `Auto-merging <new_path>` for the moved file when a real content
1238            // merge ran (a rename or both-sides change drives filemask>=6 through
1239            // handle_content_merge). A plain one-sided add (filemask 2/4) is moved
1240            // aside silently, so we must NOT force the flag on here.
1241            slot.worktree = Some((entry.0, moved_bytes));
1242            slot.conflict = Some(MergeConflictKind::FileDirectory {
1243                original_path: original.clone(),
1244                moved_from: branch.to_string(),
1245            });
1246        } else {
1247            paths.push(MergedPath {
1248                path: new_path.clone(),
1249                stages: MergeStages {
1250                    base: None,
1251                    ours: if from_ours { Some(entry) } else { None },
1252                    theirs: if from_ours { None } else { Some(entry) },
1253                },
1254                result: Some(entry),
1255                worktree: Some((entry.0, moved_bytes)),
1256                conflict: Some(MergeConflictKind::FileDirectory {
1257                    original_path: original.clone(),
1258                    moved_from: branch.to_string(),
1259                }),
1260                auto_merged: false,
1261            });
1262        }
1263    }
1264
1265    // Keep `paths` sorted by destination path (callers and tests assume order).
1266    paths.sort_by(|a, b| a.path.cmp(&b.path));
1267    Ok(())
1268}
1269
1270/// Construct a clean (non-conflicted) [`MergedPath`].
1271fn clean_path(path: Vec<u8>, result: Option<(u32, ObjectId)>) -> MergedPath {
1272    clean_path_auto(path, result, false)
1273}
1274
1275/// Like [`clean_path`] but records whether the path went through a textual
1276/// 3-way content merge (for the "Auto-merging" message).
1277fn clean_path_auto(
1278    path: Vec<u8>,
1279    result: Option<(u32, ObjectId)>,
1280    auto_merged: bool,
1281) -> MergedPath {
1282    MergedPath {
1283        path,
1284        stages: MergeStages::default(),
1285        result,
1286        worktree: None,
1287        conflict: None,
1288        auto_merged,
1289    }
1290}
1291
1292/// Snapshot the present stages for a conflicted path.
1293fn stages_for(
1294    base: &Option<(u32, ObjectId)>,
1295    ours: &Option<(u32, ObjectId)>,
1296    theirs: &Option<(u32, ObjectId)>,
1297) -> MergeStages {
1298    MergeStages {
1299        base: *base,
1300        ours: *ours,
1301        theirs: *theirs,
1302    }
1303}
1304
1305/// Read a blob's raw bytes, requiring it to be a blob object.
1306fn merge_blob_bytes(reader: &impl ObjectReader, oid: &ObjectId) -> Result<Vec<u8>> {
1307    let object = reader.read_object(oid)?;
1308    if object.object_type != ObjectType::Blob {
1309        return Err(GitError::InvalidObject(format!(
1310            "expected blob {}, found {}",
1311            oid,
1312            object.object_type.as_str()
1313        )));
1314    }
1315    Ok(object.body.clone())
1316}
1317
1318fn merge_worktree_bytes(reader: &impl ObjectReader, mode: u32, oid: &ObjectId) -> Result<Vec<u8>> {
1319    if sley_index::is_gitlink(mode) {
1320        Ok(Vec::new())
1321    } else {
1322        merge_blob_bytes(reader, oid)
1323    }
1324}
1325
1326/// 3-way merge of a file mode. Returns the resolved mode and whether the modes
1327/// conflict (both sides changed it to different non-base values).
1328fn merge_file_modes(base: Option<u32>, ours: u32, theirs: u32) -> (u32, bool) {
1329    if ours == theirs {
1330        return (ours, false);
1331    }
1332    match base {
1333        Some(base) if ours == base => (theirs, false),
1334        Some(base) if theirs == base => (ours, false),
1335        _ => (ours, true),
1336    }
1337}
1338
1339/// Build a top-level tree object from a flat map of `path -> (mode, oid)`
1340/// leaves, writing every (sub)tree object to `db`.
1341fn write_merged_tree(db: &FileObjectDatabase, leaves: &MergeEntryMap) -> Result<ObjectId> {
1342    let mut root = MergeTreeNode::default();
1343    for (path, (mode, oid)) in leaves {
1344        root.insert(path, *mode, *oid);
1345    }
1346    root.write(db)
1347}
1348
1349#[derive(Default)]
1350struct MergeTreeNode {
1351    blobs: BTreeMap<Vec<u8>, (u32, ObjectId)>,
1352    subtrees: BTreeMap<Vec<u8>, MergeTreeNode>,
1353}
1354
1355impl MergeTreeNode {
1356    fn insert(&mut self, path: &[u8], mode: u32, oid: ObjectId) {
1357        match path.iter().position(|byte| *byte == b'/') {
1358            Some(slash) => {
1359                let component = path[..slash].to_vec();
1360                let rest = &path[slash + 1..];
1361                self.subtrees
1362                    .entry(component)
1363                    .or_default()
1364                    .insert(rest, mode, oid);
1365            }
1366            None => {
1367                self.blobs.insert(path.to_vec(), (mode, oid));
1368            }
1369        }
1370    }
1371
1372    fn write(&self, db: &FileObjectDatabase) -> Result<ObjectId> {
1373        let mut entries: Vec<TreeEntry> = Vec::new();
1374        for (name, (mode, oid)) in &self.blobs {
1375            entries.push(TreeEntry {
1376                mode: *mode,
1377                name: BString::from(name.clone()),
1378                oid: *oid,
1379            });
1380        }
1381        for (name, subtree) in &self.subtrees {
1382            let oid = subtree.write(db)?;
1383            entries.push(TreeEntry {
1384                mode: 0o040000,
1385                name: BString::from(name.clone()),
1386                oid,
1387            });
1388        }
1389        entries.sort_by_key(merge_tree_sort_key);
1390        let tree = Tree { entries };
1391        db.write_object(EncodedObject::new(ObjectType::Tree, tree.write()))
1392    }
1393}
1394
1395fn merge_tree_sort_key(entry: &TreeEntry) -> Vec<u8> {
1396    let mut key = entry.name.as_bytes().to_vec();
1397    if entry.mode == 0o040000 {
1398        key.push(b'/');
1399    }
1400    key
1401}
1402
1403// --- Rename-aware non-recursive merge -------------------------------------
1404
1405/// Which side of the merge performed a rename.
1406#[derive(Clone, Copy, PartialEq, Eq)]
1407enum RenameSide {
1408    Ours,
1409    Theirs,
1410}
1411
1412/// One detected one-sided rename: its source path and which side renamed it.
1413#[derive(Clone)]
1414struct MergeRename {
1415    source: Vec<u8>,
1416    side: RenameSide,
1417}
1418
1419/// A file renamed on one side whose source was *deleted* on the other side — a
1420/// rename/delete conflict. git keeps the renamed content at the destination but
1421/// flags the merge as conflicted.
1422#[derive(Clone)]
1423struct RenameDelete {
1424    /// The pre-rename source path (deleted on the other side).
1425    source: Vec<u8>,
1426    /// Which side performed the rename (the other side deleted the source).
1427    side: RenameSide,
1428}
1429
1430/// The rename pairings discovered for one merge: which destination paths came
1431/// from which source path, and which side renamed (so the other side's change
1432/// can follow the rename and conflict labels can be path-qualified like git).
1433#[derive(Default)]
1434struct MergeRenames {
1435    /// One-sided renames keyed by *destination* path. Only renames where the
1436    /// OTHER side kept/modified the source in place are recorded (the case
1437    /// where the modification must follow the rename).
1438    dest_to_source: BTreeMap<Vec<u8>, MergeRename>,
1439    /// Rename/delete conflicts: a file renamed on one side whose source the
1440    /// other side deleted. Keyed by destination path.
1441    rename_deletes: BTreeMap<Vec<u8>, RenameDelete>,
1442    /// Rename/rename(1to2) conflicts keyed by source path.
1443    rename_rename_one_to_two: BTreeMap<Vec<u8>, RenameRenameOneToTwo>,
1444    /// Rename/rename(2to1) conflicts keyed by the shared *destination* path:
1445    /// ours renamed `ours_source`->dest and theirs renamed `theirs_source`->dest.
1446    rename_rename_two_to_one: BTreeMap<Vec<u8>, RenameRenameTwoToOne>,
1447    /// Rename/add conflicts keyed by *destination*: one side renamed a file to
1448    /// `dest` while the other side added a different file at the same `dest`.
1449    rename_adds: BTreeMap<Vec<u8>, RenameAdd>,
1450}
1451
1452#[derive(Clone)]
1453struct RenameRenameOneToTwo {
1454    ours_dest: Vec<u8>,
1455    theirs_dest: Vec<u8>,
1456}
1457
1458/// A rename/rename(2to1): two distinct sources renamed onto one destination, one
1459/// rename per side. Each side's content at the destination is the 3-way merge of
1460/// its rename (the other side's change to that source follows the rename).
1461#[derive(Clone)]
1462struct RenameRenameTwoToOne {
1463    /// The source ours renamed onto the destination.
1464    ours_source: Vec<u8>,
1465    /// The source theirs renamed onto the destination.
1466    theirs_source: Vec<u8>,
1467}
1468
1469/// A rename/add: one side renamed a file onto `dest`, the other side added an
1470/// unrelated file at `dest`. The renaming side's content is the 3-way merge of
1471/// its rename; the adding side contributes its added blob verbatim.
1472#[derive(Clone)]
1473struct RenameAdd {
1474    /// The pre-rename source path on the renaming side.
1475    source: Vec<u8>,
1476    /// Which side performed the rename (the other side added at `dest`).
1477    side: RenameSide,
1478}
1479
1480/// Every file rename observed on one side (base->side), as `(old, new)` pairs.
1481/// Unlike [`MergeRenames`] this is the *complete* rename set on a side — it is
1482/// the input to directory-rename inference, which needs to see all the per-file
1483/// moves between directories, not just the ones the other side kept in place.
1484struct SideRenames {
1485    pairs: Vec<(Vec<u8>, Vec<u8>)>,
1486}
1487
1488/// Detect one-sided renames usable for a non-recursive merge: a path present in
1489/// `base`, deleted on one side and present (renamed) at a new path on that same
1490/// side, while the OTHER side still has the original path (modified or
1491/// unchanged). Such a rename lets the other side's change move to the
1492/// destination.
1493///
1494/// Also returns the complete per-side rename set so the caller can infer
1495/// directory renames (which need every file move, not just the merge-relevant
1496/// ones).
1497fn detect_merge_renames(
1498    db: &FileObjectDatabase,
1499    format: ObjectFormat,
1500    base_map: &MergeEntryMap,
1501    ours_map: &MergeEntryMap,
1502    theirs_map: &MergeEntryMap,
1503    options: &MergeTreesOptions<'_>,
1504) -> Result<(MergeRenames, SideRenames, SideRenames)> {
1505    let mut renames = MergeRenames::default();
1506
1507    // Renames on ours: the other side that must carry its change is theirs.
1508    let ours_side = collect_side_renames(
1509        db,
1510        format,
1511        base_map,
1512        ours_map,
1513        theirs_map,
1514        RenameSide::Ours,
1515        options.rename_threshold,
1516        options.rename_limit,
1517        &mut renames,
1518    )?;
1519    // Renames on theirs: the other side that carries its change is ours.
1520    let theirs_side = collect_side_renames(
1521        db,
1522        format,
1523        base_map,
1524        theirs_map,
1525        ours_map,
1526        RenameSide::Theirs,
1527        options.rename_threshold,
1528        options.rename_limit,
1529        &mut renames,
1530    )?;
1531
1532    collect_rename_rename_one_to_two(&mut renames, &ours_side, &theirs_side);
1533    collect_rename_rename_two_to_one_and_adds(
1534        &mut renames,
1535        &ours_side,
1536        &theirs_side,
1537        base_map,
1538        ours_map,
1539        theirs_map,
1540    );
1541
1542    Ok((renames, ours_side, theirs_side))
1543}
1544
1545/// Detect rename/rename(2to1) and rename/add conflicts from the complete per-side
1546/// rename sets. Both arise when a one-sided rename's destination is *occupied* on
1547/// the other side (so [`collect_side_renames`] left it out of `dest_to_source`):
1548///
1549/// * 2to1 — both sides renamed (distinct sources) onto the same destination.
1550/// * rename/add — one side renamed onto a path the other side *added* fresh
1551///   (the destination is new to the other side, not a base path it kept and not
1552///   itself a rename destination on that side).
1553fn collect_rename_rename_two_to_one_and_adds(
1554    renames: &mut MergeRenames,
1555    ours_side: &SideRenames,
1556    theirs_side: &SideRenames,
1557    base_map: &MergeEntryMap,
1558    ours_map: &MergeEntryMap,
1559    theirs_map: &MergeEntryMap,
1560) {
1561    let ours_by_dest: BTreeMap<&[u8], &[u8]> = ours_side
1562        .pairs
1563        .iter()
1564        .map(|(old, new)| (new.as_slice(), old.as_slice()))
1565        .collect();
1566    let theirs_by_dest: BTreeMap<&[u8], &[u8]> = theirs_side
1567        .pairs
1568        .iter()
1569        .map(|(old, new)| (new.as_slice(), old.as_slice()))
1570        .collect();
1571
1572    // 2to1: a destination that is a rename target on BOTH sides from different
1573    // sources. (Same source on both sides is a rename/rename(1to1), handled by
1574    // the path-keyed core; same source to two dests is the 1to2 case above.)
1575    for (dest, ours_src) in &ours_by_dest {
1576        let Some(theirs_src) = theirs_by_dest.get(dest) else {
1577            continue;
1578        };
1579        if ours_src == theirs_src {
1580            continue;
1581        }
1582        // Don't disturb a destination the 1to2 pass already claimed.
1583        if renames.rename_rename_one_to_two.contains_key(*dest) {
1584            continue;
1585        }
1586        renames.rename_rename_two_to_one.insert(
1587            dest.to_vec(),
1588            RenameRenameTwoToOne {
1589                ours_source: ours_src.to_vec(),
1590                theirs_source: theirs_src.to_vec(),
1591            },
1592        );
1593    }
1594
1595    // rename/add on ours: ours renamed onto `dest`, which theirs added (present
1596    // on theirs, absent from base, and not a theirs rename target).
1597    for (dest, ours_src) in &ours_by_dest {
1598        if renames.rename_rename_two_to_one.contains_key(*dest)
1599            || renames.rename_rename_one_to_two.contains_key(*dest)
1600        {
1601            continue;
1602        }
1603        if theirs_map.contains_key(*dest)
1604            && !base_map.contains_key(*dest)
1605            && !theirs_by_dest.contains_key(dest)
1606        {
1607            renames.rename_adds.insert(
1608                dest.to_vec(),
1609                RenameAdd {
1610                    source: ours_src.to_vec(),
1611                    side: RenameSide::Ours,
1612                },
1613            );
1614        }
1615    }
1616    // rename/add on theirs: symmetric.
1617    for (dest, theirs_src) in &theirs_by_dest {
1618        if renames.rename_rename_two_to_one.contains_key(*dest)
1619            || renames.rename_rename_one_to_two.contains_key(*dest)
1620            || renames.rename_adds.contains_key(*dest)
1621        {
1622            continue;
1623        }
1624        if ours_map.contains_key(*dest)
1625            && !base_map.contains_key(*dest)
1626            && !ours_by_dest.contains_key(dest)
1627        {
1628            renames.rename_adds.insert(
1629                dest.to_vec(),
1630                RenameAdd {
1631                    source: theirs_src.to_vec(),
1632                    side: RenameSide::Theirs,
1633                },
1634            );
1635        }
1636    }
1637}
1638
1639fn collect_rename_rename_one_to_two(
1640    renames: &mut MergeRenames,
1641    ours_side: &SideRenames,
1642    theirs_side: &SideRenames,
1643) {
1644    let ours_by_source: BTreeMap<&[u8], &[u8]> = ours_side
1645        .pairs
1646        .iter()
1647        .map(|(old, new)| (old.as_slice(), new.as_slice()))
1648        .collect();
1649    for (old, theirs_new) in &theirs_side.pairs {
1650        let Some(ours_new) = ours_by_source.get(old.as_slice()) else {
1651            continue;
1652        };
1653        if *ours_new == theirs_new.as_slice() {
1654            continue;
1655        }
1656        renames.rename_deletes.remove(*ours_new);
1657        renames.rename_deletes.remove(theirs_new);
1658        renames.dest_to_source.remove(*ours_new);
1659        renames.dest_to_source.remove(theirs_new);
1660        renames.rename_rename_one_to_two.insert(
1661            old.clone(),
1662            RenameRenameOneToTwo {
1663                ours_dest: (*ours_new).to_vec(),
1664                theirs_dest: theirs_new.clone(),
1665            },
1666        );
1667    }
1668}
1669
1670/// Collect renames that occurred on `side` (relative to `base`). Records the
1671/// merge-relevant subset (renames the `other` side still references) into
1672/// `renames`, and returns the *complete* per-side rename set for directory-rename
1673/// inference. `db`/`format` resolve blob bytes for similarity scoring.
1674#[allow(clippy::too_many_arguments)]
1675fn collect_side_renames(
1676    db: &FileObjectDatabase,
1677    format: ObjectFormat,
1678    base_map: &MergeEntryMap,
1679    side_map: &MergeEntryMap,
1680    other_map: &MergeEntryMap,
1681    side: RenameSide,
1682    threshold: u8,
1683    rename_limit: usize,
1684    renames: &mut MergeRenames,
1685) -> Result<SideRenames> {
1686    // Diff base->side with inexact rename detection; the resulting `Renamed`
1687    // entries name (old_path -> new_path) pairs on this side.
1688    let base_tree = entry_map_as_tracked(base_map);
1689    let side_tree = entry_map_as_tracked(side_map);
1690    let options = DiffNameStatusOptions {
1691        detect_renames: true,
1692        detect_copies: false,
1693        find_copies_harder: false,
1694        rename_empty: false,
1695        detect_inexact: true,
1696        rename_threshold: threshold,
1697        copy_threshold: threshold,
1698        rename_limit,
1699        ..Default::default()
1700    };
1701    let changes = diff_name_status_maps_with_renames(
1702        &base_tree,
1703        &side_tree,
1704        base_tree.keys().chain(side_tree.keys()),
1705        options,
1706        |oid| merge_blob_bytes(db, oid).ok().map(|b| Arc::from(b.into_boxed_slice())),
1707    )?;
1708
1709    let mut pairs = Vec::new();
1710    for change in changes {
1711        let NameStatus::Renamed(_) = change.status else {
1712            continue;
1713        };
1714        let Some(old_path) = change.old_path.as_ref() else {
1715            continue;
1716        };
1717        let old = old_path.as_bytes().to_vec();
1718        let new = change.path.as_bytes().to_vec();
1719        // Complete rename set, fed to directory-rename inference.
1720        pairs.push((old.clone(), new.clone()));
1721
1722        // Only act when the destination is genuinely new (not already present
1723        // in either side from a different origin) and the OTHER side still
1724        // references the source path — i.e. the other side modified/kept `old`,
1725        // and its change should follow the rename to `new`.
1726        if !other_map.contains_key(&old) {
1727            // The source path is gone on the other side. If it existed in base
1728            // (so the other side *deleted* it) and the other side did not also
1729            // produce `new`, this is a rename/delete conflict: this side renamed
1730            // the file, the other side deleted its source.
1731            if base_map.contains_key(&old) && !other_map.contains_key(&new) {
1732                renames
1733                    .rename_deletes
1734                    .entry(new.clone())
1735                    .or_insert(RenameDelete {
1736                        source: old.clone(),
1737                        side,
1738                    });
1739            }
1740            continue;
1741        }
1742        // If the other side ALSO renamed/created `new`, that is a rename/rename
1743        // or rename/add corner case we leave to the path-keyed core (stage-b).
1744        if other_map.contains_key(&new) {
1745            continue;
1746        }
1747        // Skip if both sides renamed the same source to the same dest (already
1748        // recorded) or to anything (first writer wins; the path-keyed core then
1749        // sees identical dest entries and resolves trivially).
1750        renames
1751            .dest_to_source
1752            .entry(new)
1753            .or_insert(MergeRename { source: old, side });
1754    }
1755
1756    let _ = format;
1757    Ok(SideRenames { pairs })
1758}
1759
1760/// Rewrite the three side maps so that each detected one-sided rename old->new
1761/// presents the OTHER side's `old` entry at `new`, and removes `old` from
1762/// every side. The path-keyed merge core then performs the 3-way content merge
1763/// at `new` with base=base[old], one side = the renaming side's new content,
1764/// the other side = the modifying side's old content.
1765fn apply_merge_renames(
1766    base_map: &MergeEntryMap,
1767    ours_map: &MergeEntryMap,
1768    theirs_map: &MergeEntryMap,
1769    renames: &MergeRenames,
1770) -> (MergeEntryMap, MergeEntryMap, MergeEntryMap) {
1771    if renames.dest_to_source.is_empty() {
1772        return (base_map.clone(), ours_map.clone(), theirs_map.clone());
1773    }
1774    let mut base = base_map.clone();
1775    let mut ours = ours_map.clone();
1776    let mut theirs = theirs_map.clone();
1777
1778    for (new, rename) in &renames.dest_to_source {
1779        let old = &rename.source;
1780        // Move base[old] to base[new] so the destination has a proper ancestor.
1781        if let Some(entry) = base.remove(old) {
1782            base.entry(new.clone()).or_insert(entry);
1783        }
1784        // For each side, if it still has `old`, move that entry to `new`.
1785        for side in [&mut ours, &mut theirs] {
1786            if let Some(entry) = side.remove(old) {
1787                side.entry(new.clone()).or_insert(entry);
1788            }
1789        }
1790    }
1791    (base, ours, theirs)
1792}
1793
1794// --- Directory-rename detection -------------------------------------------
1795
1796/// The parent directory of `path`, or `None` for a top-level path.
1797fn parent_dir(path: &[u8]) -> Option<&[u8]> {
1798    path.iter().rposition(|b| *b == b'/').map(|i| &path[..i])
1799}
1800
1801/// Apply a directory rename `old_dir -> new_dir` to `path` (which must live
1802/// under `old_dir`). E.g. `old_dir=z`, `new_dir=y`, `path=z/d` -> `y/d`; an
1803/// empty `new_dir` (rename into the repo root) drops the directory prefix.
1804fn apply_dir_rename(old_dir: &[u8], new_dir: &[u8], path: &[u8]) -> Vec<u8> {
1805    // The portion of `path` after `old_dir/` (handle root-target by stepping
1806    // past the separator, exactly as git's apply_dir_rename does).
1807    let rest_start = if new_dir.is_empty() {
1808        old_dir.len() + 1
1809    } else {
1810        old_dir.len()
1811    };
1812    let mut out = new_dir.to_vec();
1813    out.extend_from_slice(&path[rest_start..]);
1814    out
1815}
1816
1817/// Find the longest renamed ancestor directory of `path`: walk parent dirs from
1818/// the deepest up and return the first one present in `dir_renames`. Mirrors
1819/// merge-ort's `check_dir_renamed`.
1820fn check_dir_renamed<'a>(
1821    path: &[u8],
1822    dir_renames: &'a BTreeMap<Vec<u8>, Vec<u8>>,
1823) -> Option<(&'a [u8], &'a [u8])> {
1824    let mut cur = parent_dir(path);
1825    while let Some(dir) = cur {
1826        if let Some((old_dir, new_dir)) = dir_renames.get_key_value(dir) {
1827            return Some((old_dir.as_slice(), new_dir.as_slice()));
1828        }
1829        cur = parent_dir(dir);
1830    }
1831    None
1832}
1833
1834/// The provisional directory renames computed for both sides, plus the source
1835/// directories whose rename was ambiguous (a "split").
1836struct DirectoryRenameMaps {
1837    /// `old_dir -> new_dir` directory renames detected on ours' side. A path
1838    /// added/renamed by theirs under `old_dir` re-homes into `new_dir`.
1839    ours: BTreeMap<Vec<u8>, Vec<u8>>,
1840    /// Directory renames detected on theirs' side.
1841    theirs: BTreeMap<Vec<u8>, Vec<u8>>,
1842    /// Source directories whose split was unclear on ours' side (no unique
1843    /// majority target); paths on theirs that would need to follow such a rename
1844    /// are a conflict, not silent.
1845    ours_split: BTreeSet<Vec<u8>>,
1846    /// Source directories whose split was unclear on theirs' side.
1847    theirs_split: BTreeSet<Vec<u8>>,
1848}
1849
1850/// Infer directory renames from the complete per-side file-rename sets, mirroring
1851/// merge-ort's `get_provisional_directory_renames` + `handle_directory_level_conflicts`.
1852/// For every file moved `.../old_dir/x -> .../new_dir/x`, the ancestor pairs are
1853/// tallied (`dir_rename_count`) and collapsed to `old_dir -> best_new_dir` where
1854/// `best` is the unique highest count. A tie marks the source directory as a
1855/// "split". A rename is only kept if the source directory was *entirely removed*
1856/// on that side (the `dirs_removed` gate). A directory renamed on BOTH sides is
1857/// dropped from both maps (ambiguous).
1858fn compute_directory_renames(
1859    ours_map: &MergeEntryMap,
1860    theirs_map: &MergeEntryMap,
1861    ours_side: &SideRenames,
1862    theirs_side: &SideRenames,
1863) -> DirectoryRenameMaps {
1864    let ours = compute_side_dir_renames(&ours_side.pairs, ours_map);
1865    let theirs = compute_side_dir_renames(&theirs_side.pairs, theirs_map);
1866
1867    // A directory renamed on BOTH sides (to whatever target) is ambiguous;
1868    // git's handle_directory_level_conflicts drops it from both maps so neither
1869    // side's directory rename is applied.
1870    let mut ours_map_out = ours.renames;
1871    let mut theirs_map_out = theirs.renames;
1872    let dup: Vec<Vec<u8>> = ours_map_out
1873        .keys()
1874        .filter(|k| theirs_map_out.contains_key(*k))
1875        .cloned()
1876        .collect();
1877    for k in dup {
1878        ours_map_out.remove(&k);
1879        theirs_map_out.remove(&k);
1880    }
1881
1882    DirectoryRenameMaps {
1883        ours: ours_map_out,
1884        theirs: theirs_map_out,
1885        ours_split: ours.split,
1886        theirs_split: theirs.split,
1887    }
1888}
1889
1890/// Per-side directory-rename computation result.
1891struct SideDirRenames {
1892    renames: BTreeMap<Vec<u8>, Vec<u8>>,
1893    split: BTreeSet<Vec<u8>>,
1894}
1895
1896/// Compute one side's `old_dir -> new_dir` map from its file renames, gated on
1897/// the source directory being fully removed on that side.
1898fn compute_side_dir_renames(
1899    pairs: &[(Vec<u8>, Vec<u8>)],
1900    side_map: &MergeEntryMap,
1901) -> SideDirRenames {
1902    // dir_rename_count: count[old_dir][new_dir]. Built by walking every rename's
1903    // ancestor directories while the *trailing* path components match, exactly
1904    // as merge-ort's update_dir_rename_counts does. For
1905    //   a/b/c/d/e/foo.c -> a/b/some/thing/else/e/foo.c
1906    // this records both
1907    //   a/b/c/d/e => a/b/some/thing/else/e   AND   a/b/c/d => a/b/some/thing/else
1908    // but stops once the trailing components diverge.
1909    let mut counts: BTreeMap<Vec<u8>, BTreeMap<Vec<u8>, usize>> = BTreeMap::new();
1910    for (old, new) in pairs {
1911        update_dir_rename_counts(&mut counts, old, new);
1912    }
1913
1914    let mut renames = BTreeMap::new();
1915    let mut split = BTreeSet::new();
1916    for (old_dir, targets) in counts {
1917        let mut max = 0usize;
1918        let mut bad_max = 0usize;
1919        let mut best: Option<Vec<u8>> = None;
1920        for (target, count) in &targets {
1921            if *count == max {
1922                bad_max = max;
1923            } else if *count > max {
1924                max = *count;
1925                best = Some(target.clone());
1926            }
1927        }
1928        if max == 0 {
1929            continue;
1930        }
1931        if bad_max == max {
1932            split.insert(old_dir);
1933            continue;
1934        }
1935        // dirs_removed gate: the source directory must be entirely gone on this
1936        // side. New files that recreate the old directory count too; otherwise
1937        // cases like "both sides renamed z/ -> y/, but one side added z/d"
1938        // incorrectly look like both sides performed a whole-directory rename.
1939        if let Some(best) = best
1940            && directory_fully_removed(&old_dir, side_map)
1941        {
1942            renames.insert(old_dir, best);
1943        }
1944    }
1945
1946    SideDirRenames { renames, split }
1947}
1948
1949/// Tally the ancestor directory-rename pairs implied by a single file rename
1950/// `old -> new`, mirroring merge-ort's `update_dir_rename_counts`. Starting from
1951/// the immediate parent dirs, we strip one trailing component at a time and
1952/// record `old_ancestor -> new_ancestor` as long as the *remaining* trailing
1953/// suffix still matches between the two paths.
1954fn update_dir_rename_counts(
1955    counts: &mut BTreeMap<Vec<u8>, BTreeMap<Vec<u8>, usize>>,
1956    old: &[u8],
1957    new: &[u8],
1958) {
1959    // Work on owned copies we progressively truncate at each '/'.
1960    let mut old_dir = old.to_vec();
1961    let mut new_dir = new.to_vec();
1962    let mut first = true;
1963    loop {
1964        // Strip the trailing component (basename on the first pass, then a dir
1965        // each pass) to ascend one level.
1966        let old_has = dir_munge(&mut old_dir);
1967        let new_has = dir_munge(&mut new_dir);
1968
1969        // On the first pass we only stripped the basename; the dirs need not
1970        // match. On later passes the *trailing* components must agree, otherwise
1971        // the rename no longer implies this ancestor pairing.
1972        if !first {
1973            let old_sub = trailing_component(old, &old_dir);
1974            let new_sub = trailing_component(new, &new_dir);
1975            if old_sub != new_sub {
1976                break;
1977            }
1978        }
1979
1980        if old_dir == new_dir {
1981            // Same directory at this level — no rename implied, and no deeper
1982            // ancestor can differ usefully either.
1983            break;
1984        }
1985        *counts
1986            .entry(old_dir.clone())
1987            .or_default()
1988            .entry(new_dir.clone())
1989            .or_default() += 1;
1990
1991        first = false;
1992        // Hitting the toplevel ("") on either side ends the ascent.
1993        if old_dir.is_empty() || new_dir.is_empty() {
1994            break;
1995        }
1996        // If the two ancestors are identical from here up, stop (git stops once
1997        // the suffix-equal walk reaches a common prefix).
1998        if !old_has || !new_has {
1999            break;
2000        }
2001    }
2002}
2003
2004/// Truncate `buf` at its last '/', leaving the parent directory (or empty for a
2005/// toplevel name). Returns whether a '/' was present (i.e. there is a deeper
2006/// ancestor to ascend into).
2007fn dir_munge(buf: &mut Vec<u8>) -> bool {
2008    match buf.iter().rposition(|b| *b == b'/') {
2009        Some(i) => {
2010            buf.truncate(i);
2011            true
2012        }
2013        None => {
2014            buf.clear();
2015            false
2016        }
2017    }
2018}
2019
2020/// The trailing path component that was stripped from `full` to reach `dir`
2021/// (i.e. the suffix of `full` after `dir/`). Used to compare whether the two
2022/// sides of a rename share the same trailing directory chain.
2023fn trailing_component<'a>(full: &'a [u8], dir: &[u8]) -> &'a [u8] {
2024    if dir.is_empty() {
2025        full
2026    } else {
2027        // full = dir + "/" + suffix
2028        &full[dir.len() + 1..]
2029    }
2030}
2031
2032/// True when no path under `dir/` exists on `side` (the directory was entirely
2033/// removed there). Mirrors merge-ort's `dirs_removed` precondition.
2034fn directory_fully_removed(dir: &[u8], side_map: &MergeEntryMap) -> bool {
2035    let mut prefix = dir.to_vec();
2036    prefix.push(b'/');
2037    for path in side_map.keys() {
2038        if path.starts_with(&prefix) {
2039            return false;
2040        }
2041    }
2042    true
2043}
2044
2045/// A path on one side whose location is rewritten by a directory rename the
2046/// *other* side performed. The rewrite applies equally to a freshly added file
2047/// and to a file the side itself renamed (a transitive rename).
2048struct DirRenameMove {
2049    /// The path as it currently sits in the side's effective map (the side's own
2050    /// rename, if any, already applied).
2051    from: Vec<u8>,
2052    /// The re-homed destination, after applying the other side's directory rename.
2053    to: Vec<u8>,
2054    /// `Some(source)` when `from` is a rename destination produced by this side
2055    /// (transitive rename); `None` for a fresh add. Drives git's
2056    /// "renamed to"/"added in" message wording.
2057    renamed_from: Option<Vec<u8>>,
2058}
2059
2060struct DirRenameTwoToOne {
2061    dest: Vec<u8>,
2062    ours_source: Vec<u8>,
2063    theirs_source: Vec<u8>,
2064    ours_label_path: Vec<u8>,
2065    theirs_label_path: Vec<u8>,
2066}
2067
2068/// Provenance of a re-homed path, for `=conflict`-mode `CONFLICT (file location)`
2069/// reporting.
2070#[derive(Clone)]
2071struct RehomeInfo {
2072    /// The pre-re-home path on the adding/renaming side.
2073    old_path: Vec<u8>,
2074    /// `Some(source)` for a transitive rename, `None` for a fresh add.
2075    renamed_from: Option<Vec<u8>>,
2076    /// Whether the *adding/renaming* side was ours (true) or theirs (false). The
2077    /// caller resolves this to a branch label.
2078    added_on_ours: bool,
2079}
2080
2081/// Per-side provenance for a destination created by directory-rename rehoming.
2082#[derive(Clone, Default)]
2083struct RehomeSides {
2084    ours: Option<RehomeInfo>,
2085    theirs: Option<RehomeInfo>,
2086}
2087
2088/// An implicit-directory-rename collision: one or more paths a directory rename
2089/// would re-home onto `dest`, which is blocked because `dest` is already
2090/// occupied (a file in the way) or because multiple sources map to it. git emits
2091/// `CONFLICT (implicit dir rename): Existing file/dir at <dest> in the way ...`.
2092struct DirRenameCollision {
2093    /// The blocked destination path (the file/dir already there).
2094    dest: Vec<u8>,
2095    /// The source path(s) the directory rename tried to move onto `dest`.
2096    sources: Vec<Vec<u8>>,
2097}
2098
2099/// Outcome of applying directory renames to all three effective maps.
2100struct DirRenameOutcome {
2101    /// Rewritten base/ours/theirs maps with re-homed paths moved to their
2102    /// destinations. `base` moves too so a re-homed content-merge keeps its
2103    /// ancestor at the new location.
2104    base: MergeEntryMap,
2105    ours: MergeEntryMap,
2106    theirs: MergeEntryMap,
2107    /// Re-homed destination path -> provenance (for `=conflict`-mode reporting).
2108    rehomed: BTreeMap<Vec<u8>, RehomeSides>,
2109    /// Implicit-dir-rename collisions (file in the way / N-to-1), for the
2110    /// `CONFLICT (implicit dir rename)` message; always conflicts regardless of
2111    /// mode.
2112    collisions: Vec<DirRenameCollision>,
2113    /// Split source dirs that were relevant to a path on the other side.
2114    splits: BTreeSet<Vec<u8>>,
2115    /// Destinations where a directory rename moved a file back onto its own base
2116    /// source path (rename-to-self) and the other side modified that path. git
2117    /// records these as an unmerged file-location conflict (`UU`) rather than a
2118    /// clean auto-resolution; the trivial 3-way at the destination would
2119    /// otherwise resolve cleanly because the renamed side's content equals base.
2120    back_to_self: BTreeSet<Vec<u8>>,
2121    /// True if a directory-level collision or split made the merge dirty even in
2122    /// `=true` mode (e.g. two paths re-homed onto one destination).
2123    dirty: bool,
2124    info_messages: Vec<MergeInfoMessage>,
2125}
2126
2127/// Apply directory renames to both sides' effective maps.
2128///
2129/// This mirrors merge-ort's `collect_renames` + `check_for_directory_rename` +
2130/// `apply_directory_rename_modifications`: every path a side *added* or *renamed*
2131/// that lives under a directory the OTHER side renamed has its destination
2132/// rewritten to follow that rename — making the directory rename a property of
2133/// the rename-detection pass that every path consults, not a per-file special
2134/// case. Handles:
2135///   - transitive renames (a file the side renamed into a dir the other side
2136///     renamed follows on into the final directory),
2137///   - `dir_rename_exclusions` (never re-home into a directory THIS side itself
2138///     renamed — that would create a spurious rename/rename(1to2)),
2139///   - collisions (N paths mapping to one destination -> conflict),
2140///   - splits (a source dir with no majority target -> conflict, leave in place).
2141#[allow(clippy::too_many_arguments)]
2142fn apply_directory_renames(
2143    base_map: &MergeEntryMap,
2144    eff_base: &MergeEntryMap,
2145    eff_ours: &MergeEntryMap,
2146    eff_theirs: &MergeEntryMap,
2147    ours_side: &SideRenames,
2148    theirs_side: &SideRenames,
2149    dir_renames: &DirectoryRenameMaps,
2150    file_rename_dests: &BTreeMap<Vec<u8>, MergeRename>,
2151) -> DirRenameOutcome {
2152    let mut base = eff_base.clone();
2153    let mut ours = eff_ours.clone();
2154    let mut theirs = eff_theirs.clone();
2155    let mut rehomed = BTreeMap::new();
2156    let mut collisions = Vec::new();
2157    let mut splits = BTreeSet::new();
2158    let mut back_to_self = BTreeSet::new();
2159    let mut info_messages = Vec::new();
2160    let mut dirty = false;
2161
2162    // Ours' paths follow THEIRS' directory renames; the exclusions are OURS' own
2163    // renamed-into dirs (never re-home a path into a directory this same side
2164    // renamed). Symmetrically for theirs.
2165    let ours_excl = exclusion_dirs(&dir_renames.ours);
2166    let theirs_excl = exclusion_dirs(&dir_renames.theirs);
2167
2168    // Plan ours' moves (following theirs' dir-renames) and theirs' moves
2169    // (following ours' dir-renames). Planning before applying lets us detect
2170    // collisions (N paths onto one destination) across the whole side.
2171    let ours_moves = plan_rehome(
2172        base_map,
2173        &ours,
2174        ours_side,
2175        &dir_renames.theirs,
2176        &ours_excl,
2177        &dir_renames.theirs_split,
2178        &mut collisions,
2179        &mut splits,
2180        &mut info_messages,
2181        &mut dirty,
2182    );
2183    let theirs_moves = plan_rehome(
2184        base_map,
2185        &theirs,
2186        theirs_side,
2187        &dir_renames.ours,
2188        &theirs_excl,
2189        &dir_renames.ours_split,
2190        &mut collisions,
2191        &mut splits,
2192        &mut info_messages,
2193        &mut dirty,
2194    );
2195
2196    apply_rehome_moves(
2197        base_map,
2198        file_rename_dests,
2199        &mut base,
2200        &mut ours,
2201        &mut theirs,
2202        ours_moves,
2203        true,
2204        &mut rehomed,
2205        &mut collisions,
2206        &mut back_to_self,
2207        &mut dirty,
2208    );
2209    apply_rehome_moves(
2210        base_map,
2211        file_rename_dests,
2212        &mut base,
2213        &mut ours,
2214        &mut theirs,
2215        theirs_moves,
2216        false,
2217        &mut rehomed,
2218        &mut collisions,
2219        &mut back_to_self,
2220        &mut dirty,
2221    );
2222
2223    DirRenameOutcome {
2224        base,
2225        ours,
2226        theirs,
2227        rehomed,
2228        collisions,
2229        splits,
2230        back_to_self,
2231        dirty,
2232        info_messages,
2233    }
2234}
2235
2236/// The set of *source* directories a side renamed away from. A directory rename
2237/// the other side wants to apply into one of these dirs is skipped (it would
2238/// produce a spurious rename/rename(1to2)); git's `dir_rename_exclusions`.
2239fn exclusion_dirs(side_dir_renames: &BTreeMap<Vec<u8>, Vec<u8>>) -> BTreeSet<Vec<u8>> {
2240    side_dir_renames.keys().cloned().collect()
2241}
2242
2243/// Re-home `target`'s added/renamed paths that fall under a directory the other
2244/// side renamed (`renamer_dirs`: `old_dir -> new_dir`).
2245///
2246/// Candidates are paths present on this side and absent in base — i.e. both
2247/// Plan the directory-rename moves for one side: which of its added/renamed
2248/// paths re-home where, following `renamer_dirs` (the OTHER side's dir-renames).
2249///
2250/// Candidates are paths present on this side and absent in base — both freshly
2251/// added files AND this side's own rename destinations (the latter give the
2252/// transitive-rename behaviour). A candidate whose target directory is in
2253/// `exclusions` (a dir this side itself renamed) is skipped. Splits mark the
2254/// merge dirty; N-to-1 collisions (multiple sources onto one destination) record
2255/// a `DirRenameCollision` and yield no move. Returns the surviving single moves
2256/// (one per destination).
2257#[allow(clippy::too_many_arguments)]
2258fn plan_rehome(
2259    base_map: &MergeEntryMap,
2260    side: &MergeEntryMap,
2261    side_renames: &SideRenames,
2262    renamer_dirs: &BTreeMap<Vec<u8>, Vec<u8>>,
2263    exclusions: &BTreeSet<Vec<u8>>,
2264    split_dirs: &BTreeSet<Vec<u8>>,
2265    collisions: &mut Vec<DirRenameCollision>,
2266    splits: &mut BTreeSet<Vec<u8>>,
2267    info_messages: &mut Vec<MergeInfoMessage>,
2268    dirty: &mut bool,
2269) -> Vec<DirRenameMove> {
2270    if renamer_dirs.is_empty() && split_dirs.is_empty() {
2271        return Vec::new();
2272    }
2273
2274    // This side's rename destinations -> sources; eligible for a transitive
2275    // rewrite and carry the original source for message wording.
2276    let side_rename_src: BTreeMap<&[u8], &[u8]> = side_renames
2277        .pairs
2278        .iter()
2279        .map(|(o, n)| (n.as_slice(), o.as_slice()))
2280        .collect();
2281
2282    let candidates: Vec<Vec<u8>> = side
2283        .keys()
2284        .filter(|p| !base_map.contains_key(*p) || side_rename_src.contains_key(p.as_slice()))
2285        .cloned()
2286        .collect();
2287
2288    // dest -> the moves wanting to land there (collision detection).
2289    let mut planned: BTreeMap<Vec<u8>, Vec<DirRenameMove>> = BTreeMap::new();
2290    for path in candidates {
2291        if let Some(split_dir) = check_dir_split(&path, split_dirs) {
2292            splits.insert(split_dir.to_vec());
2293            *dirty = true;
2294            continue;
2295        }
2296        let Some((old_dir, new_dir)) = check_dir_renamed(&path, renamer_dirs) else {
2297            continue;
2298        };
2299        // dir_rename_exclusions: don't apply a rename INTO a directory this side
2300        // itself renamed; that would cause a spurious rename/rename(1to2). The
2301        // file instead follows this side's own rename, so leave it.
2302        let new_dir_is_exclusion = exclusions.contains(new_dir);
2303        let new_dir_inside_exclusion = exclusions
2304            .iter()
2305            .any(|dir| directory_contains_proper(dir, new_dir));
2306        if new_dir_is_exclusion
2307            || (new_dir_inside_exclusion
2308                && !side_has_pure_add_under_dir(side, base_map, &side_rename_src, old_dir))
2309        {
2310            info_messages.push(MergeInfoMessage::DirRenameSkippedDueToRerename {
2311                old_dir: old_dir.to_vec(),
2312                path: path.clone(),
2313                new_dir: new_dir.to_vec(),
2314            });
2315            continue;
2316        }
2317        let dest = apply_dir_rename(old_dir, new_dir, &path);
2318        if dest == path {
2319            // Directory rename causes a rename-to-self: already in place.
2320            continue;
2321        }
2322        let renamed_from = side_rename_src.get(path.as_slice()).map(|s| s.to_vec());
2323        planned
2324            .entry(dest.clone())
2325            .or_default()
2326            .push(DirRenameMove {
2327                from: path,
2328                to: dest,
2329                renamed_from,
2330            });
2331    }
2332
2333    let mut moves = Vec::new();
2334    for (dest, group) in planned {
2335        if group.len() > 1 {
2336            // Multiple paths map to one destination: an implicit-dir-rename
2337            // collision. git leaves all of them in place and conflicts.
2338            *dirty = true;
2339            collisions.push(DirRenameCollision {
2340                dest,
2341                sources: group.into_iter().map(|m| m.from).collect(),
2342            });
2343            continue;
2344        }
2345        moves.push(group.into_iter().next().expect("non-empty"));
2346    }
2347    moves
2348}
2349
2350fn check_dir_split<'a>(path: &[u8], split_dirs: &'a BTreeSet<Vec<u8>>) -> Option<&'a [u8]> {
2351    let mut dir = parent_dir(path)?;
2352    loop {
2353        if let Some(split_dir) = split_dirs.get(dir) {
2354            return Some(split_dir);
2355        }
2356        dir = parent_dir(dir)?;
2357    }
2358}
2359
2360fn directory_contains_proper(parent: &[u8], child: &[u8]) -> bool {
2361    !parent.is_empty()
2362        && child.len() > parent.len()
2363        && child.starts_with(parent)
2364        && child[parent.len()] == b'/'
2365}
2366
2367fn side_has_pure_add_under_dir(
2368    side: &MergeEntryMap,
2369    base_map: &MergeEntryMap,
2370    side_rename_src: &BTreeMap<&[u8], &[u8]>,
2371    dir: &[u8],
2372) -> bool {
2373    side.keys().any(|path| {
2374        path_is_under_dir(path, dir)
2375            && !base_map.contains_key(path)
2376            && !side_rename_src.contains_key(path.as_slice())
2377    })
2378}
2379
2380fn path_is_under_dir(path: &[u8], dir: &[u8]) -> bool {
2381    !dir.is_empty() && path.len() > dir.len() && path.starts_with(dir) && path[dir.len()] == b'/'
2382}
2383
2384/// Apply a side's planned re-home moves to all three effective maps.
2385///
2386/// `side_is_ours` says whether the moves originate from ours' (true) or theirs'
2387/// (false) paths — used both for `=conflict`-mode provenance and to decide which
2388/// side's entry the move primarily belongs to. A move whose source is a
2389/// content-merge path (present on the other side and in base too) re-homes
2390/// across `base`/`ours`/`theirs` together, so the 3-way merge follows it to the
2391/// new location; a pure add re-homes only its own side.
2392#[allow(clippy::too_many_arguments)]
2393fn apply_rehome_moves(
2394    original_base: &MergeEntryMap,
2395    file_rename_dests: &BTreeMap<Vec<u8>, MergeRename>,
2396    base: &mut MergeEntryMap,
2397    ours: &mut MergeEntryMap,
2398    theirs: &mut MergeEntryMap,
2399    moves: Vec<DirRenameMove>,
2400    side_is_ours: bool,
2401    rehomed: &mut BTreeMap<Vec<u8>, RehomeSides>,
2402    collisions: &mut Vec<DirRenameCollision>,
2403    back_to_self: &mut BTreeSet<Vec<u8>>,
2404    dirty: &mut bool,
2405) {
2406    for mv in moves {
2407        // A file in the way at the destination is only a blocker when it is
2408        // present on this same side (or in base). If the other side already
2409        // occupies the destination, applying this move produces the normal
2410        // two-sided conflict at that path (e.g. t6423 1d's rename/rename(2to1)).
2411        let occupied_on_this_side = if side_is_ours {
2412            ours.contains_key(&mv.to) || map_has_directory_at(ours, &mv.to)
2413        } else {
2414            theirs.contains_key(&mv.to) || map_has_directory_at(theirs, &mv.to)
2415        };
2416        let occupied_by_cross_rename =
2417            file_rename_dests
2418                .get(&mv.to)
2419                .is_some_and(|rename| match (side_is_ours, rename.side) {
2420                    (true, RenameSide::Theirs) | (false, RenameSide::Ours) => true,
2421                    (true, RenameSide::Ours) | (false, RenameSide::Theirs) => false,
2422                });
2423        let base_entry_at_dest = original_base.get(&mv.to).copied();
2424        let base_entry_at_source = original_base.get(&mv.from).copied();
2425        let other_side_entry_at_dest = if side_is_ours {
2426            theirs.get(&mv.to).copied()
2427        } else {
2428            ours.get(&mv.to).copied()
2429        };
2430        let other_side_entry_at_source = if side_is_ours {
2431            theirs.get(&mv.from).copied()
2432        } else {
2433            ours.get(&mv.from).copied()
2434        };
2435        let base_entry_for_shifted_source = base_entry_at_source.or(base_entry_at_dest);
2436        let rename_back_to_modified_source = mv
2437            .renamed_from
2438            .as_ref()
2439            .is_some_and(|source| source == &mv.to)
2440            && base_entry_at_dest.is_some()
2441            && (other_side_entry_at_dest.is_some_and(|entry| Some(entry) != base_entry_at_dest)
2442                || other_side_entry_at_source
2443                    .is_some_and(|entry| Some(entry) != base_entry_for_shifted_source));
2444        if ((base_entry_at_dest.is_some() && !rename_back_to_modified_source)
2445            || (occupied_on_this_side && !occupied_by_cross_rename))
2446            && mv.to != mv.from
2447        {
2448            *dirty = true;
2449            collisions.push(DirRenameCollision {
2450                dest: mv.to.clone(),
2451                sources: vec![mv.from.clone()],
2452            });
2453            continue;
2454        }
2455        let mut moved = false;
2456        if occupied_by_cross_rename {
2457            base.remove(&mv.from);
2458            if side_is_ours {
2459                if let Some(entry) = ours.remove(&mv.from) {
2460                    ours.insert(mv.to.clone(), entry);
2461                    moved = true;
2462                }
2463                theirs.remove(&mv.from);
2464            } else {
2465                ours.remove(&mv.from);
2466                if let Some(entry) = theirs.remove(&mv.from) {
2467                    theirs.insert(mv.to.clone(), entry);
2468                    moved = true;
2469                }
2470            }
2471        } else {
2472            // Move the path on every map that holds it (base for the ancestor,
2473            // and whichever sides carry content at the path). This keeps a
2474            // content-merge keyed consistently at the re-homed destination.
2475            for m in [&mut *base, &mut *ours, &mut *theirs] {
2476                if let Some(entry) = m.remove(&mv.from) {
2477                    m.insert(mv.to.clone(), entry);
2478                    moved = true;
2479                }
2480            }
2481        }
2482        if moved {
2483            if rename_back_to_modified_source {
2484                back_to_self.insert(mv.to.clone());
2485            }
2486            let info = RehomeInfo {
2487                old_path: mv.from.clone(),
2488                renamed_from: mv.renamed_from.clone(),
2489                added_on_ours: side_is_ours,
2490            };
2491            let entry = rehomed.entry(mv.to.clone()).or_default();
2492            if side_is_ours {
2493                entry.ours = Some(info);
2494            } else {
2495                entry.theirs = Some(info);
2496            }
2497        }
2498    }
2499}
2500
2501fn collect_dir_rename_two_to_one(
2502    renames: &MergeRenames,
2503    rehomed: &BTreeMap<Vec<u8>, RehomeSides>,
2504) -> Vec<DirRenameTwoToOne> {
2505    let mut conflicts = Vec::new();
2506    for (dest, sides) in rehomed {
2507        let Some(file_rename) = renames.dest_to_source.get(dest) else {
2508            continue;
2509        };
2510        match file_rename.side {
2511            RenameSide::Ours => {
2512                let Some(info) = sides.theirs.as_ref() else {
2513                    continue;
2514                };
2515                let Some(theirs_source) = info.renamed_from.as_ref() else {
2516                    continue;
2517                };
2518                conflicts.push(DirRenameTwoToOne {
2519                    dest: dest.clone(),
2520                    ours_source: file_rename.source.clone(),
2521                    theirs_source: theirs_source.clone(),
2522                    ours_label_path: dest.clone(),
2523                    theirs_label_path: info.old_path.clone(),
2524                });
2525            }
2526            RenameSide::Theirs => {
2527                let Some(info) = sides.ours.as_ref() else {
2528                    continue;
2529                };
2530                let Some(ours_source) = info.renamed_from.as_ref() else {
2531                    continue;
2532                };
2533                conflicts.push(DirRenameTwoToOne {
2534                    dest: dest.clone(),
2535                    ours_source: ours_source.clone(),
2536                    theirs_source: file_rename.source.clone(),
2537                    ours_label_path: info.old_path.clone(),
2538                    theirs_label_path: dest.clone(),
2539                });
2540            }
2541        }
2542    }
2543    conflicts
2544}
2545
2546fn map_has_directory_at(map: &MergeEntryMap, path: &[u8]) -> bool {
2547    let mut prefix = path.to_vec();
2548    prefix.push(b'/');
2549    map.keys().any(|candidate| candidate.starts_with(&prefix))
2550}
2551
2552fn remap_rename_destinations(renames: &mut MergeRenames, rehomed: &BTreeMap<Vec<u8>, RehomeSides>) {
2553    if rehomed.is_empty() {
2554        return;
2555    }
2556    let mut remapped_deletes = BTreeMap::new();
2557    for (dest, rd) in std::mem::take(&mut renames.rename_deletes) {
2558        let new_dest = rehomed
2559            .iter()
2560            .find_map(|(new_dest, sides)| {
2561                let moved = sides
2562                    .ours
2563                    .as_ref()
2564                    .is_some_and(|info| info.old_path == dest)
2565                    || sides
2566                        .theirs
2567                        .as_ref()
2568                        .is_some_and(|info| info.old_path == dest);
2569                moved.then(|| new_dest.clone())
2570            })
2571            .unwrap_or(dest);
2572        remapped_deletes.insert(new_dest, rd);
2573    }
2574    renames.rename_deletes = remapped_deletes;
2575
2576    for rename in renames.rename_rename_one_to_two.values_mut() {
2577        for (dest, sides) in rehomed {
2578            if sides
2579                .ours
2580                .as_ref()
2581                .is_some_and(|info| info.old_path == rename.ours_dest)
2582            {
2583                rename.ours_dest = dest.clone();
2584            }
2585            if sides
2586                .theirs
2587                .as_ref()
2588                .is_some_and(|info| info.old_path == rename.theirs_dest)
2589            {
2590                rename.theirs_dest = dest.clone();
2591            }
2592        }
2593    }
2594}
2595
2596fn drop_collapsed_rename_rename_conflicts(renames: &mut MergeRenames) {
2597    renames
2598        .rename_rename_one_to_two
2599        .retain(|_, rename| rename.ours_dest != rename.theirs_dest);
2600}
2601
2602fn apply_dir_rename_two_to_one_conflicts(
2603    db: &FileObjectDatabase,
2604    eff_ours: &MergeEntryMap,
2605    eff_theirs: &MergeEntryMap,
2606    conflicts: &[DirRenameTwoToOne],
2607    paths: &mut [MergedPath],
2608    leaves: &mut MergeEntryMap,
2609    options: &MergeTreesOptions<'_>,
2610) -> Result<()> {
2611    for conflict in conflicts {
2612        let Some(slot) = paths.iter_mut().find(|path| path.path == conflict.dest) else {
2613            continue;
2614        };
2615        let ours_entry = eff_ours.get(&conflict.dest).copied();
2616        let theirs_entry = eff_theirs.get(&conflict.dest).copied();
2617        let (Some((ours_mode, ours_oid)), Some((theirs_mode, theirs_oid))) =
2618            (ours_entry, theirs_entry)
2619        else {
2620            continue;
2621        };
2622        let ours_bytes = merge_blob_bytes(db, &ours_oid)?;
2623        let theirs_bytes = merge_blob_bytes(db, &theirs_oid)?;
2624        let (resolved_mode, mode_conflict) = merge_file_modes(None, ours_mode, theirs_mode);
2625        let favor = merge_favor_for_path(options, &conflict.dest);
2626        let result = if is_mergeable_file_mode(ours_mode) && is_mergeable_file_mode(theirs_mode) {
2627            merge_blobs(
2628                &[],
2629                &ours_bytes,
2630                &theirs_bytes,
2631                &MergeBlobOptions {
2632                    ours_label: &qualify_label(options.ours_label, &conflict.ours_label_path),
2633                    theirs_label: &qualify_label(options.theirs_label, &conflict.theirs_label_path),
2634                    base_label: options.ancestor_label,
2635                    style: options.style,
2636                    favor,
2637                    ws_ignore: options.ws_ignore,
2638                    marker_size: merge_marker_size_for_path(options, &conflict.dest),
2639                },
2640            )
2641        } else {
2642            MergeBlobResult {
2643                content: ours_bytes.clone(),
2644                conflicted: true,
2645            }
2646        };
2647        let oid = db.write_object(EncodedObject::new(ObjectType::Blob, result.content.clone()))?;
2648        leaves.insert(conflict.dest.clone(), (resolved_mode, oid));
2649        slot.stages = MergeStages {
2650            base: None,
2651            ours: ours_entry,
2652            theirs: theirs_entry,
2653        };
2654        slot.result = Some((resolved_mode, oid));
2655        slot.worktree = Some((
2656            if ours_mode == theirs_mode {
2657                ours_mode
2658            } else {
2659                0o100644
2660            },
2661            result.content,
2662        ));
2663        slot.conflict = Some(MergeConflictKind::RenameRenameTwoToOne {
2664            ours_path: conflict.ours_source.clone(),
2665            theirs_path: conflict.theirs_source.clone(),
2666        });
2667        slot.auto_merged = !mode_conflict;
2668    }
2669    Ok(())
2670}
2671
2672/// 3-way merge one rename's content into a single leaf entry: `base` is the
2673/// source's ancestor blob, `ours`/`theirs` the two sides' content (one of which
2674/// is the renamed file, the other the other side's change to the source). Both
2675/// present and differing → a real content merge; otherwise the surviving side's
2676/// entry is carried as-is.
2677fn rename_merged_leaf(
2678    db: &FileObjectDatabase,
2679    base: Option<(u32, ObjectId)>,
2680    ours: Option<(u32, ObjectId)>,
2681    theirs: Option<(u32, ObjectId)>,
2682    path: &[u8],
2683    options: &MergeTreesOptions<'_>,
2684) -> Result<Option<(u32, ObjectId)>> {
2685    match (ours, theirs) {
2686        (None, None) => Ok(None),
2687        (Some(entry), None) | (None, Some(entry)) => Ok(Some(entry)),
2688        (Some((ours_mode, ours_oid)), Some((theirs_mode, theirs_oid))) => {
2689            if (ours_mode, ours_oid) == (theirs_mode, theirs_oid) {
2690                return Ok(Some((ours_mode, ours_oid)));
2691            }
2692            if !is_mergeable_file_mode(ours_mode) || !is_mergeable_file_mode(theirs_mode) {
2693                return Ok(Some((ours_mode, ours_oid)));
2694            }
2695            let base_bytes = match base {
2696                Some((_, oid)) => merge_blob_bytes(db, &oid)?,
2697                None => Vec::new(),
2698            };
2699            let favor = merge_favor_for_path(options, path);
2700            let result = merge_blobs(
2701                &base_bytes,
2702                &merge_blob_bytes(db, &ours_oid)?,
2703                &merge_blob_bytes(db, &theirs_oid)?,
2704                &MergeBlobOptions {
2705                    ours_label: options.ours_label,
2706                    theirs_label: options.theirs_label,
2707                    base_label: options.ancestor_label,
2708                    style: options.style,
2709                    favor,
2710                    ws_ignore: options.ws_ignore,
2711                    marker_size: merge_marker_size_for_path(options, path),
2712                },
2713            );
2714            let (mode, _) = merge_file_modes(base.map(|(mode, _)| mode), ours_mode, theirs_mode);
2715            let oid = db.write_object(EncodedObject::new(ObjectType::Blob, result.content))?;
2716            Ok(Some((mode, oid)))
2717        }
2718    }
2719}
2720
2721/// Apply rename/rename(2to1) and rename/add conflicts: two distinct contents
2722/// land on one destination path. Each side's content at the destination is the
2723/// 3-way merge of its own rename (so the other side's change to the renamed
2724/// source follows the rename); the two results become stages 2 and 3 with no
2725/// common ancestor, and the worktree holds their two-way merge. The rename
2726/// source paths are consumed (removed from the path set) so they don't surface as
2727/// a spurious modify/delete.
2728#[allow(clippy::too_many_arguments)]
2729fn apply_rename_two_to_one_and_add_conflicts(
2730    db: &FileObjectDatabase,
2731    base_map: &MergeEntryMap,
2732    ours_map: &MergeEntryMap,
2733    theirs_map: &MergeEntryMap,
2734    renames: &MergeRenames,
2735    paths: &mut Vec<MergedPath>,
2736    leaves: &mut MergeEntryMap,
2737    options: &MergeTreesOptions<'_>,
2738) -> Result<()> {
2739    let mut consumed_sources: Vec<Vec<u8>> = Vec::new();
2740
2741    for (dest, conflict) in &renames.rename_rename_two_to_one {
2742        // Ours renamed `ours_source`->dest; theirs' change to `ours_source`
2743        // follows the rename. Symmetric for theirs.
2744        let ours_leaf = rename_merged_leaf(
2745            db,
2746            base_map.get(&conflict.ours_source).copied(),
2747            ours_map.get(dest).copied(),
2748            theirs_map.get(&conflict.ours_source).copied(),
2749            dest,
2750            options,
2751        )?;
2752        let theirs_leaf = rename_merged_leaf(
2753            db,
2754            base_map.get(&conflict.theirs_source).copied(),
2755            ours_map.get(&conflict.theirs_source).copied(),
2756            theirs_map.get(dest).copied(),
2757            dest,
2758            options,
2759        )?;
2760        write_two_sided_dest_conflict(
2761            db,
2762            dest,
2763            ours_leaf,
2764            theirs_leaf,
2765            MergeConflictKind::RenameRenameTwoToOne {
2766                ours_path: conflict.ours_source.clone(),
2767                theirs_path: conflict.theirs_source.clone(),
2768            },
2769            options,
2770            paths,
2771            leaves,
2772        )?;
2773        consumed_sources.push(conflict.ours_source.clone());
2774        consumed_sources.push(conflict.theirs_source.clone());
2775    }
2776
2777    for (dest, add) in &renames.rename_adds {
2778        let (ours_leaf, theirs_leaf) = match add.side {
2779            RenameSide::Ours => (
2780                rename_merged_leaf(
2781                    db,
2782                    base_map.get(&add.source).copied(),
2783                    ours_map.get(dest).copied(),
2784                    theirs_map.get(&add.source).copied(),
2785                    dest,
2786                    options,
2787                )?,
2788                theirs_map.get(dest).copied(),
2789            ),
2790            RenameSide::Theirs => (
2791                ours_map.get(dest).copied(),
2792                rename_merged_leaf(
2793                    db,
2794                    base_map.get(&add.source).copied(),
2795                    ours_map.get(&add.source).copied(),
2796                    theirs_map.get(dest).copied(),
2797                    dest,
2798                    options,
2799                )?,
2800            ),
2801        };
2802        write_two_sided_dest_conflict(
2803            db,
2804            dest,
2805            ours_leaf,
2806            theirs_leaf,
2807            MergeConflictKind::Content { add_add: true },
2808            options,
2809            paths,
2810            leaves,
2811        )?;
2812        consumed_sources.push(add.source.clone());
2813    }
2814
2815    // The rename source paths are consumed by the rename: the other side's
2816    // change to them followed the rename to the destination, so they resolve to
2817    // a clean deletion (not the path-keyed core's modify/delete). Marking them
2818    // `Resolved(None)` lets the worktree writer remove the now-stale source file
2819    // rather than leaving it as a stray untracked file.
2820    for source in &consumed_sources {
2821        leaves.remove(source);
2822        if let Some(slot) = paths.iter_mut().find(|path| &path.path == source) {
2823            slot.stages = MergeStages::default();
2824            slot.result = None;
2825            slot.worktree = None;
2826            slot.conflict = None;
2827            slot.auto_merged = false;
2828        } else {
2829            paths.push(MergedPath {
2830                path: source.clone(),
2831                stages: MergeStages::default(),
2832                result: None,
2833                worktree: None,
2834                conflict: None,
2835                auto_merged: false,
2836            });
2837        }
2838    }
2839    Ok(())
2840}
2841
2842/// Record a destination path that holds two unmerged contents (rename/rename
2843/// 2to1 or rename/add): stage 2 = `ours_leaf`, stage 3 = `theirs_leaf`, no
2844/// common ancestor, worktree = their two-way merge. Replaces any existing slot
2845/// (the path-keyed core's add/add result) for the destination.
2846#[allow(clippy::too_many_arguments)]
2847fn write_two_sided_dest_conflict(
2848    db: &FileObjectDatabase,
2849    dest: &[u8],
2850    ours_leaf: Option<(u32, ObjectId)>,
2851    theirs_leaf: Option<(u32, ObjectId)>,
2852    kind: MergeConflictKind,
2853    options: &MergeTreesOptions<'_>,
2854    paths: &mut Vec<MergedPath>,
2855    leaves: &mut MergeEntryMap,
2856) -> Result<()> {
2857    let ours_bytes = match ours_leaf {
2858        Some((mode, oid)) => Some((mode, merge_worktree_bytes(db, mode, &oid)?)),
2859        None => None,
2860    };
2861    let theirs_bytes = match theirs_leaf {
2862        Some((mode, oid)) => Some((mode, merge_worktree_bytes(db, mode, &oid)?)),
2863        None => None,
2864    };
2865    let (worktree_mode, worktree_content, result_leaf) = match (&ours_bytes, &theirs_bytes) {
2866        (Some((ours_mode, ours_content)), Some((theirs_mode, theirs_content))) => {
2867            let favor = merge_favor_for_path(options, dest);
2868            let merged = merge_blobs(
2869                &[],
2870                ours_content,
2871                theirs_content,
2872                &MergeBlobOptions {
2873                    ours_label: options.ours_label,
2874                    theirs_label: options.theirs_label,
2875                    base_label: options.ancestor_label,
2876                    style: options.style,
2877                    favor,
2878                    ws_ignore: options.ws_ignore,
2879                    marker_size: merge_marker_size_for_path(options, dest),
2880                },
2881            );
2882            let mode = if ours_mode == theirs_mode {
2883                *ours_mode
2884            } else {
2885                0o100644
2886            };
2887            let oid =
2888                db.write_object(EncodedObject::new(ObjectType::Blob, merged.content.clone()))?;
2889            (mode, merged.content, Some((mode, oid)))
2890        }
2891        (Some((mode, content)), None) | (None, Some((mode, content))) => {
2892            (*mode, content.clone(), ours_leaf.or(theirs_leaf))
2893        }
2894        (None, None) => (0o100644, Vec::new(), None),
2895    };
2896
2897    let slot = MergedPath {
2898        path: dest.to_vec(),
2899        stages: MergeStages {
2900            base: None,
2901            ours: ours_leaf,
2902            theirs: theirs_leaf,
2903        },
2904        result: result_leaf,
2905        worktree: Some((worktree_mode, worktree_content)),
2906        conflict: Some(kind),
2907        auto_merged: true,
2908    };
2909    if let Some(existing) = paths.iter_mut().find(|path| path.path == dest) {
2910        *existing = slot;
2911    } else {
2912        paths.push(slot);
2913    }
2914    if let Some(leaf) = result_leaf {
2915        leaves.insert(dest.to_vec(), leaf);
2916    } else {
2917        leaves.remove(dest);
2918    }
2919    Ok(())
2920}
2921
2922#[allow(clippy::too_many_arguments)]
2923fn apply_rename_rename_one_to_two_conflicts(
2924    db: &FileObjectDatabase,
2925    base_map: &MergeEntryMap,
2926    eff_ours: &MergeEntryMap,
2927    eff_theirs: &MergeEntryMap,
2928    conflicts: &BTreeMap<Vec<u8>, RenameRenameOneToTwo>,
2929    paths: &mut Vec<MergedPath>,
2930    leaves: &mut MergeEntryMap,
2931    options: &MergeTreesOptions<'_>,
2932) -> Result<()> {
2933    for (old_path, conflict) in conflicts {
2934        let base_entry = base_map.get(old_path).copied();
2935        let ours_entry = eff_ours.get(&conflict.ours_dest).copied();
2936        let theirs_entry = eff_theirs.get(&conflict.theirs_dest).copied();
2937        let theirs_add_at_ours_dest = eff_theirs.get(&conflict.ours_dest).copied();
2938        let ours_add_at_theirs_dest = eff_ours.get(&conflict.theirs_dest).copied();
2939
2940        leaves.remove(old_path);
2941        leaves.remove(&conflict.ours_dest);
2942        leaves.remove(&conflict.theirs_dest);
2943        paths.retain(|path| {
2944            path.path != *old_path
2945                && path.path != conflict.ours_dest
2946                && path.path != conflict.theirs_dest
2947        });
2948
2949        paths.push(MergedPath {
2950            path: old_path.clone(),
2951            stages: MergeStages {
2952                base: base_entry,
2953                ours: None,
2954                theirs: None,
2955            },
2956            result: None,
2957            worktree: None,
2958            conflict: Some(MergeConflictKind::RenameRenameOneToTwo {
2959                old_path: old_path.clone(),
2960                ours_path: conflict.ours_dest.clone(),
2961                theirs_path: conflict.theirs_dest.clone(),
2962                ours_label: options.ours_label.to_string(),
2963                theirs_label: options.theirs_label.to_string(),
2964            }),
2965            auto_merged: false,
2966        });
2967
2968        let ours_worktree = match ours_entry {
2969            Some((mode, oid)) => Some((mode, merge_worktree_bytes(db, mode, &oid)?)),
2970            None => None,
2971        };
2972        paths.push(MergedPath {
2973            path: conflict.ours_dest.clone(),
2974            stages: MergeStages {
2975                base: None,
2976                ours: ours_entry,
2977                theirs: theirs_add_at_ours_dest,
2978            },
2979            result: None,
2980            worktree: ours_worktree,
2981            conflict: Some(MergeConflictKind::RenameRenameOneToTwoStage),
2982            auto_merged: false,
2983        });
2984
2985        let theirs_worktree = match theirs_entry {
2986            Some((mode, oid)) => Some((mode, merge_worktree_bytes(db, mode, &oid)?)),
2987            None => None,
2988        };
2989        paths.push(MergedPath {
2990            path: conflict.theirs_dest.clone(),
2991            stages: MergeStages {
2992                base: None,
2993                ours: ours_add_at_theirs_dest,
2994                theirs: theirs_entry,
2995            },
2996            result: None,
2997            worktree: theirs_worktree,
2998            conflict: Some(MergeConflictKind::RenameRenameOneToTwoStage),
2999            auto_merged: false,
3000        });
3001    }
3002    Ok(())
3003}
3004
3005/// Build a path-qualified conflict-marker label `"<label>:<path>"`, as git does
3006/// for renamed files (so the two sides of a conflict name their distinct paths).
3007fn qualify_label(label: &str, path: &[u8]) -> String {
3008    format!("{label}:{}", String::from_utf8_lossy(path))
3009}
3010
3011/// Adapt a flat `path -> (mode, oid)` map into the `TrackedEntry` map the
3012/// name-status diff core consumes.
3013fn entry_map_as_tracked(map: &MergeEntryMap) -> BTreeMap<Vec<u8>, TrackedEntry> {
3014    map.iter()
3015        .map(|(path, (mode, oid))| {
3016            (
3017                path.clone(),
3018                TrackedEntry {
3019                    mode: *mode,
3020                    oid: *oid,
3021                },
3022            )
3023        })
3024        .collect()
3025}
3026
3027/// Build the flattened entry map of the *virtual ancestor* for a 3-way merge,
3028/// recursively merging the merge bases together (merge-recursive's criss-cross
3029/// construction). Conflicts in the virtual merge are folded into the tree rather
3030/// than surfaced — the outer merge owns conflict reporting.
3031///
3032/// `merge_bases` supplies the pairwise merge-base list for two commits (typically
3033/// [`sley_rev::merge_bases`] at the call site; kept injectable to avoid a
3034/// `sley-diff-merge` ↔ `sley-rev` dependency cycle).
3035pub fn virtual_ancestor_entry_map(
3036    db: &FileObjectDatabase,
3037    format: ObjectFormat,
3038    bases: &[ObjectId],
3039    merge_bases: impl Fn(&ObjectId, &ObjectId) -> Result<Vec<ObjectId>>,
3040) -> Result<MergeEntryMap> {
3041    let first = bases
3042        .first()
3043        .ok_or_else(|| GitError::Command("virtual ancestor needs at least one base".into()))?;
3044    let acc_tree = commit_tree_oid(db, format, first)?;
3045    let mut acc_map = flatten_tree(db, format, &acc_tree)?;
3046    let mut acc_commits = vec![*first];
3047
3048    for base in &bases[1..] {
3049        let other_tree = commit_tree_oid(db, format, base)?;
3050        let other_map = flatten_tree(db, format, &other_tree)?;
3051        let sub_bases = merge_bases(&acc_commits[0], base)?;
3052        let sub_base_map = match sub_bases.first() {
3053            Some(sb) => {
3054                let sb_tree = commit_tree_oid(db, format, sb)?;
3055                flatten_tree(db, format, &sb_tree)?
3056            }
3057            None => MergeEntryMap::new(),
3058        };
3059        let merge = merge_entry_maps(
3060            db,
3061            format,
3062            &sub_base_map,
3063            &acc_map,
3064            &other_map,
3065            &MergeTreesOptions {
3066                ours_label: "Temporary merge branch 1",
3067                theirs_label: "Temporary merge branch 2",
3068                detect_renames: true,
3069                rename_threshold: DEFAULT_RENAME_THRESHOLD,
3070                ..Default::default()
3071            },
3072        )?;
3073        let mut next = MergeEntryMap::new();
3074        for entry in merge.paths {
3075            if let Some(leaf) = fold_virtual_ancestor_path(db, &entry)? {
3076                next.insert(entry.path, leaf);
3077            }
3078        }
3079        acc_map = next;
3080        acc_commits = vec![*base];
3081    }
3082    Ok(acc_map)
3083}
3084
3085fn commit_tree_oid(
3086    db: &FileObjectDatabase,
3087    format: ObjectFormat,
3088    commit_oid: &ObjectId,
3089) -> Result<ObjectId> {
3090    let object = db.read_object(commit_oid)?;
3091    if object.object_type != ObjectType::Commit {
3092        return Err(GitError::InvalidObject(format!(
3093            "expected commit {commit_oid}, found {}",
3094            object.object_type.as_str()
3095        )));
3096    }
3097    Ok(Commit::parse_ref(format, &object.body)?.tree)
3098}
3099
3100fn fold_virtual_ancestor_path(
3101    db: &FileObjectDatabase,
3102    entry: &MergedPath,
3103) -> Result<Option<(u32, ObjectId)>> {
3104    if entry.conflict.is_none() {
3105        return Ok(entry.result);
3106    }
3107    let base = entry.stages.base;
3108    let ours = entry.stages.ours;
3109    let theirs = entry.stages.theirs;
3110    if let Some((mode, _)) = entry.worktree.as_ref() {
3111        if is_symlink_mode(*mode) {
3112            return Ok(base);
3113        }
3114        if is_gitlink(*mode) {
3115            return Ok(theirs.or(ours));
3116        }
3117        if let Some((mode, bytes)) = &entry.worktree {
3118            let oid = db.write_object(EncodedObject::new(ObjectType::Blob, bytes.clone()))?;
3119            return Ok(Some((*mode, oid)));
3120        }
3121    }
3122    Ok(ours.or(theirs))
3123}