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