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::collections::{BTreeMap, HashMap, HashSet};
8
9use crate::diff::{detect_renames, diff_trees, DiffStatus};
10use crate::index::{Index, IndexEntry};
11use crate::merge_file::{merge, ConflictStyle, MergeFavor, MergeInput};
12use crate::objects::{parse_tree, ObjectId, ObjectKind};
13use crate::odb::Odb;
14use crate::repo::Repository;
15use crate::write_tree::write_tree_from_index;
16
17/// Result of merging three trees with optional conflict-marker blobs for checkout.
18#[derive(Debug)]
19pub struct TreeMergeOutput {
20    /// Merged index (may include unmerged stages).
21    pub index: Index,
22    /// Conflict-marker blob OIDs keyed by path (stage-2 path bytes).
23    pub conflict_content: BTreeMap<Vec<u8>, ObjectId>,
24}
25
26#[derive(Clone, Copy, Debug, Default)]
27pub struct WhitespaceMergeOptions {
28    pub ignore_all_space: bool,
29    pub ignore_space_change: bool,
30    pub ignore_space_at_eol: bool,
31    pub ignore_cr_at_eol: bool,
32}
33
34/// How to label the "theirs" side in textual conflict markers.
35#[derive(Clone, Copy, Debug)]
36pub enum TheirsConflictLabel<'a> {
37    /// Use the UTF-8 file path (matches cherry-pick / revert).
38    PathUtf8,
39    /// Fixed label (e.g. `local` for `git checkout -m`).
40    Fixed(&'a str),
41}
42
43/// Labels and marker style for conflict output during tree merges.
44#[derive(Clone, Copy, Debug)]
45pub struct TreeMergeConflictPresentation<'a> {
46    /// Label after `<<<<<<<` (Git: ours side name).
47    pub label_ours: &'a str,
48    /// Label after `>>>>>>>` (path-based or fixed).
49    pub label_theirs: TheirsConflictLabel<'a>,
50    /// Label after `|||||||` in diff3 output.
51    pub label_base: &'a str,
52    /// Two-way vs diff3 markers.
53    pub style: ConflictStyle,
54    /// `git checkout -m`: always leave unmerged index entries when ours ≠ theirs.
55    pub checkout_merge: bool,
56}
57
58impl Default for TreeMergeConflictPresentation<'_> {
59    fn default() -> Self {
60        Self {
61            label_ours: "HEAD",
62            label_theirs: TheirsConflictLabel::PathUtf8,
63            label_base: "merged common ancestors",
64            style: ConflictStyle::Merge,
65            checkout_merge: false,
66        }
67    }
68}
69
70/// Merge `base` + `ours` + `their` trees (by OID) into an index using rename detection.
71///
72/// Paths in the result follow the **ours** tree naming. Rename detection uses a 50%
73/// similarity threshold, matching Git's default rename detection during merges.
74///
75/// # Parameters
76///
77/// - `base_tree` — common ancestor tree (parent tree for cherry-pick, reverted commit tree for revert).
78/// - `ours_tree` — current branch tree (HEAD during pick/revert).
79/// - `theirs_tree` — tree being applied (picked commit for cherry-pick, parent of reverted commit for revert).
80/// - `favor` / `ws` — merge strategy favour and whitespace options for textual merges.
81/// - `diff_algorithm` — optional line diff algorithm name used for textual merges.
82/// - `presentation` — conflict marker labels and style (`label_base` is the diff3 "base" name).
83///
84/// # Errors
85///
86/// Returns [`crate::error::Error`] on ODB read failures or corrupt trees.
87pub fn merge_trees_three_way(
88    repo: &Repository,
89    base_tree: ObjectId,
90    ours_tree: ObjectId,
91    theirs_tree: ObjectId,
92    favor: MergeFavor,
93    ws: WhitespaceMergeOptions,
94    diff_algorithm: Option<&str>,
95    presentation: TreeMergeConflictPresentation<'_>,
96) -> crate::error::Result<TreeMergeOutput> {
97    let odb = &repo.odb;
98    let base = tree_to_map(tree_to_index_entries(repo, &base_tree, "")?);
99    let ours = tree_to_map(tree_to_index_entries(repo, &ours_tree, "")?);
100    let theirs = tree_to_map(tree_to_index_entries(repo, &theirs_tree, "")?);
101
102    let ours_old_to_new = rename_pairs_base_to_other(odb, &base_tree, &ours_tree)?;
103    let theirs_pairs = rename_pairs_base_to_other(odb, &base_tree, &theirs_tree)?;
104
105    let mut ours_new_to_old: HashMap<Vec<u8>, Vec<u8>> = HashMap::new();
106    let mut ours_best_by_dest: HashMap<Vec<u8>, (Vec<u8>, u32)> = HashMap::new();
107    for (old, new, score) in &ours_old_to_new {
108        let new_b = new.clone();
109        let should_take = match ours_best_by_dest.get(&new_b) {
110            None => true,
111            Some((_, s)) => *score > *s,
112        };
113        if should_take {
114            ours_best_by_dest.insert(new_b, (old.clone(), *score));
115        }
116    }
117    for (new_path, (old_path, _)) in ours_best_by_dest {
118        ours_new_to_old.insert(new_path, old_path);
119    }
120    let mut theirs_old_to_new: HashMap<Vec<u8>, Vec<u8>> = HashMap::new();
121    let mut best_by_dest: HashMap<Vec<u8>, (Vec<u8>, u32)> = HashMap::new();
122    for (old, new, score) in theirs_pairs {
123        let new_b = new.clone();
124        let should_take = match best_by_dest.get(&new_b) {
125            None => true,
126            Some((_, s)) => score > *s,
127        };
128        if should_take {
129            best_by_dest.insert(new_b, (old.clone(), score));
130        }
131    }
132    for (new_path, (old_path, _)) in best_by_dest {
133        theirs_old_to_new.insert(old_path, new_path);
134    }
135
136    three_way_on_aligned_paths(
137        repo,
138        &base,
139        &ours,
140        &theirs,
141        &ours_new_to_old,
142        &theirs_old_to_new,
143        favor,
144        ws,
145        diff_algorithm,
146        presentation,
147    )
148}
149
150fn rename_pairs_base_to_other(
151    odb: &Odb,
152    base_tree: &ObjectId,
153    other_tree: &ObjectId,
154) -> crate::error::Result<Vec<(Vec<u8>, Vec<u8>, u32)>> {
155    let mut entries = diff_trees(odb, Some(base_tree), Some(other_tree), "")?;
156    entries = detect_renames(odb, None, entries, 50);
157    let mut out = Vec::new();
158    for e in entries {
159        if e.status != DiffStatus::Renamed {
160            continue;
161        }
162        let Some(old) = e.old_path else {
163            continue;
164        };
165        let Some(new) = e.new_path else {
166            continue;
167        };
168        let score = e.score.unwrap_or(0);
169        out.push((old.into_bytes(), new.into_bytes(), score));
170    }
171    Ok(out)
172}
173
174fn three_way_on_aligned_paths(
175    repo: &Repository,
176    base: &HashMap<Vec<u8>, IndexEntry>,
177    ours: &HashMap<Vec<u8>, IndexEntry>,
178    theirs: &HashMap<Vec<u8>, IndexEntry>,
179    ours_new_to_old: &HashMap<Vec<u8>, Vec<u8>>,
180    theirs_old_to_new: &HashMap<Vec<u8>, Vec<u8>>,
181    favor: MergeFavor,
182    ws: WhitespaceMergeOptions,
183    diff_algorithm: Option<&str>,
184    presentation: TreeMergeConflictPresentation<'_>,
185) -> crate::error::Result<TreeMergeOutput> {
186    let mut out = Index::new();
187    let mut conflict_content = BTreeMap::new();
188    let mut handled_base: HashSet<Vec<u8>> = HashSet::new();
189    let mut handled_theirs: HashSet<Vec<u8>> = HashSet::new();
190
191    for op in sorted_paths(ours.keys()) {
192        let bp = if let Some(old) = ours_new_to_old.get(&op) {
193            Some(old.clone())
194        } else if base.contains_key(&op) {
195            Some(op.clone())
196        } else {
197            None
198        };
199
200        let tp = if let Some(ref bpath) = bp {
201            theirs_old_to_new
202                .get(bpath)
203                .cloned()
204                .unwrap_or_else(|| bpath.clone())
205        } else {
206            op.clone()
207        };
208
209        let b = bp.as_ref().and_then(|p| base.get(p));
210        let o = ours.get(&op);
211        let t = theirs.get(&tp);
212        if t.is_some() {
213            handled_theirs.insert(tp.clone());
214        }
215
216        if let Some(ref p) = bp {
217            handled_base.insert(p.clone());
218        }
219
220        // When our branch still has the file at the same path as the merge base, but the side
221        // being applied renamed that path, the result must use their pathname (matches Git
222        // cherry-pick / merge-ort: e.g. base+HEAD at `file.txt`, picked commit has `renamed.txt`).
223        let out_path = if bp.as_ref().is_some_and(|b| b == &op) && tp != op {
224            tp.clone()
225        } else {
226            op.clone()
227        };
228
229        merge_one_path(
230            repo,
231            &mut out,
232            &mut conflict_content,
233            &out_path,
234            b,
235            o,
236            t,
237            favor,
238            ws,
239            diff_algorithm,
240            presentation,
241        )?;
242    }
243
244    for bp in sorted_paths(base.keys()) {
245        if handled_base.contains(&bp) {
246            continue;
247        }
248        let tp = theirs_old_to_new
249            .get(&bp)
250            .cloned()
251            .unwrap_or_else(|| bp.clone());
252        let b = base.get(&bp);
253        let o: Option<&IndexEntry> = None;
254        let t = theirs.get(&tp);
255        if t.is_some() {
256            handled_theirs.insert(tp.clone());
257        }
258        merge_one_path(
259            repo,
260            &mut out,
261            &mut conflict_content,
262            &bp,
263            b,
264            o,
265            t,
266            favor,
267            ws,
268            diff_algorithm,
269            presentation,
270        )?;
271    }
272
273    for tp in sorted_paths(theirs.keys()) {
274        if handled_theirs.contains(&tp) {
275            continue;
276        }
277        let b: Option<&IndexEntry> = None;
278        let o: Option<&IndexEntry> = None;
279        let t = theirs.get(&tp);
280        merge_one_path(
281            repo,
282            &mut out,
283            &mut conflict_content,
284            &tp,
285            b,
286            o,
287            t,
288            favor,
289            ws,
290            diff_algorithm,
291            presentation,
292        )?;
293    }
294
295    out.sort();
296    Ok(TreeMergeOutput {
297        index: out,
298        conflict_content,
299    })
300}
301
302fn sorted_paths<'a>(keys: impl Iterator<Item = &'a Vec<u8>>) -> Vec<Vec<u8>> {
303    let mut v: Vec<Vec<u8>> = keys.cloned().collect();
304    v.sort();
305    v
306}
307
308fn merge_one_path(
309    repo: &Repository,
310    index: &mut Index,
311    conflict_content: &mut BTreeMap<Vec<u8>, ObjectId>,
312    out_path: &[u8],
313    b: Option<&IndexEntry>,
314    o: Option<&IndexEntry>,
315    t: Option<&IndexEntry>,
316    favor: MergeFavor,
317    ws: WhitespaceMergeOptions,
318    diff_algorithm: Option<&str>,
319    presentation: TreeMergeConflictPresentation<'_>,
320) -> crate::error::Result<()> {
321    match (b, o, t) {
322        (_, Some(oe), Some(te)) if same_blob(oe, te) => {
323            let mut e = oe.clone();
324            e.path = out_path.to_vec();
325            e.flags = path_len_flags(out_path);
326            index.entries.push(e);
327        }
328        (Some(be), Some(oe), Some(te)) if same_blob(be, oe) => {
329            let mut e = te.clone();
330            e.path = out_path.to_vec();
331            e.flags = path_len_flags(out_path);
332            index.entries.push(e);
333        }
334        (Some(be), Some(oe), Some(te)) if same_blob(be, te) => {
335            let mut e = oe.clone();
336            e.path = out_path.to_vec();
337            e.flags = path_len_flags(out_path);
338            index.entries.push(e);
339        }
340        (Some(be), Some(oe), Some(te))
341            if be.mode == 0o160000 && oe.mode == 0o160000 && te.mode == 0o160000 =>
342        {
343            if same_blob(oe, te) {
344                let mut e = oe.clone();
345                e.path = out_path.to_vec();
346                e.flags = path_len_flags(out_path);
347                index.entries.push(e);
348            } else if same_blob(be, oe) {
349                let mut e = te.clone();
350                e.path = out_path.to_vec();
351                e.flags = path_len_flags(out_path);
352                index.entries.push(e);
353            } else if same_blob(be, te) {
354                let mut e = oe.clone();
355                e.path = out_path.to_vec();
356                e.flags = path_len_flags(out_path);
357                index.entries.push(e);
358            } else {
359                stage_entry(index, out_path, be, 1);
360                stage_entry(index, out_path, oe, 2);
361                stage_entry(index, out_path, te, 3);
362            }
363        }
364        (Some(be), Some(oe), Some(te)) => {
365            content_merge_or_conflict(
366                repo,
367                index,
368                conflict_content,
369                out_path,
370                be,
371                oe,
372                te,
373                favor,
374                ws,
375                diff_algorithm,
376                presentation,
377            )?;
378        }
379        (None, Some(oe), None) => {
380            let mut e = oe.clone();
381            e.path = out_path.to_vec();
382            e.flags = path_len_flags(out_path);
383            index.entries.push(e);
384        }
385        (None, None, Some(te)) => {
386            let mut e = te.clone();
387            e.path = out_path.to_vec();
388            e.flags = path_len_flags(out_path);
389            index.entries.push(e);
390        }
391        (None, Some(oe), Some(te)) if same_blob(oe, te) => {
392            let mut e = oe.clone();
393            e.path = out_path.to_vec();
394            e.flags = path_len_flags(out_path);
395            index.entries.push(e);
396        }
397        (None, Some(oe), Some(te)) => {
398            // add/add conflict: both sides introduced the path with differing content and there
399            // is no merge base. Git performs a content merge against an empty base, leaving
400            // conflict markers in the working tree (and unmerged stages 2/3 in the index). We
401            // previously only recorded the index stages, leaving the working-tree file as the
402            // plain "ours" blob — that hid the conflict from `rerere` and the user (t3504).
403            add_add_content_conflict(
404                repo,
405                index,
406                conflict_content,
407                out_path,
408                oe,
409                te,
410                favor,
411                ws,
412                diff_algorithm,
413                presentation,
414            )?;
415        }
416        (Some(_), None, None) => {}
417        (Some(be), Some(oe), None) if same_blob(be, oe) => {}
418        (Some(be), None, Some(te)) if same_blob(be, te) => {}
419        (Some(be), Some(oe), None) => {
420            stage_entry(index, out_path, be, 1);
421            stage_entry(index, out_path, oe, 2);
422        }
423        (Some(be), None, Some(te)) => {
424            stage_entry(index, out_path, be, 1);
425            stage_entry(index, out_path, te, 3);
426        }
427        (None, None, None) => {}
428    }
429    Ok(())
430}
431
432fn path_len_flags(path: &[u8]) -> u16 {
433    path.len().min(0xFFF) as u16
434}
435
436fn same_blob(a: &IndexEntry, b: &IndexEntry) -> bool {
437    a.oid == b.oid && a.mode == b.mode
438}
439
440fn stage_entry(index: &mut Index, path: &[u8], src: &IndexEntry, stage: u8) {
441    let mut e = src.clone();
442    e.path = path.to_vec();
443    e.flags = path_len_flags(path) | ((stage as u16) << 12);
444    index.entries.push(e);
445}
446
447fn content_merge_or_conflict(
448    repo: &Repository,
449    index: &mut Index,
450    conflict_content: &mut BTreeMap<Vec<u8>, ObjectId>,
451    path: &[u8],
452    base: &IndexEntry,
453    ours: &IndexEntry,
454    theirs: &IndexEntry,
455    favor: MergeFavor,
456    ws: WhitespaceMergeOptions,
457    diff_algorithm: Option<&str>,
458    presentation: TreeMergeConflictPresentation<'_>,
459) -> crate::error::Result<()> {
460    if base.mode == 0o160000 || ours.mode == 0o160000 || theirs.mode == 0o160000 {
461        stage_entry(index, path, base, 1);
462        stage_entry(index, path, ours, 2);
463        stage_entry(index, path, theirs, 3);
464        return Ok(());
465    }
466
467    // `git checkout -m` runs the normal three-way line merge below: when the merge auto-resolves
468    // (non-overlapping edits) Git records a clean stage-0 entry and reports a single `M <path>`;
469    // only a genuine line conflict (`result.conflicts > 0`) records unmerged stages 1/2/3. The
470    // marker style (merge vs. diff3) is taken from `presentation.style`, so `--conflict=diff3`
471    // produces the `||||||| base` section (t7201 5, 6, 9).
472    let base_obj = repo.odb.read(&base.oid)?;
473    let ours_obj = repo.odb.read(&ours.oid)?;
474    let theirs_obj = repo.odb.read(&theirs.oid)?;
475
476    if crate::merge_file::is_binary(&base_obj.data)
477        || crate::merge_file::is_binary(&ours_obj.data)
478        || crate::merge_file::is_binary(&theirs_obj.data)
479    {
480        match favor {
481            MergeFavor::Theirs => {
482                let mut e = theirs.clone();
483                e.path = path.to_vec();
484                e.flags = path_len_flags(path);
485                index.entries.push(e);
486                return Ok(());
487            }
488            MergeFavor::Ours => {
489                let mut e = ours.clone();
490                e.path = path.to_vec();
491                e.flags = path_len_flags(path);
492                index.entries.push(e);
493                return Ok(());
494            }
495            _ => {
496                stage_entry(index, path, base, 1);
497                stage_entry(index, path, ours, 2);
498                stage_entry(index, path, theirs, 3);
499                return Ok(());
500            }
501        }
502    }
503
504    let path_label = String::from_utf8_lossy(path);
505    let label_theirs: std::borrow::Cow<'_, str> = match presentation.label_theirs {
506        TheirsConflictLabel::PathUtf8 => path_label.clone(),
507        TheirsConflictLabel::Fixed(s) => std::borrow::Cow::Borrowed(s),
508    };
509    let input = MergeInput {
510        base: &base_obj.data,
511        ours: &ours_obj.data,
512        theirs: &theirs_obj.data,
513        label_ours: presentation.label_ours,
514        label_base: presentation.label_base,
515        label_theirs: label_theirs.as_ref(),
516        favor,
517        style: presentation.style,
518        marker_size: 7,
519        diff_algorithm: diff_algorithm.map(str::to_owned),
520        ignore_all_space: ws.ignore_all_space,
521        ignore_space_change: ws.ignore_space_change,
522        ignore_space_at_eol: ws.ignore_space_at_eol,
523        ignore_cr_at_eol: ws.ignore_cr_at_eol,
524    };
525
526    let result = merge(&input)?;
527
528    if result.conflicts > 0 {
529        let conflict_oid = repo.odb.write(ObjectKind::Blob, &result.content)?;
530        conflict_content.insert(path.to_vec(), conflict_oid);
531        stage_entry(index, path, base, 1);
532        stage_entry(index, path, ours, 2);
533        stage_entry(index, path, theirs, 3);
534    } else {
535        let merged_oid = repo.odb.write(ObjectKind::Blob, &result.content)?;
536        let mut entry = ours.clone();
537        entry.path = path.to_vec();
538        entry.flags = path_len_flags(path);
539        entry.oid = merged_oid;
540        if base.mode == ours.mode && base.mode != theirs.mode {
541            entry.mode = theirs.mode;
542        }
543        index.entries.push(entry);
544    }
545
546    Ok(())
547}
548
549/// Resolve an add/add conflict (no merge base, both sides created `path`).
550///
551/// Mirrors Git's behaviour: a content merge with an empty base. When the line merge does not
552/// auto-resolve (the common case for genuinely different content), the conflict-marker blob is
553/// recorded for the working tree and the unmerged stages 2/3 are written to the index. Gitlink
554/// (submodule) and binary add/adds fall back to plain unmerged stages, matching the
555/// `content_merge_or_conflict` policy.
556#[allow(clippy::too_many_arguments)]
557fn add_add_content_conflict(
558    repo: &Repository,
559    index: &mut Index,
560    conflict_content: &mut BTreeMap<Vec<u8>, ObjectId>,
561    path: &[u8],
562    ours: &IndexEntry,
563    theirs: &IndexEntry,
564    favor: MergeFavor,
565    ws: WhitespaceMergeOptions,
566    diff_algorithm: Option<&str>,
567    presentation: TreeMergeConflictPresentation<'_>,
568) -> crate::error::Result<()> {
569    // Gitlink add/add: no textual merge is possible; leave unmerged stages.
570    if ours.mode == 0o160000 || theirs.mode == 0o160000 {
571        stage_entry(index, path, ours, 2);
572        stage_entry(index, path, theirs, 3);
573        return Ok(());
574    }
575
576    let ours_obj = repo.odb.read(&ours.oid)?;
577    let theirs_obj = repo.odb.read(&theirs.oid)?;
578
579    // Binary add/add: honour an explicit favour, else leave unmerged stages.
580    if crate::merge_file::is_binary(&ours_obj.data)
581        || crate::merge_file::is_binary(&theirs_obj.data)
582    {
583        match favor {
584            MergeFavor::Theirs => {
585                let mut e = theirs.clone();
586                e.path = path.to_vec();
587                e.flags = path_len_flags(path);
588                index.entries.push(e);
589            }
590            MergeFavor::Ours => {
591                let mut e = ours.clone();
592                e.path = path.to_vec();
593                e.flags = path_len_flags(path);
594                index.entries.push(e);
595            }
596            _ => {
597                stage_entry(index, path, ours, 2);
598                stage_entry(index, path, theirs, 3);
599            }
600        }
601        return Ok(());
602    }
603
604    let path_label = String::from_utf8_lossy(path);
605    let label_theirs: std::borrow::Cow<'_, str> = match presentation.label_theirs {
606        TheirsConflictLabel::PathUtf8 => path_label.clone(),
607        TheirsConflictLabel::Fixed(s) => std::borrow::Cow::Borrowed(s),
608    };
609    let input = MergeInput {
610        base: b"",
611        ours: &ours_obj.data,
612        theirs: &theirs_obj.data,
613        label_ours: presentation.label_ours,
614        label_base: presentation.label_base,
615        label_theirs: label_theirs.as_ref(),
616        favor,
617        style: presentation.style,
618        marker_size: 7,
619        diff_algorithm: diff_algorithm.map(str::to_owned),
620        ignore_all_space: ws.ignore_all_space,
621        ignore_space_change: ws.ignore_space_change,
622        ignore_space_at_eol: ws.ignore_space_at_eol,
623        ignore_cr_at_eol: ws.ignore_cr_at_eol,
624    };
625
626    let result = merge(&input)?;
627
628    // Git treats an add/add as a conflict whenever the two sides cannot be reconciled to a single
629    // stage-0 entry. With no merge base there is nothing to disambiguate a mode clash, so when both
630    // sides introduce the path as a regular file with a different executable bit (100644 vs 100755)
631    // the result is a conflict even if the *content* merges cleanly — Git records unmerged stages
632    // 2/3 with the diverging modes (t6411: double mode change with identical content). Type clashes
633    // (file/symlink/submodule) are handled by the gitlink branch above / elsewhere.
634    let both_regular_files =
635        matches!(ours.mode, 0o100644 | 0o100755) && matches!(theirs.mode, 0o100644 | 0o100755);
636    let mode_conflict = both_regular_files && ours.mode != theirs.mode && favor == MergeFavor::None;
637
638    if result.conflicts > 0 || mode_conflict {
639        let conflict_oid = repo.odb.write(ObjectKind::Blob, &result.content)?;
640        conflict_content.insert(path.to_vec(), conflict_oid);
641        stage_entry(index, path, ours, 2);
642        stage_entry(index, path, theirs, 3);
643    } else {
644        // A favour (ours/theirs/union) or identical-after-normalisation merge resolved the
645        // content cleanly: record a single stage-0 entry with the merged blob.
646        let merged_oid = repo.odb.write(ObjectKind::Blob, &result.content)?;
647        let mut entry = ours.clone();
648        entry.path = path.to_vec();
649        entry.flags = path_len_flags(path);
650        entry.oid = merged_oid;
651        index.entries.push(entry);
652    }
653
654    Ok(())
655}
656
657fn tree_to_index_entries(
658    repo: &Repository,
659    oid: &ObjectId,
660    prefix: &str,
661) -> crate::error::Result<Vec<IndexEntry>> {
662    let obj = repo.odb.read(oid)?;
663    if obj.kind != ObjectKind::Tree {
664        return Err(crate::error::Error::CorruptObject(format!(
665            "expected tree, got {}",
666            obj.kind.as_str()
667        )));
668    }
669    let entries = parse_tree(&obj.data)?;
670    let mut result = Vec::new();
671
672    for te in entries {
673        let name = String::from_utf8_lossy(&te.name).into_owned();
674        let path = if prefix.is_empty() {
675            name.clone()
676        } else {
677            format!("{prefix}/{name}")
678        };
679
680        if te.mode == 0o040000 {
681            let sub = tree_to_index_entries(repo, &te.oid, &path)?;
682            result.extend(sub);
683        } else {
684            let path_bytes = path.into_bytes();
685            result.push(IndexEntry {
686                ctime_sec: 0,
687                ctime_nsec: 0,
688                mtime_sec: 0,
689                mtime_nsec: 0,
690                dev: 0,
691                ino: 0,
692                mode: te.mode,
693                uid: 0,
694                gid: 0,
695                size: 0,
696                oid: te.oid,
697                flags: path_bytes.len().min(0xFFF) as u16,
698                flags_extended: None,
699                path: path_bytes,
700                base_index_pos: 0,
701            });
702        }
703    }
704    Ok(result)
705}
706
707fn tree_to_map(entries: Vec<IndexEntry>) -> HashMap<Vec<u8>, IndexEntry> {
708    let mut out = HashMap::new();
709    for e in entries {
710        out.insert(e.path.clone(), e);
711    }
712    out
713}
714
715/// True when the index tree matches `head_tree_oid` (used for empty pick detection).
716#[must_use]
717pub fn index_tree_oid_matches_head(
718    odb: &Odb,
719    index: &Index,
720    head_tree_oid: &ObjectId,
721) -> crate::error::Result<bool> {
722    let merged = write_tree_from_index(odb, index, "")?;
723    Ok(merged == *head_tree_oid)
724}