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, None)?,
191    };
192    let (old_bytes, old_binary) = if new_binary {
193        (SideBytes::from_bytes(Vec::new()), false)
194    } else {
195        load_old_side(odb, &entry, None)?
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 load_worktree_new_side(
218    odb: &Odb,
219    entry: &DiffEntry,
220    side: &WorktreeSide<'_>,
221) -> Result<(SideBytes, bool)> {
222    if entry.new_mode == FileMode::GITLINK {
223        let line = worktree_gitlink_line(entry, side.work_tree);
224        return Ok((SideBytes::from_bytes(line.into_bytes()), false));
225    }
226    if entry.new_mode == FileMode::ABSENT {
227        return Ok((SideBytes::from_bytes(Vec::new()), false));
228    }
229    let Some(path) = entry.new_path.as_deref().or(entry.old_path.as_deref()) else {
230        return load_new_side(odb, entry, None);
231    };
232    // Working-tree entries often carry only a hash of the normalized file, and the blob is not
233    // in the object database until it is staged, so always read the live file.
234    let index_entry = side.index.get(&crate::repo_relpath::storage_bytes(path), 0);
235    let rules = side.rules.lock().unwrap_or_else(|e| e.into_inner());
236    load_worktree_side_for_diff(
237        odb,
238        side.work_tree,
239        path,
240        entry.new_mode.bits(),
241        rules.config(),
242        index_entry,
243        Some(&rules),
244        false,
245    )
246}
247
248/// The submodule's checked-out commit, with `-dirty` when its tracked content is modified.
249fn worktree_gitlink_line(entry: &DiffEntry, work_tree: &Path) -> String {
250    let path = entry.new_path.as_deref().or(entry.old_path.as_deref());
251    let head = if entry.new_oid != ObjectId::zero() {
252        Some(entry.new_oid)
253    } else {
254        path.and_then(|p| read_submodule_head_oid(&work_tree.join(p)))
255    };
256    let Some(head) = head.filter(|oid| *oid != ObjectId::zero()) else {
257        return String::new();
258    };
259    let dirty = entry.old_mode == FileMode::GITLINK
260        && path.is_some_and(|p| submodule_porcelain_flags(work_tree, p, entry.old_oid).modified);
261    if dirty {
262        format!("Subproject commit {}-dirty\n", head.to_hex())
263    } else {
264        gitlink_line(&head)
265    }
266}
267
268/// Paths to scan before a worktree or index diff when `pathspecs` limit the operation.
269///
270/// Includes index entries selected by the pathspecs plus blob paths in `base_tree` that match,
271/// so a staged deletion (absent from the index but present in `HEAD`) is not dropped.
272pub(crate) fn pathspec_restrict_paths_for_scan(
273    repo: &Repository,
274    index: &Index,
275    pathspecs: &[String],
276    base_tree: Option<&ObjectId>,
277) -> Result<Option<Arc<BTreeSet<String>>>> {
278    if pathspecs.is_empty() {
279        return Ok(None);
280    }
281    let base_ctx = repo.pathspec_match_context();
282    let mut paths = crate::porcelain::add::restricted_index_paths_for_pathspecs(
283        repo, index, pathspecs, base_ctx,
284    )?;
285    if let Some(tree) = base_tree {
286        let attr_matcher = if pathspecs
287            .iter()
288            .any(|s| crate::pathspec::pathspec_has_attr_magic(s))
289        {
290            Some(repo.pathspec_matcher(pathspecs)?)
291        } else {
292            None
293        };
294        for (path, mode, _) in super::flatten_tree_entries(repo.odb(), tree)? {
295            if crate::porcelain::add::blob_path_matches_restrict_pathspecs(
296                &path,
297                mode.bits(),
298                pathspecs,
299                base_ctx,
300                attr_matcher.as_ref(),
301            )? {
302                paths.insert(path);
303            }
304        }
305    }
306    Ok(Some(Arc::new(paths)))
307}
308
309fn rename_threshold(renames: RenameDetection) -> u32 {
310    renames
311        .threshold_percent()
312        .unwrap_or(RENAME_DETECTION_DISABLED)
313}
314
315/// Index against `HEAD`'s tree, sorted by path.
316///
317/// A non-empty `restrict_to_paths` compares only those exact paths, without rename detection.
318pub(crate) fn staged_entries_in(
319    repo: &Repository,
320    index: &Index,
321    rename_threshold: u32,
322    restrict_to_paths: Option<&BTreeSet<String>>,
323) -> Result<Vec<DiffEntry>> {
324    let head_tree = resolve_head_for_repository(repo)?.tree(repo.odb())?;
325    let mut entries = match restrict_to_paths.filter(|paths| !paths.is_empty()) {
326        Some(paths) => diff_index_to_tree_for_paths(repo, index, head_tree.as_ref(), paths)?,
327        None => {
328            let config = repo.config()?;
329            diff_index_to_tree_with_renames(
330                repo.odb(),
331                index,
332                head_tree.as_ref(),
333                false,
334                rename_threshold,
335                config.as_ref(),
336                &repo.thread_policy(),
337            )?
338        }
339    };
340    sort_entries_by_path(&mut entries);
341    Ok(entries)
342}
343
344/// Working tree against `index`, sorted by path.
345///
346/// `restrict_to_paths` limits the scan to those paths and skips the fsmonitor refresh. A file
347/// whose size changed (under an identity clean conversion) is reported without being hashed,
348/// with a null `new_oid`; loading its side reads the live file anyway.
349pub(crate) fn unstaged_entries_in(
350    repo: &Repository,
351    index: &mut Index,
352    work_tree: &Path,
353    rules: &Arc<Mutex<WorktreeRules>>,
354    rename_threshold: u32,
355    restrict_to_paths: Option<Arc<BTreeSet<String>>>,
356) -> Result<Vec<DiffEntry>> {
357    let config = rules.lock().unwrap_or_else(|e| e.into_inner()).config_arc();
358    let refresh_fsmonitor = restrict_to_paths.is_none() && fsmonitor_enabled(config.as_ref());
359    let options = DiffIndexToWorktreeOptions {
360        repository_git_dir: Some(repo.git_dir().to_path_buf()),
361        index_mtime: index_mtime_for_diff(index, None),
362        config: Some(config),
363        thread_policy: Some(repo.thread_policy()),
364        refresh_index_stat_in_pass: false,
365        size_change_proves_modified: true,
366        refresh_fsmonitor,
367        worktree_rules: Some(Arc::clone(rules)),
368        command_runner: Some(repo.command_runner()),
369        restrict_to_paths,
370        skip_inaccessible_paths: true,
371        diagnostics: Some(repo.diagnostics()),
372        ..DiffIndexToWorktreeOptions::default()
373    };
374    let (mut entries, _) =
375        diff_index_to_worktree_with_renames(repo, index, work_tree, options, rename_threshold)?;
376    sort_entries_by_path(&mut entries);
377    Ok(entries)
378}
379
380/// Working tree (through `index`) against `base_tree`, sorted by path.
381///
382/// When the index's cache tree matches `base_tree`, this is the cheaper index-against-worktree
383/// scan; otherwise the tree is walked against the working tree directly.
384pub(crate) fn worktree_entries_in(
385    repo: &Repository,
386    index: &mut Index,
387    work_tree: &Path,
388    rules: &Arc<Mutex<WorktreeRules>>,
389    base_tree: Option<&ObjectId>,
390    rename_threshold: u32,
391    restrict_to_paths: Option<Arc<BTreeSet<String>>>,
392) -> Result<Vec<DiffEntry>> {
393    let index_matches_base = match (base_tree, index.cache_tree_root.as_ref()) {
394        (Some(base), Some(cached)) => base == cached,
395        (None, None) => true,
396        _ => false,
397    };
398    if index_matches_base && cache_tree_extension_valid(index.cache_tree.as_ref()) {
399        return unstaged_entries_in(
400            repo,
401            index,
402            work_tree,
403            rules,
404            rename_threshold,
405            restrict_to_paths,
406        );
407    }
408    let config = repo.config()?;
409    let raw = {
410        let rules = rules.lock().unwrap_or_else(|e| e.into_inner());
411        diff_tree_to_worktree_with_git_dir_and_rules_extras(
412            repo.odb(),
413            base_tree,
414            work_tree,
415            repo.git_dir(),
416            index,
417            Some(config.as_ref()),
418            Some(&rules),
419            DiffTreeToWorktreeExtras {
420                skip_inaccessible_paths: true,
421                diagnostics: Some(repo.diagnostics()),
422                restrict_to_paths: restrict_to_paths.clone(),
423            },
424        )?
425    };
426    let mut entries = apply_rename_detection_to_diff_entries(
427        repo.odb(),
428        raw,
429        Some(work_tree),
430        rename_threshold,
431        config.as_ref(),
432        &repo.thread_policy(),
433    )?;
434    sort_entries_by_path(&mut entries);
435    Ok(entries)
436}
437
438/// Staged, unstaged, and working-tree diffs over one loaded index.
439///
440/// Every method reuses the same [`Index`] and, once a working-tree method needs them, the same
441/// [`WorktreeRules`], so collecting entries and then loading their contents reads neither twice.
442/// Working-tree scans may refresh stat data on the in-memory index; nothing is written to disk.
443///
444/// Obtain one from [`Diff::uncommitted`](crate::diff::Diff::uncommitted) or
445/// [`UncommittedDiff::new`].
446pub struct UncommittedDiff<'repo> {
447    repo: &'repo Repository,
448    index: Index,
449    rules: Option<Arc<Mutex<WorktreeRules>>>,
450}
451
452impl<'repo> UncommittedDiff<'repo> {
453    /// Diff `repo` against an index the caller already loaded.
454    ///
455    /// # Parameters
456    ///
457    /// - `repo` - repository to diff.
458    /// - `index` - its index (normally the on-disk index).
459    #[must_use]
460    pub fn new(repo: &'repo Repository, index: Index) -> Self {
461        Self {
462            repo,
463            index,
464            rules: None,
465        }
466    }
467
468    /// The index every comparison uses.
469    #[must_use]
470    pub fn index(&self) -> &Index {
471        &self.index
472    }
473
474    /// Index against `HEAD`'s tree (`grit diff --staged`), sorted by path.
475    ///
476    /// An unborn `HEAD` compares against the empty tree, so every index entry is added.
477    ///
478    /// # Parameters
479    ///
480    /// - `renames` - rename detection between deleted and added paths.
481    ///
482    /// # Errors
483    ///
484    /// Returns `HEAD` resolution, config, and object read errors.
485    pub fn staged_entries(&self, renames: RenameDetection) -> Result<Vec<DiffEntry>> {
486        self.staged_entries_for_paths(renames, None)
487    }
488
489    fn staged_entries_for_paths(
490        &self,
491        renames: RenameDetection,
492        restrict_to_paths: Option<Arc<BTreeSet<String>>>,
493    ) -> Result<Vec<DiffEntry>> {
494        staged_entries_in(
495            self.repo,
496            &self.index,
497            rename_threshold(renames),
498            restrict_to_paths.as_deref(),
499        )
500    }
501
502    /// Working tree against the index (`grit diff --unstaged`), sorted by path.
503    ///
504    /// Like Git's `diff-files`, a working-tree side that was not hashed carries a null
505    /// `new_oid`; a tracked file whose size changed is reported that way without reading it.
506    ///
507    /// # Parameters
508    ///
509    /// - `renames` - rename detection between deleted and added paths.
510    ///
511    /// # Errors
512    ///
513    /// Returns [`Error::RequiresWorkTree`] in a bare repository, plus worktree rule, I/O, and
514    /// object read errors.
515    pub fn unstaged_entries(&mut self, renames: RenameDetection) -> Result<Vec<DiffEntry>> {
516        self.unstaged_entries_for_paths(renames, None)
517    }
518
519    fn unstaged_entries_for_paths(
520        &mut self,
521        renames: RenameDetection,
522        restrict_to_paths: Option<Arc<BTreeSet<String>>>,
523    ) -> Result<Vec<DiffEntry>> {
524        let work_tree = self.work_tree()?;
525        let rules = self.rules()?;
526        unstaged_entries_in(
527            self.repo,
528            &mut self.index,
529            work_tree,
530            &rules,
531            rename_threshold(renames),
532            restrict_to_paths,
533        )
534    }
535
536    /// Working tree and index together against `base_tree` (`grit diff`, `grit diff --against`),
537    /// sorted by path.
538    ///
539    /// # Parameters
540    ///
541    /// - `base_tree` - tree to compare with; `None` for the empty tree (an unborn `HEAD`).
542    /// - `renames` - rename detection between deleted and added paths.
543    ///
544    /// # Errors
545    ///
546    /// Returns [`Error::RequiresWorkTree`] in a bare repository, plus worktree rule, I/O, and
547    /// object read errors.
548    pub fn worktree_entries(
549        &mut self,
550        base_tree: Option<&ObjectId>,
551        renames: RenameDetection,
552    ) -> Result<Vec<DiffEntry>> {
553        self.worktree_entries_for_paths(base_tree, renames, None)
554    }
555
556    fn worktree_entries_for_paths(
557        &mut self,
558        base_tree: Option<&ObjectId>,
559        renames: RenameDetection,
560        restrict_to_paths: Option<Arc<BTreeSet<String>>>,
561    ) -> Result<Vec<DiffEntry>> {
562        let work_tree = self.work_tree()?;
563        let rules = self.rules()?;
564        worktree_entries_in(
565            self.repo,
566            &mut self.index,
567            work_tree,
568            &rules,
569            base_tree,
570            rename_threshold(renames),
571            restrict_to_paths,
572        )
573    }
574
575    /// Changed paths for any [`DiffTarget`], limited to `pathspecs` and to the statuses in
576    /// `options.filter` (`--diff-filter`), sorted by path. No contents are loaded.
577    ///
578    /// [`DiffTarget::WorktreeVsHead`] compares with `HEAD`'s tree (the empty tree when unborn).
579    /// Commit and tree targets ignore the index, so prefer [`Diff::entries`](crate::diff::Diff::entries)
580    /// for them; it does not load one.
581    ///
582    /// # Parameters
583    ///
584    /// - `target` - what to compare, e.g. from [`resolve_diff_request`](crate::diff::resolve_diff_request).
585    /// - `pathspecs` - repository-relative pathspecs; empty means every path.
586    /// - `options` - only `renames` and `filter` are used.
587    ///
588    /// # Errors
589    ///
590    /// Returns [`Error::RequiresWorkTree`] for a working-tree target in a bare repository, plus
591    /// `HEAD` resolution, pathspec, worktree rule, I/O, and object read errors.
592    pub fn entries(
593        &mut self,
594        target: &DiffTarget,
595        pathspecs: &[String],
596        options: &DiffOptions,
597    ) -> Result<super::core::DiffEntriesResult> {
598        let renames = options.renames;
599        let result = match target {
600            DiffTarget::IndexVsHead => {
601                let head_tree = resolve_head_for_repository(self.repo)?.tree(self.repo.odb())?;
602                let restrict = pathspec_restrict_paths_for_scan(
603                    self.repo,
604                    &self.index,
605                    pathspecs,
606                    head_tree.as_ref(),
607                )?;
608                super::core::DiffEntriesResult {
609                    entries: self.staged_entries_for_paths(renames, restrict)?,
610                    inexact_rename_skipped: None,
611                }
612            }
613            DiffTarget::WorktreeVsIndex => {
614                let restrict =
615                    pathspec_restrict_paths_for_scan(self.repo, &self.index, pathspecs, None)?;
616                super::core::DiffEntriesResult {
617                    entries: self.unstaged_entries_for_paths(renames, restrict)?,
618                    inexact_rename_skipped: None,
619                }
620            }
621            DiffTarget::WorktreeVsHead => {
622                let head_tree = resolve_head_for_repository(self.repo)?.tree(self.repo.odb())?;
623                let restrict = pathspec_restrict_paths_for_scan(
624                    self.repo,
625                    &self.index,
626                    pathspecs,
627                    head_tree.as_ref(),
628                )?;
629                super::core::DiffEntriesResult {
630                    entries: self.worktree_entries_for_paths(
631                        head_tree.as_ref(),
632                        renames,
633                        restrict,
634                    )?,
635                    inexact_rename_skipped: None,
636                }
637            }
638            DiffTarget::WorktreeVsTree(tree) => {
639                let restrict = pathspec_restrict_paths_for_scan(
640                    self.repo,
641                    &self.index,
642                    pathspecs,
643                    Some(tree),
644                )?;
645                super::core::DiffEntriesResult {
646                    entries: self.worktree_entries_for_paths(Some(tree), renames, restrict)?,
647                    inexact_rename_skipped: None,
648                }
649            }
650            DiffTarget::Commit(_) | DiffTarget::Trees { .. } => {
651                stored_target_entries(self.repo, target, pathspecs, options)?.unwrap_or_default()
652            }
653        };
654        limit_target_entries(self.repo, result, pathspecs, options)
655    }
656
657    /// Changes on the other side of the index for the paths in `primary_paths`, which feed
658    /// [`hunk_ids_with_duplicate_peers`](crate::diff::hunk_ids_with_duplicate_peers) so hunk ids
659    /// stay stable when an identical hunk moves between the staged and unstaged sides.
660    ///
661    /// For [`DiffSide::Staged`] primaries this loads the unstaged changes on those paths; for
662    /// [`DiffSide::Unstaged`] the staged ones. [`DiffSide::Other`] has no peer side. A path
663    /// matches when it is the peer's display path or, for a rename, its old path. No
664    /// `--diff-filter` applies: ids must not depend on which statuses are shown.
665    ///
666    /// # Parameters
667    ///
668    /// - `side` - the side the primary changes came from.
669    /// - `pathspecs` - the pathspecs the primary changes were limited to.
670    /// - `renames` - rename detection, as for the primary changes.
671    /// - `primary_paths` - display paths of the primary changes.
672    ///
673    /// # Errors
674    ///
675    /// Returns [`Error::RequiresWorkTree`] for staged primaries in a bare repository, plus the
676    /// errors of [`Self::entries`] and of loading the changes.
677    pub fn peer_changes<'a>(
678        &mut self,
679        side: DiffSide,
680        pathspecs: &[String],
681        renames: RenameDetection,
682        primary_paths: impl IntoIterator<Item = &'a str>,
683    ) -> Result<Vec<FileChange>> {
684        let peer_target = match side {
685            DiffSide::Staged => DiffTarget::WorktreeVsIndex,
686            DiffSide::Unstaged => DiffTarget::IndexVsHead,
687            DiffSide::Other => return Ok(Vec::new()),
688        };
689        let primary: HashSet<&str> = primary_paths.into_iter().collect();
690        if primary.is_empty() {
691            return Ok(Vec::new());
692        }
693        let options = DiffOptions::default().with_renames(renames);
694        let entries: Vec<DiffEntry> = self
695            .entries(&peer_target, pathspecs, &options)?
696            .entries
697            .into_iter()
698            .filter(|entry| {
699                let display = entry
700                    .new_path
701                    .as_deref()
702                    .or(entry.old_path.as_deref())
703                    .unwrap_or("");
704                peer_scope_includes_path(display, entry.old_path.as_deref(), &primary)
705            })
706            .collect();
707        match side {
708            DiffSide::Staged => self.load_worktree_changes(entries),
709            DiffSide::Unstaged | DiffSide::Other => load_object_changes(self.repo, &entries),
710        }
711    }
712
713    /// Load entries from [`Self::unstaged_entries`] or [`Self::worktree_entries`]: old sides
714    /// from the object database, new sides from the working tree (with gitlinks showing the
715    /// submodule's checked-out commit and `-dirty` state).
716    ///
717    /// # Errors
718    ///
719    /// Returns [`Error::RequiresWorkTree`] in a bare repository, plus I/O, filter, and object
720    /// read errors.
721    pub fn load_worktree_changes(&mut self, entries: Vec<DiffEntry>) -> Result<Vec<FileChange>> {
722        let work_tree = self.work_tree()?;
723        let rules = self.rules()?;
724        let side = WorktreeSide {
725            work_tree,
726            index: &self.index,
727            rules: &rules,
728        };
729        load_changes(self.repo.odb(), entries, Some(&side))
730    }
731
732    fn work_tree(&self) -> Result<&'repo Path> {
733        self.repo.work_tree().ok_or(Error::RequiresWorkTree)
734    }
735
736    fn rules(&mut self) -> Result<Arc<Mutex<WorktreeRules>>> {
737        if let Some(rules) = &self.rules {
738            return Ok(Arc::clone(rules));
739        }
740        let rules = Arc::new(Mutex::new(WorktreeRules::from_repository(
741            self.repo,
742            &self.index,
743        )?));
744        self.rules = Some(Arc::clone(&rules));
745        Ok(rules)
746    }
747}
748
749#[cfg(test)]
750mod tests {
751    use super::*;
752    use crate::repo::InitOptions;
753
754    fn entry(status: DiffStatus, old: Option<&str>, new: Option<&str>) -> DiffEntry {
755        DiffEntry {
756            status,
757            old_path: old.map(str::to_owned),
758            new_path: new.map(str::to_owned),
759            old_mode: FileMode::REGULAR,
760            new_mode: FileMode::REGULAR,
761            old_oid: ObjectId::zero(),
762            new_oid: ObjectId::zero(),
763            score: Some(87),
764        }
765    }
766
767    #[test]
768    fn from_entry_keeps_old_path_only_for_real_renames() {
769        let bytes = || SideBytes::from_bytes(Vec::new());
770        let renamed = FileChange::from_entry(
771            entry(DiffStatus::Renamed, Some("a"), Some("b")),
772            bytes(),
773            bytes(),
774            false,
775        );
776        assert_eq!(renamed.path, "b");
777        assert_eq!(renamed.old_path.as_deref(), Some("a"));
778        assert_eq!(renamed.similarity, Some(87));
779
780        let deleted = FileChange::from_entry(
781            entry(DiffStatus::Deleted, Some("a"), None),
782            bytes(),
783            bytes(),
784            false,
785        );
786        assert_eq!(deleted.path, "a");
787        assert_eq!(deleted.old_path, None);
788        assert_eq!(deleted.similarity, None);
789    }
790
791    #[test]
792    fn binary_change_has_no_hunks_or_line_stat() {
793        let mut change = FileChange::from_entry(
794            entry(DiffStatus::Modified, Some("a"), Some("a")),
795            SideBytes::from_bytes(b"one\n".to_vec()),
796            SideBytes::from_bytes(b"two\n".to_vec()),
797            true,
798        );
799        assert!(change.structured(DiffOptions::default()).hunks.is_empty());
800        assert_eq!(change.line_stat(&DiffOptions::default()), None);
801
802        change.binary = false;
803        assert_eq!(change.structured(DiffOptions::default()).hunks.len(), 1);
804        assert_eq!(
805            change.line_stat(&DiffOptions::default()),
806            Some(LineStat {
807                insertions: 1,
808                deletions: 1,
809            })
810        );
811    }
812
813    #[test]
814    fn worktree_gitlink_line_reads_submodule_head_when_unhashed() {
815        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
816        let mut gitlink = entry(DiffStatus::Added, None, Some("missing-sub"));
817        gitlink.old_mode = FileMode::ABSENT;
818        gitlink.new_mode = FileMode::GITLINK;
819        assert_eq!(worktree_gitlink_line(&gitlink, dir.path()), "");
820
821        let oid = ObjectId::from_hex("1111111111111111111111111111111111111111")
822            .unwrap_or_else(|e| panic!("oid: {e}"));
823        gitlink.new_oid = oid;
824        assert_eq!(
825            worktree_gitlink_line(&gitlink, dir.path()),
826            format!("Subproject commit {}\n", oid.to_hex())
827        );
828    }
829
830    /// Repository whose index tracks `files`, staged from the working tree.
831    fn staged_repo(files: &[(&str, &[u8])]) -> (tempfile::TempDir, Repository) {
832        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
833        let repo = Repository::init(dir.path(), &InitOptions::default())
834            .unwrap_or_else(|e| panic!("init: {e}"));
835        for (path, bytes) in files {
836            std::fs::write(dir.path().join(path), bytes).unwrap_or_else(|e| panic!("write: {e}"));
837        }
838        crate::porcelain::add::stage(
839            &repo,
840            &crate::porcelain::add::StageOptions::default(),
841            &mut crate::progress::NullProgress,
842            &crate::progress::NeverCancel,
843        )
844        .unwrap_or_else(|e| panic!("stage: {e}"));
845        (dir, repo)
846    }
847
848    fn unstaged(repo: &Repository) -> Vec<DiffEntry> {
849        let index = repo
850            .index_handle()
851            .load()
852            .unwrap_or_else(|e| panic!("index: {e}"));
853        UncommittedDiff::new(repo, index)
854            .unstaged_entries(RenameDetection::Off)
855            .unwrap_or_else(|e| panic!("unstaged: {e}"))
856    }
857
858    #[test]
859    fn unstaged_size_change_is_reported_without_hashing() {
860        let (dir, repo) = staged_repo(&[("grown.txt", b"one\n"), ("same.txt", b"aaaa\n")]);
861        std::fs::write(dir.path().join("grown.txt"), b"one\ntwo\n")
862            .unwrap_or_else(|e| panic!("{e}"));
863        std::fs::write(dir.path().join("same.txt"), b"bbbb\n").unwrap_or_else(|e| panic!("{e}"));
864
865        let metrics = repo.odb().hot_path_metrics();
866        metrics.set_blob_counting(true);
867        metrics.reset_blob_content_reads();
868        let entries = unstaged(&repo);
869        // Only the same-size file is read (to hash it); the size change alone proves the other.
870        assert_eq!(metrics.blob_content_reads(), 1);
871        assert_eq!(entries.len(), 2);
872        let grown = &entries[0];
873        assert_eq!(grown.path(), "grown.txt");
874        assert_eq!(grown.status, DiffStatus::Modified);
875        assert_eq!(grown.new_oid, ObjectId::zero());
876        // A same-size edit is only reported once hashing proves the content changed.
877        let same = &entries[1];
878        assert_eq!(same.path(), "same.txt");
879        assert_eq!(same.status, DiffStatus::Modified);
880
881        let index = repo
882            .index_handle()
883            .load()
884            .unwrap_or_else(|e| panic!("index: {e}"));
885        let changes = UncommittedDiff::new(&repo, index)
886            .load_worktree_changes(entries)
887            .unwrap_or_else(|e| panic!("load: {e}"));
888        assert!(changes[0].content_changed);
889        assert_eq!(
890            changes[0].line_stat(&DiffOptions::default()),
891            Some(LineStat {
892                insertions: 1,
893                deletions: 0,
894            })
895        );
896    }
897
898    #[test]
899    fn binary_worktree_side_skips_reading_the_old_blob() {
900        let (dir, repo) = staged_repo(&[("data.bin", b"old text\n"), ("notes.txt", b"a\n")]);
901        std::fs::write(dir.path().join("data.bin"), b"new\0binary")
902            .unwrap_or_else(|e| panic!("{e}"));
903        std::fs::write(dir.path().join("notes.txt"), b"a\nb\n").unwrap_or_else(|e| panic!("{e}"));
904
905        let entries = unstaged(&repo);
906        let index = repo
907            .index_handle()
908            .load()
909            .unwrap_or_else(|e| panic!("index: {e}"));
910        let mut diff = UncommittedDiff::new(&repo, index);
911        let metrics = repo.odb().hot_path_metrics();
912        metrics.set_blob_counting(true);
913        metrics.reset_blob_content_reads();
914        let binary = diff
915            .load_worktree_changes(vec![entries[0].clone()])
916            .unwrap_or_else(|e| panic!("load: {e}"));
917        assert_eq!(binary[0].path, "data.bin");
918        assert!(binary[0].binary);
919        assert!(binary[0].content_changed);
920        // Only the working-tree file was read.
921        assert_eq!(metrics.blob_content_reads(), 1);
922
923        metrics.reset_blob_content_reads();
924        let text = diff
925            .load_worktree_changes(vec![entries[1].clone()])
926            .unwrap_or_else(|e| panic!("load: {e}"));
927        assert!(!text[0].binary);
928        assert_eq!(text[0].old_bytes.text(), "a\n");
929        assert_eq!(metrics.blob_content_reads(), 2);
930    }
931
932    #[test]
933    fn bare_repository_worktree_methods_require_a_work_tree() {
934        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
935        let init = InitOptions {
936            bare: true,
937            ..InitOptions::default()
938        };
939        let repo = Repository::init(dir.path(), &init).unwrap_or_else(|e| panic!("init: {e}"));
940        let mut diff = UncommittedDiff::new(&repo, Index::default());
941        assert!(diff
942            .staged_entries(RenameDetection::Off)
943            .is_ok_and(|e| e.is_empty()));
944        assert!(matches!(
945            diff.unstaged_entries(RenameDetection::Off),
946            Err(Error::RequiresWorkTree)
947        ));
948        assert!(matches!(
949            diff.worktree_entries(None, RenameDetection::Off),
950            Err(Error::RequiresWorkTree)
951        ));
952        assert!(matches!(
953            diff.load_worktree_changes(Vec::new()),
954            Err(Error::RequiresWorkTree)
955        ));
956    }
957}