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, FileDiff, LineStat};
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, merge commits (2+ parents) produce empty file lists for every detail level
26    /// (like `git log` without `-m`, `-c`, or `--cc`).
27    pub skip_merge_patches: bool,
28}
29
30/// One commit's diff output in [`Diff::commit_files_many`] (same order as input oids).
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct CommitFilesResult {
33    /// Per-file diff bodies for this commit.
34    pub output: DiffFilesOutput,
35    /// Number of parents (0 = root).
36    pub parent_count: usize,
37}
38
39struct CommitWork {
40    tree: ObjectId,
41    parent_tree: Option<ObjectId>,
42    parent_count: usize,
43}
44
45fn rename_threshold(options: &DiffOptions) -> u32 {
46    options.renames.threshold_percent().unwrap_or(101)
47}
48
49fn filter_entries(
50    repo: &crate::repo::Repository,
51    entries: Vec<DiffEntry>,
52    pathspecs: &[String],
53) -> Result<Vec<DiffEntry>> {
54    if pathspecs.is_empty() {
55        return Ok(entries);
56    }
57    let matcher = repo.pathspec_matcher(pathspecs)?;
58    Ok(entries
59        .into_iter()
60        .filter(|entry| {
61            let path = entry
62                .new_path
63                .as_deref()
64                .or(entry.old_path.as_deref())
65                .unwrap_or("");
66            matcher.matches_change(path, entry.old_path.as_deref())
67        })
68        .collect())
69}
70
71fn parent_tree_for_commit(
72    odb: &Odb,
73    parent: &ObjectId,
74    trees_by_commit: &HashMap<ObjectId, ObjectId>,
75) -> Result<ObjectId> {
76    if let Some(tree) = trees_by_commit.get(parent) {
77        return Ok(*tree);
78    }
79    let raw = odb.read(parent)?;
80    Ok(parse_commit(&raw.data)?.tree)
81}
82
83fn totals_from_patch_files(files: &[FileDiff]) -> DiffTotals {
84    let lines: LineStat = files
85        .iter()
86        .filter_map(|file| {
87            file.structured
88                .as_ref()
89                .map(|structured| LineStat::from_hunks(&structured.hunks))
90        })
91        .sum();
92    DiffTotals {
93        files_changed: files.len(),
94        insertions: lines.insertions,
95        deletions: lines.deletions,
96    }
97}
98
99fn totals_for_detail(files: &[FileDiff], detail: DiffDetail) -> Option<DiffTotals> {
100    match detail {
101        DiffDetail::Stat => Some(DiffTotals::from_files(files)),
102        DiffDetail::Patch => Some(totals_from_patch_files(files)),
103        DiffDetail::NameOnly | DiffDetail::NameStatus => None,
104    }
105}
106
107fn empty_merge_output(detail: DiffDetail) -> DiffFilesOutput {
108    let totals = match detail {
109        DiffDetail::NameOnly | DiffDetail::NameStatus => None,
110        DiffDetail::Stat | DiffDetail::Patch => Some(DiffTotals {
111            files_changed: 0,
112            insertions: 0,
113            deletions: 0,
114        }),
115    };
116    DiffFilesOutput {
117        files: Vec::new(),
118        totals,
119        inexact_rename_skipped: None,
120    }
121}
122
123/// Parallel options for diffing many commits in one batch.
124///
125/// Honors [`ThreadPolicy::threads_override`] `Some(1)` (serial). When the caller did not
126/// cap threads, uses auto worker count so multi-commit batches can run in parallel like
127/// Git's `log -p`. Per-commit work still uses the caller's original policy.
128fn commit_files_many_parallel_options(
129    thread_policy: &ThreadPolicy,
130    config: &ConfigSet,
131) -> ParallelOptions {
132    let batch_policy = if thread_policy.threads_override == Some(1) {
133        *thread_policy
134    } else {
135        ThreadPolicy {
136            threads_override: thread_policy.threads_override.or(Some(0)),
137            min_items_override: thread_policy.min_items_override,
138            min_total_bytes_override: thread_policy.min_total_bytes_override,
139        }
140    };
141    let mut par_opts = batch_policy.resolve(ParallelOp::DiffContent, config);
142    par_opts.min_items = par_opts.min_items.min(2);
143    par_opts.min_total_bytes = None;
144    par_opts
145}
146
147fn diff_one_commit(
148    diff: &Diff,
149    work: &CommitWork,
150    pathspecs: &[String],
151    opts: &CommitFilesManyOptions,
152    config: &ConfigSet,
153    thread_policy: &ThreadPolicy,
154) -> Result<CommitFilesResult> {
155    if opts.skip_merge_patches && work.parent_count > 1 {
156        return Ok(CommitFilesResult {
157            output: empty_merge_output(opts.detail),
158            parent_count: work.parent_count,
159        });
160    }
161
162    let repo = diff.repository();
163    let odb = repo.odb();
164    let pathspec_matcher = if pathspecs.is_empty() {
165        None
166    } else {
167        Some(repo.pathspec_matcher(pathspecs)?)
168    };
169    let mut entries = diff_trees_with_renames_for_pathspecs_and_policy(
170        odb,
171        work.parent_tree.as_ref(),
172        Some(&work.tree),
173        "",
174        pathspecs,
175        pathspec_matcher.as_ref(),
176        rename_threshold(&opts.diff_options),
177        config,
178        thread_policy,
179    )?;
180    if rename_threshold(&opts.diff_options) >= 100 {
181        super::sort_entries_by_path(&mut entries);
182    }
183    entries = filter_entries(diff.repository(), entries, pathspecs)?;
184    let mut output = build_file_diffs(
185        odb,
186        entries,
187        opts.detail,
188        opts.diff_options
189            .clone()
190            .with_renames(crate::diff::RenameDetection::Off),
191        config,
192        thread_policy,
193    )?;
194    if output.totals.is_none() {
195        output.totals = totals_for_detail(&output.files, opts.detail);
196    }
197    Ok(CommitFilesResult {
198        output,
199        parent_count: work.parent_count,
200    })
201}
202
203/// One commit's identity for [`Diff::commit_files_many_work`], avoiding a second ODB read.
204#[derive(Debug, Clone)]
205pub struct CommitFilesManyInput {
206    /// Commit object id (order matches the log walk).
207    pub oid: ObjectId,
208    /// First-parent chain and other parents from the commit object.
209    pub parents: Vec<ObjectId>,
210    /// Tree object id for this commit.
211    pub tree: ObjectId,
212}
213
214impl Diff<'_> {
215    /// Build per-commit file diffs for a slice of commit oids, preserving input order.
216    ///
217    /// Parent trees are resolved once and reused across consecutive commits (same as
218    /// [`super::write_commits_log_patch`]). When `oids` are newest-first and history is
219    /// linear, each commit's first parent is the next oid and its tree is reused without
220    /// an extra read. Work runs in parallel when [`ThreadPolicy`] allows and the batch is
221    /// large enough to amortize pool startup.
222    ///
223    /// # Errors
224    ///
225    /// Propagates object read, tree diff, and parallel pool errors.
226    pub fn commit_files_many(
227        &self,
228        oids: &[ObjectId],
229        pathspecs: &[String],
230        opts: CommitFilesManyOptions,
231        config: &ConfigSet,
232        thread_policy: &ThreadPolicy,
233    ) -> Result<Vec<CommitFilesResult>> {
234        if oids.is_empty() {
235            return Ok(Vec::new());
236        }
237        let repo = self.repository();
238        let odb = repo.odb();
239        let parent_ctx = crate::shallow::EffectiveCommitParents::load(repo);
240        let inputs = oids
241            .iter()
242            .map(|oid| {
243                let commit = odb.read_commit(oid)?;
244                Ok(CommitFilesManyInput {
245                    oid: *oid,
246                    parents: parent_ctx.for_commit(*oid, &commit.parents),
247                    tree: commit.tree,
248                })
249            })
250            .collect::<Result<Vec<_>>>()?;
251        self.commit_files_many_work(&inputs, pathspecs, opts, config, thread_policy)
252    }
253
254    /// Lines each commit added and removed against its first parent, in input order.
255    ///
256    /// This is the per-commit summary of `git log --shortstat`: a root commit is compared with
257    /// the empty tree, and a merge (two or more parents) is not diffed at all and gets `None`.
258    /// Commits that change no lines (or only binary files) get `Some` totals with zero
259    /// insertions and deletions, so callers decide whether to show them.
260    ///
261    /// # Parameters
262    ///
263    /// - `oids`: commits to summarize; newest-first linear history reuses each parent tree.
264    /// - `pathspecs`: resolved pathspecs limiting the counted paths (empty counts every path).
265    /// - `diff_options`: rename and line-diff settings, usually [`DiffOptions::from_config`].
266    /// - `config`: repository configuration for attributes and parallelism.
267    /// - `thread_policy`: worker limits for the batch.
268    ///
269    /// # Errors
270    ///
271    /// Propagates object read (including a non-commit `oid`), tree diff, and parallel pool
272    /// errors.
273    pub fn commit_line_totals(
274        &self,
275        oids: &[ObjectId],
276        pathspecs: &[String],
277        diff_options: DiffOptions,
278        config: &ConfigSet,
279        thread_policy: &ThreadPolicy,
280    ) -> Result<Vec<Option<DiffTotals>>> {
281        let repo = self.repository();
282        let odb = repo.odb();
283        let parent_ctx = crate::shallow::EffectiveCommitParents::load(repo);
284        let mut inputs = Vec::with_capacity(oids.len());
285        let mut slots = Vec::with_capacity(oids.len());
286        for oid in oids {
287            let commit = odb.read_commit(oid)?;
288            let parents = parent_ctx.for_commit(*oid, &commit.parents);
289            if parents.len() > 1 {
290                slots.push(None);
291                continue;
292            }
293            slots.push(Some(inputs.len()));
294            inputs.push(CommitFilesManyInput {
295                oid: *oid,
296                parents,
297                tree: commit.tree,
298            });
299        }
300        let opts = CommitFilesManyOptions {
301            diff_options,
302            detail: DiffDetail::Stat,
303            skip_merge_patches: true,
304        };
305        let results =
306            self.commit_files_many_work(&inputs, pathspecs, opts, config, thread_policy)?;
307        Ok(slots
308            .into_iter()
309            .map(|slot| {
310                let output = &results.get(slot?)?.output;
311                Some(
312                    output
313                        .totals
314                        .unwrap_or_else(|| DiffTotals::from_files(&output.files)),
315                )
316            })
317            .collect())
318    }
319
320    /// Like [`Self::commit_files_many`] when commit trees and parents are already loaded.
321    ///
322    /// # Errors
323    ///
324    /// Propagates object read, tree diff, and parallel pool errors.
325    pub fn commit_files_many_work(
326        &self,
327        inputs: &[CommitFilesManyInput],
328        pathspecs: &[String],
329        opts: CommitFilesManyOptions,
330        config: &ConfigSet,
331        thread_policy: &ThreadPolicy,
332    ) -> Result<Vec<CommitFilesResult>> {
333        if inputs.is_empty() {
334            return Ok(Vec::new());
335        }
336        let repo = self.repository();
337        let odb = repo.odb();
338        let mut trees_by_commit: HashMap<ObjectId, ObjectId> = HashMap::with_capacity(inputs.len());
339        for input in inputs {
340            trees_by_commit.insert(input.oid, input.tree);
341        }
342
343        let mut work_items: Vec<CommitWork> = Vec::with_capacity(inputs.len());
344        for (i, input) in inputs.iter().enumerate() {
345            let parent_count = input.parents.len();
346            let parent_tree = if parent_count == 0 {
347                None
348            } else if i + 1 < inputs.len() && input.parents[0] == inputs[i + 1].oid {
349                Some(inputs[i + 1].tree)
350            } else {
351                Some(parent_tree_for_commit(
352                    odb,
353                    &input.parents[0],
354                    &trees_by_commit,
355                )?)
356            };
357            work_items.push(CommitWork {
358                tree: input.tree,
359                parent_tree,
360                parent_count,
361            });
362        }
363
364        odb.with_pack_read_context(|| {
365            let par_opts = commit_files_many_parallel_options(thread_policy, config);
366            try_par_map(&work_items, par_opts, 0, |work| {
367                diff_one_commit(self, work, pathspecs, &opts, config, thread_policy)
368            })
369            .map_err(|err| match err {
370                ParallelHashError::Task(e) => e,
371            })
372        })
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use crate::objects::{serialize_commit, CommitData, ObjectKind, TreeEntry};
380    use crate::repo::{InitOptions, Repository};
381
382    fn tree_with(repo: &Repository, entries: &[(&str, &[u8])]) -> ObjectId {
383        let mut tes = Vec::new();
384        for (name, data) in entries {
385            let oid = repo.odb().write(ObjectKind::Blob, data).expect("blob");
386            tes.push(TreeEntry {
387                mode: 0o100644,
388                name: name.as_bytes().to_vec(),
389                oid,
390            });
391        }
392        let data = crate::objects::serialize_tree(&tes);
393        repo.odb().write(ObjectKind::Tree, &data).expect("tree")
394    }
395
396    fn write_commit(
397        repo: &Repository,
398        tree: ObjectId,
399        parents: Vec<ObjectId>,
400        msg: &str,
401    ) -> ObjectId {
402        let ident = "A <a@example.com> 0 +0000";
403        let commit = CommitData {
404            tree,
405            parents,
406            author: ident.into(),
407            committer: ident.into(),
408            author_raw: Vec::new(),
409            committer_raw: Vec::new(),
410            encoding: None,
411            message: format!("{msg}\n"),
412            raw_message: None,
413            extra_headers: Vec::new(),
414        };
415        repo.odb()
416            .write(ObjectKind::Commit, &serialize_commit(&commit))
417            .expect("commit")
418    }
419
420    #[test]
421    fn commit_files_many_matches_single_commit_calls() -> Result<()> {
422        let dir = tempfile::tempdir().expect("tempdir");
423        let repo = Repository::init(dir.path(), &InitOptions::default())?;
424        let tree1 = tree_with(&repo, &[("f.txt", b"a\n")]);
425        let root = write_commit(&repo, tree1, vec![], "root");
426        let tree2 = tree_with(&repo, &[("f.txt", b"b\n")]);
427        let child = write_commit(&repo, tree2, vec![root], "child");
428
429        let config = ConfigSet::default();
430        let policy = ThreadPolicy::default();
431        let diff_options = DiffOptions::default();
432        let opts = CommitFilesManyOptions {
433            diff_options,
434            detail: DiffDetail::Stat,
435            skip_merge_patches: true,
436        };
437
438        let many = repo
439            .diff()
440            .commit_files_many(&[child], &[], opts.clone(), &config, &policy)?;
441        assert_eq!(many.len(), 1);
442
443        let entries = diff_trees_with_renames_for_pathspecs_and_policy(
444            repo.odb(),
445            Some(&tree1),
446            Some(&tree2),
447            "",
448            &[],
449            None,
450            101,
451            &config,
452            &policy,
453        )?;
454        let single = build_file_diffs(
455            repo.odb(),
456            entries,
457            opts.detail,
458            opts.diff_options
459                .clone()
460                .with_renames(crate::diff::RenameDetection::Off),
461            &config,
462            &policy,
463        )?;
464        assert_eq!(many[0].output.files, single.files);
465
466        let policy_serial = ThreadPolicy::with_threads_override(Some(1));
467        let policy_par = ThreadPolicy::with_threads_override(Some(4))
468            .min_items_override(Some(1))
469            .min_total_bytes_override(Some(0));
470        let tree3 = tree_with(&repo, &[("f.txt", b"c\n")]);
471        let mid = write_commit(&repo, tree3, vec![child], "mid");
472        let tree4 = tree_with(&repo, &[("f.txt", b"d\n")]);
473        let tip = write_commit(&repo, tree4, vec![mid], "tip");
474        let batch = [tip, mid, child];
475        let many_par =
476            repo.diff()
477                .commit_files_many(&batch, &[], opts.clone(), &config, &policy_par)?;
478        let many_serial =
479            repo.diff()
480                .commit_files_many(&batch, &[], opts, &config, &policy_serial)?;
481        assert_eq!(many_par.len(), 3);
482        assert_eq!(many_par, many_serial);
483        Ok(())
484    }
485
486    #[test]
487    fn commit_line_totals_skip_merges_and_honor_pathspecs() -> Result<()> {
488        let dir = tempfile::tempdir().expect("tempdir");
489        let repo = Repository::init(dir.path(), &InitOptions::default())?;
490        let root_tree = tree_with(&repo, &[("a.txt", b"1\n2\n"), ("b.txt", b"x\n")]);
491        let root = write_commit(&repo, root_tree, vec![], "root");
492        let edit_tree = tree_with(&repo, &[("a.txt", b"1\nchanged\n3\n"), ("b.txt", b"x\n")]);
493        let edit = write_commit(&repo, edit_tree, vec![root], "edit a");
494        let side_tree = tree_with(&repo, &[("a.txt", b"1\n2\n"), ("b.txt", b"y\n")]);
495        let side = write_commit(&repo, side_tree, vec![root], "edit b");
496        let merge = write_commit(&repo, edit_tree, vec![edit, side], "merge");
497        let binary_tree = tree_with(
498            &repo,
499            &[
500                ("a.txt", b"1\nchanged\n3\n"),
501                ("b.txt", b"x\n"),
502                ("c.bin", b"\0"),
503            ],
504        );
505        let binary = write_commit(&repo, binary_tree, vec![merge], "add binary");
506        let oids = [binary, merge, side, edit, root];
507
508        let config = ConfigSet::default();
509        let totals = repo.diff().commit_line_totals(
510            &oids,
511            &[],
512            DiffOptions::default(),
513            &config,
514            &ThreadPolicy::default(),
515        )?;
516        let lines = |t: Option<DiffTotals>| t.map(|t| (t.files_changed, t.insertions, t.deletions));
517        assert_eq!(
518            totals.into_iter().map(lines).collect::<Vec<_>>(),
519            vec![
520                Some((1, 0, 0)),
521                None,
522                Some((1, 1, 1)),
523                Some((1, 2, 1)),
524                Some((2, 3, 0)),
525            ]
526        );
527
528        let only_b = repo.diff().commit_line_totals(
529            &oids,
530            &["b.txt".to_owned()],
531            DiffOptions::default(),
532            &config,
533            &ThreadPolicy::with_threads_override(Some(1)),
534        )?;
535        assert_eq!(
536            only_b.into_iter().map(lines).collect::<Vec<_>>(),
537            vec![
538                Some((0, 0, 0)),
539                None,
540                Some((1, 1, 1)),
541                Some((0, 0, 0)),
542                Some((1, 1, 0)),
543            ]
544        );
545
546        assert!(repo
547            .diff()
548            .commit_line_totals(
549                &[root_tree],
550                &[],
551                DiffOptions::default(),
552                &config,
553                &ThreadPolicy::default(),
554            )
555            .is_err());
556        Ok(())
557    }
558
559    #[test]
560    fn patch_totals_match_hunk_line_counts() -> Result<()> {
561        let dir = tempfile::tempdir().expect("tempdir");
562        let repo = Repository::init(dir.path(), &InitOptions::default())?;
563        let tree1 = tree_with(&repo, &[("a.txt", b"one\n")]);
564        let root = write_commit(&repo, tree1, vec![], "root");
565        let tree2 = tree_with(&repo, &[("a.txt", b"one\ntwo\n")]);
566        let child = write_commit(&repo, tree2, vec![root], "add line");
567
568        let config = ConfigSet::default();
569        let policy = ThreadPolicy::default();
570        let opts = CommitFilesManyOptions {
571            diff_options: DiffOptions::default(),
572            detail: DiffDetail::Patch,
573            skip_merge_patches: true,
574        };
575        let results =
576            repo.diff()
577                .commit_files_many(&[child], &[], opts.clone(), &config, &policy)?;
578        let totals = results[0].output.totals.expect("patch totals");
579        assert_eq!(
580            (totals.files_changed, totals.insertions, totals.deletions),
581            (1, 1, 0)
582        );
583
584        let name_status_opts = CommitFilesManyOptions {
585            detail: DiffDetail::NameStatus,
586            ..opts.clone()
587        };
588        let name_status =
589            repo.diff()
590                .commit_files_many(&[child], &[], name_status_opts, &config, &policy)?;
591        assert!(name_status[0].output.totals.is_none());
592        Ok(())
593    }
594
595    #[test]
596    fn skip_merge_patches_empties_stat_and_name_modes() -> Result<()> {
597        let dir = tempfile::tempdir().expect("tempdir");
598        let repo = Repository::init(dir.path(), &InitOptions::default())?;
599        let base_tree = tree_with(&repo, &[("a.txt", b"one\n")]);
600        let base = write_commit(&repo, base_tree, vec![], "base");
601        let side_tree = tree_with(&repo, &[("a.txt", b"one\n"), ("b.txt", b"side\n")]);
602        let side = write_commit(&repo, side_tree, vec![base], "side");
603        let main_tree = tree_with(&repo, &[("a.txt", b"one\ntwo\n")]);
604        let main = write_commit(&repo, main_tree, vec![base], "main");
605        let merge = write_commit(&repo, side_tree, vec![main, side], "merge");
606
607        let config = ConfigSet::default();
608        let policy = ThreadPolicy::default();
609        let base_opts = CommitFilesManyOptions {
610            diff_options: DiffOptions::default(),
611            detail: DiffDetail::Stat,
612            skip_merge_patches: true,
613        };
614
615        for detail in [
616            DiffDetail::Patch,
617            DiffDetail::Stat,
618            DiffDetail::NameOnly,
619            DiffDetail::NameStatus,
620        ] {
621            let opts = CommitFilesManyOptions {
622                detail,
623                ..base_opts.clone()
624            };
625            let results = repo
626                .diff()
627                .commit_files_many(&[merge], &[], opts, &config, &policy)?;
628            assert!(
629                results[0].output.files.is_empty(),
630                "merge files should be empty for {detail:?}"
631            );
632            match detail {
633                DiffDetail::NameOnly | DiffDetail::NameStatus => {
634                    assert!(results[0].output.totals.is_none());
635                }
636                DiffDetail::Patch | DiffDetail::Stat => {
637                    assert_eq!(
638                        results[0].output.totals,
639                        Some(DiffTotals {
640                            files_changed: 0,
641                            insertions: 0,
642                            deletions: 0,
643                        })
644                    );
645                }
646            }
647        }
648        Ok(())
649    }
650}