Skip to main content

sley_diff_merge/
name_status.rs

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