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::{DiffEntry, DiffOptions, DiffStatus};
13
14fn path_from_entry(entry: &DiffEntry) -> String {
15    entry
16        .new_path
17        .clone()
18        .or_else(|| entry.old_path.clone())
19        .unwrap_or_default()
20}
21
22fn old_path_from_entry(entry: &DiffEntry, path: &str) -> Option<String> {
23    entry
24        .old_path
25        .as_ref()
26        .filter(|op| op.as_str() != path)
27        .cloned()
28}
29
30impl FileDiff {
31    /// Metadata-only row for name-only and name-status output.
32    ///
33    /// Moves the paths and modes out of `entry` without reading any blob, so converting a
34    /// list entry by entry costs no more than the entries themselves. This is the row
35    /// [`build_file_diffs`] produces for [`DiffDetail::NameOnly`] and [`DiffDetail::NameStatus`].
36    ///
37    /// # Parameters
38    ///
39    /// - `entry`: one tree or index diff record.
40    ///
41    /// # Returns
42    ///
43    /// A [`FileDiff`] whose `path` is the new path (old path for deletions), with `old_path`
44    /// set only when it differs, absent-side modes (`000000`) as `None`, and no stat or hunks.
45    #[must_use]
46    pub fn from_metadata(entry: DiffEntry) -> Self {
47        let similarity = entry.rename_similarity();
48        let (path, old_path) = match (entry.new_path, entry.old_path) {
49            (Some(path), old) => {
50                let old = old.filter(|old| old != &path);
51                (path, old)
52            }
53            (None, old) => (old.unwrap_or_default(), None),
54        };
55        Self {
56            path,
57            old_path,
58            status: entry.status,
59            old_mode: entry.old_mode.octal_if_present(),
60            new_mode: entry.new_mode.octal_if_present(),
61            binary: false,
62            similarity,
63            stat: None,
64            structured: None,
65            combined: None,
66            combined_status: None,
67        }
68    }
69}
70
71fn file_diff_from_structured(t: TreeDiffStructured) -> FileDiff {
72    let path = path_from_entry(&t.entry);
73    FileDiff {
74        path: path.clone(),
75        old_path: old_path_from_entry(&t.entry, &path),
76        status: t.entry.status,
77        old_mode: t.structured.old_mode.clone(),
78        new_mode: t.structured.new_mode.clone(),
79        binary: t.binary,
80        similarity: t.entry.rename_similarity(),
81        stat: None,
82        structured: Some(t.structured),
83        combined: None,
84        combined_status: None,
85    }
86}
87
88fn file_diff_from_stat(entry: DiffEntry, binary: bool, stat: Option<LineStat>) -> FileDiff {
89    let path = path_from_entry(&entry);
90    FileDiff {
91        path: path.clone(),
92        old_path: old_path_from_entry(&entry, &path),
93        status: entry.status,
94        old_mode: entry.old_mode.octal_if_present(),
95        new_mode: entry.new_mode.octal_if_present(),
96        binary,
97        similarity: entry.rename_similarity(),
98        stat,
99        structured: None,
100        combined: None,
101        combined_status: None,
102    }
103}
104
105/// Output of [`build_file_diffs`].
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct DiffFilesOutput {
108    /// Per-file results in path order.
109    pub files: Vec<FileDiff>,
110    /// Aggregate counts for stat detail; `None` for patch and name-only modes.
111    pub totals: Option<DiffTotals>,
112}
113
114/// Fill per-file diff bodies for tree/index diff entries.
115///
116/// Name-only and name-status skip blob reads for line diffs; stat loads blobs for
117/// counts only; patch runs the full structured line engine.
118///
119/// # Errors
120///
121/// Propagates object database and parallel diff errors.
122pub fn build_file_diffs(
123    odb: &Odb,
124    entries: Vec<DiffEntry>,
125    detail: DiffDetail,
126    diff_options: DiffOptions,
127    config: &ConfigSet,
128    thread_policy: &ThreadPolicy,
129) -> Result<DiffFilesOutput> {
130    match detail {
131        DiffDetail::NameOnly | DiffDetail::NameStatus => {
132            let files: Vec<FileDiff> = entries.into_iter().map(FileDiff::from_metadata).collect();
133            Ok(DiffFilesOutput {
134                files,
135                totals: None,
136            })
137        }
138        DiffDetail::Stat => {
139            let rows = tree_diff_stat_parallel(odb, entries, config, thread_policy, diff_options)?;
140            let files: Vec<FileDiff> = rows
141                .into_iter()
142                .map(|(entry, binary, stat)| file_diff_from_stat(entry, binary, stat))
143                .collect();
144            let totals = Some(DiffTotals::from_files(&files));
145            Ok(DiffFilesOutput { files, totals })
146        }
147        DiffDetail::Patch => {
148            let structured =
149                tree_diff_structured_parallel(odb, entries, config, thread_policy, diff_options)?;
150            let files: Vec<FileDiff> = structured
151                .into_iter()
152                .map(file_diff_from_structured)
153                .collect();
154            Ok(DiffFilesOutput {
155                files,
156                totals: None,
157            })
158        }
159    }
160}
161
162/// Build patch [`FileDiff`] rows one entry at a time without retaining every hunk list.
163///
164/// The callback receives each [`FileDiff`] in path order; drop it before the next entry is
165/// computed so peak memory stays near one file's diff.
166///
167/// # Errors
168///
169/// Propagates object database and parallel diff errors, and callback errors.
170pub fn build_file_diffs_streaming(
171    odb: &Odb,
172    entries: Vec<DiffEntry>,
173    diff_options: DiffOptions,
174    config: &ConfigSet,
175    thread_policy: &ThreadPolicy,
176    mut emit: impl FnMut(FileDiff) -> Result<()>,
177) -> Result<()> {
178    use super::tree_content::for_each_tree_diff_structured;
179    for_each_tree_diff_structured(
180        odb,
181        &entries,
182        diff_options,
183        config,
184        thread_policy,
185        |structured| emit(file_diff_from_structured(structured)),
186    )
187}
188
189/// Git `--name-status` letter for a diff entry (uppercase status + rename score suffix).
190#[must_use]
191pub fn name_status_line(entry: &DiffEntry) -> String {
192    let path = path_from_entry(entry);
193    match entry.status {
194        DiffStatus::Renamed => {
195            let score = entry.score.unwrap_or(100);
196            let old = entry.old_path.as_deref().unwrap_or("");
197            format!("R{score:03}\t{old}\t{path}")
198        }
199        DiffStatus::Copied => {
200            let score = entry.score.unwrap_or(100);
201            let old = entry.old_path.as_deref().unwrap_or("");
202            format!("C{score:03}\t{old}\t{path}")
203        }
204        DiffStatus::Added => format!("A\t{path}"),
205        DiffStatus::Deleted => format!("D\t{path}"),
206        DiffStatus::Modified => format!("M\t{path}"),
207        DiffStatus::TypeChanged => format!("T\t{path}"),
208        DiffStatus::Unmerged => format!("U\t{path}"),
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::objects::{FileMode, ObjectId};
216
217    #[test]
218    fn metadata_rows_preserve_selected_paths_modes_and_similarity() {
219        for status in [
220            DiffStatus::Added,
221            DiffStatus::Deleted,
222            DiffStatus::Modified,
223            DiffStatus::Renamed,
224            DiffStatus::Copied,
225        ] {
226            let entry = DiffEntry {
227                status,
228                old_path: (status != DiffStatus::Added).then(|| "old".to_owned()),
229                new_path: (status != DiffStatus::Deleted).then(|| {
230                    if status == DiffStatus::Modified {
231                        "old"
232                    } else {
233                        "new"
234                    }
235                    .to_owned()
236                }),
237                old_mode: if status == DiffStatus::Added {
238                    FileMode::ABSENT
239                } else {
240                    FileMode::REGULAR
241                },
242                new_mode: if status == DiffStatus::Deleted {
243                    FileMode::ABSENT
244                } else {
245                    FileMode::EXECUTABLE
246                },
247                old_oid: ObjectId::zero(),
248                new_oid: ObjectId::zero(),
249                score: Some(75),
250            };
251            let file = FileDiff::from_metadata(entry);
252            assert_eq!(file.status, status);
253            assert_eq!(
254                file.path,
255                if matches!(status, DiffStatus::Deleted | DiffStatus::Modified) {
256                    "old"
257                } else {
258                    "new"
259                }
260            );
261            let renamed = matches!(status, DiffStatus::Renamed | DiffStatus::Copied);
262            assert_eq!(file.old_path.as_deref(), renamed.then_some("old"));
263            assert_eq!(file.similarity, renamed.then_some(75));
264            assert_eq!(
265                file.old_mode.as_deref(),
266                (status != DiffStatus::Added).then_some("100644")
267            );
268            assert_eq!(
269                file.new_mode.as_deref(),
270                (status != DiffStatus::Deleted).then_some("100755")
271            );
272            assert!(file.stat.is_none() && file.structured.is_none());
273        }
274    }
275}