1use 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#[derive(Debug, Clone)]
20pub struct CommitFilesManyOptions {
21 pub diff_options: DiffOptions,
23 pub detail: DiffDetail,
25 pub skip_merge_patches: bool,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct CommitFilesResult {
33 pub output: DiffFilesOutput,
35 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
123fn 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#[derive(Debug, Clone)]
205pub struct CommitFilesManyInput {
206 pub oid: ObjectId,
208 pub parents: Vec<ObjectId>,
210 pub tree: ObjectId,
212}
213
214impl Diff<'_> {
215 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 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 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}