Skip to main content

grit_lib/diff/
commit_files_many.rs

1//! Per-commit file diffs for many commits (used by `grit log -p` / `--stat`).
2
3use std::collections::HashMap;
4
5use crate::config::ConfigSet;
6use crate::error::Result;
7use crate::objects::{parse_commit, ObjectId};
8use crate::odb::Odb;
9use crate::parallel::{try_par_map, ParallelHashError, ParallelOp, ParallelOptions, ThreadPolicy};
10
11use super::core::diff_trees_with_renames_for_pathspecs_and_policy;
12use super::detail::{DiffDetail, DiffTotals};
13use super::files::{build_file_diffs, DiffFilesOutput};
14use super::options::DiffOptions;
15use super::Diff;
16use super::DiffEntry;
17
18/// Options for [`Diff::commit_files_many`].
19#[derive(Debug, Clone)]
20pub struct CommitFilesManyOptions {
21    /// Line diff and rename settings.
22    pub diff_options: DiffOptions,
23    /// How much to compute per changed file.
24    pub detail: DiffDetail,
25    /// When set with [`DiffDetail::Patch`], merge commits (2+ parents) produce empty file lists.
26    pub skip_merge_patches: bool,
27}
28
29/// One commit's diff output in [`Diff::commit_files_many`] (same order as input oids).
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct CommitFilesResult {
32    /// Per-file diff bodies for this commit.
33    pub output: DiffFilesOutput,
34    /// Number of parents (0 = root).
35    pub parent_count: usize,
36}
37
38struct CommitWork {
39    tree: ObjectId,
40    parent_tree: Option<ObjectId>,
41    parent_count: usize,
42}
43
44fn rename_threshold(options: &DiffOptions) -> u32 {
45    options.renames.threshold_percent().unwrap_or(101)
46}
47
48fn filter_entries(
49    repo: &crate::repo::Repository,
50    entries: Vec<DiffEntry>,
51    pathspecs: &[String],
52) -> Result<Vec<DiffEntry>> {
53    if pathspecs.is_empty() {
54        return Ok(entries);
55    }
56    let matcher = repo.pathspec_matcher(pathspecs)?;
57    Ok(entries
58        .into_iter()
59        .filter(|entry| {
60            let path = entry
61                .new_path
62                .as_deref()
63                .or(entry.old_path.as_deref())
64                .unwrap_or("");
65            matcher.matches_change(path, entry.old_path.as_deref())
66        })
67        .collect())
68}
69
70fn parent_tree_for_commit(
71    odb: &Odb,
72    parent: &ObjectId,
73    trees_by_commit: &HashMap<ObjectId, ObjectId>,
74) -> Result<ObjectId> {
75    if let Some(tree) = trees_by_commit.get(parent) {
76        return Ok(*tree);
77    }
78    let raw = odb.read(parent)?;
79    Ok(parse_commit(&raw.data)?.tree)
80}
81
82fn totals_for_detail(files: &[super::detail::FileDiff], detail: DiffDetail) -> Option<DiffTotals> {
83    match detail {
84        DiffDetail::Stat | DiffDetail::Patch => Some(DiffTotals::from_files(files)),
85        DiffDetail::NameOnly | DiffDetail::NameStatus => Some(DiffTotals {
86            files_changed: files.len(),
87            insertions: 0,
88            deletions: 0,
89        }),
90    }
91}
92
93/// Parallel options for diffing many commits in one batch.
94///
95/// Honors [`ThreadPolicy::threads_override`] `Some(1)` (serial). When the caller did not
96/// cap threads, uses auto worker count so multi-commit batches can run in parallel like
97/// Git's `log -p`. Per-commit work still uses the caller's original policy.
98fn commit_files_many_parallel_options(
99    thread_policy: &ThreadPolicy,
100    config: &ConfigSet,
101) -> ParallelOptions {
102    let batch_policy = if thread_policy.threads_override == Some(1) {
103        *thread_policy
104    } else {
105        ThreadPolicy {
106            threads_override: thread_policy.threads_override.or(Some(0)),
107            min_items_override: thread_policy.min_items_override,
108            min_total_bytes_override: thread_policy.min_total_bytes_override,
109        }
110    };
111    let mut par_opts = batch_policy.resolve(ParallelOp::DiffContent, config);
112    par_opts.min_items = par_opts.min_items.min(2);
113    par_opts.min_total_bytes = None;
114    par_opts
115}
116
117fn diff_one_commit(
118    diff: &Diff,
119    work: &CommitWork,
120    pathspecs: &[String],
121    opts: &CommitFilesManyOptions,
122    config: &ConfigSet,
123    thread_policy: &ThreadPolicy,
124) -> Result<CommitFilesResult> {
125    if opts.skip_merge_patches && opts.detail == DiffDetail::Patch && work.parent_count > 1 {
126        return Ok(CommitFilesResult {
127            output: DiffFilesOutput {
128                files: Vec::new(),
129                totals: Some(DiffTotals {
130                    files_changed: 0,
131                    insertions: 0,
132                    deletions: 0,
133                }),
134                inexact_rename_skipped: None,
135            },
136            parent_count: work.parent_count,
137        });
138    }
139
140    let repo = diff.repository();
141    let odb = repo.odb();
142    let pathspec_matcher = if pathspecs.is_empty() {
143        None
144    } else {
145        Some(repo.pathspec_matcher(pathspecs)?)
146    };
147    let mut entries = diff_trees_with_renames_for_pathspecs_and_policy(
148        odb,
149        work.parent_tree.as_ref(),
150        Some(&work.tree),
151        "",
152        pathspecs,
153        pathspec_matcher.as_ref(),
154        rename_threshold(&opts.diff_options),
155        config,
156        thread_policy,
157    )?;
158    if rename_threshold(&opts.diff_options) >= 100 {
159        super::sort_entries_by_path(&mut entries);
160    }
161    entries = filter_entries(diff.repository(), entries, pathspecs)?;
162    let mut output = build_file_diffs(
163        odb,
164        entries,
165        opts.detail,
166        opts.diff_options
167            .clone()
168            .with_renames(crate::diff::RenameDetection::Off),
169        config,
170        thread_policy,
171    )?;
172    if output.totals.is_none() {
173        output.totals = totals_for_detail(&output.files, opts.detail);
174    }
175    Ok(CommitFilesResult {
176        output,
177        parent_count: work.parent_count,
178    })
179}
180
181/// One commit's identity for [`Diff::commit_files_many_work`], avoiding a second ODB read.
182#[derive(Debug, Clone)]
183pub struct CommitFilesManyInput {
184    /// Commit object id (order matches the log walk).
185    pub oid: ObjectId,
186    /// First-parent chain and other parents from the commit object.
187    pub parents: Vec<ObjectId>,
188    /// Tree object id for this commit.
189    pub tree: ObjectId,
190}
191
192impl Diff<'_> {
193    /// Build per-commit file diffs for a slice of commit oids, preserving input order.
194    ///
195    /// Parent trees are resolved once and reused across consecutive commits (same as
196    /// [`super::write_commits_log_patch`]). When `oids` are newest-first and history is
197    /// linear, each commit's first parent is the next oid and its tree is reused without
198    /// an extra read. Work runs in parallel when [`ThreadPolicy`] allows and the batch is
199    /// large enough to amortize pool startup.
200    ///
201    /// # Errors
202    ///
203    /// Propagates object read, tree diff, and parallel pool errors.
204    pub fn commit_files_many(
205        &self,
206        oids: &[ObjectId],
207        pathspecs: &[String],
208        opts: CommitFilesManyOptions,
209        config: &ConfigSet,
210        thread_policy: &ThreadPolicy,
211    ) -> Result<Vec<CommitFilesResult>> {
212        if oids.is_empty() {
213            return Ok(Vec::new());
214        }
215        let odb = self.repository().odb();
216        let mut inputs = Vec::with_capacity(oids.len());
217        for oid in oids {
218            let raw = odb.read(oid)?;
219            let commit = parse_commit(&raw.data)?;
220            inputs.push(CommitFilesManyInput {
221                oid: *oid,
222                parents: commit.parents.clone(),
223                tree: commit.tree,
224            });
225        }
226        self.commit_files_many_work(&inputs, pathspecs, opts, config, thread_policy)
227    }
228
229    /// Like [`Self::commit_files_many`] when commit trees and parents are already loaded.
230    ///
231    /// # Errors
232    ///
233    /// Propagates object read, tree diff, and parallel pool errors.
234    pub fn commit_files_many_work(
235        &self,
236        inputs: &[CommitFilesManyInput],
237        pathspecs: &[String],
238        opts: CommitFilesManyOptions,
239        config: &ConfigSet,
240        thread_policy: &ThreadPolicy,
241    ) -> Result<Vec<CommitFilesResult>> {
242        if inputs.is_empty() {
243            return Ok(Vec::new());
244        }
245        let repo = self.repository();
246        let odb = repo.odb();
247        let mut trees_by_commit: HashMap<ObjectId, ObjectId> = HashMap::with_capacity(inputs.len());
248        for input in inputs {
249            trees_by_commit.insert(input.oid, input.tree);
250        }
251
252        let mut work_items: Vec<CommitWork> = Vec::with_capacity(inputs.len());
253        for (i, input) in inputs.iter().enumerate() {
254            let parent_count = input.parents.len();
255            let parent_tree = if parent_count == 0 {
256                None
257            } else if i + 1 < inputs.len() && input.parents[0] == inputs[i + 1].oid {
258                Some(inputs[i + 1].tree)
259            } else {
260                Some(parent_tree_for_commit(
261                    odb,
262                    &input.parents[0],
263                    &trees_by_commit,
264                )?)
265            };
266            work_items.push(CommitWork {
267                tree: input.tree,
268                parent_tree,
269                parent_count,
270            });
271        }
272
273        odb.with_pack_read_context(|| {
274            let par_opts = commit_files_many_parallel_options(thread_policy, config);
275            try_par_map(&work_items, par_opts, 0, |work| {
276                diff_one_commit(self, work, pathspecs, &opts, config, thread_policy)
277            })
278            .map_err(|err| match err {
279                ParallelHashError::Task(e) => e,
280            })
281        })
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::objects::{serialize_commit, CommitData, ObjectKind, TreeEntry};
289    use crate::repo::{InitOptions, Repository};
290
291    fn tree_with(repo: &Repository, entries: &[(&str, &[u8])]) -> ObjectId {
292        let mut tes = Vec::new();
293        for (name, data) in entries {
294            let oid = repo.odb().write(ObjectKind::Blob, data).expect("blob");
295            tes.push(TreeEntry {
296                mode: 0o100644,
297                name: name.as_bytes().to_vec(),
298                oid,
299            });
300        }
301        let data = crate::objects::serialize_tree(&tes);
302        repo.odb().write(ObjectKind::Tree, &data).expect("tree")
303    }
304
305    fn write_commit(
306        repo: &Repository,
307        tree: ObjectId,
308        parents: Vec<ObjectId>,
309        msg: &str,
310    ) -> ObjectId {
311        let ident = "A <a@example.com> 0 +0000";
312        let commit = CommitData {
313            tree,
314            parents,
315            author: ident.into(),
316            committer: ident.into(),
317            author_raw: Vec::new(),
318            committer_raw: Vec::new(),
319            encoding: None,
320            message: format!("{msg}\n"),
321            raw_message: None,
322            extra_headers: Vec::new(),
323        };
324        repo.odb()
325            .write(ObjectKind::Commit, &serialize_commit(&commit))
326            .expect("commit")
327    }
328
329    #[test]
330    fn commit_files_many_matches_single_commit_calls() -> Result<()> {
331        let dir = tempfile::tempdir().expect("tempdir");
332        let repo = Repository::init(dir.path(), &InitOptions::default())?;
333        let tree1 = tree_with(&repo, &[("f.txt", b"a\n")]);
334        let root = write_commit(&repo, tree1, vec![], "root");
335        let tree2 = tree_with(&repo, &[("f.txt", b"b\n")]);
336        let child = write_commit(&repo, tree2, vec![root], "child");
337
338        let config = ConfigSet::default();
339        let policy = ThreadPolicy::default();
340        let diff_options = DiffOptions::default();
341        let opts = CommitFilesManyOptions {
342            diff_options,
343            detail: DiffDetail::Stat,
344            skip_merge_patches: true,
345        };
346
347        let many = repo
348            .diff()
349            .commit_files_many(&[child], &[], opts.clone(), &config, &policy)?;
350        assert_eq!(many.len(), 1);
351
352        let entries = diff_trees_with_renames_for_pathspecs_and_policy(
353            repo.odb(),
354            Some(&tree1),
355            Some(&tree2),
356            "",
357            &[],
358            None,
359            101,
360            &config,
361            &policy,
362        )?;
363        let single = build_file_diffs(
364            repo.odb(),
365            entries,
366            opts.detail,
367            opts.diff_options
368                .clone()
369                .with_renames(crate::diff::RenameDetection::Off),
370            &config,
371            &policy,
372        )?;
373        assert_eq!(many[0].output.files, single.files);
374
375        let policy_serial = ThreadPolicy::with_threads_override(Some(1));
376        let policy_par = ThreadPolicy::with_threads_override(Some(4))
377            .min_items_override(Some(1))
378            .min_total_bytes_override(Some(0));
379        let tree3 = tree_with(&repo, &[("f.txt", b"c\n")]);
380        let mid = write_commit(&repo, tree3, vec![child], "mid");
381        let tree4 = tree_with(&repo, &[("f.txt", b"d\n")]);
382        let tip = write_commit(&repo, tree4, vec![mid], "tip");
383        let batch = [tip, mid, child];
384        let many_par =
385            repo.diff()
386                .commit_files_many(&batch, &[], opts.clone(), &config, &policy_par)?;
387        let many_serial =
388            repo.diff()
389                .commit_files_many(&batch, &[], opts, &config, &policy_serial)?;
390        assert_eq!(many_par.len(), 3);
391        assert_eq!(many_par, many_serial);
392        Ok(())
393    }
394}