Skip to main content

grit_lib/
merge_trees.rs

1//! Rename-aware three-way tree merge for cherry-pick / revert style merges.
2//!
3//! Flattens trees to path → [`IndexEntry`] maps, aligns paths using rename detection
4//! between base↔ours and base↔theirs (same idea as Git's merge-ort rename paths), then
5//! runs path-by-path three-way rules with optional content merge.
6
7use std::cell::OnceCell;
8use std::collections::{BTreeMap, HashMap, HashSet};
9
10#[path = "merge_tree_cache.rs"]
11mod merge_tree_cache;
12pub(crate) use merge_tree_cache::{lookup_index_entry_at_path_cached, MergeTreeCache};
13#[path = "merge_dir_renames.rs"]
14mod merge_dir_renames;
15#[path = "merge_trees_walk.rs"]
16mod merge_trees_walk;
17
18use crate::config::ConfigSet;
19use crate::crlf::{DiffAttr, FileAttrs, MergeAttr, TextAttr};
20use crate::diff::{detect_renames, diff_trees, DiffStatus};
21use crate::error::ConfigError;
22use crate::index::{
23    entry_from_stat, worktree_path_from_index_rel, Index, IndexEntry, MODE_REGULAR,
24};
25use crate::merge_driver::{try_merge_driver, MergeDriverLabels, MergeDriverOutcome};
26use crate::merge_file::{is_binary, merge, ConflictStyle, MergeFavor, MergeInput};
27use crate::objects::{ObjectId, ObjectKind};
28use crate::odb::Odb;
29use crate::repo::Repository;
30use crate::worktree_rules::WorktreeRules;
31use crate::write_tree::write_tree_from_index;
32
33type RenameTriple = (Vec<u8>, Vec<u8>, u32);
34
35/// Result of merging three trees with optional conflict-marker blobs for checkout.
36#[derive(Debug)]
37pub struct TreeMergeOutput {
38    /// Merged index entries for touched paths (may include unmerged stages).
39    pub index: Index,
40    /// Conflict-marker blob OIDs keyed by path (stage-2 path bytes).
41    pub conflict_content: BTreeMap<Vec<u8>, ObjectId>,
42    /// Result tree OID (clean merge layout; conflict paths use stage-0 entries from [`Self::index`]).
43    pub result_tree: ObjectId,
44    /// Repository-relative paths whose merge outcome differs from `ours_tree`.
45    pub touched_paths: HashSet<Vec<u8>>,
46    /// Base paths renamed to a different destination on each side, sorted by base path.
47    ///
48    /// Each one leaves the base entry at stage 1 under [`RenameRenameConflict::base_path`],
49    /// stage 2 under [`RenameRenameConflict::ours_path`] and stage 3 under
50    /// [`RenameRenameConflict::theirs_path`], with the merged content at both destinations.
51    pub rename_conflicts: Vec<RenameRenameConflict>,
52    /// Directory renames that need the user's attention, sorted by [`DirectoryRenameConflict::path`].
53    ///
54    /// [`DirectoryRenameConflict::Moved`] paths also have unmerged index rows; split and
55    /// blocked directory renames have none, yet still make the merge conflicted.
56    pub directory_rename_conflicts: Vec<DirectoryRenameConflict>,
57}
58
59/// How a three-way merge treats files one side adds inside a directory the other side renamed.
60///
61/// A directory counts as renamed when one side moved all of its files out of it and most of
62/// them (the destination with the most files, without a tie) into one other directory. Files the
63/// other side added to the old directory, or renamed into it, then most likely belong in the new
64/// one. Read from the `merge.directoryRenames` config with [`Self::from_config`].
65#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
66pub enum DirectoryRenameMode {
67    /// Leave such files where the adding side put them (`merge.directoryRenames=false`).
68    Off,
69    /// Move them into the renamed directory as a clean merge (`true`).
70    Apply,
71    /// Move them, but leave each one unmerged at the adding side's stage so the user confirms
72    /// the new location (`conflict`; Git's default).
73    #[default]
74    Conflict,
75}
76
77impl DirectoryRenameMode {
78    /// Config key holding the mode.
79    pub const CONFIG_KEY: &'static str = "merge.directoryRenames";
80
81    /// Parse a `merge.directoryRenames` value: `conflict` or a Git boolean (`true` applies the
82    /// renames, `false` turns them off).
83    ///
84    /// # Errors
85    ///
86    /// [`ConfigError::InvalidDirectoryRenames`] for any other value.
87    pub fn from_config_value(value: &str) -> std::result::Result<Self, ConfigError> {
88        if value.eq_ignore_ascii_case("conflict") {
89            return Ok(Self::Conflict);
90        }
91        match crate::config::parse_bool(value) {
92            Ok(true) => Ok(Self::Apply),
93            Ok(false) => Ok(Self::Off),
94            Err(_) => Err(ConfigError::InvalidDirectoryRenames {
95                value: value.to_owned(),
96            }),
97        }
98    }
99
100    /// The mode set by [`Self::CONFIG_KEY`] in `config`, or [`Self::Conflict`] when unset.
101    ///
102    /// # Errors
103    ///
104    /// [`ConfigError::InvalidDirectoryRenames`] when the value is not recognised.
105    pub fn from_config(config: &ConfigSet) -> std::result::Result<Self, ConfigError> {
106        config
107            .get(Self::CONFIG_KEY)
108            .map_or(Ok(Self::default()), |value| Self::from_config_value(&value))
109    }
110
111    /// [`Self::from_config`] for the configuration of `repo`.
112    ///
113    /// # Errors
114    ///
115    /// Config load failures, and [`ConfigError::InvalidDirectoryRenames`].
116    pub fn from_repo(repo: &Repository) -> crate::error::Result<Self> {
117        let config = repo.config()?;
118        Ok(Self::from_config(&config)?)
119    }
120}
121
122/// One side of a three-way merge.
123#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
124pub enum MergeSide {
125    /// The side merged into (index stage 2).
126    Ours,
127    /// The side merged in (index stage 3).
128    Theirs,
129}
130
131impl MergeSide {
132    /// Index stage holding this side's version of an unmerged path (2 or 3).
133    #[must_use]
134    pub fn index_stage(self) -> u8 {
135        match self {
136            Self::Ours => 2,
137            Self::Theirs => 3,
138        }
139    }
140}
141
142/// A directory rename that a merge could not settle on its own.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum DirectoryRenameConflict {
145    /// A file was moved into the renamed directory and left unmerged
146    /// ([`DirectoryRenameMode::Conflict`]) so the user can confirm its new location.
147    Moved {
148        /// Path the adding side gave the file, inside the old directory.
149        from: Vec<u8>,
150        /// Path in the renamed directory, where the merge put it.
151        to: Vec<u8>,
152        /// Side that added the file (or renamed it into the old directory).
153        added_by: MergeSide,
154    },
155    /// A side spread a directory's files over several directories with no single most popular
156    /// destination, so nothing added to it on the other side was moved.
157    Split {
158        /// The directory in the merge base.
159        dir: Vec<u8>,
160        /// Side that emptied the directory.
161        renamed_by: MergeSide,
162    },
163    /// New files were not moved into a renamed directory because their destination is taken on
164    /// the side that added them, or claimed by several of them. (A file the base or the renaming
165    /// side has there does not block the move; the moved file is merged with it.)
166    Blocked {
167        /// Destination the directory rename gives the files.
168        path: Vec<u8>,
169        /// The new files that would have moved there, sorted; they stay where they were added.
170        sources: Vec<Vec<u8>>,
171        /// `true` when the adding side already has a file or directory at
172        /// [`Self::Blocked::path`] (or the renaming side has a directory there); `false`
173        /// when several new files would land on it.
174        existing: bool,
175    },
176}
177
178impl DirectoryRenameConflict {
179    /// The path the conflict is reported at: the new location for [`Self::Moved`], the old
180    /// directory for [`Self::Split`] and the blocked destination for [`Self::Blocked`].
181    #[must_use]
182    pub fn path(&self) -> &[u8] {
183        match self {
184            Self::Moved { to, .. } => to,
185            Self::Split { dir, .. } => dir,
186            Self::Blocked { path, .. } => path,
187        }
188    }
189}
190
191/// One base path that each side of a merge renamed to a different destination.
192///
193/// Git calls this a rename/rename (1to2) conflict: the merge cannot pick a name, so both
194/// destinations are kept and the path stays unmerged until the user resolves it.
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct RenameRenameConflict {
197    /// Path of the file in the merge base.
198    pub base_path: Vec<u8>,
199    /// Destination the ours side renamed it to.
200    pub ours_path: Vec<u8>,
201    /// Destination the theirs side renamed it to.
202    pub theirs_path: Vec<u8>,
203}
204
205#[derive(Clone, Copy, Debug, Default)]
206/// `WhitespaceMergeOptions` struct.
207pub struct WhitespaceMergeOptions {
208    /// `ignore_all_space` field.
209    pub ignore_all_space: bool,
210    /// `ignore_space_change` field.
211    pub ignore_space_change: bool,
212    /// `ignore_space_at_eol` field.
213    pub ignore_space_at_eol: bool,
214    /// `ignore_cr_at_eol` field.
215    pub ignore_cr_at_eol: bool,
216}
217
218/// How to label the "theirs" side in textual conflict markers.
219#[derive(Clone, Copy, Debug)]
220pub enum TheirsConflictLabel<'a> {
221    /// Use the UTF-8 file path (matches cherry-pick / revert).
222    PathUtf8,
223    /// Fixed label (e.g. `local` for `git checkout -m`).
224    Fixed(&'a str),
225}
226
227/// Labels and marker style for conflict output during tree merges.
228#[derive(Clone, Copy, Debug)]
229pub struct TreeMergeConflictPresentation<'a> {
230    /// Label after `<<<<<<<` (Git: ours side name).
231    pub label_ours: &'a str,
232    /// Label after `>>>>>>>` (path-based or fixed).
233    pub label_theirs: TheirsConflictLabel<'a>,
234    /// Label after `|||||||` in diff3 output.
235    pub label_base: &'a str,
236    /// Two-way vs diff3 markers.
237    pub style: ConflictStyle,
238    /// `git checkout -m`: always leave unmerged index entries when ours ≠ theirs.
239    pub checkout_merge: bool,
240    /// Ours-side commit oid for `path~<commit>` when relocating a blocking **ours** file.
241    pub df_directory_ours_commit: Option<ObjectId>,
242    /// Theirs-side commit oid for `path~<commit>` when relocating a blocking **theirs** file.
243    pub df_directory_theirs_commit: Option<ObjectId>,
244}
245
246impl Default for TreeMergeConflictPresentation<'_> {
247    fn default() -> Self {
248        Self {
249            label_ours: "HEAD",
250            label_theirs: TheirsConflictLabel::PathUtf8,
251            label_base: "merged common ancestors",
252            style: ConflictStyle::Merge,
253            checkout_merge: false,
254            df_directory_ours_commit: None,
255            df_directory_theirs_commit: None,
256        }
257    }
258}
259
260/// Conflict-marker labels for replaying one commit onto a new base (pick and rebase).
261///
262/// The incoming side is named `"<abbrev> (<subject>)"` and the diff3 base
263/// `"parent of <abbrev> (<subject>)"`, the labels Git uses for the same replays, so each
264/// stop of a multi-commit replay names the commit it came from. Ours stays `HEAD`.
265pub(crate) struct ReplayConflictLabels {
266    theirs: String,
267    base: String,
268}
269
270impl ReplayConflictLabels {
271    /// Labels for replaying `commit`, whose message subject line is `subject`.
272    pub(crate) fn new(commit: ObjectId, subject: &str) -> Self {
273        let theirs = format!("{} ({subject})", &commit.to_hex()[..7]);
274        let base = format!("parent of {theirs}");
275        Self { theirs, base }
276    }
277
278    /// Presentation using these labels with marker `style`; other fields keep their defaults.
279    pub(crate) fn presentation(&self, style: ConflictStyle) -> TreeMergeConflictPresentation<'_> {
280        TreeMergeConflictPresentation {
281            label_theirs: TheirsConflictLabel::Fixed(&self.theirs),
282            label_base: &self.base,
283            style,
284            ..TreeMergeConflictPresentation::default()
285        }
286    }
287}
288
289#[expect(clippy::too_many_arguments)]
290/// Merge `base` + `ours` + `their` trees (by OID) into an index using rename detection.
291///
292/// Paths in the result follow the **ours** tree naming. Rename detection uses a 50%
293/// similarity threshold, matching Git's default rename detection during merges.
294///
295/// A rename onto a path the other side also fills with a different file (two files renamed
296/// to one name, or a rename onto an added file) is an add/add conflict at that path, as in
297/// Git: stage 2 and stage 3 hold each side's file, a renamed one already merged with the other
298/// side's edits of its source (conflict markers included when those overlap), and the old
299/// names are gone. Nothing is dropped until the user resolves the path.
300///
301/// # Parameters
302///
303/// - `base_tree` — common ancestor tree (parent tree for cherry-pick, reverted commit tree for revert).
304/// - `ours_tree` — current branch tree (HEAD during pick/revert).
305/// - `theirs_tree` — tree being applied (picked commit for cherry-pick, parent of reverted commit for revert).
306/// - `favor` / `ws` — merge strategy favour and whitespace options for textual merges.
307/// - `diff_algorithm` — optional line diff algorithm name used for textual merges.
308/// - `presentation` — conflict marker labels and style (`label_base` is the diff3 "base" name).
309/// - `directory_renames` — whether files one side added inside a directory the other side
310///   renamed follow the rename ([`DirectoryRenameMode`]; callers usually pass
311///   [`DirectoryRenameMode::from_repo`]). Conflicts are reported in
312///   [`TreeMergeOutput::directory_rename_conflicts`].
313///
314/// # Errors
315///
316/// Returns [`crate::error::Error`] on ODB read failures or corrupt trees.
317pub fn merge_trees_three_way(
318    repo: &Repository,
319    base_tree: ObjectId,
320    ours_tree: ObjectId,
321    theirs_tree: ObjectId,
322    favor: MergeFavor,
323    ws: WhitespaceMergeOptions,
324    diff_algorithm: Option<&str>,
325    presentation: TreeMergeConflictPresentation<'_>,
326    directory_renames: DirectoryRenameMode,
327) -> crate::error::Result<TreeMergeOutput> {
328    merge_trees_three_way_cached(
329        repo,
330        &mut MergeTreeCache::default(),
331        base_tree,
332        ours_tree,
333        theirs_tree,
334        favor,
335        ws,
336        diff_algorithm,
337        presentation,
338        directory_renames,
339    )
340}
341
342/// Like [`merge_trees_three_way`], reusing parsed-tree cache across calls (e.g. replay sequences).
343#[expect(clippy::too_many_arguments)]
344pub(crate) fn merge_trees_three_way_cached(
345    repo: &Repository,
346    cache: &mut MergeTreeCache,
347    base_tree: ObjectId,
348    ours_tree: ObjectId,
349    theirs_tree: ObjectId,
350    favor: MergeFavor,
351    ws: WhitespaceMergeOptions,
352    diff_algorithm: Option<&str>,
353    presentation: TreeMergeConflictPresentation<'_>,
354    directory_renames: DirectoryRenameMode,
355) -> crate::error::Result<TreeMergeOutput> {
356    merge_trees_at_depth(
357        repo,
358        cache,
359        base_tree,
360        ours_tree,
361        theirs_tree,
362        favor,
363        ws,
364        diff_algorithm,
365        presentation,
366        directory_renames,
367        0,
368        None,
369    )
370}
371
372/// Precomputed fork→onto file renames for [`merge_trees_replay_transplant`].
373pub(crate) struct ReplayUpstreamRenames<'a> {
374    pub cached_ours_old_to_new: &'a HashMap<Vec<u8>, Vec<u8>>,
375    pub fork_onto_rename_triples: &'a [(Vec<u8>, Vec<u8>, u32)],
376    pub upstream_touched: &'a HashSet<Vec<u8>>,
377    pub commit_theirs_diff: Vec<crate::diff::DiffEntry>,
378}
379
380/// Three-way merge for replay transplant when upstream fork→onto renames are precomputed.
381///
382/// Skips diffing `base_tree`→`ours_tree` (the expensive upstream delta) and reuses
383/// `cached_ours_old_to_new` for rename alignment. `commit_theirs_diff` must be `base_tree`→`theirs_tree`.
384#[expect(clippy::too_many_arguments)]
385pub(crate) fn merge_trees_replay_transplant(
386    repo: &Repository,
387    cache: &mut MergeTreeCache,
388    base_tree: ObjectId,
389    ours_tree: ObjectId,
390    theirs_tree: ObjectId,
391    commit_theirs_diff: Vec<crate::diff::DiffEntry>,
392    cached_ours_old_to_new: &HashMap<Vec<u8>, Vec<u8>>,
393    fork_onto_rename_triples: &[(Vec<u8>, Vec<u8>, u32)],
394    upstream_touched: &HashSet<Vec<u8>>,
395    favor: MergeFavor,
396    ws: WhitespaceMergeOptions,
397    diff_algorithm: Option<&str>,
398    presentation: TreeMergeConflictPresentation<'_>,
399    directory_renames: DirectoryRenameMode,
400) -> crate::error::Result<TreeMergeOutput> {
401    merge_trees_at_depth(
402        repo,
403        cache,
404        base_tree,
405        ours_tree,
406        theirs_tree,
407        favor,
408        ws,
409        diff_algorithm,
410        presentation,
411        directory_renames,
412        0,
413        Some(ReplayUpstreamRenames {
414            cached_ours_old_to_new,
415            fork_onto_rename_triples,
416            upstream_touched,
417            commit_theirs_diff,
418        }),
419    )
420}
421
422fn paths_overlap_for_merge(a: &[u8], b: &[u8]) -> bool {
423    a.starts_with(b) && (a.len() == b.len() || a.get(b.len()) == Some(&b'/'))
424        || b.starts_with(a) && (b.len() == a.len() || b.get(a.len()) == Some(&b'/'))
425}
426
427fn map_path_dir_prefix(path: &[u8], dir_map: &HashMap<Vec<u8>, Vec<u8>>) -> Option<Vec<u8>> {
428    let mut best: Option<(usize, &Vec<u8>, &Vec<u8>)> = None;
429    for (old_dir, new_dir) in dir_map {
430        if path.starts_with(old_dir)
431            && (path.len() == old_dir.len() || path.get(old_dir.len()) == Some(&b'/'))
432        {
433            let len = old_dir.len();
434            if best.is_none_or(|(bl, _, _)| len > bl) {
435                best = Some((len, old_dir, new_dir));
436            }
437        }
438    }
439    best.map(|(len, _old_dir, new_dir)| {
440        let mut out = new_dir.clone();
441        if path.len() > len {
442            if !out.is_empty() {
443                out.push(b'/');
444            }
445            out.extend_from_slice(&path[len + 1..]);
446        }
447        out
448    })
449}
450
451/// Three-way tree merge at recursion level `call_depth` of a multi-base merge.
452///
453/// Level 0 is the merge the caller asked for. Higher levels merge merge bases into a
454/// virtual base, and their result tree (not their conflict state) is what matters: every
455/// conflict is settled to a single stage-0 entry, as Git's recursive merge does. Content
456/// conflicts keep their markers (each level two characters wider, so nested markers stay
457/// distinct), while modify/delete, binary, symlink, submodule and type-change conflicts keep
458/// the base version. `favor` should be [`MergeFavor::None`] for levels above 0, where
459/// directory renames are never applied.
460#[expect(clippy::too_many_arguments)]
461pub(crate) fn merge_trees_at_depth(
462    repo: &Repository,
463    cache: &mut MergeTreeCache,
464    base_tree: ObjectId,
465    ours_tree: ObjectId,
466    theirs_tree: ObjectId,
467    favor: MergeFavor,
468    ws: WhitespaceMergeOptions,
469    diff_algorithm: Option<&str>,
470    presentation: TreeMergeConflictPresentation<'_>,
471    directory_renames: DirectoryRenameMode,
472    call_depth: usize,
473    replay_upstream: Option<ReplayUpstreamRenames<'_>>,
474) -> crate::error::Result<TreeMergeOutput> {
475    use crate::porcelain::cherry_pick::directory_renames_from_file_renames;
476
477    let directory_renames = if call_depth > 0 {
478        DirectoryRenameMode::Off
479    } else {
480        directory_renames
481    };
482    let odb = &repo.odb();
483
484    let (mut touched, mut ours_old_to_new_pairs, mut theirs_pairs) = if let Some(replay) =
485        replay_upstream
486    {
487        let dir_map = directory_renames_from_file_renames(
488            &replay
489                .cached_ours_old_to_new
490                .iter()
491                .map(|(o, n)| (o.clone(), n.clone()))
492                .collect::<Vec<_>>(),
493        );
494        let mut touched = paths_from_diff_entries(&replay.commit_theirs_diff);
495        for path in touched.clone() {
496            if let Some(new) = replay.cached_ours_old_to_new.get(&path) {
497                touched.insert(new.clone());
498            }
499            if let Some(mapped) = map_path_dir_prefix(&path, &dir_map) {
500                touched.insert(mapped);
501            }
502        }
503        let commit_touched = touched.clone();
504        if commit_touched.iter().any(|p| {
505            replay.upstream_touched.contains(p) && !replay.cached_ours_old_to_new.contains_key(p)
506        }) {
507            touched.extend(replay.cached_ours_old_to_new.keys().cloned());
508            touched.extend(replay.cached_ours_old_to_new.values().cloned());
509            touched.extend(replay.upstream_touched.iter().cloned());
510        } else {
511            for path in replay.upstream_touched.iter() {
512                if touched.iter().any(|t| paths_overlap_for_merge(t, path)) {
513                    touched.insert(path.clone());
514                }
515            }
516        }
517        for (old, new) in replay.cached_ours_old_to_new {
518            if old != new
519                && (touched.contains(old)
520                    || touched.contains(new)
521                    || touched.iter().any(|t| paths_overlap_for_merge(t, old)))
522            {
523                touched.insert(old.clone());
524                touched.insert(new.clone());
525            }
526        }
527        let ours_old_to_new_pairs: Vec<RenameTriple> = replay
528            .fork_onto_rename_triples
529            .iter()
530            .filter(|(old, new, _)| old != new && (touched.contains(old) || touched.contains(new)))
531            .cloned()
532            .collect();
533        let theirs_pairs = rename_pairs_from_diff_entries(odb, replay.commit_theirs_diff)?;
534        (touched, ours_old_to_new_pairs, theirs_pairs)
535    } else {
536        let ours_diff = diff_trees(odb, Some(&base_tree), Some(&ours_tree), "")?;
537        let theirs_diff = diff_trees(odb, Some(&base_tree), Some(&theirs_tree), "")?;
538        let mut touched = paths_from_diff_entries(&ours_diff);
539        touched.extend(paths_from_diff_entries(&theirs_diff));
540        let ours_old_to_new_pairs = rename_pairs_from_diff_entries(odb, ours_diff)?;
541        let theirs_pairs = rename_pairs_from_diff_entries(odb, theirs_diff)?;
542        (touched, ours_old_to_new_pairs, theirs_pairs)
543    };
544
545    let (mut base, mut ours, mut theirs) = merge_trees_walk::build_sparse_maps(
546        cache,
547        repo,
548        base_tree,
549        ours_tree,
550        theirs_tree,
551        &touched,
552    )?;
553
554    augment_renames_with_same_blob(&base, &ours, &mut ours_old_to_new_pairs);
555    augment_renames_with_same_blob(&base, &theirs, &mut theirs_pairs);
556
557    let mut df_escapes = DfEscapes::default();
558    if let (Some(ours_commit), Some(theirs_commit)) = (
559        presentation.df_directory_ours_commit,
560        presentation.df_directory_theirs_commit,
561    ) {
562        df_escapes = apply_df_file_directory_conflicts_trees(
563            cache,
564            repo,
565            &base_tree,
566            &ours_tree,
567            &theirs_tree,
568            &mut base,
569            &mut ours,
570            &mut theirs,
571            &mut touched,
572            ours_commit,
573            theirs_commit,
574        )?;
575    }
576
577    let mut ours_new_to_old: HashMap<Vec<u8>, Vec<u8>> = HashMap::new();
578    let mut ours_best_by_dest: HashMap<Vec<u8>, (Vec<u8>, u32)> = HashMap::new();
579    for (old, new, score) in &ours_old_to_new_pairs {
580        let new_b = new.clone();
581        let should_take = match ours_best_by_dest.get(&new_b) {
582            None => true,
583            Some((_, s)) => *score > *s,
584        };
585        if should_take {
586            ours_best_by_dest.insert(new_b, (old.clone(), *score));
587        }
588    }
589    for (new_path, (old_path, _)) in ours_best_by_dest {
590        ours_new_to_old.insert(new_path, old_path);
591    }
592    let mut theirs_old_to_new: HashMap<Vec<u8>, Vec<u8>> = HashMap::new();
593    let mut best_by_dest: HashMap<Vec<u8>, (Vec<u8>, u32)> = HashMap::new();
594    for (old, new, score) in theirs_pairs {
595        let new_b = new.clone();
596        let should_take = match best_by_dest.get(&new_b) {
597            None => true,
598            Some((_, s)) => score > *s,
599        };
600        if should_take {
601            best_by_dest.insert(new_b, (old.clone(), score));
602        }
603    }
604    for (new_path, (old_path, _)) in best_by_dest {
605        theirs_old_to_new.insert(old_path, new_path);
606    }
607
608    for (old, new, _) in &ours_old_to_new_pairs {
609        touched.insert(old.clone());
610        touched.insert(new.clone());
611    }
612    for (old, new) in &theirs_old_to_new {
613        touched.insert(old.clone());
614        touched.insert(new.clone());
615    }
616    let rename_conflicts =
617        rename_rename_conflicts(&ours_new_to_old, &theirs_old_to_new, &base, &ours, &theirs);
618    let directory_rename_plan = if directory_renames == DirectoryRenameMode::Off {
619        None
620    } else {
621        let ours_changes = merge_dir_renames::SideChanges {
622            tree: ours_tree,
623            files: &ours,
624            renames: ours_new_to_old
625                .iter()
626                .filter(|(new, old)| new != old)
627                .map(|(new, old)| (old.as_slice(), new.as_slice()))
628                .collect(),
629            new_paths: paths_missing_from(&ours, &base),
630        };
631        let theirs_changes = merge_dir_renames::SideChanges {
632            tree: theirs_tree,
633            files: &theirs,
634            renames: theirs_old_to_new
635                .iter()
636                .filter(|(old, new)| old != new)
637                .map(|(old, new)| (old.as_slice(), new.as_slice()))
638                .collect(),
639            new_paths: paths_missing_from(&theirs, &base),
640        };
641        Some(merge_dir_renames::plan_directory_renames(
642            cache,
643            odb,
644            base_tree,
645            &base,
646            &ours_changes,
647            &theirs_changes,
648        )?)
649    };
650
651    drop(ours_old_to_new_pairs);
652    let mut output = merge_trees_walk::three_way_on_touched_paths(
653        cache,
654        repo,
655        base_tree,
656        ours_tree,
657        theirs_tree,
658        &touched,
659        &base,
660        &ours,
661        &theirs,
662        &ours_new_to_old,
663        &theirs_old_to_new,
664        rename_conflicts,
665        favor,
666        ws,
667        diff_algorithm,
668        presentation,
669        call_depth,
670    )?;
671    for orig in &df_escapes.relocated_from {
672        output.index.remove_path_all_stages(orig);
673    }
674    for (path, entry) in df_escapes.stage2 {
675        output.index.remove_path_all_stages(&path);
676        stage_entry(&mut output.index, &path, &entry, 2);
677        output.touched_paths.insert(path);
678    }
679    for (path, entry) in df_escapes.stage3 {
680        output.index.remove_path_all_stages(&path);
681        stage_entry(&mut output.index, &path, &entry, 3);
682        output.touched_paths.insert(path);
683    }
684    output.index.sort();
685    drop((base, ours, theirs, touched));
686    let mut renamed_away = HashSet::new();
687    for (old, new) in &theirs_old_to_new {
688        if old != new {
689            renamed_away.insert(old.clone());
690        }
691    }
692    for (new, old) in &ours_new_to_old {
693        if new != old {
694            renamed_away.insert(old.clone());
695        }
696    }
697    drop((ours_new_to_old, theirs_old_to_new));
698    if let Some(plan) = directory_rename_plan {
699        merge_dir_renames::apply_directory_rename_plan(
700            &mut output,
701            plan,
702            directory_renames,
703            &mut renamed_away,
704            &merge_dir_renames::DestinationMerge {
705                ctx: &PathMergeContext::new(repo, call_depth),
706                favor,
707                ws,
708                diff_algorithm,
709                presentation,
710            },
711        )?;
712    }
713    output.result_tree = merge_trees_walk::finish_merge_result_tree(
714        cache,
715        odb,
716        &output,
717        base_tree,
718        ours_tree,
719        theirs_tree,
720        &renamed_away,
721    )?;
722    Ok(output)
723}
724
725/// Paths of `side` that `base` has no file at.
726fn paths_missing_from<'a>(
727    side: &'a BTreeMap<Vec<u8>, IndexEntry>,
728    base: &BTreeMap<Vec<u8>, IndexEntry>,
729) -> Vec<&'a [u8]> {
730    side.keys()
731        .filter(|path| !base.contains_key(*path))
732        .map(Vec::as_slice)
733        .collect()
734}
735
736fn augment_renames_with_same_blob(
737    base: &BTreeMap<Vec<u8>, IndexEntry>,
738    side: &BTreeMap<Vec<u8>, IndexEntry>,
739    pairs: &mut Vec<RenamePathPair>,
740) {
741    use crate::porcelain::cherry_pick::same_blob_renames_from_entries;
742    for (old, new) in same_blob_renames_from_entries(
743        base.iter(),
744        side.iter(),
745        |path| base.contains_key(path),
746        |path| side.contains_key(path),
747    ) {
748        if pairs
749            .iter()
750            .any(|(o, n, _)| o.as_slice() == old.as_slice() && n.as_slice() == new.as_slice())
751        {
752            continue;
753        }
754        pairs.push((old, new, 100));
755    }
756}
757
758/// Base files each side renamed to a different, otherwise unoccupied destination.
759///
760/// Destinations that collide with another file on the opposite side (rename/add,
761/// rename/rename 2to1) become add/add conflicts in the path-by-path walk.
762fn rename_rename_conflicts(
763    ours_new_to_old: &HashMap<Vec<u8>, Vec<u8>>,
764    theirs_old_to_new: &HashMap<Vec<u8>, Vec<u8>>,
765    base: &BTreeMap<Vec<u8>, IndexEntry>,
766    ours: &BTreeMap<Vec<u8>, IndexEntry>,
767    theirs: &BTreeMap<Vec<u8>, IndexEntry>,
768) -> Vec<RenameRenameConflict> {
769    let mut out: Vec<RenameRenameConflict> = ours_new_to_old
770        .iter()
771        .filter_map(|(ours_path, base_path)| {
772            let theirs_path = theirs_old_to_new.get(base_path)?;
773            let distinct =
774                ours_path != base_path && theirs_path != base_path && theirs_path != ours_path;
775            let unoccupied = !ours.contains_key(base_path)
776                && !theirs.contains_key(base_path)
777                && !ours.contains_key(theirs_path)
778                && !theirs.contains_key(ours_path);
779            let present = base.contains_key(base_path)
780                && ours.contains_key(ours_path)
781                && theirs.contains_key(theirs_path);
782            (distinct && unoccupied && present).then(|| RenameRenameConflict {
783                base_path: base_path.clone(),
784                ours_path: ours_path.clone(),
785                theirs_path: theirs_path.clone(),
786            })
787        })
788        .collect();
789    out.sort_by(|a, b| a.base_path.cmp(&b.base_path));
790    out
791}
792
793/// Record a rename/rename (1to2) conflict the way Git's index does.
794///
795/// The base entry stays at stage 1 under the old name. Both destinations get the three-way
796/// merge of the content (with conflict markers when the edits overlap): stage 2 at the ours
797/// name and stage 3 at the theirs name, and the same blob in the work tree. Content that
798/// cannot be merged line by line (binary, symlink, gitlink) keeps each side's own version.
799#[expect(clippy::too_many_arguments)]
800pub(crate) fn merge_rename_rename(
801    ctx: &PathMergeContext<'_>,
802    index: &mut Index,
803    conflict_content: &mut BTreeMap<Vec<u8>, ObjectId>,
804    conflict: &RenameRenameConflict,
805    base: &IndexEntry,
806    ours: &IndexEntry,
807    theirs: &IndexEntry,
808    favor: MergeFavor,
809    ws: WhitespaceMergeOptions,
810    diff_algorithm: Option<&str>,
811    presentation: TreeMergeConflictPresentation<'_>,
812) -> crate::error::Result<()> {
813    let merged = renamed_file_content(
814        ctx,
815        [
816            &conflict.base_path,
817            &conflict.ours_path,
818            &conflict.theirs_path,
819        ],
820        base,
821        ours,
822        theirs,
823        favor,
824        ws,
825        diff_algorithm,
826        presentation,
827    )?;
828    let (at_ours, at_theirs) = match &merged {
829        Some(entry) => (entry, entry),
830        None => (ours, theirs),
831    };
832    stage_entry(index, &conflict.base_path, base, 1);
833    stage_entry(index, &conflict.ours_path, at_ours, 2);
834    stage_entry(index, &conflict.theirs_path, at_theirs, 3);
835    conflict_content.insert(conflict.ours_path.clone(), at_ours.oid);
836    conflict_content.insert(conflict.theirs_path.clone(), at_theirs.oid);
837    Ok(())
838}
839
840/// Three-way merge of one file that a merge side renamed, or `None` when each side keeps its own
841/// blob (binary, symlink or gitlink content).
842///
843/// `paths` holds the file's base, ours and theirs paths; they label the conflict markers, which
844/// are 8 characters wide so the blob matches the one Git writes for a renamed file.
845#[expect(clippy::too_many_arguments)]
846fn renamed_file_content(
847    ctx: &PathMergeContext<'_>,
848    paths: [&[u8]; 3],
849    base: &IndexEntry,
850    ours: &IndexEntry,
851    theirs: &IndexEntry,
852    favor: MergeFavor,
853    ws: WhitespaceMergeOptions,
854    diff_algorithm: Option<&str>,
855    presentation: TreeMergeConflictPresentation<'_>,
856) -> crate::error::Result<Option<IndexEntry>> {
857    let [base_path, ours_path, theirs_path] = paths;
858    if same_blob(ours, theirs) || same_blob(base, theirs) {
859        return Ok(Some(ours.clone()));
860    }
861    if same_blob(base, ours) {
862        return Ok(Some(theirs.clone()));
863    }
864    let regular = |e: &IndexEntry| matches!(e.mode, 0o100644 | 0o100755);
865    if !(regular(base) && regular(ours) && regular(theirs)) {
866        return Ok(None);
867    }
868    let repo = ctx.repo;
869    let base_obj = repo.odb().read(&base.oid)?;
870    let ours_obj = repo.odb().read(&ours.oid)?;
871    let theirs_obj = repo.odb().read(&theirs.oid)?;
872    let favor = merge_favor_for_path(ctx.file_attrs(base_path).as_ref(), favor);
873    let binary = |path: &[u8], data: &[u8]| {
874        is_binary(data) || attrs_force_binary(ctx.file_attrs(path).as_ref())
875    };
876    if binary(base_path, &base_obj.data)
877        || binary(ours_path, &ours_obj.data)
878        || binary(theirs_path, &theirs_obj.data)
879    {
880        if ctx.virtual_base() {
881            return Ok(Some(base.clone()));
882        }
883        return Ok(match favor {
884            MergeFavor::Ours => Some(ours.clone()),
885            MergeFavor::Theirs => Some(theirs.clone()),
886            _ => None,
887        });
888    }
889
890    let label_base = format!(
891        "{}:{}",
892        presentation.label_base,
893        String::from_utf8_lossy(base_path)
894    );
895    let label_ours = format!(
896        "{}:{}",
897        presentation.label_ours,
898        String::from_utf8_lossy(ours_path)
899    );
900    let theirs_path = String::from_utf8_lossy(theirs_path);
901    let label_theirs = match presentation.label_theirs {
902        TheirsConflictLabel::PathUtf8 => theirs_path.into_owned(),
903        TheirsConflictLabel::Fixed(s) => format!("{s}:{theirs_path}"),
904    };
905    let result = merge(&MergeInput {
906        base: &base_obj.data,
907        ours: &ours_obj.data,
908        theirs: &theirs_obj.data,
909        label_ours: &label_ours,
910        label_base: &label_base,
911        label_theirs: &label_theirs,
912        favor,
913        style: presentation.style,
914        marker_size: 8 + 2 * ctx.call_depth,
915        diff_algorithm: diff_algorithm.map(str::to_owned),
916        ignore_all_space: ws.ignore_all_space,
917        ignore_space_change: ws.ignore_space_change,
918        ignore_space_at_eol: ws.ignore_space_at_eol,
919        ignore_cr_at_eol: ws.ignore_cr_at_eol,
920    })?;
921    let mut entry = ours.clone();
922    entry.oid = repo.odb().write(ObjectKind::Blob, &result.content)?;
923    if base.mode == ours.mode {
924        entry.mode = theirs.mode;
925    }
926    Ok(Some(entry))
927}
928
929#[derive(Default)]
930struct DfEscapes {
931    stage2: BTreeMap<Vec<u8>, IndexEntry>,
932    stage3: BTreeMap<Vec<u8>, IndexEntry>,
933    relocated_from: Vec<Vec<u8>>,
934}
935
936fn side_has_descendant(side: &BTreeMap<Vec<u8>, IndexEntry>, prefix: &[u8]) -> bool {
937    side.keys()
938        .any(|p| p.len() > prefix.len() && p.starts_with(prefix) && p[prefix.len()] == b'/')
939}
940
941#[expect(clippy::too_many_arguments)]
942fn apply_df_file_directory_conflicts_trees(
943    cache: &mut merge_tree_cache::MergeTreeCache,
944    repo: &Repository,
945    base_tree: &ObjectId,
946    ours_tree: &ObjectId,
947    theirs_tree: &ObjectId,
948    base: &mut BTreeMap<Vec<u8>, IndexEntry>,
949    ours: &mut BTreeMap<Vec<u8>, IndexEntry>,
950    theirs: &mut BTreeMap<Vec<u8>, IndexEntry>,
951    touched: &mut HashSet<Vec<u8>>,
952    ours_commit: ObjectId,
953    theirs_commit: ObjectId,
954) -> crate::error::Result<DfEscapes> {
955    use merge_tree_cache::MergeTreeSide;
956
957    let odb = repo.odb();
958    let mut out = DfEscapes::default();
959    // Relocation mutates the maps; borrow the original path snapshots for prefix queries.
960    let ours_paths: Vec<Vec<u8>> = ours.keys().cloned().collect();
961    let theirs_paths: Vec<Vec<u8>> = theirs.keys().cloned().collect();
962    let theirs_sparse = merge_trees_walk::PathPrefixIndex::new(&theirs_paths);
963    let ours_sparse = merge_trees_walk::PathPrefixIndex::new(&ours_paths);
964    for path in &ours_paths {
965        let Some(entry) = ours.get(path) else {
966            continue;
967        };
968        if entry.mode == 0o040000 {
969            continue;
970        }
971        if !merge_trees_walk::side_tree_has_descendant_at(
972            cache,
973            odb,
974            theirs_tree,
975            MergeTreeSide::Theirs,
976            path,
977            &theirs_sparse,
978        )? {
979            continue;
980        }
981        if skip_df_expansion_ours_file(base, ours, theirs, path) {
982            continue;
983        }
984        if parent_has_file(theirs, path) {
985            continue;
986        }
987        if directory_unchanged_from_base(cache, odb, base_tree, theirs_tree, path)? {
988            continue;
989        }
990        let escaped = escape_df_path_side(ours, path, ours_commit, &mut out.stage2);
991        out.relocated_from.push(path.clone());
992        touched.insert(path.clone());
993        if let Some(p) = escaped {
994            touched.insert(p);
995        }
996    }
997    for path in &theirs_paths {
998        let Some(entry) = theirs.get(path) else {
999            continue;
1000        };
1001        if entry.mode == 0o040000 {
1002            continue;
1003        }
1004        if !merge_trees_walk::side_tree_has_descendant_at(
1005            cache,
1006            odb,
1007            ours_tree,
1008            MergeTreeSide::Ours,
1009            path,
1010            &ours_sparse,
1011        )? {
1012            continue;
1013        }
1014        if skip_df_expansion_theirs_file(base, ours, theirs, path) {
1015            continue;
1016        }
1017        if parent_has_file(ours, path) {
1018            continue;
1019        }
1020        if directory_unchanged_from_base(cache, odb, base_tree, ours_tree, path)? {
1021            continue;
1022        }
1023        let escaped = escape_df_path_side(theirs, path, theirs_commit, &mut out.stage3);
1024        out.relocated_from.push(path.clone());
1025        touched.insert(path.clone());
1026        if let Some(p) = escaped {
1027            touched.insert(p);
1028        }
1029    }
1030    let _ = base;
1031    Ok(out)
1032}
1033
1034/// True when `side_tree` holds exactly the base's directory at `path`.
1035///
1036/// The other side put a file at `path`, so it has nothing under `path/` and deleted every base
1037/// entry there. With the directory side unchanged, all of those deletions merge cleanly, nothing
1038/// is left under `path/`, and the file does not clash with a directory.
1039fn directory_unchanged_from_base(
1040    cache: &mut merge_tree_cache::MergeTreeCache,
1041    odb: &Odb,
1042    base_tree: &ObjectId,
1043    side_tree: &ObjectId,
1044    path: &[u8],
1045) -> crate::error::Result<bool> {
1046    let Some(base) = merge_trees_walk::lookup_index_entry_at_path(cache, odb, base_tree, path)?
1047    else {
1048        return Ok(false);
1049    };
1050    let Some(side) = merge_trees_walk::lookup_index_entry_at_path(cache, odb, side_tree, path)?
1051    else {
1052        return Ok(false);
1053    };
1054    Ok(base.mode == 0o040000 && side.mode == 0o040000 && base.oid == side.oid)
1055}
1056
1057fn escape_df_path_side(
1058    side: &mut BTreeMap<Vec<u8>, IndexEntry>,
1059    path: &[u8],
1060    side_commit: ObjectId,
1061    staged: &mut BTreeMap<Vec<u8>, IndexEntry>,
1062) -> Option<Vec<u8>> {
1063    let mut entry = side.remove(path)?;
1064    let hex = side_commit.to_hex();
1065    let mut escaped_path = path.to_vec();
1066    escaped_path.extend_from_slice(b"~");
1067    escaped_path.extend_from_slice(hex.as_bytes());
1068    entry.path = escaped_path.clone();
1069    entry.flags = path_len_flags(&escaped_path);
1070    staged.insert(escaped_path.clone(), entry.clone());
1071    side.insert(escaped_path.clone(), entry);
1072    Some(escaped_path)
1073}
1074
1075/// Git skips D/F relocation when one side only expanded a base file into `path/…`.
1076fn skip_df_expansion_ours_file(
1077    base: &BTreeMap<Vec<u8>, IndexEntry>,
1078    ours: &BTreeMap<Vec<u8>, IndexEntry>,
1079    theirs: &BTreeMap<Vec<u8>, IndexEntry>,
1080    path: &[u8],
1081) -> bool {
1082    let Some(base_e) = base.get(path) else {
1083        return false;
1084    };
1085    let Some(ours_e) = ours.get(path) else {
1086        return false;
1087    };
1088    if base_e.mode == 0o040000 {
1089        return false;
1090    }
1091    if !same_blob(base_e, ours_e) {
1092        return false;
1093    }
1094    if theirs.contains_key(path) {
1095        return false;
1096    }
1097    side_has_descendant(theirs, path)
1098}
1099
1100fn skip_df_expansion_theirs_file(
1101    base: &BTreeMap<Vec<u8>, IndexEntry>,
1102    ours: &BTreeMap<Vec<u8>, IndexEntry>,
1103    theirs: &BTreeMap<Vec<u8>, IndexEntry>,
1104    path: &[u8],
1105) -> bool {
1106    let Some(base_e) = base.get(path) else {
1107        return false;
1108    };
1109    let Some(theirs_e) = theirs.get(path) else {
1110        return false;
1111    };
1112    if base_e.mode == 0o040000 {
1113        return false;
1114    }
1115    if !same_blob(base_e, theirs_e) {
1116        return false;
1117    }
1118    if ours.contains_key(path) {
1119        return false;
1120    }
1121    side_has_descendant(ours, path)
1122}
1123
1124fn parent_has_file(side: &BTreeMap<Vec<u8>, IndexEntry>, path: &[u8]) -> bool {
1125    let Some(pos) = path.iter().rposition(|b| *b == b'/') else {
1126        return false;
1127    };
1128    side.get(&path[..pos]).is_some_and(|e| e.mode != 0o040000)
1129}
1130
1131type RenamePathPair = (Vec<u8>, Vec<u8>, u32);
1132
1133fn paths_from_diff_entries(entries: &[crate::diff::DiffEntry]) -> HashSet<Vec<u8>> {
1134    use crate::diff::DiffStatus;
1135    let mut out = HashSet::new();
1136    for e in entries {
1137        if e.status == DiffStatus::Unmerged {
1138            continue;
1139        }
1140        if let Some(p) = &e.old_path {
1141            out.insert(crate::repo_relpath::storage_bytes(p).into_owned());
1142        }
1143        if let Some(p) = &e.new_path {
1144            out.insert(crate::repo_relpath::storage_bytes(p).into_owned());
1145        }
1146    }
1147    out
1148}
1149
1150fn rename_pairs_from_diff_entries(
1151    odb: &Odb,
1152    entries: Vec<crate::diff::DiffEntry>,
1153) -> crate::error::Result<Vec<RenamePathPair>> {
1154    let entries = detect_renames(odb, None, entries, 50);
1155    let mut out = Vec::new();
1156    for e in entries {
1157        if e.status != DiffStatus::Renamed {
1158            continue;
1159        }
1160        let Some(old) = e.old_path else {
1161            continue;
1162        };
1163        let Some(new) = e.new_path else {
1164            continue;
1165        };
1166        let score = e.score.unwrap_or(0);
1167        out.push((
1168            crate::repo_relpath::storage_bytes(&old).into_owned(),
1169            crate::repo_relpath::storage_bytes(&new).into_owned(),
1170            score,
1171        ));
1172    }
1173    Ok(out)
1174}
1175
1176#[expect(clippy::too_many_arguments)]
1177pub(crate) fn merge_one_path(
1178    ctx: &PathMergeContext<'_>,
1179    index: &mut Index,
1180    conflict_content: &mut BTreeMap<Vec<u8>, ObjectId>,
1181    out_path: &[u8],
1182    b: Option<&IndexEntry>,
1183    o: Option<&IndexEntry>,
1184    t: Option<&IndexEntry>,
1185    favor: MergeFavor,
1186    ws: WhitespaceMergeOptions,
1187    diff_algorithm: Option<&str>,
1188    presentation: TreeMergeConflictPresentation<'_>,
1189) -> crate::error::Result<()> {
1190    match (b, o, t) {
1191        (_, Some(oe), Some(te)) if same_blob(oe, te) => {
1192            let mut e = oe.clone();
1193            e.path = out_path.to_vec();
1194            e.flags = path_len_flags(out_path);
1195            index.push_entry_unsorted(e);
1196        }
1197        (Some(be), Some(oe), Some(te)) if same_blob(be, oe) => {
1198            let mut e = te.clone();
1199            e.path = out_path.to_vec();
1200            e.flags = path_len_flags(out_path);
1201            index.push_entry_unsorted(e);
1202        }
1203        (Some(be), Some(oe), Some(te)) if same_blob(be, te) => {
1204            let mut e = oe.clone();
1205            e.path = out_path.to_vec();
1206            e.flags = path_len_flags(out_path);
1207            index.push_entry_unsorted(e);
1208        }
1209        (Some(be), Some(oe), Some(te))
1210            if be.mode == oe.mode
1211                && oe.mode == te.mode
1212                && matches!(be.mode, 0o120000 | 0o160000) =>
1213        {
1214            if ctx.virtual_base() {
1215                push_stage0(index, out_path, be);
1216            } else if same_blob(oe, te) {
1217                let mut e = oe.clone();
1218                e.path = out_path.to_vec();
1219                e.flags = path_len_flags(out_path);
1220                index.push_entry_unsorted(e);
1221            } else if same_blob(be, oe) {
1222                let mut e = te.clone();
1223                e.path = out_path.to_vec();
1224                e.flags = path_len_flags(out_path);
1225                index.push_entry_unsorted(e);
1226            } else if same_blob(be, te) {
1227                let mut e = oe.clone();
1228                e.path = out_path.to_vec();
1229                e.flags = path_len_flags(out_path);
1230                index.push_entry_unsorted(e);
1231            } else {
1232                stage_entry(index, out_path, be, 1);
1233                stage_entry(index, out_path, oe, 2);
1234                stage_entry(index, out_path, te, 3);
1235            }
1236        }
1237        (Some(be), Some(oe), Some(te)) => {
1238            content_merge_or_conflict(
1239                ctx,
1240                index,
1241                conflict_content,
1242                out_path,
1243                be,
1244                oe,
1245                te,
1246                favor,
1247                ws,
1248                diff_algorithm,
1249                presentation,
1250            )?;
1251        }
1252        (None, Some(oe), None) => {
1253            let mut e = oe.clone();
1254            e.path = out_path.to_vec();
1255            e.flags = path_len_flags(out_path);
1256            index.push_entry_unsorted(e);
1257        }
1258        (None, None, Some(te)) => {
1259            let mut e = te.clone();
1260            e.path = out_path.to_vec();
1261            e.flags = path_len_flags(out_path);
1262            index.push_entry_unsorted(e);
1263        }
1264        (None, Some(oe), Some(te)) if same_blob(oe, te) => {
1265            let mut e = oe.clone();
1266            e.path = out_path.to_vec();
1267            e.flags = path_len_flags(out_path);
1268            index.push_entry_unsorted(e);
1269        }
1270        (None, Some(oe), Some(te)) => {
1271            // add/add conflict: both sides introduced the path with differing content and there
1272            // is no merge base. Git performs a content merge against an empty base, leaving
1273            // conflict markers in the working tree (and unmerged stages 2/3 in the index). We
1274            // previously only recorded the index stages, leaving the working-tree file as the
1275            // plain "ours" blob — that hid the conflict from `rerere` and the user (t3504).
1276            add_add_content_conflict(
1277                ctx,
1278                index,
1279                conflict_content,
1280                out_path,
1281                oe,
1282                te,
1283                favor,
1284                ws,
1285                diff_algorithm,
1286                presentation,
1287            )?;
1288        }
1289        (Some(_), None, None) => {}
1290        (Some(be), Some(oe), None) if same_blob(be, oe) && be.path.as_slice() == out_path => {}
1291        (Some(be), None, Some(te)) if same_blob(be, te) => {}
1292        (Some(be), Some(_), None) | (Some(be), None, Some(_)) if ctx.virtual_base() => {
1293            push_stage0(index, out_path, be);
1294        }
1295        (Some(be), Some(oe), None) => {
1296            stage_entry(index, out_path, be, 1);
1297            stage_entry(index, out_path, oe, 2);
1298            // Modify/delete: deleted on the other side; leave our version in the worktree.
1299            conflict_content.insert(out_path.to_vec(), oe.oid);
1300        }
1301        (Some(be), None, Some(te)) => {
1302            stage_entry(index, out_path, be, 1);
1303            stage_entry(index, out_path, te, 3);
1304            // Modify/delete: deleted on ours; leave their version in the worktree.
1305            conflict_content.insert(out_path.to_vec(), te.oid);
1306        }
1307        (None, None, None) => {}
1308    }
1309    Ok(())
1310}
1311
1312fn path_len_flags(path: &[u8]) -> u16 {
1313    path.len().min(0xFFF) as u16
1314}
1315
1316/// Record `src` as the merged (stage-0) entry at `path`.
1317fn push_stage0(index: &mut Index, path: &[u8], src: &IndexEntry) {
1318    let mut e = src.clone();
1319    e.path = path.to_vec();
1320    e.flags = path_len_flags(path);
1321    index.push_entry_unsorted(e);
1322}
1323
1324/// Record merged `content` at `path` as a stage-0 entry with `mode`.
1325fn push_merged_blob(
1326    repo: &Repository,
1327    index: &mut Index,
1328    path: &[u8],
1329    template: &IndexEntry,
1330    mode: u32,
1331    content: &[u8],
1332) -> crate::error::Result<()> {
1333    let mut e = template.clone();
1334    e.path = path.to_vec();
1335    e.flags = path_len_flags(path);
1336    e.oid = repo.odb().write(ObjectKind::Blob, content)?;
1337    e.mode = mode;
1338    index.push_entry_unsorted(e);
1339    Ok(())
1340}
1341
1342fn same_blob(a: &IndexEntry, b: &IndexEntry) -> bool {
1343    a.oid == b.oid && a.mode == b.mode
1344}
1345
1346/// Repository handle plus the attribute rules shared by every path of one tree merge.
1347///
1348/// The rules come from a single load of the repository index, made the first time a path
1349/// needs a content merge; a failure to load them means "no attributes", as for each path before.
1350pub(crate) struct PathMergeContext<'r> {
1351    repo: &'r Repository,
1352    rules: OnceCell<Option<WorktreeRules>>,
1353    /// Recursion level of a multi-base merge (see [`merge_trees_at_depth`]); 0 for the real merge.
1354    call_depth: usize,
1355}
1356
1357impl<'r> PathMergeContext<'r> {
1358    pub(crate) fn new(repo: &'r Repository, call_depth: usize) -> Self {
1359        Self {
1360            repo,
1361            rules: OnceCell::new(),
1362            call_depth,
1363        }
1364    }
1365
1366    /// True while building a virtual merge base, where every conflict settles to one entry.
1367    fn virtual_base(&self) -> bool {
1368        self.call_depth > 0
1369    }
1370
1371    /// Conflict marker width for `attrs`, widened by two per recursion level.
1372    fn marker_size(&self, attrs: &FileAttrs) -> usize {
1373        attrs.effective_conflict_marker_size() + 2 * self.call_depth
1374    }
1375
1376    /// A virtual base keeps a driver's conflicted output as its content.
1377    fn settle_driver_outcome(&self, outcome: MergeDriverOutcome) -> MergeDriverOutcome {
1378        match outcome {
1379            MergeDriverOutcome::ConflictWithMarkers(content) if self.virtual_base() => {
1380                MergeDriverOutcome::Clean(content)
1381            }
1382            other => other,
1383        }
1384    }
1385
1386    fn file_attrs(&self, path: &[u8]) -> Option<FileAttrs> {
1387        let rules = self
1388            .rules
1389            .get_or_init(|| {
1390                let index = self.repo.load_index().ok()?;
1391                WorktreeRules::from_repository(self.repo, &index).ok()
1392            })
1393            .as_ref()?;
1394        Some(rules.file_attrs(&String::from_utf8_lossy(path), false))
1395    }
1396}
1397
1398fn merge_favor_for_path(attrs: Option<&FileAttrs>, favor: MergeFavor) -> MergeFavor {
1399    match attrs.map(|a| &a.merge) {
1400        Some(MergeAttr::Driver(name)) if favor == MergeFavor::None && name == "union" => {
1401            MergeFavor::Union
1402        }
1403        _ => favor,
1404    }
1405}
1406
1407/// True when attributes force a binary merge (`-merge`, `-text` or `-diff`).
1408fn attrs_force_binary(attrs: Option<&FileAttrs>) -> bool {
1409    attrs.is_some_and(|a| {
1410        matches!(a.merge, MergeAttr::Unset)
1411            || a.text == TextAttr::Unset
1412            || matches!(a.diff_attr, DiffAttr::Unset)
1413    })
1414}
1415
1416fn add_add_executable_mode_conflict(
1417    ours: &IndexEntry,
1418    theirs: &IndexEntry,
1419    favor: MergeFavor,
1420) -> bool {
1421    matches!(ours.mode, 0o100644 | 0o100755)
1422        && matches!(theirs.mode, 0o100644 | 0o100755)
1423        && ours.mode != theirs.mode
1424        && favor == MergeFavor::None
1425}
1426
1427pub(crate) fn stage_entry(index: &mut Index, path: &[u8], src: &IndexEntry, stage: u8) {
1428    let mut e = src.clone();
1429    e.path = path.to_vec();
1430    e.flags = path_len_flags(path) | ((stage as u16) << 12);
1431    index.push_entry_unsorted(e);
1432}
1433
1434#[allow(clippy::too_many_arguments)]
1435fn finish_merge_driver_result(
1436    repo: &Repository,
1437    index: &mut Index,
1438    conflict_content: &mut BTreeMap<Vec<u8>, ObjectId>,
1439    path: &[u8],
1440    base: Option<&IndexEntry>,
1441    ours: &IndexEntry,
1442    theirs: &IndexEntry,
1443    outcome: MergeDriverOutcome,
1444) -> crate::error::Result<()> {
1445    match outcome {
1446        MergeDriverOutcome::Clean(merged) => {
1447            let merged_oid = repo.odb().write(ObjectKind::Blob, &merged)?;
1448            let mut entry = ours.clone();
1449            entry.path = path.to_vec();
1450            entry.flags = path_len_flags(path);
1451            entry.oid = merged_oid;
1452            if let Some(base) = base {
1453                if base.mode == ours.mode && base.mode != theirs.mode {
1454                    entry.mode = theirs.mode;
1455                }
1456            }
1457            index.push_entry_unsorted(entry);
1458        }
1459        MergeDriverOutcome::ConflictWithMarkers(content) => {
1460            let conflict_oid = repo.odb().write(ObjectKind::Blob, &content)?;
1461            conflict_content.insert(path.to_vec(), conflict_oid);
1462            if let Some(base) = base {
1463                stage_entry(index, path, base, 1);
1464            }
1465            stage_entry(index, path, ours, 2);
1466            stage_entry(index, path, theirs, 3);
1467        }
1468        MergeDriverOutcome::Conflict => {
1469            if let Some(base) = base {
1470                stage_entry(index, path, base, 1);
1471            }
1472            stage_entry(index, path, ours, 2);
1473            stage_entry(index, path, theirs, 3);
1474        }
1475    }
1476    Ok(())
1477}
1478#[expect(clippy::too_many_arguments)]
1479fn content_merge_or_conflict(
1480    ctx: &PathMergeContext<'_>,
1481    index: &mut Index,
1482    conflict_content: &mut BTreeMap<Vec<u8>, ObjectId>,
1483    path: &[u8],
1484    base: &IndexEntry,
1485    ours: &IndexEntry,
1486    theirs: &IndexEntry,
1487    favor: MergeFavor,
1488    ws: WhitespaceMergeOptions,
1489    diff_algorithm: Option<&str>,
1490    presentation: TreeMergeConflictPresentation<'_>,
1491) -> crate::error::Result<()> {
1492    if matches!(base.mode, 0o120000 | 0o160000)
1493        || matches!(ours.mode, 0o120000 | 0o160000)
1494        || matches!(theirs.mode, 0o120000 | 0o160000)
1495    {
1496        if ctx.virtual_base() {
1497            push_stage0(index, path, base);
1498            return Ok(());
1499        }
1500        stage_entry(index, path, base, 1);
1501        stage_entry(index, path, ours, 2);
1502        stage_entry(index, path, theirs, 3);
1503        return Ok(());
1504    }
1505
1506    // `git checkout -m` runs the normal three-way line merge below: when the merge auto-resolves
1507    // (non-overlapping edits) Git records a clean stage-0 entry and reports a single `M <path>`;
1508    // only a genuine line conflict (`result.conflicts > 0`) records unmerged stages 1/2/3. The
1509    // marker style (merge vs. diff3) is taken from `presentation.style`, so `--conflict=diff3`
1510    // produces the `||||||| base` section (t7201 5, 6, 9).
1511    let repo = ctx.repo;
1512    let base_obj = repo.odb().read(&base.oid)?;
1513    let ours_obj = repo.odb().read(&ours.oid)?;
1514    let theirs_obj = repo.odb().read(&theirs.oid)?;
1515    let attrs = ctx.file_attrs(path);
1516    let file_attrs = attrs.clone().unwrap_or_default();
1517    let favor = merge_favor_for_path(attrs.as_ref(), favor);
1518    let path_str = String::from_utf8_lossy(path).into_owned();
1519    let label_theirs = match presentation.label_theirs {
1520        TheirsConflictLabel::PathUtf8 => path_str.clone(),
1521        TheirsConflictLabel::Fixed(s) => s.to_owned(),
1522    };
1523    let driver_labels = MergeDriverLabels {
1524        base: presentation.label_base,
1525        ours: presentation.label_ours,
1526        theirs: &label_theirs,
1527    };
1528    if let Some((outcome, _content)) = try_merge_driver(
1529        repo,
1530        &file_attrs,
1531        &path_str,
1532        Some(&base_obj.data),
1533        &ours_obj.data,
1534        &theirs_obj.data,
1535        favor,
1536        presentation.style,
1537        diff_algorithm,
1538        ws.ignore_all_space,
1539        ws.ignore_space_change,
1540        ws.ignore_space_at_eol,
1541        ws.ignore_cr_at_eol,
1542        driver_labels,
1543    )? {
1544        return finish_merge_driver_result(
1545            repo,
1546            index,
1547            conflict_content,
1548            path,
1549            Some(base),
1550            ours,
1551            theirs,
1552            ctx.settle_driver_outcome(outcome),
1553        );
1554    }
1555
1556    if attrs_force_binary(attrs.as_ref())
1557        || is_binary(&base_obj.data)
1558        || is_binary(&ours_obj.data)
1559        || is_binary(&theirs_obj.data)
1560    {
1561        if ctx.virtual_base() {
1562            push_stage0(index, path, base);
1563            return Ok(());
1564        }
1565        match favor {
1566            MergeFavor::Theirs => {
1567                let mut e = theirs.clone();
1568                e.path = path.to_vec();
1569                e.flags = path_len_flags(path);
1570                index.push_entry_unsorted(e);
1571                return Ok(());
1572            }
1573            MergeFavor::Ours => {
1574                let mut e = ours.clone();
1575                e.path = path.to_vec();
1576                e.flags = path_len_flags(path);
1577                index.push_entry_unsorted(e);
1578                return Ok(());
1579            }
1580            _ => {
1581                stage_entry(index, path, base, 1);
1582                stage_entry(index, path, ours, 2);
1583                stage_entry(index, path, theirs, 3);
1584                return Ok(());
1585            }
1586        }
1587    }
1588
1589    let input = MergeInput {
1590        base: &base_obj.data,
1591        ours: &ours_obj.data,
1592        theirs: &theirs_obj.data,
1593        label_ours: presentation.label_ours,
1594        label_base: presentation.label_base,
1595        label_theirs: &label_theirs,
1596        favor,
1597        style: presentation.style,
1598        marker_size: ctx.marker_size(&file_attrs),
1599        diff_algorithm: diff_algorithm.map(str::to_owned),
1600        ignore_all_space: ws.ignore_all_space,
1601        ignore_space_change: ws.ignore_space_change,
1602        ignore_space_at_eol: ws.ignore_space_at_eol,
1603        ignore_cr_at_eol: ws.ignore_cr_at_eol,
1604    };
1605
1606    let result = merge(&input)?;
1607
1608    if result.conflicts > 0 && !ctx.virtual_base() {
1609        let conflict_oid = repo.odb().write(ObjectKind::Blob, &result.content)?;
1610        conflict_content.insert(path.to_vec(), conflict_oid);
1611        stage_entry(index, path, base, 1);
1612        stage_entry(index, path, ours, 2);
1613        stage_entry(index, path, theirs, 3);
1614    } else {
1615        let merged_oid = repo.odb().write(ObjectKind::Blob, &result.content)?;
1616        let mut entry = ours.clone();
1617        entry.path = path.to_vec();
1618        entry.flags = path_len_flags(path);
1619        entry.oid = merged_oid;
1620        if base.mode == ours.mode && base.mode != theirs.mode {
1621            entry.mode = theirs.mode;
1622        }
1623        index.push_entry_unsorted(entry);
1624    }
1625
1626    Ok(())
1627}
1628
1629/// Resolve an add/add conflict (no merge base, both sides created `path`).
1630///
1631/// Mirrors Git's behaviour: a content merge with an empty base. When the line merge does not
1632/// auto-resolve (the common case for genuinely different content), the conflict-marker blob is
1633/// recorded for the working tree and the unmerged stages 2/3 are written to the index. Gitlink
1634/// (submodule) and binary add/adds fall back to plain unmerged stages, matching the
1635/// `content_merge_or_conflict` policy.
1636#[allow(clippy::too_many_arguments)]
1637fn add_add_content_conflict(
1638    ctx: &PathMergeContext<'_>,
1639    index: &mut Index,
1640    conflict_content: &mut BTreeMap<Vec<u8>, ObjectId>,
1641    path: &[u8],
1642    ours: &IndexEntry,
1643    theirs: &IndexEntry,
1644    favor: MergeFavor,
1645    ws: WhitespaceMergeOptions,
1646    diff_algorithm: Option<&str>,
1647    presentation: TreeMergeConflictPresentation<'_>,
1648) -> crate::error::Result<()> {
1649    // Gitlink add/add: no textual merge is possible; leave unmerged stages. A virtual base keeps
1650    // the (absent) base version, so the path is left out.
1651    if matches!(ours.mode, 0o120000 | 0o160000) || matches!(theirs.mode, 0o120000 | 0o160000) {
1652        if ctx.virtual_base() {
1653            return Ok(());
1654        }
1655        stage_entry(index, path, ours, 2);
1656        stage_entry(index, path, theirs, 3);
1657        return Ok(());
1658    }
1659
1660    let repo = ctx.repo;
1661    let ours_obj = repo.odb().read(&ours.oid)?;
1662    let theirs_obj = repo.odb().read(&theirs.oid)?;
1663    let attrs = ctx.file_attrs(path);
1664    let file_attrs = attrs.clone().unwrap_or_default();
1665    let favor = merge_favor_for_path(attrs.as_ref(), favor);
1666    let path_str = String::from_utf8_lossy(path).into_owned();
1667    let label_theirs = match presentation.label_theirs {
1668        TheirsConflictLabel::PathUtf8 => path_str.clone(),
1669        TheirsConflictLabel::Fixed(s) => s.to_owned(),
1670    };
1671    let driver_labels = MergeDriverLabels {
1672        base: presentation.label_base,
1673        ours: presentation.label_ours,
1674        theirs: &label_theirs,
1675    };
1676    if let Some((outcome, content)) = try_merge_driver(
1677        repo,
1678        &file_attrs,
1679        &path_str,
1680        None,
1681        &ours_obj.data,
1682        &theirs_obj.data,
1683        favor,
1684        presentation.style,
1685        diff_algorithm,
1686        ws.ignore_all_space,
1687        ws.ignore_space_change,
1688        ws.ignore_space_at_eol,
1689        ws.ignore_cr_at_eol,
1690        driver_labels,
1691    )? {
1692        let outcome = ctx.settle_driver_outcome(outcome);
1693        if !ctx.virtual_base() && add_add_executable_mode_conflict(ours, theirs, favor) {
1694            let worktree_bytes = match &outcome {
1695                MergeDriverOutcome::Clean(c) | MergeDriverOutcome::ConflictWithMarkers(c) => {
1696                    c.as_slice()
1697                }
1698                MergeDriverOutcome::Conflict => content.as_slice(),
1699            };
1700            let conflict_oid = repo.odb().write(ObjectKind::Blob, worktree_bytes)?;
1701            conflict_content.insert(path.to_vec(), conflict_oid);
1702            stage_entry(index, path, ours, 2);
1703            stage_entry(index, path, theirs, 3);
1704            return Ok(());
1705        }
1706        return finish_merge_driver_result(
1707            repo,
1708            index,
1709            conflict_content,
1710            path,
1711            None,
1712            ours,
1713            theirs,
1714            outcome,
1715        );
1716    }
1717
1718    // Binary add/add: honour an explicit favour, else leave unmerged stages. A virtual base
1719    // takes the empty base content.
1720    if attrs_force_binary(attrs.as_ref())
1721        || is_binary(&ours_obj.data)
1722        || is_binary(&theirs_obj.data)
1723    {
1724        if ctx.virtual_base() {
1725            return push_merged_blob(repo, index, path, ours, ours.mode, b"");
1726        }
1727        match favor {
1728            MergeFavor::Theirs => {
1729                let mut e = theirs.clone();
1730                e.path = path.to_vec();
1731                e.flags = path_len_flags(path);
1732                index.push_entry_unsorted(e);
1733            }
1734            MergeFavor::Ours => {
1735                let mut e = ours.clone();
1736                e.path = path.to_vec();
1737                e.flags = path_len_flags(path);
1738                index.push_entry_unsorted(e);
1739            }
1740            _ => {
1741                stage_entry(index, path, ours, 2);
1742                stage_entry(index, path, theirs, 3);
1743            }
1744        }
1745        return Ok(());
1746    }
1747
1748    let input = MergeInput {
1749        base: b"",
1750        ours: &ours_obj.data,
1751        theirs: &theirs_obj.data,
1752        label_ours: presentation.label_ours,
1753        label_base: presentation.label_base,
1754        label_theirs: &label_theirs,
1755        favor,
1756        style: presentation.style,
1757        marker_size: ctx.marker_size(&file_attrs),
1758        diff_algorithm: diff_algorithm.map(str::to_owned),
1759        ignore_all_space: ws.ignore_all_space,
1760        ignore_space_change: ws.ignore_space_change,
1761        ignore_space_at_eol: ws.ignore_space_at_eol,
1762        ignore_cr_at_eol: ws.ignore_cr_at_eol,
1763    };
1764
1765    let result = merge(&input)?;
1766    if ctx.virtual_base() {
1767        return push_merged_blob(repo, index, path, ours, ours.mode, &result.content);
1768    }
1769
1770    // Git treats an add/add as a conflict whenever the two sides cannot be reconciled to a single
1771    // stage-0 entry. With no merge base there is nothing to disambiguate a mode clash, so when both
1772    // sides introduce the path as a regular file with a different executable bit (100644 vs 100755)
1773    // the result is a conflict even if the *content* merges cleanly — Git records unmerged stages
1774    // 2/3 with the diverging modes (t6411: double mode change with identical content). Type clashes
1775    // (file/symlink/submodule) are handled by the gitlink branch above / elsewhere.
1776    let both_regular_files =
1777        matches!(ours.mode, 0o100644 | 0o100755) && matches!(theirs.mode, 0o100644 | 0o100755);
1778    let mode_conflict = both_regular_files && ours.mode != theirs.mode && favor == MergeFavor::None;
1779
1780    if result.conflicts > 0 || mode_conflict {
1781        let conflict_oid = repo.odb().write(ObjectKind::Blob, &result.content)?;
1782        conflict_content.insert(path.to_vec(), conflict_oid);
1783        stage_entry(index, path, ours, 2);
1784        stage_entry(index, path, theirs, 3);
1785    } else {
1786        // A favour (ours/theirs/union) or identical-after-normalisation merge resolved the
1787        // content cleanly: record a single stage-0 entry with the merged blob.
1788        let merged_oid = repo.odb().write(ObjectKind::Blob, &result.content)?;
1789        let mut entry = ours.clone();
1790        entry.path = path.to_vec();
1791        entry.flags = path_len_flags(path);
1792        entry.oid = merged_oid;
1793        index.push_entry_unsorted(entry);
1794    }
1795
1796    Ok(())
1797}
1798
1799/// Merge sparse [`TreeMergeOutput::index`] entries into `index` at [`TreeMergeOutput::touched_paths`].
1800///
1801/// Removes all stages at each touched path, then merges `merged.index` in one sorted pass via
1802/// [`Index::remove_paths_and_insert`] (same semantics as the former remove/push/sort/dedup cycle).
1803pub fn apply_tree_merge_output_to_index(index: &mut Index, merged: &TreeMergeOutput) {
1804    let paths: Vec<&[u8]> = merged.touched_paths.iter().map(Vec::as_slice).collect();
1805    let replacements: Vec<IndexEntry> = merged.index.entries().to_vec();
1806    index.remove_paths_and_insert(paths, replacements);
1807}
1808
1809/// Refresh stage-0 stat cache for sparse merge index rows after a worktree checkout.
1810///
1811/// Sparse merge entries are built without reading the work tree; without this pass,
1812/// [`apply_tree_merge_output_to_index`] would leave stale zeroed stat fields and
1813/// `status` would report clean merge results as modified.
1814///
1815/// # Errors
1816///
1817/// Propagates work tree and `stat` failures from [`entry_from_stat`].
1818pub fn refresh_tree_merge_index_stat_cache(
1819    repo: &Repository,
1820    index: &mut Index,
1821    merged: &TreeMergeOutput,
1822) -> crate::error::Result<()> {
1823    let Some(work_tree) = repo.work_tree() else {
1824        return Ok(());
1825    };
1826    let mut refreshed_rows = Vec::new();
1827    for entry in merged.index.entries() {
1828        if entry.stage() != 0 {
1829            continue;
1830        }
1831        if entry.mode != MODE_REGULAR && entry.mode != crate::index::MODE_EXECUTABLE {
1832            continue;
1833        }
1834        if let Some(existing) = index.get(&entry.path, 0) {
1835            if existing.oid == entry.oid
1836                && existing.mode == entry.mode
1837                && existing.size > 0
1838                && (existing.mtime_sec > 0 || existing.ctime_sec > 0)
1839            {
1840                continue;
1841            }
1842        }
1843        let abs = worktree_path_from_index_rel(work_tree, &entry.path);
1844        if !abs.is_file() {
1845            continue;
1846        }
1847        refreshed_rows.push(entry_from_stat(&abs, &entry.path, entry.oid, entry.mode)?);
1848    }
1849    index.overwrite_stage0_entries(refreshed_rows);
1850    Ok(())
1851}
1852
1853/// Apply sparse merge index rows and sync stat cache from the work tree when present.
1854///
1855/// Skips paths whose stage-0 row already matches the merge result (including when only
1856/// in-memory extended flags such as fsmonitor-valid differ). Applies the remainder in one
1857/// index pass, then patches stat fields in place for any rows that still need refresh.
1858///
1859/// # Errors
1860///
1861/// Propagates errors from [`refresh_tree_merge_index_stat_cache`].
1862pub fn apply_tree_merge_output_to_worktree_index(
1863    repo: &Repository,
1864    index: &mut Index,
1865    merged: &TreeMergeOutput,
1866) -> crate::error::Result<()> {
1867    if repo.work_tree().is_none() {
1868        apply_tree_merge_output_to_index(index, merged);
1869        return Ok(());
1870    }
1871    let pending = pending_tree_merge_index_rows(index, merged);
1872    if pending.touched_paths.is_empty() && pending.index.entries().is_empty() {
1873        return Ok(());
1874    }
1875    apply_tree_merge_output_to_index(index, &pending);
1876    refresh_tree_merge_index_stat_cache(repo, index, &pending)
1877}
1878
1879/// The part of `merged` that still has to be written into `index`.
1880///
1881/// After a worktree checkout the index usually already holds every merged row, with fresh stat
1882/// data, so re-removing, re-inserting and re-stat'ing those paths only costs O(n) vector moves
1883/// per path. A path is dropped from the result when `index` already has exactly the merged
1884/// stage-0 regular-file row with populated stat data (the state the stat refresh would keep),
1885/// or when neither side has any row for it.
1886fn pending_tree_merge_index_rows(index: &Index, merged: &TreeMergeOutput) -> TreeMergeOutput {
1887    let mut merged_rows: HashMap<&[u8], Vec<&IndexEntry>> = HashMap::new();
1888    for entry in merged.index.entries() {
1889        merged_rows
1890            .entry(entry.path.as_slice())
1891            .or_default()
1892            .push(entry);
1893    }
1894    let up_to_date = |path: &[u8]| -> bool {
1895        let has_unmerged = (1..=3).any(|stage| index.get(path, stage).is_some());
1896        if has_unmerged {
1897            return false;
1898        }
1899        let existing = index.get(path, 0);
1900        match (merged_rows.get(path).map(Vec::as_slice), existing) {
1901            (None, None) => true,
1902            // Checkout recorded this row without writing the file (sparse checkout skipped the
1903            // path); stat'ing whatever is on disk would mark foreign content as clean.
1904            (Some([row]), Some(existing))
1905                if existing.skip_worktree() || existing.stat_cache_unset() =>
1906            {
1907                row.stage() == 0 && existing.oid == row.oid && existing.mode == row.mode
1908            }
1909            (Some([row]), Some(existing)) => {
1910                row.stage() == 0
1911                    && (row.mode == MODE_REGULAR || row.mode == crate::index::MODE_EXECUTABLE)
1912                    && existing.oid == row.oid
1913                    && existing.mode == row.mode
1914                    && existing.flags == row.flags
1915                    && IndexEntry::extended_flags_equal_ignoring_volatile(
1916                        existing.flags_extended,
1917                        row.flags_extended,
1918                    )
1919                    && !existing.intent_to_add()
1920                    && existing.size > 0
1921                    && (existing.mtime_sec > 0 || existing.ctime_sec > 0)
1922            }
1923            _ => false,
1924        }
1925    };
1926
1927    let mut pending_index = Index::new();
1928    for entry in merged.index.entries() {
1929        if !up_to_date(&entry.path) {
1930            pending_index.push_entry_unsorted(entry.clone());
1931        }
1932    }
1933    pending_index.sort();
1934    TreeMergeOutput {
1935        index: pending_index,
1936        conflict_content: BTreeMap::new(),
1937        result_tree: merged.result_tree,
1938        touched_paths: merged
1939            .touched_paths
1940            .iter()
1941            .filter(|path| !up_to_date(path))
1942            .cloned()
1943            .collect(),
1944        rename_conflicts: Vec::new(),
1945        directory_rename_conflicts: Vec::new(),
1946    }
1947}
1948
1949/// Tree OID to record in a commit after a three-way merge (applies conflict-marker policy).
1950pub fn materialized_merge_result_tree(
1951    odb: &Odb,
1952    merged: &TreeMergeOutput,
1953) -> crate::error::Result<ObjectId> {
1954    use std::collections::HashSet;
1955
1956    use crate::write_tree::write_tree_partial_from_index;
1957
1958    if merged.conflict_content.is_empty() {
1959        return Ok(merged.result_tree);
1960    }
1961
1962    let mut patch_paths: HashSet<Vec<u8>> = HashSet::new();
1963    let mut patch_index = Index::new();
1964
1965    for path in merged.conflict_content.keys().chain(
1966        merged
1967            .index
1968            .entries()
1969            .iter()
1970            .filter(|e| e.stage() != 0)
1971            .map(|e| &e.path),
1972    ) {
1973        patch_paths.insert(path.clone());
1974    }
1975
1976    for path in &patch_paths {
1977        if let Some(marker) = merged.conflict_content.get(path) {
1978            let mode = merged
1979                .index
1980                .get(path, 2)
1981                .or_else(|| merged.index.get(path, 3))
1982                .map(|e| e.mode)
1983                .unwrap_or(crate::index::MODE_REGULAR);
1984            let mut entry = IndexEntry {
1985                ctime_sec: 0,
1986                ctime_nsec: 0,
1987                mtime_sec: 0,
1988                mtime_nsec: 0,
1989                dev: 0,
1990                ino: 0,
1991                mode,
1992                uid: 0,
1993                gid: 0,
1994                size: 0,
1995                oid: *marker,
1996                flags: (path.len().min(0xFFF) as u16),
1997                flags_extended: None,
1998                path: path.clone(),
1999                base_index_pos: 0,
2000            };
2001            entry.set_stage(0);
2002            entry.flags = path.len().min(0xFFF) as u16;
2003            patch_index.add_or_replace(entry);
2004        } else if let Some(stage2) = merged.index.get(path, 2) {
2005            let mut entry = stage2.clone();
2006            entry.set_stage(0);
2007            entry.flags = path.len().min(0xFFF) as u16;
2008            patch_index.add_or_replace(entry);
2009        } else if let Some(stage3) = merged.index.get(path, 3) {
2010            let mut entry = stage3.clone();
2011            entry.set_stage(0);
2012            entry.flags = path.len().min(0xFFF) as u16;
2013            patch_index.add_or_replace(entry);
2014        }
2015    }
2016
2017    write_tree_partial_from_index(odb, &patch_index, &merged.result_tree, &patch_paths)
2018}
2019
2020/// True when the index tree matches `head_tree_oid` (used for empty pick detection).
2021pub fn index_tree_oid_matches_head(
2022    odb: &Odb,
2023    index: &Index,
2024    head_tree_oid: &ObjectId,
2025) -> crate::error::Result<bool> {
2026    let merged = write_tree_from_index(odb, index, "")?;
2027    Ok(merged == *head_tree_oid)
2028}
2029
2030#[cfg(test)]
2031mod tests {
2032    use super::*;
2033    use crate::diff::zero_oid;
2034    use crate::environment::RepositoryOptions;
2035    use crate::index::{IndexEntry, MODE_REGULAR};
2036    use crate::objects::{parse_commit, ObjectId};
2037    use crate::repo::Repository;
2038
2039    #[cfg_attr(
2040        not(feature = "full-tests"),
2041        ignore = "starts system git or is heavy; run with --features full-tests"
2042    )]
2043    #[test]
2044    fn merge_trees_df_skips_relocation_when_theirs_expands_base_file() {
2045        let tmp = tempfile::tempdir().expect("tempdir");
2046        grit_test_support::git(tmp.path(), &["init", "-q", "-b", "main"]);
2047        let repo =
2048            Repository::discover(Some(tmp.path()), &RepositoryOptions::empty()).expect("repo");
2049        std::fs::write(tmp.path().join("dir"), "base\n").expect("write");
2050        grit_test_support::git(tmp.path(), &["add", "dir"]);
2051        grit_test_support::git(tmp.path(), &["commit", "-m", "base"]);
2052        let base_tree = {
2053            let base: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
2054                .trim()
2055                .parse()
2056                .expect("base");
2057            let obj = repo.odb().read(&base).expect("read");
2058            parse_commit(&obj.data).expect("parse").tree
2059        };
2060        grit_test_support::git(tmp.path(), &["switch", "-c", "theirs"]);
2061        grit_test_support::git(tmp.path(), &["rm", "-f", "dir"]);
2062        std::fs::create_dir_all(tmp.path().join("dir")).expect("mkdir");
2063        std::fs::write(tmp.path().join("dir/foo"), "foo\n").expect("write");
2064        grit_test_support::git(tmp.path(), &["add", "dir/foo"]);
2065        grit_test_support::git(tmp.path(), &["commit", "-m", "theirs"]);
2066        let theirs_commit: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
2067            .trim()
2068            .parse()
2069            .expect("theirs");
2070        let theirs_tree = {
2071            let obj = repo.odb().read(&theirs_commit).expect("read");
2072            parse_commit(&obj.data).expect("parse").tree
2073        };
2074        grit_test_support::git(tmp.path(), &["switch", "main"]);
2075        let ours_tree = base_tree;
2076        let ours_commit: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
2077            .trim()
2078            .parse()
2079            .expect("ours");
2080        let out = merge_trees_three_way(
2081            &repo,
2082            base_tree,
2083            ours_tree,
2084            theirs_tree,
2085            MergeFavor::default(),
2086            WhitespaceMergeOptions::default(),
2087            None,
2088            TreeMergeConflictPresentation {
2089                df_directory_ours_commit: Some(ours_commit),
2090                df_directory_theirs_commit: Some(theirs_commit),
2091                ..TreeMergeConflictPresentation::default()
2092            },
2093            DirectoryRenameMode::default(),
2094        )
2095        .expect("merge");
2096        assert!(out.index.get(b"dir/foo", 0).is_some());
2097        assert!(
2098            out.index.entries().iter().all(|e| e.stage() == 0),
2099            "expected clean merge without df escape entries"
2100        );
2101    }
2102
2103    /// A repository with `base`, `changed` (from `base`, `src/lib` swaps between a directory and
2104    /// a file) and `other` (from `base`, adds `src/b.rs` and leaves `src/lib` alone).
2105    struct DirectoryFileFixture {
2106        dir: tempfile::TempDir,
2107        repo: Repository,
2108        base: ObjectId,
2109        changed: ObjectId,
2110        other: ObjectId,
2111    }
2112
2113    fn directory_file_fixture(base_has_directory: bool) -> DirectoryFileFixture {
2114        let dir = tempfile::tempdir().expect("tempdir");
2115        let path = dir.path();
2116        let git = |args: &[&str]| grit_test_support::git(path, args);
2117        let head = || -> ObjectId { git(&["rev-parse", "HEAD"]).trim().parse().expect("oid") };
2118        git(&["init", "-q", "-b", "main"]);
2119        std::fs::create_dir_all(path.join("src")).expect("mkdir");
2120        std::fs::write(path.join("src/a.rs"), "a\n").expect("write");
2121        let as_directory = |path: &std::path::Path| {
2122            std::fs::create_dir_all(path.join("src/lib")).expect("mkdir");
2123            std::fs::write(path.join("src/lib/x.rs"), "x\n").expect("write");
2124        };
2125        let as_file = |path: &std::path::Path| {
2126            std::fs::write(path.join("src/lib"), "now a file\n").expect("write");
2127        };
2128        if base_has_directory {
2129            as_directory(path);
2130        } else {
2131            as_file(path);
2132        }
2133        git(&["add", "."]);
2134        git(&["commit", "-q", "-m", "base"]);
2135        let base = head();
2136        git(&["switch", "-q", "-c", "changed"]);
2137        git(&["rm", "-q", "-r", "src/lib"]);
2138        if base_has_directory {
2139            as_file(path);
2140        } else {
2141            as_directory(path);
2142        }
2143        git(&["add", "."]);
2144        git(&["commit", "-q", "-m", "swap src/lib"]);
2145        let changed = head();
2146        git(&["switch", "-q", "-c", "other", "main"]);
2147        std::fs::write(path.join("src/b.rs"), "b\n").expect("write");
2148        git(&["add", "src/b.rs"]);
2149        git(&["commit", "-q", "-m", "add b"]);
2150        let other = head();
2151        let repo = Repository::discover(Some(path), &RepositoryOptions::empty()).expect("repo");
2152        DirectoryFileFixture {
2153            dir,
2154            repo,
2155            base,
2156            changed,
2157            other,
2158        }
2159    }
2160
2161    impl DirectoryFileFixture {
2162        fn tree(&self, commit: ObjectId) -> ObjectId {
2163            self.repo.odb().read_commit(&commit).expect("commit").tree
2164        }
2165
2166        /// Merge `ours` and `theirs` over `base`, with or without merge's D/F relocation.
2167        fn merge(&self, ours: ObjectId, theirs: ObjectId, relocate: bool) -> TreeMergeOutput {
2168            let presentation = if relocate {
2169                TreeMergeConflictPresentation {
2170                    df_directory_ours_commit: Some(ours),
2171                    df_directory_theirs_commit: Some(theirs),
2172                    ..TreeMergeConflictPresentation::default()
2173                }
2174            } else {
2175                TreeMergeConflictPresentation::default()
2176            };
2177            merge_trees_three_way(
2178                &self.repo,
2179                self.tree(self.base),
2180                self.tree(ours),
2181                self.tree(theirs),
2182                MergeFavor::default(),
2183                WhitespaceMergeOptions::default(),
2184                None,
2185                presentation,
2186                DirectoryRenameMode::default(),
2187            )
2188            .expect("merge")
2189        }
2190
2191        /// The tree system git's `merge-tree` writes for the same merge, when it is clean.
2192        fn git_merge_tree(&self, ours: ObjectId, theirs: ObjectId) -> Option<ObjectId> {
2193            let out = grit_test_support::git_cmd(&[
2194                "merge-tree",
2195                "--write-tree",
2196                &format!("--merge-base={}", self.base),
2197                &ours.to_hex(),
2198                &theirs.to_hex(),
2199            ])
2200            .in_dir(self.dir.path())
2201            .exec();
2202            out.ok().then(|| {
2203                out.stdout
2204                    .lines()
2205                    .next()
2206                    .expect("tree line")
2207                    .parse()
2208                    .expect("tree")
2209            })
2210        }
2211    }
2212
2213    #[cfg_attr(
2214        not(feature = "full-tests"),
2215        ignore = "starts system git or is heavy; run with --features full-tests"
2216    )]
2217    #[test]
2218    fn a_path_swapped_between_directory_and_file_merges_with_a_side_that_left_it_alone() {
2219        for base_has_directory in [true, false] {
2220            let fx = directory_file_fixture(base_has_directory);
2221            for (ours, theirs) in [(fx.changed, fx.other), (fx.other, fx.changed)] {
2222                let expected = fx
2223                    .git_merge_tree(ours, theirs)
2224                    .expect("git merges this cleanly");
2225                for relocate in [false, true] {
2226                    let case = format!(
2227                        "base has a directory: {base_has_directory}, ours is the changed side: {}, \
2228                         relocate: {relocate}",
2229                        ours == fx.changed
2230                    );
2231                    let out = fx.merge(ours, theirs, relocate);
2232                    assert!(!out.index.has_unmerged_entries(), "no conflict ({case})");
2233                    assert!(out.conflict_content.is_empty(), "no markers ({case})");
2234                    assert!(
2235                        out.index.entries().iter().all(|e| e.mode != 0o040000),
2236                        "no directory rows in the index ({case})"
2237                    );
2238                    assert_eq!(out.result_tree, expected, "same tree as git ({case})");
2239                }
2240            }
2241        }
2242    }
2243
2244    #[cfg_attr(
2245        not(feature = "full-tests"),
2246        ignore = "starts system git or is heavy; run with --features full-tests"
2247    )]
2248    #[test]
2249    fn a_directory_changed_on_one_side_and_made_a_file_on_the_other_still_conflicts() {
2250        let fx = directory_file_fixture(true);
2251        let path = fx.dir.path();
2252        grit_test_support::git(path, &["switch", "-q", "other"]);
2253        std::fs::write(path.join("src/lib/x.rs"), "x changed\n").expect("write");
2254        grit_test_support::git(path, &["commit", "-q", "-am", "change x"]);
2255        let other: ObjectId = grit_test_support::git(path, &["rev-parse", "HEAD"])
2256            .trim()
2257            .parse()
2258            .expect("oid");
2259        assert_eq!(fx.git_merge_tree(fx.changed, other), None, "git conflicts");
2260        for relocate in [false, true] {
2261            let out = fx.merge(fx.changed, other, relocate);
2262            assert!(
2263                out.index.has_unmerged_entries(),
2264                "a conflict (relocate: {relocate})"
2265            );
2266        }
2267    }
2268
2269    #[cfg_attr(
2270        not(feature = "full-tests"),
2271        ignore = "starts system git or is heavy; run with --features full-tests"
2272    )]
2273    #[test]
2274    fn merge_trees_three_way_theirs_only_addition() {
2275        let tmp = tempfile::tempdir().expect("tempdir");
2276        grit_test_support::git(tmp.path(), &["init", "-q", "-b", "main"]);
2277        let repo =
2278            Repository::discover(Some(tmp.path()), &RepositoryOptions::empty()).expect("repo");
2279        std::fs::write(tmp.path().join("base.txt"), "b\n").expect("write");
2280        grit_test_support::git(tmp.path(), &["add", "base.txt"]);
2281        grit_test_support::git(tmp.path(), &["commit", "-m", "base"]);
2282        let base: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
2283            .trim()
2284            .parse()
2285            .expect("base");
2286        let base_tree = {
2287            let obj = repo.odb().read(&base).expect("read");
2288            parse_commit(&obj.data).expect("parse").tree
2289        };
2290        grit_test_support::git(tmp.path(), &["switch", "-c", "topic"]);
2291        std::fs::write(tmp.path().join("theirs-only.txt"), "t\n").expect("write");
2292        grit_test_support::git(tmp.path(), &["add", "theirs-only.txt"]);
2293        grit_test_support::git(tmp.path(), &["commit", "-m", "topic"]);
2294        let theirs: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
2295            .trim()
2296            .parse()
2297            .expect("theirs");
2298        let theirs_tree = {
2299            let obj = repo.odb().read(&theirs).expect("read");
2300            parse_commit(&obj.data).expect("parse").tree
2301        };
2302        grit_test_support::git(tmp.path(), &["switch", "main"]);
2303        let ours: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
2304            .trim()
2305            .parse()
2306            .expect("ours");
2307        let ours_tree = {
2308            let obj = repo.odb().read(&ours).expect("read");
2309            parse_commit(&obj.data).expect("parse").tree
2310        };
2311        let out = merge_trees_three_way(
2312            &repo,
2313            base_tree,
2314            ours_tree,
2315            theirs_tree,
2316            MergeFavor::default(),
2317            WhitespaceMergeOptions::default(),
2318            None,
2319            TreeMergeConflictPresentation::default(),
2320            DirectoryRenameMode::default(),
2321        )
2322        .expect("merge");
2323        assert!(out.index.get(b"theirs-only.txt", 0).is_some());
2324        assert!(out.conflict_content.is_empty());
2325    }
2326
2327    #[cfg_attr(
2328        not(feature = "full-tests"),
2329        ignore = "starts system git or is heavy; run with --features full-tests"
2330    )]
2331    #[test]
2332    fn merge_trees_clean_textual_merge_auto_resolves() {
2333        let tmp = tempfile::tempdir().expect("tempdir");
2334        grit_test_support::git(tmp.path(), &["init", "-q", "-b", "main"]);
2335        let repo =
2336            Repository::discover(Some(tmp.path()), &RepositoryOptions::empty()).expect("repo");
2337        std::fs::write(tmp.path().join("f.txt"), "1\n2\n3\n4\n").expect("write");
2338        grit_test_support::git(tmp.path(), &["add", "f.txt"]);
2339        grit_test_support::git(tmp.path(), &["commit", "-m", "base"]);
2340        let base_tree = {
2341            let base: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
2342                .trim()
2343                .parse()
2344                .expect("base");
2345            let obj = repo.odb().read(&base).expect("read");
2346            parse_commit(&obj.data).expect("parse").tree
2347        };
2348        grit_test_support::git(tmp.path(), &["switch", "-c", "topic"]);
2349        std::fs::write(tmp.path().join("f.txt"), "1\n2\n3\nFOUR\n").expect("write");
2350        grit_test_support::git(tmp.path(), &["add", "f.txt"]);
2351        grit_test_support::git(tmp.path(), &["commit", "-m", "theirs"]);
2352        let theirs_tree = {
2353            let oid: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
2354                .trim()
2355                .parse()
2356                .expect("theirs");
2357            let obj = repo.odb().read(&oid).expect("read");
2358            parse_commit(&obj.data).expect("parse").tree
2359        };
2360        grit_test_support::git(tmp.path(), &["switch", "main"]);
2361        std::fs::write(tmp.path().join("f.txt"), "1\nTWO\n3\n4\n").expect("write");
2362        grit_test_support::git(tmp.path(), &["add", "f.txt"]);
2363        grit_test_support::git(tmp.path(), &["commit", "-m", "ours"]);
2364        let ours_tree = {
2365            let oid: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
2366                .trim()
2367                .parse()
2368                .expect("ours");
2369            let obj = repo.odb().read(&oid).expect("read");
2370            parse_commit(&obj.data).expect("parse").tree
2371        };
2372        let out = merge_trees_three_way(
2373            &repo,
2374            base_tree,
2375            ours_tree,
2376            theirs_tree,
2377            MergeFavor::default(),
2378            WhitespaceMergeOptions::default(),
2379            None,
2380            TreeMergeConflictPresentation::default(),
2381            DirectoryRenameMode::default(),
2382        )
2383        .expect("merge");
2384        assert!(out.index.get(b"f.txt", 0).is_some());
2385        assert!(out.conflict_content.is_empty());
2386        let blob = repo
2387            .odb()
2388            .read(&out.index.get(b"f.txt", 0).expect("entry").oid)
2389            .expect("blob");
2390        assert_eq!(blob.data, b"1\nTWO\n3\nFOUR\n");
2391    }
2392
2393    #[cfg_attr(
2394        not(feature = "full-tests"),
2395        ignore = "starts system git or is heavy; run with --features full-tests"
2396    )]
2397    #[test]
2398    fn merge_trees_gitlink_three_way_conflict_stages() {
2399        let tmp = tempfile::tempdir().expect("tempdir");
2400        grit_test_support::git(tmp.path(), &["init", "-q", "-b", "main"]);
2401        let nested = tmp.path().join("nested");
2402        std::fs::create_dir_all(&nested).expect("mkdir");
2403        grit_test_support::git(&nested, &["init", "-q"]);
2404        std::fs::write(nested.join("inner.txt"), "x\n").expect("write");
2405        grit_test_support::git(&nested, &["add", "inner.txt"]);
2406        grit_test_support::git(&nested, &["commit", "-m", "nested"]);
2407        let sub_a = grit_test_support::git(&nested, &["rev-parse", "HEAD"])
2408            .trim()
2409            .to_owned();
2410        std::process::Command::new("git")
2411            .current_dir(tmp.path())
2412            .args([
2413                "update-index",
2414                "--add",
2415                "--cacheinfo",
2416                &format!("160000,{sub_a},sub"),
2417            ])
2418            .status()
2419            .expect("update-index");
2420        grit_test_support::git(tmp.path(), &["commit", "-m", "base"]);
2421        let repo =
2422            Repository::discover(Some(tmp.path()), &RepositoryOptions::empty()).expect("repo");
2423        let base_tree = {
2424            let base: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
2425                .trim()
2426                .parse()
2427                .expect("base");
2428            let obj = repo.odb().read(&base).expect("read");
2429            parse_commit(&obj.data).expect("parse").tree
2430        };
2431        grit_test_support::git(&nested, &["commit", "--allow-empty", "-m", "bump"]);
2432        let sub_b = grit_test_support::git(&nested, &["rev-parse", "HEAD"])
2433            .trim()
2434            .to_owned();
2435        grit_test_support::git(tmp.path(), &["switch", "-c", "topic"]);
2436        std::process::Command::new("git")
2437            .current_dir(tmp.path())
2438            .args([
2439                "update-index",
2440                "--cacheinfo",
2441                &format!("160000,{sub_b},sub"),
2442            ])
2443            .status()
2444            .expect("update-index");
2445        grit_test_support::git(tmp.path(), &["commit", "-m", "theirs sub"]);
2446        let theirs_tree = {
2447            let oid: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
2448                .trim()
2449                .parse()
2450                .expect("theirs");
2451            let obj = repo.odb().read(&oid).expect("read");
2452            parse_commit(&obj.data).expect("parse").tree
2453        };
2454        grit_test_support::git(&nested, &["commit", "--allow-empty", "-m", "bump2"]);
2455        let sub_c = grit_test_support::git(&nested, &["rev-parse", "HEAD"])
2456            .trim()
2457            .to_owned();
2458        grit_test_support::git(tmp.path(), &["switch", "main"]);
2459        std::process::Command::new("git")
2460            .current_dir(tmp.path())
2461            .args([
2462                "update-index",
2463                "--cacheinfo",
2464                &format!("160000,{sub_c},sub"),
2465            ])
2466            .status()
2467            .expect("update-index");
2468        grit_test_support::git(tmp.path(), &["commit", "-m", "ours sub"]);
2469        let ours_tree = {
2470            let oid: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
2471                .trim()
2472                .parse()
2473                .expect("ours");
2474            let obj = repo.odb().read(&oid).expect("read");
2475            parse_commit(&obj.data).expect("parse").tree
2476        };
2477        let out = merge_trees_three_way(
2478            &repo,
2479            base_tree,
2480            ours_tree,
2481            theirs_tree,
2482            MergeFavor::default(),
2483            WhitespaceMergeOptions::default(),
2484            None,
2485            TreeMergeConflictPresentation::default(),
2486            DirectoryRenameMode::default(),
2487        )
2488        .expect("merge");
2489        assert!(out.index.get(b"sub", 1).is_some());
2490        assert!(out.index.get(b"sub", 2).is_some());
2491        assert!(out.index.get(b"sub", 3).is_some());
2492    }
2493
2494    #[cfg_attr(
2495        not(feature = "full-tests"),
2496        ignore = "starts system git or is heavy; run with --features full-tests"
2497    )]
2498    #[test]
2499    fn merge_trees_binary_conflict_honors_ours_favor() {
2500        let tmp = tempfile::tempdir().expect("tempdir");
2501        grit_test_support::git(tmp.path(), &["init", "-q", "-b", "main"]);
2502        let repo =
2503            Repository::discover(Some(tmp.path()), &RepositoryOptions::empty()).expect("repo");
2504        std::fs::write(tmp.path().join("bin.dat"), [0, 1, 2]).expect("write");
2505        grit_test_support::git(tmp.path(), &["add", "bin.dat"]);
2506        grit_test_support::git(tmp.path(), &["commit", "-m", "base"]);
2507        let base_tree = {
2508            let base: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
2509                .trim()
2510                .parse()
2511                .expect("base");
2512            let obj = repo.odb().read(&base).expect("read");
2513            parse_commit(&obj.data).expect("parse").tree
2514        };
2515        grit_test_support::git(tmp.path(), &["switch", "-c", "topic"]);
2516        std::fs::write(tmp.path().join("bin.dat"), [0, 1, 3]).expect("write");
2517        grit_test_support::git(tmp.path(), &["add", "bin.dat"]);
2518        grit_test_support::git(tmp.path(), &["commit", "-m", "theirs"]);
2519        let theirs_tree = {
2520            let oid: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
2521                .trim()
2522                .parse()
2523                .expect("theirs");
2524            let obj = repo.odb().read(&oid).expect("read");
2525            parse_commit(&obj.data).expect("parse").tree
2526        };
2527        grit_test_support::git(tmp.path(), &["switch", "main"]);
2528        std::fs::write(tmp.path().join("bin.dat"), [0, 1, 4]).expect("write");
2529        grit_test_support::git(tmp.path(), &["add", "bin.dat"]);
2530        grit_test_support::git(tmp.path(), &["commit", "-m", "ours"]);
2531        let ours_tree = {
2532            let oid: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
2533                .trim()
2534                .parse()
2535                .expect("ours");
2536            let obj = repo.odb().read(&oid).expect("read");
2537            parse_commit(&obj.data).expect("parse").tree
2538        };
2539        let out = merge_trees_three_way(
2540            &repo,
2541            base_tree,
2542            ours_tree,
2543            theirs_tree,
2544            MergeFavor::Ours,
2545            WhitespaceMergeOptions::default(),
2546            None,
2547            TreeMergeConflictPresentation::default(),
2548            DirectoryRenameMode::default(),
2549        )
2550        .expect("merge");
2551        let entry = out.index.get(b"bin.dat", 0).expect("stage 0");
2552        let blob = repo.odb().read(&entry.oid).expect("blob");
2553        assert_eq!(blob.data, vec![0, 1, 4]);
2554    }
2555
2556    #[cfg_attr(
2557        not(feature = "full-tests"),
2558        ignore = "starts system git or is heavy; run with --features full-tests"
2559    )]
2560    #[test]
2561    fn merge_trees_binary_conflict_honors_theirs_favor() {
2562        let tmp = tempfile::tempdir().expect("tempdir");
2563        grit_test_support::git(tmp.path(), &["init", "-q", "-b", "main"]);
2564        let repo =
2565            Repository::discover(Some(tmp.path()), &RepositoryOptions::empty()).expect("repo");
2566        std::fs::write(tmp.path().join("bin.dat"), [0, 1, 2]).expect("write");
2567        grit_test_support::git(tmp.path(), &["add", "bin.dat"]);
2568        grit_test_support::git(tmp.path(), &["commit", "-m", "base"]);
2569        let base: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
2570            .trim()
2571            .parse()
2572            .expect("base");
2573        let base_tree = {
2574            let obj = repo.odb().read(&base).expect("read");
2575            parse_commit(&obj.data).expect("parse").tree
2576        };
2577        grit_test_support::git(tmp.path(), &["switch", "-c", "topic"]);
2578        std::fs::write(tmp.path().join("bin.dat"), [0, 1, 3]).expect("write");
2579        grit_test_support::git(tmp.path(), &["add", "bin.dat"]);
2580        grit_test_support::git(tmp.path(), &["commit", "-m", "theirs"]);
2581        let theirs_tree = {
2582            let oid: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
2583                .trim()
2584                .parse()
2585                .expect("theirs");
2586            let obj = repo.odb().read(&oid).expect("read");
2587            parse_commit(&obj.data).expect("parse").tree
2588        };
2589        grit_test_support::git(tmp.path(), &["switch", "main"]);
2590        std::fs::write(tmp.path().join("bin.dat"), [0, 1, 4]).expect("write");
2591        grit_test_support::git(tmp.path(), &["add", "bin.dat"]);
2592        grit_test_support::git(tmp.path(), &["commit", "-m", "ours"]);
2593        let ours_tree = {
2594            let oid: ObjectId = grit_test_support::git(tmp.path(), &["rev-parse", "HEAD"])
2595                .trim()
2596                .parse()
2597                .expect("ours");
2598            let obj = repo.odb().read(&oid).expect("read");
2599            parse_commit(&obj.data).expect("parse").tree
2600        };
2601        let out = merge_trees_three_way(
2602            &repo,
2603            base_tree,
2604            ours_tree,
2605            theirs_tree,
2606            MergeFavor::Theirs,
2607            WhitespaceMergeOptions::default(),
2608            None,
2609            TreeMergeConflictPresentation::default(),
2610            DirectoryRenameMode::default(),
2611        )
2612        .expect("merge");
2613        let entry = out.index.get(b"bin.dat", 0).expect("stage 0");
2614        let theirs_blob = repo.odb().read(&entry.oid).expect("blob");
2615        assert_eq!(theirs_blob.data, vec![0, 1, 3]);
2616    }
2617
2618    fn head_tree(repo: &Repository, dir: &std::path::Path) -> ObjectId {
2619        let oid: ObjectId = grit_test_support::git(dir, &["rev-parse", "HEAD"])
2620            .trim()
2621            .parse()
2622            .expect("HEAD");
2623        let obj = repo.odb().read(&oid).expect("read");
2624        parse_commit(&obj.data).expect("parse").tree
2625    }
2626
2627    #[cfg_attr(
2628        not(feature = "full-tests"),
2629        ignore = "starts system git or is heavy; run with --features full-tests"
2630    )]
2631    #[test]
2632    fn merge_trees_loads_attributes_once_and_applies_them_per_path() {
2633        use crate::worktree_rules::file_load_counters;
2634
2635        const N: usize = 20;
2636        let tmp = tempfile::tempdir().expect("tempdir");
2637        let dir = tmp.path();
2638        grit_test_support::git(dir, &["init", "-q", "-b", "main"]);
2639        std::fs::write(dir.join(".gitattributes"), "*.u merge=union\n*.bin -text\n")
2640            .expect("write");
2641        std::fs::write(dir.join(".git/info/exclude"), "*.tmp\n").expect("write");
2642        let body = |i: usize| -> String { (0..10).map(|j| format!("line {j} of {i}\n")).collect() };
2643        for i in 0..N {
2644            std::fs::write(dir.join(format!("f{i}.u")), "shared\n").expect("write");
2645            std::fs::write(dir.join(format!("f{i}.bin")), body(i)).expect("write");
2646            std::fs::write(dir.join(format!("f{i}.txt")), body(i)).expect("write");
2647        }
2648        grit_test_support::git(dir, &["add", "-A"]);
2649        grit_test_support::git(dir, &["commit", "-qm", "base"]);
2650        grit_test_support::git(dir, &["switch", "-qc", "topic"]);
2651        for i in 0..N {
2652            std::fs::write(dir.join(format!("f{i}.u")), "shared\ntheirs\n").expect("write");
2653            std::fs::write(dir.join(format!("f{i}.bin")), format!("{}tail\n", body(i)))
2654                .expect("write");
2655            std::fs::write(dir.join(format!("f{i}.txt")), format!("{}tail\n", body(i)))
2656                .expect("write");
2657        }
2658        grit_test_support::git(dir, &["commit", "-qam", "theirs"]);
2659        let repo = Repository::discover(Some(dir), &RepositoryOptions::empty()).expect("repo");
2660        let theirs_tree = head_tree(&repo, dir);
2661        grit_test_support::git(dir, &["switch", "-q", "main"]);
2662        let base_tree = head_tree(&repo, dir);
2663        for i in 0..N {
2664            std::fs::write(dir.join(format!("f{i}.u")), "shared\nours\n").expect("write");
2665            std::fs::write(dir.join(format!("f{i}.bin")), format!("head\n{}", body(i)))
2666                .expect("write");
2667            std::fs::write(dir.join(format!("f{i}.txt")), format!("head\n{}", body(i)))
2668                .expect("write");
2669        }
2670        grit_test_support::git(dir, &["commit", "-qam", "ours"]);
2671        let ours_tree = head_tree(&repo, dir);
2672
2673        file_load_counters::reset();
2674        let out = merge_trees_three_way(
2675            &repo,
2676            base_tree,
2677            ours_tree,
2678            theirs_tree,
2679            MergeFavor::default(),
2680            WhitespaceMergeOptions::default(),
2681            None,
2682            TreeMergeConflictPresentation::default(),
2683            DirectoryRenameMode::default(),
2684        )
2685        .expect("merge");
2686        assert!(
2687            file_load_counters::max_reads_per_file() <= 1,
2688            "attribute/ignore files must be read at most once per tree merge, got {}",
2689            file_load_counters::max_reads_per_file()
2690        );
2691        assert_eq!(
2692            file_load_counters::reads_for(&dir.join(".git/info/exclude")),
2693            1
2694        );
2695
2696        for i in 0..N {
2697            let union = out
2698                .index
2699                .get(format!("f{i}.u").as_bytes(), 0)
2700                .expect("union path merges cleanly");
2701            let blob = repo.odb().read(&union.oid).expect("blob");
2702            assert_eq!(blob.data, b"shared\nours\ntheirs\n");
2703
2704            let bin = format!("f{i}.bin");
2705            for stage in 1..=3 {
2706                assert!(
2707                    out.index.get(bin.as_bytes(), stage).is_some(),
2708                    "-text path {bin} conflicts as binary (stage {stage})"
2709                );
2710            }
2711            assert!(!out.conflict_content.contains_key(bin.as_bytes()));
2712
2713            let txt = out
2714                .index
2715                .get(format!("f{i}.txt").as_bytes(), 0)
2716                .expect("text path merges cleanly");
2717            let blob = repo.odb().read(&txt.oid).expect("blob");
2718            assert_eq!(blob.data, format!("head\n{}tail\n", body(i)).as_bytes());
2719        }
2720    }
2721
2722    #[test]
2723    fn augment_renames_includes_same_blob_pairs() {
2724        let mut base = BTreeMap::new();
2725        let mut side = BTreeMap::new();
2726        let oid = zero_oid();
2727        let old_e = IndexEntry {
2728            ctime_sec: 0,
2729            ctime_nsec: 0,
2730            mtime_sec: 0,
2731            mtime_nsec: 0,
2732            dev: 0,
2733            ino: 0,
2734            mode: MODE_REGULAR,
2735            uid: 0,
2736            gid: 0,
2737            size: 0,
2738            oid,
2739            flags: 0,
2740            flags_extended: None,
2741            path: b"old".to_vec(),
2742            base_index_pos: 0,
2743        };
2744        let mut new_e = old_e.clone();
2745        new_e.path = b"new".to_vec();
2746        base.insert(b"old".to_vec(), old_e);
2747        side.insert(b"new".to_vec(), new_e);
2748        let mut pairs = Vec::new();
2749        augment_renames_with_same_blob(&base, &side, &mut pairs);
2750        assert_eq!(pairs.len(), 1);
2751        assert_eq!(pairs[0].2, 100);
2752    }
2753
2754    fn sparse_merge_test_entry(path: &str, oid: ObjectId, stage: u8) -> IndexEntry {
2755        let mut flags = path.len().min(0xFFF) as u16;
2756        if stage > 0 {
2757            flags |= (stage as u16) << 12;
2758        }
2759        IndexEntry {
2760            ctime_sec: 10,
2761            ctime_nsec: 0,
2762            mtime_sec: 10,
2763            mtime_nsec: 0,
2764            dev: 1,
2765            ino: 1,
2766            mode: MODE_REGULAR,
2767            uid: 0,
2768            gid: 0,
2769            size: 4,
2770            oid,
2771            flags,
2772            flags_extended: None,
2773            path: path.as_bytes().to_vec(),
2774            base_index_pos: 0,
2775        }
2776    }
2777
2778    #[test]
2779    fn apply_tree_merge_output_to_index_sparse_updates_keep_sorted() {
2780        let oid = zero_oid();
2781        let changed_oid =
2782            ObjectId::from_hex("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").expect("oid");
2783        let mut index = Index::new();
2784        for i in 0..800 {
2785            index.push_entry_unsorted(sparse_merge_test_entry(
2786                &format!("wide/f{i:04}.txt"),
2787                oid,
2788                0,
2789            ));
2790        }
2791        index.sort();
2792        assert!(index.entries_sorted);
2793        let before_len = index.entries().len();
2794
2795        let del_path = b"wide/f0100.txt".to_vec();
2796        let conflict_path = b"wide/f0200.txt".to_vec();
2797        let update_path = b"wide/f0300.txt".to_vec();
2798        let mut merge_index = Index::new();
2799        merge_index.push_entry_unsorted(sparse_merge_test_entry("wide/f0200.txt", oid, 2));
2800        merge_index.push_entry_unsorted(sparse_merge_test_entry("wide/f0200.txt", changed_oid, 3));
2801        merge_index.push_entry_unsorted(sparse_merge_test_entry("wide/f0300.txt", changed_oid, 0));
2802        merge_index.sort();
2803        let mut touched = HashSet::new();
2804        touched.insert(del_path.clone());
2805        touched.insert(conflict_path.clone());
2806        touched.insert(update_path.clone());
2807        let merged = TreeMergeOutput {
2808            index: merge_index,
2809            conflict_content: BTreeMap::new(),
2810            result_tree: oid,
2811            touched_paths: touched,
2812            rename_conflicts: Vec::new(),
2813            directory_rename_conflicts: Vec::new(),
2814        };
2815
2816        apply_tree_merge_output_to_index(&mut index, &merged);
2817
2818        assert!(index.entries_sorted);
2819        assert!(index.get(&del_path, 0).is_none());
2820        assert!(index.get(&conflict_path, 2).is_some());
2821        assert!(index.get(&conflict_path, 3).is_some());
2822        assert_eq!(index.get(&update_path, 0).map(|e| e.oid), Some(changed_oid));
2823        assert_eq!(index.entries().len(), before_len);
2824    }
2825
2826    #[test]
2827    fn apply_tree_merge_worktree_index_skips_fsmonitor_only_diff() {
2828        let dir = tempfile::tempdir().expect("tempdir");
2829        grit_test_support::git(dir.path(), &["init", "-q", "-b", "main"]);
2830        std::fs::write(dir.path().join("tracked.txt"), "hello\n").expect("write");
2831        grit_test_support::git(dir.path(), &["add", "tracked.txt"]);
2832        let repo =
2833            Repository::discover(Some(dir.path()), &RepositoryOptions::empty()).expect("repo");
2834        let mut index = repo.index_handle().load().expect("index");
2835        let row = index.get(b"tracked.txt", 0).expect("row").clone();
2836        let oid = row.oid;
2837        let mut indexed = row.clone();
2838        indexed.set_fsmonitor_valid(true);
2839        index.overwrite_stage0_entries(vec![indexed]);
2840        let entries_before = index.entries().to_vec();
2841
2842        let mut merge_index = Index::new();
2843        merge_index.push_entry_unsorted(IndexEntry {
2844            flags_extended: None,
2845            size: 0,
2846            ctime_sec: 0,
2847            ctime_nsec: 0,
2848            mtime_sec: 0,
2849            mtime_nsec: 0,
2850            dev: 0,
2851            ino: 0,
2852            ..sparse_merge_test_entry("tracked.txt", oid, 0)
2853        });
2854        merge_index.sort();
2855        let mut touched = HashSet::new();
2856        touched.insert(b"tracked.txt".to_vec());
2857        let merged = TreeMergeOutput {
2858            index: merge_index,
2859            conflict_content: BTreeMap::new(),
2860            result_tree: oid,
2861            touched_paths: touched,
2862            rename_conflicts: Vec::new(),
2863            directory_rename_conflicts: Vec::new(),
2864        };
2865
2866        apply_tree_merge_output_to_worktree_index(&repo, &mut index, &merged).expect("apply");
2867
2868        assert_eq!(index.entries(), entries_before.as_slice());
2869        assert!(index.get(b"tracked.txt", 0).unwrap().fsmonitor_valid());
2870    }
2871
2872    #[test]
2873    fn apply_tree_merge_worktree_index_applies_oid_change() {
2874        let dir = tempfile::tempdir().expect("tempdir");
2875        grit_test_support::git(dir.path(), &["init", "-q", "-b", "main"]);
2876        std::fs::write(dir.path().join("tracked.txt"), "hello\n").expect("write");
2877        grit_test_support::git(dir.path(), &["add", "tracked.txt"]);
2878        let repo =
2879            Repository::discover(Some(dir.path()), &RepositoryOptions::empty()).expect("repo");
2880        let mut index = repo.index_handle().load().expect("index");
2881        let old_oid = index.get(b"tracked.txt", 0).expect("row").oid;
2882        let mut indexed = index.get(b"tracked.txt", 0).expect("row").clone();
2883        indexed.set_fsmonitor_valid(true);
2884        index.overwrite_stage0_entries(vec![indexed]);
2885
2886        let new_oid = repo
2887            .odb()
2888            .write(crate::objects::ObjectKind::Blob, b"other\n")
2889            .expect("write");
2890        assert_ne!(old_oid, new_oid);
2891
2892        let mut merge_index = Index::new();
2893        merge_index.push_entry_unsorted(sparse_merge_test_entry("tracked.txt", new_oid, 0));
2894        merge_index.sort();
2895        let mut touched = HashSet::new();
2896        touched.insert(b"tracked.txt".to_vec());
2897        let merged = TreeMergeOutput {
2898            index: merge_index,
2899            conflict_content: BTreeMap::new(),
2900            result_tree: new_oid,
2901            touched_paths: touched,
2902            rename_conflicts: Vec::new(),
2903            directory_rename_conflicts: Vec::new(),
2904        };
2905
2906        apply_tree_merge_output_to_worktree_index(&repo, &mut index, &merged).expect("apply");
2907
2908        assert_eq!(index.get(b"tracked.txt", 0).map(|e| e.oid), Some(new_oid));
2909    }
2910}