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