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