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        .and_then(|r| r.ok())
238        .unwrap_or(true)
239}
240
241/// Git `repo_config_get_index_threads` value semantics for `index.threads`.
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243enum IndexThreadsSetting {
244    /// Unset — Grit hash paths treat this as auto (all CPUs), matching prior behavior.
245    UnsetAuto,
246    Auto,
247    Serial,
248    Count(usize),
249}
250
251#[must_use]
252fn index_threads_setting(config: &ConfigSet) -> IndexThreadsSetting {
253    if let Some(Ok(n)) = config.get_i64("index.threads") {
254        return match n {
255            0 => IndexThreadsSetting::Auto,
256            1 => IndexThreadsSetting::Serial,
257            n if n > 1 => IndexThreadsSetting::Count(usize::try_from(n).unwrap_or(1)),
258            _ => IndexThreadsSetting::Serial,
259        };
260    }
261    if config.get("index.threads").is_some_and(|v| v.trim() == "0") {
262        return IndexThreadsSetting::Auto;
263    }
264    if let Some(result) = config.get_bool("index.threads") {
265        return match result {
266            Ok(true) => IndexThreadsSetting::Auto,
267            Ok(false) => IndexThreadsSetting::Serial,
268            Err(_) => IndexThreadsSetting::UnsetAuto,
269        };
270    }
271    IndexThreadsSetting::UnsetAuto
272}
273
274#[must_use]
275fn resolve_index_threads(config: &ConfigSet, override_threads: Option<usize>) -> NonZeroUsize {
276    if let Some(n) = override_threads {
277        return Parallelism::resolve(Some(n)).threads();
278    }
279    match index_threads_setting(config) {
280        IndexThreadsSetting::UnsetAuto | IndexThreadsSetting::Auto => {
281            Parallelism::resolve(None).threads()
282        }
283        IndexThreadsSetting::Serial => NonZeroUsize::MIN,
284        IndexThreadsSetting::Count(n) => Parallelism::resolve(Some(n)).threads(),
285    }
286}
287
288/// Git `pack.threads` parsing (`None` = unset → auto at resolve time).
289fn parse_pack_threads(config: &ConfigSet) -> Result<Option<usize>, ConfigError> {
290    const KEY: &str = "pack.threads";
291    let Some(raw) = config.get(KEY) else {
292        return Ok(None);
293    };
294    let trimmed = raw.trim();
295    match config.get_i64(KEY) {
296        Some(Ok(n)) if n < 0 => Err(ConfigError::Other(format!(
297            "invalid number of threads specified ({n})"
298        ))),
299        Some(Ok(0)) => Ok(None),
300        Some(Ok(n)) => Ok(Some(usize::try_from(n).unwrap_or(1))),
301        Some(Err(_)) | None => Err(ConfigError::BadNumericValue {
302            key: KEY.into(),
303            value: trimmed.into(),
304            reason: BadNumericSource::InvalidUnit,
305        }),
306    }
307}
308
309fn resolve_pack_threads(
310    config: &ConfigSet,
311    override_threads: Option<usize>,
312) -> Result<NonZeroUsize, ConfigError> {
313    if let Some(n) = override_threads {
314        return Ok(Parallelism::resolve(Some(n)).threads());
315    }
316    let requested = parse_pack_threads(config)?;
317    Ok(Parallelism::resolve(requested).threads())
318}
319
320/// Parsed `checkout.workers` value (Git: unset vs explicit auto vs explicit count).
321enum CheckoutWorkersSetting {
322    /// Key absent or unreadable — platform default applies.
323    Unset,
324    /// Explicit `< 1` (including `0`) — auto / all logical CPUs.
325    Auto,
326    /// Explicit worker count.
327    Count(usize),
328}
329
330#[must_use]
331fn checkout_workers_setting(config: &ConfigSet) -> CheckoutWorkersSetting {
332    match config.get_i64("checkout.workers") {
333        None | Some(Err(_)) => CheckoutWorkersSetting::Unset,
334        Some(Ok(n)) if n < 1 => CheckoutWorkersSetting::Auto,
335        Some(Ok(n)) => CheckoutWorkersSetting::Count(n as usize),
336    }
337}
338
339#[must_use]
340fn resolve_checkout_workers(config: &ConfigSet, override_threads: Option<usize>) -> NonZeroUsize {
341    let setting = checkout_workers_setting(config);
342
343    if let Some(n) = override_threads {
344        #[cfg(windows)]
345        if matches!(setting, CheckoutWorkersSetting::Unset) {
346            // Issue #1613: global `--threads` must not enable checkout disk pools when
347            // `checkout.workers` was never configured.
348            let _ = n;
349            return NonZeroUsize::MIN;
350        }
351        return Parallelism::resolve(Some(n)).threads();
352    }
353
354    match setting {
355        CheckoutWorkersSetting::Unset => {
356            #[cfg(windows)]
357            {
358                return NonZeroUsize::MIN;
359            }
360            #[cfg(not(windows))]
361            {
362                Parallelism::resolve(None).threads()
363            }
364        }
365        CheckoutWorkersSetting::Auto => Parallelism::resolve(None).threads(),
366        CheckoutWorkersSetting::Count(n) => Parallelism::resolve(Some(n)).threads(),
367    }
368}
369
370#[must_use]
371fn checkout_parallelism_threshold(config: &ConfigSet) -> i64 {
372    match config.get_i64("checkout.thresholdForParallelism") {
373        None => 100,
374        Some(Ok(n)) => n,
375        Some(Err(_)) => 100,
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use crate::config::{ConfigScope, ConfigSet};
383    use crate::environment::Environment;
384
385    fn config_from(text: &str) -> ConfigSet {
386        use crate::config::ConfigFile;
387        use std::path::Path;
388        let file = ConfigFile::parse(Path::new("test"), text, ConfigScope::Local).expect("parse");
389        let mut set = ConfigSet::new();
390        set.merge(&file);
391        set
392    }
393
394    #[test]
395    fn index_threads_bool_and_int_semantics() {
396        let true_auto = config_from("[index]\n\tthreads = true\n");
397        assert_eq!(index_threads_setting(&true_auto), IndexThreadsSetting::Auto);
398        let false_serial = config_from("[index]\n\tthreads = false\n");
399        assert_eq!(
400            index_threads_setting(&false_serial),
401            IndexThreadsSetting::Serial
402        );
403        let zero = config_from("[index]\n\tthreads = 0\n");
404        assert_eq!(index_threads_setting(&zero), IndexThreadsSetting::Auto);
405        let one = config_from("[index]\n\tthreads = 1\n");
406        assert_eq!(index_threads_setting(&one), IndexThreadsSetting::Serial);
407        let four = config_from("[index]\n\tthreads = 4\n");
408        assert_eq!(index_threads_setting(&four), IndexThreadsSetting::Count(4));
409        let neg = config_from("[index]\n\tthreads = -3\n");
410        assert_eq!(index_threads_setting(&neg), IndexThreadsSetting::Serial);
411    }
412
413    #[test]
414    fn index_threads_negative_is_serial_for_refresh() {
415        let cfg = config_from("[index]\n\tthreads = -3\n");
416        let opts = ThreadPolicy::default().resolve(ParallelOp::IndexRefresh, &cfg);
417        assert_eq!(opts.threads, NonZeroUsize::MIN);
418    }
419
420    #[test]
421    fn pack_threads_negative_is_config_error() {
422        let cfg = config_from("[pack]\n\tthreads = -3\n");
423        let err = ThreadPolicy::default()
424            .try_resolve(ParallelOp::IndexPack, &cfg)
425            .expect_err("negative pack.threads");
426        assert_eq!(
427            err,
428            ConfigError::Other("invalid number of threads specified (-3)".into())
429        );
430    }
431
432    #[test]
433    fn pack_threads_bool_is_bad_numeric() {
434        for (raw, label) in [("true", "true"), ("false", "false")] {
435            let cfg = config_from(&format!("[pack]\n\tthreads = {raw}\n"));
436            let err = ThreadPolicy::default()
437                .try_resolve(ParallelOp::IndexPack, &cfg)
438                .expect_err(&format!("pack.threads = {label}"));
439            assert!(
440                matches!(
441                    &err,
442                    ConfigError::BadNumericValue {
443                        key,
444                        value,
445                        reason
446                    } if key == "pack.threads" && value == label && *reason == BadNumericSource::InvalidUnit
447                ),
448                "unexpected error for {label}: {err:?}"
449            );
450        }
451    }
452
453    #[test]
454    fn preload_index_disables_index_refresh_workers() {
455        let cfg = config_from("[core]\n\tpreloadIndex = false\n[index]\n\tthreads = 8\n");
456        let policy = ThreadPolicy::default();
457        let opts = policy.resolve(ParallelOp::IndexRefresh, &cfg);
458        assert_eq!(opts.threads, NonZeroUsize::MIN);
459    }
460
461    #[test]
462    fn thread_override_enables_index_refresh_when_preload_off() {
463        let cfg = config_from("[core]\n\tpreloadIndex = false\n[index]\n\tthreads = 2\n");
464        let policy = ThreadPolicy::with_threads_override(Some(6));
465        let opts = policy.resolve(ParallelOp::IndexRefresh, &cfg);
466        assert_eq!(
467            opts.threads.get(),
468            6.min(
469                std::thread::available_parallelism()
470                    .map(|n| n.get())
471                    .unwrap_or(1)
472            )
473        );
474    }
475
476    #[test]
477    fn thread_override_beats_config() {
478        let cfg = config_from("[index]\n\tthreads = 2\n");
479        let policy = ThreadPolicy::with_threads_override(Some(6));
480        let opts = policy.resolve(ParallelOp::IndexRefresh, &cfg);
481        assert_eq!(
482            opts.threads.get(),
483            6.min(
484                std::thread::available_parallelism()
485                    .map(|n| n.get())
486                    .unwrap_or(1)
487            )
488        );
489    }
490
491    #[test]
492    fn min_items_override_for_tests() {
493        let cfg = ConfigSet::new();
494        let policy = ThreadPolicy::with_min_items_override(Some(0));
495        let opts = policy.resolve(ParallelOp::IndexPack, &cfg);
496        assert_eq!(opts.min_items, 0);
497    }
498
499    #[test]
500    fn pack_threads_zero_is_auto() {
501        let cfg = config_from("[pack]\n\tthreads = 0\n");
502        let opts = ThreadPolicy::default().resolve(ParallelOp::IndexPack, &cfg);
503        assert!(opts.threads.get() > 1 || std::thread::available_parallelism().is_ok());
504    }
505
506    #[test]
507    #[cfg(not(windows))]
508    fn checkout_workers_unset_defaults_to_auto() {
509        let cfg = ConfigSet::new();
510        let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
511        assert_eq!(
512            opts.threads.get(),
513            std::thread::available_parallelism()
514                .map(NonZeroUsize::get)
515                .unwrap_or(1)
516        );
517        assert_eq!(opts.signed_entry_threshold, Some(100));
518    }
519
520    #[test]
521    #[cfg(windows)]
522    fn checkout_workers_unset_defaults_to_serial() {
523        let cfg = ConfigSet::new();
524        let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
525        assert_eq!(opts.threads, NonZeroUsize::MIN);
526        assert_eq!(opts.signed_entry_threshold, Some(100));
527    }
528
529    #[test]
530    #[cfg(windows)]
531    fn checkout_global_threads_override_ignored_without_checkout_workers() {
532        let cfg = ConfigSet::new();
533        let policy = ThreadPolicy::with_threads_override(Some(8));
534        let opts = policy.resolve(ParallelOp::Checkout, &cfg);
535        assert_eq!(opts.threads, NonZeroUsize::MIN);
536    }
537
538    #[test]
539    #[cfg(windows)]
540    fn checkout_explicit_auto_enables_parallel_workers() {
541        let cfg = config_from("[checkout]\n\tworkers = 0\n");
542        let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
543        assert_eq!(
544            opts.threads.get(),
545            std::thread::available_parallelism()
546                .map(NonZeroUsize::get)
547                .unwrap_or(1)
548        );
549    }
550
551    #[test]
552    #[cfg(windows)]
553    fn checkout_workers_two_respects_cpu_cap_on_windows() {
554        let cfg = config_from("[checkout]\n\tworkers = 2\n");
555        let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
556        assert_eq!(
557            opts.threads.get(),
558            2.min(
559                std::thread::available_parallelism()
560                    .map(|n| n.get())
561                    .unwrap_or(1)
562            )
563        );
564    }
565
566    #[test]
567    #[cfg(windows)]
568    fn checkout_thread_override_beats_workers_config_on_windows() {
569        let cfg = config_from("[checkout]\n\tworkers = 2\n");
570        let policy = ThreadPolicy::with_threads_override(Some(4));
571        let opts = policy.resolve(ParallelOp::Checkout, &cfg);
572        assert_eq!(
573            opts.threads.get(),
574            4.min(
575                std::thread::available_parallelism()
576                    .map(|n| n.get())
577                    .unwrap_or(1)
578            )
579        );
580    }
581
582    #[test]
583    fn checkout_workers_zero_is_auto() {
584        let cfg = config_from("[checkout]\n\tworkers = 0\n");
585        let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
586        assert_eq!(
587            opts.threads.get(),
588            std::thread::available_parallelism()
589                .map(|n| n.get())
590                .unwrap_or(1)
591        );
592    }
593
594    #[test]
595    fn checkout_threshold_negative_is_preserved() {
596        let cfg = config_from("[checkout]\n\tthresholdForParallelism = -5\n");
597        let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
598        assert_eq!(opts.signed_entry_threshold, Some(-5));
599    }
600
601    #[test]
602    fn checkout_threshold_negative_keeps_small_batches_serial() {
603        use crate::parallel::map::pool_worthwhile;
604        let cfg =
605            config_from("[checkout]\n\tthresholdForParallelism = -5\n[checkout]\n\tworkers = 4\n");
606        let opts = ThreadPolicy::default().resolve(ParallelOp::Checkout, &cfg);
607        assert!(!pool_worthwhile(0, 0, opts));
608        // Threads are capped at the machine's cores (Miri reports one), and a
609        // single thread never uses the pool.
610        assert_eq!(pool_worthwhile(100, 0, opts), opts.threads.get() > 1);
611    }
612
613    #[test]
614    fn single_item_never_uses_the_pool() {
615        use crate::parallel::map::pool_worthwhile;
616        let opts = ParallelOptions {
617            threads: NonZeroUsize::new(8).unwrap_or(NonZeroUsize::MIN),
618            min_items: 0,
619            min_total_bytes: None,
620            signed_entry_threshold: None,
621        };
622        assert!(!pool_worthwhile(1, usize::MAX, opts));
623        assert!(pool_worthwhile(2, 0, opts));
624    }
625
626    #[test]
627    fn checkout_thread_override_beats_workers_config() {
628        let cfg = config_from("[checkout]\n\tworkers = 2\n");
629        let policy = ThreadPolicy::with_threads_override(Some(6));
630        let opts = policy.resolve(ParallelOp::Checkout, &cfg);
631        assert_eq!(
632            opts.threads.get(),
633            6.min(
634                std::thread::available_parallelism()
635                    .map(|n| n.get())
636                    .unwrap_or(1)
637            )
638        );
639    }
640
641    #[test]
642    fn stat_preload_respects_preload_math_and_override() {
643        let cfg = ConfigSet::new();
644        let policy = ThreadPolicy::default();
645        let index_cap = resolve_index_threads(&cfg, None).get();
646        assert_eq!(
647            policy.index_stat_preload_threads(&cfg, 5000, None),
648            10_usize.min(20).min(index_cap)
649        );
650        let policy = ThreadPolicy::with_threads_override(Some(8));
651        let override_cap = resolve_index_threads(&cfg, Some(8)).get();
652        assert_eq!(
653            policy.index_stat_preload_threads(&cfg, 5000, None),
654            10_usize.min(20).min(override_cap)
655        );
656        assert_eq!(
657            policy.index_stat_preload_threads(&cfg, 5000, Some(4)),
658            4_usize.min(override_cap)
659        );
660    }
661
662    #[test]
663    #[cfg_attr(miri, ignore = "creates a temp dir; Miri isolation blocks mkdir")]
664    fn pack_index_parallelism_for_git_dir_matches_config() {
665        let dir = tempfile::tempdir().expect("tempdir");
666        std::fs::write(dir.path().join("config"), "[pack]\n\tthreads = 3\n").expect("write config");
667        let env = Environment::empty();
668        let par = ConfigSet::pack_index_parallelism_for_git_dir(&env, Some(dir.path()));
669        assert_eq!(par.threads().get(), 3);
670    }
671}