Skip to main content

grit_lib/diff/
files.rs

1//! Build [`FileDiff`] lists for patch, stat, and name-only detail levels.
2
3use crate::config::ConfigSet;
4use crate::error::Result;
5use crate::odb::Odb;
6use crate::parallel::ThreadPolicy;
7
8use super::detail::{DiffDetail, DiffTotals, FileDiff, LineStat};
9use super::tree_content::{
10    tree_diff_stat_parallel, tree_diff_structured_parallel, TreeDiffStructured,
11};
12use super::{
13    detect_renames_with_policy, DetectRenamesOptions, DiffEntry, DiffOptions, DiffStatus,
14    InexactRenameDetectionSkipped,
15};
16
17fn entries_with_options_renames(
18    odb: &Odb,
19    entries: Vec<DiffEntry>,
20    diff_options: &DiffOptions,
21    config: &ConfigSet,
22    thread_policy: &ThreadPolicy,
23    inexact_notice: Option<&mut Option<InexactRenameDetectionSkipped>>,
24) -> Result<Vec<DiffEntry>> {
25    if let Some(percent) = diff_options.rename_similarity_threshold() {
26        if percent <= 100 {
27            let out = detect_renames_with_policy(
28                odb,
29                None,
30                entries,
31                percent,
32                config,
33                thread_policy,
34                None,
35                DetectRenamesOptions::default(),
36            )?;
37            if let Some(slot) = inexact_notice {
38                if out.inexact_skipped.is_some() {
39                    *slot = out.inexact_skipped;
40                }
41            }
42            return Ok(out.entries);
43        }
44    }
45    Ok(entries)
46}
47
48fn path_from_entry(entry: &DiffEntry) -> String {
49    entry
50        .new_path
51        .clone()
52        .or_else(|| entry.old_path.clone())
53        .unwrap_or_default()
54}
55
56fn old_path_from_entry(entry: &DiffEntry, path: &str) -> Option<String> {
57    entry
58        .old_path
59        .as_ref()
60        .filter(|op| op.as_str() != path)
61        .cloned()
62}
63
64impl FileDiff {
65    /// Metadata-only row for name-only and name-status output.
66    ///
67    /// Moves the paths and modes out of `entry` without reading any blob, so converting a
68    /// list entry by entry costs no more than the entries themselves. This is the row
69    /// [`build_file_diffs`] produces for [`DiffDetail::NameOnly`] and [`DiffDetail::NameStatus`].
70    ///
71    /// # Parameters
72    ///
73    /// - `entry`: one tree or index diff record.
74    ///
75    /// # Returns
76    ///
77    /// A [`FileDiff`] whose `path` is the new path (old path for deletions), with `old_path`
78    /// set only when it differs, absent-side modes (`000000`) as `None`, and no stat or hunks.
79    #[must_use]
80    pub fn from_metadata(entry: DiffEntry) -> Self {
81        let similarity = entry.rename_similarity();
82        let (path, old_path) = match (entry.new_path, entry.old_path) {
83            (Some(path), old) => {
84                let old = old.filter(|old| old != &path);
85                (path, old)
86            }
87            (None, old) => (old.unwrap_or_default(), None),
88        };
89        Self {
90            path,
91            old_path,
92            status: entry.status,
93            old_mode: entry.old_mode.octal_if_present(),
94            new_mode: entry.new_mode.octal_if_present(),
95            binary: false,
96            old_size: 0,
97            new_size: 0,
98            similarity,
99            stat: None,
100            structured: None,
101            combined: None,
102            combined_status: None,
103        }
104    }
105}
106
107fn file_diff_from_structured(t: TreeDiffStructured) -> FileDiff {
108    let path = path_from_entry(&t.entry);
109    FileDiff {
110        path: path.clone(),
111        old_path: old_path_from_entry(&t.entry, &path),
112        status: t.entry.status,
113        old_mode: t.structured.old_mode.clone(),
114        new_mode: t.structured.new_mode.clone(),
115        binary: t.binary,
116        old_size: t.old_size,
117        new_size: t.new_size,
118        similarity: t.entry.rename_similarity(),
119        stat: None,
120        structured: Some(t.structured),
121        combined: None,
122        combined_status: None,
123    }
124}
125
126fn file_diff_from_stat(
127    entry: DiffEntry,
128    binary: bool,
129    stat: Option<LineStat>,
130    old_size: u64,
131    new_size: u64,
132) -> FileDiff {
133    let path = path_from_entry(&entry);
134    FileDiff {
135        path: path.clone(),
136        old_path: old_path_from_entry(&entry, &path),
137        status: entry.status,
138        old_mode: entry.old_mode.octal_if_present(),
139        new_mode: entry.new_mode.octal_if_present(),
140        binary,
141        old_size,
142        new_size,
143        similarity: entry.rename_similarity(),
144        stat,
145        structured: None,
146        combined: None,
147        combined_status: None,
148    }
149}
150
151/// Output of [`build_file_diffs`].
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct DiffFilesOutput {
154    /// Per-file results in path order.
155    pub files: Vec<FileDiff>,
156    /// Aggregate counts for stat detail; `None` for patch and name-only modes.
157    pub totals: Option<DiffTotals>,
158    /// Set when inexact rename detection was skipped due to `diff.renameLimit`.
159    pub inexact_rename_skipped: Option<InexactRenameDetectionSkipped>,
160}
161
162/// Fill per-file diff bodies for tree/index diff entries.
163///
164/// Applies [`DiffOptions::renames`] to the entry list first (Git's default `-M50`), pairing
165/// added and deleted paths before stat or patch work. Use [`RenameDetection::Off`](crate::diff::RenameDetection::Off) when the
166/// caller already ran rename detection (`detect_renames`) or
167/// [`diff_trees_with_renames`](crate::diff::diff_trees_with_renames). Metadata-only
168/// [`DiffDetail::NameOnly`] and [`DiffDetail::NameStatus`] still skip line-diff blob reads,
169/// but rename detection may read blob sides for similarity scoring when renames are enabled.
170///
171/// # Errors
172///
173/// Propagates object database and parallel diff errors.
174pub fn build_file_diffs(
175    odb: &Odb,
176    entries: Vec<DiffEntry>,
177    detail: DiffDetail,
178    diff_options: DiffOptions,
179    config: &ConfigSet,
180    thread_policy: &ThreadPolicy,
181) -> Result<DiffFilesOutput> {
182    let mut inexact_rename_skipped = None;
183    let entries = entries_with_options_renames(
184        odb,
185        entries,
186        &diff_options,
187        config,
188        thread_policy,
189        Some(&mut inexact_rename_skipped),
190    )?;
191    match detail {
192        DiffDetail::NameOnly | DiffDetail::NameStatus => {
193            let files: Vec<FileDiff> = entries.into_iter().map(FileDiff::from_metadata).collect();
194            Ok(DiffFilesOutput {
195                files,
196                totals: None,
197                inexact_rename_skipped,
198            })
199        }
200        DiffDetail::Stat => {
201            let rows = tree_diff_stat_parallel(odb, entries, config, thread_policy, diff_options)?;
202            let files: Vec<FileDiff> = rows
203                .into_iter()
204                .map(|(entry, binary, stat, old_size, new_size)| {
205                    file_diff_from_stat(entry, binary, stat, old_size, new_size)
206                })
207                .collect();
208            let totals = Some(DiffTotals::from_files(&files));
209            Ok(DiffFilesOutput {
210                files,
211                totals,
212                inexact_rename_skipped,
213            })
214        }
215        DiffDetail::Patch => {
216            let structured =
217                tree_diff_structured_parallel(odb, entries, config, thread_policy, diff_options)?;
218            let files: Vec<FileDiff> = structured
219                .into_iter()
220                .map(file_diff_from_structured)
221                .collect();
222            Ok(DiffFilesOutput {
223                files,
224                totals: None,
225                inexact_rename_skipped,
226            })
227        }
228    }
229}
230
231/// Build patch [`FileDiff`] rows one entry at a time without retaining every hunk list.
232///
233/// The callback receives each [`FileDiff`] in path order; drop it before the next entry is
234/// computed so peak memory stays near one file's diff.
235///
236/// # Errors
237///
238/// Propagates object database and parallel diff errors, and callback errors.
239pub fn build_file_diffs_streaming(
240    odb: &Odb,
241    entries: Vec<DiffEntry>,
242    diff_options: DiffOptions,
243    config: &ConfigSet,
244    thread_policy: &ThreadPolicy,
245    mut emit: impl FnMut(FileDiff) -> Result<()>,
246) -> Result<()> {
247    use super::tree_content::for_each_tree_diff_structured;
248    let entries =
249        entries_with_options_renames(odb, entries, &diff_options, config, thread_policy, None)?;
250    for_each_tree_diff_structured(
251        odb,
252        &entries,
253        diff_options,
254        config,
255        thread_policy,
256        |structured| emit(file_diff_from_structured(structured)),
257    )
258}
259
260/// Git `--name-status` letter for a diff entry (uppercase status + rename score suffix).
261#[must_use]
262pub fn name_status_line(entry: &DiffEntry) -> String {
263    let path = path_from_entry(entry);
264    match entry.status {
265        DiffStatus::Renamed => {
266            let score = entry.score.unwrap_or(100);
267            let old = entry.old_path.as_deref().unwrap_or("");
268            format!("R{score:03}\t{old}\t{path}")
269        }
270        DiffStatus::Copied => {
271            let score = entry.score.unwrap_or(100);
272            let old = entry.old_path.as_deref().unwrap_or("");
273            format!("C{score:03}\t{old}\t{path}")
274        }
275        DiffStatus::Added => format!("A\t{path}"),
276        DiffStatus::Deleted => format!("D\t{path}"),
277        DiffStatus::Modified => format!("M\t{path}"),
278        DiffStatus::TypeChanged => format!("T\t{path}"),
279        DiffStatus::Unmerged => format!("U\t{path}"),
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use crate::objects::{FileMode, ObjectId};
287
288    #[test]
289    fn metadata_rows_preserve_selected_paths_modes_and_similarity() {
290        for status in [
291            DiffStatus::Added,
292            DiffStatus::Deleted,
293            DiffStatus::Modified,
294            DiffStatus::Renamed,
295            DiffStatus::Copied,
296        ] {
297            let entry = DiffEntry {
298                status,
299                old_path: (status != DiffStatus::Added).then(|| "old".to_owned()),
300                new_path: (status != DiffStatus::Deleted).then(|| {
301                    if status == DiffStatus::Modified {
302                        "old"
303                    } else {
304                        "new"
305                    }
306                    .to_owned()
307                }),
308                old_mode: if status == DiffStatus::Added {
309                    FileMode::ABSENT
310                } else {
311                    FileMode::REGULAR
312                },
313                new_mode: if status == DiffStatus::Deleted {
314                    FileMode::ABSENT
315                } else {
316                    FileMode::EXECUTABLE
317                },
318                old_oid: ObjectId::zero(),
319                new_oid: ObjectId::zero(),
320                score: Some(75),
321            };
322            let file = FileDiff::from_metadata(entry);
323            assert_eq!(file.status, status);
324            assert_eq!(
325                file.path,
326                if matches!(status, DiffStatus::Deleted | DiffStatus::Modified) {
327                    "old"
328                } else {
329                    "new"
330                }
331            );
332            let renamed = matches!(status, DiffStatus::Renamed | DiffStatus::Copied);
333            assert_eq!(file.old_path.as_deref(), renamed.then_some("old"));
334            assert_eq!(file.similarity, renamed.then_some(75));
335            assert_eq!(
336                file.old_mode.as_deref(),
337                (status != DiffStatus::Added).then_some("100644")
338            );
339            assert_eq!(
340                file.new_mode.as_deref(),
341                (status != DiffStatus::Deleted).then_some("100755")
342            );
343            assert!(file.stat.is_none() && file.structured.is_none());
344        }
345    }
346
347    #[test]
348    fn build_file_diffs_applies_default_rename_detection() {
349        use crate::config::ConfigSet;
350        use crate::objects::{serialize_commit, CommitData, ObjectKind, TreeEntry};
351        use crate::parallel::ThreadPolicy;
352        use crate::repo::{InitOptions, Repository};
353
354        fn tree_with(repo: &Repository, entries: &[(&str, &[u8])]) -> ObjectId {
355            let mut tes = Vec::new();
356            for (name, data) in entries {
357                let oid = repo.odb().write(ObjectKind::Blob, data).expect("blob");
358                tes.push(TreeEntry {
359                    mode: 0o100644,
360                    name: name.as_bytes().to_vec(),
361                    oid,
362                });
363            }
364            let data = crate::objects::serialize_tree(&tes);
365            repo.odb().write(ObjectKind::Tree, &data).expect("tree")
366        }
367
368        fn write_commit(repo: &Repository, tree: ObjectId, parents: Vec<ObjectId>) -> ObjectId {
369            let ident = "A <a@example.com> 0 +0000";
370            let commit = CommitData {
371                tree,
372                parents,
373                author: ident.into(),
374                committer: ident.into(),
375                author_raw: Vec::new(),
376                committer_raw: Vec::new(),
377                encoding: None,
378                message: "m\n".into(),
379                raw_message: None,
380                extra_headers: Vec::new(),
381            };
382            repo.odb()
383                .write(ObjectKind::Commit, &serialize_commit(&commit))
384                .expect("commit")
385        }
386
387        let dir = tempfile::tempdir().expect("tempdir");
388        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
389        let shared = b"same line\nsame line\nsame line\n";
390        let mut old_body = shared.to_vec();
391        old_body.extend_from_slice(b"tail\n");
392        let mut new_body = shared.to_vec();
393        new_body.extend_from_slice(b"tail2\n");
394        let parent_tree = tree_with(&repo, &[("old.txt", &old_body)]);
395        let parent = write_commit(&repo, parent_tree, vec![]);
396        let child_tree = tree_with(&repo, &[("new.txt", &new_body)]);
397        let _child = write_commit(&repo, child_tree, vec![parent]);
398        let old_tree = repo.odb().commit_tree(&parent).expect("tree");
399        let new_tree = repo.odb().commit_tree(&_child).expect("tree");
400        let entries = repo
401            .diff()
402            .tree_to_tree(Some(&old_tree), Some(&new_tree))
403            .expect("diff");
404        let config = ConfigSet::default();
405        let with_renames = build_file_diffs(
406            repo.odb(),
407            entries.clone(),
408            DiffDetail::Stat,
409            DiffOptions::default(),
410            &config,
411            &ThreadPolicy::default(),
412        )
413        .expect("stat");
414        assert_eq!(with_renames.files.len(), 1);
415        assert_eq!(with_renames.files[0].status, DiffStatus::Renamed);
416        let without = build_file_diffs(
417            repo.odb(),
418            entries,
419            DiffDetail::Stat,
420            DiffOptions::default().with_renames(crate::diff::RenameDetection::Off),
421            &config,
422            &ThreadPolicy::default(),
423        )
424        .expect("stat");
425        assert_eq!(without.files.len(), 2);
426    }
427}