Skip to main content

grit_lib/diff/
tree_content.rs

1//! Parallel blob loads for tree-to-tree diffs (commit ranges, `grit diff A B`).
2
3use 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
58/// Fewest changed entries for which per-entry blob loads and line diffs use worker threads.
59///
60/// Starting workers (stacks, allocator arenas, per-thread pack inflaters) costs more than
61/// loading and diffing a small commit's blobs on the calling thread: showing git.git commits
62/// that touch up to 17 files ran faster serially, while from about 18 files the pool won.
63/// Fewer, larger files gain nothing either, since each entry is diffed by one thread.
64const PER_ENTRY_POOL_MIN_ITEMS: usize = 16;
65
66/// Pool options for per-entry content work, fanning out once there are enough entries.
67///
68/// Below [`PER_ENTRY_POOL_MIN_ITEMS`] entries (or the policy's
69/// [`ThreadPolicy::min_items_override`], never below two) the work runs on the calling
70/// thread and the policy, which probes the CPU count and cgroup quota, is not consulted.
71fn 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/// Tree diff entry with structured line hunks ready for CLI rendering.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct TreeDiffStructured {
97    /// Tree diff metadata (paths, modes, oids, status).
98    pub entry: DiffEntry,
99    /// Structured hunks and mode metadata.
100    pub structured: StructuredFileDiff,
101    /// `true` when either side looks like a binary blob (NUL in the first 8 KiB).
102    pub binary: bool,
103    /// Worktree/index side bytes when content is not materialized in the ODB.
104    pub patch_bytes: Option<(SideBytes, SideBytes)>,
105}
106
107/// One tree diff entry with both sides loaded for line-level diffing.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct TreeDiffContent {
110    /// Tree diff metadata (paths, modes, oids, status).
111    pub entry: DiffEntry,
112    /// Old-side bytes prepared for [`super::structured::structured_text_diff`].
113    pub old_bytes: SideBytes,
114    /// New-side bytes prepared for structured diffing.
115    pub new_bytes: SideBytes,
116    /// `true` when either side looks like a binary blob (NUL in the first 8 KiB).
117    pub binary: bool,
118}
119
120/// Load old/new blob bytes for each entry, using a worker pool when thresholds allow.
121///
122/// Submodule gitlinks are synthesized as `Subproject commit <oid>` lines; missing sides
123/// are empty. Order matches `entries`.
124///
125/// # Errors
126///
127/// Propagates object database read failures.
128pub 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
155/// Per-file line counts for stat modes (loads blobs, no hunk allocation).
156///
157/// Worker threads are used only for 16 or more entries (or the policy's
158/// [`ThreadPolicy::min_items_override`]); smaller diffs run on the calling thread.
159///
160/// # Errors
161///
162/// Propagates object database read failures.
163pub 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
222/// Load sides and compute a structured line diff for one tree diff entry.
223///
224/// # Errors
225///
226/// Propagates object database read failures.
227pub 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
279/// Visit structured tree diffs in path order without retaining every file's hunks at once.
280///
281/// When thread policy allows, workers compute up to about `2 × threads` entries ahead while
282/// the callback consumes results strictly in order.
283///
284/// Worker threads are used only for 16 or more entries (or the policy's
285/// [`ThreadPolicy::min_items_override`]); smaller diffs run on the calling thread.
286///
287/// # Errors
288///
289/// Propagates object database read failures and callback errors.
290pub 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
328/// Load sides and compute structured line diffs for each entry in parallel.
329///
330/// Worker threads are used only for 16 or more entries (or the policy's
331/// [`ThreadPolicy::min_items_override`]); smaller diffs run on the calling thread.
332///
333/// # Errors
334///
335/// Propagates object database read failures.
336pub 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
400/// Text a gitlink side contributes to a patch: `Subproject commit <hex>\n`,
401/// or nothing for the zero id (an absent side).
402pub(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
413/// True when a path's attributes or textconv require reading a blob above
414/// [`Odb::big_file_threshold`](crate::objects::odb::Odb::big_file_threshold).
415fn 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
433/// Whether a blob/worktree side at `size` bytes should be treated as binary without reading
434/// content (Git `diff_populate_filespec`; strict `>` on `core.bigFileThreshold`).
435pub(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
456/// Classify blob bytes for diffing after inflation.
457///
458/// When `retain_binary_payload` is false, binary sides drop payload bytes (line diffs stay lean).
459/// When true (patch-text / `--binary` emission), payload is kept and `binary` is still set.
460pub(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
472/// Load ODB blob bytes for diffing, skipping inflation when over `core.bigFileThreshold`.
473///
474/// Returns empty [`SideBytes`] with `binary = true` when content is not read. When
475/// `force_read_content` is true, large blobs are inflated and binary payload bytes are retained
476/// (for `GIT binary patch` / `git apply`).
477///
478/// # Errors
479///
480/// Propagates object database and config errors.
481pub 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/// Classify raw blob bytes for line diffs (NUL in the first 8 KiB ⇒ binary).
507#[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}