Skip to main content

sley_diff_merge/
name_status.rs

1//! Tree/index/worktree name-status diff and rename detection.
2
3use sley_core::{
4    object_id_for_bytes, BString, GitError, ObjectFormat, ObjectId, RepoPath, Result,
5};
6use sley_index::{BorrowedIndex, Index, IndexStatCache};
7use sley_object::{Commit, EncodedObject, ObjectType, Tree, TreeEntries, TreeEntry};
8use sley_odb::{FileObjectDatabase, ObjectReader, ObjectWriter};
9use sley_refs::{FileRefStore, RefTarget};
10use std::collections::{BTreeMap, BTreeSet, HashMap};
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14
15use crate::line_diff::DiffAlgorithm;
16
17// ===========================================================================
18// Gitlink (submodule) resolution helpers.
19//
20// A gitlink is a mode-160000 tree/index entry whose oid names the commit an
21// embedded repository has checked out. These helpers resolve, for a directory
22// in the working tree, (a) the embedded repository's git directory — either a
23// `.git` directory or a `.git` *file* carrying a `gitdir: <path>` pointer (the
24// layout `git submodule add`/`update` creates, pointing into the
25// superproject's `.git/modules/<name>`) — and (b) the commit its HEAD names.
26// They are the native equivalent of upstream's `resolve_gitlink_ref()`.
27// ===========================================================================
28
29/// Resolve the git directory of an embedded repository whose working tree is
30/// at `sub_root`. A `.git` directory is returned as-is; a `.git` file is
31/// followed through its `gitdir: <path>` pointer (a relative pointer resolves
32/// against `sub_root`). Returns `None` when there is no `.git` entry or the
33/// pointer does not name an existing directory.
34pub fn gitlink_git_dir(sub_root: &Path) -> Option<PathBuf> {
35    let dot_git = sub_root.join(".git");
36    let metadata = fs::symlink_metadata(&dot_git).ok()?;
37    if metadata.is_dir() {
38        return Some(dot_git);
39    }
40    if !metadata.is_file() {
41        return None;
42    }
43    let contents = fs::read_to_string(&dot_git).ok()?;
44    let target = contents.strip_prefix("gitdir:")?.trim();
45    if target.is_empty() {
46        return None;
47    }
48    let target = PathBuf::from(target);
49    let git_dir = if target.is_absolute() {
50        target
51    } else {
52        sub_root.join(target)
53    };
54    if git_dir.is_dir() {
55        Some(git_dir)
56    } else {
57        None
58    }
59}
60
61/// When `sub_root` holds a *broken* gitlink — a `.git` file whose `gitdir:`
62/// pointer names a directory that no longer exists (e.g. the submodule's git
63/// directory was moved out of `.git/modules/`) — return that unresolved gitdir
64/// path. git's status / diff-index fail fatally ("not a git repository: …")
65/// here. Returns `None` for a valid gitlink (a `.git` directory, or a `.git`
66/// file with a live gitdir) and for an *unpopulated* gitlink (no `.git` entry at
67/// all), both of which git treats as non-fatal (the latter as unchanged).
68pub fn gitlink_broken_gitdir(sub_root: &Path) -> Option<PathBuf> {
69    let dot_git = sub_root.join(".git");
70    let metadata = fs::symlink_metadata(&dot_git).ok()?;
71    if !metadata.is_file() {
72        // No `.git` (unpopulated) or a real `.git` directory — not broken.
73        return None;
74    }
75    let contents = fs::read_to_string(&dot_git).ok()?;
76    let target = contents.strip_prefix("gitdir:")?.trim();
77    if target.is_empty() {
78        return None;
79    }
80    let target_path = if Path::new(target).is_absolute() {
81        PathBuf::from(target)
82    } else {
83        sub_root.join(target)
84    };
85    if target_path.is_dir() {
86        None
87    } else {
88        Some(target_path)
89    }
90}
91
92/// Resolve the commit checked out in the embedded repository at `sub_root`
93/// (the value a gitlink entry for that path records): its git directory's
94/// HEAD, followed through symbolic refs. `None` when `sub_root` is not a
95/// repository or its HEAD does not resolve to a commit (e.g. an unborn
96/// branch) — upstream's `resolve_gitlink_ref() < 0` case.
97pub fn gitlink_head_oid(sub_root: &Path, format: ObjectFormat) -> Option<ObjectId> {
98    let git_dir = gitlink_git_dir(sub_root)?;
99    let store = FileRefStore::new(&git_dir, format);
100    let mut target = store.read_ref("HEAD").ok()??;
101    // Follow symbolic-ref chains defensively (git caps the depth too).
102    for _ in 0..10 {
103        match target {
104            RefTarget::Direct(oid) => return Some(oid),
105            RefTarget::Symbolic(name) => target = store.read_ref(&name).ok()??,
106        }
107    }
108    None
109}
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum FileChange {
112    Add { path: RepoPath },
113    Delete { path: RepoPath },
114    Modify { path: RepoPath },
115    Rename { old: RepoPath, new: RepoPath },
116    Copy { source: RepoPath, dest: RepoPath },
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct Conflict {
121    pub path: RepoPath,
122    pub ours: Vec<u8>,
123    pub theirs: Vec<u8>,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum NameStatus {
128    Added,
129    Deleted,
130    Modified,
131    /// A path whose file type (`S_IFMT` bits of the mode) changed between the two
132    /// sides — regular↔symlink, regular↔gitlink, symlink↔gitlink. git renders this
133    /// as `T` (`DIFF_STATUS_TYPE_CHANGED`, set by `diffcore.h`'s
134    /// `DIFF_PAIR_TYPE_CHANGED` before rename/modify resolution). An exec-bit-only
135    /// change (100644↔100755) is NOT a typechange — same `S_IFMT`.
136    TypeChanged,
137    Renamed(u8),
138    Copied(u8),
139    /// An unmerged (conflicted) path: the index holds higher-stage entries.
140    /// git emits a standalone `U <path>` pair (`diff_unmerge`) for it in
141    /// addition to the regular worktree-vs-stage-2 modify.
142    Unmerged,
143}
144
145impl NameStatus {
146    pub const fn code(self) -> char {
147        match self {
148            Self::Added => 'A',
149            Self::Deleted => 'D',
150            Self::Modified => 'M',
151            Self::TypeChanged => 'T',
152            Self::Renamed(_) => 'R',
153            Self::Copied(_) => 'C',
154            Self::Unmerged => 'U',
155        }
156    }
157
158    pub fn label(self) -> String {
159        match self {
160            Self::Renamed(score) => format!("R{score:03}"),
161            Self::Copied(score) => format!("C{score:03}"),
162            _ => self.code().to_string(),
163        }
164    }
165}
166
167/// The bit mask isolating the file-type bits of a git mode (`S_IFMT`). Regular
168/// files are `0o100000`, symlinks `0o120000`, gitlinks `0o160000`, trees
169/// `0o040000`.
170pub const S_IFMT: u32 = 0o170000;
171
172/// Whether a pair of (non-zero) modes constitutes a git "typechange": the file
173/// type bits (`S_IFMT`) differ. Mirrors `diffcore.h`'s `DIFF_PAIR_TYPE_CHANGED`
174/// (`(S_IFMT & one->mode) != (S_IFMT & two->mode)`). An exec-bit-only change
175/// (`0o100644` ↔ `0o100755`) is NOT a typechange — same `S_IFMT`.
176#[must_use]
177pub const fn is_type_change(old_mode: u32, new_mode: u32) -> bool {
178    (old_mode & S_IFMT) != (new_mode & S_IFMT)
179}
180
181/// Classify a both-sides-present change whose entries already differ: a
182/// [`NameStatus::TypeChanged`] when the modes' `S_IFMT` bits differ, otherwise a
183/// plain [`NameStatus::Modified`]. git sets `DIFF_STATUS_TYPE_CHANGED` before any
184/// rename/modify resolution (`diff.c` ~6650).
185#[must_use]
186pub const fn modify_or_type_change(old_mode: u32, new_mode: u32) -> NameStatus {
187    if is_type_change(old_mode, new_mode) {
188        NameStatus::TypeChanged
189    } else {
190        NameStatus::Modified
191    }
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct NameStatusEntry {
196    pub status: NameStatus,
197    pub path: BString,
198    pub old_path: Option<BString>,
199    pub old_mode: Option<u32>,
200    pub new_mode: Option<u32>,
201    pub old_oid: Option<ObjectId>,
202    pub new_oid: Option<ObjectId>,
203}
204
205impl NameStatusEntry {
206    pub fn line(&self) -> String {
207        if let Some(old_path) = &self.old_path {
208            format!(
209                "{}\t{}\t{}",
210                self.status.label(),
211                String::from_utf8_lossy(old_path.as_bytes()),
212                String::from_utf8_lossy(self.path.as_bytes())
213            )
214        } else {
215            format!(
216                "{}\t{}",
217                self.status.label(),
218                String::from_utf8_lossy(self.path.as_bytes())
219            )
220        }
221    }
222}
223
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct IndexGitlinkEntry {
226    pub path: BString,
227    pub oid: ObjectId,
228}
229
230#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct IndexWorktreeDiff {
232    pub entries: Vec<NameStatusEntry>,
233    pub staged_gitlinks: Vec<IndexGitlinkEntry>,
234}
235
236#[derive(Clone, Copy)]
237pub struct IndexWorktreeValidationEntry<'a> {
238    pub path: &'a [u8],
239    pub mode: u32,
240    pub oid: ObjectId,
241    pub size: u32,
242}
243
244pub struct IndexWorktreeValidatedEntry {
245    pub mode: u32,
246    pub oid: ObjectId,
247}
248
249pub type IndexWorktreeStatCleanValidator<'a> = dyn FnMut(
250        IndexWorktreeValidationEntry<'_>,
251        &Path,
252        &fs::Metadata,
253    ) -> Result<Option<IndexWorktreeValidatedEntry>>
254    + 'a;
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub struct DiffNameStatusOptions {
258    pub detect_renames: bool,
259    pub detect_copies: bool,
260    pub find_copies_harder: bool,
261    pub rename_empty: bool,
262    pub detect_inexact: bool,
263    pub rename_threshold: u8,
264    pub copy_threshold: u8,
265    pub rename_limit: usize,
266}
267
268impl Default for DiffNameStatusOptions {
269    fn default() -> Self {
270        Self {
271            detect_renames: true,
272            detect_copies: false,
273            find_copies_harder: false,
274            rename_empty: true,
275            detect_inexact: false,
276            rename_threshold: DEFAULT_RENAME_THRESHOLD,
277            copy_threshold: DEFAULT_RENAME_THRESHOLD,
278            rename_limit: 0,
279        }
280    }
281}
282
283impl DiffNameStatusOptions {
284    pub fn inexact(mut self) -> Self {
285        self.detect_inexact = true;
286        self
287    }
288}
289
290/// git's default minimum similarity (as a percentage) for a pair of files to be
291/// reported as a rename or copy. Matches `git`'s built-in `-M`/`-C` threshold
292/// of 50% (`DEFAULT_RENAME_SCORE` is `MAX_SCORE / 2`).
293pub const DEFAULT_RENAME_THRESHOLD: u8 = 50;
294
295#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
296pub struct RenameLimitDiagnostics {
297    pub inexact_renames_skipped: bool,
298    pub inexact_copies_skipped: bool,
299    pub inexact_copies_degraded: bool,
300}
301
302impl RenameLimitDiagnostics {
303    pub fn any_skipped(self) -> bool {
304        self.inexact_renames_skipped || self.inexact_copies_skipped
305    }
306
307    pub fn any_warning(self) -> bool {
308        self.any_skipped() || self.inexact_copies_degraded
309    }
310}
311
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct NameStatusWithRenameDiagnostics {
314    pub entries: Vec<NameStatusEntry>,
315    pub rename_limit: RenameLimitDiagnostics,
316}
317
318pub fn diff_name_status_head_worktree(
319    worktree_root: impl AsRef<Path>,
320    git_dir: impl AsRef<Path>,
321    format: ObjectFormat,
322) -> Result<Vec<NameStatusEntry>> {
323    diff_name_status_head_worktree_with_options(
324        worktree_root,
325        git_dir,
326        format,
327        DiffNameStatusOptions::default(),
328    )
329}
330
331pub fn diff_name_status_head_worktree_with_options(
332    worktree_root: impl AsRef<Path>,
333    git_dir: impl AsRef<Path>,
334    format: ObjectFormat,
335    options: DiffNameStatusOptions,
336) -> Result<Vec<NameStatusEntry>> {
337    let worktree_root = worktree_root.as_ref();
338    let git_dir = git_dir.as_ref();
339    let db = FileObjectDatabase::from_git_dir(git_dir, format);
340    let head = head_tree_entries(git_dir, format, &db)?;
341    let IndexSnapshot {
342        entries: index,
343        stat_cache,
344        missing_skip_worktree_entries,
345        present_skip_worktree_entries,
346    } = read_index_snapshot(git_dir, format)?;
347    let index_gitlinks = index_gitlinks(&index);
348    let candidate_paths = candidate_path_set(head.keys().chain(index.keys()));
349    let worktree = worktree_entries_for_path_set(
350        worktree_root,
351        format,
352        &candidate_paths,
353        &index_gitlinks,
354        Some(&stat_cache),
355        Some(&missing_skip_worktree_entries),
356        Some(&present_skip_worktree_entries),
357    )?;
358    let changes = if options.detect_inexact {
359        let cache = worktree_blob_cache_for_path_set(
360            worktree_root,
361            &head,
362            &worktree,
363            &candidate_paths,
364            Some(&present_skip_worktree_entries),
365            options,
366        )?;
367        diff_name_status_maps_with_renames_for_path_set(
368            &head,
369            &worktree,
370            &candidate_paths,
371            options,
372            |oid| cache_or_odb_blob(&cache, &db, oid),
373        )?
374    } else {
375        diff_name_status_maps_for_path_set(&head, &worktree, &candidate_paths, options)?
376    };
377    Ok(mark_unstaged_worktree_oids_unresolved(
378        changes, &index, &worktree,
379    ))
380}
381
382pub fn diff_name_status_head_index(
383    git_dir: impl AsRef<Path>,
384    format: ObjectFormat,
385) -> Result<Vec<NameStatusEntry>> {
386    diff_name_status_head_index_with_options(git_dir, format, DiffNameStatusOptions::default())
387}
388
389pub fn diff_name_status_head_index_with_options(
390    git_dir: impl AsRef<Path>,
391    format: ObjectFormat,
392    options: DiffNameStatusOptions,
393) -> Result<Vec<NameStatusEntry>> {
394    if options.detect_inexact {
395        return Ok(
396            diff_name_status_head_index_with_options_and_diagnostics(git_dir, format, options)?
397                .entries,
398        );
399    }
400    let git_dir = git_dir.as_ref();
401    let db = FileObjectDatabase::from_git_dir(git_dir, format);
402    let head = head_tree_entries(git_dir, format, &db)?;
403    let index = read_index_entries(git_dir, format)?;
404    diff_name_status_maps(&head, &index, head.keys().chain(index.keys()), options)
405}
406
407pub fn diff_name_status_head_index_with_options_and_diagnostics(
408    git_dir: impl AsRef<Path>,
409    format: ObjectFormat,
410    options: DiffNameStatusOptions,
411) -> Result<NameStatusWithRenameDiagnostics> {
412    let git_dir = git_dir.as_ref();
413    let db = FileObjectDatabase::from_git_dir(git_dir, format);
414    let head = head_tree_entries(git_dir, format, &db)?;
415    let index = read_index_entries(git_dir, format)?;
416    diff_name_status_maps_with_renames_and_diagnostics(
417        &head,
418        &index,
419        head.keys().chain(index.keys()),
420        options,
421        |oid| read_blob_bytes(&db, oid),
422    )
423}
424
425/// Read an arbitrary tree object's flattened blob entries (recursively) keyed by
426/// repository-relative path. This is the tree-side counterpart used by
427/// `git diff-index <tree-ish>`: unlike [`head_tree_entries`] it does not consult
428/// `HEAD`, so any commit/tag (peeled to a tree) or tree oid can be compared.
429///
430/// The canonical empty tree (`git hash-object -t tree /dev/null`) is treated as
431/// always present and yields no entries, even when the object was never written
432/// to the database. git makes the same guarantee, which keeps the common idiom
433/// `git diff-index --cached <empty-tree-sha>` working in a fresh repository.
434fn tree_entries(
435    tree_oid: &ObjectId,
436    format: ObjectFormat,
437    db: &FileObjectDatabase,
438) -> Result<BTreeMap<Vec<u8>, TrackedEntry>> {
439    let mut entries = BTreeMap::new();
440    if *tree_oid == empty_tree_oid(format)? {
441        return Ok(entries);
442    }
443    collect_tree_entries(db, format, tree_oid, Vec::new(), &mut entries)?;
444    Ok(entries)
445}
446
447/// The well-known oid of the empty tree for `format` (the hash of a zero-length
448/// tree object). git hard-codes this value and treats it as always existing.
449pub(crate) fn empty_tree_oid(format: ObjectFormat) -> Result<ObjectId> {
450    object_id_for_bytes(format, "tree", b"")
451}
452
453/// Name-status diff of an arbitrary tree against the index, the engine behind
454/// `git diff-index --cached <tree-ish>`. Exact rename/copy detection follows
455/// `options`; all blob content comes from the object database.
456pub fn diff_name_status_tree_index_with_options(
457    git_dir: impl AsRef<Path>,
458    format: ObjectFormat,
459    tree_oid: &ObjectId,
460    options: DiffNameStatusOptions,
461) -> Result<Vec<NameStatusEntry>> {
462    if options.detect_inexact {
463        return Ok(
464            diff_name_status_tree_index_with_options_and_diagnostics(
465                git_dir, format, tree_oid, options,
466            )?
467            .entries,
468        );
469    }
470    let git_dir = git_dir.as_ref();
471    let db = FileObjectDatabase::from_git_dir(git_dir, format);
472    let tree = tree_entries(tree_oid, format, &db)?;
473    let index = read_index_entries(git_dir, format)?;
474    diff_name_status_maps(&tree, &index, tree.keys().chain(index.keys()), options)
475}
476
477pub fn diff_name_status_tree_index_with_options_and_diagnostics(
478    git_dir: impl AsRef<Path>,
479    format: ObjectFormat,
480    tree_oid: &ObjectId,
481    options: DiffNameStatusOptions,
482) -> Result<NameStatusWithRenameDiagnostics> {
483    let git_dir = git_dir.as_ref();
484    let db = FileObjectDatabase::from_git_dir(git_dir, format);
485    let tree = tree_entries(tree_oid, format, &db)?;
486    let index = read_index_entries(git_dir, format)?;
487    diff_name_status_maps_with_renames_and_diagnostics(
488        &tree,
489        &index,
490        tree.keys().chain(index.keys()),
491        options,
492        |oid| read_blob_bytes(&db, oid),
493    )
494}
495
496/// Name-status diff of an arbitrary tree against the working tree, the engine
497/// behind plain `git diff-index <tree-ish>` (no `--cached`). New-side oids for
498/// paths whose worktree contents differ from the index are cleared (rendered as
499/// zeros), matching git, which only reports the worktree blob oid when it is
500/// known-clean against the index.
501pub fn diff_name_status_tree_worktree_with_options(
502    worktree_root: impl AsRef<Path>,
503    git_dir: impl AsRef<Path>,
504    format: ObjectFormat,
505    tree_oid: &ObjectId,
506    options: DiffNameStatusOptions,
507) -> Result<Vec<NameStatusEntry>> {
508    let worktree_root = worktree_root.as_ref();
509    let git_dir = git_dir.as_ref();
510    let db = FileObjectDatabase::from_git_dir(git_dir, format);
511    let tree = tree_entries(tree_oid, format, &db)?;
512    let IndexSnapshot {
513        entries: index,
514        stat_cache,
515        missing_skip_worktree_entries,
516        present_skip_worktree_entries,
517    } = read_index_snapshot(git_dir, format)?;
518    let index_gitlinks = index_gitlinks(&index);
519    let candidate_paths = candidate_path_set(tree.keys().chain(index.keys()));
520    let worktree = worktree_entries_for_path_set(
521        worktree_root,
522        format,
523        &candidate_paths,
524        &index_gitlinks,
525        Some(&stat_cache),
526        Some(&missing_skip_worktree_entries),
527        Some(&present_skip_worktree_entries),
528    )?;
529    let changes = if options.detect_inexact {
530        let cache = worktree_blob_cache_for_path_set(
531            worktree_root,
532            &tree,
533            &worktree,
534            &candidate_paths,
535            Some(&present_skip_worktree_entries),
536            options,
537        )?;
538        diff_name_status_maps_with_renames_for_path_set(
539            &tree,
540            &worktree,
541            &candidate_paths,
542            options,
543            |oid| cache_or_odb_blob(&cache, &db, oid),
544        )?
545    } else {
546        diff_name_status_maps_for_path_set(&tree, &worktree, &candidate_paths, options)?
547    };
548    Ok(mark_unstaged_worktree_oids_unresolved(
549        changes, &index, &worktree,
550    ))
551}
552
553pub fn diff_name_status_index_worktree(
554    worktree_root: impl AsRef<Path>,
555    git_dir: impl AsRef<Path>,
556    format: ObjectFormat,
557) -> Result<Vec<NameStatusEntry>> {
558    diff_name_status_index_worktree_with_options(
559        worktree_root,
560        git_dir,
561        format,
562        DiffNameStatusOptions::default(),
563    )
564}
565
566pub fn diff_name_status_index_worktree_with_options(
567    worktree_root: impl AsRef<Path>,
568    git_dir: impl AsRef<Path>,
569    format: ObjectFormat,
570    options: DiffNameStatusOptions,
571) -> Result<Vec<NameStatusEntry>> {
572    Ok(diff_name_status_index_worktree_with_options_and_gitlinks(
573        worktree_root,
574        git_dir,
575        format,
576        options,
577    )?
578    .entries)
579}
580
581pub fn diff_name_status_index_worktree_with_options_and_gitlinks(
582    worktree_root: impl AsRef<Path>,
583    git_dir: impl AsRef<Path>,
584    format: ObjectFormat,
585    options: DiffNameStatusOptions,
586) -> Result<IndexWorktreeDiff> {
587    let IndexWorktreeDiff {
588        entries,
589        staged_gitlinks,
590    } = diff_name_status_index_worktree_changes(
591        worktree_root.as_ref(),
592        git_dir.as_ref(),
593        format,
594        None,
595    )?;
596    let entries = apply_name_status_options_to_index_worktree_changes(entries, options)?;
597    Ok(IndexWorktreeDiff {
598        entries,
599        staged_gitlinks,
600    })
601}
602
603pub fn diff_name_status_index_worktree_with_options_and_gitlinks_validated(
604    worktree_root: impl AsRef<Path>,
605    git_dir: impl AsRef<Path>,
606    format: ObjectFormat,
607    options: DiffNameStatusOptions,
608    stat_clean_validator: &mut IndexWorktreeStatCleanValidator<'_>,
609) -> Result<IndexWorktreeDiff> {
610    let IndexWorktreeDiff {
611        entries,
612        staged_gitlinks,
613    } = diff_name_status_index_worktree_changes(
614        worktree_root.as_ref(),
615        git_dir.as_ref(),
616        format,
617        Some(stat_clean_validator),
618    )?;
619    let entries = apply_name_status_options_to_index_worktree_changes(entries, options)?;
620    Ok(IndexWorktreeDiff {
621        entries,
622        staged_gitlinks,
623    })
624}
625
626fn diff_name_status_index_worktree_changes(
627    worktree_root: &Path,
628    git_dir: &Path,
629    format: ObjectFormat,
630    mut stat_clean_validator: Option<&mut IndexWorktreeStatCleanValidator<'_>>,
631) -> Result<IndexWorktreeDiff> {
632    let index_path = sley_index::repository_index_path(git_dir);
633    let index_metadata = match fs::metadata(&index_path) {
634        Ok(metadata) => metadata,
635        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
636            return Ok(IndexWorktreeDiff {
637                entries: Vec::new(),
638                staged_gitlinks: Vec::new(),
639            });
640        }
641        Err(err) => return Err(err.into()),
642    };
643    let index_bytes = fs::read(&index_path)?;
644    if let Ok(index) = BorrowedIndex::parse(&index_bytes, format)
645        && index.extension(&sley_index::INDEX_EXT_LINK)?.is_none()
646        && !index.entries.iter().any(borrowed_entry_is_sparse_dir)
647    {
648        let (has_non_normal_stage, staged_gitlinks) =
649            index_worktree_metadata_for_entries(&index.entries);
650        if has_non_normal_stage {
651            return diff_name_status_index_worktree_changes_from_snapshot(
652                worktree_root,
653                git_dir,
654                format,
655            );
656        }
657        let stat_cache =
658            IndexStatCache::from_index_mtime_only(sley_index::file_mtime_parts(&index_metadata));
659        let entries = diff_name_status_index_worktree_changes_for_borrowed_entries(
660            worktree_root,
661            format,
662            &index.entries,
663            &stat_cache,
664            stat_clean_validator.as_deref_mut(),
665        )?;
666        return Ok(IndexWorktreeDiff {
667            entries,
668            staged_gitlinks,
669        });
670    }
671    let index = expand_sparse_index_for_worktree_diff(
672        sley_index::read_repository_index(git_dir, format)?,
673        git_dir,
674        format,
675    )?;
676    let (has_non_normal_stage, staged_gitlinks) =
677        index_worktree_metadata_for_entries(&index.entries);
678    if has_non_normal_stage {
679        return diff_name_status_index_worktree_changes_from_snapshot(
680            worktree_root,
681            git_dir,
682            format,
683        );
684    }
685    let stat_cache =
686        IndexStatCache::from_index_mtime_only(sley_index::file_mtime_parts(&index_metadata));
687    let entries = diff_name_status_index_worktree_changes_for_entries(
688        worktree_root,
689        format,
690        &index.entries,
691        &stat_cache,
692        stat_clean_validator.as_deref_mut(),
693    )?;
694    Ok(IndexWorktreeDiff {
695        entries,
696        staged_gitlinks,
697    })
698}
699
700fn borrowed_entry_is_sparse_dir(entry: &sley_index::IndexEntryRef<'_>) -> bool {
701    entry.mode == sley_index::SPARSE_DIR_MODE && entry.is_skip_worktree()
702}
703
704fn expand_sparse_index_for_worktree_diff(
705    mut index: Index,
706    git_dir: &Path,
707    format: ObjectFormat,
708) -> Result<Index> {
709    if !index
710        .entries
711        .iter()
712        .any(sley_index::IndexEntry::is_sparse_dir)
713    {
714        return Ok(index);
715    }
716
717    let db = FileObjectDatabase::from_git_dir(git_dir, format);
718    let mut expanded = Vec::with_capacity(index.entries.len());
719    for entry in std::mem::take(&mut index.entries) {
720        if !entry.is_sparse_dir() {
721            expanded.push(entry);
722            continue;
723        }
724
725        let dir_prefix = entry.path.as_bytes();
726        for (rel_path, (mode, oid)) in flatten_tree(&db, format, &entry.oid)? {
727            let mut path = dir_prefix.to_vec();
728            path.extend_from_slice(&rel_path);
729            let mut expanded_entry = sley_index::IndexEntry {
730                ctime_seconds: 0,
731                ctime_nanoseconds: 0,
732                mtime_seconds: 0,
733                mtime_nanoseconds: 0,
734                dev: 0,
735                ino: 0,
736                mode,
737                uid: 0,
738                gid: 0,
739                size: 0,
740                oid,
741                flags: 0,
742                flags_extended: 0,
743                path: BString::from(path),
744            };
745            expanded_entry.set_skip_worktree(true);
746            expanded_entry.refresh_name_length();
747            expanded.push(expanded_entry);
748        }
749    }
750
751    expanded.sort_by(|left, right| left.path.as_bytes().cmp(right.path.as_bytes()));
752    index.entries = expanded;
753    index.clear_sparse_extension()?;
754    Ok(index)
755}
756
757fn diff_name_status_index_worktree_changes_for_borrowed_entries(
758    worktree_root: &Path,
759    format: ObjectFormat,
760    entries: &[sley_index::IndexEntryRef<'_>],
761    stat_cache: &IndexStatCache,
762    stat_clean_validator: Option<&mut IndexWorktreeStatCleanValidator<'_>>,
763) -> Result<Vec<NameStatusEntry>> {
764    const PARALLEL_SCAN_MIN_ENTRIES: usize = 2048;
765    let workers = std::thread::available_parallelism()
766        .map(|count| count.get())
767        .unwrap_or(1)
768        .min(8);
769    if stat_clean_validator.is_some() || workers <= 1 || entries.len() < PARALLEL_SCAN_MIN_ENTRIES {
770        return diff_name_status_index_worktree_changes_for_borrowed_entry_chunk(
771            worktree_root,
772            format,
773            entries,
774            stat_cache,
775            stat_clean_validator,
776        );
777    }
778    let chunk_size = entries.len().div_ceil(workers);
779    std::thread::scope(|scope| {
780        let mut handles = Vec::new();
781        for chunk in entries.chunks(chunk_size) {
782            handles.push(scope.spawn(move || {
783                diff_name_status_index_worktree_changes_for_borrowed_entry_chunk(
784                    worktree_root,
785                    format,
786                    chunk,
787                    stat_cache,
788                    None,
789                )
790            }));
791        }
792        let mut changes = Vec::new();
793        for handle in handles {
794            let chunk_changes = handle
795                .join()
796                .map_err(|_| GitError::Command("diff worker panicked".into()))??;
797            changes.extend(chunk_changes);
798        }
799        Ok(changes)
800    })
801}
802
803fn diff_name_status_index_worktree_changes_for_entries(
804    worktree_root: &Path,
805    format: ObjectFormat,
806    entries: &[sley_index::IndexEntry],
807    stat_cache: &IndexStatCache,
808    stat_clean_validator: Option<&mut IndexWorktreeStatCleanValidator<'_>>,
809) -> Result<Vec<NameStatusEntry>> {
810    const PARALLEL_SCAN_MIN_ENTRIES: usize = 2048;
811    let workers = std::thread::available_parallelism()
812        .map(|count| count.get())
813        .unwrap_or(1)
814        .min(8);
815    if stat_clean_validator.is_some() || workers <= 1 || entries.len() < PARALLEL_SCAN_MIN_ENTRIES {
816        return diff_name_status_index_worktree_changes_for_entry_chunk(
817            worktree_root,
818            format,
819            entries,
820            stat_cache,
821            stat_clean_validator,
822        );
823    }
824    let chunk_size = entries.len().div_ceil(workers);
825    std::thread::scope(|scope| {
826        let mut handles = Vec::new();
827        for chunk in entries.chunks(chunk_size) {
828            handles.push(scope.spawn(move || {
829                diff_name_status_index_worktree_changes_for_entry_chunk(
830                    worktree_root,
831                    format,
832                    chunk,
833                    stat_cache,
834                    None,
835                )
836            }));
837        }
838        let mut changes = Vec::new();
839        for handle in handles {
840            let chunk_changes = handle
841                .join()
842                .map_err(|_| GitError::Command("diff worker panicked".into()))??;
843            changes.extend(chunk_changes);
844        }
845        Ok(changes)
846    })
847}
848
849fn diff_name_status_index_worktree_changes_for_entry_chunk(
850    worktree_root: &Path,
851    format: ObjectFormat,
852    entries: &[sley_index::IndexEntry],
853    stat_cache: &IndexStatCache,
854    mut stat_clean_validator: Option<&mut IndexWorktreeStatCleanValidator<'_>>,
855) -> Result<Vec<NameStatusEntry>> {
856    let mut changes = Vec::new();
857    let mut path = PathBuf::from(worktree_root);
858    for entry in entries {
859        worktree_path_for_repo_path_into(&mut path, worktree_root, entry.path.as_bytes());
860        if let Some(change) = index_worktree_change_for_entry(
861            &path,
862            format,
863            entry,
864            stat_cache,
865            &mut stat_clean_validator,
866        )? {
867            changes.push(change);
868        }
869    }
870    Ok(changes)
871}
872
873fn diff_name_status_index_worktree_changes_for_borrowed_entry_chunk(
874    worktree_root: &Path,
875    format: ObjectFormat,
876    entries: &[sley_index::IndexEntryRef<'_>],
877    stat_cache: &IndexStatCache,
878    mut stat_clean_validator: Option<&mut IndexWorktreeStatCleanValidator<'_>>,
879) -> Result<Vec<NameStatusEntry>> {
880    let mut changes = Vec::new();
881    let mut path = PathBuf::from(worktree_root);
882    for entry in entries {
883        worktree_path_for_repo_path_into(&mut path, worktree_root, entry.path);
884        if let Some(change) = index_worktree_change_for_entry(
885            &path,
886            format,
887            entry,
888            stat_cache,
889            &mut stat_clean_validator,
890        )? {
891            changes.push(change);
892        }
893    }
894    Ok(changes)
895}
896
897fn index_worktree_metadata_for_entries(
898    entries: &[impl WorktreeIndexEntry],
899) -> (bool, Vec<IndexGitlinkEntry>) {
900    let mut needs_snapshot = false;
901    let mut staged_gitlinks = Vec::new();
902    for entry in entries {
903        if entry.stage() != sley_index::Stage::Normal {
904            needs_snapshot = true;
905        }
906        // Intent-to-add entries (`git add -N`) must take the snapshot path, which
907        // diffs them as new files rather than loading their empty-blob id.
908        if entry.is_intent_to_add() {
909            needs_snapshot = true;
910        }
911        if sley_index::is_gitlink(entry.mode()) {
912            staged_gitlinks.push(IndexGitlinkEntry {
913                path: BString::from_bytes(entry.git_path()),
914                oid: entry.oid(),
915            });
916        }
917    }
918    (needs_snapshot, staged_gitlinks)
919}
920
921fn diff_name_status_index_worktree_changes_from_snapshot(
922    worktree_root: &Path,
923    git_dir: &Path,
924    format: ObjectFormat,
925) -> Result<IndexWorktreeDiff> {
926    let IndexSnapshot {
927        entries: index,
928        stat_cache,
929        ..
930    } = read_index_snapshot(git_dir, format)?;
931    // Intent-to-add (`git add -N`) paths are placeholders: git's `run_diff_files`
932    // diffs them as a brand-new file (`/dev/null` → worktree), never loading the
933    // recorded empty-blob id. `read_index_snapshot` drops the ITA flag, so read
934    // the set of ITA stage-0 paths separately and override their verdict below.
935    let intent_to_add_paths = read_intent_to_add_paths(git_dir, format)?;
936    // `read_index_snapshot` collapses each path to a single entry; for an
937    // unmerged path it keeps the last-written stage. To match git's
938    // `run_diff_files` we need the conflict stages, so read them separately:
939    // git diffs the worktree against the "ours" stage (stage 2, the default
940    // `diff_unmerged_stage`) and additionally emits a standalone `U <path>`
941    // pair via `diff_unmerge` (diff-lib.c).
942    let unmerged = read_unmerged_stages(git_dir, format)?;
943    let index_gitlinks = index_gitlinks(&index);
944    let staged_gitlinks = index_gitlinks
945        .iter()
946        .map(|(path, oid)| IndexGitlinkEntry {
947            path: BString::from_bytes(path),
948            oid: *oid,
949        })
950        .collect();
951    let mut changes = Vec::new();
952    for (git_path, left) in &index {
953        // For a conflicted path git first queues the `U` pair, then compares the
954        // worktree against stage 2 (ours). The snapshot's collapsed `left` may
955        // be the wrong stage, so override it with the stage-2 entry when present.
956        let conflict_stages = unmerged.get(git_path);
957        let right = worktree_entry_for_path(
958            worktree_root,
959            format,
960            git_path,
961            &index_gitlinks,
962            Some(&stat_cache),
963            None,
964            None,
965        )?;
966        if conflict_stages.is_some() {
967            // git's `diff_unmerge` makes a pair with a null old side and the
968            // worktree mode on the new side (diff-lib.c `wt_mode`); the oids stay
969            // zero. The raw line is `:000000 <wt_mode> 0..0 0..0 U <path>`.
970            changes.push(NameStatusEntry {
971                status: NameStatus::Unmerged,
972                path: git_path.clone().into(),
973                old_path: None,
974                old_mode: None,
975                new_mode: right.as_ref().map(|entry| entry.mode),
976                old_oid: None,
977                new_oid: None,
978            });
979        }
980        // The index side for the modify comparison: stage 2 (ours) for a
981        // conflict, otherwise the normal stage-0 entry. If the conflict has no
982        // stage-2 (deleted on our side / added by them), git has no entry to
983        // diff the worktree against, so it emits only the `U` line.
984        let left = match conflict_stages {
985            Some(stages) => match stages.ours.as_ref() {
986                Some(ours) => ours,
987                None => continue,
988            },
989            None => left,
990        };
991        // Intent-to-add placeholder: git's `run_diff_files` diffs it as a new
992        // file. With the worktree file present, queue an `Added` pair whose old
993        // side is null (`/dev/null` → worktree blob); with the file gone, an ITA
994        // entry yields no diff-files entry (there is nothing to add).
995        if intent_to_add_paths.contains(git_path.as_slice()) {
996            if let Some(right) = right {
997                changes.push(NameStatusEntry {
998                    status: NameStatus::Added,
999                    path: git_path.clone().into(),
1000                    old_path: None,
1001                    old_mode: None,
1002                    new_mode: Some(right.mode),
1003                    old_oid: None,
1004                    new_oid: Some(right.oid),
1005                });
1006            }
1007            continue;
1008        }
1009        let Some(right) = right else {
1010            changes.push(NameStatusEntry {
1011                status: NameStatus::Deleted,
1012                path: git_path.clone().into(),
1013                old_path: None,
1014                old_mode: Some(left.mode),
1015                new_mode: None,
1016                old_oid: Some(left.oid),
1017                new_oid: None,
1018            });
1019            continue;
1020        };
1021        if right != *left {
1022            changes.push(NameStatusEntry {
1023                status: modify_or_type_change(left.mode, right.mode),
1024                path: git_path.clone().into(),
1025                old_path: None,
1026                old_mode: Some(left.mode),
1027                new_mode: Some(right.mode),
1028                old_oid: Some(left.oid),
1029                new_oid: Some(right.oid),
1030            });
1031        }
1032    }
1033    Ok(IndexWorktreeDiff {
1034        entries: changes,
1035        staged_gitlinks,
1036    })
1037}
1038
1039/// The conflict stages recorded for one unmerged index path.
1040struct ConflictStages {
1041    ours: Option<TrackedEntry>,
1042}
1043
1044/// Read the higher-stage (conflict) index entries, keyed by path, recording the
1045/// "ours" (stage 2) entry git diffs the worktree against. Paths with only a
1046/// stage-0 entry are absent from the result.
1047fn read_unmerged_stages(
1048    git_dir: &Path,
1049    format: ObjectFormat,
1050) -> Result<BTreeMap<Vec<u8>, ConflictStages>> {
1051    let index_path = sley_index::repository_index_path(git_dir);
1052    if !index_path.exists() {
1053        return Ok(BTreeMap::new());
1054    }
1055    let index = sley_index::read_repository_index(git_dir, format)?;
1056    let mut out: BTreeMap<Vec<u8>, ConflictStages> = BTreeMap::new();
1057    for entry in &index.entries {
1058        let stage = entry.stage();
1059        if stage == sley_index::Stage::Normal {
1060            continue;
1061        }
1062        let path = entry.path.clone().into_bytes();
1063        let slot = out.entry(path).or_insert(ConflictStages { ours: None });
1064        if stage == sley_index::Stage::Ours {
1065            slot.ours = Some(TrackedEntry {
1066                mode: entry.mode,
1067                oid: entry.oid,
1068            });
1069        }
1070    }
1071    Ok(out)
1072}
1073
1074fn apply_name_status_options_to_index_worktree_changes(
1075    mut changes: Vec<NameStatusEntry>,
1076    options: DiffNameStatusOptions,
1077) -> Result<Vec<NameStatusEntry>> {
1078    if options.detect_renames {
1079        changes = detect_exact_renames_from_changes(changes, options.rename_empty);
1080    } else if options.detect_copies {
1081        changes.sort_by(|left, right| diff_entry_sort_path(left).cmp(diff_entry_sort_path(right)));
1082    }
1083    Ok(changes)
1084}
1085
1086fn detect_exact_renames_from_changes(
1087    changes: Vec<NameStatusEntry>,
1088    rename_empty: bool,
1089) -> Vec<NameStatusEntry> {
1090    let added = changes
1091        .iter()
1092        .enumerate()
1093        .filter(|(_, entry)| entry.status == NameStatus::Added)
1094        .map(|(idx, entry)| (idx, entry.path.clone()))
1095        .collect::<Vec<_>>();
1096    let mut sources = changes
1097        .iter()
1098        .enumerate()
1099        .filter(|(_, entry)| entry.status == NameStatus::Deleted)
1100        .filter_map(|(idx, entry)| {
1101            entry
1102                .old_oid
1103                .map(|oid| (idx, entry.path.clone(), oid, entry.old_mode))
1104        })
1105        .collect::<Vec<_>>();
1106    sources.sort_by(|left, right| left.1.cmp(&right.1).then_with(|| left.0.cmp(&right.0)));
1107
1108    let mut src_used = vec![false; sources.len()];
1109    let mut consumed_added = BTreeSet::new();
1110    let mut consumed_deleted = BTreeSet::new();
1111    let mut result = Vec::new();
1112
1113    for (idx, new_path) in &added {
1114        let Some(right_oid) = changes[*idx].new_oid else {
1115            continue;
1116        };
1117        if !rename_empty && is_empty_blob_oid(&right_oid) {
1118            continue;
1119        };
1120        let mut best: Option<usize> = None;
1121        let mut best_score = -1i32;
1122        for (source_idx, (_, src_path, src_oid, _)) in sources.iter().enumerate() {
1123            if src_used[source_idx] || *src_oid != right_oid {
1124                continue;
1125            }
1126            let score = 1 + i32::from(path_basename(src_path) == path_basename(new_path));
1127            if score > best_score {
1128                best = Some(source_idx);
1129                best_score = score;
1130                if score == 2 {
1131                    break;
1132                }
1133            }
1134        }
1135        if let Some(source_idx) = best {
1136            src_used[source_idx] = true;
1137            consumed_added.insert(*idx);
1138            let (old_idx, old_path, old_oid, old_mode) = &sources[source_idx];
1139            consumed_deleted.insert(*old_idx);
1140            if old_path.as_bytes() == new_path.as_bytes()
1141                && *old_mode == changes[*idx].new_mode
1142                && *old_oid == right_oid
1143            {
1144                continue;
1145            }
1146            result.push(NameStatusEntry {
1147                status: NameStatus::Renamed(100),
1148                path: new_path.clone(),
1149                old_path: Some(old_path.clone()),
1150                old_mode: *old_mode,
1151                new_mode: changes[*idx].new_mode,
1152                old_oid: Some(*old_oid),
1153                new_oid: Some(right_oid),
1154            });
1155        }
1156    }
1157
1158    for (index, entry) in changes.into_iter().enumerate() {
1159        if consumed_added.contains(&index) || consumed_deleted.contains(&index) {
1160            continue;
1161        }
1162        result.push(entry);
1163    }
1164    result.sort_by(|left, right| diff_entry_sort_path(left).cmp(diff_entry_sort_path(right)));
1165    result
1166}
1167
1168/// Index-vs-worktree name-status for **`git diff-files`** (plumbing), which
1169/// selects changed paths by the cached *stat* rather than by content.
1170///
1171/// This is the crucial difference from [`diff_name_status_index_worktree_with_options`]
1172/// (the engine behind porcelain `git diff`): porcelain `git diff` refreshes the
1173/// index first, so a stat-dirty-but-content-identical entry (a `touch`ed file, or
1174/// a freshly `rm --cached`-then-`reset --no-refresh` entry with a zeroed cached
1175/// stat) is re-stamped clean and suppressed. `git diff-files` does **not** refresh
1176/// — it reports every entry whose cached stat fails to prove it clean as `M`,
1177/// without re-hashing the content to "rescue" it (`builtin/diff.c` →
1178/// `run_diff_files` → `ie_match_stat`). The raw / name-only / name-status output
1179/// and the `--quiet`/`--exit-code` status therefore list such entries even when
1180/// the content is byte-identical; patch/stat output, which diffs actual content,
1181/// renders them as an empty hunk.
1182///
1183/// We layer that stat-based selection on top of the content-based diff: the
1184/// content diff already catches adds/deletes/genuine-content modifies (with
1185/// rename detection), and we then append a `Modified` entry for any stage-0 path
1186/// whose worktree file is present and whose cached stat is dirty per
1187/// [`IndexStatCache::index_entry_worktree_stat_dirty`] but which the content diff
1188/// did not already report. Content-identical stat-dirty entries cannot be rename
1189/// sources/targets (their content is unchanged), so they never interact with the
1190/// rename machinery — they are plain `M`.
1191pub fn diff_name_status_index_worktree_for_diff_files_with_options(
1192    worktree_root: impl AsRef<Path>,
1193    git_dir: impl AsRef<Path>,
1194    format: ObjectFormat,
1195    options: DiffNameStatusOptions,
1196) -> Result<Vec<NameStatusEntry>> {
1197    let worktree_root = worktree_root.as_ref();
1198    let git_dir = git_dir.as_ref();
1199    let changes =
1200        diff_name_status_index_worktree_with_options(worktree_root, git_dir, format, options)?;
1201    augment_with_stat_dirty_entries(worktree_root, git_dir, format, changes)
1202}
1203
1204/// Append a `Modified` entry for every stage-0 index path whose worktree file is
1205/// present and whose cached stat is dirty (`ce_match_stat` "changed") but which
1206/// `content_changes` did not already report. The result is re-sorted by path so
1207/// the merged set keeps git's diff-queue ordering. New-side oids on the added
1208/// entries are left `None` (rendered as zeros in raw output), matching git, which
1209/// reports the worktree blob oid only for entries it has hashed.
1210pub fn augment_with_stat_dirty_entries(
1211    worktree_root: &Path,
1212    git_dir: &Path,
1213    format: ObjectFormat,
1214    mut content_changes: Vec<NameStatusEntry>,
1215) -> Result<Vec<NameStatusEntry>> {
1216    let IndexSnapshot {
1217        entries: index,
1218        stat_cache,
1219        ..
1220    } = read_index_snapshot(git_dir, format)?;
1221    // Paths the content diff already accounts for (by new-side path, the position
1222    // git queues a pair at — a rename's destination, a modify/add/delete's path).
1223    let already_reported: BTreeSet<&[u8]> = content_changes
1224        .iter()
1225        .map(|entry| entry.path.as_bytes())
1226        .collect();
1227    let mut extras = Vec::new();
1228    for (git_path, tracked) in &index {
1229        if already_reported.contains(git_path.as_slice()) {
1230            continue;
1231        }
1232        let Some(cached) = stat_cache.entry_for_git_path(git_path) else {
1233            continue;
1234        };
1235        // Gitlinks (submodules) have their own dirtiness model and are not stat-
1236        // compared here; the content diff already handles changed gitlink oids.
1237        if sley_index::is_gitlink(tracked.mode) {
1238            continue;
1239        }
1240        let path = worktree_path_for_repo_path(worktree_root, git_path);
1241        let Ok(metadata) = fs::symlink_metadata(&path) else {
1242            // A missing worktree file is a deletion, which the content diff
1243            // already reports; nothing to add here.
1244            continue;
1245        };
1246        if !(metadata.is_file() || metadata.file_type().is_symlink()) {
1247            continue;
1248        }
1249        match stat_cache.index_entry_worktree_stat_verdict(cached, &metadata) {
1250            sley_index::StatVerdict::Clean => continue,
1251            sley_index::StatVerdict::Dirty => {}
1252            // A racily-clean entry must be resolved by content: git re-hashes it
1253            // (`ce_compare_data`) and only reports `M` when the worktree bytes
1254            // actually differ from the cached oid — so a `touch`ed-then-re-`add`ed
1255            // file (same-second mtime as the index) stays clean.
1256            sley_index::StatVerdict::RacyNeedsContentCheck => {
1257                if worktree_oid_matches_index(worktree_root, git_path, &metadata, tracked, format)?
1258                {
1259                    continue;
1260                }
1261            }
1262        }
1263        extras.push(NameStatusEntry {
1264            status: NameStatus::Modified,
1265            path: git_path.clone().into(),
1266            old_path: None,
1267            old_mode: Some(tracked.mode),
1268            new_mode: Some(tracked.mode),
1269            old_oid: Some(tracked.oid),
1270            new_oid: None,
1271        });
1272    }
1273    if !extras.is_empty() {
1274        content_changes.extend(extras);
1275        content_changes
1276            .sort_by(|left, right| diff_entry_sort_path(left).cmp(diff_entry_sort_path(right)));
1277    }
1278    Ok(content_changes)
1279}
1280
1281/// Hash the worktree file or symlink at `path` into the `(mode, oid)` blob entry
1282/// git would record for it. `metadata` must already describe a regular file or a
1283/// symlink — gitlink and directory classification is the caller's concern, since
1284/// those need the index/HEAD context this leaf does not have. This is the single
1285/// owner of the symlink-vs-regular body-source and mode split that `diff-files`
1286/// ([`worktree_oid_matches_index`]), the candidate-path collector
1287/// ([`worktree_entry_for_path`]), and the index↔worktree walk
1288/// ([`index_worktree_change_for_entry`]) all share; before consolidation each
1289/// open-coded it with a bare `0o120000`.
1290fn classify_worktree_entry(
1291    path: &Path,
1292    metadata: &fs::Metadata,
1293    format: ObjectFormat,
1294) -> Result<TrackedEntry> {
1295    let is_symlink = metadata.file_type().is_symlink();
1296    let body = if is_symlink {
1297        symlink_target_bytes(path)?
1298    } else {
1299        fs::read(path)?
1300    };
1301    let oid = EncodedObject::new(ObjectType::Blob, body).object_id(format)?;
1302    let mode = if is_symlink {
1303        sley_index::SYMLINK_MODE
1304    } else {
1305        file_mode(metadata)
1306    };
1307    Ok(TrackedEntry { mode, oid })
1308}
1309
1310/// Whether the worktree file at `git_path` hashes to the index entry's oid (mode
1311/// included). Used to resolve a racily-clean `diff-files` entry: git re-hashes the
1312/// content and only reports it changed when the bytes truly differ. Shares the
1313/// worktree-oid computation with [`worktree_entry_for_path`] via
1314/// [`classify_worktree_entry`].
1315fn worktree_oid_matches_index(
1316    worktree_root: &Path,
1317    git_path: &[u8],
1318    metadata: &fs::Metadata,
1319    index_entry: &TrackedEntry,
1320    format: ObjectFormat,
1321) -> Result<bool> {
1322    let path = worktree_path_for_repo_path(worktree_root, git_path);
1323    let entry = classify_worktree_entry(&path, metadata, format)?;
1324    Ok(entry.oid == index_entry.oid && entry.mode == index_entry.mode)
1325}
1326
1327pub fn diff_name_status_trees_with_options(
1328    db: &FileObjectDatabase,
1329    format: ObjectFormat,
1330    left_tree: &ObjectId,
1331    right_tree: &ObjectId,
1332    options: DiffNameStatusOptions,
1333) -> Result<Vec<NameStatusEntry>> {
1334    if options.detect_inexact {
1335        return Ok(
1336            diff_name_status_trees_with_options_and_diagnostics(
1337                db, format, left_tree, right_tree, options,
1338            )?
1339            .entries,
1340        );
1341    }
1342    if !options.detect_copies {
1343        let mut changes = changed_tree_name_status_entries(db, format, left_tree, right_tree)?;
1344        if options.detect_renames {
1345            changes = detect_exact_renames_from_changes(changes, options.rename_empty);
1346        }
1347        return Ok(changes);
1348    }
1349
1350    // `--find-copies-harder` may pair an *unchanged* left-side file as a copy
1351    // source, so it needs the complete left map; every other mode only consults
1352    // changed paths, so the pruned simultaneous walk (which skips identical
1353    // subtrees) suffices and produces byte-identical output.
1354    let needs_full_maps = options.detect_copies && options.find_copies_harder;
1355    let (left_entries, right_entries) = if needs_full_maps {
1356        collect_full_tree_pair(db, format, left_tree, right_tree)?
1357    } else {
1358        changed_tree_entries(db, format, left_tree, right_tree)?
1359    };
1360    diff_name_status_maps(
1361        &left_entries,
1362        &right_entries,
1363        left_entries.keys().chain(right_entries.keys()),
1364        options,
1365    )
1366}
1367
1368pub fn diff_name_status_empty_tree_with_options(
1369    db: &FileObjectDatabase,
1370    format: ObjectFormat,
1371    right_tree: &ObjectId,
1372    options: DiffNameStatusOptions,
1373) -> Result<Vec<NameStatusEntry>> {
1374    let left_entries = BTreeMap::new();
1375    let mut right_entries = BTreeMap::new();
1376    collect_tree_entries(db, format, right_tree, Vec::new(), &mut right_entries)?;
1377    if options.detect_inexact {
1378        return diff_name_status_maps_with_renames(
1379            &left_entries,
1380            &right_entries,
1381            right_entries.keys(),
1382            options,
1383            |oid| read_blob_bytes(db, oid),
1384        );
1385    }
1386    diff_name_status_maps(&left_entries, &right_entries, right_entries.keys(), options)
1387}
1388
1389pub fn diff_name_status_trees_with_options_and_diagnostics(
1390    db: &FileObjectDatabase,
1391    format: ObjectFormat,
1392    left_tree: &ObjectId,
1393    right_tree: &ObjectId,
1394    options: DiffNameStatusOptions,
1395) -> Result<NameStatusWithRenameDiagnostics> {
1396        if !options.detect_copies {
1397        let mut diagnostics = RenameLimitDiagnostics::default();
1398        let mut changes = changed_tree_name_status_entries(db, format, left_tree, right_tree)?;
1399        if options.detect_renames {
1400            changes = detect_exact_renames_from_changes(changes, options.rename_empty);
1401        }
1402        if options.detect_renames && options.detect_inexact {
1403            diagnostics.inexact_renames_skipped = inexact_rename_limit_exceeded(&changes, &options);
1404            changes = detect_inexact_renames(changes, &options, &|oid| read_blob_bytes(db, oid));
1405        }
1406        return Ok(NameStatusWithRenameDiagnostics {
1407            entries: changes,
1408            rename_limit: diagnostics,
1409        });
1410    }
1411
1412    // See `diff_name_status_trees_with_options`: only `--find-copies-harder`
1413    // needs unchanged left entries as copy sources; otherwise the pruned walk
1414    // (skipping identical subtrees) yields identical output far more cheaply.
1415    let needs_full_maps = options.detect_copies && options.find_copies_harder;
1416    let (left_entries, right_entries) = if needs_full_maps {
1417        collect_full_tree_pair(db, format, left_tree, right_tree)?
1418    } else {
1419        changed_tree_entries(db, format, left_tree, right_tree)?
1420    };
1421    diff_name_status_maps_with_renames_and_diagnostics(
1422        &left_entries,
1423        &right_entries,
1424        left_entries.keys().chain(right_entries.keys()),
1425        options,
1426        |oid| read_blob_bytes(db, oid),
1427    )
1428}
1429
1430/// Read a blob's raw bytes from the ODB, returning `None` if the object cannot
1431/// be read or is not a blob. Used as the similarity-scoring blob fetcher; a
1432/// missing object simply makes a candidate pair non-similar rather than failing
1433/// the whole diff.
1434pub(crate) fn read_blob_bytes(db: &FileObjectDatabase, oid: &ObjectId) -> Option<Arc<[u8]>> {
1435    match db.read_object(oid) {
1436        Ok(object) if object.object_type == ObjectType::Blob => {
1437            Some(Arc::from(object.body.clone().into_boxed_slice()))
1438        }
1439        _ => None,
1440    }
1441}
1442
1443/// Build the raw per-path add/delete/modify change list (before any rename or
1444/// copy detection) from the two entry maps and the candidate path set.
1445fn raw_name_status_changes_for_unique_paths<'a>(
1446    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1447    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1448    paths: impl Iterator<Item = &'a Vec<u8>>,
1449) -> Vec<NameStatusEntry> {
1450    let mut changes = Vec::new();
1451    for path in paths {
1452        let left = left_entries.get(path);
1453        let right = right_entries.get(path);
1454        let status = match (left, right) {
1455            (None, Some(_)) => Some(NameStatus::Added),
1456            (Some(_), None) => Some(NameStatus::Deleted),
1457            (Some(left), Some(right)) if left != right => {
1458                Some(modify_or_type_change(left.mode, right.mode))
1459            }
1460            _ => None,
1461        };
1462        if let Some(status) = status {
1463            changes.push(NameStatusEntry {
1464                status,
1465                path: path.clone().into(),
1466                old_path: None,
1467                old_mode: left.map(|entry| entry.mode),
1468                new_mode: right.map(|entry| entry.mode),
1469                old_oid: left.map(|entry| entry.oid),
1470                new_oid: right.map(|entry| entry.oid),
1471            });
1472        }
1473    }
1474    changes
1475}
1476
1477pub(crate) fn diff_name_status_maps<'a>(
1478    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1479    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1480    candidate_paths: impl Iterator<Item = &'a Vec<u8>>,
1481    options: DiffNameStatusOptions,
1482) -> Result<Vec<NameStatusEntry>> {
1483    let paths = candidate_path_set(candidate_paths);
1484    diff_name_status_maps_for_path_set(left_entries, right_entries, &paths, options)
1485}
1486
1487fn diff_name_status_maps_for_path_set(
1488    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1489    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1490    candidate_paths: &BTreeSet<Vec<u8>>,
1491    options: DiffNameStatusOptions,
1492) -> Result<Vec<NameStatusEntry>> {
1493    diff_name_status_maps_for_unique_paths(
1494        left_entries,
1495        right_entries,
1496        candidate_paths.iter(),
1497        options,
1498    )
1499}
1500
1501fn diff_name_status_maps_for_unique_paths<'a>(
1502    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1503    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1504    candidate_paths: impl Iterator<Item = &'a Vec<u8>>,
1505    options: DiffNameStatusOptions,
1506) -> Result<Vec<NameStatusEntry>> {
1507    let mut changes =
1508        raw_name_status_changes_for_unique_paths(left_entries, right_entries, candidate_paths);
1509    if options.detect_renames {
1510        changes = detect_exact_renames(changes, left_entries, right_entries, options.rename_empty);
1511    }
1512    if options.detect_copies {
1513        changes = detect_exact_copies(
1514            changes,
1515            left_entries,
1516            right_entries,
1517            options.find_copies_harder,
1518            options.rename_empty,
1519        );
1520    }
1521    Ok(changes)
1522}
1523
1524/// Like [`diff_name_status_maps`], but additionally runs inexact (similarity)
1525/// rename/copy detection when `options.detect_inexact` is set.
1526///
1527/// `fetch_blob` resolves an [`ObjectId`] to that blob's raw bytes; it is only
1528/// consulted for the candidate pairs considered during inexact detection, and
1529/// only when inexact detection is enabled. A pair whose blob bytes cannot be
1530/// fetched is simply skipped (treated as not similar), so a missing object never
1531/// fails the whole diff.
1532pub(crate) fn diff_name_status_maps_with_renames<'a>(
1533    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1534    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1535    candidate_paths: impl Iterator<Item = &'a Vec<u8>>,
1536    options: DiffNameStatusOptions,
1537    fetch_blob: impl Fn(&ObjectId) -> Option<Arc<[u8]>>,
1538) -> Result<Vec<NameStatusEntry>> {
1539    Ok(diff_name_status_maps_with_renames_and_diagnostics(
1540        left_entries,
1541        right_entries,
1542        candidate_paths,
1543        options,
1544        fetch_blob,
1545    )?
1546    .entries)
1547}
1548
1549fn diff_name_status_maps_with_renames_and_diagnostics<'a>(
1550    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1551    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1552    candidate_paths: impl Iterator<Item = &'a Vec<u8>>,
1553    options: DiffNameStatusOptions,
1554    fetch_blob: impl Fn(&ObjectId) -> Option<Arc<[u8]>>,
1555) -> Result<NameStatusWithRenameDiagnostics> {
1556    let paths = candidate_path_set(candidate_paths);
1557    diff_name_status_maps_with_renames_for_path_set_and_diagnostics(
1558        left_entries,
1559        right_entries,
1560        &paths,
1561        options,
1562        fetch_blob,
1563    )
1564}
1565
1566fn diff_name_status_maps_with_renames_for_path_set(
1567    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1568    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1569    candidate_paths: &BTreeSet<Vec<u8>>,
1570    options: DiffNameStatusOptions,
1571    fetch_blob: impl Fn(&ObjectId) -> Option<Arc<[u8]>>,
1572) -> Result<Vec<NameStatusEntry>> {
1573    Ok(
1574        diff_name_status_maps_with_renames_for_path_set_and_diagnostics(
1575            left_entries,
1576            right_entries,
1577            candidate_paths,
1578            options,
1579            fetch_blob,
1580        )?
1581        .entries,
1582    )
1583}
1584
1585fn diff_name_status_maps_with_renames_for_path_set_and_diagnostics(
1586    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1587    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1588    candidate_paths: &BTreeSet<Vec<u8>>,
1589    options: DiffNameStatusOptions,
1590    fetch_blob: impl Fn(&ObjectId) -> Option<Arc<[u8]>>,
1591) -> Result<NameStatusWithRenameDiagnostics> {
1592    diff_name_status_maps_with_renames_for_unique_paths_and_diagnostics(
1593        left_entries,
1594        right_entries,
1595        candidate_paths.iter(),
1596        options,
1597        fetch_blob,
1598    )
1599}
1600
1601fn diff_name_status_maps_with_renames_for_unique_paths_and_diagnostics<'a>(
1602    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1603    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1604    candidate_paths: impl Iterator<Item = &'a Vec<u8>>,
1605    options: DiffNameStatusOptions,
1606    fetch_blob: impl Fn(&ObjectId) -> Option<Arc<[u8]>>,
1607) -> Result<NameStatusWithRenameDiagnostics> {
1608        let mut diagnostics = RenameLimitDiagnostics::default();
1609    let mut changes =
1610        raw_name_status_changes_for_unique_paths(left_entries, right_entries, candidate_paths);
1611    if options.detect_renames {
1612        changes = detect_exact_renames(changes, left_entries, right_entries, options.rename_empty);
1613    }
1614    // Inexact rename detection runs after exact renames so exact matches keep
1615    // priority (and their score of 100). It only fires when rename detection is
1616    // enabled at all, mirroring git's `-M`.
1617    if options.detect_renames && options.detect_inexact {
1618        diagnostics.inexact_renames_skipped = inexact_rename_limit_exceeded(&changes, &options);
1619        changes = detect_inexact_renames(changes, &options, &fetch_blob);
1620    }
1621    if options.detect_copies {
1622        changes = detect_exact_copies(
1623            changes,
1624            left_entries,
1625            right_entries,
1626            options.find_copies_harder,
1627            options.rename_empty,
1628        );
1629    }
1630    if options.detect_copies && options.detect_inexact {
1631        match inexact_copy_limit_outcome(&changes, left_entries, &options) {
1632            InexactCopyLimitOutcome::Full => {}
1633            InexactCopyLimitOutcome::DegradedToChangedSources => {
1634                diagnostics.inexact_copies_degraded = true;
1635            }
1636            InexactCopyLimitOutcome::Skip => {
1637                diagnostics.inexact_copies_skipped = true;
1638            }
1639        }
1640        changes = detect_inexact_copies(changes, left_entries, &options, &fetch_blob);
1641    }
1642    Ok(NameStatusWithRenameDiagnostics {
1643        entries: changes,
1644        rename_limit: diagnostics,
1645    })
1646}
1647
1648fn changed_tree_name_status_entries(
1649    db: &FileObjectDatabase,
1650    format: ObjectFormat,
1651    left_tree: &ObjectId,
1652    right_tree: &ObjectId,
1653) -> Result<Vec<NameStatusEntry>> {
1654    let mut changes = Vec::new();
1655    if left_tree != right_tree {
1656        diff_tree_pair_entries(db, format, left_tree, right_tree, &[], &mut changes)?;
1657    }
1658    sort_name_status_entries_by_path(&mut changes);
1659    Ok(changes)
1660}
1661
1662fn diff_tree_pair_entries(
1663    db: &FileObjectDatabase,
1664    format: ObjectFormat,
1665    left_tree: &ObjectId,
1666    right_tree: &ObjectId,
1667    prefix: &[u8],
1668    changes: &mut Vec<NameStatusEntry>,
1669) -> Result<()> {
1670    let left_entries = read_tree_object(db, format, left_tree)?.entries;
1671    let right_entries = read_tree_object(db, format, right_tree)?.entries;
1672    let mut left_idx = 0usize;
1673    let mut right_idx = 0usize;
1674
1675    while left_idx < left_entries.len() || right_idx < right_entries.len() {
1676        match (left_entries.get(left_idx), right_entries.get(right_idx)) {
1677            (Some(left_entry), Some(right_entry)) => match left_entry.name.cmp(&right_entry.name) {
1678                std::cmp::Ordering::Equal => {
1679                    let left_name = left_entry.name.clone();
1680                    let right_name = right_entry.name.clone();
1681                    let left_start = left_idx;
1682                    let right_start = right_idx;
1683                    while left_idx < left_entries.len() && left_entries[left_idx].name == left_name
1684                    {
1685                        left_idx += 1;
1686                    }
1687                    while right_idx < right_entries.len()
1688                        && right_entries[right_idx].name == right_name
1689                    {
1690                        right_idx += 1;
1691                    }
1692
1693                    let left_group = &left_entries[left_start..left_idx];
1694                    let right_group = &right_entries[right_start..right_idx];
1695                    let paired = left_group.len().min(right_group.len());
1696                    for idx in 0..paired {
1697                        diff_tree_entry_pair_entries(
1698                            db,
1699                            format,
1700                            prefix,
1701                            Some(&left_group[idx]),
1702                            Some(&right_group[idx]),
1703                            changes,
1704                        )?;
1705                    }
1706                    for entry in &left_group[paired..] {
1707                        diff_tree_entry_pair_entries(
1708                            db,
1709                            format,
1710                            prefix,
1711                            Some(entry),
1712                            None,
1713                            changes,
1714                        )?;
1715                    }
1716                    for entry in &right_group[paired..] {
1717                        diff_tree_entry_pair_entries(
1718                            db,
1719                            format,
1720                            prefix,
1721                            None,
1722                            Some(entry),
1723                            changes,
1724                        )?;
1725                    }
1726                }
1727                std::cmp::Ordering::Less => {
1728                    let left_name = left_entry.name.clone();
1729                    while left_idx < left_entries.len() && left_entries[left_idx].name == left_name
1730                    {
1731                        diff_tree_entry_pair_entries(
1732                            db,
1733                            format,
1734                            prefix,
1735                            Some(&left_entries[left_idx]),
1736                            None,
1737                            changes,
1738                        )?;
1739                        left_idx += 1;
1740                    }
1741                }
1742                std::cmp::Ordering::Greater => {
1743                    let right_name = right_entry.name.clone();
1744                    while right_idx < right_entries.len()
1745                        && right_entries[right_idx].name == right_name
1746                    {
1747                        diff_tree_entry_pair_entries(
1748                            db,
1749                            format,
1750                            prefix,
1751                            None,
1752                            Some(&right_entries[right_idx]),
1753                            changes,
1754                        )?;
1755                        right_idx += 1;
1756                    }
1757                }
1758            },
1759            (Some(left_entry), None) => {
1760                let left_name = left_entry.name.clone();
1761                while left_idx < left_entries.len() && left_entries[left_idx].name == left_name {
1762                    diff_tree_entry_pair_entries(
1763                        db,
1764                        format,
1765                        prefix,
1766                        Some(&left_entries[left_idx]),
1767                        None,
1768                        changes,
1769                    )?;
1770                    left_idx += 1;
1771                }
1772            }
1773            (None, Some(right_entry)) => {
1774                let right_name = right_entry.name.clone();
1775                while right_idx < right_entries.len() && right_entries[right_idx].name == right_name
1776                {
1777                    diff_tree_entry_pair_entries(
1778                        db,
1779                        format,
1780                        prefix,
1781                        None,
1782                        Some(&right_entries[right_idx]),
1783                        changes,
1784                    )?;
1785                    right_idx += 1;
1786                }
1787            }
1788            (None, None) => break,
1789        }
1790    }
1791
1792    Ok(())
1793}
1794
1795fn diff_tree_entry_pair_entries(
1796    db: &FileObjectDatabase,
1797    format: ObjectFormat,
1798    prefix: &[u8],
1799    left_entry: Option<&TreeEntry>,
1800    right_entry: Option<&TreeEntry>,
1801    changes: &mut Vec<NameStatusEntry>,
1802) -> Result<()> {
1803    let left_is_tree = left_entry.is_some_and(|entry| entry.mode == TREE_ENTRY_MODE);
1804    let right_is_tree = right_entry.is_some_and(|entry| entry.mode == TREE_ENTRY_MODE);
1805
1806    if let (Some(left_entry), Some(right_entry)) = (left_entry, right_entry) {
1807        if left_is_tree && right_is_tree {
1808            if left_entry.oid == right_entry.oid {
1809                return Ok(());
1810            }
1811            let path = join_tree_path(prefix, left_entry.name.as_bytes());
1812            return diff_tree_pair_entries(
1813                db,
1814                format,
1815                &left_entry.oid,
1816                &right_entry.oid,
1817                &path,
1818                changes,
1819            );
1820        }
1821        if !left_is_tree && !right_is_tree {
1822            if left_entry.mode == right_entry.mode && left_entry.oid == right_entry.oid {
1823                return Ok(());
1824            }
1825            let path = join_tree_path(prefix, left_entry.name.as_bytes());
1826            changes.push(NameStatusEntry {
1827                status: modify_or_type_change(left_entry.mode, right_entry.mode),
1828                path: path.into(),
1829                old_path: None,
1830                old_mode: Some(left_entry.mode),
1831                new_mode: Some(right_entry.mode),
1832                old_oid: Some(left_entry.oid),
1833                new_oid: Some(right_entry.oid),
1834            });
1835            return Ok(());
1836        }
1837    }
1838
1839    if let Some(left_entry) = left_entry {
1840        let path = join_tree_path(prefix, left_entry.name.as_bytes());
1841        collect_tree_side_changes(
1842            db,
1843            format,
1844            path,
1845            left_entry.mode,
1846            left_entry.oid,
1847            NameStatus::Deleted,
1848            changes,
1849        )?;
1850    }
1851    if let Some(right_entry) = right_entry {
1852        let path = join_tree_path(prefix, right_entry.name.as_bytes());
1853        collect_tree_side_changes(
1854            db,
1855            format,
1856            path,
1857            right_entry.mode,
1858            right_entry.oid,
1859            NameStatus::Added,
1860            changes,
1861        )?;
1862    }
1863    Ok(())
1864}
1865
1866fn collect_tree_side_changes(
1867    db: &FileObjectDatabase,
1868    format: ObjectFormat,
1869    path: Vec<u8>,
1870    mode: u32,
1871    oid: ObjectId,
1872    status: NameStatus,
1873    changes: &mut Vec<NameStatusEntry>,
1874) -> Result<()> {
1875    if mode == TREE_ENTRY_MODE {
1876        for entry in read_tree_object(db, format, &oid)?.entries {
1877            let child_path = join_tree_path(&path, entry.name.as_bytes());
1878            collect_tree_side_changes(
1879                db, format, child_path, entry.mode, entry.oid, status, changes,
1880            )?;
1881        }
1882        return Ok(());
1883    }
1884
1885    changes.push(NameStatusEntry {
1886        status,
1887        path: path.into(),
1888        old_path: None,
1889        old_mode: (status == NameStatus::Deleted).then_some(mode),
1890        new_mode: (status == NameStatus::Added).then_some(mode),
1891        old_oid: (status == NameStatus::Deleted).then_some(oid),
1892        new_oid: (status == NameStatus::Added).then_some(oid),
1893    });
1894    Ok(())
1895}
1896
1897fn sort_name_status_entries_by_path(changes: &mut [NameStatusEntry]) {
1898    changes.sort_by(|left, right| diff_entry_sort_path(left).cmp(diff_entry_sort_path(right)));
1899}
1900
1901fn detect_exact_renames(
1902    changes: Vec<NameStatusEntry>,
1903    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1904    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1905    rename_empty: bool,
1906) -> Vec<NameStatusEntry> {
1907    let added = changes
1908        .iter()
1909        .enumerate()
1910        .filter(|(_, entry)| entry.status == NameStatus::Added)
1911        .map(|(idx, entry)| (idx, entry.path.clone()))
1912        .collect::<Vec<_>>();
1913    // Candidate sources in path order (git's `rename_src` ordering), so the
1914    // best-source search tie-breaks deterministically.
1915    let mut sources = changes
1916        .iter()
1917        .filter(|entry| entry.status == NameStatus::Deleted)
1918        .filter_map(|entry| {
1919            left_entries
1920                .get(entry.path.as_bytes())
1921                .map(|left| (entry.path.clone(), left.oid))
1922        })
1923        .collect::<Vec<_>>();
1924    sources.sort_by(|a, b| a.0.cmp(&b.0));
1925    let mut src_used = vec![false; sources.len()];
1926    let mut consumed = BTreeSet::new();
1927    let mut renamed_old_paths = BTreeSet::new();
1928    let mut result = Vec::new();
1929
1930    // git's `find_identical_files`: for each destination, among the still-unused
1931    // sources with the identical OID, prefer one that shares the destination's
1932    // basename (score 2 short-circuits; otherwise the first such source wins).
1933    // Iterating destinations (not sources) is what lets a same-basename source
1934    // win over an alphabetically-earlier different-basename one.
1935    for (idx, new_path) in &added {
1936        let Some(right) = right_entries.get(new_path.as_bytes()) else {
1937            continue;
1938        };
1939        if !rename_empty && is_empty_blob_oid(&right.oid) {
1940            continue;
1941        }
1942        let mut best: Option<usize> = None;
1943        let mut best_score = -1i32;
1944        for (si, (src_path, src_oid)) in sources.iter().enumerate() {
1945            if src_used[si] || *src_oid != right.oid {
1946                continue;
1947            }
1948            let score = 1 + i32::from(path_basename(src_path) == path_basename(new_path));
1949            if score > best_score {
1950                best = Some(si);
1951                best_score = score;
1952                if score == 2 {
1953                    break;
1954                }
1955            }
1956        }
1957        if let Some(si) = best {
1958            src_used[si] = true;
1959            consumed.insert(*idx);
1960            let old_path = sources[si].0.clone();
1961            let left = &left_entries[old_path.as_bytes()];
1962            renamed_old_paths.insert(old_path.clone());
1963            result.push(NameStatusEntry {
1964                status: NameStatus::Renamed(100),
1965                path: new_path.clone(),
1966                old_path: Some(old_path),
1967                old_mode: Some(left.mode),
1968                new_mode: Some(right.mode),
1969                old_oid: Some(left.oid),
1970                new_oid: Some(right.oid),
1971            });
1972        }
1973    }
1974
1975    for (idx, entry) in changes.into_iter().enumerate() {
1976        if entry.status == NameStatus::Added && consumed.contains(&idx) {
1977            continue;
1978        }
1979        if entry.status == NameStatus::Deleted && renamed_old_paths.contains(&entry.path) {
1980            continue;
1981        }
1982        result.push(entry);
1983    }
1984    result.sort_by(|left, right| diff_entry_sort_path(left).cmp(diff_entry_sort_path(right)));
1985    result
1986}
1987
1988fn detect_exact_copies(
1989    changes: Vec<NameStatusEntry>,
1990    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1991    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
1992    find_copies_harder: bool,
1993    rename_empty: bool,
1994) -> Vec<NameStatusEntry> {
1995    let changed_sources = changes
1996        .iter()
1997        .filter(|entry| matches!(entry.status, NameStatus::Deleted | NameStatus::Modified))
1998        .map(|entry| entry.path.clone())
1999        .collect::<BTreeSet<_>>();
2000    let source_paths = left_entries
2001        .keys()
2002        .filter(|path| find_copies_harder || changed_sources.contains(path.as_slice()))
2003        .cloned()
2004        .collect::<Vec<_>>();
2005
2006    let mut result = Vec::new();
2007    for entry in changes {
2008        if entry.status != NameStatus::Added {
2009            result.push(entry);
2010            continue;
2011        }
2012        let Some(right) = right_entries.get(entry.path.as_bytes()) else {
2013            result.push(entry);
2014            continue;
2015        };
2016        if let Some(old_path) = source_paths.iter().find(|old_path| {
2017            old_path.as_slice() != entry.path.as_bytes()
2018                && left_entries.get(*old_path).is_some_and(|left| {
2019                    left.oid == right.oid && (rename_empty || !is_empty_blob_oid(&left.oid))
2020                })
2021        }) {
2022            result.push(NameStatusEntry {
2023                status: NameStatus::Copied(100),
2024                path: entry.path,
2025                old_path: Some(old_path.clone().into()),
2026                old_mode: left_entries
2027                    .get(old_path.as_slice())
2028                    .map(|entry| entry.mode),
2029                new_mode: entry.new_mode,
2030                old_oid: left_entries.get(old_path.as_slice()).map(|entry| entry.oid),
2031                new_oid: entry.new_oid,
2032            });
2033        } else {
2034            result.push(entry);
2035        }
2036    }
2037    result.sort_by(|left, right| diff_entry_sort_path(left).cmp(diff_entry_sort_path(right)));
2038    result
2039}
2040
2041/// Old-side metadata of a rename source, snapshotted before the source delete
2042/// entry is consumed so it can be attached to the renamed destination.
2043#[derive(Debug, Clone)]
2044struct RenameSourceMeta {
2045    path: BString,
2046    mode: Option<u32>,
2047    oid: Option<ObjectId>,
2048}
2049
2050/// A scored candidate pairing of a deleted source with an added destination,
2051/// used to order inexact-rename assignment best-match-first.
2052struct ScoredPair {
2053    /// Index into the `deleted` candidate list.
2054    src: usize,
2055    /// Index into the `added` candidate list.
2056    dst: usize,
2057    /// Similarity percentage in `0..=100`.
2058    score: u8,
2059}
2060
2061fn rename_limit_exceeded(source_count: usize, dest_count: usize, rename_limit: usize) -> bool {
2062    rename_limit > 0
2063        && source_count
2064            .saturating_mul(dest_count)
2065            .gt(&rename_limit.saturating_mul(rename_limit))
2066}
2067
2068#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2069enum InexactCopyLimitOutcome {
2070    Full,
2071    DegradedToChangedSources,
2072    Skip,
2073}
2074
2075fn inexact_rename_candidates(
2076    changes: &[NameStatusEntry],
2077    options: &DiffNameStatusOptions,
2078) -> (usize, usize) {
2079    let mut deleted = 0usize;
2080    let mut added = 0usize;
2081    for entry in changes {
2082        match entry.status {
2083            NameStatus::Deleted => {
2084                if entry
2085                    .old_oid
2086                    .as_ref()
2087                    .is_some_and(|oid| options.rename_empty || !is_empty_blob_oid(oid))
2088                {
2089                    deleted += 1;
2090                }
2091            }
2092            NameStatus::Added => {
2093                if entry
2094                    .new_oid
2095                    .as_ref()
2096                    .is_some_and(|oid| options.rename_empty || !is_empty_blob_oid(oid))
2097                {
2098                    added += 1;
2099                }
2100            }
2101            _ => {}
2102        }
2103    }
2104    (deleted, added)
2105}
2106
2107fn inexact_rename_limit_exceeded(
2108    changes: &[NameStatusEntry],
2109    options: &DiffNameStatusOptions,
2110) -> bool {
2111    let (source_count, dest_count) = inexact_rename_candidates(changes, options);
2112    rename_limit_exceeded(source_count, dest_count, options.rename_limit)
2113}
2114
2115fn changed_copy_sources(changes: &[NameStatusEntry]) -> BTreeSet<BString> {
2116    changes
2117        .iter()
2118        .filter(|entry| matches!(entry.status, NameStatus::Deleted | NameStatus::Modified))
2119        .map(|entry| entry.path.clone())
2120        .collect()
2121}
2122
2123fn inexact_copy_source_count(
2124    changed_sources: &BTreeSet<BString>,
2125    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
2126    options: &DiffNameStatusOptions,
2127    find_copies_harder: bool,
2128) -> usize {
2129    left_entries
2130        .iter()
2131        .filter(|(path, _)| find_copies_harder || changed_sources.contains(path.as_slice()))
2132        .filter(|(_, tracked)| options.rename_empty || !is_empty_blob_oid(&tracked.oid))
2133        .count()
2134}
2135
2136fn inexact_copy_dest_count(changes: &[NameStatusEntry], options: &DiffNameStatusOptions) -> usize {
2137    changes
2138        .iter()
2139        .filter(|entry| entry.status == NameStatus::Added)
2140        .filter(|entry| {
2141            entry
2142                .new_oid
2143                .as_ref()
2144                .is_some_and(|oid| options.rename_empty || !is_empty_blob_oid(oid))
2145        })
2146        .count()
2147}
2148
2149fn inexact_copy_limit_outcome(
2150    changes: &[NameStatusEntry],
2151    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
2152    options: &DiffNameStatusOptions,
2153) -> InexactCopyLimitOutcome {
2154    let changed_sources = changed_copy_sources(changes);
2155    let dest_count = inexact_copy_dest_count(changes, options);
2156    let source_count = inexact_copy_source_count(
2157        &changed_sources,
2158        left_entries,
2159        options,
2160        options.find_copies_harder,
2161    );
2162    if !rename_limit_exceeded(source_count, dest_count, options.rename_limit) {
2163        return InexactCopyLimitOutcome::Full;
2164    }
2165    if options.find_copies_harder {
2166        let changed_source_count =
2167            inexact_copy_source_count(&changed_sources, left_entries, options, false);
2168        if !rename_limit_exceeded(changed_source_count, dest_count, options.rename_limit) {
2169            return InexactCopyLimitOutcome::DegradedToChangedSources;
2170        }
2171    }
2172    InexactCopyLimitOutcome::Skip
2173}
2174
2175/// Inexact rename detection: pair still-unmatched deleted files with still-
2176/// unmatched added files by content similarity, replacing the best matches
2177/// (similarity >= `rename_threshold`) with [`NameStatus::Renamed`].
2178///
2179/// Exact renames have already run, so the only `Deleted`/`Added` entries left
2180/// here are ones with no identical-OID partner. Assignment is greedy by
2181/// descending score (then by source/destination order for determinism), and
2182/// each source and destination is used at most once — matching git's
2183/// `diffcore-rename` behaviour. Empty blobs are never used as a rename source
2184/// when `rename_empty` is false, mirroring exact detection.
2185fn detect_inexact_renames(
2186    changes: Vec<NameStatusEntry>,
2187    options: &DiffNameStatusOptions,
2188    fetch_blob: &impl Fn(&ObjectId) -> Option<Arc<[u8]>>,
2189) -> Vec<NameStatusEntry> {
2190    let threshold = options.rename_threshold;
2191    // A threshold above 100 can never be met; nothing to do.
2192    if threshold > 100 {
2193        return changes;
2194    }
2195
2196    let mut deleted_indices = Vec::new();
2197    let mut added_indices = Vec::new();
2198    for (idx, entry) in changes.iter().enumerate() {
2199        match entry.status {
2200            NameStatus::Deleted => {
2201                let Some(oid) = entry.old_oid.as_ref() else {
2202                    continue;
2203                };
2204                if !options.rename_empty && is_empty_blob_oid(oid) {
2205                    continue;
2206                }
2207                deleted_indices.push(idx);
2208            }
2209            NameStatus::Added => {
2210                let Some(oid) = entry.new_oid.as_ref() else {
2211                    continue;
2212                };
2213                if !options.rename_empty && is_empty_blob_oid(oid) {
2214                    continue;
2215                }
2216                added_indices.push(idx);
2217            }
2218            _ => {}
2219        }
2220    }
2221
2222    if deleted_indices.is_empty() || added_indices.is_empty() {
2223        return changes;
2224    }
2225
2226    // git's `too_many_rename_candidates`: if the rename matrix would exceed a
2227    // `rename_limit` square, skip inexact detection wholesale (exact-OID renames
2228    // were already resolved upstream). A non-positive limit is unlimited.
2229    if rename_limit_exceeded(
2230        deleted_indices.len(),
2231        added_indices.len(),
2232        options.rename_limit,
2233    ) {
2234        return changes;
2235    }
2236
2237    // Fetch blob bytes only after the cap check, so a low rename limit avoids
2238    // both the quadratic scoring matrix and the broad source/destination reads.
2239    let mut deleted: Vec<(usize, Arc<[u8]>)> = Vec::new();
2240    for idx in deleted_indices {
2241        let Some(oid) = changes[idx].old_oid.as_ref() else {
2242            continue;
2243        };
2244        if let Some(bytes) = fetch_blob(oid) {
2245            deleted.push((idx, bytes));
2246        }
2247    }
2248    let mut added: Vec<(usize, Arc<[u8]>)> = Vec::new();
2249    for idx in added_indices {
2250        let Some(oid) = changes[idx].new_oid.as_ref() else {
2251            continue;
2252        };
2253        if let Some(bytes) = fetch_blob(oid) {
2254            added.push((idx, bytes));
2255        }
2256    }
2257    if deleted.is_empty() || added.is_empty() {
2258        return changes;
2259    }
2260
2261    let mut src_used = vec![false; deleted.len()];
2262    let mut dst_used = vec![false; added.len()];
2263    // destination changes-index -> (source changes-index, score).
2264    let mut rename_of: BTreeMap<usize, (usize, u8)> = BTreeMap::new();
2265
2266    // Basename pre-pass (git's `find_basename_matches`): before the global
2267    // matrix, pair unique-basename src/dst at the stricter basename score, so a
2268    // same-basename rename wins over a globally-more-similar different basename.
2269    // git only does this for pure rename detection (`!want_copies`); when copies
2270    // are also wanted it culls differently and skips the basename heuristic.
2271    if !options.detect_copies {
2272        let src_paths: Vec<&[u8]> = deleted
2273            .iter()
2274            .map(|(idx, _)| &changes[*idx].path[..])
2275            .collect();
2276        let dst_paths: Vec<&[u8]> = added
2277            .iter()
2278            .map(|(idx, _)| &changes[*idx].path[..])
2279            .collect();
2280        let basename_pairs = basename_rename_matches(
2281            &src_paths,
2282            &dst_paths,
2283            &src_used,
2284            &dst_used,
2285            threshold,
2286            |si, di| Some(blob_similarity(&deleted[si].1, &added[di].1)),
2287        );
2288        for (si, di, score) in basename_pairs {
2289            src_used[si] = true;
2290            dst_used[di] = true;
2291            rename_of.insert(added[di].0, (deleted[si].0, score));
2292        }
2293    }
2294
2295    // Score every remaining (delete, add) pair; keep only those meeting the
2296    // threshold.
2297    let mut pairs: Vec<ScoredPair> = Vec::new();
2298    for (si, (_, src_bytes)) in deleted.iter().enumerate() {
2299        if src_used[si] {
2300            continue;
2301        }
2302        for (di, (_, dst_bytes)) in added.iter().enumerate() {
2303            if dst_used[di] {
2304                continue;
2305            }
2306            let score = blob_similarity(src_bytes, dst_bytes);
2307            if score >= threshold {
2308                pairs.push(ScoredPair {
2309                    src: si,
2310                    dst: di,
2311                    score,
2312                });
2313            }
2314        }
2315    }
2316    // Best score first; ties broken by source then destination order so the
2317    // result is deterministic regardless of input ordering.
2318    pairs.sort_by(|a, b| {
2319        b.score
2320            .cmp(&a.score)
2321            .then_with(|| a.src.cmp(&b.src))
2322            .then_with(|| a.dst.cmp(&b.dst))
2323    });
2324
2325    for pair in pairs {
2326        if src_used[pair.src] || dst_used[pair.dst] {
2327            continue;
2328        }
2329        src_used[pair.src] = true;
2330        dst_used[pair.dst] = true;
2331        let src_change_idx = deleted[pair.src].0;
2332        let dst_change_idx = added[pair.dst].0;
2333        rename_of.insert(dst_change_idx, (src_change_idx, pair.score));
2334    }
2335
2336    if rename_of.is_empty() {
2337        return changes;
2338    }
2339
2340    // Snapshot the source (delete) entries' metadata before we consume them, so
2341    // each renamed destination can carry the correct old path/mode/oid.
2342    let consumed_sources: BTreeSet<usize> =
2343        rename_of.values().map(|(src_idx, _)| *src_idx).collect();
2344    let source_meta: BTreeMap<usize, RenameSourceMeta> = consumed_sources
2345        .iter()
2346        .map(|&src_idx| {
2347            let src = &changes[src_idx];
2348            (
2349                src_idx,
2350                RenameSourceMeta {
2351                    path: src.path.clone(),
2352                    mode: src.old_mode,
2353                    oid: src.old_oid,
2354                },
2355            )
2356        })
2357        .collect();
2358
2359    let mut result = Vec::with_capacity(changes.len());
2360    for (idx, entry) in changes.into_iter().enumerate() {
2361        if consumed_sources.contains(&idx) {
2362            // This delete became the source of a rename; drop it.
2363            continue;
2364        }
2365        if let Some((src_idx, score)) = rename_of.get(&idx) {
2366            // The destination becomes a rename from the matched source. Pull the
2367            // old-side metadata from the snapshot; the new-side metadata stays as
2368            // the destination's.
2369            let meta = source_meta
2370                .get(src_idx)
2371                .cloned()
2372                .unwrap_or(RenameSourceMeta {
2373                    path: BString::default(),
2374                    mode: None,
2375                    oid: None,
2376                });
2377            result.push(NameStatusEntry {
2378                status: NameStatus::Renamed(*score),
2379                path: entry.path,
2380                old_path: Some(meta.path),
2381                old_mode: meta.mode,
2382                new_mode: entry.new_mode,
2383                old_oid: meta.oid,
2384                new_oid: entry.new_oid,
2385            });
2386            continue;
2387        }
2388        result.push(entry);
2389    }
2390
2391    result.sort_by(|left, right| diff_entry_sort_path(left).cmp(diff_entry_sort_path(right)));
2392    result
2393}
2394
2395/// Inexact copy detection: for each still-`Added` file, find the most similar
2396/// candidate *source* on the left side (similarity >= `copy_threshold`) and, if
2397/// found, report it as a [`NameStatus::Copied`]. The source is not removed
2398/// (copies leave the original in place).
2399///
2400/// Candidate sources follow the same rule as exact copy detection: with
2401/// `find_copies_harder` every left-side path is eligible; otherwise only paths
2402/// that were themselves changed (deleted or modified) on this diff. Exact copies
2403/// have already run, so any remaining `Added` here had no identical-OID source.
2404fn detect_inexact_copies(
2405    changes: Vec<NameStatusEntry>,
2406    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
2407    options: &DiffNameStatusOptions,
2408    fetch_blob: &impl Fn(&ObjectId) -> Option<Arc<[u8]>>,
2409) -> Vec<NameStatusEntry> {
2410    let threshold = options.copy_threshold;
2411    if threshold > 100 {
2412        return changes;
2413    }
2414
2415    let changed_sources = changed_copy_sources(&changes);
2416    let limit_outcome = inexact_copy_limit_outcome(&changes, left_entries, options);
2417    if limit_outcome == InexactCopyLimitOutcome::Skip {
2418        return changes;
2419    }
2420    let find_copies_harder = options.find_copies_harder
2421        && limit_outcome != InexactCopyLimitOutcome::DegradedToChangedSources;
2422    let mut source_candidates: Vec<(Vec<u8>, &TrackedEntry)> = Vec::new();
2423    for (path, tracked) in left_entries {
2424        if !(find_copies_harder || changed_sources.contains(path.as_slice())) {
2425            continue;
2426        }
2427        if !options.rename_empty && is_empty_blob_oid(&tracked.oid) {
2428            continue;
2429        }
2430        source_candidates.push((path.clone(), tracked));
2431    }
2432    if source_candidates.is_empty() {
2433        return changes;
2434    }
2435
2436    let mut sources: Vec<(Vec<u8>, &TrackedEntry, Arc<[u8]>)> = Vec::new();
2437    for (path, tracked) in source_candidates {
2438        if let Some(bytes) = fetch_blob(&tracked.oid) {
2439            sources.push((path, tracked, bytes));
2440        }
2441    }
2442    if sources.is_empty() {
2443        return changes;
2444    }
2445
2446    let mut result = Vec::with_capacity(changes.len());
2447    for entry in changes {
2448        if entry.status != NameStatus::Added {
2449            result.push(entry);
2450            continue;
2451        }
2452        let Some(new_oid) = entry.new_oid.as_ref() else {
2453            result.push(entry);
2454            continue;
2455        };
2456        let Some(dst_bytes) = fetch_blob(new_oid) else {
2457            result.push(entry);
2458            continue;
2459        };
2460
2461        // Pick the best-scoring source path that meets the threshold. Ties are
2462        // broken by path order (BTreeMap iteration is sorted) so the choice is
2463        // deterministic.
2464        let mut best: Option<(usize, u8)> = None;
2465        for (i, (src_path, _, src_bytes)) in sources.iter().enumerate() {
2466            if src_path.as_slice() == entry.path.as_bytes() {
2467                continue;
2468            }
2469            let score = blob_similarity(src_bytes, &dst_bytes);
2470            if score < threshold {
2471                continue;
2472            }
2473            match best {
2474                Some((_, best_score)) if best_score >= score => {}
2475                _ => best = Some((i, score)),
2476            }
2477        }
2478
2479        if let Some((src_idx, score)) = best {
2480            let (src_path, src_tracked, _) = &sources[src_idx];
2481            result.push(NameStatusEntry {
2482                status: NameStatus::Copied(score),
2483                path: entry.path,
2484                old_path: Some(src_path.clone().into()),
2485                old_mode: Some(src_tracked.mode),
2486                new_mode: entry.new_mode,
2487                old_oid: Some(src_tracked.oid),
2488                new_oid: entry.new_oid,
2489            });
2490        } else {
2491            result.push(entry);
2492        }
2493    }
2494    result.sort_by(|left, right| diff_entry_sort_path(left).cmp(diff_entry_sort_path(right)));
2495    result
2496}
2497
2498fn is_empty_blob_oid(oid: &ObjectId) -> bool {
2499    object_id_for_bytes(oid.format(), "blob", b"").is_ok_and(|empty| empty == *oid)
2500}
2501
2502// ===========================================================================
2503// Content similarity (the engine for inexact `-M`/`-C` rename/copy detection).
2504//
2505// This mirrors upstream git's similarity estimate from `diffcore-delta.c`
2506// (the span-hash counting) and `diffcore-rename.c` (the score formula), so the
2507// `R<score>`/`C<score>` we emit match git's percentages.
2508//
2509// The metric, precisely:
2510//
2511//   1. Each blob is broken into *spans*. Starting at a byte, we accumulate a
2512//      rolling hash of the bytes and end the span at the first `\n` (inclusive)
2513//      or once the span reaches `MAX_SPAN_BYTES` (64) bytes, whichever comes
2514//      first. (The 64-byte cap keeps a file with no/few newlines — e.g. a
2515//      binary blob or one very long line — from collapsing into a single span,
2516//      so similarity still tracks shared substrings.) Each span yields a
2517//      `(hash, byte_count)` pair, where `byte_count` is the span's length in
2518//      bytes. This is the exact loop git uses in `hash_chars()`.
2519//
2520//   2. The two blobs' spans are reduced to multisets keyed by hash: for each
2521//      hash we keep the total number of bytes spanned by entries with that
2522//      hash, on each side. `common_bytes` is then the sum over all hashes of
2523//      `min(bytes_on_src, bytes_on_dst)` — the bytes that exist on both sides.
2524//      This is git's `src_copied`.
2525//
2526//   3. The score is `common_bytes / max(size_src, size_dst)`, scaled to a
2527//      percentage and rounded to the nearest integer:
2528//
2529//          score% = round(common_bytes * 100 / max(size_src, size_dst))
2530//
2531//      git computes an internal score `src_copied * MAX_SCORE / max_size` with
2532//      `MAX_SCORE == 60000` and reports `round(score * 100 / MAX_SCORE)`; that
2533//      is algebraically the same rounded percentage, which we compute directly
2534//      to avoid intermediate precision loss.
2535//
2536// Edge cases match git: two empty blobs are 100% similar (identical content);
2537// an empty blob vs a non-empty one is 0%. Equal byte buffers are always 100%.
2538
2539/// Maximum number of bytes in a single similarity span before it is force-cut.
2540///
2541/// git uses 64 (`hash_chars()` breaks a span once `++chunks >= 64`).
2542const MAX_SPAN_BYTES: usize = 64;
2543
2544/// Compute the content similarity of two blobs as an integer percentage in
2545/// `0..=100`, using git's span-hash counting metric (see the module comment
2546/// above for the exact definition).
2547///
2548/// The result is symmetric (`blob_similarity(a, b) == blob_similarity(b, a)`)
2549/// because the score divides the common-byte count by the larger of the two
2550/// sizes. Byte-identical blobs return `100`; a non-empty blob compared against
2551/// an empty one returns `0`; two empty blobs return `100`.
2552///
2553/// This is the same number git prints as `similarity index N%` and uses to
2554/// decide `-M`/`-C` rename and copy detection.
2555pub fn blob_similarity(a: &[u8], b: &[u8]) -> u8 {
2556    // Fast paths that also pin down the empty-blob conventions.
2557    if a == b {
2558        return 100;
2559    }
2560    let max_size = a.len().max(b.len());
2561    if max_size == 0 {
2562        // Both empty (and not caught by `a == b` only if both are empty, which
2563        // they are here) -> identical.
2564        return 100;
2565    }
2566
2567    let src = span_hash_counts(a, blob_is_text(a));
2568    let dst = span_hash_counts(b, blob_is_text(b));
2569    let common = common_span_bytes(&src, &dst);
2570
2571    // Match git's diffcore-rename integer math exactly. git computes an internal
2572    // score `src_copied * MAX_SCORE / max_size` (MAX_SCORE == 60000) with integer
2573    // truncation, then reports the similarity index as `score * 100 / MAX_SCORE`,
2574    // truncated again. This two-step truncation -- *not* a single rounded
2575    // `common * 100 / max_size` -- is what yields git's exact percentages: e.g.
2576    // common=4, max_size=6 gives 4*60000/6=40000 then 40000*100/60000=66 (git's
2577    // `R066`), whereas a rounded single step would give 67.
2578    const MAX_SCORE: u64 = 60000;
2579    let internal = (common as u64 * MAX_SCORE) / max_size as u64;
2580    let score = internal * 100 / MAX_SCORE;
2581    score.min(100) as u8
2582}
2583
2584/// The basename of a slash-separated path: the portion after the last `/`
2585/// (git's `get_basename`).
2586pub fn path_basename(path: &[u8]) -> &[u8] {
2587    match path.iter().rposition(|&byte| byte == b'/') {
2588        Some(slash) => &path[slash + 1..],
2589        None => path,
2590    }
2591}
2592
2593/// The stricter score a basename match must reach: git's `min_basename_score`
2594/// with the default `GIT_BASENAME_FACTOR` of 0.5, i.e. halfway between the
2595/// rename threshold and 100%. (For the default 50% threshold this is 75%.)
2596pub fn basename_min_score(threshold: u8) -> u8 {
2597    let threshold = threshold.min(100);
2598    threshold + (100 - threshold) / 2
2599}
2600
2601/// git's `find_basename_matches`: among the still-unmatched rename sources and
2602/// destinations, pair those whose basename is UNIQUE on *both* sides and whose
2603/// similarity meets [`basename_min_score`]. Returns the `(src_local, dst_local,
2604/// score)` pairings to apply *before* the full O(n·m) similarity matrix, so a
2605/// same-basename rename wins over a globally-more-similar different-basename
2606/// candidate (diffcore-rename.c).
2607///
2608/// `src_paths`/`dst_paths` are the candidate paths, indexed in parallel with the
2609/// `src_used`/`dst_used` flags (entries already consumed by exact-OID matching).
2610/// `similarity(src_local, dst_local)` returns the blob similarity for a pair, or
2611/// `None` when a blob is unreadable / ineligible. Only unique basenames are
2612/// considered: git's plain-diff path has no directory-rename fallback, so an
2613/// ambiguous basename is skipped entirely.
2614pub fn basename_rename_matches(
2615    src_paths: &[&[u8]],
2616    dst_paths: &[&[u8]],
2617    src_used: &[bool],
2618    dst_used: &[bool],
2619    threshold: u8,
2620    mut similarity: impl FnMut(usize, usize) -> Option<u8>,
2621) -> Vec<(usize, usize, u8)> {
2622    let min_score = basename_min_score(threshold);
2623    // basename -> Some(unique local index), or None once a second candidate with
2624    // the same basename appears (ambiguous).
2625    let mut src_by_base: HashMap<&[u8], Option<usize>> = HashMap::new();
2626    for (si, path) in src_paths.iter().enumerate() {
2627        if src_used.get(si).copied().unwrap_or(false) {
2628            continue;
2629        }
2630        src_by_base
2631            .entry(path_basename(path))
2632            .and_modify(|slot| *slot = None)
2633            .or_insert(Some(si));
2634    }
2635    let mut dst_by_base: HashMap<&[u8], Option<usize>> = HashMap::new();
2636    for (di, path) in dst_paths.iter().enumerate() {
2637        if dst_used.get(di).copied().unwrap_or(false) {
2638            continue;
2639        }
2640        dst_by_base
2641            .entry(path_basename(path))
2642            .and_modify(|slot| *slot = None)
2643            .or_insert(Some(di));
2644    }
2645    let mut matches = Vec::new();
2646    let mut dst_taken = vec![false; dst_paths.len()];
2647    for (si, path) in src_paths.iter().enumerate() {
2648        if src_used.get(si).copied().unwrap_or(false) {
2649            continue;
2650        }
2651        let base = path_basename(path);
2652        // Both basenames must be unique among the remaining candidates.
2653        let Some(Some(src_idx)) = src_by_base.get(base).copied() else {
2654            continue;
2655        };
2656        if src_idx != si {
2657            continue;
2658        }
2659        let Some(Some(dst_idx)) = dst_by_base.get(base).copied() else {
2660            continue;
2661        };
2662        if dst_used.get(dst_idx).copied().unwrap_or(false) || dst_taken[dst_idx] {
2663            continue;
2664        }
2665        let Some(score) = similarity(si, dst_idx) else {
2666            continue;
2667        };
2668        if score < min_score {
2669            continue;
2670        }
2671        dst_taken[dst_idx] = true;
2672        matches.push((si, dst_idx, score));
2673    }
2674    matches
2675}
2676
2677/// Break `data` into spans and return, per span hash, the total number of bytes
2678/// covered by spans with that hash. Spans end at a newline (inclusive) or once
2679/// they reach [`MAX_SPAN_BYTES`] bytes — exactly git's `hash_chars()` loop.
2680///
2681/// The returned map is `hash -> total_span_bytes`. Summing all values yields
2682/// `data.len()`, so the byte accounting is exact.
2683fn span_hash_counts(data: &[u8], is_text: bool) -> BTreeMap<u64, usize> {
2684    let mut counts: BTreeMap<u64, usize> = BTreeMap::new();
2685    let mut idx = 0usize;
2686    let len = data.len();
2687    while idx < len {
2688        // Roll a hash over the bytes of this span. The mixing mirrors git's
2689        // two-accumulator scheme from `diffcore-delta.c`; the exact constants do
2690        // not matter for correctness (any good per-span hash works), only that
2691        // identical spans collide and distinct spans rarely do.
2692        let mut accum1: u32 = 0;
2693        let mut accum2: u32 = 0;
2694        let mut span_len = 0usize;
2695        loop {
2696            let c = data[idx] as u32;
2697            idx += 1;
2698            // Ignore CR in a CRLF sequence for text blobs, so a file that only
2699            // differs by LF<->CRLF is still scored as (near-)identical — git's
2700            // `hash_chars()` does the same, which is what makes a CRLF-only
2701            // rename detectable.
2702            if is_text && c == u32::from(b'\r') && idx < len && data[idx] == b'\n' {
2703                continue;
2704            }
2705            span_len += 1;
2706            accum1 = (accum1 << 7) ^ (accum2 >> 25);
2707            accum2 = (accum2 << 7) ^ (accum1 >> 25);
2708            accum1 = accum1.wrapping_add(c);
2709            let newline = c == u32::from(b'\n');
2710            if span_len >= MAX_SPAN_BYTES || newline || idx >= len {
2711                break;
2712            }
2713        }
2714        // Fold the two accumulators (and the span length) into one 64-bit key.
2715        // Including the length keeps spans of different lengths from colliding
2716        // when their rolling-hash states happen to coincide.
2717        let hash = ((accum1 as u64) << 32) ^ (accum2 as u64) ^ ((span_len as u64) << 1);
2718        *counts.entry(hash).or_insert(0) += span_len;
2719    }
2720    counts
2721}
2722
2723/// Sum, over every hash present in both maps, the smaller of the two byte
2724/// counts. This is git's `src_copied`: the number of bytes that appear on both
2725/// sides (counting multiplicity via the per-hash byte totals).
2726/// git `diffcore_count_changes()`: span-hash byte accounting between two
2727/// blobs. Returns `(src_copied, literal_added)` — the bytes of `src` that
2728/// survive into `dst`, and the bytes of `dst` not accounted for by `src`.
2729/// `--dirstat`'s default "changes" damage is
2730/// `(src.len() - src_copied) + literal_added`.
2731pub fn count_changes(src: &[u8], dst: &[u8]) -> (usize, usize) {
2732    let src_counts = span_hash_counts(src, blob_is_text(src));
2733    let dst_counts = span_hash_counts(dst, blob_is_text(dst));
2734    let copied = common_span_bytes(&src_counts, &dst_counts);
2735    (copied, dst.len() - copied)
2736}
2737
2738/// Whether a blob is treated as text for span hashing (git's
2739/// `diff_filespec_is_binary` / `buffer_is_binary`): a NUL byte within the first
2740/// 8000 bytes marks it binary, in which case CRs are hashed literally.
2741fn blob_is_text(data: &[u8]) -> bool {
2742    const FIRST_FEW_BYTES: usize = 8000;
2743    !data.iter().take(FIRST_FEW_BYTES).any(|&byte| byte == 0)
2744}
2745
2746fn common_span_bytes(src: &BTreeMap<u64, usize>, dst: &BTreeMap<u64, usize>) -> usize {
2747    let mut common = 0usize;
2748    // Iterate the smaller map for a few less lookups.
2749    let (small, large) = if src.len() <= dst.len() {
2750        (src, dst)
2751    } else {
2752        (dst, src)
2753    };
2754    for (hash, small_bytes) in small {
2755        if let Some(large_bytes) = large.get(hash) {
2756            common += (*small_bytes).min(*large_bytes);
2757        }
2758    }
2759    common
2760}
2761
2762fn diff_entry_sort_path(entry: &NameStatusEntry) -> &[u8] {
2763    // git's diffcore re-inserts rename/copy pairs at their *destination*'s
2764    // position, so the queue (raw, numstat, stat, ...) sorts by the new path.
2765    entry.path.as_bytes()
2766}
2767
2768fn mark_unstaged_worktree_oids_unresolved(
2769    changes: Vec<NameStatusEntry>,
2770    index_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
2771    worktree_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
2772) -> Vec<NameStatusEntry> {
2773    changes
2774        .into_iter()
2775        .map(|mut entry| {
2776            let worktree_entry = worktree_entries.get(entry.path.as_bytes());
2777            if worktree_entry != index_entries.get(entry.path.as_bytes()) {
2778                entry.new_oid = None;
2779            }
2780            entry
2781        })
2782        .collect()
2783}
2784
2785#[derive(Debug, Clone, PartialEq, Eq)]
2786pub(crate) struct TrackedEntry {
2787    pub(crate) mode: u32,
2788    pub(crate) oid: ObjectId,
2789}
2790
2791/// A path-keyed map of tracked entries: one flattened side of a tree (or index/
2792/// worktree) snapshot.
2793type TrackedEntryMap = BTreeMap<Vec<u8>, TrackedEntry>;
2794
2795/// The `(left, right)` sides produced by a tree-vs-tree comparison.
2796type TrackedEntryPair = (TrackedEntryMap, TrackedEntryMap);
2797
2798struct IndexSnapshot {
2799    entries: BTreeMap<Vec<u8>, TrackedEntry>,
2800    stat_cache: IndexStatCache,
2801    missing_skip_worktree_entries: BTreeMap<Vec<u8>, TrackedEntry>,
2802    present_skip_worktree_entries: BTreeMap<Vec<u8>, TrackedEntry>,
2803}
2804
2805fn read_index_entries(
2806    git_dir: &Path,
2807    format: ObjectFormat,
2808) -> Result<BTreeMap<Vec<u8>, TrackedEntry>> {
2809    let index_path = sley_index::repository_index_path(git_dir);
2810    if !index_path.exists() {
2811        return Ok(BTreeMap::new());
2812    }
2813    let index = expand_sparse_index_for_worktree_diff(
2814        sley_index::read_repository_index(git_dir, format)?,
2815        git_dir,
2816        format,
2817    )?;
2818    Ok(index
2819        .entries
2820        .into_iter()
2821        .filter(|entry| entry.stage() == sley_index::Stage::Normal && !entry.is_intent_to_add())
2822        .map(|entry| {
2823            (
2824                entry.path.into_bytes(),
2825                TrackedEntry {
2826                    mode: entry.mode,
2827                    oid: entry.oid,
2828                },
2829            )
2830        })
2831        .collect())
2832}
2833
2834/// Collect the set of stage-0 paths flagged intent-to-add (`git add -N`) in the
2835/// index. These diff as new files rather than as modifications of their recorded
2836/// empty-blob id.
2837fn read_intent_to_add_paths(
2838    git_dir: &Path,
2839    format: ObjectFormat,
2840) -> Result<std::collections::HashSet<Vec<u8>>> {
2841    let index_path = sley_index::repository_index_path(git_dir);
2842    if !index_path.exists() {
2843        return Ok(std::collections::HashSet::new());
2844    }
2845    let index = expand_sparse_index_for_worktree_diff(
2846        sley_index::read_repository_index(git_dir, format)?,
2847        git_dir,
2848        format,
2849    )?;
2850    Ok(index
2851        .entries
2852        .iter()
2853        .filter(|entry| entry.stage() == sley_index::Stage::Normal && entry.is_intent_to_add())
2854        .map(|entry| entry.path.as_bytes().to_vec())
2855        .collect())
2856}
2857
2858fn read_index_snapshot(git_dir: &Path, format: ObjectFormat) -> Result<IndexSnapshot> {
2859    let index_path = sley_index::repository_index_path(git_dir);
2860    let index_metadata = match fs::metadata(&index_path) {
2861        Ok(metadata) => metadata,
2862        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
2863            return Ok(IndexSnapshot {
2864                entries: BTreeMap::new(),
2865                stat_cache: IndexStatCache::default(),
2866                missing_skip_worktree_entries: BTreeMap::new(),
2867                present_skip_worktree_entries: BTreeMap::new(),
2868            });
2869        }
2870        Err(err) => return Err(err.into()),
2871    };
2872    let raw_index = sley_index::read_repository_index(git_dir, format)?;
2873    let sparse_checkout_active = raw_index.is_sparse()
2874        || raw_index
2875            .entries
2876            .iter()
2877            .any(|entry| entry.mode == sley_index::SPARSE_DIR_MODE && entry.is_skip_worktree())
2878        || sparse_checkout_config_enabled(git_dir);
2879    let index = expand_sparse_index_for_worktree_diff(raw_index, git_dir, format)?;
2880    let stat_cache =
2881        IndexStatCache::from_index_mtime(&index, sley_index::file_mtime_parts(&index_metadata));
2882    let mut entries = BTreeMap::new();
2883    let mut missing_skip_worktree_entries = BTreeMap::new();
2884    let mut present_skip_worktree_entries = BTreeMap::new();
2885    for entry in index.entries {
2886        let is_skip_worktree = entry.stage() == sley_index::Stage::Normal
2887            && entry.is_skip_worktree()
2888            && !entry.is_intent_to_add();
2889        let path = entry.path.into_bytes();
2890        let tracked = TrackedEntry {
2891            mode: entry.mode,
2892            oid: entry.oid,
2893        };
2894        if is_skip_worktree {
2895            missing_skip_worktree_entries.insert(path.clone(), tracked.clone());
2896            if !sparse_checkout_active {
2897                present_skip_worktree_entries.insert(path.clone(), tracked.clone());
2898            }
2899        }
2900        entries.insert(path, tracked);
2901    }
2902    Ok(IndexSnapshot {
2903        entries,
2904        stat_cache,
2905        missing_skip_worktree_entries,
2906        present_skip_worktree_entries,
2907    })
2908}
2909
2910fn sparse_checkout_config_enabled(git_dir: &Path) -> bool {
2911    sley_config::GitConfig::read(git_dir.join("config"))
2912        .ok()
2913        .and_then(|config| config.get_bool("core", None, "sparseCheckout"))
2914        == Some(true)
2915        || sley_config::GitConfig::read(git_dir.join("config.worktree"))
2916            .ok()
2917            .and_then(|config| config.get_bool("core", None, "sparseCheckout"))
2918            == Some(true)
2919}
2920
2921trait WorktreeIndexEntry {
2922    fn git_path(&self) -> &[u8];
2923    fn stage(&self) -> sley_index::Stage;
2924    fn mode(&self) -> u32;
2925    fn oid(&self) -> ObjectId;
2926    fn size(&self) -> u32;
2927    fn is_intent_to_add(&self) -> bool;
2928    fn is_skip_worktree(&self) -> bool;
2929    fn reusable_with(&self, stat_cache: &IndexStatCache, metadata: &fs::Metadata) -> bool;
2930}
2931
2932impl WorktreeIndexEntry for sley_index::IndexEntry {
2933    fn git_path(&self) -> &[u8] {
2934        self.path.as_bytes()
2935    }
2936
2937    fn stage(&self) -> sley_index::Stage {
2938        sley_index::IndexEntry::stage(self)
2939    }
2940
2941    fn mode(&self) -> u32 {
2942        self.mode
2943    }
2944
2945    fn oid(&self) -> ObjectId {
2946        self.oid
2947    }
2948
2949    fn size(&self) -> u32 {
2950        self.size
2951    }
2952
2953    fn is_intent_to_add(&self) -> bool {
2954        sley_index::IndexEntry::is_intent_to_add(self)
2955    }
2956
2957    fn is_skip_worktree(&self) -> bool {
2958        sley_index::IndexEntry::is_skip_worktree(self)
2959    }
2960
2961    fn reusable_with(&self, stat_cache: &IndexStatCache, metadata: &fs::Metadata) -> bool {
2962        stat_cache.reusable_index_entry(self, metadata).is_some()
2963    }
2964}
2965
2966impl WorktreeIndexEntry for sley_index::IndexEntryRef<'_> {
2967    fn git_path(&self) -> &[u8] {
2968        self.path
2969    }
2970
2971    fn stage(&self) -> sley_index::Stage {
2972        sley_index::IndexEntryRef::stage(self)
2973    }
2974
2975    fn mode(&self) -> u32 {
2976        self.mode
2977    }
2978
2979    fn oid(&self) -> ObjectId {
2980        self.oid
2981    }
2982
2983    fn size(&self) -> u32 {
2984        self.size
2985    }
2986
2987    fn is_intent_to_add(&self) -> bool {
2988        sley_index::IndexEntryRef::is_intent_to_add(self)
2989    }
2990
2991    fn is_skip_worktree(&self) -> bool {
2992        sley_index::IndexEntryRef::is_skip_worktree(self)
2993    }
2994
2995    fn reusable_with(&self, stat_cache: &IndexStatCache, metadata: &fs::Metadata) -> bool {
2996        stat_cache.reusable_index_entry_ref(self, metadata)
2997    }
2998}
2999
3000fn tracked_entry_from_index(entry: &impl WorktreeIndexEntry) -> TrackedEntry {
3001    TrackedEntry {
3002        mode: entry.mode(),
3003        oid: entry.oid(),
3004    }
3005}
3006
3007fn head_tree_entries(
3008    git_dir: &Path,
3009    format: ObjectFormat,
3010    db: &FileObjectDatabase,
3011) -> Result<BTreeMap<Vec<u8>, TrackedEntry>> {
3012    let refs = FileRefStore::new(git_dir, format);
3013    let Some(head) = refs.read_ref("HEAD")? else {
3014        return Ok(BTreeMap::new());
3015    };
3016    let commit_oid = match head {
3017        RefTarget::Direct(oid) => Some(oid),
3018        RefTarget::Symbolic(name) => match refs.read_ref(&name)? {
3019            Some(RefTarget::Direct(oid)) => Some(oid),
3020            _ => None,
3021        },
3022    };
3023    let Some(commit_oid) = commit_oid else {
3024        return Ok(BTreeMap::new());
3025    };
3026    let object = db.read_object(&commit_oid)?;
3027    if object.object_type != ObjectType::Commit {
3028        return Err(GitError::InvalidObject(format!(
3029            "HEAD {commit_oid} is not a commit"
3030        )));
3031    }
3032    let commit = Commit::parse_ref(format, &object.body)?;
3033    let mut entries = BTreeMap::new();
3034    collect_tree_entries(db, format, &commit.tree, Vec::new(), &mut entries)?;
3035    Ok(entries)
3036}
3037
3038/// Flatten `tree_oid` into `entries` (keyed by `prefix`-rooted full paths),
3039/// adapting the canonical [`flatten_tree`] tuples into [`TrackedEntry`].
3040///
3041/// `flatten_tree` flattens from an empty prefix; each of its paths is rejoined
3042/// under `prefix` with [`join_tree_path`], reproducing the recursive
3043/// prefix-building this helper previously did inline. Used by the full
3044/// (non-pruned) flatten paths: `--find-copies-harder` and the changed-subtree
3045/// add/delete sides of the simultaneous diff walk.
3046fn collect_tree_entries(
3047    db: &FileObjectDatabase,
3048    format: ObjectFormat,
3049    tree_oid: &ObjectId,
3050    prefix: Vec<u8>,
3051    entries: &mut BTreeMap<Vec<u8>, TrackedEntry>,
3052) -> Result<()> {
3053    for (rel_path, (mode, oid)) in flatten_tree(db, format, tree_oid)? {
3054        let path = join_tree_path(&prefix, &rel_path);
3055        entries.insert(path, TrackedEntry { mode, oid });
3056    }
3057    Ok(())
3058}
3059
3060/// Git's mode value for a subtree (directory) entry inside a tree object.
3061pub(crate) const TREE_ENTRY_MODE: u32 = 0o040000;
3062
3063/// Read `tree_oid` and parse it as a tree, erroring if the object is some other
3064/// type. Shared by the simultaneous tree-diff walk so both sides validate the
3065/// object type identically to [`collect_tree_entries`].
3066fn read_tree_object(
3067    db: &FileObjectDatabase,
3068    format: ObjectFormat,
3069    tree_oid: &ObjectId,
3070) -> Result<Tree> {
3071    let object = db.read_object(tree_oid)?;
3072    if object.object_type != ObjectType::Tree {
3073        return Err(GitError::InvalidObject(format!(
3074            "expected tree {tree_oid}, found {}",
3075            object.object_type.as_str()
3076        )));
3077    }
3078    Tree::parse(format, &object.body)
3079}
3080
3081/// Append `name` to `prefix` with a `/` separator (mirroring the path
3082/// construction in [`collect_tree_entries`]), returning the joined path.
3083fn join_tree_path(prefix: &[u8], name: &[u8]) -> Vec<u8> {
3084    let mut path = Vec::with_capacity(prefix.len() + 1 + name.len());
3085    path.extend_from_slice(prefix);
3086    if !path.is_empty() {
3087        path.push(b'/');
3088    }
3089    path.extend_from_slice(name);
3090    path
3091}
3092
3093/// Fully flatten both trees into independent `left`/`right` maps (every blob on
3094/// each side, no pruning). Used only on the `--find-copies-harder` path, where
3095/// copy detection may reach into otherwise-unchanged subtrees for a source.
3096pub(crate) fn collect_full_tree_pair(
3097    db: &FileObjectDatabase,
3098    format: ObjectFormat,
3099    left_tree: &ObjectId,
3100    right_tree: &ObjectId,
3101) -> Result<TrackedEntryPair> {
3102    let mut left = BTreeMap::new();
3103    collect_tree_entries(db, format, left_tree, Vec::new(), &mut left)?;
3104    let mut right = BTreeMap::new();
3105    collect_tree_entries(db, format, right_tree, Vec::new(), &mut right)?;
3106    Ok((left, right))
3107}
3108
3109/// Walk two trees *simultaneously*, collecting into `left` and `right` only the
3110/// blob entries that differ between the two sides — every entry that is present
3111/// and byte-identical (same mode + same OID) on both sides is omitted, and any
3112/// subtree whose OID is identical on both sides is skipped wholesale without
3113/// being read or recursed into. This is the core optimization git relies on to
3114/// make tree diffs cheap: equal subtrees are pruned in O(1).
3115///
3116/// The resulting `left`/`right` maps are exactly the subset of the fully
3117/// flattened maps (as produced by [`collect_tree_entries`]) restricted to the
3118/// paths that participate in an Added/Deleted/Modified change. Because
3119/// [`raw_name_status_changes`] emits nothing for a path that is identical on both
3120/// sides, diffing these pruned maps yields byte-identical name-status output to
3121/// diffing the full maps. (Callers that need the *complete* left map — i.e.
3122/// `--find-copies-harder`, where an unchanged file may be a copy source — must
3123/// still use [`collect_tree_entries`]; see the tree-diff entry points.)
3124pub(crate) fn changed_tree_entries(
3125    db: &FileObjectDatabase,
3126    format: ObjectFormat,
3127    left_tree: &ObjectId,
3128    right_tree: &ObjectId,
3129) -> Result<TrackedEntryPair> {
3130    let mut left = BTreeMap::new();
3131    let mut right = BTreeMap::new();
3132    // Identical root trees produce no changes at all and need not be read.
3133    if left_tree != right_tree {
3134        diff_tree_pair(
3135            db,
3136            format,
3137            left_tree,
3138            right_tree,
3139            &[],
3140            &mut left,
3141            &mut right,
3142        )?;
3143    }
3144    Ok((left, right))
3145}
3146
3147/// Recursively diff two subtrees rooted at `prefix`, appending differing blob
3148/// entries to `left` / `right`. Invariant: the two OIDs are already known to
3149/// differ (identical subtrees are pruned by the caller before recursing).
3150fn diff_tree_pair(
3151    db: &FileObjectDatabase,
3152    format: ObjectFormat,
3153    left_tree: &ObjectId,
3154    right_tree: &ObjectId,
3155    prefix: &[u8],
3156    left: &mut BTreeMap<Vec<u8>, TrackedEntry>,
3157    right: &mut BTreeMap<Vec<u8>, TrackedEntry>,
3158) -> Result<()> {
3159    let left_entries = read_tree_object(db, format, left_tree)?.entries;
3160    let right_entries = read_tree_object(db, format, right_tree)?.entries;
3161
3162    // Index the right side by name so the union of names can be walked without
3163    // relying on git's directory-aware entry ordering. (Iterating the union of
3164    // names, rather than a positional merge, keeps correctness independent of
3165    // entry order.)
3166    let mut right_by_name: HashMap<&[u8], &TreeEntry> = HashMap::with_capacity(right_entries.len());
3167    for entry in &right_entries {
3168        right_by_name.insert(entry.name.as_bytes(), entry);
3169    }
3170
3171    for left_entry in &left_entries {
3172        match right_by_name.remove(left_entry.name.as_bytes()) {
3173            Some(right_entry) => {
3174                merge_tree_entry(
3175                    db,
3176                    format,
3177                    prefix,
3178                    Some(left_entry),
3179                    Some(right_entry),
3180                    left,
3181                    right,
3182                )?;
3183            }
3184            None => {
3185                merge_tree_entry(db, format, prefix, Some(left_entry), None, left, right)?;
3186            }
3187        }
3188    }
3189    // Names only present on the right are pure additions.
3190    for right_entry in &right_entries {
3191        if right_by_name.contains_key(right_entry.name.as_bytes()) {
3192            merge_tree_entry(db, format, prefix, None, Some(right_entry), left, right)?;
3193        }
3194    }
3195    Ok(())
3196}
3197
3198/// Reconcile a single name that may appear on the left side, the right side, or
3199/// both, recording any resulting blob change(s) into `left` / `right`. This
3200/// reproduces exactly the union-of-flattened-maps semantics:
3201///
3202/// * tree vs tree with equal OID -> pruned (no read, no recursion);
3203/// * tree vs tree with differing OID -> recurse;
3204/// * blob vs blob, equal mode+OID -> unchanged, emitted nowhere;
3205/// * blob vs blob, differing mode or OID -> both sides recorded (a Modify);
3206/// * a tree on one side and a non-tree on the other (or a name present on only
3207///   one side) -> the flattened paths differ (`name/...` vs `name`), so the two
3208///   are unrelated: the tree side is flattened wholesale and the blob side is
3209///   recorded independently (an Add and/or a Delete).
3210fn merge_tree_entry(
3211    db: &FileObjectDatabase,
3212    format: ObjectFormat,
3213    prefix: &[u8],
3214    left_entry: Option<&TreeEntry>,
3215    right_entry: Option<&TreeEntry>,
3216    left: &mut BTreeMap<Vec<u8>, TrackedEntry>,
3217    right: &mut BTreeMap<Vec<u8>, TrackedEntry>,
3218) -> Result<()> {
3219    let left_is_tree = left_entry.is_some_and(|entry| entry.mode == TREE_ENTRY_MODE);
3220    let right_is_tree = right_entry.is_some_and(|entry| entry.mode == TREE_ENTRY_MODE);
3221
3222    if let (Some(left_entry), Some(right_entry)) = (left_entry, right_entry) {
3223        if left_is_tree && right_is_tree {
3224            // Two subtrees under the same name: prune if identical, else recurse.
3225            if left_entry.oid == right_entry.oid {
3226                return Ok(());
3227            }
3228            let path = join_tree_path(prefix, left_entry.name.as_bytes());
3229            return diff_tree_pair(
3230                db,
3231                format,
3232                &left_entry.oid,
3233                &right_entry.oid,
3234                &path,
3235                left,
3236                right,
3237            );
3238        }
3239        if !left_is_tree && !right_is_tree {
3240            // Two blobs under the same name. Identical mode+OID means unchanged
3241            // (nothing emitted); otherwise both sides are recorded so the diff
3242            // sees a Modify, matching the full-map `left != right` comparison.
3243            if left_entry.mode == right_entry.mode && left_entry.oid == right_entry.oid {
3244                return Ok(());
3245            }
3246            let path = join_tree_path(prefix, left_entry.name.as_bytes());
3247            left.insert(
3248                path.clone(),
3249                TrackedEntry {
3250                    mode: left_entry.mode,
3251                    oid: left_entry.oid,
3252                },
3253            );
3254            right.insert(
3255                path,
3256                TrackedEntry {
3257                    mode: right_entry.mode,
3258                    oid: right_entry.oid,
3259                },
3260            );
3261            return Ok(());
3262        }
3263        // Mixed: tree on one side, blob on the other. Their flattened paths
3264        // never collide, so handle each side as if the name existed only there.
3265    }
3266
3267    // Left side (if any): record as deletions.
3268    if let Some(left_entry) = left_entry {
3269        let path = join_tree_path(prefix, left_entry.name.as_bytes());
3270        if left_is_tree {
3271            collect_tree_entries(db, format, &left_entry.oid, path, left)?;
3272        } else {
3273            left.insert(
3274                path,
3275                TrackedEntry {
3276                    mode: left_entry.mode,
3277                    oid: left_entry.oid,
3278                },
3279            );
3280        }
3281    }
3282    // Right side (if any): record as additions.
3283    if let Some(right_entry) = right_entry {
3284        let path = join_tree_path(prefix, right_entry.name.as_bytes());
3285        if right_is_tree {
3286            collect_tree_entries(db, format, &right_entry.oid, path, right)?;
3287        } else {
3288            right.insert(
3289                path,
3290                TrackedEntry {
3291                    mode: right_entry.mode,
3292                    oid: right_entry.oid,
3293                },
3294            );
3295        }
3296    }
3297    Ok(())
3298}
3299
3300fn index_gitlinks(index: &BTreeMap<Vec<u8>, TrackedEntry>) -> BTreeMap<Vec<u8>, ObjectId> {
3301    index
3302        .iter()
3303        .filter(|(_, entry)| sley_index::is_gitlink(entry.mode))
3304        .map(|(path, entry)| (path.clone(), entry.oid))
3305        .collect()
3306}
3307
3308fn candidate_path_set<'a>(candidate_paths: impl Iterator<Item = &'a Vec<u8>>) -> BTreeSet<Vec<u8>> {
3309    candidate_paths.cloned().collect()
3310}
3311
3312fn worktree_entries_for_path_set(
3313    worktree_root: &Path,
3314    format: ObjectFormat,
3315    candidates: &BTreeSet<Vec<u8>>,
3316    index_gitlinks: &BTreeMap<Vec<u8>, ObjectId>,
3317    stat_cache: Option<&IndexStatCache>,
3318    missing_skip_worktree_entries: Option<&BTreeMap<Vec<u8>, TrackedEntry>>,
3319    present_skip_worktree_entries: Option<&BTreeMap<Vec<u8>, TrackedEntry>>,
3320) -> Result<BTreeMap<Vec<u8>, TrackedEntry>> {
3321    worktree_entries_for_unique_paths(
3322        worktree_root,
3323        format,
3324        candidates.iter(),
3325        index_gitlinks,
3326        stat_cache,
3327        missing_skip_worktree_entries,
3328        present_skip_worktree_entries,
3329    )
3330}
3331
3332fn worktree_entries_for_unique_paths<'a>(
3333    worktree_root: &Path,
3334    format: ObjectFormat,
3335    candidates: impl Iterator<Item = &'a Vec<u8>>,
3336    index_gitlinks: &BTreeMap<Vec<u8>, ObjectId>,
3337    stat_cache: Option<&IndexStatCache>,
3338    missing_skip_worktree_entries: Option<&BTreeMap<Vec<u8>, TrackedEntry>>,
3339    present_skip_worktree_entries: Option<&BTreeMap<Vec<u8>, TrackedEntry>>,
3340) -> Result<BTreeMap<Vec<u8>, TrackedEntry>> {
3341    let mut entries = BTreeMap::new();
3342    for git_path in candidates {
3343        if let Some(entry) = worktree_entry_for_path(
3344            worktree_root,
3345            format,
3346            git_path,
3347            index_gitlinks,
3348            stat_cache,
3349            missing_skip_worktree_entries,
3350            present_skip_worktree_entries,
3351        )? {
3352            entries.insert(git_path.clone(), entry);
3353        }
3354    }
3355    Ok(entries)
3356}
3357
3358fn worktree_entry_for_path(
3359    worktree_root: &Path,
3360    format: ObjectFormat,
3361    git_path: &[u8],
3362    index_gitlinks: &BTreeMap<Vec<u8>, ObjectId>,
3363    stat_cache: Option<&IndexStatCache>,
3364    missing_skip_worktree_entries: Option<&BTreeMap<Vec<u8>, TrackedEntry>>,
3365    present_skip_worktree_entries: Option<&BTreeMap<Vec<u8>, TrackedEntry>>,
3366) -> Result<Option<TrackedEntry>> {
3367    if let Some(entry) = present_skip_worktree_entries.and_then(|entries| {
3368        entries
3369            .get(git_path)
3370            .cloned()
3371            .filter(|entry| !sley_index::is_gitlink(entry.mode))
3372    }) {
3373        return Ok(Some(entry));
3374    }
3375    let path = worktree_path_for_repo_path(worktree_root, git_path);
3376    let metadata = match fs::symlink_metadata(&path) {
3377        Ok(metadata) => metadata,
3378        Err(err) if is_missing_worktree_path_error(&err) => {
3379            return Ok(missing_skip_worktree_entries.and_then(|entries| {
3380                entries
3381                    .get(git_path)
3382                    .cloned()
3383                    .filter(|entry| !sley_index::is_gitlink(entry.mode))
3384            }));
3385        }
3386        Err(err) => return Err(GitError::Io(err.to_string())),
3387    };
3388    let file_type = metadata.file_type();
3389    if let Some(staged_oid) = index_gitlinks.get(git_path)
3390        && metadata.is_dir()
3391    {
3392        let oid = gitlink_head_oid(&path, format).unwrap_or(*staged_oid);
3393        return Ok(Some(TrackedEntry {
3394            mode: sley_index::GITLINK_MODE,
3395            oid,
3396        }));
3397    }
3398    if metadata.is_dir() {
3399        if let Some(oid) = gitlink_head_oid(&path, format) {
3400            return Ok(Some(TrackedEntry {
3401                mode: sley_index::GITLINK_MODE,
3402                oid,
3403            }));
3404        }
3405        return Ok(None);
3406    }
3407    if !(metadata.is_file() || file_type.is_symlink()) {
3408        return Ok(None);
3409    }
3410    if let Some(entry) = stat_cache.and_then(|cache| cache.reusable_entry(git_path, &metadata)) {
3411        return Ok(Some(tracked_entry_from_index(entry)));
3412    }
3413    Ok(Some(classify_worktree_entry(&path, &metadata, format)?))
3414}
3415
3416fn index_worktree_change_for_entry(
3417    path: &Path,
3418    format: ObjectFormat,
3419    index_entry: &impl WorktreeIndexEntry,
3420    stat_cache: &IndexStatCache,
3421    stat_clean_validator: &mut Option<&mut IndexWorktreeStatCleanValidator<'_>>,
3422) -> Result<Option<NameStatusEntry>> {
3423    let git_path = index_entry.git_path();
3424    let metadata = match fs::symlink_metadata(path) {
3425        Ok(metadata) => metadata,
3426        Err(err) if is_missing_worktree_path_error(&err) && index_entry.is_skip_worktree() => {
3427            return Ok(None);
3428        }
3429        Err(err) if is_missing_worktree_path_error(&err) => {
3430            return Ok(Some(index_worktree_deleted_entry(index_entry)));
3431        }
3432        Err(err) => return Err(GitError::Io(err.to_string())),
3433    };
3434    let file_type = metadata.file_type();
3435    let right = if metadata.is_dir() {
3436        if sley_index::is_gitlink(index_entry.mode()) {
3437            let oid = gitlink_head_oid(path, format).unwrap_or(index_entry.oid());
3438            Some(TrackedEntry {
3439                mode: sley_index::GITLINK_MODE,
3440                oid,
3441            })
3442        } else {
3443            gitlink_head_oid(path, format).map(|oid| TrackedEntry {
3444                mode: sley_index::GITLINK_MODE,
3445                oid,
3446            })
3447        }
3448    } else if metadata.is_file() || file_type.is_symlink() {
3449        let validated = if let Some(validator) = stat_clean_validator.as_deref_mut() {
3450            validator(
3451                IndexWorktreeValidationEntry {
3452                    path: index_entry.git_path(),
3453                    mode: index_entry.mode(),
3454                    oid: index_entry.oid(),
3455                    size: index_entry.size(),
3456                },
3457                path,
3458                &metadata,
3459            )?
3460        } else {
3461            None
3462        };
3463        if index_entry.reusable_with(stat_cache, &metadata) {
3464            return Ok(None);
3465        }
3466        Some(match validated {
3467            Some(entry) => TrackedEntry {
3468                mode: entry.mode,
3469                oid: entry.oid,
3470            },
3471            None => classify_worktree_entry(path, &metadata, format)?,
3472        })
3473    } else {
3474        None
3475    };
3476    let Some(right) = right else {
3477        return Ok(Some(index_worktree_deleted_entry(index_entry)));
3478    };
3479    let left = tracked_entry_from_index(index_entry);
3480    if right == left {
3481        return Ok(None);
3482    }
3483    Ok(Some(NameStatusEntry {
3484        status: modify_or_type_change(left.mode, right.mode),
3485        path: git_path.to_vec().into(),
3486        old_path: None,
3487        old_mode: Some(left.mode),
3488        new_mode: Some(right.mode),
3489        old_oid: Some(left.oid),
3490        new_oid: Some(right.oid),
3491    }))
3492}
3493
3494fn index_worktree_deleted_entry(index_entry: &impl WorktreeIndexEntry) -> NameStatusEntry {
3495    NameStatusEntry {
3496        status: NameStatus::Deleted,
3497        path: index_entry.git_path().to_vec().into(),
3498        old_path: None,
3499        old_mode: Some(index_entry.mode()),
3500        new_mode: None,
3501        old_oid: Some(index_entry.oid()),
3502        new_oid: None,
3503    }
3504}
3505
3506fn is_missing_worktree_path_error(err: &std::io::Error) -> bool {
3507    matches!(
3508        err.kind(),
3509        std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
3510    )
3511}
3512
3513fn worktree_blob_cache_for_path_set(
3514    worktree_root: &Path,
3515    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3516    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3517    candidate_paths: &BTreeSet<Vec<u8>>,
3518    odb_backed_worktree_entries: Option<&BTreeMap<Vec<u8>, TrackedEntry>>,
3519    options: DiffNameStatusOptions,
3520) -> Result<HashMap<ObjectId, Arc<[u8]>>> {
3521    worktree_blob_cache_for_unique_paths(
3522        worktree_root,
3523        left_entries,
3524        right_entries,
3525        candidate_paths.iter(),
3526        odb_backed_worktree_entries,
3527        options,
3528    )
3529}
3530
3531fn worktree_blob_cache_for_unique_paths<'a>(
3532    worktree_root: &Path,
3533    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3534    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3535    candidate_paths: impl Iterator<Item = &'a Vec<u8>>,
3536    odb_backed_worktree_entries: Option<&BTreeMap<Vec<u8>, TrackedEntry>>,
3537    options: DiffNameStatusOptions,
3538) -> Result<HashMap<ObjectId, Arc<[u8]>>> {
3539    if !options.detect_inexact || !(options.detect_renames || options.detect_copies) {
3540        return Ok(HashMap::new());
3541    }
3542        let mut changes =
3543        raw_name_status_changes_for_unique_paths(left_entries, right_entries, candidate_paths);
3544    if options.detect_renames {
3545        changes = detect_exact_renames(changes, left_entries, right_entries, options.rename_empty);
3546    }
3547    if options.detect_copies {
3548        changes = detect_exact_copies(
3549            changes,
3550            left_entries,
3551            right_entries,
3552            options.find_copies_harder,
3553            options.rename_empty,
3554        );
3555    }
3556    let has_rename_source = options.detect_renames
3557        && changes.iter().any(|entry| {
3558            entry.status == NameStatus::Deleted
3559                && entry
3560                    .old_oid
3561                    .as_ref()
3562                    .is_some_and(|oid| options.rename_empty || !is_empty_blob_oid(oid))
3563        });
3564    let has_copy_source = options.detect_copies
3565        && (options.find_copies_harder
3566            || changes
3567                .iter()
3568                .any(|entry| matches!(entry.status, NameStatus::Deleted | NameStatus::Modified)));
3569    if !has_rename_source && !has_copy_source {
3570        return Ok(HashMap::new());
3571    }
3572    let candidate_oids = changes
3573        .iter()
3574        .filter(|entry| entry.status == NameStatus::Added)
3575        .filter_map(|entry| entry.new_oid)
3576        .filter(|oid| options.rename_empty || !is_empty_blob_oid(oid))
3577        .collect::<BTreeSet<_>>();
3578    if candidate_oids.is_empty() {
3579        return Ok(HashMap::new());
3580    }
3581    let mut cache = HashMap::new();
3582    for (git_path, entry) in right_entries {
3583        if odb_backed_worktree_entries.is_some_and(|entries| entries.contains_key(git_path)) {
3584            continue;
3585        }
3586        if sley_index::is_gitlink(entry.mode) || !candidate_oids.contains(&entry.oid) {
3587            continue;
3588        }
3589        let path = worktree_path_for_repo_path(worktree_root, git_path);
3590        let body = if sley_index::is_symlink_mode(entry.mode) {
3591            symlink_target_bytes(&path)?
3592        } else {
3593            fs::read(&path)?
3594        };
3595        cache
3596            .entry(entry.oid)
3597            .or_insert_with(|| Arc::from(body.into_boxed_slice()));
3598    }
3599    Ok(cache)
3600}
3601
3602/// A blob fetcher that consults an in-memory `oid -> bytes` cache first (e.g.
3603/// freshly-read worktree files) and falls back to the object database.
3604fn cache_or_odb_blob(
3605    cache: &HashMap<ObjectId, Arc<[u8]>>,
3606    db: &FileObjectDatabase,
3607    oid: &ObjectId,
3608) -> Option<Arc<[u8]>> {
3609    if let Some(bytes) = cache.get(oid) {
3610        return Some(bytes.clone());
3611    }
3612    read_blob_bytes(db, oid)
3613}
3614
3615#[cfg(unix)]
3616fn worktree_path_for_repo_path(worktree_root: &Path, path: &[u8]) -> PathBuf {
3617    use std::ffi::OsStr;
3618    use std::os::unix::ffi::OsStrExt;
3619
3620    let mut out = PathBuf::from(worktree_root);
3621    out.push(OsStr::from_bytes(path));
3622    out
3623}
3624
3625#[cfg(unix)]
3626fn worktree_path_for_repo_path_into(out: &mut PathBuf, worktree_root: &Path, path: &[u8]) {
3627    use std::ffi::OsStr;
3628    use std::os::unix::ffi::OsStrExt;
3629
3630    out.clear();
3631    out.push(worktree_root);
3632    out.push(OsStr::from_bytes(path));
3633}
3634
3635#[cfg(not(unix))]
3636fn worktree_path_for_repo_path(worktree_root: &Path, path: &[u8]) -> PathBuf {
3637    worktree_root.join(repo_path_to_path(path))
3638}
3639
3640#[cfg(not(unix))]
3641fn worktree_path_for_repo_path_into(out: &mut PathBuf, worktree_root: &Path, path: &[u8]) {
3642    out.clear();
3643    out.push(worktree_root);
3644    out.push(repo_path_to_path(path));
3645}
3646
3647#[cfg(not(unix))]
3648fn repo_path_to_path(path: &[u8]) -> PathBuf {
3649    let mut out = PathBuf::new();
3650    for component in String::from_utf8_lossy(path).split('/') {
3651        if !component.is_empty() {
3652            out.push(component);
3653        }
3654    }
3655    out
3656}
3657
3658#[cfg(unix)]
3659fn file_mode(metadata: &fs::Metadata) -> u32 {
3660    use std::os::unix::fs::PermissionsExt;
3661    if metadata.permissions().mode() & 0o111 != 0 {
3662        0o100755
3663    } else {
3664        0o100644
3665    }
3666}
3667
3668#[cfg(not(unix))]
3669fn file_mode(_metadata: &fs::Metadata) -> u32 {
3670    0o100644
3671}
3672
3673/// Read a symbolic link's target as git stores it: the raw target path bytes,
3674/// with no trailing newline. This is the "content" of a symlink blob (mode
3675/// `120000`) — git's `diff_populate_filespec` uses `strbuf_readlink` for a
3676/// worktree symlink rather than dereferencing it.
3677#[cfg(unix)]
3678pub fn symlink_target_bytes(path: &Path) -> Result<Vec<u8>> {
3679    use std::os::unix::ffi::OsStrExt;
3680    let target = fs::read_link(path)?;
3681    Ok(target.as_os_str().as_bytes().to_vec())
3682}
3683
3684/// See the unix variant: the raw symlink target bytes git stores as the blob.
3685#[cfg(not(unix))]
3686pub fn symlink_target_bytes(path: &Path) -> Result<Vec<u8>> {
3687    let target = fs::read_link(path)?;
3688    Ok(target.to_string_lossy().replace('\\', "/").into_bytes())
3689}
3690/// Flattened tree: repository-relative path -> (mode, blob/symlink/gitlink oid).
3691pub type MergeEntryMap = BTreeMap<Vec<u8>, (u32, ObjectId)>;
3692/// Read a tree object (by oid) into a flattened path -> (mode, oid) map,
3693/// descending into subtrees. The canonical empty tree yields an empty map.
3694pub fn flatten_tree(
3695    reader: &impl ObjectReader,
3696    format: ObjectFormat,
3697    tree_oid: &ObjectId,
3698) -> Result<MergeEntryMap> {
3699    let mut entries = BTreeMap::new();
3700    if *tree_oid == empty_tree_oid(format)? {
3701        return Ok(entries);
3702    }
3703    collect_flat_tree(reader, format, tree_oid, Vec::new(), &mut entries)?;
3704    Ok(entries)
3705}
3706
3707pub fn corrupted_cache_tree_error() -> GitError {
3708    GitError::InvalidFormat("error: corrupted cache-tree has entries not present in index".into())
3709}
3710
3711pub fn tree_has_duplicate_leaf_paths(
3712    reader: &impl ObjectReader,
3713    format: ObjectFormat,
3714    tree_oid: &ObjectId,
3715) -> Result<bool> {
3716    if *tree_oid == empty_tree_oid(format)? {
3717        return Ok(false);
3718    }
3719    let mut seen = BTreeSet::new();
3720    collect_duplicate_leaf_paths(reader, format, tree_oid, Vec::new(), &mut seen)
3721}
3722
3723fn collect_duplicate_leaf_paths(
3724    reader: &impl ObjectReader,
3725    format: ObjectFormat,
3726    tree_oid: &ObjectId,
3727    prefix: Vec<u8>,
3728    seen: &mut BTreeSet<Vec<u8>>,
3729) -> Result<bool> {
3730    let object = reader.read_object(tree_oid)?;
3731    if object.object_type != ObjectType::Tree {
3732        return Err(GitError::InvalidObject(format!(
3733            "expected tree {}, found {}",
3734            tree_oid,
3735            object.object_type.as_str()
3736        )));
3737    }
3738    for entry in TreeEntries::new(format, &object.body) {
3739        let entry = entry?;
3740        let mut path = prefix.clone();
3741        if !path.is_empty() {
3742            path.push(b'/');
3743        }
3744        path.extend_from_slice(entry.name);
3745        if entry.mode == 0o040000 {
3746            if collect_duplicate_leaf_paths(reader, format, &entry.oid, path, seen)? {
3747                return Ok(true);
3748            }
3749        } else if !seen.insert(path) {
3750            return Ok(true);
3751        }
3752    }
3753    Ok(false)
3754}
3755
3756fn collect_flat_tree(
3757    reader: &impl ObjectReader,
3758    format: ObjectFormat,
3759    tree_oid: &ObjectId,
3760    prefix: Vec<u8>,
3761    entries: &mut MergeEntryMap,
3762) -> Result<()> {
3763    let object = reader.read_object(tree_oid)?;
3764    if object.object_type != ObjectType::Tree {
3765        return Err(GitError::InvalidObject(format!(
3766            "expected tree {}, found {}",
3767            tree_oid,
3768            object.object_type.as_str()
3769        )));
3770    }
3771    for entry in TreeEntries::new(format, &object.body) {
3772        let entry = entry?;
3773        let mut path = prefix.clone();
3774        if !path.is_empty() {
3775            path.push(b'/');
3776        }
3777        path.extend_from_slice(entry.name);
3778        if entry.mode == 0o040000 {
3779            collect_flat_tree(reader, format, &entry.oid, path, entries)?;
3780        } else {
3781            entries.insert(path, (entry.mode, entry.oid));
3782        }
3783    }
3784    Ok(())
3785}