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