Skip to main content

grit_lib/parallel/
policy.rs

1//! Resolve worker counts and batch thresholds from Git config and explicit overrides.
2
3use std::num::NonZeroUsize;
4
5use crate::config::ConfigSet;
6use crate::error::{BadNumericSource, ConfigError};
7use crate::worktree_scan::PARALLEL_STAT_MIN_ENTRIES;
8
9/// Minimum item count before parallel hash helpers use more than one thread.
10const PAR_HASH_MIN_ITEMS: usize = 32;
11/// Minimum total payload bytes for parallel hashing.
12const PAR_HASH_MIN_TOTAL_BYTES: usize = 256 * 1024;
13
14use super::Parallelism;
15
16/// Git operation that selects which config keys apply to parallelism.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum ParallelOp {
19    /// Index stat preload and parallel worktree hashing (`index.threads`, `core.preloadIndex`).
20    IndexRefresh,
21    /// Parallel index load paths such as lazy name-hash init (`index.threads`, `core.preloadIndex`).
22    IndexLoad,
23    /// Checkout worker pool (`checkout.workers`, `checkout.thresholdForParallelism`).
24    Checkout,
25    /// `index-pack` and in-memory pack indexing (`pack.threads`).
26    IndexPack,
27    /// Pack writing / `pack-objects` threading (`pack.threads`).
28    PackWrite,
29    /// Parallel windowed delta search during pack planning (`pack.threads`).
30    PackDeltaSearch,
31    /// Rename/copy detection scoring (`index.threads` when preload is enabled).
32    DiffRename,
33    /// Per-file blob loads and line diffs for tree/commit ranges (`index.threads`).
34    DiffContent,
35}
36
37/// Explicit CLI or embedder overrides layered on top of config resolution.
38#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
39pub struct ThreadPolicy {
40    /// When set, replaces config-derived thread counts (`0` = auto / all logical CPUs).
41    pub threads_override: Option<usize>,
42    /// When set, replaces the operation's default minimum item count before using a pool.
43    pub min_items_override: Option<usize>,
44    /// When set, replaces the byte threshold before using a pool (`Some(0)` disables it).
45    pub min_total_bytes_override: Option<usize>,
46}
47
48impl ThreadPolicy {
49    /// Override worker count (`None` = use config only; `Some(0)` = auto).
50    #[must_use]
51    pub fn with_threads_override(threads: Option<usize>) -> Self {
52        Self {
53            threads_override: threads,
54            ..Self::default()
55        }
56    }
57
58    /// Override minimum batch size (`Some(0)` forces the parallel path on tiny inputs).
59    #[must_use]
60    pub fn with_min_items_override(min_items: Option<usize>) -> Self {
61        Self {
62            min_items_override: min_items,
63            ..Self::default()
64        }
65    }
66
67    /// Set minimum batch override on an existing policy.
68    #[must_use]
69    pub fn min_items_override(mut self, min_items: Option<usize>) -> Self {
70        self.min_items_override = min_items;
71        self
72    }
73
74    /// Override minimum total bytes (`Some(0)` disables the byte threshold).
75    #[must_use]
76    pub fn min_total_bytes_override(mut self, min_bytes: Option<usize>) -> Self {
77        self.min_total_bytes_override = min_bytes;
78        self
79    }
80
81    /// Resolve pool options for `op` using merged `config` and this policy.
82    ///
83    /// # Panics
84    ///
85    /// Panics when `op` is [`ParallelOp::IndexPack`] or [`ParallelOp::PackWrite`] and
86    /// `pack.threads` is invalid (call [`Self::try_resolve`] to surface errors).
87    #[must_use]
88    pub fn resolve(&self, op: ParallelOp, config: &ConfigSet) -> ParallelOptions {
89        self.try_resolve(op, config).unwrap_or_else(|e| match op {
90            ParallelOp::IndexPack | ParallelOp::PackWrite | ParallelOp::PackDeltaSearch => {
91                panic!("invalid pack.threads config: {e}")
92            }
93            _ => unreachable!("only pack operations return config errors"),
94        })
95    }
96
97    /// Resolve pool options, returning a config error when `pack.threads` is invalid.
98    ///
99    /// # Errors
100    ///
101    /// Returns [`ConfigError`] for rejected `pack.threads` values (negative integers,
102    /// booleans, or non-numeric strings), matching Git's pack/index-pack behavior.
103    pub fn try_resolve(
104        &self,
105        op: ParallelOp,
106        config: &ConfigSet,
107    ) -> Result<ParallelOptions, ConfigError> {
108        let preload = core_preload_index(config);
109        let threads = match op {
110            ParallelOp::IndexRefresh | ParallelOp::IndexLoad => {
111                if self.threads_override.is_some() || preload {
112                    resolve_index_threads(config, self.threads_override)
113                } else {
114                    NonZeroUsize::MIN
115                }
116            }
117            ParallelOp::DiffRename | ParallelOp::DiffContent => {
118                if self.threads_override.is_some() || preload {
119                    resolve_index_threads(config, self.threads_override)
120                } else {
121                    NonZeroUsize::MIN
122                }
123            }
124            ParallelOp::IndexPack | ParallelOp::PackWrite | ParallelOp::PackDeltaSearch => {
125                resolve_pack_threads(config, self.threads_override)?
126            }
127            ParallelOp::Checkout => resolve_checkout_workers(config, self.threads_override),
128        };
129
130        let (default_min_items, default_min_bytes) = match op {
131            ParallelOp::IndexRefresh => (PAR_HASH_MIN_ITEMS, Some(PAR_HASH_MIN_TOTAL_BYTES)),
132            ParallelOp::IndexLoad => (crate::index_name_hash_lazy::LAZY_THREAD_COST * 2, None),
133            ParallelOp::Checkout => (0, None),
134            ParallelOp::IndexPack | ParallelOp::PackWrite | ParallelOp::PackDeltaSearch => {
135                (0, None)
136            }
137            ParallelOp::DiffRename | ParallelOp::DiffContent => {
138                (PAR_HASH_MIN_ITEMS, Some(PAR_HASH_MIN_TOTAL_BYTES))
139            }
140        };
141
142        let min_items = self.min_items_override.unwrap_or(default_min_items);
143        let signed_entry_threshold = match op {
144            ParallelOp::Checkout => Some(checkout_parallelism_threshold(config)),
145            _ => None,
146        };
147
148        let min_total_bytes = match self.min_total_bytes_override {
149            Some(0) => None,
150            Some(n) => Some(n),
151            None => default_min_bytes,
152        };
153
154        Ok(ParallelOptions {
155            threads,
156            min_items,
157            min_total_bytes,
158            signed_entry_threshold,
159        })
160    }
161
162    /// Thread count for index stat preload (Git `preload-index.c`: ~500 entries per thread).
163    #[must_use]
164    pub fn index_stat_preload_threads(
165        &self,
166        config: &ConfigSet,
167        blob_count: usize,
168        stat_parallel_threads: Option<usize>,
169    ) -> usize {
170        if !core_preload_index(config) || blob_count < PARALLEL_STAT_MIN_ENTRIES {
171            return 1;
172        }
173        self.index_stat_scan_threads(config, blob_count, stat_parallel_threads)
174    }
175
176    /// Parallel workers for directory-grouped worktree stat scans without requiring
177    /// `core.preloadIndex` (hard reset and other RSS-conscious callers).
178    #[must_use]
179    pub(crate) fn index_stat_scan_threads(
180        &self,
181        config: &ConfigSet,
182        blob_count: usize,
183        stat_parallel_threads: Option<usize>,
184    ) -> usize {
185        const THREAD_COST: usize = 500;
186        const MAX_PARALLEL: usize = 20;
187
188        if blob_count < PARALLEL_STAT_MIN_ENTRIES {
189            return 1;
190        }
191
192        let mut threads = blob_count / THREAD_COST;
193        if threads < 2 {
194            threads = 2;
195        }
196        threads = threads.min(MAX_PARALLEL);
197
198        let index_cap = resolve_index_threads(config, self.threads_override).get();
199        threads = threads.min(index_cap);
200
201        if let Some(requested) = stat_parallel_threads {
202            if requested <= 1 {
203                return 1;
204            }
205            threads = threads.min(requested);
206        }
207
208        threads.max(1)
209    }
210}
211
212/// Resolved worker count and serial-fallback thresholds for one parallel map call.
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub struct ParallelOptions {
215    /// Worker threads (always at least 1).
216    pub threads: NonZeroUsize,
217    /// Minimum input length before spawning a pool (0 = always parallel when `threads` > 1).
218    pub min_items: usize,
219    /// Optional minimum summed payload bytes (used by object hashing).
220    pub min_total_bytes: Option<usize>,
221    /// Git `checkout.thresholdForParallelism` (signed); parallel only when `count >=` this value.
222    pub signed_entry_threshold: Option<i64>,
223}
224
225impl ParallelOptions {
226    /// View as [`Parallelism`] for legacy hash helpers.
227    #[must_use]
228    pub fn parallelism(self) -> Parallelism {
229        Parallelism::new(self.threads)
230    }
231}
232
233#[must_use]
234fn core_preload_index(config: &ConfigSet) -> bool {
235    config
236        .get_bool("core.preloadindex")
237        .or_else(|| config.get_bool("core.preloadIndex"))
238        .and_then(|r| r.ok())
239        .unwrap_or(true)
240}
241
242/// Git `repo_config_get_index_threads` value semantics for `index.threads`.
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244enum IndexThreadsSetting {
245    /// Unset — Grit hash paths treat this as auto (all CPUs), matching prior behavior.
246    UnsetAuto,
247    Auto,
248    Serial,
249    Count(usize),
250}
251
252#[must_use]
253fn index_threads_setting(config: &ConfigSet) -> IndexThreadsSetting {
254    if let Some(Ok(n)) = config.get_i64("index.threads") {
255        return match n {
256            0 => IndexThreadsSetting::Auto,
257            1 => IndexThreadsSetting::Serial,
258            n if n > 1 => IndexThreadsSetting::Count(usize::try_from(n).unwrap_or(1)),
259            _ => IndexThreadsSetting::Serial,
260        };
261    }
262    if config.get("index.threads").is_some_and(|v| v.trim() == "0") {
263        return IndexThreadsSetting::Auto;
264    }
265    if let Some(result) = config.get_bool("index.threads") {
266        return match result {
267            Ok(true) => IndexThreadsSetting::Auto,
268            Ok(false) => IndexThreadsSetting::Serial,
269            Err(_) => IndexThreadsSetting::UnsetAuto,
270        };
271    }
272    IndexThreadsSetting::UnsetAuto
273}
274
275#[must_use]
276fn resolve_index_threads(config: &ConfigSet, override_threads: Option<usize>) -> NonZeroUsize {
277    if let Some(n) = override_threads {
278        return Parallelism::resolve(Some(n)).threads();
279    }
280    match index_threads_setting(config) {
281        IndexThreadsSetting::UnsetAuto | IndexThreadsSetting::Auto => {
282            Parallelism::resolve(None).threads()
283        }
284        IndexThreadsSetting::Serial => NonZeroUsize::MIN,
285        IndexThreadsSetting::Count(n) => Parallelism::resolve(Some(n)).threads(),
286    }
287}
288
289/// Git `pack.threads` parsing (`None` = unset → auto at resolve time).
290fn parse_pack_threads(config: &ConfigSet) -> Result<Option<usize>, ConfigError> {
291    const KEY: &str = "pack.threads";
292    let Some(raw) = config.get(KEY) else {
293        return Ok(None);
294    };
295    let trimmed = raw.trim();
296    match config.get_i64(KEY) {
297        Some(Ok(n)) if n < 0 => Err(ConfigError::Other(format!(
298            "invalid number of threads specified ({n})"
299        ))),
300        Some(Ok(0)) => Ok(None),
301        Some(Ok(n)) => Ok(Some(usize::try_from(n).unwrap_or(1))),
302        Some(Err(_)) | None => Err(ConfigError::BadNumericValue {
303            key: KEY.into(),
304            value: trimmed.into(),
305            reason: BadNumericSource::InvalidUnit,
306        }),
307    }
308}
309
310fn resolve_pack_threads(
311    config: &ConfigSet,
312    override_threads: Option<usize>,
313) -> Result<NonZeroUsize, ConfigError> {
314    if let Some(n) = override_threads {
315        return Ok(Parallelism::resolve(Some(n)).threads());
316    }
317    let requested = parse_pack_threads(config)?;
318    Ok(Parallelism::resolve(requested).threads())
319}
320
321/// Parsed `checkout.workers` value (Git: unset vs explicit auto vs explicit count).
322enum CheckoutWorkersSetting {
323    /// Key absent or unreadable — platform default applies.
324    Unset,
325    /// Explicit `< 1` (including `0`) — auto / all logical CPUs.
326    Auto,
327    /// Explicit worker count.
328    Count(usize),
329}
330
331#[must_use]
332fn checkout_workers_setting(config: &ConfigSet) -> CheckoutWorkersSetting {
333    match config.get_i64("checkout.workers") {
334        None | Some(Err(_)) => CheckoutWorkersSetting::Unset,
335        Some(Ok(n)) if n < 1 => CheckoutWorkersSetting::Auto,
336        Some(Ok(n)) => CheckoutWorkersSetting::Count(n as usize),
337    }
338}
339
340#[must_use]
341fn resolve_checkout_workers(config: &ConfigSet, override_threads: Option<usize>) -> NonZeroUsize {
342    let setting = checkout_workers_setting(config);
343
344    if let Some(n) = override_threads {
345        #[cfg(windows)]
346        if matches!(setting, CheckoutWorkersSetting::Unset) {
347            // Issue #1613: global `--threads` must not enable checkout disk pools when
348            // `checkout.workers` was never configured.
349            let _ = n;
350            return NonZeroUsize::MIN;
351        }
352        return Parallelism::resolve(Some(n)).threads();
353    }
354
355    match setting {
356        CheckoutWorkersSetting::Unset => {
357            #[cfg(windows)]
358            {
359                return NonZeroUsize::MIN;
360            }
361            #[cfg(not(windows))]
362            {
363                Parallelism::resolve(None).threads()
364            }
365        }
366        CheckoutWorkersSetting::Auto => Parallelism::resolve(None).threads(),
367        CheckoutWorkersSetting::Count(n) => Parallelism::resolve(Some(n)).threads(),
368    }
369}
370
371#[must_use]
372fn checkout_parallelism_threshold(config: &ConfigSet) -> i64 {
373    match config.get_i64("checkout.thresholdForParallelism") {
374        None => 100,
375        Some(Ok(n)) => n,
376        Some(Err(_)) => 100,
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use crate::config::{ConfigScope, ConfigSet};
384    use crate::environment::Environment;
385
386    fn config_from(text: &str) -> ConfigSet {
387        use crate::config::ConfigFile;
388        use std::path::Path;
389        let file = ConfigFile::parse(Path::new("test"), text, ConfigScope::Local).expect("parse");
390        let mut set = ConfigSet::new();
391        set.merge(&file);
392        set
393    }
394
395    #[test]
396    fn index_threads_bool_and_int_semantics() {
397        let true_auto = config_from("[index]\n\tthreads = true\n");
398        assert_eq!(index_threads_setting(&true_auto), IndexThreadsSetting::Auto);
399        let false_serial = config_from("[index]\n\tthreads = false\n");
400        assert_eq!(
401            index_threads_setting(&false_serial),
402            IndexThreadsSetting::Serial
403        );
404        let zero = config_from("[index]\n\tthreads = 0\n");
405        assert_eq!(index_threads_setting(&zero), IndexThreadsSetting::Auto);
406        let one = config_from("[index]\n\tthreads = 1\n");
407        assert_eq!(index_threads_setting(&one), IndexThreadsSetting::Serial);
408        let four = config_from("[index]\n\tthreads = 4\n");
409        assert_eq!(index_threads_setting(&four), IndexThreadsSetting::Count(4));
410        let neg = config_from("[index]\n\tthreads = -3\n");
411        assert_eq!(index_threads_setting(&neg), IndexThreadsSetting::Serial);
412    }
413
414    #[test]
415    fn index_threads_negative_is_serial_for_refresh() {
416        let cfg = config_from("[index]\n\tthreads = -3\n");
417        let opts = ThreadPolicy::default().resolve(ParallelOp::IndexRefresh, &cfg);
418        assert_eq!(opts.threads, NonZeroUsize::MIN);
419    }
420
421    #[test]
422    fn pack_threads_negative_is_config_error() {
423        let cfg = config_from("[pack]\n\tthreads = -3\n");
424        let err = ThreadPolicy::default()
425            .try_resolve(ParallelOp::IndexPack, &cfg)
426            .expect_err("negative pack.threads");
427        assert_eq!(
428            err,
429            ConfigError::Other("invalid number of threads specified (-3)".into())
430        );
431    }
432
433    #[test]
434    fn pack_threads_bool_is_bad_numeric() {
435        for (raw, label) in [("true", "true"), ("false", "false")] {
436            let cfg = config_from(&format!("[pack]\n\tthreads = {raw}\n"));
437            let err = ThreadPolicy::default()
438                .try_resolve(ParallelOp::IndexPack, &cfg)
439                .expect_err(&format!("pack.threads = {label}"));
440            assert!(
441                matches!(
442                    &err,
443                    ConfigError::BadNumericValue {
444                        key,
445                        value,
446                        reason
447                    } if key == "pack.threads" && value == label && *reason == BadNumericSource::InvalidUnit
448                ),
449                "unexpected error for {label}: {err:?}"
450            );
451        }
452    }
453
454    #[test]
455    fn preload_index_disables_index_refresh_workers() {
456        let cfg = config_from("[core]\n\tpreloadIndex = false\n[index]\n\tthreads = 8\n");
457        let policy = ThreadPolicy::default();
458        let opts = policy.resolve(ParallelOp::IndexRefresh, &cfg);
459        assert_eq!(opts.threads, NonZeroUsize::MIN);
460    }
461
462    #[test]
463    fn thread_override_enables_index_refresh_when_preload_off() {
464        let cfg = config_from("[core]\n\tpreloadIndex = false\n[index]\n\tthreads = 2\n");
465        let policy = ThreadPolicy::with_threads_override(Some(6));
466        let opts = policy.resolve(ParallelOp::IndexRefresh, &cfg);
467        assert_eq!(
468            opts.threads.get(),
469            6.min(
470                std::thread::available_parallelism()
471                    .map(|n| n.get())
472                    .unwrap_or(1)
473            )
474        );
475    }
476
477    #[test]
478    fn thread_override_beats_config() {
479        let cfg = config_from("[index]\n\tthreads = 2\n");
480        let policy = ThreadPolicy::with_threads_override(Some(6));
481        let opts = policy.resolve(ParallelOp::IndexRefresh, &cfg);
482        assert_eq!(
483            opts.threads.get(),
484            6.min(
485                std::thread::available_parallelism()
486                    .map(|n| n.get())
487                    .unwrap_or(1)
488            )
489        );
490    }
491
492    #[test]
493    fn min_items_override_for_tests() {
494        let cfg = ConfigSet::new();
495        let policy = ThreadPolicy::with_min_items_override(Some(0));
496        let opts = policy.resolve(ParallelOp::IndexPack, &cfg);
497        assert_eq!(opts.min_items, 0);
498    }
499
500    #[test]
501    fn pack_threads_zero_is_auto() {
502        let cfg = config_from("[pack]\n\tthreads = 0\n");
503        let opts = ThreadPolicy::default().resolve(ParallelOp::IndexPack, &cfg);
504        assert!(opts.threads.get() > 1 || std::thread::available_parallelism().is_ok());
505    }
506
507    #[test]
508    #[cfg(not(windows))]
509    fn checkout_workers_unset_defaults_to_auto() {
510        let cfg = ConfigSet::new();
511        let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
512        assert_eq!(
513            opts.threads.get(),
514            std::thread::available_parallelism()
515                .map(NonZeroUsize::get)
516                .unwrap_or(1)
517        );
518        assert_eq!(opts.signed_entry_threshold, Some(100));
519    }
520
521    #[test]
522    #[cfg(windows)]
523    fn checkout_workers_unset_defaults_to_serial() {
524        let cfg = ConfigSet::new();
525        let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
526        assert_eq!(opts.threads, NonZeroUsize::MIN);
527        assert_eq!(opts.signed_entry_threshold, Some(100));
528    }
529
530    #[test]
531    #[cfg(windows)]
532    fn checkout_global_threads_override_ignored_without_checkout_workers() {
533        let cfg = ConfigSet::new();
534        let policy = ThreadPolicy::with_threads_override(Some(8));
535        let opts = policy.resolve(ParallelOp::Checkout, &cfg);
536        assert_eq!(opts.threads, NonZeroUsize::MIN);
537    }
538
539    #[test]
540    #[cfg(windows)]
541    fn checkout_explicit_auto_enables_parallel_workers() {
542        let cfg = config_from("[checkout]\n\tworkers = 0\n");
543        let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
544        assert_eq!(
545            opts.threads.get(),
546            std::thread::available_parallelism()
547                .map(NonZeroUsize::get)
548                .unwrap_or(1)
549        );
550    }
551
552    #[test]
553    #[cfg(windows)]
554    fn checkout_workers_two_respects_cpu_cap_on_windows() {
555        let cfg = config_from("[checkout]\n\tworkers = 2\n");
556        let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
557        assert_eq!(
558            opts.threads.get(),
559            2.min(
560                std::thread::available_parallelism()
561                    .map(|n| n.get())
562                    .unwrap_or(1)
563            )
564        );
565    }
566
567    #[test]
568    #[cfg(windows)]
569    fn checkout_thread_override_beats_workers_config_on_windows() {
570        let cfg = config_from("[checkout]\n\tworkers = 2\n");
571        let policy = ThreadPolicy::with_threads_override(Some(4));
572        let opts = policy.resolve(ParallelOp::Checkout, &cfg);
573        assert_eq!(
574            opts.threads.get(),
575            4.min(
576                std::thread::available_parallelism()
577                    .map(|n| n.get())
578                    .unwrap_or(1)
579            )
580        );
581    }
582
583    #[test]
584    fn checkout_workers_zero_is_auto() {
585        let cfg = config_from("[checkout]\n\tworkers = 0\n");
586        let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
587        assert_eq!(
588            opts.threads.get(),
589            std::thread::available_parallelism()
590                .map(|n| n.get())
591                .unwrap_or(1)
592        );
593    }
594
595    #[test]
596    fn checkout_threshold_negative_is_preserved() {
597        let cfg = config_from("[checkout]\n\tthresholdForParallelism = -5\n");
598        let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
599        assert_eq!(opts.signed_entry_threshold, Some(-5));
600    }
601
602    #[test]
603    fn checkout_threshold_negative_keeps_small_batches_serial() {
604        use crate::parallel::map::pool_worthwhile;
605        let cfg =
606            config_from("[checkout]\n\tthresholdForParallelism = -5\n[checkout]\n\tworkers = 4\n");
607        let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
608        assert!(!pool_worthwhile(0, 0, opts));
609        // Threads are capped at the machine's cores (Miri reports one), and a
610        // single thread never uses the pool.
611        assert_eq!(pool_worthwhile(100, 0, opts), opts.threads.get() > 1);
612    }
613
614    #[test]
615    fn single_item_never_uses_the_pool() {
616        use crate::parallel::map::pool_worthwhile;
617        let opts = ParallelOptions {
618            threads: NonZeroUsize::new(8).unwrap_or(NonZeroUsize::MIN),
619            min_items: 0,
620            min_total_bytes: None,
621            signed_entry_threshold: None,
622        };
623        assert!(!pool_worthwhile(1, usize::MAX, opts));
624        assert!(pool_worthwhile(2, 0, opts));
625    }
626
627    #[test]
628    fn checkout_thread_override_beats_workers_config() {
629        let cfg = config_from("[checkout]\n\tworkers = 2\n");
630        let policy = ThreadPolicy::with_threads_override(Some(6));
631        let opts = policy.resolve(ParallelOp::Checkout, &cfg);
632        assert_eq!(
633            opts.threads.get(),
634            6.min(
635                std::thread::available_parallelism()
636                    .map(|n| n.get())
637                    .unwrap_or(1)
638            )
639        );
640    }
641
642    #[test]
643    fn stat_preload_respects_preload_math_and_override() {
644        let cfg = ConfigSet::new();
645        let policy = ThreadPolicy::default();
646        let index_cap = resolve_index_threads(&cfg, None).get();
647        assert_eq!(
648            policy.index_stat_preload_threads(&cfg, 5000, None),
649            10_usize.min(20).min(index_cap)
650        );
651        let policy = ThreadPolicy::with_threads_override(Some(8));
652        let override_cap = resolve_index_threads(&cfg, Some(8)).get();
653        assert_eq!(
654            policy.index_stat_preload_threads(&cfg, 5000, None),
655            10_usize.min(20).min(override_cap)
656        );
657        assert_eq!(
658            policy.index_stat_preload_threads(&cfg, 5000, Some(4)),
659            4_usize.min(override_cap)
660        );
661    }
662
663    #[test]
664    #[cfg_attr(miri, ignore = "creates a temp dir; Miri isolation blocks mkdir")]
665    fn pack_index_parallelism_for_git_dir_matches_config() {
666        let dir = tempfile::tempdir().expect("tempdir");
667        std::fs::write(dir.path().join("config"), "[pack]\n\tthreads = 3\n").expect("write config");
668        let env = Environment::empty();
669        let par = ConfigSet::pack_index_parallelism_for_git_dir(&env, Some(dir.path()));
670        assert_eq!(par.threads().get(), 3);
671    }
672}