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::load_worktree_changes`] and
6//! [`load_object_changes`] then turn entries into [`FileChange`]s whose bytes feed structured
7//! hunks ([`FileChange::structured`]) or line counts ([`FileChange::line_stat`]).
8
9use std::collections::BTreeSet;
10use std::path::Path;
11use std::sync::{Arc, Mutex};
12
13use crate::diff::structured::{structured_file_diff, SideBytes, StructuredFileDiff};
14use crate::diff::targets::{commit_tree, diff_index_to_tree_for_paths};
15use crate::diff::tree_content::{gitlink_line, load_new_side, load_old_side};
16use crate::diff::{
17    apply_rename_detection_to_diff_entries, diff_index_to_tree_with_renames,
18    diff_index_to_worktree_with_renames, diff_tree_to_worktree_with_git_dir_and_rules,
19    index_mtime_for_diff, line_change_counts, load_worktree_side_for_diff, read_submodule_head_oid,
20    sort_entries_by_path, submodule_porcelain_flags, DiffEntry, DiffIndexToWorktreeOptions,
21    DiffOptions, DiffStatus, LineStat, RenameDetection, RENAME_DETECTION_DISABLED,
22};
23use crate::error::{Error, Result};
24use crate::index::write_tree::cache_tree_extension_valid;
25use crate::index::Index;
26use crate::objects::odb::Odb;
27use crate::objects::{FileMode, ObjectId};
28use crate::parallel::{try_par_map, ParallelHashError, ParallelOp};
29use crate::repo::Repository;
30use crate::state::resolve_head;
31use crate::worktree::fsmonitor::fsmonitor_enabled;
32use crate::worktree::worktree_rules::WorktreeRules;
33
34/// One changed path with both sides' bytes loaded for diffing.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct FileChange {
37    /// Display path (new path, or old path for a deletion).
38    pub path: String,
39    /// Pre-rename path, only when it differs from [`Self::path`].
40    pub old_path: Option<String>,
41    /// Change kind for this path.
42    pub status: DiffStatus,
43    /// Old side mode ([`FileMode::ABSENT`] when the path is added).
44    pub old_mode: FileMode,
45    /// New side mode ([`FileMode::ABSENT`] when the path is deleted).
46    pub new_mode: FileMode,
47    /// Old side object id (zero when absent).
48    pub old_oid: ObjectId,
49    /// New side object id (zero when absent or not yet hashed from the working tree).
50    pub new_oid: ObjectId,
51    /// Old side contents (a `Subproject commit` line for gitlinks).
52    pub old_bytes: SideBytes,
53    /// New side contents, read from the working tree for working-tree changes.
54    pub new_bytes: SideBytes,
55    /// Either side is treated as binary; binary sides may not carry their bytes.
56    pub binary: bool,
57    /// The object ids differ (false for a mode-only change).
58    pub content_changed: bool,
59    /// Rename or copy similarity (0–100), when applicable.
60    pub similarity: Option<u32>,
61}
62
63impl FileChange {
64    fn from_entry(
65        entry: DiffEntry,
66        old_bytes: SideBytes,
67        new_bytes: SideBytes,
68        binary: bool,
69    ) -> Self {
70        let path = entry
71            .new_path
72            .clone()
73            .or_else(|| entry.old_path.clone())
74            .unwrap_or_default();
75        let similarity = entry.rename_similarity();
76        // A deletion's display path is its old path; only a real rename keeps `old_path`.
77        let old_path = entry.old_path.filter(|old| *old != path);
78        Self {
79            path,
80            old_path,
81            status: entry.status,
82            old_mode: entry.old_mode,
83            new_mode: entry.new_mode,
84            old_oid: entry.old_oid,
85            new_oid: entry.new_oid,
86            old_bytes,
87            new_bytes,
88            binary,
89            content_changed: entry.old_oid != entry.new_oid,
90            similarity,
91        }
92    }
93
94    /// Structured line hunks and mode metadata for this change.
95    ///
96    /// Binary changes get mode metadata and no hunks.
97    ///
98    /// # Parameters
99    ///
100    /// - `options` - line-diff settings (context, algorithm, whitespace handling).
101    #[must_use]
102    pub fn structured(&self, options: DiffOptions) -> StructuredFileDiff {
103        if self.binary {
104            return StructuredFileDiff {
105                old_mode: self.old_mode.octal_if_present(),
106                new_mode: self.new_mode.octal_if_present(),
107                old_encoding_lossy: self.old_bytes.encoding_lossy,
108                new_encoding_lossy: self.new_bytes.encoding_lossy,
109                hunks: Vec::new(),
110            };
111        }
112        structured_file_diff(
113            self.old_mode,
114            self.new_mode,
115            &self.old_bytes,
116            &self.new_bytes,
117            options,
118        )
119    }
120
121    /// Inserted and deleted line counts, or `None` for a binary change.
122    ///
123    /// # Parameters
124    ///
125    /// - `options` - line-diff settings; whitespace modes change what counts as a changed line.
126    #[must_use]
127    pub fn line_stat(&self, options: &DiffOptions) -> Option<LineStat> {
128        if self.binary {
129            return None;
130        }
131        Some(line_change_counts(
132            &self.old_bytes.text(),
133            &self.new_bytes.text(),
134            options,
135        ))
136    }
137}
138
139/// Load both sides of each entry from the object database.
140///
141/// Use this for index-against-tree and tree-against-tree entries, whose sides are all stored
142/// objects. Entries are loaded in parallel per the repository's thread policy; the result keeps
143/// the order of `entries`.
144///
145/// # Parameters
146///
147/// - `repo` - repository whose object database holds both sides.
148/// - `entries` - diff rows to load, e.g. from [`UncommittedDiff::staged_entries`].
149///
150/// # Errors
151///
152/// Returns config load failures and object read errors.
153pub fn load_object_changes(repo: &Repository, entries: &[DiffEntry]) -> Result<Vec<FileChange>> {
154    let config = repo.config()?;
155    let par_opts = repo
156        .thread_policy()
157        .resolve(ParallelOp::DiffContent, config.as_ref());
158    let odb = repo.odb();
159    let est_bytes = entries.len().saturating_mul(8192);
160    try_par_map(entries, par_opts, est_bytes, |entry| {
161        load_change(odb, entry.clone(), None)
162    })
163    .map_err(|ParallelHashError::Task(err)| err)
164}
165
166/// Where a working-tree change's new side is read from.
167pub(crate) struct WorktreeSide<'a> {
168    pub(crate) work_tree: &'a Path,
169    pub(crate) index: &'a Index,
170    pub(crate) rules: &'a Mutex<WorktreeRules>,
171}
172
173/// Load one entry's sides: the old side from the object database, the new side from the
174/// working tree when `worktree` is given, otherwise from the object database.
175pub(crate) fn load_change(
176    odb: &Odb,
177    entry: DiffEntry,
178    worktree: Option<&WorktreeSide<'_>>,
179) -> Result<FileChange> {
180    let (old_bytes, old_binary) = load_old_side(odb, &entry, None)?;
181    let (new_bytes, new_binary) = match worktree {
182        Some(side) => load_worktree_new_side(odb, &entry, side)?,
183        None => load_new_side(odb, &entry, None)?,
184    };
185    Ok(FileChange::from_entry(
186        entry,
187        old_bytes,
188        new_bytes,
189        old_binary || new_binary,
190    ))
191}
192
193/// Load every entry with [`load_change`], in order.
194pub(crate) fn load_changes(
195    odb: &Odb,
196    entries: Vec<DiffEntry>,
197    worktree: Option<&WorktreeSide<'_>>,
198) -> Result<Vec<FileChange>> {
199    entries
200        .into_iter()
201        .map(|entry| load_change(odb, entry, worktree))
202        .collect()
203}
204
205fn load_worktree_new_side(
206    odb: &Odb,
207    entry: &DiffEntry,
208    side: &WorktreeSide<'_>,
209) -> Result<(SideBytes, bool)> {
210    if entry.new_mode == FileMode::GITLINK {
211        let line = worktree_gitlink_line(entry, side.work_tree);
212        return Ok((SideBytes::from_bytes(line.into_bytes()), false));
213    }
214    if entry.new_mode == FileMode::ABSENT {
215        return Ok((SideBytes::from_bytes(Vec::new()), false));
216    }
217    let Some(path) = entry.new_path.as_deref().or(entry.old_path.as_deref()) else {
218        return load_new_side(odb, entry, None);
219    };
220    // Working-tree entries often carry only a hash of the normalized file, and the blob is not
221    // in the object database until it is staged, so always read the live file.
222    let index_entry = side.index.get(&crate::repo_relpath::storage_bytes(path), 0);
223    let rules = side.rules.lock().unwrap_or_else(|e| e.into_inner());
224    load_worktree_side_for_diff(
225        odb,
226        side.work_tree,
227        path,
228        entry.new_mode.bits(),
229        rules.config(),
230        index_entry,
231        Some(&rules),
232        false,
233    )
234}
235
236/// The submodule's checked-out commit, with `-dirty` when its tracked content is modified.
237fn worktree_gitlink_line(entry: &DiffEntry, work_tree: &Path) -> String {
238    let path = entry.new_path.as_deref().or(entry.old_path.as_deref());
239    let head = if entry.new_oid != ObjectId::zero() {
240        Some(entry.new_oid)
241    } else {
242        path.and_then(|p| read_submodule_head_oid(&work_tree.join(p)))
243    };
244    let Some(head) = head.filter(|oid| *oid != ObjectId::zero()) else {
245        return String::new();
246    };
247    let dirty = entry.old_mode == FileMode::GITLINK
248        && path.is_some_and(|p| submodule_porcelain_flags(work_tree, p, entry.old_oid).modified);
249    if dirty {
250        format!("Subproject commit {}-dirty\n", head.to_hex())
251    } else {
252        gitlink_line(&head)
253    }
254}
255
256fn rename_threshold(renames: RenameDetection) -> u32 {
257    renames
258        .threshold_percent()
259        .unwrap_or(RENAME_DETECTION_DISABLED)
260}
261
262/// `HEAD`'s tree, or `None` on an unborn branch.
263pub(crate) fn head_tree(repo: &Repository) -> Result<Option<ObjectId>> {
264    resolve_head(repo.git_dir())?
265        .oid()
266        .map(|oid| commit_tree(repo, oid))
267        .transpose()
268}
269
270/// Index against `HEAD`'s tree, sorted by path.
271///
272/// A non-empty `restrict_to_paths` compares only those exact paths, without rename detection.
273pub(crate) fn staged_entries_in(
274    repo: &Repository,
275    index: &Index,
276    rename_threshold: u32,
277    restrict_to_paths: Option<&BTreeSet<String>>,
278) -> Result<Vec<DiffEntry>> {
279    let head_tree = head_tree(repo)?;
280    let mut entries = match restrict_to_paths.filter(|paths| !paths.is_empty()) {
281        Some(paths) => diff_index_to_tree_for_paths(repo, index, head_tree.as_ref(), paths)?,
282        None => {
283            let config = repo.config()?;
284            diff_index_to_tree_with_renames(
285                repo.odb(),
286                index,
287                head_tree.as_ref(),
288                false,
289                rename_threshold,
290                config.as_ref(),
291                &repo.thread_policy(),
292            )?
293        }
294    };
295    sort_entries_by_path(&mut entries);
296    Ok(entries)
297}
298
299/// Working tree against `index`, sorted by path.
300///
301/// `restrict_to_paths` limits the scan to those paths and skips the fsmonitor refresh.
302pub(crate) fn unstaged_entries_in(
303    repo: &Repository,
304    index: &mut Index,
305    work_tree: &Path,
306    rules: &Arc<Mutex<WorktreeRules>>,
307    rename_threshold: u32,
308    restrict_to_paths: Option<Arc<BTreeSet<String>>>,
309) -> Result<Vec<DiffEntry>> {
310    let config = rules.lock().unwrap_or_else(|e| e.into_inner()).config_arc();
311    let refresh_fsmonitor = restrict_to_paths.is_none() && fsmonitor_enabled(config.as_ref());
312    let options = DiffIndexToWorktreeOptions {
313        repository_git_dir: Some(repo.git_dir().to_path_buf()),
314        index_mtime: index_mtime_for_diff(index, None),
315        config: Some(config),
316        thread_policy: Some(repo.thread_policy()),
317        refresh_index_stat_in_pass: false,
318        refresh_fsmonitor,
319        worktree_rules: Some(Arc::clone(rules)),
320        command_runner: Some(repo.command_runner()),
321        restrict_to_paths,
322        ..DiffIndexToWorktreeOptions::default()
323    };
324    let (mut entries, _) =
325        diff_index_to_worktree_with_renames(repo, index, work_tree, options, rename_threshold)?;
326    sort_entries_by_path(&mut entries);
327    Ok(entries)
328}
329
330/// Working tree (through `index`) against `base_tree`, sorted by path.
331///
332/// When the index's cache tree matches `base_tree`, this is the cheaper index-against-worktree
333/// scan; otherwise the tree is walked against the working tree directly.
334pub(crate) fn worktree_entries_in(
335    repo: &Repository,
336    index: &mut Index,
337    work_tree: &Path,
338    rules: &Arc<Mutex<WorktreeRules>>,
339    base_tree: Option<&ObjectId>,
340    rename_threshold: u32,
341) -> Result<Vec<DiffEntry>> {
342    let index_matches_base = match (base_tree, index.cache_tree_root.as_ref()) {
343        (Some(base), Some(cached)) => base == cached,
344        (None, None) => true,
345        _ => false,
346    };
347    if index_matches_base && cache_tree_extension_valid(index.cache_tree.as_ref()) {
348        return unstaged_entries_in(repo, index, work_tree, rules, rename_threshold, None);
349    }
350    let config = repo.config()?;
351    let raw = {
352        let rules = rules.lock().unwrap_or_else(|e| e.into_inner());
353        diff_tree_to_worktree_with_git_dir_and_rules(
354            repo.odb(),
355            base_tree,
356            work_tree,
357            repo.git_dir(),
358            index,
359            Some(config.as_ref()),
360            Some(&rules),
361        )?
362    };
363    let mut entries = apply_rename_detection_to_diff_entries(
364        repo.odb(),
365        raw,
366        Some(work_tree),
367        rename_threshold,
368        config.as_ref(),
369        &repo.thread_policy(),
370    )?;
371    sort_entries_by_path(&mut entries);
372    Ok(entries)
373}
374
375/// Staged, unstaged, and working-tree diffs over one loaded index.
376///
377/// Every method reuses the same [`Index`] and, once a working-tree method needs them, the same
378/// [`WorktreeRules`], so collecting entries and then loading their contents reads neither twice.
379/// Working-tree scans may refresh stat data on the in-memory index; nothing is written to disk.
380///
381/// Obtain one from [`Diff::uncommitted`](crate::diff::Diff::uncommitted) or
382/// [`UncommittedDiff::new`].
383pub struct UncommittedDiff<'repo> {
384    repo: &'repo Repository,
385    index: Index,
386    rules: Option<Arc<Mutex<WorktreeRules>>>,
387}
388
389impl<'repo> UncommittedDiff<'repo> {
390    /// Diff `repo` against an index the caller already loaded.
391    ///
392    /// # Parameters
393    ///
394    /// - `repo` - repository to diff.
395    /// - `index` - its index (normally the on-disk index).
396    #[must_use]
397    pub fn new(repo: &'repo Repository, index: Index) -> Self {
398        Self {
399            repo,
400            index,
401            rules: None,
402        }
403    }
404
405    /// The index every comparison uses.
406    #[must_use]
407    pub fn index(&self) -> &Index {
408        &self.index
409    }
410
411    /// Index against `HEAD`'s tree (`grit diff --staged`), sorted by path.
412    ///
413    /// An unborn `HEAD` compares against the empty tree, so every index entry is added.
414    ///
415    /// # Parameters
416    ///
417    /// - `renames` - rename detection between deleted and added paths.
418    ///
419    /// # Errors
420    ///
421    /// Returns `HEAD` resolution, config, and object read errors.
422    pub fn staged_entries(&self, renames: RenameDetection) -> Result<Vec<DiffEntry>> {
423        staged_entries_in(self.repo, &self.index, rename_threshold(renames), None)
424    }
425
426    /// Working tree against the index (`grit diff --unstaged`), sorted by path.
427    ///
428    /// # Parameters
429    ///
430    /// - `renames` - rename detection between deleted and added paths.
431    ///
432    /// # Errors
433    ///
434    /// Returns [`Error::RequiresWorkTree`] in a bare repository, plus worktree rule, I/O, and
435    /// object read errors.
436    pub fn unstaged_entries(&mut self, renames: RenameDetection) -> Result<Vec<DiffEntry>> {
437        let work_tree = self.work_tree()?;
438        let rules = self.rules()?;
439        unstaged_entries_in(
440            self.repo,
441            &mut self.index,
442            work_tree,
443            &rules,
444            rename_threshold(renames),
445            None,
446        )
447    }
448
449    /// Working tree and index together against `base_tree` (`grit diff`, `grit diff --against`),
450    /// sorted by path.
451    ///
452    /// # Parameters
453    ///
454    /// - `base_tree` - tree to compare with; `None` for the empty tree (an unborn `HEAD`).
455    /// - `renames` - rename detection between deleted and added paths.
456    ///
457    /// # Errors
458    ///
459    /// Returns [`Error::RequiresWorkTree`] in a bare repository, plus worktree rule, I/O, and
460    /// object read errors.
461    pub fn worktree_entries(
462        &mut self,
463        base_tree: Option<&ObjectId>,
464        renames: RenameDetection,
465    ) -> Result<Vec<DiffEntry>> {
466        let work_tree = self.work_tree()?;
467        let rules = self.rules()?;
468        worktree_entries_in(
469            self.repo,
470            &mut self.index,
471            work_tree,
472            &rules,
473            base_tree,
474            rename_threshold(renames),
475        )
476    }
477
478    /// Load entries from [`Self::unstaged_entries`] or [`Self::worktree_entries`]: old sides
479    /// from the object database, new sides from the working tree (with gitlinks showing the
480    /// submodule's checked-out commit and `-dirty` state).
481    ///
482    /// # Errors
483    ///
484    /// Returns [`Error::RequiresWorkTree`] in a bare repository, plus I/O, filter, and object
485    /// read errors.
486    pub fn load_worktree_changes(&mut self, entries: Vec<DiffEntry>) -> Result<Vec<FileChange>> {
487        let work_tree = self.work_tree()?;
488        let rules = self.rules()?;
489        let side = WorktreeSide {
490            work_tree,
491            index: &self.index,
492            rules: &rules,
493        };
494        load_changes(self.repo.odb(), entries, Some(&side))
495    }
496
497    fn work_tree(&self) -> Result<&'repo Path> {
498        self.repo.work_tree().ok_or(Error::RequiresWorkTree)
499    }
500
501    fn rules(&mut self) -> Result<Arc<Mutex<WorktreeRules>>> {
502        if let Some(rules) = &self.rules {
503            return Ok(Arc::clone(rules));
504        }
505        let rules = Arc::new(Mutex::new(WorktreeRules::from_repository(
506            self.repo,
507            &self.index,
508        )?));
509        self.rules = Some(Arc::clone(&rules));
510        Ok(rules)
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517    use crate::repo::InitOptions;
518
519    fn entry(status: DiffStatus, old: Option<&str>, new: Option<&str>) -> DiffEntry {
520        DiffEntry {
521            status,
522            old_path: old.map(str::to_owned),
523            new_path: new.map(str::to_owned),
524            old_mode: FileMode::REGULAR,
525            new_mode: FileMode::REGULAR,
526            old_oid: ObjectId::zero(),
527            new_oid: ObjectId::zero(),
528            score: Some(87),
529        }
530    }
531
532    #[test]
533    fn from_entry_keeps_old_path_only_for_real_renames() {
534        let bytes = || SideBytes::from_bytes(Vec::new());
535        let renamed = FileChange::from_entry(
536            entry(DiffStatus::Renamed, Some("a"), Some("b")),
537            bytes(),
538            bytes(),
539            false,
540        );
541        assert_eq!(renamed.path, "b");
542        assert_eq!(renamed.old_path.as_deref(), Some("a"));
543        assert_eq!(renamed.similarity, Some(87));
544
545        let deleted = FileChange::from_entry(
546            entry(DiffStatus::Deleted, Some("a"), None),
547            bytes(),
548            bytes(),
549            false,
550        );
551        assert_eq!(deleted.path, "a");
552        assert_eq!(deleted.old_path, None);
553        assert_eq!(deleted.similarity, None);
554    }
555
556    #[test]
557    fn binary_change_has_no_hunks_or_line_stat() {
558        let mut change = FileChange::from_entry(
559            entry(DiffStatus::Modified, Some("a"), Some("a")),
560            SideBytes::from_bytes(b"one\n".to_vec()),
561            SideBytes::from_bytes(b"two\n".to_vec()),
562            true,
563        );
564        assert!(change.structured(DiffOptions::default()).hunks.is_empty());
565        assert_eq!(change.line_stat(&DiffOptions::default()), None);
566
567        change.binary = false;
568        assert_eq!(change.structured(DiffOptions::default()).hunks.len(), 1);
569        assert_eq!(
570            change.line_stat(&DiffOptions::default()),
571            Some(LineStat {
572                insertions: 1,
573                deletions: 1,
574            })
575        );
576    }
577
578    #[test]
579    fn worktree_gitlink_line_reads_submodule_head_when_unhashed() {
580        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
581        let mut gitlink = entry(DiffStatus::Added, None, Some("missing-sub"));
582        gitlink.old_mode = FileMode::ABSENT;
583        gitlink.new_mode = FileMode::GITLINK;
584        assert_eq!(worktree_gitlink_line(&gitlink, dir.path()), "");
585
586        let oid = ObjectId::from_hex("1111111111111111111111111111111111111111")
587            .unwrap_or_else(|e| panic!("oid: {e}"));
588        gitlink.new_oid = oid;
589        assert_eq!(
590            worktree_gitlink_line(&gitlink, dir.path()),
591            format!("Subproject commit {}\n", oid.to_hex())
592        );
593    }
594
595    #[test]
596    fn bare_repository_worktree_methods_require_a_work_tree() {
597        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
598        let init = InitOptions {
599            bare: true,
600            ..InitOptions::default()
601        };
602        let repo = Repository::init(dir.path(), &init).unwrap_or_else(|e| panic!("init: {e}"));
603        let mut diff = UncommittedDiff::new(&repo, Index::default());
604        assert!(diff
605            .staged_entries(RenameDetection::Off)
606            .is_ok_and(|e| e.is_empty()));
607        assert!(matches!(
608            diff.unstaged_entries(RenameDetection::Off),
609            Err(Error::RequiresWorkTree)
610        ));
611        assert!(matches!(
612            diff.worktree_entries(None, RenameDetection::Off),
613            Err(Error::RequiresWorkTree)
614        ));
615        assert!(matches!(
616            diff.load_worktree_changes(Vec::new()),
617            Err(Error::RequiresWorkTree)
618        ));
619    }
620}