Skip to main content

amont_runtime/
config.rs

1//! Reading configuration — and reading it the way git itself does.
2//!
3//! Everything this project can be tuned with is a `git config` key, so the
4//! honest implementation of that promise is to let git do the parsing.
5//! `git config --type=bool` implements git-config(1) by definition: `on`,
6//! `yes`, `1`, an empty value, and every capitalisation of each. A hand-rolled
7//! `matches!(v, "true" | "1" | "yes")` is our own dialect wearing git's
8//! clothes, and it had already drifted — `git config amont.fix on` looked
9//! like it worked and did not.
10//!
11//! The exit code carries the part that matters most:
12//!
13//! | exit | means |
14//! |---|---|
15//! | 0 | the key is set, and stdout is git's normalised value |
16//! | 1 | the key is not set anywhere git looked |
17//! | 128 | the key is set to something git refuses to parse, and said so on stderr |
18//!
19//! Collapsing 1 and 128 into "no" is the bug this module exists to prevent: a
20//! limit you believe you raised and did not is exactly the silent-config
21//! failure that `hook.skip` announcements were introduced for. So a bad value
22//! falls back to the shipped default **and says so**, once per key per run.
23
24use crate::git;
25use crate::ui::{highlight, warning_sign};
26use std::collections::BTreeSet;
27use std::ops::RangeInclusive;
28use std::sync::{Mutex, OnceLock};
29
30/// What a key said. Three answers, because "unset" is a state this project
31/// makes decisions with — `amont.commit.gitmoji` has four meanings and one
32/// of them is absence.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum Value<T> {
35    Unset,
36    Set(T),
37    /// Set to something that could not be read. `why` is git's own diagnostic
38    /// where git produced one, and ours where the constraint is ours (a value
39    /// outside an allowed set, or outside a range).
40    Bad {
41        why: String,
42    },
43}
44
45impl<T> Value<T> {
46    pub fn is_set(&self) -> bool {
47        matches!(self, Value::Set(_))
48    }
49}
50
51/// `git config --type=<ty> --get <key>`, with the three exits kept apart.
52///
53/// Git failing to run at all is reported as `Unset`: this crate's standing
54/// posture is that an unanswerable question takes the default rather than
55/// blocking a commit.
56fn typed(key: &str, ty: &str) -> Value<String> {
57    let type_flag = format!("--type={ty}");
58    let Some(out) = git::output(&["config", &type_flag, "--get", key]) else {
59        return Value::Unset;
60    };
61    match out.code {
62        0 => Value::Set(out.stdout),
63        1 => Value::Unset,
64        _ => Value::Bad {
65            why: first_line(&out.stderr),
66        },
67    }
68}
69
70/// As [`typed`], but reading a POLICY-SUPPLIED literal instead of the
71/// machine's config: `git -c <key>=<raw> config --type=<ty> --get <key>`.
72/// GIT parses the value, so `Value::Bad` and `complain` work unchanged and
73/// no second config dialect exists — the founding argument of this module.
74fn typed_literal(key: &str, raw: &str, ty: Option<&str>) -> Value<String> {
75    let assignment = format!("{key}={raw}");
76    let mut args: Vec<&str> = vec!["-c", &assignment, "config"];
77    let type_flag = ty.map(|t| format!("--type={t}"));
78    if let Some(tf) = &type_flag {
79        args.push(tf);
80    }
81    args.extend(["--get", key]);
82    let Some(out) = git::output(&args) else {
83        return Value::Unset;
84    };
85    match out.code {
86        0 => Value::Set(out.stdout),
87        1 => Value::Unset,
88        _ => Value::Bad {
89            why: first_line(&out.stderr),
90        },
91    }
92}
93
94/// The ladder, as a short-circuit rather than a general mechanism:
95///
96///   policy has no value for `key`      → [`typed`], byte-for-byte as before
97///   key set at local/worktree/command  → [`typed`] — the machine wins
98///   otherwise                          → [`typed_literal`] — policy wins
99///
100/// The invariant this shape buys: a repository with no `set` lines spawns
101/// not one extra git process anywhere — the scoped scan below runs only
102/// when the policy actually carries settings.
103fn resolve(settings: &crate::config::Settings, key: &str, ty: Option<&str>) -> Value<String> {
104    let policy = settings.policy();
105    let Some(raw) = policy.settings.get(key) else {
106        return match ty {
107            Some(t) => typed(key, t),
108            None => untyped(key),
109        };
110    };
111    if key_set_above_policy(settings, key) {
112        return match ty {
113            Some(t) => typed(key, t),
114            None => untyped(key),
115        };
116    }
117    typed_literal(key, raw, ty)
118}
119
120/// The raw read `enumerated` has always done, factored so `resolve` can
121/// route it.
122fn untyped(key: &str) -> Value<String> {
123    let Some(out) = git::output(&["config", "--get", key]) else {
124        return Value::Unset;
125    };
126    match out.code {
127        0 => Value::Set(out.stdout),
128        1 => Value::Unset,
129        _ => Value::Bad {
130            why: first_line(&out.stderr),
131        },
132    }
133}
134
135/// Is `key` set at a scope that outranks policy (local/worktree/command)?
136///
137/// One lazily-cached `git config --show-scope --get-regexp '^amont\.'` for
138/// the whole process — this is the PRECEDENCE reader, deliberately separate
139/// from [`scope_of`], which is `--show-origin`-based and display-oriented.
140/// Scope words `system`/`global` rank below policy; every other word —
141/// `local`, `worktree`, `command`, and whatever a future git invents —
142/// ranks above, because misreading a local as below would let a file pulled
143/// from a remote silently override a person's explicit machine setting.
144///
145/// On a git too old for `--show-scope` the cache is `None` and this
146/// DEGRADES fail-safe: any set key counts as above, i.e. all git config
147/// beats policy.
148fn key_set_above_policy(settings: &crate::config::Settings, key: &str) -> bool {
149    let above = settings.scoped.get_or_init(|| {
150        crate::git::stdout(&["config", "--show-scope", "--get-regexp", r"^amont\."]).map(|scoped| {
151            scoped
152                .lines()
153                .filter_map(|line| {
154                    let (scope, rest) = line.split_once('\t')?;
155                    match scope {
156                        "system" | "global" => None,
157                        _ => rest.split_whitespace().next().map(str::to_ascii_lowercase),
158                    }
159                })
160                .collect()
161        })
162    });
163    match above {
164        Some(set) => set.contains(&key.to_ascii_lowercase()),
165        // Degraded: no scope information — any set key beats policy.
166        None => untyped(key).is_set(),
167    }
168}
169
170/// Git's `fatal:` line, without the noise around it. Empty stderr still yields
171/// something printable, because a warning that names no cause is a puzzle.
172fn first_line(stderr: &str) -> String {
173    let line = stderr.lines().next().unwrap_or("").trim();
174    let line = line.strip_prefix("fatal: ").unwrap_or(line);
175    if line.is_empty() {
176        "git could not read the value".to_string()
177    } else {
178        line.to_string()
179    }
180}
181
182/// Everything this process resolved once about configuration: the trusted
183/// repository policy, and the reads memoised on top of it.
184///
185/// This replaces a process-global `OnceLock<Policy>`. The global was correct
186/// for the hook path — one process means one repository — but it made a rule
187/// the compiler could not see. A multi-repo walker had to KNOW not to seed
188/// it, and `amont-fleet` carried that knowledge as a comment with no test
189/// able to assert it, because asserting it would have meant seeding it.
190///
191/// Owned high and borrowed everywhere — the shape `Ctx` already uses for
192/// `manifest` and `push` — the rule stops needing to be remembered: there is
193/// no store to seed. The memo fields keep the per-process caching these reads
194/// have always had, which `spawn_budget` measures; one `Settings` per process
195/// buys the same number of git spawns the statics did.
196#[derive(Debug, Default)]
197pub struct Settings {
198    policy: crate::policy::Policy,
199    /// The precedence reader: one `--show-scope` scan per process.
200    scoped: OnceLock<Option<BTreeSet<String>>>,
201    /// The host-key reader: one `--show-scope` scan with values, run only
202    /// when a host key is first read. `(scope, key lowercased, value)`.
203    host: OnceLock<Option<Vec<(String, String, String)>>>,
204    /// Reads memoised on first use. Each was a module-level `OnceLock` beside
205    /// its accessor; here, "resolved once" is scoped to a `Settings` rather
206    /// than to the process, so a second one cannot inherit the first's answers.
207    pub(crate) timeout: OnceLock<u64>,
208    pub(crate) idle: OnceLock<u64>,
209    pub(crate) idle_cpu: OnceLock<bool>,
210    pub(crate) lock_wait: OnceLock<u64>,
211    pub(crate) idle_load_scale: OnceLock<u64>,
212    pub(crate) quiet: OnceLock<bool>,
213    pub(crate) progress: OnceLock<bool>,
214    pub(crate) declared_mode: OnceLock<bool>,
215    pub(crate) fixing: OnceLock<bool>,
216}
217
218/// `amont run --quiet` or `--raw`.
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub enum QuietOverride {
221    Quiet,
222    Raw,
223}
224
225impl Settings {
226    /// Decide `amont.quiet` for this run, ahead of the configuration:
227    /// `amont run --quiet` / `--raw`. Only before anything has read it —
228    /// a read first would have fixed the answer, and the flag would do
229    /// nothing; that is a bug in the caller, not a configuration.
230    pub fn override_quiet(&self, mode: QuietOverride) {
231        let quiet = match mode {
232            QuietOverride::Quiet => true,
233            QuietOverride::Raw => false,
234        };
235        self.quiet
236            .set(quiet)
237            .expect("amont.quiet was read before --quiet/--raw could decide it");
238    }
239
240    /// The policy a trusted manifest declared — empty when nothing was
241    /// declared or the manifest was not trusted. `manifest::load` decides
242    /// that and hands the answer here.
243    pub fn new(policy: crate::policy::Policy) -> Self {
244        Self {
245            policy,
246            ..Self::default()
247        }
248    }
249
250    pub fn policy(&self) -> &crate::policy::Policy {
251        &self.policy
252    }
253
254    /// A fresh `Settings` over the same policy, with empty caches.
255    ///
256    /// For a spawned thread, which cannot borrow this one. It resolves the
257    /// same answers — same policy, same git config — and it resolves them
258    /// LAZILY, which is the property that matters: a paint or heartbeat
259    /// thread that never lives long enough to read a budget must not have
260    /// cost a `git config` spawn for it up front. Handing the thread
261    /// pre-resolved numbers did exactly that, and `spawn_budget` said so.
262    pub fn for_thread(&self) -> Settings {
263        Settings::new(self.policy.clone())
264    }
265}
266
267pub fn boolean(settings: &crate::config::Settings, key: &str) -> Value<bool> {
268    match resolve(settings, key, Some("bool")) {
269        Value::Set(v) => match v.as_str() {
270            "true" => Value::Set(true),
271            "false" => Value::Set(false),
272            other => Value::Bad {
273                why: format!("git normalised it to {other:?}, which is neither true nor false"),
274            },
275        },
276        Value::Unset => Value::Unset,
277        Value::Bad { why } => Value::Bad { why },
278    }
279}
280
281/// An integer, in git's own spelling — which includes the `k`/`m`/`g` suffixes
282/// git accepts, since `--type=int` expands them before we see them.
283pub fn integer(settings: &crate::config::Settings, key: &str) -> Value<i64> {
284    match resolve(settings, key, Some("int")) {
285        Value::Set(v) => match v.parse::<i64>() {
286            Ok(n) => Value::Set(n),
287            Err(_) => Value::Bad {
288                why: format!("git returned {v:?}, which is not a whole number"),
289            },
290        },
291        Value::Unset => Value::Unset,
292        Value::Bad { why } => Value::Bad { why },
293    }
294}
295
296/// One of a fixed set of words, compared case-insensitively.
297///
298/// Git has no `--type` for this, so the value is read raw and checked here —
299/// which means this is the one reader whose `Bad` message is ours. It names
300/// every accepted spelling, because a rejection that does not say what was
301/// wanted sends the reader to the documentation for a list we already hold.
302pub fn enumerated(
303    settings: &crate::config::Settings,
304    key: &str,
305    allowed: &[&'static str],
306) -> Value<&'static str> {
307    match resolve(settings, key, None) {
308        Value::Set(v) => {
309            let got = v.trim().to_ascii_lowercase();
310            match allowed.iter().find(|a| a.eq_ignore_ascii_case(&got)) {
311                Some(hit) => Value::Set(hit),
312                None => Value::Bad {
313                    why: format!("{got:?} is not one of {}", allowed.join(", ")),
314                },
315            }
316        }
317        Value::Unset => Value::Unset,
318        Value::Bad { why } => Value::Bad { why },
319    }
320}
321
322/// Say once, per key, that a configured value could not be used.
323///
324/// Deduplicated because a key read twice in one run is a detail of how the
325/// code is arranged, and repeating the warning would make it look like two
326/// separate mistakes.
327/// A free-form string key, policy-aware. Untyped reads have no `--type`
328/// for git to refuse, so `Bad` cannot arise — this is Set-or-not.
329pub fn string_value(settings: &crate::config::Settings, key: &str) -> Option<String> {
330    match resolve(settings, key, None) {
331        Value::Set(v) => Some(v),
332        _ => None,
333    }
334}
335
336pub fn complain(key: &str, why: &str, using: &str) {
337    static SAID: OnceLock<Mutex<BTreeSet<String>>> = OnceLock::new();
338    let said = SAID.get_or_init(|| Mutex::new(BTreeSet::new()));
339    // A poisoned mutex means another thread panicked mid-insert; warning twice
340    // is strictly better than joining it in panicking.
341    let fresh = match said.lock() {
342        Ok(mut set) => set.insert(key.to_string()),
343        Err(_) => true,
344    };
345    if fresh {
346        eprintln!(
347            "{} {}: {why} — using {using}",
348            warning_sign().trim(),
349            highlight(key)
350        );
351    }
352}
353
354pub fn boolean_or(settings: &crate::config::Settings, key: &str, default: bool) -> bool {
355    match boolean(settings, key) {
356        Value::Set(v) => v,
357        Value::Unset => default,
358        Value::Bad { why } => {
359            complain(key, &why, &default.to_string());
360            default
361        }
362    }
363}
364
365/// An integer, clamped to what the setting can actually mean.
366///
367/// Out of range is treated exactly as unparseable: a `subjectMax` of 0 would
368/// block every commit forever from a config file, and one of 10_000 is not a
369/// limit. Both are mistakes, and both get the default plus a line saying so.
370pub fn integer_or(
371    settings: &crate::config::Settings,
372    key: &str,
373    default: i64,
374    range: RangeInclusive<i64>,
375) -> i64 {
376    match integer(settings, key) {
377        Value::Set(v) if range.contains(&v) => v,
378        Value::Set(v) => {
379            complain(
380                key,
381                &format!("{v} is outside {}..={}", range.start(), range.end()),
382                &default.to_string(),
383            );
384            default
385        }
386        Value::Unset => default,
387        Value::Bad { why } => {
388            complain(key, &why, &default.to_string());
389            default
390        }
391    }
392}
393
394/// A HOST key: a setting that describes the machine — how many heavy checks
395/// it runs at once, how far its load may stretch a budget — and so is read
396/// from `--global` or `--system` config only (ADR-0009). A value set in a
397/// repository is ignored with one warning naming the scope to use: two
398/// repositories that disagreed about the host's slot count would each take
399/// slots the other never sees. An environment variable named after the key
400/// (`amont.idleLoadScale` → `AMONT_IDLE_LOAD_SCALE`) outranks both, which
401/// is the precedence `cli.config.precedence` gives it: flag or environment,
402/// then the person's config, then the system's, then the default. The
403/// `amont.conf` policy ladder does not apply: a committed file cannot set
404/// a host key.
405///
406/// One `--show-scope` scan per `Settings`, run on the first host key read
407/// and never before: `amont.idleLoadScale` is read when the first observed
408/// tool of any check is spawned, so a commit that runs no tool spawns
409/// nothing for it and one that does pays exactly one spawn, pinned by
410/// `spawn_budget.rs`.
411pub fn host_integer_or(
412    settings: &crate::config::Settings,
413    key: &str,
414    default: i64,
415    range: RangeInclusive<i64>,
416) -> i64 {
417    let accept = |raw: &str, source: &str| -> Option<i64> {
418        match raw.trim().parse::<i64>() {
419            Ok(v) if range.contains(&v) => Some(v),
420            Ok(v) => {
421                complain(
422                    key,
423                    &format!(
424                        "{v} ({source}) is outside {}..={}",
425                        range.start(),
426                        range.end()
427                    ),
428                    &default.to_string(),
429                );
430                None
431            }
432            Err(_) => {
433                complain(
434                    key,
435                    &format!("{raw:?} ({source}) is not a whole number"),
436                    &default.to_string(),
437                );
438                None
439            }
440        }
441    };
442    let env_name = host_env_name(key);
443    if let Some(raw) = std::env::var(&env_name)
444        .ok()
445        .filter(|v| !v.trim().is_empty())
446    {
447        if let Some(v) = accept(&raw, &env_name) {
448            return v;
449        }
450    }
451    let rows = settings.host.get_or_init(|| {
452        git::stdout(&["config", "--show-scope", "--get-regexp", r"^amont\."]).map(|out| {
453            out.lines()
454                .filter_map(|line| {
455                    let (scope, rest) = line.split_once('\t')?;
456                    let (k, v) = match rest.split_once(' ') {
457                        Some((k, v)) => (k, v),
458                        None => (rest, ""),
459                    };
460                    Some((scope.to_string(), k.to_ascii_lowercase(), v.to_string()))
461                })
462                .collect()
463        })
464    });
465    let Some(rows) = rows else {
466        // A git too old for `--show-scope`: the default, said once.
467        complain(key, "git cannot report config scopes", &default.to_string());
468        return default;
469    };
470    let wanted = key.to_ascii_lowercase();
471    let host = rows
472        .iter()
473        .rev()
474        .find(|(scope, k, _)| *k == wanted && (scope == "global" || scope == "system"));
475    if host.is_none() && rows.iter().any(|(_, k, _)| *k == wanted) {
476        complain(
477            key,
478            "is set in this repository's config, but a host key is read from --global or \
479             --system only",
480            &default.to_string(),
481        );
482    }
483    host.and_then(|(scope, _, v)| accept(v, scope))
484        .unwrap_or(default)
485}
486
487/// `amont.idleLoadScale` → `AMONT_IDLE_LOAD_SCALE`: the environment name a
488/// host key answers to.
489pub fn host_env_name(key: &str) -> String {
490    let mut out = String::from("AMONT");
491    for part in key.trim_start_matches("amont.").split('.') {
492        out.push('_');
493        for c in part.chars() {
494            if c.is_ascii_uppercase() {
495                out.push('_');
496            }
497            out.push(c.to_ascii_uppercase());
498        }
499    }
500    out
501}
502
503#[cfg(test)]
504mod host_key_tests {
505    #[test]
506    fn a_host_key_names_its_environment_variable() {
507        use super::host_env_name;
508        assert_eq!(
509            host_env_name("amont.idleLoadScale"),
510            "AMONT_IDLE_LOAD_SCALE"
511        );
512        assert_eq!(host_env_name("amont.hostSlots"), "AMONT_HOST_SLOTS");
513        assert_eq!(host_env_name("amont.timeout"), "AMONT_TIMEOUT");
514        assert_eq!(
515            host_env_name("amont.commit.bodyWrap"),
516            "AMONT_COMMIT_BODY_WRAP"
517        );
518    }
519}
520
521pub fn enumerated_or(
522    settings: &crate::config::Settings,
523    key: &str,
524    allowed: &[&'static str],
525    default: &'static str,
526) -> &'static str {
527    match enumerated(settings, key, allowed) {
528        Value::Set(v) => v,
529        Value::Unset => default,
530        Value::Bad { why } => {
531            complain(key, &why, default);
532            default
533        }
534    }
535}
536
537/// Which keys under `prefix` are set at all — one git call for the whole
538/// family.
539///
540/// This runs on the commit path, where four independent `--get` calls would be
541/// four processes spent discovering that nobody has configured anything. One
542/// `--get-regexp` answers that, and only the keys it names are read for real.
543/// Same shape as `registry::Overrides::read`, for the same reason.
544///
545/// **Names come back lowercased.** Git lowercases the section and key parts of
546/// every name it prints, so `amont.commit.subjectMax` is reported as
547/// `amont.commit.subjectmax`. Keys are case-insensitive on lookup, so this
548/// only affects comparison here — hence [`is_present`] rather than a bare
549/// `contains`.
550pub fn present(settings: &crate::config::Settings, prefix: &str) -> BTreeSet<String> {
551    let pattern = format!("^{}", regex_escape(prefix));
552    let policy_names = || -> BTreeSet<String> {
553        settings
554            .policy()
555            .settings
556            .keys()
557            .filter(|k| {
558                k.to_ascii_lowercase()
559                    .starts_with(&prefix.to_ascii_lowercase())
560            })
561            .map(|k| k.to_ascii_lowercase())
562            .collect()
563    };
564    let Some(out) = git::output(&["config", "--get-regexp", &pattern]) else {
565        return policy_names();
566    };
567    if out.code != 0 {
568        return policy_names();
569    }
570    let mut names: BTreeSet<String> = out
571        .stdout
572        .lines()
573        .filter_map(|l| l.split_whitespace().next())
574        .map(|k| k.to_ascii_lowercase())
575        .collect();
576    names.extend(
577        settings
578            .policy()
579            .settings
580            .keys()
581            .filter(|k| {
582                k.to_ascii_lowercase()
583                    .starts_with(&prefix.to_ascii_lowercase())
584            })
585            .map(|k| k.to_ascii_lowercase()),
586    );
587    names
588}
589
590/// Is `key` among the names [`present`] returned? Case-insensitive, because
591/// git config key names are.
592pub fn is_present(names: &BTreeSet<String>, key: &str) -> bool {
593    names.contains(&key.to_ascii_lowercase())
594}
595
596/// Escape the characters a config key can hold that a POSIX basic regex would
597/// otherwise read as syntax. Only `.` occurs in practice; the rest are here so
598/// this cannot become wrong if a caller passes something else.
599fn regex_escape(s: &str) -> String {
600    let mut out = String::with_capacity(s.len() * 2);
601    for c in s.chars() {
602        if matches!(c, '.' | '*' | '[' | ']' | '^' | '$' | '\\') {
603            out.push('\\');
604        }
605        out.push(c);
606    }
607    out
608}
609
610/// Where a key's value came from, for the commands whose job is reading
611/// configuration back.
612///
613/// This costs a second git call per key and must never be used on the commit
614/// path — `amont list` and `amont setup` are the only callers, and they
615/// are already asking git several questions to render one screen.
616#[derive(Debug, Clone, Copy, PartialEq, Eq)]
617pub enum Scope {
618    Default,
619    Local,
620    Global,
621    System,
622    CommandLine,
623    /// The value in effect comes from the repository's committed policy.
624    Policy,
625    Other,
626}
627
628impl Scope {
629    pub fn as_str(self) -> &'static str {
630        match self {
631            Scope::Default => "default",
632            Scope::Local => "local",
633            Scope::Global => "global",
634            Scope::System => "system",
635            Scope::CommandLine => "command line",
636            Scope::Policy => "amont.conf",
637            Scope::Other => "other",
638        }
639    }
640}
641
642/// `git config --show-origin --get <key>` → which file it came from.
643///
644/// `Default` for a key nobody set, which is also the answer when git cannot be
645/// asked — the value in use is the shipped one either way.
646pub fn scope_of(settings: &crate::config::Settings, key: &str) -> Scope {
647    // Display mirrors resolution: policy owns the key unless something above
648    // it on the ladder set it. The precedence answer comes from the same
649    // classifier `resolve` uses, never re-derived from file paths.
650    if settings.policy().settings.contains_key(key) && !key_set_above_policy(settings, key) {
651        return Scope::Policy;
652    }
653    let Some(out) = git::output(&["config", "--show-origin", "--get", key]) else {
654        return Scope::Default;
655    };
656    if out.code != 0 {
657        return Scope::Default;
658    }
659    // `<origin>\t<value>`; the origin is `file:/path`, `command line:` or
660    // `blob:…`, and the path is what tells local from global.
661    let origin = out.stdout.split('\t').next().unwrap_or("");
662    if origin.starts_with("command line") {
663        return Scope::CommandLine;
664    }
665    let Some(path) = origin.strip_prefix("file:") else {
666        return Scope::Other;
667    };
668    let path = path.trim();
669    // A repository's own config is the only one inside a `.git` directory;
670    // asking git for the paths rather than guessing at `~` keeps this correct
671    // under `GIT_CONFIG_GLOBAL`, worktrees and `$XDG_CONFIG_HOME`.
672    if same_file(
673        path,
674        git::stdout(&["rev-parse", "--git-path", "config"]).as_deref(),
675    ) {
676        return Scope::Local;
677    }
678    for (flag, scope) in [("--global", Scope::Global), ("--system", Scope::System)] {
679        let listed = git::output(&["config", flag, "--list", "--show-origin"]);
680        if let Some(o) = listed {
681            if o.code == 0
682                && o.stdout
683                    .lines()
684                    .filter_map(|l| l.split('\t').next())
685                    .filter_map(|o| o.strip_prefix("file:"))
686                    .any(|p| same_file(path, Some(p.trim())))
687            {
688                return scope;
689            }
690        }
691    }
692    Scope::Other
693}
694
695/// Compare two paths as the same file where the filesystem can say so, falling
696/// back to the strings. `--git-path` answers relatively (`.git/config`) while
697/// `--show-origin` may answer absolutely, so a string comparison alone
698/// misreports a local key as `other`.
699fn same_file(a: &str, b: Option<&str>) -> bool {
700    let Some(b) = b else { return false };
701    if a == b {
702        return true;
703    }
704    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
705        (Ok(x), Ok(y)) => x == y,
706        _ => false,
707    }
708}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713
714    /// The distinction the whole module exists for: git's two failure exits
715    /// mean opposite things, and `first_line` is what a reader is shown for
716    /// the one that is a mistake.
717    #[test]
718    fn a_fatal_line_is_reported_without_its_prefix() {
719        assert_eq!(
720            first_line("fatal: bad numeric config value 'wide' for 'a.b'"),
721            "bad numeric config value 'wide' for 'a.b'"
722        );
723        assert_eq!(first_line("first\nsecond"), "first");
724    }
725
726    /// A warning that names no cause is a puzzle, so there is always a cause.
727    #[test]
728    fn an_empty_diagnostic_still_says_something() {
729        assert!(!first_line("").is_empty());
730        assert!(!first_line("   \n  ").is_empty());
731    }
732
733    #[test]
734    fn a_key_name_is_escaped_before_it_becomes_a_pattern() {
735        // Without escaping, `.` matches any character and the prefix would
736        // also select `amontXcommit.*`.
737        assert_eq!(regex_escape("amont.commit."), "amont\\.commit\\.");
738        assert_eq!(regex_escape("plain"), "plain");
739    }
740
741    /// Git lowercases the names it prints, so a presence test that respected
742    /// case would answer "not set" for every camelCase key we ship.
743    #[test]
744    fn presence_is_case_insensitive_because_git_lowercases_names() {
745        let names: BTreeSet<String> = ["amont.commit.subjectmax".to_string()]
746            .into_iter()
747            .collect();
748        assert!(is_present(&names, "amont.commit.subjectMax"));
749        assert!(is_present(&names, "AMONT.COMMIT.SUBJECTMAX"));
750        assert!(!is_present(&names, "amont.commit.bodyWrap"));
751    }
752
753    #[test]
754    fn every_scope_has_a_name() {
755        for s in [
756            Scope::Default,
757            Scope::Local,
758            Scope::Global,
759            Scope::System,
760            Scope::CommandLine,
761            Scope::Other,
762        ] {
763            assert!(!s.as_str().is_empty());
764        }
765    }
766}