Skip to main content

grit_lib/diff/
changes.rs

1//! Staged, unstaged, and working-tree changes with both sides' contents loaded.
2//!
3//! [`UncommittedDiff`] collects [`DiffEntry`] rows for the index against `HEAD`, the working
4//! tree against the index, or the working tree against any tree, reusing one loaded index and
5//! one set of [`WorktreeRules`] for every step; [`UncommittedDiff::entries`] picks the comparison
6//! from a [`DiffTarget`] and applies pathspecs and `--diff-filter`.
7//! [`UncommittedDiff::load_worktree_changes`] and
8//! [`load_object_changes`] then turn entries into [`FileChange`]s whose bytes feed structured
9//! hunks ([`FileChange::structured`]) or line counts ([`FileChange::line_stat`]).
10
11use std::collections::{BTreeSet, HashSet};
12use std::path::Path;
13use std::sync::{Arc, Mutex};
14
15use crate::diff::structured::{structured_file_diff, SideBytes, StructuredFileDiff};
16use crate::diff::targets::{
17    diff_index_to_tree_for_paths, limit_target_entries, stored_target_entries,
18};
19use crate::diff::tree_content::{gitlink_line, load_new_side, load_old_side};
20use crate::diff::{
21    apply_rename_detection_to_diff_entries, diff_index_to_tree_with_renames,
22    diff_index_to_worktree_with_renames, diff_tree_to_worktree_with_git_dir_and_rules_extras,
23    index_mtime_for_diff, line_change_counts, load_worktree_side_for_diff,
24    peer_scope_includes_path, read_submodule_head_oid, sort_entries_by_path,
25    submodule_porcelain_flags, DiffEntry, DiffIndexToWorktreeOptions, DiffOptions, DiffSide,
26    DiffStatus, DiffTarget, DiffTreeToWorktreeExtras, LineStat, RenameDetection,
27    RENAME_DETECTION_DISABLED,
28};
29use crate::error::{Error, Result};
30use crate::index::write_tree::cache_tree_extension_valid;
31use crate::index::Index;
32use crate::objects::odb::Odb;
33use crate::objects::{FileMode, ObjectId};
34use crate::parallel::{try_par_map, ParallelHashError, ParallelOp};
35use crate::repo::Repository;
36use crate::state::resolve_head_for_repository;
37use crate::worktree::fsmonitor::fsmonitor_enabled;
38use crate::worktree::worktree_rules::WorktreeRules;
39
40/// One changed path with both sides' bytes loaded for diffing.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct FileChange {
43    /// Display path (new path, or old path for a deletion).
44    pub path: String,
45    /// Pre-rename path, only when it differs from [`Self::path`].
46    pub old_path: Option<String>,
47    /// Change kind for this path.
48    pub status: DiffStatus,
49    /// Old side mode ([`FileMode::ABSENT`] when the path is added).
50    pub old_mode: FileMode,
51    /// New side mode ([`FileMode::ABSENT`] when the path is deleted).
52    pub new_mode: FileMode,
53    /// Old side object id (zero when absent).
54    pub old_oid: ObjectId,
55    /// New side object id (zero when absent or not yet hashed from the working tree).
56    pub new_oid: ObjectId,
57    /// Old side contents (a `Subproject commit` line for gitlinks).
58    pub old_bytes: SideBytes,
59    /// New side contents, read from the working tree for working-tree changes.
60    pub new_bytes: SideBytes,
61    /// Either side is treated as binary; a binary change may carry neither side's bytes.
62    pub binary: bool,
63    /// The object ids differ (false for a mode-only change).
64    pub content_changed: bool,
65    /// Rename or copy similarity (0–100), when applicable.
66    pub similarity: Option<u32>,
67}
68
69impl FileChange {
70    fn from_entry(
71        entry: DiffEntry,
72        old_bytes: SideBytes,
73        new_bytes: SideBytes,
74        binary: bool,
75    ) -> Self {
76        let path = entry
77            .new_path
78            .clone()
79            .or_else(|| entry.old_path.clone())
80            .unwrap_or_default();
81        let similarity = entry.rename_similarity();
82        // A deletion's display path is its old path; only a real rename keeps `old_path`.
83        let old_path = entry.old_path.filter(|old| *old != path);
84        Self {
85            path,
86            old_path,
87            status: entry.status,
88            old_mode: entry.old_mode,
89            new_mode: entry.new_mode,
90            old_oid: entry.old_oid,
91            new_oid: entry.new_oid,
92            old_bytes,
93            new_bytes,
94            binary,
95            content_changed: entry.old_oid != entry.new_oid,
96            similarity,
97        }
98    }
99
100    /// Structured line hunks and mode metadata for this change.
101    ///
102    /// Binary changes get mode metadata and no hunks.
103    ///
104    /// # Parameters
105    ///
106    /// - `options` - line-diff settings (context, algorithm, whitespace handling).
107    #[must_use]
108    pub fn structured(&self, options: DiffOptions) -> StructuredFileDiff {
109        if self.binary {
110            return StructuredFileDiff {
111                old_mode: self.old_mode.octal_if_present(),
112                new_mode: self.new_mode.octal_if_present(),
113                old_encoding_lossy: self.old_bytes.encoding_lossy,
114                new_encoding_lossy: self.new_bytes.encoding_lossy,
115                hunks: Vec::new(),
116            };
117        }
118        structured_file_diff(
119            self.old_mode,
120            self.new_mode,
121            &self.old_bytes,
122            &self.new_bytes,
123            options,
124        )
125    }
126
127    /// Inserted and deleted line counts, or `None` for a binary change.
128    ///
129    /// # Parameters
130    ///
131    /// - `options` - line-diff settings; whitespace modes change what counts as a changed line.
132    #[must_use]
133    pub fn line_stat(&self, options: &DiffOptions) -> Option<LineStat> {
134        if self.binary {
135            return None;
136        }
137        Some(line_change_counts(
138            &self.old_bytes.text(),
139            &self.new_bytes.text(),
140            options,
141        ))
142    }
143}
144
145/// Load both sides of each entry from the object database.
146///
147/// Use this for index-against-tree and tree-against-tree entries, whose sides are all stored
148/// objects. Entries are loaded in parallel per the repository's thread policy; the result keeps
149/// the order of `entries`.
150///
151/// # Parameters
152///
153/// - `repo` - repository whose object database holds both sides.
154/// - `entries` - diff rows to load, e.g. from [`UncommittedDiff::staged_entries`].
155///
156/// # Errors
157///
158/// Returns config load failures and object read errors.
159pub fn load_object_changes(repo: &Repository, entries: &[DiffEntry]) -> Result<Vec<FileChange>> {
160    let config = repo.config()?;
161    let par_opts = repo
162        .thread_policy()
163        .resolve(ParallelOp::DiffContent, config.as_ref());
164    let odb = repo.odb();
165    let est_bytes = entries.len().saturating_mul(8192);
166    try_par_map(entries, par_opts, est_bytes, |entry| {
167        load_change(odb, entry.clone(), None)
168    })
169    .map_err(|ParallelHashError::Task(err)| err)
170}
171
172/// Where a working-tree change's new side is read from.
173pub(crate) struct WorktreeSide<'a> {
174    pub(crate) work_tree: &'a Path,
175    pub(crate) index: &'a Index,
176    pub(crate) rules: &'a Mutex<WorktreeRules>,
177}
178
179/// Load one entry's sides: the old side from the object database, the new side from the
180/// working tree when `worktree` is given, otherwise from the object database.
181///
182/// A binary new side makes the whole change binary, so the old blob is then not read at all.
183pub(crate) fn load_change(
184    odb: &Odb,
185    entry: DiffEntry,
186    worktree: Option<&WorktreeSide<'_>>,
187) -> Result<FileChange> {
188    let (new_bytes, new_binary) = match worktree {
189        Some(side) => load_worktree_new_side(odb, &entry, side)?,
190        None => load_new_side(odb, &entry)?,
191    };
192    let (old_bytes, old_binary) = if new_binary {
193        (old_side_without_payload(odb, &entry, worktree)?, false)
194    } else {
195        load_old_side(odb, &entry)?
196    };
197    Ok(FileChange::from_entry(
198        entry,
199        old_bytes,
200        new_bytes,
201        old_binary || new_binary,
202    ))
203}
204
205/// Load every entry with [`load_change`], in order.
206pub(crate) fn load_changes(
207    odb: &Odb,
208    entries: Vec<DiffEntry>,
209    worktree: Option<&WorktreeSide<'_>>,
210) -> Result<Vec<FileChange>> {
211    entries
212        .into_iter()
213        .map(|entry| load_change(odb, entry, worktree))
214        .collect()
215}
216
217fn old_side_without_payload(
218    odb: &Odb,
219    entry: &DiffEntry,
220    worktree: Option<&WorktreeSide<'_>>,
221) -> Result<SideBytes> {
222    if entry.old_mode == FileMode::GITLINK {
223        return Ok(SideBytes::from_bytes(
224            gitlink_line(&entry.old_oid).into_bytes(),
225        ));
226    }
227    if entry.old_mode == FileMode::ABSENT {
228        return Ok(SideBytes::empty_with_size(0));
229    }
230    if let Some(side) = worktree {
231        if let Some(path) = entry.old_path.as_deref().or(entry.new_path.as_deref()) {
232            if let Some(ie) = side.index.get(&crate::repo_relpath::storage_bytes(path), 0) {
233                if ie.oid == entry.old_oid && !ie.stat_cache_unset() {
234                    return Ok(SideBytes::empty_with_size(u64::from(ie.size)));
235                }
236            }
237        }
238    }
239    if entry.old_oid.is_zero() {
240        return Ok(SideBytes::empty_with_size(0));
241    }
242    let info = odb.read_info(&entry.old_oid)?;
243    Ok(SideBytes::empty_with_size(info.size))
244}
245
246fn load_worktree_new_side(
247    odb: &Odb,
248    entry: &DiffEntry,
249    side: &WorktreeSide<'_>,
250) -> Result<(SideBytes, bool)> {
251    if entry.new_mode == FileMode::GITLINK {
252        let line = worktree_gitlink_line(entry, side.work_tree);
253        return Ok((SideBytes::from_bytes(line.into_bytes()), false));
254    }
255    if entry.new_mode == FileMode::ABSENT {
256        return Ok((SideBytes::from_bytes(Vec::new()), false));
257    }
258    let Some(path) = entry.new_path.as_deref().or(entry.old_path.as_deref()) else {
259        return load_new_side(odb, entry);
260    };
261    // Working-tree entries often carry only a hash of the normalized file, and the blob is not
262    // in the object database until it is staged, so always read the live file.
263    let index_entry = side.index.get(&crate::repo_relpath::storage_bytes(path), 0);
264    let rules = side.rules.lock().unwrap_or_else(|e| e.into_inner());
265    load_worktree_side_for_diff(
266        odb,
267        side.work_tree,
268        path,
269        entry.new_mode.bits(),
270        rules.config(),
271        index_entry,
272        Some(&rules),
273        false,
274    )
275}
276
277/// The submodule's checked-out commit, with `-dirty` when its tracked content is modified.
278fn worktree_gitlink_line(entry: &DiffEntry, work_tree: &Path) -> String {
279    let path = entry.new_path.as_deref().or(entry.old_path.as_deref());
280    let head = if entry.new_oid != ObjectId::zero() {
281        Some(entry.new_oid)
282    } else {
283        path.and_then(|p| read_submodule_head_oid(&work_tree.join(p)))
284    };
285    let Some(head) = head.filter(|oid| *oid != ObjectId::zero()) else {
286        return String::new();
287    };
288    let dirty = entry.old_mode == FileMode::GITLINK
289        && path.is_some_and(|p| submodule_porcelain_flags(work_tree, p, entry.old_oid).modified);
290    if dirty {
291        format!("Subproject commit {}-dirty\n", head.to_hex())
292    } else {
293        gitlink_line(&head)
294    }
295}
296
297/// Paths to scan before a worktree or index diff when `pathspecs` limit the operation.
298///
299/// Includes index entries selected by the pathspecs plus blob paths in `base_tree` that match,
300/// so a staged deletion (absent from the index but present in `HEAD`) is not dropped.
301pub(crate) fn pathspec_restrict_paths_for_scan(
302    repo: &Repository,
303    index: &Index,
304    pathspecs: &[String],
305    base_tree: Option<&ObjectId>,
306) -> Result<Option<Arc<BTreeSet<String>>>> {
307    if pathspecs.is_empty() {
308        return Ok(None);
309    }
310    let base_ctx = repo.pathspec_match_context();
311    let mut paths = crate::porcelain::add::restricted_index_paths_for_pathspecs(
312        repo, index, pathspecs, base_ctx,
313    )?;
314    if let Some(tree) = base_tree {
315        let attr_matcher = if pathspecs
316            .iter()
317            .any(|s| crate::pathspec::pathspec_has_attr_magic(s))
318        {
319            Some(repo.pathspec_matcher(pathspecs)?)
320        } else {
321            None
322        };
323        for (path, mode, _) in super::flatten_tree_entries(repo.odb(), tree)? {
324            if crate::porcelain::add::blob_path_matches_restrict_pathspecs(
325                &path,
326                mode.bits(),
327                pathspecs,
328                base_ctx,
329                attr_matcher.as_ref(),
330            )? {
331                paths.insert(path);
332            }
333        }
334    }
335    Ok(Some(Arc::new(paths)))
336}
337
338fn rename_threshold(renames: RenameDetection) -> u32 {
339    renames
340        .threshold_percent()
341        .unwrap_or(RENAME_DETECTION_DISABLED)
342}
343
344/// Index against `HEAD`'s tree, sorted by path.
345///
346/// A non-empty `restrict_to_paths` compares only those exact paths, without rename detection.
347pub(crate) fn staged_entries_in(
348    repo: &Repository,
349    index: &Index,
350    rename_threshold: u32,
351    restrict_to_paths: Option<&BTreeSet<String>>,
352) -> Result<Vec<DiffEntry>> {
353    let head_tree = resolve_head_for_repository(repo)?.tree(repo.odb())?;
354    let config = repo.config()?;
355    let mut entries = match restrict_to_paths.filter(|paths| !paths.is_empty()) {
356        Some(paths) => diff_index_to_tree_for_paths(repo, index, head_tree.as_ref(), paths)?,
357        None => diff_index_to_tree_with_renames(
358            repo.odb(),
359            index,
360            head_tree.as_ref(),
361            false,
362            rename_threshold,
363            config.as_ref(),
364            &repo.thread_policy(),
365        )?,
366    };
367    if let Some(work_tree) = repo.work_tree() {
368        entries.retain(|entry| {
369            if !entry.old_mode.is_gitlink() && !entry.new_mode.is_gitlink() {
370                return true;
371            }
372            let Some(path) = entry.new_path.as_deref().or(entry.old_path.as_deref()) else {
373                return true;
374            };
375            !crate::submodule_ignore::staged_gitlink_omitted_in_diff(
376                config.as_ref(),
377                work_tree,
378                path,
379            )
380        });
381    }
382    sort_entries_by_path(&mut entries);
383    Ok(entries)
384}
385
386/// Working tree against `index`, sorted by path.
387///
388/// `restrict_to_paths` limits the scan to those paths and skips the fsmonitor refresh. A file
389/// whose size changed (under an identity clean conversion) is reported without being hashed,
390/// with a null `new_oid`; loading its side reads the live file anyway.
391pub(crate) fn unstaged_entries_in(
392    repo: &Repository,
393    index: &mut Index,
394    work_tree: &Path,
395    rules: &Arc<Mutex<WorktreeRules>>,
396    rename_threshold: u32,
397    restrict_to_paths: Option<Arc<BTreeSet<String>>>,
398) -> Result<Vec<DiffEntry>> {
399    let config = rules.lock().unwrap_or_else(|e| e.into_inner()).config_arc();
400    let refresh_fsmonitor = restrict_to_paths.is_none() && fsmonitor_enabled(config.as_ref());
401    let options = DiffIndexToWorktreeOptions {
402        repository_git_dir: Some(repo.git_dir().to_path_buf()),
403        index_mtime: index_mtime_for_diff(index, None),
404        config: Some(config),
405        thread_policy: Some(repo.thread_policy()),
406        refresh_index_stat_in_pass: false,
407        size_change_proves_modified: true,
408        refresh_fsmonitor,
409        worktree_rules: Some(Arc::clone(rules)),
410        command_runner: Some(repo.command_runner()),
411        restrict_to_paths,
412        skip_inaccessible_paths: true,
413        diagnostics: Some(repo.diagnostics()),
414        ..DiffIndexToWorktreeOptions::default()
415    };
416    let (mut entries, _) =
417        diff_index_to_worktree_with_renames(repo, index, work_tree, options, rename_threshold)?;
418    sort_entries_by_path(&mut entries);
419    Ok(entries)
420}
421
422/// Working tree (through `index`) against `base_tree`, sorted by path.
423///
424/// When the index's cache tree matches `base_tree`, this is the cheaper index-against-worktree
425/// scan; otherwise the tree is walked against the working tree directly.
426pub(crate) fn worktree_entries_in(
427    repo: &Repository,
428    index: &mut Index,
429    work_tree: &Path,
430    rules: &Arc<Mutex<WorktreeRules>>,
431    base_tree: Option<&ObjectId>,
432    rename_threshold: u32,
433    restrict_to_paths: Option<Arc<BTreeSet<String>>>,
434) -> Result<Vec<DiffEntry>> {
435    let index_matches_base = match (base_tree, index.cache_tree_root.as_ref()) {
436        (Some(base), Some(cached)) => base == cached,
437        (None, None) => true,
438        _ => false,
439    };
440    if index_matches_base && cache_tree_extension_valid(index.cache_tree.as_ref()) {
441        return unstaged_entries_in(
442            repo,
443            index,
444            work_tree,
445            rules,
446            rename_threshold,
447            restrict_to_paths,
448        );
449    }
450    let config = repo.config()?;
451    let raw = {
452        let rules = rules.lock().unwrap_or_else(|e| e.into_inner());
453        diff_tree_to_worktree_with_git_dir_and_rules_extras(
454            repo.odb(),
455            base_tree,
456            work_tree,
457            repo.git_dir(),
458            index,
459            Some(config.as_ref()),
460            Some(&rules),
461            DiffTreeToWorktreeExtras {
462                skip_inaccessible_paths: true,
463                diagnostics: Some(repo.diagnostics()),
464                restrict_to_paths: restrict_to_paths.clone(),
465            },
466        )?
467    };
468    let mut entries = apply_rename_detection_to_diff_entries(
469        repo.odb(),
470        raw,
471        Some(work_tree),
472        rename_threshold,
473        config.as_ref(),
474        &repo.thread_policy(),
475    )?;
476    sort_entries_by_path(&mut entries);
477    Ok(entries)
478}
479
480/// Staged, unstaged, and working-tree diffs over one loaded index.
481///
482/// Every method reuses the same [`Index`] and, once a working-tree method needs them, the same
483/// [`WorktreeRules`], so collecting entries and then loading their contents reads neither twice.
484/// Working-tree scans may refresh stat data on the in-memory index; nothing is written to disk.
485///
486/// Obtain one from [`Diff::uncommitted`](crate::diff::Diff::uncommitted) or
487/// [`UncommittedDiff::new`].
488pub struct UncommittedDiff<'repo> {
489    repo: &'repo Repository,
490    index: Index,
491    rules: Option<Arc<Mutex<WorktreeRules>>>,
492}
493
494impl<'repo> UncommittedDiff<'repo> {
495    /// Diff `repo` against an index the caller already loaded.
496    ///
497    /// # Parameters
498    ///
499    /// - `repo` - repository to diff.
500    /// - `index` - its index (normally the on-disk index).
501    #[must_use]
502    pub fn new(repo: &'repo Repository, index: Index) -> Self {
503        Self {
504            repo,
505            index,
506            rules: None,
507        }
508    }
509
510    /// The index every comparison uses.
511    #[must_use]
512    pub fn index(&self) -> &Index {
513        &self.index
514    }
515
516    /// Index against `HEAD`'s tree (`grit diff --staged`), sorted by path.
517    ///
518    /// An unborn `HEAD` compares against the empty tree, so every index entry is added.
519    ///
520    /// # Parameters
521    ///
522    /// - `renames` - rename detection between deleted and added paths.
523    ///
524    /// # Errors
525    ///
526    /// Returns `HEAD` resolution, config, and object read errors.
527    pub fn staged_entries(&self, renames: RenameDetection) -> Result<Vec<DiffEntry>> {
528        self.staged_entries_for_paths(renames, None)
529    }
530
531    fn staged_entries_for_paths(
532        &self,
533        renames: RenameDetection,
534        restrict_to_paths: Option<Arc<BTreeSet<String>>>,
535    ) -> Result<Vec<DiffEntry>> {
536        staged_entries_in(
537            self.repo,
538            &self.index,
539            rename_threshold(renames),
540            restrict_to_paths.as_deref(),
541        )
542    }
543
544    /// Working tree against the index (`grit diff --unstaged`), sorted by path.
545    ///
546    /// Like Git's `diff-files`, a working-tree side that was not hashed carries a null
547    /// `new_oid`; a tracked file whose size changed is reported that way without reading it.
548    ///
549    /// # Parameters
550    ///
551    /// - `renames` - rename detection between deleted and added paths.
552    ///
553    /// # Errors
554    ///
555    /// Returns [`Error::RequiresWorkTree`] in a bare repository, plus worktree rule, I/O, and
556    /// object read errors.
557    pub fn unstaged_entries(&mut self, renames: RenameDetection) -> Result<Vec<DiffEntry>> {
558        self.unstaged_entries_for_paths(renames, None)
559    }
560
561    fn unstaged_entries_for_paths(
562        &mut self,
563        renames: RenameDetection,
564        restrict_to_paths: Option<Arc<BTreeSet<String>>>,
565    ) -> Result<Vec<DiffEntry>> {
566        let work_tree = self.work_tree()?;
567        let rules = self.rules()?;
568        unstaged_entries_in(
569            self.repo,
570            &mut self.index,
571            work_tree,
572            &rules,
573            rename_threshold(renames),
574            restrict_to_paths,
575        )
576    }
577
578    /// Working tree and index together against `base_tree` (`grit diff`, `grit diff --against`),
579    /// sorted by path.
580    ///
581    /// # Parameters
582    ///
583    /// - `base_tree` - tree to compare with; `None` for the empty tree (an unborn `HEAD`).
584    /// - `renames` - rename detection between deleted and added paths.
585    ///
586    /// # Errors
587    ///
588    /// Returns [`Error::RequiresWorkTree`] in a bare repository, plus worktree rule, I/O, and
589    /// object read errors.
590    pub fn worktree_entries(
591        &mut self,
592        base_tree: Option<&ObjectId>,
593        renames: RenameDetection,
594    ) -> Result<Vec<DiffEntry>> {
595        self.worktree_entries_for_paths(base_tree, renames, None)
596    }
597
598    fn worktree_entries_for_paths(
599        &mut self,
600        base_tree: Option<&ObjectId>,
601        renames: RenameDetection,
602        restrict_to_paths: Option<Arc<BTreeSet<String>>>,
603    ) -> Result<Vec<DiffEntry>> {
604        let work_tree = self.work_tree()?;
605        let rules = self.rules()?;
606        worktree_entries_in(
607            self.repo,
608            &mut self.index,
609            work_tree,
610            &rules,
611            base_tree,
612            rename_threshold(renames),
613            restrict_to_paths,
614        )
615    }
616
617    /// Changed paths for any [`DiffTarget`], limited to `pathspecs` and to the statuses in
618    /// `options.filter` (`--diff-filter`), sorted by path. No contents are loaded.
619    ///
620    /// [`DiffTarget::WorktreeVsHead`] compares with `HEAD`'s tree (the empty tree when unborn).
621    /// Commit and tree targets ignore the index, so prefer [`Diff::entries`](crate::diff::Diff::entries)
622    /// for them; it does not load one.
623    ///
624    /// # Parameters
625    ///
626    /// - `target` - what to compare, e.g. from [`resolve_diff_request`](crate::diff::resolve_diff_request).
627    /// - `pathspecs` - repository-relative pathspecs; empty means every path.
628    /// - `options` - only `renames` and `filter` are used.
629    ///
630    /// # Errors
631    ///
632    /// Returns [`Error::RequiresWorkTree`] for a working-tree target in a bare repository, plus
633    /// `HEAD` resolution, pathspec, worktree rule, I/O, and object read errors.
634    pub fn entries(
635        &mut self,
636        target: &DiffTarget,
637        pathspecs: &[String],
638        options: &DiffOptions,
639    ) -> Result<super::core::DiffEntriesResult> {
640        let renames = options.renames;
641        let result = match target {
642            DiffTarget::IndexVsHead => {
643                let head_tree = resolve_head_for_repository(self.repo)?.tree(self.repo.odb())?;
644                let restrict = pathspec_restrict_paths_for_scan(
645                    self.repo,
646                    &self.index,
647                    pathspecs,
648                    head_tree.as_ref(),
649                )?;
650                super::core::DiffEntriesResult {
651                    entries: self.staged_entries_for_paths(renames, restrict)?,
652                    inexact_rename_skipped: None,
653                }
654            }
655            DiffTarget::WorktreeVsIndex => {
656                let restrict =
657                    pathspec_restrict_paths_for_scan(self.repo, &self.index, pathspecs, None)?;
658                super::core::DiffEntriesResult {
659                    entries: self.unstaged_entries_for_paths(renames, restrict)?,
660                    inexact_rename_skipped: None,
661                }
662            }
663            DiffTarget::WorktreeVsHead => {
664                let head_tree = resolve_head_for_repository(self.repo)?.tree(self.repo.odb())?;
665                let restrict = pathspec_restrict_paths_for_scan(
666                    self.repo,
667                    &self.index,
668                    pathspecs,
669                    head_tree.as_ref(),
670                )?;
671                super::core::DiffEntriesResult {
672                    entries: self.worktree_entries_for_paths(
673                        head_tree.as_ref(),
674                        renames,
675                        restrict,
676                    )?,
677                    inexact_rename_skipped: None,
678                }
679            }
680            DiffTarget::WorktreeVsTree(tree) => {
681                let restrict = pathspec_restrict_paths_for_scan(
682                    self.repo,
683                    &self.index,
684                    pathspecs,
685                    Some(tree),
686                )?;
687                super::core::DiffEntriesResult {
688                    entries: self.worktree_entries_for_paths(Some(tree), renames, restrict)?,
689                    inexact_rename_skipped: None,
690                }
691            }
692            DiffTarget::Commit(_) | DiffTarget::Trees { .. } => {
693                stored_target_entries(self.repo, target, pathspecs, options)?.unwrap_or_default()
694            }
695        };
696        limit_target_entries(self.repo, result, pathspecs, options)
697    }
698
699    /// Changes on the other side of the index for the paths in `primary_paths`, which feed
700    /// [`hunk_ids_with_duplicate_peers`](crate::diff::hunk_ids_with_duplicate_peers) so hunk ids
701    /// stay stable when an identical hunk moves between the staged and unstaged sides.
702    ///
703    /// For [`DiffSide::Staged`] primaries this loads the unstaged changes on those paths; for
704    /// [`DiffSide::Unstaged`] the staged ones. [`DiffSide::Other`] has no peer side. A path
705    /// matches when it is the peer's display path or, for a rename, its old path. No
706    /// `--diff-filter` applies: ids must not depend on which statuses are shown.
707    ///
708    /// # Parameters
709    ///
710    /// - `side` - the side the primary changes came from.
711    /// - `pathspecs` - the pathspecs the primary changes were limited to.
712    /// - `renames` - rename detection, as for the primary changes.
713    /// - `primary_paths` - display paths of the primary changes.
714    ///
715    /// # Errors
716    ///
717    /// Returns [`Error::RequiresWorkTree`] for staged primaries in a bare repository, plus the
718    /// errors of [`Self::entries`] and of loading the changes.
719    pub fn peer_changes<'a>(
720        &mut self,
721        side: DiffSide,
722        pathspecs: &[String],
723        renames: RenameDetection,
724        primary_paths: impl IntoIterator<Item = &'a str>,
725    ) -> Result<Vec<FileChange>> {
726        let peer_target = match side {
727            DiffSide::Staged => DiffTarget::WorktreeVsIndex,
728            DiffSide::Unstaged => DiffTarget::IndexVsHead,
729            DiffSide::Other => return Ok(Vec::new()),
730        };
731        let primary: HashSet<&str> = primary_paths.into_iter().collect();
732        if primary.is_empty() {
733            return Ok(Vec::new());
734        }
735        let options = DiffOptions::default().with_renames(renames);
736        let entries: Vec<DiffEntry> = self
737            .entries(&peer_target, pathspecs, &options)?
738            .entries
739            .into_iter()
740            .filter(|entry| {
741                let display = entry
742                    .new_path
743                    .as_deref()
744                    .or(entry.old_path.as_deref())
745                    .unwrap_or("");
746                peer_scope_includes_path(display, entry.old_path.as_deref(), &primary)
747            })
748            .collect();
749        match side {
750            DiffSide::Staged => self.load_worktree_changes(entries),
751            DiffSide::Unstaged | DiffSide::Other => load_object_changes(self.repo, &entries),
752        }
753    }
754
755    /// Load entries from [`Self::unstaged_entries`] or [`Self::worktree_entries`]: old sides
756    /// from the object database, new sides from the working tree (with gitlinks showing the
757    /// submodule's checked-out commit and `-dirty` state).
758    ///
759    /// # Errors
760    ///
761    /// Returns [`Error::RequiresWorkTree`] in a bare repository, plus I/O, filter, and object
762    /// read errors.
763    pub fn load_worktree_changes(&mut self, entries: Vec<DiffEntry>) -> Result<Vec<FileChange>> {
764        let work_tree = self.work_tree()?;
765        let rules = self.rules()?;
766        let side = WorktreeSide {
767            work_tree,
768            index: &self.index,
769            rules: &rules,
770        };
771        load_changes(self.repo.odb(), entries, Some(&side))
772    }
773
774    fn work_tree(&self) -> Result<&'repo Path> {
775        self.repo.work_tree().ok_or(Error::RequiresWorkTree)
776    }
777
778    fn rules(&mut self) -> Result<Arc<Mutex<WorktreeRules>>> {
779        if let Some(rules) = &self.rules {
780            return Ok(Arc::clone(rules));
781        }
782        let rules = Arc::new(Mutex::new(WorktreeRules::from_repository(
783            self.repo,
784            &self.index,
785        )?));
786        self.rules = Some(Arc::clone(&rules));
787        Ok(rules)
788    }
789}
790
791#[cfg(test)]
792mod tests {
793    use super::*;
794    use crate::repo::InitOptions;
795
796    fn entry(status: DiffStatus, old: Option<&str>, new: Option<&str>) -> DiffEntry {
797        DiffEntry {
798            status,
799            old_path: old.map(str::to_owned),
800            new_path: new.map(str::to_owned),
801            old_mode: FileMode::REGULAR,
802            new_mode: FileMode::REGULAR,
803            old_oid: ObjectId::zero(),
804            new_oid: ObjectId::zero(),
805            score: Some(87),
806        }
807    }
808
809    #[test]
810    fn from_entry_keeps_old_path_only_for_real_renames() {
811        let bytes = || SideBytes::from_bytes(Vec::new());
812        let renamed = FileChange::from_entry(
813            entry(DiffStatus::Renamed, Some("a"), Some("b")),
814            bytes(),
815            bytes(),
816            false,
817        );
818        assert_eq!(renamed.path, "b");
819        assert_eq!(renamed.old_path.as_deref(), Some("a"));
820        assert_eq!(renamed.similarity, Some(87));
821
822        let deleted = FileChange::from_entry(
823            entry(DiffStatus::Deleted, Some("a"), None),
824            bytes(),
825            bytes(),
826            false,
827        );
828        assert_eq!(deleted.path, "a");
829        assert_eq!(deleted.old_path, None);
830        assert_eq!(deleted.similarity, None);
831    }
832
833    #[test]
834    fn binary_change_has_no_hunks_or_line_stat() {
835        let mut change = FileChange::from_entry(
836            entry(DiffStatus::Modified, Some("a"), Some("a")),
837            SideBytes::from_bytes(b"one\n".to_vec()),
838            SideBytes::from_bytes(b"two\n".to_vec()),
839            true,
840        );
841        assert!(change.structured(DiffOptions::default()).hunks.is_empty());
842        assert_eq!(change.line_stat(&DiffOptions::default()), None);
843
844        change.binary = false;
845        assert_eq!(change.structured(DiffOptions::default()).hunks.len(), 1);
846        assert_eq!(
847            change.line_stat(&DiffOptions::default()),
848            Some(LineStat {
849                insertions: 1,
850                deletions: 1,
851            })
852        );
853    }
854
855    #[test]
856    fn worktree_gitlink_line_reads_submodule_head_when_unhashed() {
857        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
858        let mut gitlink = entry(DiffStatus::Added, None, Some("missing-sub"));
859        gitlink.old_mode = FileMode::ABSENT;
860        gitlink.new_mode = FileMode::GITLINK;
861        assert_eq!(worktree_gitlink_line(&gitlink, dir.path()), "");
862
863        let oid = ObjectId::from_hex("1111111111111111111111111111111111111111")
864            .unwrap_or_else(|e| panic!("oid: {e}"));
865        gitlink.new_oid = oid;
866        assert_eq!(
867            worktree_gitlink_line(&gitlink, dir.path()),
868            format!("Subproject commit {}\n", oid.to_hex())
869        );
870    }
871
872    /// Repository whose index tracks `files`, staged from the working tree.
873    fn staged_repo(files: &[(&str, &[u8])]) -> (tempfile::TempDir, Repository) {
874        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
875        let repo = Repository::init(dir.path(), &InitOptions::default())
876            .unwrap_or_else(|e| panic!("init: {e}"));
877        for (path, bytes) in files {
878            std::fs::write(dir.path().join(path), bytes).unwrap_or_else(|e| panic!("write: {e}"));
879        }
880        crate::porcelain::add::stage(
881            &repo,
882            &crate::porcelain::add::StageOptions::default(),
883            &mut crate::progress::NullProgress,
884            &crate::progress::NeverCancel,
885        )
886        .unwrap_or_else(|e| panic!("stage: {e}"));
887        (dir, repo)
888    }
889
890    fn unstaged(repo: &Repository) -> Vec<DiffEntry> {
891        let index = repo
892            .index_handle()
893            .load()
894            .unwrap_or_else(|e| panic!("index: {e}"));
895        UncommittedDiff::new(repo, index)
896            .unstaged_entries(RenameDetection::Off)
897            .unwrap_or_else(|e| panic!("unstaged: {e}"))
898    }
899
900    #[test]
901    fn unstaged_size_change_is_reported_without_hashing() {
902        let (dir, repo) = staged_repo(&[("grown.txt", b"one\n"), ("same.txt", b"aaaa\n")]);
903        std::fs::write(dir.path().join("grown.txt"), b"one\ntwo\n")
904            .unwrap_or_else(|e| panic!("{e}"));
905        std::fs::write(dir.path().join("same.txt"), b"bbbb\n").unwrap_or_else(|e| panic!("{e}"));
906
907        let metrics = repo.odb().hot_path_metrics();
908        metrics.set_blob_counting(true);
909        metrics.reset_blob_content_reads();
910        let entries = unstaged(&repo);
911        // Only the same-size file is read (to hash it); the size change alone proves the other.
912        assert_eq!(metrics.blob_content_reads(), 1);
913        assert_eq!(entries.len(), 2);
914        let grown = &entries[0];
915        assert_eq!(grown.path(), "grown.txt");
916        assert_eq!(grown.status, DiffStatus::Modified);
917        assert_eq!(grown.new_oid, ObjectId::zero());
918        // A same-size edit is only reported once hashing proves the content changed.
919        let same = &entries[1];
920        assert_eq!(same.path(), "same.txt");
921        assert_eq!(same.status, DiffStatus::Modified);
922
923        let index = repo
924            .index_handle()
925            .load()
926            .unwrap_or_else(|e| panic!("index: {e}"));
927        let changes = UncommittedDiff::new(&repo, index)
928            .load_worktree_changes(entries)
929            .unwrap_or_else(|e| panic!("load: {e}"));
930        assert!(changes[0].content_changed);
931        assert_eq!(
932            changes[0].line_stat(&DiffOptions::default()),
933            Some(LineStat {
934                insertions: 1,
935                deletions: 0,
936            })
937        );
938    }
939
940    #[test]
941    fn binary_worktree_side_skips_reading_the_old_blob() {
942        let (dir, repo) = staged_repo(&[("data.bin", b"old text\n"), ("notes.txt", b"a\n")]);
943        std::fs::write(dir.path().join("data.bin"), b"new\0binary")
944            .unwrap_or_else(|e| panic!("{e}"));
945        std::fs::write(dir.path().join("notes.txt"), b"a\nb\n").unwrap_or_else(|e| panic!("{e}"));
946
947        let entries = unstaged(&repo);
948        let index = repo
949            .index_handle()
950            .load()
951            .unwrap_or_else(|e| panic!("index: {e}"));
952        let mut diff = UncommittedDiff::new(&repo, index);
953        let metrics = repo.odb().hot_path_metrics();
954        metrics.set_blob_counting(true);
955        metrics.reset_blob_content_reads();
956        let binary = diff
957            .load_worktree_changes(vec![entries[0].clone()])
958            .unwrap_or_else(|e| panic!("load: {e}"));
959        assert_eq!(binary[0].path, "data.bin");
960        assert!(binary[0].binary);
961        assert!(binary[0].content_changed);
962        // Only the working-tree file was read.
963        assert_eq!(metrics.blob_content_reads(), 1);
964
965        metrics.reset_blob_content_reads();
966        let text = diff
967            .load_worktree_changes(vec![entries[1].clone()])
968            .unwrap_or_else(|e| panic!("load: {e}"));
969        assert!(!text[0].binary);
970        assert_eq!(text[0].old_bytes.text(), "a\n");
971        assert_eq!(metrics.blob_content_reads(), 2);
972    }
973
974    #[test]
975    fn bare_repository_worktree_methods_require_a_work_tree() {
976        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
977        let init = InitOptions {
978            bare: true,
979            ..InitOptions::default()
980        };
981        let repo = Repository::init(dir.path(), &init).unwrap_or_else(|e| panic!("init: {e}"));
982        let mut diff = UncommittedDiff::new(&repo, Index::default());
983        assert!(diff
984            .staged_entries(RenameDetection::Off)
985            .is_ok_and(|e| e.is_empty()));
986        assert!(matches!(
987            diff.unstaged_entries(RenameDetection::Off),
988            Err(Error::RequiresWorkTree)
989        ));
990        assert!(matches!(
991            diff.worktree_entries(None, RenameDetection::Off),
992            Err(Error::RequiresWorkTree)
993        ));
994        assert!(matches!(
995            diff.load_worktree_changes(Vec::new()),
996            Err(Error::RequiresWorkTree)
997        ));
998    }
999}