1use crate::config::ConfigSet;
4use crate::diff_rename_parallel::parallel_map_indices;
5use crate::error::{Error, Result};
6use crate::merge_diff::{diff_attr_forces_text, diff_driver_forces_text, diff_textconv_active};
7use crate::objects::pack_store::PackReadContextGuard;
8use crate::objects::FileMode;
9use crate::objects::ObjectId;
10use crate::objects::ObjectKind;
11use crate::objects::OidHashSet;
12use crate::odb::Odb;
13use crate::parallel::run_ordered_batches;
14use crate::parallel::{try_par_map_with_local, ParallelHashError};
15use crate::parallel::{ParallelOp, ParallelOptions, ThreadPolicy};
16use std::num::NonZeroUsize;
17use std::path::Path;
18use std::sync::atomic::{AtomicUsize, Ordering};
19use std::sync::Arc;
20
21use super::detail::{line_change_counts, LineStat};
22use super::structured::{structured_file_diff, SideBytes, StructuredFileDiff};
23use super::DiffEntry;
24use super::DiffOptions;
25
26fn note_blob_read(counter: Option<&AtomicUsize>) {
27 if let Some(c) = counter {
28 c.fetch_add(1, Ordering::Relaxed);
29 }
30}
31
32fn collect_tree_diff_blob_oids(entries: &[DiffEntry]) -> Vec<ObjectId> {
33 let mut set = OidHashSet::default();
34 for entry in entries {
35 if entry.old_mode != FileMode::GITLINK
36 && entry.old_mode != FileMode::ABSENT
37 && !entry.old_oid.is_zero()
38 {
39 set.insert(entry.old_oid);
40 }
41 if entry.new_mode != FileMode::GITLINK
42 && entry.new_mode != FileMode::ABSENT
43 && !entry.new_oid.is_zero()
44 {
45 set.insert(entry.new_oid);
46 }
47 }
48 set.into_iter().collect()
49}
50
51pub(crate) fn prefetch_tree_diff_blobs(odb: &Odb, entries: &[DiffEntry]) -> Result<()> {
52 if entries.is_empty() {
53 return Ok(());
54 }
55 odb.prefetch_promisor_read_batch(collect_tree_diff_blob_oids(entries))
56}
57
58const PER_ENTRY_POOL_MIN_ITEMS: usize = 16;
65
66fn per_entry_parallel_options(
72 thread_policy: &ThreadPolicy,
73 config: &ConfigSet,
74 entry_count: usize,
75) -> ParallelOptions {
76 let min_items = thread_policy
77 .min_items_override
78 .unwrap_or(PER_ENTRY_POOL_MIN_ITEMS)
79 .max(2);
80 if entry_count < min_items {
81 return ParallelOptions {
82 threads: NonZeroUsize::MIN,
83 min_items: 0,
84 min_total_bytes: None,
85 signed_entry_threshold: None,
86 };
87 }
88 let mut par_opts = thread_policy.resolve(ParallelOp::DiffContent, config);
89 par_opts.min_items = 0;
90 par_opts.min_total_bytes = None;
91 par_opts
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct TreeDiffStructured {
97 pub entry: DiffEntry,
99 pub structured: StructuredFileDiff,
101 pub binary: bool,
103 pub patch_bytes: Option<(SideBytes, SideBytes)>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct TreeDiffContent {
110 pub entry: DiffEntry,
112 pub old_bytes: SideBytes,
114 pub new_bytes: SideBytes,
116 pub binary: bool,
118}
119
120pub fn tree_diff_contents_parallel(
129 odb: &Odb,
130 entries: Vec<DiffEntry>,
131 config: &ConfigSet,
132 thread_policy: &ThreadPolicy,
133) -> Result<Vec<TreeDiffContent>> {
134 if entries.is_empty() {
135 return Ok(Vec::new());
136 }
137 prefetch_tree_diff_blobs(odb, &entries)?;
138 let mut par_opts = thread_policy.resolve(ParallelOp::DiffContent, config);
139 par_opts.min_items = par_opts.min_items.min(8);
140 let est_bytes = entries.len().saturating_mul(8192);
141 let rows = parallel_map_indices(odb, entries.len(), par_opts, est_bytes, |i| {
142 let entry = &entries[i];
143 let (old_bytes, old_bin) = load_old_side(odb, entry, None)?;
144 let (new_bytes, new_bin) = load_new_side(odb, entry, None)?;
145 Ok::<TreeDiffContent, Error>(TreeDiffContent {
146 entry: entry.clone(),
147 old_bytes,
148 new_bytes,
149 binary: old_bin || new_bin,
150 })
151 })?;
152 Ok(rows)
153}
154
155pub fn tree_diff_stat_parallel(
164 odb: &Odb,
165 entries: Vec<DiffEntry>,
166 config: &ConfigSet,
167 thread_policy: &ThreadPolicy,
168 diff_options: DiffOptions,
169) -> Result<Vec<(DiffEntry, bool, Option<LineStat>)>> {
170 if entries.is_empty() {
171 return Ok(Vec::new());
172 }
173 prefetch_tree_diff_blobs(odb, &entries)?;
174 let par_opts = per_entry_parallel_options(thread_policy, config, entries.len());
175 let line_diff_opts = diff_options;
176 let blob_counter = line_diff_opts.blob_read_counter.clone();
177 let store = odb.pack_store_for(odb.objects_dir());
178 let rows = try_par_map_with_local(
179 &entries,
180 par_opts,
181 0,
182 || PackReadContextGuard::install(Arc::clone(&store)),
183 move |_ctx, entry| {
184 let blob_reads = blob_counter.as_deref();
185 if !entry.old_oid.is_zero()
186 && entry.old_oid == entry.new_oid
187 && entry.old_mode == entry.new_mode
188 {
189 let (_, old_bin) = load_old_side(odb, entry, blob_reads)?;
190 let binary = old_bin;
191 return Ok((
192 entry.clone(),
193 binary,
194 if binary {
195 None
196 } else {
197 Some(LineStat::default())
198 },
199 ));
200 }
201 let (old_bytes, old_bin) = load_old_side(odb, entry, blob_reads)?;
202 let (new_bytes, new_bin) = load_new_side(odb, entry, blob_reads)?;
203 let binary = old_bin || new_bin;
204 let stat = if binary {
205 None
206 } else {
207 Some(line_change_counts(
208 &String::from_utf8_lossy(&old_bytes.data),
209 &String::from_utf8_lossy(&new_bytes.data),
210 &line_diff_opts,
211 ))
212 };
213 Ok((entry.clone(), binary, stat))
214 },
215 );
216 match rows {
217 Ok(rows) => Ok(rows),
218 Err(ParallelHashError::Task(err)) => Err(err),
219 }
220}
221
222pub fn tree_diff_structured_entry(
228 odb: &Odb,
229 entry: &DiffEntry,
230 diff_options: &DiffOptions,
231) -> Result<TreeDiffStructured> {
232 let blob_reads = diff_options.blob_read_counter.as_deref();
233 if !entry.old_oid.is_zero()
234 && entry.old_oid == entry.new_oid
235 && entry.old_mode == entry.new_mode
236 {
237 let (old_bytes, old_bin) = load_old_side(odb, entry, blob_reads)?;
238 return Ok(TreeDiffStructured {
239 entry: entry.clone(),
240 structured: StructuredFileDiff {
241 old_mode: entry.old_mode.octal_if_present(),
242 new_mode: entry.new_mode.octal_if_present(),
243 old_encoding_lossy: old_bytes.encoding_lossy,
244 new_encoding_lossy: old_bytes.encoding_lossy,
245 hunks: Vec::new(),
246 },
247 binary: old_bin,
248 patch_bytes: None,
249 });
250 }
251 let (old_bytes, old_bin) = load_old_side(odb, entry, blob_reads)?;
252 let (new_bytes, new_bin) = load_new_side(odb, entry, blob_reads)?;
253 let binary = old_bin || new_bin;
254 let structured = if binary {
255 StructuredFileDiff {
256 old_mode: entry.old_mode.octal_if_present(),
257 new_mode: entry.new_mode.octal_if_present(),
258 old_encoding_lossy: old_bytes.encoding_lossy,
259 new_encoding_lossy: new_bytes.encoding_lossy,
260 hunks: Vec::new(),
261 }
262 } else {
263 structured_file_diff(
264 entry.old_mode,
265 entry.new_mode,
266 &old_bytes,
267 &new_bytes,
268 diff_options.clone(),
269 )
270 };
271 Ok(TreeDiffStructured {
272 entry: entry.clone(),
273 structured,
274 binary,
275 patch_bytes: None,
276 })
277}
278
279pub fn for_each_tree_diff_structured(
291 odb: &Odb,
292 entries: &[DiffEntry],
293 diff_options: DiffOptions,
294 config: &ConfigSet,
295 thread_policy: &ThreadPolicy,
296 mut visit: impl FnMut(TreeDiffStructured) -> Result<()>,
297) -> Result<()> {
298 if entries.is_empty() {
299 return Ok(());
300 }
301 prefetch_tree_diff_blobs(odb, entries)?;
302 let diff_options = diff_options.cap_word_level_inline_for_changed_files(entries.len());
303 let par_opts = per_entry_parallel_options(thread_policy, config, entries.len());
304 let n_workers = par_opts.threads.get();
305 if n_workers <= 1 || entries.len() < 2 {
306 for entry in entries {
307 visit(tree_diff_structured_entry(odb, entry, &diff_options)?)?;
308 }
309 return Ok(());
310 }
311 let line_diff_opts = diff_options;
312 let store = odb.pack_store_for(odb.objects_dir());
313 run_ordered_batches(
314 entries,
315 1,
316 n_workers,
317 || PackReadContextGuard::install(Arc::clone(&store)),
318 move |_ctx, entry| tree_diff_structured_entry(odb, entry, &line_diff_opts),
319 move |_batch, rows| {
320 for row in rows {
321 visit(row)?;
322 }
323 Ok(())
324 },
325 )
326}
327
328pub fn tree_diff_structured_parallel(
337 odb: &Odb,
338 entries: Vec<DiffEntry>,
339 config: &ConfigSet,
340 thread_policy: &ThreadPolicy,
341 diff_options: DiffOptions,
342) -> Result<Vec<TreeDiffStructured>> {
343 if entries.is_empty() {
344 return Ok(Vec::new());
345 }
346 prefetch_tree_diff_blobs(odb, &entries)?;
347 let diff_options = diff_options.cap_word_level_inline_for_changed_files(entries.len());
348 let par_opts = per_entry_parallel_options(thread_policy, config, entries.len());
349 let line_diff_opts = diff_options;
350 let store = odb.pack_store_for(odb.objects_dir());
351 let rows = try_par_map_with_local(
352 &entries,
353 par_opts,
354 0,
355 || PackReadContextGuard::install(Arc::clone(&store)),
356 move |_ctx, entry| tree_diff_structured_entry(odb, entry, &line_diff_opts),
357 );
358 match rows {
359 Ok(rows) => Ok(rows),
360 Err(ParallelHashError::Task(err)) => Err(err),
361 }
362}
363
364pub(crate) fn load_old_side(
365 odb: &Odb,
366 e: &DiffEntry,
367 blob_reads: Option<&AtomicUsize>,
368) -> Result<(SideBytes, bool)> {
369 if e.old_mode == FileMode::GITLINK {
370 return Ok((
371 SideBytes::from_bytes(gitlink_line(&e.old_oid).into_bytes()),
372 false,
373 ));
374 }
375 if e.old_mode == FileMode::ABSENT {
376 return Ok((SideBytes::from_bytes(Vec::new()), false));
377 }
378 let path = e.old_path.as_deref().or(e.new_path.as_deref());
379 load_odb_blob_side_for_diff(odb, &e.old_oid, path, blob_reads, false)
380}
381
382pub(crate) fn load_new_side(
383 odb: &Odb,
384 e: &DiffEntry,
385 blob_reads: Option<&AtomicUsize>,
386) -> Result<(SideBytes, bool)> {
387 if e.new_mode == FileMode::GITLINK {
388 return Ok((
389 SideBytes::from_bytes(gitlink_line(&e.new_oid).into_bytes()),
390 false,
391 ));
392 }
393 if e.new_mode == FileMode::ABSENT {
394 return Ok((SideBytes::from_bytes(Vec::new()), false));
395 }
396 let path = e.new_path.as_deref().or(e.old_path.as_deref());
397 load_odb_blob_side_for_diff(odb, &e.new_oid, path, blob_reads, false)
398}
399
400pub(crate) fn gitlink_line(oid: &ObjectId) -> String {
403 if *oid == ObjectId::zero() {
404 return String::new();
405 }
406 format!("Subproject commit {}\n", oid.to_hex())
407}
408
409fn git_dir_for_diff(odb: &Odb) -> Option<&Path> {
410 odb.config_git_dir().or_else(|| odb.objects_dir().parent())
411}
412
413fn diff_side_must_read_large_blob(odb: &Odb, path: &str) -> Result<bool> {
416 let Some(git_dir) = git_dir_for_diff(odb) else {
417 return Ok(false);
418 };
419 if diff_attr_forces_text(git_dir, path) {
420 return Ok(true);
421 }
422 let config = crate::config::ConfigSet::load(
423 &crate::environment::Environment::empty(),
424 Some(git_dir),
425 true,
426 )?;
427 if diff_textconv_active(git_dir, &config, path) {
428 return Ok(true);
429 }
430 Ok(diff_driver_forces_text(git_dir, &config, path))
431}
432
433pub(crate) fn treat_side_as_binary_without_reading(
436 odb: &Odb,
437 size: u64,
438 path: Option<&str>,
439 force_read_content: bool,
440) -> Result<bool> {
441 if force_read_content || size == 0 {
442 return Ok(false);
443 }
444 let threshold = odb.big_file_threshold()?;
445 if size <= threshold {
446 return Ok(false);
447 }
448 if let Some(path) = path {
449 if diff_side_must_read_large_blob(odb, path)? {
450 return Ok(false);
451 }
452 }
453 Ok(true)
454}
455
456pub(crate) fn side_bytes_after_blob_read(
461 data: Vec<u8>,
462 retain_binary_payload: bool,
463) -> (SideBytes, bool) {
464 let binary = crate::crlf::is_binary(&data);
465 if binary && !retain_binary_payload {
466 (SideBytes::from_bytes(Vec::new()), true)
467 } else {
468 (SideBytes::from_bytes(data), binary)
469 }
470}
471
472pub fn load_odb_blob_side_for_diff(
482 odb: &Odb,
483 oid: &ObjectId,
484 path: Option<&str>,
485 blob_reads: Option<&AtomicUsize>,
486 force_read_content: bool,
487) -> Result<(SideBytes, bool)> {
488 if *oid == ObjectId::zero() {
489 return Ok((SideBytes::from_bytes(Vec::new()), false));
490 }
491 if !force_read_content {
492 let info = odb.read_info(oid)?;
493 if info.kind == ObjectKind::Blob
494 && treat_side_as_binary_without_reading(odb, info.size, path, false)?
495 {
496 return Ok((SideBytes::from_bytes(Vec::new()), true));
497 }
498 }
499 note_blob_read(blob_reads);
500 #[cfg(test)]
501 odb.hot_path_metrics().record_blob_content_read();
502 let data = odb.read(oid)?.data;
503 Ok(side_bytes_after_blob_read(data, force_read_content))
504}
505
506#[must_use]
508pub fn bytes_for_diff(data: Vec<u8>) -> (SideBytes, bool) {
509 if crate::crlf::is_binary(&data) {
510 (SideBytes::from_bytes(Vec::new()), true)
511 } else {
512 (SideBytes::from_bytes(data), false)
513 }
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519 use crate::config::ConfigSet;
520 use crate::diff::{diff_trees, DiffOptions, DiffStatus};
521 use crate::objects::{serialize_tree, ObjectKind, TreeEntry};
522 use crate::parallel::ThreadPolicy;
523 use crate::repo::{InitOptions, Repository};
524
525 fn init_repo() -> (tempfile::TempDir, Repository) {
526 let dir = tempfile::tempdir().expect("tempdir");
527 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
528 (dir, repo)
529 }
530
531 fn init_repo_with_core_config(snippet: &str) -> (tempfile::TempDir, Repository) {
532 let dir = tempfile::tempdir().expect("tempdir");
533 let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
534 std::fs::write(repo.git_dir().join("config"), snippet).expect("config");
535 (dir, repo)
536 }
537
538 #[test]
539 fn tree_diff_contents_parallel_matches_serial_load() {
540 let (_dir, repo) = init_repo();
541 let odb = repo.odb();
542 let a = odb.write(ObjectKind::Blob, b"alpha\n").expect("write a");
543 let b = odb.write(ObjectKind::Blob, b"beta\n").expect("write b");
544 let tree_a = odb
545 .write(
546 ObjectKind::Tree,
547 &serialize_tree(&[TreeEntry {
548 mode: 0o100644,
549 name: b"file-a".to_vec(),
550 oid: a,
551 }]),
552 )
553 .expect("tree a");
554 let tree_b = odb
555 .write(
556 ObjectKind::Tree,
557 &serialize_tree(&[TreeEntry {
558 mode: 0o100644,
559 name: b"file-b".to_vec(),
560 oid: b,
561 }]),
562 )
563 .expect("tree b");
564 let raw = diff_trees(odb, Some(&tree_a), Some(&tree_b), "").expect("diff");
565 assert_eq!(raw.len(), 2);
566 let config = repo.config().expect("config");
567 let serial = tree_diff_contents_parallel(
568 odb,
569 raw.clone(),
570 &config,
571 &ThreadPolicy::with_threads_override(Some(1)),
572 )
573 .expect("serial load");
574 let parallel = tree_diff_contents_parallel(
575 odb,
576 raw,
577 &config,
578 &ThreadPolicy::with_threads_override(Some(4)),
579 )
580 .expect("parallel load");
581 assert_eq!(serial, parallel);
582 assert!(serial
583 .iter()
584 .any(|c| c.entry.status == DiffStatus::Added || c.entry.status == DiffStatus::Deleted));
585 }
586
587 #[test]
588 fn name_only_does_not_read_blobs_for_tree_to_tree() {
589 use crate::config::ConfigSet;
590 use crate::diff::{build_file_diffs, DiffDetail, DiffOptions};
591
592 let (_dir, repo) = init_repo();
593 let odb = repo.odb();
594 let a = odb.write(ObjectKind::Blob, b"alpha\n").expect("write a");
595 let b = odb.write(ObjectKind::Blob, b"beta\n").expect("write b");
596 let old = odb
597 .write(
598 ObjectKind::Tree,
599 &serialize_tree(&[TreeEntry {
600 mode: 0o100644,
601 name: b"f.txt".to_vec(),
602 oid: a,
603 }]),
604 )
605 .expect("old tree");
606 let new = odb
607 .write(
608 ObjectKind::Tree,
609 &serialize_tree(&[TreeEntry {
610 mode: 0o100644,
611 name: b"f.txt".to_vec(),
612 oid: b,
613 }]),
614 )
615 .expect("new tree");
616 let entries = diff_trees(odb, Some(&old), Some(&new), "").expect("entries");
617 let metrics = odb.hot_path_metrics();
618 metrics.set_blob_counting(true);
619 metrics.reset_blob_content_reads();
620 let config = ConfigSet::new();
621 let _ = build_file_diffs(
622 odb,
623 entries,
624 DiffDetail::NameOnly,
625 DiffOptions::default(),
626 &config,
627 &ThreadPolicy::default(),
628 )
629 .expect("name-only");
630 metrics.set_blob_counting(false);
631 assert_eq!(metrics.blob_content_reads(), 0);
632 }
633
634 #[test]
635 fn small_entry_counts_run_serially_without_consulting_the_policy() {
636 let config = ConfigSet::new();
637 let policy = ThreadPolicy::with_threads_override(Some(4));
638 for count in [0, 1, 2, PER_ENTRY_POOL_MIN_ITEMS - 1] {
639 let opts = per_entry_parallel_options(&policy, &config, count);
640 assert_eq!(opts.threads, NonZeroUsize::MIN, "{count} entries");
641 }
642 for count in [PER_ENTRY_POOL_MIN_ITEMS, PER_ENTRY_POOL_MIN_ITEMS * 4] {
643 let opts = per_entry_parallel_options(&policy, &config, count);
644 assert_eq!(opts.min_items, 0);
645 assert_eq!(opts.min_total_bytes, None);
646 assert_eq!(
647 opts.threads,
648 policy.resolve(ParallelOp::DiffContent, &config).threads
649 );
650 }
651 }
652
653 #[test]
654 fn min_items_override_moves_the_pool_threshold() {
655 let config = ConfigSet::new();
656 let threads = ThreadPolicy::with_threads_override(Some(4))
657 .resolve(ParallelOp::DiffContent, &config)
658 .threads;
659 let forced = ThreadPolicy::with_threads_override(Some(4)).min_items_override(Some(0));
660 assert_eq!(
661 per_entry_parallel_options(&forced, &config, 1).threads,
662 NonZeroUsize::MIN
663 );
664 assert_eq!(
665 per_entry_parallel_options(&forced, &config, 2).threads,
666 threads
667 );
668 let raised = ThreadPolicy::with_threads_override(Some(4)).min_items_override(Some(100));
669 assert_eq!(
670 per_entry_parallel_options(&raised, &config, 99).threads,
671 NonZeroUsize::MIN
672 );
673 assert_eq!(
674 per_entry_parallel_options(&raised, &config, 100).threads,
675 threads
676 );
677 }
678
679 #[test]
680 fn structured_diff_below_and_above_pool_threshold_matches_serial() {
681 let (_dir, repo) = init_repo();
682 let odb = repo.odb();
683 let tree_of = |version: &str, files: usize| {
684 let entries: Vec<TreeEntry> = (0..files)
685 .map(|i| TreeEntry {
686 mode: 0o100644,
687 name: format!("f{i:03}.txt").into_bytes(),
688 oid: odb
689 .write(
690 ObjectKind::Blob,
691 format!("file {i}\n{version}\nend\n").as_bytes(),
692 )
693 .expect("blob"),
694 })
695 .collect();
696 odb.write(ObjectKind::Tree, &serialize_tree(&entries))
697 .expect("tree")
698 };
699 let config = repo.config().expect("config");
700 for files in [PER_ENTRY_POOL_MIN_ITEMS - 1, PER_ENTRY_POOL_MIN_ITEMS + 1] {
701 let (old, new) = (tree_of("old", files), tree_of("new", files));
702 let entries = diff_trees(odb, Some(&old), Some(&new), "").expect("entries");
703 assert_eq!(entries.len(), files);
704 let run = |threads| {
705 tree_diff_structured_parallel(
706 odb,
707 entries.clone(),
708 &config,
709 &ThreadPolicy::with_threads_override(Some(threads)),
710 DiffOptions::default(),
711 )
712 .expect("structured diff")
713 };
714 let serial = run(1);
715 assert_eq!(serial, run(4), "{files} files");
716 assert!(serial.iter().all(|f| f.structured.hunks.len() == 1));
717 }
718 }
719
720 #[test]
721 fn big_file_threshold_honors_diff_driver_binary_false() -> Result<()> {
722 let (dir, repo) = init_repo_with_core_config(
723 "[core]\n\tbigFileThreshold = 4k\n\n[diff \"foo\"]\n\tbinary = false\n",
724 );
725 std::fs::write(dir.path().join(".gitattributes"), "*.bin diff=foo\n")?;
726 let odb = repo.odb();
727 let mut old_body = vec![b'a'; 100];
728 old_body.extend(std::iter::repeat_n(b'x', 4900));
729 assert_eq!(old_body.len(), 5000);
730 let mut new_body = old_body.clone();
731 new_body[0] = b'b';
732 let old_oid = odb.write(ObjectKind::Blob, &old_body)?;
733 let new_oid = odb.write(ObjectKind::Blob, &new_body)?;
734 let tree_old = odb.write(
735 ObjectKind::Tree,
736 &serialize_tree(&[TreeEntry {
737 mode: 0o100644,
738 name: b"a.bin".to_vec(),
739 oid: old_oid,
740 }]),
741 )?;
742 let tree_new = odb.write(
743 ObjectKind::Tree,
744 &serialize_tree(&[TreeEntry {
745 mode: 0o100644,
746 name: b"a.bin".to_vec(),
747 oid: new_oid,
748 }]),
749 )?;
750 let entries = diff_trees(odb, Some(&tree_old), Some(&tree_new), "")?;
751 assert_eq!(entries.len(), 1);
752 let metrics = odb.hot_path_metrics();
753 metrics.set_blob_counting(true);
754 metrics.reset_blob_content_reads();
755 let structured = tree_diff_structured_entry(odb, &entries[0], &DiffOptions::default())?;
756 metrics.set_blob_counting(false);
757 assert!(
758 !structured.binary,
759 "diff.foo.binary=false must force a textual diff"
760 );
761 assert!(
762 !structured.structured.hunks.is_empty(),
763 "expected unified hunks, not binary short-circuit"
764 );
765 assert!(
766 metrics.blob_content_reads() >= 2,
767 "both sides must be read for a line diff"
768 );
769 Ok(())
770 }
771
772 #[test]
773 fn tree_diff_skips_reading_blobs_over_big_file_threshold() {
774 let (_dir, repo) = init_repo_with_core_config("[core]\n\tbigFileThreshold = 4k\n");
775 let odb = repo.odb();
776
777 let big_a = odb
778 .write(ObjectKind::Blob, &vec![b'a'; 5000])
779 .expect("big blob a");
780 let big_b = odb
781 .write(ObjectKind::Blob, &vec![b'b'; 5001])
782 .expect("big blob b");
783 let tree_old = odb
784 .write(
785 ObjectKind::Tree,
786 &serialize_tree(&[TreeEntry {
787 mode: 0o100644,
788 name: b"f.bin".to_vec(),
789 oid: big_a,
790 }]),
791 )
792 .expect("old tree");
793 let tree_new = odb
794 .write(
795 ObjectKind::Tree,
796 &serialize_tree(&[TreeEntry {
797 mode: 0o100644,
798 name: b"f.bin".to_vec(),
799 oid: big_b,
800 }]),
801 )
802 .expect("new tree");
803 let entries = diff_trees(odb, Some(&tree_old), Some(&tree_new), "").expect("entries");
804 let metrics = odb.hot_path_metrics();
805 metrics.set_blob_counting(true);
806 metrics.reset_blob_content_reads();
807 let config = repo.config().expect("config");
808 let _ = tree_diff_stat_parallel(
809 odb,
810 entries,
811 &config,
812 &ThreadPolicy::default(),
813 DiffOptions::default(),
814 )
815 .expect("stat");
816 metrics.set_blob_counting(false);
817 assert_eq!(metrics.blob_content_reads(), 0);
818 }
819
820 #[test]
821 fn blob_at_exactly_big_file_threshold_is_still_read() {
822 let (_dir, repo) = init_repo_with_core_config("[core]\n\tbigFileThreshold = 4k\n");
823 let odb = repo.odb();
824
825 let at_threshold = odb
826 .write(ObjectKind::Blob, &vec![b'b'; 4096])
827 .expect("at threshold");
828 let other = odb.write(ObjectKind::Blob, b"z\n").expect("other");
829 let tree_old = odb
830 .write(
831 ObjectKind::Tree,
832 &serialize_tree(&[TreeEntry {
833 mode: 0o100644,
834 name: b"g.bin".to_vec(),
835 oid: other,
836 }]),
837 )
838 .expect("old tree");
839 let tree_new = odb
840 .write(
841 ObjectKind::Tree,
842 &serialize_tree(&[TreeEntry {
843 mode: 0o100644,
844 name: b"g.bin".to_vec(),
845 oid: at_threshold,
846 }]),
847 )
848 .expect("new tree");
849 let entries = diff_trees(odb, Some(&tree_old), Some(&tree_new), "").expect("entries");
850 let metrics = odb.hot_path_metrics();
851 metrics.set_blob_counting(true);
852 metrics.reset_blob_content_reads();
853 let config = repo.config().expect("config");
854 let _ = tree_diff_stat_parallel(
855 odb,
856 entries,
857 &config,
858 &ThreadPolicy::default(),
859 DiffOptions::default(),
860 )
861 .expect("stat");
862 metrics.set_blob_counting(false);
863 assert!(metrics.blob_content_reads() >= 1);
864 }
865
866 #[test]
867 fn blob_under_big_file_threshold_is_read_for_stat() {
868 let (_dir, repo) = init_repo_with_core_config("[core]\n\tbigFileThreshold = 4k\n");
869 let odb = repo.odb();
870
871 let under = odb
872 .write(ObjectKind::Blob, &vec![b'c'; 4000])
873 .expect("under");
874 let other = odb.write(ObjectKind::Blob, b"x\n").expect("other");
875 let tree_a = odb
876 .write(
877 ObjectKind::Tree,
878 &serialize_tree(&[TreeEntry {
879 mode: 0o100644,
880 name: b"h.bin".to_vec(),
881 oid: other,
882 }]),
883 )
884 .expect("tree a");
885 let tree_b = odb
886 .write(
887 ObjectKind::Tree,
888 &serialize_tree(&[TreeEntry {
889 mode: 0o100644,
890 name: b"h.bin".to_vec(),
891 oid: under,
892 }]),
893 )
894 .expect("tree b");
895 let entries = diff_trees(odb, Some(&tree_a), Some(&tree_b), "").expect("entries");
896 let metrics = odb.hot_path_metrics();
897 metrics.set_blob_counting(true);
898 metrics.reset_blob_content_reads();
899 let config = repo.config().expect("config");
900 let _ = tree_diff_stat_parallel(
901 odb,
902 entries,
903 &config,
904 &ThreadPolicy::default(),
905 DiffOptions::default(),
906 )
907 .expect("stat");
908 metrics.set_blob_counting(false);
909 assert!(metrics.blob_content_reads() >= 2);
910 }
911
912 #[test]
913 fn single_entry_structured_diff_matches_threaded_policy() {
914 let (_dir, repo) = init_repo();
915 let odb = repo.odb();
916 let a = odb.write(ObjectKind::Blob, b"one\ntwo\n").expect("write a");
917 let b = odb
918 .write(ObjectKind::Blob, b"one\nthree\n")
919 .expect("write b");
920 let tree = |oid| {
921 odb.write(
922 ObjectKind::Tree,
923 &serialize_tree(&[TreeEntry {
924 mode: 0o100644,
925 name: b"f.txt".to_vec(),
926 oid,
927 }]),
928 )
929 .expect("tree")
930 };
931 let (old, new) = (tree(a), tree(b));
932 let entries = diff_trees(odb, Some(&old), Some(&new), "").expect("entries");
933 assert_eq!(entries.len(), 1);
934 let config = repo.config().expect("config");
935 let run = |threads| {
936 tree_diff_structured_parallel(
937 odb,
938 entries.clone(),
939 &config,
940 &ThreadPolicy::with_threads_override(Some(threads)),
941 DiffOptions::default(),
942 )
943 .expect("structured diff")
944 };
945 let serial = run(1);
946 assert_eq!(serial, run(4));
947 assert_eq!(serial[0].structured.hunks.len(), 1);
948 }
949}