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
218impl Settings {
219 /// The policy a trusted manifest declared — empty when nothing was
220 /// declared or the manifest was not trusted. `manifest::load` decides
221 /// that and hands the answer here.
222 pub fn new(policy: crate::policy::Policy) -> Self {
223 Self {
224 policy,
225 ..Self::default()
226 }
227 }
228
229 pub fn policy(&self) -> &crate::policy::Policy {
230 &self.policy
231 }
232
233 /// A fresh `Settings` over the same policy, with empty caches.
234 ///
235 /// For a spawned thread, which cannot borrow this one. It resolves the
236 /// same answers — same policy, same git config — and it resolves them
237 /// LAZILY, which is the property that matters: a paint or heartbeat
238 /// thread that never lives long enough to read a budget must not have
239 /// cost a `git config` spawn for it up front. Handing the thread
240 /// pre-resolved numbers did exactly that, and `spawn_budget` said so.
241 pub fn for_thread(&self) -> Settings {
242 Settings::new(self.policy.clone())
243 }
244}
245
246pub fn boolean(settings: &crate::config::Settings, key: &str) -> Value<bool> {
247 match resolve(settings, key, Some("bool")) {
248 Value::Set(v) => match v.as_str() {
249 "true" => Value::Set(true),
250 "false" => Value::Set(false),
251 other => Value::Bad {
252 why: format!("git normalised it to {other:?}, which is neither true nor false"),
253 },
254 },
255 Value::Unset => Value::Unset,
256 Value::Bad { why } => Value::Bad { why },
257 }
258}
259
260/// An integer, in git's own spelling — which includes the `k`/`m`/`g` suffixes
261/// git accepts, since `--type=int` expands them before we see them.
262pub fn integer(settings: &crate::config::Settings, key: &str) -> Value<i64> {
263 match resolve(settings, key, Some("int")) {
264 Value::Set(v) => match v.parse::<i64>() {
265 Ok(n) => Value::Set(n),
266 Err(_) => Value::Bad {
267 why: format!("git returned {v:?}, which is not a whole number"),
268 },
269 },
270 Value::Unset => Value::Unset,
271 Value::Bad { why } => Value::Bad { why },
272 }
273}
274
275/// One of a fixed set of words, compared case-insensitively.
276///
277/// Git has no `--type` for this, so the value is read raw and checked here —
278/// which means this is the one reader whose `Bad` message is ours. It names
279/// every accepted spelling, because a rejection that does not say what was
280/// wanted sends the reader to the documentation for a list we already hold.
281pub fn enumerated(
282 settings: &crate::config::Settings,
283 key: &str,
284 allowed: &[&'static str],
285) -> Value<&'static str> {
286 match resolve(settings, key, None) {
287 Value::Set(v) => {
288 let got = v.trim().to_ascii_lowercase();
289 match allowed.iter().find(|a| a.eq_ignore_ascii_case(&got)) {
290 Some(hit) => Value::Set(hit),
291 None => Value::Bad {
292 why: format!("{got:?} is not one of {}", allowed.join(", ")),
293 },
294 }
295 }
296 Value::Unset => Value::Unset,
297 Value::Bad { why } => Value::Bad { why },
298 }
299}
300
301/// Say once, per key, that a configured value could not be used.
302///
303/// Deduplicated because a key read twice in one run is a detail of how the
304/// code is arranged, and repeating the warning would make it look like two
305/// separate mistakes.
306/// A free-form string key, policy-aware. Untyped reads have no `--type`
307/// for git to refuse, so `Bad` cannot arise — this is Set-or-not.
308pub fn string_value(settings: &crate::config::Settings, key: &str) -> Option<String> {
309 match resolve(settings, key, None) {
310 Value::Set(v) => Some(v),
311 _ => None,
312 }
313}
314
315pub fn complain(key: &str, why: &str, using: &str) {
316 static SAID: OnceLock<Mutex<BTreeSet<String>>> = OnceLock::new();
317 let said = SAID.get_or_init(|| Mutex::new(BTreeSet::new()));
318 // A poisoned mutex means another thread panicked mid-insert; warning twice
319 // is strictly better than joining it in panicking.
320 let fresh = match said.lock() {
321 Ok(mut set) => set.insert(key.to_string()),
322 Err(_) => true,
323 };
324 if fresh {
325 eprintln!(
326 "{} {}: {why} — using {using}",
327 warning_sign().trim(),
328 highlight(key)
329 );
330 }
331}
332
333pub fn boolean_or(settings: &crate::config::Settings, key: &str, default: bool) -> bool {
334 match boolean(settings, key) {
335 Value::Set(v) => v,
336 Value::Unset => default,
337 Value::Bad { why } => {
338 complain(key, &why, &default.to_string());
339 default
340 }
341 }
342}
343
344/// An integer, clamped to what the setting can actually mean.
345///
346/// Out of range is treated exactly as unparseable: a `subjectMax` of 0 would
347/// block every commit forever from a config file, and one of 10_000 is not a
348/// limit. Both are mistakes, and both get the default plus a line saying so.
349pub fn integer_or(
350 settings: &crate::config::Settings,
351 key: &str,
352 default: i64,
353 range: RangeInclusive<i64>,
354) -> i64 {
355 match integer(settings, key) {
356 Value::Set(v) if range.contains(&v) => v,
357 Value::Set(v) => {
358 complain(
359 key,
360 &format!("{v} is outside {}..={}", range.start(), range.end()),
361 &default.to_string(),
362 );
363 default
364 }
365 Value::Unset => default,
366 Value::Bad { why } => {
367 complain(key, &why, &default.to_string());
368 default
369 }
370 }
371}
372
373/// A HOST key: a setting that describes the machine — how many heavy checks
374/// it runs at once, how far its load may stretch a budget — and so is read
375/// from `--global` or `--system` config only (ADR-0009). A value set in a
376/// repository is ignored with one warning naming the scope to use: two
377/// repositories that disagreed about the host's slot count would each take
378/// slots the other never sees. An environment variable named after the key
379/// (`amont.idleLoadScale` → `AMONT_IDLE_LOAD_SCALE`) outranks both, which
380/// is the precedence `cli.config.precedence` gives it: flag or environment,
381/// then the person's config, then the system's, then the default. The
382/// `amont.conf` policy ladder does not apply: a committed file cannot set
383/// a host key.
384///
385/// One `--show-scope` scan per `Settings`, run on the first host key read
386/// and never before: `amont.idleLoadScale` is read when the first observed
387/// tool of any check is spawned, so a commit that runs no tool spawns
388/// nothing for it and one that does pays exactly one spawn, pinned by
389/// `spawn_budget.rs`.
390pub fn host_integer_or(
391 settings: &crate::config::Settings,
392 key: &str,
393 default: i64,
394 range: RangeInclusive<i64>,
395) -> i64 {
396 let accept = |raw: &str, source: &str| -> Option<i64> {
397 match raw.trim().parse::<i64>() {
398 Ok(v) if range.contains(&v) => Some(v),
399 Ok(v) => {
400 complain(
401 key,
402 &format!(
403 "{v} ({source}) is outside {}..={}",
404 range.start(),
405 range.end()
406 ),
407 &default.to_string(),
408 );
409 None
410 }
411 Err(_) => {
412 complain(
413 key,
414 &format!("{raw:?} ({source}) is not a whole number"),
415 &default.to_string(),
416 );
417 None
418 }
419 }
420 };
421 let env_name = host_env_name(key);
422 if let Some(raw) = std::env::var(&env_name)
423 .ok()
424 .filter(|v| !v.trim().is_empty())
425 {
426 if let Some(v) = accept(&raw, &env_name) {
427 return v;
428 }
429 }
430 let rows = settings.host.get_or_init(|| {
431 git::stdout(&["config", "--show-scope", "--get-regexp", r"^amont\."]).map(|out| {
432 out.lines()
433 .filter_map(|line| {
434 let (scope, rest) = line.split_once('\t')?;
435 let (k, v) = match rest.split_once(' ') {
436 Some((k, v)) => (k, v),
437 None => (rest, ""),
438 };
439 Some((scope.to_string(), k.to_ascii_lowercase(), v.to_string()))
440 })
441 .collect()
442 })
443 });
444 let Some(rows) = rows else {
445 // A git too old for `--show-scope`: the default, said once.
446 complain(key, "git cannot report config scopes", &default.to_string());
447 return default;
448 };
449 let wanted = key.to_ascii_lowercase();
450 let host = rows
451 .iter()
452 .rev()
453 .find(|(scope, k, _)| *k == wanted && (scope == "global" || scope == "system"));
454 if host.is_none() && rows.iter().any(|(_, k, _)| *k == wanted) {
455 complain(
456 key,
457 "is set in this repository's config, but a host key is read from --global or \
458 --system only",
459 &default.to_string(),
460 );
461 }
462 host.and_then(|(scope, _, v)| accept(v, scope))
463 .unwrap_or(default)
464}
465
466/// `amont.idleLoadScale` → `AMONT_IDLE_LOAD_SCALE`: the environment name a
467/// host key answers to.
468pub fn host_env_name(key: &str) -> String {
469 let mut out = String::from("AMONT");
470 for part in key.trim_start_matches("amont.").split('.') {
471 out.push('_');
472 for c in part.chars() {
473 if c.is_ascii_uppercase() {
474 out.push('_');
475 }
476 out.push(c.to_ascii_uppercase());
477 }
478 }
479 out
480}
481
482#[cfg(test)]
483mod host_key_tests {
484 #[test]
485 fn a_host_key_names_its_environment_variable() {
486 use super::host_env_name;
487 assert_eq!(
488 host_env_name("amont.idleLoadScale"),
489 "AMONT_IDLE_LOAD_SCALE"
490 );
491 assert_eq!(host_env_name("amont.hostSlots"), "AMONT_HOST_SLOTS");
492 assert_eq!(host_env_name("amont.timeout"), "AMONT_TIMEOUT");
493 assert_eq!(
494 host_env_name("amont.commit.bodyWrap"),
495 "AMONT_COMMIT_BODY_WRAP"
496 );
497 }
498}
499
500pub fn enumerated_or(
501 settings: &crate::config::Settings,
502 key: &str,
503 allowed: &[&'static str],
504 default: &'static str,
505) -> &'static str {
506 match enumerated(settings, key, allowed) {
507 Value::Set(v) => v,
508 Value::Unset => default,
509 Value::Bad { why } => {
510 complain(key, &why, default);
511 default
512 }
513 }
514}
515
516/// Which keys under `prefix` are set at all — one git call for the whole
517/// family.
518///
519/// This runs on the commit path, where four independent `--get` calls would be
520/// four processes spent discovering that nobody has configured anything. One
521/// `--get-regexp` answers that, and only the keys it names are read for real.
522/// Same shape as `registry::Overrides::read`, for the same reason.
523///
524/// **Names come back lowercased.** Git lowercases the section and key parts of
525/// every name it prints, so `amont.commit.subjectMax` is reported as
526/// `amont.commit.subjectmax`. Keys are case-insensitive on lookup, so this
527/// only affects comparison here — hence [`is_present`] rather than a bare
528/// `contains`.
529pub fn present(settings: &crate::config::Settings, prefix: &str) -> BTreeSet<String> {
530 let pattern = format!("^{}", regex_escape(prefix));
531 let policy_names = || -> BTreeSet<String> {
532 settings
533 .policy()
534 .settings
535 .keys()
536 .filter(|k| {
537 k.to_ascii_lowercase()
538 .starts_with(&prefix.to_ascii_lowercase())
539 })
540 .map(|k| k.to_ascii_lowercase())
541 .collect()
542 };
543 let Some(out) = git::output(&["config", "--get-regexp", &pattern]) else {
544 return policy_names();
545 };
546 if out.code != 0 {
547 return policy_names();
548 }
549 let mut names: BTreeSet<String> = out
550 .stdout
551 .lines()
552 .filter_map(|l| l.split_whitespace().next())
553 .map(|k| k.to_ascii_lowercase())
554 .collect();
555 names.extend(
556 settings
557 .policy()
558 .settings
559 .keys()
560 .filter(|k| {
561 k.to_ascii_lowercase()
562 .starts_with(&prefix.to_ascii_lowercase())
563 })
564 .map(|k| k.to_ascii_lowercase()),
565 );
566 names
567}
568
569/// Is `key` among the names [`present`] returned? Case-insensitive, because
570/// git config key names are.
571pub fn is_present(names: &BTreeSet<String>, key: &str) -> bool {
572 names.contains(&key.to_ascii_lowercase())
573}
574
575/// Escape the characters a config key can hold that a POSIX basic regex would
576/// otherwise read as syntax. Only `.` occurs in practice; the rest are here so
577/// this cannot become wrong if a caller passes something else.
578fn regex_escape(s: &str) -> String {
579 let mut out = String::with_capacity(s.len() * 2);
580 for c in s.chars() {
581 if matches!(c, '.' | '*' | '[' | ']' | '^' | '$' | '\\') {
582 out.push('\\');
583 }
584 out.push(c);
585 }
586 out
587}
588
589/// Where a key's value came from, for the commands whose job is reading
590/// configuration back.
591///
592/// This costs a second git call per key and must never be used on the commit
593/// path — `amont list` and `amont setup` are the only callers, and they
594/// are already asking git several questions to render one screen.
595#[derive(Debug, Clone, Copy, PartialEq, Eq)]
596pub enum Scope {
597 Default,
598 Local,
599 Global,
600 System,
601 CommandLine,
602 /// The value in effect comes from the repository's committed policy.
603 Policy,
604 Other,
605}
606
607impl Scope {
608 pub fn as_str(self) -> &'static str {
609 match self {
610 Scope::Default => "default",
611 Scope::Local => "local",
612 Scope::Global => "global",
613 Scope::System => "system",
614 Scope::CommandLine => "command line",
615 Scope::Policy => "amont.conf",
616 Scope::Other => "other",
617 }
618 }
619}
620
621/// `git config --show-origin --get <key>` → which file it came from.
622///
623/// `Default` for a key nobody set, which is also the answer when git cannot be
624/// asked — the value in use is the shipped one either way.
625pub fn scope_of(settings: &crate::config::Settings, key: &str) -> Scope {
626 // Display mirrors resolution: policy owns the key unless something above
627 // it on the ladder set it. The precedence answer comes from the same
628 // classifier `resolve` uses, never re-derived from file paths.
629 if settings.policy().settings.contains_key(key) && !key_set_above_policy(settings, key) {
630 return Scope::Policy;
631 }
632 let Some(out) = git::output(&["config", "--show-origin", "--get", key]) else {
633 return Scope::Default;
634 };
635 if out.code != 0 {
636 return Scope::Default;
637 }
638 // `<origin>\t<value>`; the origin is `file:/path`, `command line:` or
639 // `blob:…`, and the path is what tells local from global.
640 let origin = out.stdout.split('\t').next().unwrap_or("");
641 if origin.starts_with("command line") {
642 return Scope::CommandLine;
643 }
644 let Some(path) = origin.strip_prefix("file:") else {
645 return Scope::Other;
646 };
647 let path = path.trim();
648 // A repository's own config is the only one inside a `.git` directory;
649 // asking git for the paths rather than guessing at `~` keeps this correct
650 // under `GIT_CONFIG_GLOBAL`, worktrees and `$XDG_CONFIG_HOME`.
651 if same_file(
652 path,
653 git::stdout(&["rev-parse", "--git-path", "config"]).as_deref(),
654 ) {
655 return Scope::Local;
656 }
657 for (flag, scope) in [("--global", Scope::Global), ("--system", Scope::System)] {
658 let listed = git::output(&["config", flag, "--list", "--show-origin"]);
659 if let Some(o) = listed {
660 if o.code == 0
661 && o.stdout
662 .lines()
663 .filter_map(|l| l.split('\t').next())
664 .filter_map(|o| o.strip_prefix("file:"))
665 .any(|p| same_file(path, Some(p.trim())))
666 {
667 return scope;
668 }
669 }
670 }
671 Scope::Other
672}
673
674/// Compare two paths as the same file where the filesystem can say so, falling
675/// back to the strings. `--git-path` answers relatively (`.git/config`) while
676/// `--show-origin` may answer absolutely, so a string comparison alone
677/// misreports a local key as `other`.
678fn same_file(a: &str, b: Option<&str>) -> bool {
679 let Some(b) = b else { return false };
680 if a == b {
681 return true;
682 }
683 match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
684 (Ok(x), Ok(y)) => x == y,
685 _ => false,
686 }
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692
693 /// The distinction the whole module exists for: git's two failure exits
694 /// mean opposite things, and `first_line` is what a reader is shown for
695 /// the one that is a mistake.
696 #[test]
697 fn a_fatal_line_is_reported_without_its_prefix() {
698 assert_eq!(
699 first_line("fatal: bad numeric config value 'wide' for 'a.b'"),
700 "bad numeric config value 'wide' for 'a.b'"
701 );
702 assert_eq!(first_line("first\nsecond"), "first");
703 }
704
705 /// A warning that names no cause is a puzzle, so there is always a cause.
706 #[test]
707 fn an_empty_diagnostic_still_says_something() {
708 assert!(!first_line("").is_empty());
709 assert!(!first_line(" \n ").is_empty());
710 }
711
712 #[test]
713 fn a_key_name_is_escaped_before_it_becomes_a_pattern() {
714 // Without escaping, `.` matches any character and the prefix would
715 // also select `amontXcommit.*`.
716 assert_eq!(regex_escape("amont.commit."), "amont\\.commit\\.");
717 assert_eq!(regex_escape("plain"), "plain");
718 }
719
720 /// Git lowercases the names it prints, so a presence test that respected
721 /// case would answer "not set" for every camelCase key we ship.
722 #[test]
723 fn presence_is_case_insensitive_because_git_lowercases_names() {
724 let names: BTreeSet<String> = ["amont.commit.subjectmax".to_string()]
725 .into_iter()
726 .collect();
727 assert!(is_present(&names, "amont.commit.subjectMax"));
728 assert!(is_present(&names, "AMONT.COMMIT.SUBJECTMAX"));
729 assert!(!is_present(&names, "amont.commit.bodyWrap"));
730 }
731
732 #[test]
733 fn every_scope_has_a_name() {
734 for s in [
735 Scope::Default,
736 Scope::Local,
737 Scope::Global,
738 Scope::System,
739 Scope::CommandLine,
740 Scope::Other,
741 ] {
742 assert!(!s.as_str().is_empty());
743 }
744 }
745}