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