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