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::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_for_repository;
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/// Index against `HEAD`'s tree, sorted by path.
263///
264/// A non-empty `restrict_to_paths` compares only those exact paths, without rename detection.
265pub(crate) fn staged_entries_in(
266    repo: &Repository,
267    index: &Index,
268    rename_threshold: u32,
269    restrict_to_paths: Option<&BTreeSet<String>>,
270) -> Result<Vec<DiffEntry>> {
271    let head_tree = resolve_head_for_repository(repo)?.tree(repo.odb())?;
272    let mut entries = match restrict_to_paths.filter(|paths| !paths.is_empty()) {
273        Some(paths) => diff_index_to_tree_for_paths(repo, index, head_tree.as_ref(), paths)?,
274        None => {
275            let config = repo.config()?;
276            diff_index_to_tree_with_renames(
277                repo.odb(),
278                index,
279                head_tree.as_ref(),
280                false,
281                rename_threshold,
282                config.as_ref(),
283                &repo.thread_policy(),
284            )?
285        }
286    };
287    sort_entries_by_path(&mut entries);
288    Ok(entries)
289}
290
291/// Working tree against `index`, sorted by path.
292///
293/// `restrict_to_paths` limits the scan to those paths and skips the fsmonitor refresh.
294pub(crate) fn unstaged_entries_in(
295    repo: &Repository,
296    index: &mut Index,
297    work_tree: &Path,
298    rules: &Arc<Mutex<WorktreeRules>>,
299    rename_threshold: u32,
300    restrict_to_paths: Option<Arc<BTreeSet<String>>>,
301) -> Result<Vec<DiffEntry>> {
302    let config = rules.lock().unwrap_or_else(|e| e.into_inner()).config_arc();
303    let refresh_fsmonitor = restrict_to_paths.is_none() && fsmonitor_enabled(config.as_ref());
304    let options = DiffIndexToWorktreeOptions {
305        repository_git_dir: Some(repo.git_dir().to_path_buf()),
306        index_mtime: index_mtime_for_diff(index, None),
307        config: Some(config),
308        thread_policy: Some(repo.thread_policy()),
309        refresh_index_stat_in_pass: false,
310        refresh_fsmonitor,
311        worktree_rules: Some(Arc::clone(rules)),
312        command_runner: Some(repo.command_runner()),
313        restrict_to_paths,
314        ..DiffIndexToWorktreeOptions::default()
315    };
316    let (mut entries, _) =
317        diff_index_to_worktree_with_renames(repo, index, work_tree, options, rename_threshold)?;
318    sort_entries_by_path(&mut entries);
319    Ok(entries)
320}
321
322/// Working tree (through `index`) against `base_tree`, sorted by path.
323///
324/// When the index's cache tree matches `base_tree`, this is the cheaper index-against-worktree
325/// scan; otherwise the tree is walked against the working tree directly.
326pub(crate) fn worktree_entries_in(
327    repo: &Repository,
328    index: &mut Index,
329    work_tree: &Path,
330    rules: &Arc<Mutex<WorktreeRules>>,
331    base_tree: Option<&ObjectId>,
332    rename_threshold: u32,
333) -> Result<Vec<DiffEntry>> {
334    let index_matches_base = match (base_tree, index.cache_tree_root.as_ref()) {
335        (Some(base), Some(cached)) => base == cached,
336        (None, None) => true,
337        _ => false,
338    };
339    if index_matches_base && cache_tree_extension_valid(index.cache_tree.as_ref()) {
340        return unstaged_entries_in(repo, index, work_tree, rules, rename_threshold, None);
341    }
342    let config = repo.config()?;
343    let raw = {
344        let rules = rules.lock().unwrap_or_else(|e| e.into_inner());
345        diff_tree_to_worktree_with_git_dir_and_rules(
346            repo.odb(),
347            base_tree,
348            work_tree,
349            repo.git_dir(),
350            index,
351            Some(config.as_ref()),
352            Some(&rules),
353        )?
354    };
355    let mut entries = apply_rename_detection_to_diff_entries(
356        repo.odb(),
357        raw,
358        Some(work_tree),
359        rename_threshold,
360        config.as_ref(),
361        &repo.thread_policy(),
362    )?;
363    sort_entries_by_path(&mut entries);
364    Ok(entries)
365}
366
367/// Staged, unstaged, and working-tree diffs over one loaded index.
368///
369/// Every method reuses the same [`Index`] and, once a working-tree method needs them, the same
370/// [`WorktreeRules`], so collecting entries and then loading their contents reads neither twice.
371/// Working-tree scans may refresh stat data on the in-memory index; nothing is written to disk.
372///
373/// Obtain one from [`Diff::uncommitted`](crate::diff::Diff::uncommitted) or
374/// [`UncommittedDiff::new`].
375pub struct UncommittedDiff<'repo> {
376    repo: &'repo Repository,
377    index: Index,
378    rules: Option<Arc<Mutex<WorktreeRules>>>,
379}
380
381impl<'repo> UncommittedDiff<'repo> {
382    /// Diff `repo` against an index the caller already loaded.
383    ///
384    /// # Parameters
385    ///
386    /// - `repo` - repository to diff.
387    /// - `index` - its index (normally the on-disk index).
388    #[must_use]
389    pub fn new(repo: &'repo Repository, index: Index) -> Self {
390        Self {
391            repo,
392            index,
393            rules: None,
394        }
395    }
396
397    /// The index every comparison uses.
398    #[must_use]
399    pub fn index(&self) -> &Index {
400        &self.index
401    }
402
403    /// Index against `HEAD`'s tree (`grit diff --staged`), sorted by path.
404    ///
405    /// An unborn `HEAD` compares against the empty tree, so every index entry is added.
406    ///
407    /// # Parameters
408    ///
409    /// - `renames` - rename detection between deleted and added paths.
410    ///
411    /// # Errors
412    ///
413    /// Returns `HEAD` resolution, config, and object read errors.
414    pub fn staged_entries(&self, renames: RenameDetection) -> Result<Vec<DiffEntry>> {
415        staged_entries_in(self.repo, &self.index, rename_threshold(renames), None)
416    }
417
418    /// Working tree against the index (`grit diff --unstaged`), sorted by path.
419    ///
420    /// # Parameters
421    ///
422    /// - `renames` - rename detection between deleted and added paths.
423    ///
424    /// # Errors
425    ///
426    /// Returns [`Error::RequiresWorkTree`] in a bare repository, plus worktree rule, I/O, and
427    /// object read errors.
428    pub fn unstaged_entries(&mut self, renames: RenameDetection) -> Result<Vec<DiffEntry>> {
429        let work_tree = self.work_tree()?;
430        let rules = self.rules()?;
431        unstaged_entries_in(
432            self.repo,
433            &mut self.index,
434            work_tree,
435            &rules,
436            rename_threshold(renames),
437            None,
438        )
439    }
440
441    /// Working tree and index together against `base_tree` (`grit diff`, `grit diff --against`),
442    /// sorted by path.
443    ///
444    /// # Parameters
445    ///
446    /// - `base_tree` - tree to compare with; `None` for the empty tree (an unborn `HEAD`).
447    /// - `renames` - rename detection between deleted and added paths.
448    ///
449    /// # Errors
450    ///
451    /// Returns [`Error::RequiresWorkTree`] in a bare repository, plus worktree rule, I/O, and
452    /// object read errors.
453    pub fn worktree_entries(
454        &mut self,
455        base_tree: Option<&ObjectId>,
456        renames: RenameDetection,
457    ) -> Result<Vec<DiffEntry>> {
458        let work_tree = self.work_tree()?;
459        let rules = self.rules()?;
460        worktree_entries_in(
461            self.repo,
462            &mut self.index,
463            work_tree,
464            &rules,
465            base_tree,
466            rename_threshold(renames),
467        )
468    }
469
470    /// Load entries from [`Self::unstaged_entries`] or [`Self::worktree_entries`]: old sides
471    /// from the object database, new sides from the working tree (with gitlinks showing the
472    /// submodule's checked-out commit and `-dirty` state).
473    ///
474    /// # Errors
475    ///
476    /// Returns [`Error::RequiresWorkTree`] in a bare repository, plus I/O, filter, and object
477    /// read errors.
478    pub fn load_worktree_changes(&mut self, entries: Vec<DiffEntry>) -> Result<Vec<FileChange>> {
479        let work_tree = self.work_tree()?;
480        let rules = self.rules()?;
481        let side = WorktreeSide {
482            work_tree,
483            index: &self.index,
484            rules: &rules,
485        };
486        load_changes(self.repo.odb(), entries, Some(&side))
487    }
488
489    fn work_tree(&self) -> Result<&'repo Path> {
490        self.repo.work_tree().ok_or(Error::RequiresWorkTree)
491    }
492
493    fn rules(&mut self) -> Result<Arc<Mutex<WorktreeRules>>> {
494        if let Some(rules) = &self.rules {
495            return Ok(Arc::clone(rules));
496        }
497        let rules = Arc::new(Mutex::new(WorktreeRules::from_repository(
498            self.repo,
499            &self.index,
500        )?));
501        self.rules = Some(Arc::clone(&rules));
502        Ok(rules)
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use crate::repo::InitOptions;
510
511    fn entry(status: DiffStatus, old: Option<&str>, new: Option<&str>) -> DiffEntry {
512        DiffEntry {
513            status,
514            old_path: old.map(str::to_owned),
515            new_path: new.map(str::to_owned),
516            old_mode: FileMode::REGULAR,
517            new_mode: FileMode::REGULAR,
518            old_oid: ObjectId::zero(),
519            new_oid: ObjectId::zero(),
520            score: Some(87),
521        }
522    }
523
524    #[test]
525    fn from_entry_keeps_old_path_only_for_real_renames() {
526        let bytes = || SideBytes::from_bytes(Vec::new());
527        let renamed = FileChange::from_entry(
528            entry(DiffStatus::Renamed, Some("a"), Some("b")),
529            bytes(),
530            bytes(),
531            false,
532        );
533        assert_eq!(renamed.path, "b");
534        assert_eq!(renamed.old_path.as_deref(), Some("a"));
535        assert_eq!(renamed.similarity, Some(87));
536
537        let deleted = FileChange::from_entry(
538            entry(DiffStatus::Deleted, Some("a"), None),
539            bytes(),
540            bytes(),
541            false,
542        );
543        assert_eq!(deleted.path, "a");
544        assert_eq!(deleted.old_path, None);
545        assert_eq!(deleted.similarity, None);
546    }
547
548    #[test]
549    fn binary_change_has_no_hunks_or_line_stat() {
550        let mut change = FileChange::from_entry(
551            entry(DiffStatus::Modified, Some("a"), Some("a")),
552            SideBytes::from_bytes(b"one\n".to_vec()),
553            SideBytes::from_bytes(b"two\n".to_vec()),
554            true,
555        );
556        assert!(change.structured(DiffOptions::default()).hunks.is_empty());
557        assert_eq!(change.line_stat(&DiffOptions::default()), None);
558
559        change.binary = false;
560        assert_eq!(change.structured(DiffOptions::default()).hunks.len(), 1);
561        assert_eq!(
562            change.line_stat(&DiffOptions::default()),
563            Some(LineStat {
564                insertions: 1,
565                deletions: 1,
566            })
567        );
568    }
569
570    #[test]
571    fn worktree_gitlink_line_reads_submodule_head_when_unhashed() {
572        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
573        let mut gitlink = entry(DiffStatus::Added, None, Some("missing-sub"));
574        gitlink.old_mode = FileMode::ABSENT;
575        gitlink.new_mode = FileMode::GITLINK;
576        assert_eq!(worktree_gitlink_line(&gitlink, dir.path()), "");
577
578        let oid = ObjectId::from_hex("1111111111111111111111111111111111111111")
579            .unwrap_or_else(|e| panic!("oid: {e}"));
580        gitlink.new_oid = oid;
581        assert_eq!(
582            worktree_gitlink_line(&gitlink, dir.path()),
583            format!("Subproject commit {}\n", oid.to_hex())
584        );
585    }
586
587    #[test]
588    fn bare_repository_worktree_methods_require_a_work_tree() {
589        let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("tempdir: {e}"));
590        let init = InitOptions {
591            bare: true,
592            ..InitOptions::default()
593        };
594        let repo = Repository::init(dir.path(), &init).unwrap_or_else(|e| panic!("init: {e}"));
595        let mut diff = UncommittedDiff::new(&repo, Index::default());
596        assert!(diff
597            .staged_entries(RenameDetection::Off)
598            .is_ok_and(|e| e.is_empty()));
599        assert!(matches!(
600            diff.unstaged_entries(RenameDetection::Off),
601            Err(Error::RequiresWorkTree)
602        ));
603        assert!(matches!(
604            diff.worktree_entries(None, RenameDetection::Off),
605            Err(Error::RequiresWorkTree)
606        ));
607        assert!(matches!(
608            diff.load_worktree_changes(Vec::new()),
609            Err(Error::RequiresWorkTree)
610        ));
611    }
612}