Skip to main content

grit_lib/config/
core.rs

1//! Git-compatible configuration file parser and accessor.
2//!
3//! Supports the standard Git config file format:
4//!
5//! ```text
6//! [section]
7//!     key = value
8//! [section "subsection"]
9//!     key = value
10//! ```
11//!
12//! # Multi-file layering
13//!
14//! Git reads configuration from several files in priority order:
15//!
16//! 1. System (`/etc/gitconfig`)
17//! 2. Global (`~/.gitconfig` or `$XDG_CONFIG_HOME/git/config`)
18//! 3. Local (`.git/config`)
19//! 4. Worktree (`.git/config.worktree`)
20//! 5. Command-line (`-c key=value` or `GIT_CONFIG_*`)
21//!
22//! [`ConfigSet`] merges all layers; last-wins for single-valued keys.
23//!
24//! # Include directives
25//!
26//! `[include] path = <path>` and `[includeIf "<condition>"] path = <path>`
27//! are supported. Conditions: `gitdir:`, `gitdir/i:`, `onbranch:`,
28//! and `hasconfig:remote.*.url:`.
29//!
30//! An included file is merged in place, at the position of its directive, so
31//! later entries still override it. `hasconfig:remote.*.url:<glob>` matches
32//! against every `remote.<name>.url` value of the whole load, not just the
33//! files read so far: a global include can select a work identity from a
34//! remote defined in the repository `config`. As in Git, remote URLs may not
35//! be set in a file reached through a conditional include once such a
36//! condition is evaluated ([`ConfigError::RemoteUrlInHasconfigInclude`]).
37
38use crate::git_path::CanonicalizeExt;
39use std::borrow::Cow;
40use std::collections::HashMap;
41
42use crate::environment::Environment;
43use std::fmt;
44use std::fs;
45use std::path::{Path, PathBuf};
46use std::sync::Arc;
47use std::time::SystemTime;
48
49use crate::error::{
50    BadNumericSource, ConfigError, ConfigValueError, Error, InvalidKeyReason, Result,
51};
52
53/// Test-only counters for config cascade load activity (see `porcelain_ops_load_config_cascade_once`).
54#[cfg(test)]
55pub mod cascade_load_counters {
56    use std::cell::Cell;
57
58    // Counters are per thread like `MEASURING`, so tests measuring on parallel threads do not
59    // add to or reset each other's counts.
60    thread_local! {
61        static MEASURING: Cell<bool> = const { Cell::new(false) };
62        static UNCACHED: Cell<usize> = const { Cell::new(0) };
63        static CACHE_VALIDATED: Cell<usize> = const { Cell::new(0) };
64    }
65
66    /// Run `f` while recording cascade loads on this thread only (parallel-safe).
67    pub fn measure<R>(f: impl FnOnce() -> R) -> R {
68        MEASURING.with(|m| {
69            m.set(true);
70            reset();
71            let out = f();
72            m.set(false);
73            out
74        })
75    }
76
77    /// Reset counters before an operation under test.
78    pub fn reset() {
79        UNCACHED.with(|c| c.set(0));
80        CACHE_VALIDATED.with(|c| c.set(0));
81    }
82
83    /// Full cascade parses (cache miss).
84    #[must_use]
85    pub fn uncached_loads() -> usize {
86        UNCACHED.with(Cell::get)
87    }
88
89    /// Served from the process-global config cache after stat revalidation.
90    #[must_use]
91    pub fn cache_validated_loads() -> usize {
92        CACHE_VALIDATED.with(Cell::get)
93    }
94
95    /// Total [`ConfigSet::load`] / [`ConfigSet::load_with_options`] invocations.
96    #[must_use]
97    pub fn total_loads() -> usize {
98        uncached_loads().saturating_add(cache_validated_loads())
99    }
100
101    pub(crate) fn record_uncached() {
102        if MEASURING.with(Cell::get) {
103            UNCACHED.with(|c| c.set(c.get() + 1));
104        }
105    }
106}
107use crate::refs;
108use crate::wildmatch::{wildmatch, WM_CASEFOLD, WM_PATHNAME};
109
110/// The scope (origin) of a configuration value.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
112pub enum ConfigScope {
113    /// System-wide configuration (`/etc/gitconfig`).
114    System,
115    /// Per-user global configuration (`~/.gitconfig` or XDG).
116    Global,
117    /// Repository-local configuration (`.git/config`).
118    Local,
119    /// Per-worktree configuration (`.git/config.worktree`).
120    Worktree,
121    /// Command-line overrides (`-c key=value`).
122    Command,
123}
124
125impl fmt::Display for ConfigScope {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        match self {
128            Self::System => write!(f, "system"),
129            Self::Global => write!(f, "global"),
130            Self::Local => write!(f, "local"),
131            Self::Worktree => write!(f, "worktree"),
132            Self::Command => write!(f, "command"),
133        }
134    }
135}
136
137/// Origin metadata for the winning config value of a key.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct ConfigValueOrigin {
140    /// String value, or `"true"` for a bare boolean key. Only `None` when the key is missing
141    /// (this struct is not used for missing keys — see [`ConfigSet::get_with_origin`]).
142    pub value: Option<String>,
143    /// Config layer that supplied the value.
144    pub scope: ConfigScope,
145    /// Source file when file-backed; absent for command-line overrides.
146    pub file: Option<PathBuf>,
147    /// One-based line number in `file`; `None` for command-line / `-f` scope (Git has no file line).
148    pub line: Option<usize>,
149}
150
151/// A single configuration entry with its origin metadata.
152#[derive(Debug, Clone)]
153pub struct ConfigEntry {
154    /// Fully-qualified key in canonical form: `section.subsection.name`
155    /// (section and name lowercased; subsection preserves case).
156    pub key: String,
157    /// The raw string value, or `None` for a boolean-true bare key.
158    pub value: Option<String>,
159    /// Which scope this entry came from.
160    pub scope: ConfigScope,
161    /// The file this entry was read from (if file-backed).
162    pub file: Option<PathBuf>,
163    /// One-based line number in the source file.
164    pub line: usize,
165}
166
167/// Where a [`ConfigFile`] was loaded from for Git include semantics.
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum ConfigIncludeOrigin {
170    /// Normal path on disk (`-f`, global/local config files, etc.).
171    Disk,
172    /// `--file -` (stdin).
173    Stdin,
174    /// Synthetic file built from `GIT_CONFIG_PARAMETERS` / `git -c`.
175    CommandLine,
176    /// `git config --blob=…`.
177    Blob,
178}
179
180/// A parsed configuration file that preserves the raw text for round-trip
181/// editing (set/unset/rename-section/remove-section).
182#[derive(Debug, Clone)]
183pub struct ConfigFile {
184    /// The path to this config file on disk.
185    pub path: PathBuf,
186    /// The scope this file represents.
187    pub scope: ConfigScope,
188    /// Parsed entries (in file order).
189    pub entries: Vec<ConfigEntry>,
190    /// Raw lines of the file (for round-trip editing).
191    raw_lines: Vec<String>,
192    /// Source kind for `[include]` resolution (Git `CONFIG_ORIGIN_*`).
193    pub include_origin: ConfigIncludeOrigin,
194}
195
196/// A merged view across all configuration scopes.
197///
198/// Entries are stored in file-order within each scope; scopes are layered
199/// in priority order (system < global < local < worktree < command).
200///
201/// # Examples
202///
203/// ```
204/// use grit_lib::repo::{InitOptions, Repository};
205///
206/// # fn main() -> grit_lib::error::Result<()> {
207/// let dir = tempfile::tempdir()?;
208/// let repo = Repository::init(dir.path(), &InitOptions::default())?;
209/// let cfg = repo.config()?;
210/// assert!(cfg.get("core.repositoryformatversion").is_some());
211/// # Ok(())
212/// # }
213/// ```
214#[derive(Debug, Clone, Default)]
215pub struct ConfigSet {
216    /// All entries across all scopes, in load order.
217    entries: Vec<ConfigEntry>,
218}
219
220/// Context for evaluating `[includeIf]` conditions (`gitdir:`, `onbranch:`, `hasconfig:`).
221#[derive(Debug, Clone)]
222pub struct IncludeContext {
223    /// Git directory path used for `gitdir:` matching (may contain unresolved symlinks).
224    pub git_dir: Option<PathBuf>,
225    /// When true, `git -c include.path=relative` fails instead of ignoring the include.
226    pub command_line_relative_include_is_error: bool,
227    /// Working directory for relative include resolution (from [`Environment::cwd`]).
228    pub cwd: PathBuf,
229    /// Symlink-preserving `$PWD` for `gitdir:` matching when set.
230    pub pwd: Option<String>,
231    /// Environment used for `~/` expansion in include paths.
232    pub env: std::sync::Arc<Environment>,
233}
234
235impl Default for IncludeContext {
236    fn default() -> Self {
237        Self {
238            git_dir: None,
239            command_line_relative_include_is_error: false,
240            cwd: PathBuf::from("."),
241            pwd: None,
242            env: std::sync::Arc::new(Environment::empty()),
243        }
244    }
245}
246
247/// Options controlling how [`ConfigSet::load_with_options`] merges files and includes.
248#[derive(Clone)]
249pub struct LoadConfigOptions {
250    /// Load `/etc/gitconfig` (unless `GIT_CONFIG_NOSYSTEM` is enabled).
251    pub include_system: bool,
252    /// Expand `[include]` / `[includeIf]` while reading file-backed layers.
253    pub process_includes: bool,
254    /// Expand includes for synthetic command-line config built from `GIT_CONFIG_PARAMETERS`.
255    pub command_includes: bool,
256    /// Git directory and environment used to evaluate `[includeIf]` conditions.
257    pub include_ctx: IncludeContext,
258    /// Optional sink for non-fatal config load warnings.
259    pub diagnostics: Option<crate::diagnostics::DiagnosticsHandle>,
260    /// When set, repository-local `config` is read at most once per [`crate::repo_caches::RepoCaches`] handle.
261    pub repo_caches: Option<std::sync::Arc<crate::repo_caches::RepoCaches>>,
262}
263
264impl std::fmt::Debug for LoadConfigOptions {
265    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266        f.debug_struct("LoadConfigOptions")
267            .field("include_system", &self.include_system)
268            .field("process_includes", &self.process_includes)
269            .field("command_includes", &self.command_includes)
270            .field("include_ctx", &self.include_ctx)
271            .finish_non_exhaustive()
272    }
273}
274
275impl Default for LoadConfigOptions {
276    fn default() -> Self {
277        Self {
278            include_system: true,
279            process_includes: true,
280            command_includes: true,
281            include_ctx: IncludeContext::default(),
282            diagnostics: None,
283            repo_caches: None,
284        }
285    }
286}
287
288impl LoadConfigOptions {
289    /// Options for the full cascade of the repository at `git_dir`, as its
290    /// [`crate::repo::Repository`] handle loads it: system config included, `gitdir:`
291    /// conditions matched against `git_dir`, and the local `config` read through `repo_caches`.
292    ///
293    /// Every loader of one repository's cascade must use these options so they share a single
294    /// [`crate::repo_caches::RepoCaches`] cache entry instead of parsing the files again.
295    pub(crate) fn for_repository(
296        env: &Environment,
297        git_dir: Option<&Path>,
298        repo_caches: Option<Arc<crate::repo_caches::RepoCaches>>,
299    ) -> Self {
300        Self {
301            include_ctx: IncludeContext {
302                git_dir: git_dir.map(Path::to_path_buf),
303                cwd: env.cwd.clone(),
304                pwd: env.pwd.clone(),
305                ..IncludeContext::default()
306            },
307            repo_caches,
308            ..Self::default()
309        }
310    }
311}
312
313/// Source file for `--show-origin` style output.
314///
315/// `-f` / `--file` reads use `command` scope but keep a real file path; synthetic
316/// `GIT_CONFIG_PARAMETERS` / `-c` entries have no file.
317#[must_use]
318pub fn config_entry_origin_file(entry: &ConfigEntry) -> Option<&Path> {
319    if entry.scope == ConfigScope::Command && entry.file.is_none() {
320        None
321    } else {
322        entry.file.as_deref()
323    }
324}
325
326/// One-based source line for `--show-origin` output, when applicable.
327#[must_use]
328pub fn config_entry_origin_line(entry: &ConfigEntry) -> Option<usize> {
329    if entry.scope == ConfigScope::Command && entry.file.is_none() {
330        None
331    } else {
332        (entry.line > 0).then_some(entry.line)
333    }
334}
335
336// ── Canonical key helpers ────────────────────────────────────────────
337
338/// Normalise a config key to canonical form.
339///
340/// - Section name is lowercased.
341/// - Variable name (last dot-separated component) is lowercased.
342/// - Subsection (middle components) preserves original case.
343///
344/// Returns `Err` if the key has fewer than two dot-separated parts.
345///
346/// # Examples
347///
348/// - `core.bare` → `core.bare`
349/// - `Section.SubSection.Key` → `section.SubSection.key`
350/// - `CORE.BARE` → `core.bare`
351pub fn canonical_key(raw: &str) -> Result<String> {
352    let invalid = |reason| {
353        Error::Config(ConfigError::InvalidKey {
354            key: raw.to_owned(),
355            reason,
356        })
357    };
358    if raw.contains('\n') || raw.contains('\r') {
359        return Err(invalid(InvalidKeyReason::LineBreak));
360    }
361
362    let (Some(first_dot), Some(last_dot)) = (raw.find('.'), raw.rfind('.')) else {
363        return Err(invalid(InvalidKeyReason::MissingSection));
364    };
365
366    if last_dot == raw.len() - 1 {
367        return Err(invalid(InvalidKeyReason::MissingVariable));
368    }
369
370    let section = &raw[..first_dot];
371    let name = &raw[last_dot + 1..];
372
373    // Validate section name: must be alphanumeric or hyphen
374    if section.is_empty() || !section.chars().all(|c| c.is_alphanumeric() || c == '-') {
375        return Err(invalid(InvalidKeyReason::InvalidSection));
376    }
377
378    // Validate variable name: must start with alpha, rest alphanumeric or hyphen
379    if !name.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
380        || !name.chars().all(|c| c.is_alphanumeric() || c == '-')
381    {
382        return Err(invalid(InvalidKeyReason::InvalidVariable));
383    }
384
385    let mut key = String::with_capacity(raw.len());
386    push_lowercase(&mut key, section);
387    key.push('.');
388    if first_dot != last_dot {
389        key.push_str(&raw[first_dot + 1..last_dot]);
390        key.push('.');
391    }
392    push_lowercase(&mut key, name);
393    Ok(key)
394}
395
396/// Append `s` lowercased like [`str::to_lowercase`], without a temporary for ASCII input.
397fn push_lowercase(out: &mut String, s: &str) {
398    if s.is_ascii() {
399        out.extend(s.bytes().map(|b| char::from(b.to_ascii_lowercase())));
400    } else {
401        out.push_str(&s.to_lowercase());
402    }
403}
404
405// ── Parser ──────────────────────────────────────────────────────────
406
407/// Display path for config diagnostics (matches the internal `config_error_path_display` helper for public callers).
408#[must_use]
409pub fn config_file_display_for_error(path: &Path) -> String {
410    config_error_path_display(path)
411}
412
413fn config_error_path_display(path: &Path) -> String {
414    if path == Path::new("-") {
415        return "standard input".to_owned();
416    }
417    if path.file_name().and_then(|s| s.to_str()) == Some("config")
418        && path
419            .parent()
420            .and_then(|p| p.file_name())
421            .and_then(|s| s.to_str())
422            == Some(".git")
423    {
424        return ".git/config".to_owned();
425    }
426    path.display().to_string()
427}
428
429/// `fetch.negotiationAlgorithm` written without `= value` is rejected at parse time.
430fn reject_valueless_negotiation_algorithm(
431    key: &str,
432    value: Option<&str>,
433    path: &Path,
434    line: usize,
435) -> Result<()> {
436    if key != "fetch.negotiationalgorithm" || value.is_some() {
437        return Ok(());
438    }
439    Err(ConfigError::BadConfigVariable {
440        key: key.to_owned(),
441        file: config_error_path_display(path),
442        line,
443    }
444    .into())
445}
446
447/// State tracked while parsing a config file line-by-line.
448struct Parser {
449    section: String,
450    subsection: Option<String>,
451    /// `section.` or `section.subsection.` in canonical case, shared by every key of the
452    /// current section.
453    key_prefix: String,
454}
455
456impl Parser {
457    fn new() -> Self {
458        Self {
459            section: String::new(),
460            subsection: None,
461            key_prefix: String::new(),
462        }
463    }
464
465    fn valid_variable_name(name: &str) -> bool {
466        let name = name.trim();
467        name.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
468            && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
469    }
470
471    fn set_section(&mut self, section: String, subsection: Option<String>) {
472        self.key_prefix.clear();
473        push_lowercase(&mut self.key_prefix, &section);
474        self.key_prefix.push('.');
475        if let Some(sub) = &subsection {
476            self.key_prefix.push_str(sub);
477            self.key_prefix.push('.');
478        }
479        self.section = section;
480        self.subsection = subsection;
481    }
482
483    /// Build the canonical key for a variable name in the current section.
484    ///
485    /// `name` must already satisfy [`Self::valid_variable_name`], so it is ASCII.
486    fn make_key(&self, name: &str) -> String {
487        let mut key = String::with_capacity(self.key_prefix.len() + name.len());
488        key.push_str(&self.key_prefix);
489        push_lowercase(&mut key, name);
490        key
491    }
492
493    /// Parse a section header line like `[section]` or `[section "subsection"]`.
494    ///
495    /// Returns `true` if the line was a section header.
496    /// If there is content after `]` (an inline key=value), it is returned
497    /// via the `inline_remainder` parameter.
498    fn try_parse_section_with_remainder<'a>(
499        &mut self,
500        line: &'a str,
501        inline_remainder: &mut Option<&'a str>,
502    ) -> bool {
503        let trimmed = line.trim();
504        if !trimmed.starts_with('[') {
505            return false;
506        }
507        // Find the closing `]` — but for subsection headers like
508        // [section "sub\"escaped"], we need to skip escaped chars
509        // inside quotes.
510        let end = {
511            let bytes = trimmed.as_bytes();
512            let mut i = 1; // skip opening '['
513            let mut in_quotes = false;
514            let mut found = None;
515            while i < bytes.len() {
516                if in_quotes {
517                    if bytes[i] == b'\\' {
518                        i += 2; // skip escaped char
519                        continue;
520                    }
521                    if bytes[i] == b'"' {
522                        in_quotes = false;
523                    }
524                } else {
525                    if bytes[i] == b'"' {
526                        in_quotes = true;
527                    }
528                    if bytes[i] == b']' {
529                        found = Some(i);
530                        break;
531                    }
532                }
533                i += 1;
534            }
535            match found {
536                Some(i) => i,
537                None => return false,
538            }
539        };
540        let inside = &trimmed[1..end];
541        // Check for subsection: [section "subsection"]
542        if let Some(quote_start) = inside.find('"') {
543            let section = inside[..quote_start].trim().to_owned();
544            let rest = &inside[quote_start + 1..];
545            // Find unescaped closing quote
546            let mut sub = String::new();
547            let mut chars = rest.chars();
548            while let Some(ch) = chars.next() {
549                if ch == '\\' {
550                    if let Some(escaped) = chars.next() {
551                        sub.push(escaped);
552                    }
553                } else if ch == '"' {
554                    break;
555                } else {
556                    sub.push(ch);
557                }
558            }
559            self.set_section(section, Some(sub));
560        } else {
561            self.set_section(inside.trim().to_owned(), None);
562        }
563        // Check for inline content after the closing `]`
564        let after = trimmed[end + 1..].trim();
565        if !after.is_empty() && !after.starts_with('#') && !after.starts_with(';') {
566            *inline_remainder = Some(after);
567        } else {
568            *inline_remainder = None;
569        }
570        true
571    }
572
573    /// Parse a section header line (without inline remainder tracking).
574    fn try_parse_section(&mut self, line: &str) -> bool {
575        let mut _remainder = None;
576        self.try_parse_section_with_remainder(line, &mut _remainder)
577    }
578
579    /// Parse a `key = value` or bare `key` line.
580    ///
581    /// Returns `Some((canonical_key, value))` if this is a variable line.
582    fn try_parse_entry(&self, line: &str) -> Option<(String, Option<String>)> {
583        let trimmed = line.trim();
584        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
585            return None;
586        }
587        if trimmed.starts_with('[') {
588            return None;
589        }
590        if self.section.is_empty() {
591            return None;
592        }
593
594        if let Some(eq_pos) = trimmed.find('=') {
595            let raw_name = trimmed[..eq_pos].trim();
596            if !Self::valid_variable_name(raw_name) {
597                return None;
598            }
599            let raw_value = trimmed[eq_pos + 1..].trim();
600            // Strip inline comment (not inside quotes)
601            let value = parse_config_value(strip_inline_comment(raw_value));
602            let key = self.make_key(raw_name);
603            Some((key, Some(value)))
604        } else {
605            // Bare key (boolean true)
606            let raw_name = strip_inline_comment(trimmed);
607            if raw_name.split_whitespace().count() > 1 {
608                return None;
609            }
610            let raw_name = raw_name.trim();
611            if !Self::valid_variable_name(raw_name) {
612                return None;
613            }
614            let key = self.make_key(raw_name);
615            Some((key, None))
616        }
617    }
618}
619
620/// Check if a value line ends with a continuation backslash.
621///
622/// This checks the value portion (after `=`) for a trailing `\` that is
623/// outside quotes and outside an inline comment. If the `\` is after
624/// a `#` or `;` that starts a comment, it does NOT count as continuation.
625/// True when the value portion (after the first `=`) ends inside an unclosed double-quoted span.
626///
627/// Mirrors Git config continuation rules: a line ending with an open `"` continues on the next
628/// physical line. Outside quotes, `#` / `;` start comments and the line is complete.
629fn entry_line_value_has_unclosed_quote(line: &str) -> bool {
630    let trimmed = line.trim();
631    let Some(eq_pos) = trimmed.find('=') else {
632        return false;
633    };
634    let raw_value = trimmed[eq_pos + 1..].trim_start();
635    let mut in_quote = false;
636    let mut last_was_backslash = false;
637    // Every byte that matters is ASCII, and UTF-8 never reuses ASCII bytes inside a
638    // multi-byte character, so scanning bytes gives the same answer as scanning chars.
639    for b in raw_value.bytes() {
640        match b {
641            b'"' if !last_was_backslash => {
642                in_quote = !in_quote;
643                last_was_backslash = false;
644            }
645            b'\\' if !last_was_backslash => {
646                last_was_backslash = true;
647                continue;
648            }
649            b'#' | b';' if !in_quote && !last_was_backslash => return false,
650            _ => {
651                last_was_backslash = false;
652            }
653        }
654    }
655    in_quote
656}
657
658fn value_line_continues(line: &str) -> bool {
659    let trimmed = line.trim();
660    if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
661        return false;
662    }
663    // Find the value portion (after '=')
664    // If no '=', this is a bare key — no continuation
665    let value_part = match trimmed.find('=') {
666        Some(pos) => &trimmed[pos + 1..],
667        None => return false,
668    };
669    // Walk the value portion tracking quotes and comments
670    let mut in_quote = false;
671    let mut last_was_backslash = false;
672    let mut in_comment = false;
673    for b in value_part.bytes() {
674        if in_comment {
675            // Inside comment, backslash doesn't matter
676            last_was_backslash = false;
677            continue;
678        }
679        match b {
680            b'"' if !last_was_backslash => {
681                in_quote = !in_quote;
682                last_was_backslash = false;
683            }
684            b'\\' if !last_was_backslash => {
685                last_was_backslash = true;
686                continue;
687            }
688            b'#' | b';' if !in_quote && !last_was_backslash => {
689                in_comment = true;
690                last_was_backslash = false;
691            }
692            _ => {
693                last_was_backslash = false;
694            }
695        }
696    }
697    // The line continues if it ends with an unescaped backslash outside comments
698    last_was_backslash && !in_comment
699}
700
701/// Strip an inline comment (`#` or `;`) that is not inside quotes, and the whitespace
702/// before it. The result is always a prefix of `s`.
703fn strip_inline_comment(s: &str) -> &str {
704    let bytes = s.as_bytes();
705    let mut in_quote = false;
706    let mut last_was_backslash = false;
707    let mut i = 0;
708    while i < bytes.len() {
709        match bytes[i] {
710            b'"' if !last_was_backslash => {
711                in_quote = !in_quote;
712                last_was_backslash = false;
713            }
714            b'\\' if !last_was_backslash => {
715                last_was_backslash = true;
716                if in_quote && i + 1 < bytes.len() {
717                    i += 1;
718                    last_was_backslash = false;
719                }
720            }
721            b'#' | b';' if !in_quote && !last_was_backslash => break,
722            _ => last_was_backslash = false,
723        }
724        i += 1;
725    }
726    // `i` stops on an ASCII byte or the end, so it is a char boundary.
727    s[..i].trim_end()
728}
729
730/// Parse a config value after `=` (comments already stripped).
731fn parse_config_value(s: &str) -> String {
732    if s.starts_with('"') {
733        unescape_value(s)
734    } else {
735        unescape_unquoted_value(s)
736    }
737}
738
739/// Unquoted config values: backslash escapes; literal TAB characters are preserved to match
740/// `git config --get` on Git **≥ 2.55** (older Git collapses literal tabs to spaces; see
741/// `t1300_*` oracle gating in `grit-lib/tests/config_parse.rs`).
742fn unescape_unquoted_value(s: &str) -> String {
743    let mut result = String::with_capacity(s.len());
744    let mut chars = s.chars();
745    while let Some(ch) = chars.next() {
746        if ch == '\\' {
747            match chars.next() {
748                Some('n') => result.push('\n'),
749                Some('r') => result.push('\r'),
750                Some('t') => result.push('\t'),
751                Some('b') => result.push('\x08'),
752                Some('\\') => result.push('\\'),
753                Some('"') => result.push('"'),
754                Some(other) => {
755                    result.push('\\');
756                    result.push(other);
757                }
758                None => result.push('\\'),
759            }
760        } else {
761            result.push(ch);
762        }
763    }
764    result
765}
766
767/// Unescape a quoted config value: handle `\"`, `\\`, `\n`, `\t`, `\b`, and strip
768/// surrounding quotes.
769fn unescape_value(s: &str) -> String {
770    let mut result = String::with_capacity(s.len());
771    let mut chars = s.chars();
772    while let Some(ch) = chars.next() {
773        match ch {
774            '"' => { /* strip quotes */ }
775            '\\' => match chars.next() {
776                Some('n') => result.push('\n'),
777                Some('r') => result.push('\r'),
778                Some('t') => result.push('\t'),
779                Some('b') => result.push('\x08'),
780                Some('\\') => result.push('\\'),
781                Some('"') => result.push('"'),
782                Some(other) => {
783                    result.push('\\');
784                    result.push(other);
785                }
786                None => result.push('\\'),
787            },
788            _ => result.push(ch),
789        }
790    }
791    result
792}
793
794/// Escape a config value for writing back to a file.
795///
796/// Wraps in double quotes if the value contains leading/trailing whitespace,
797/// internal quotes, backslashes, or special characters.
798/// Escape a subsection name for writing in a config section header.
799/// In subsection names, `"` and `\` must be escaped.
800fn escape_subsection(s: &str) -> String {
801    let mut out = String::with_capacity(s.len());
802    for ch in s.chars() {
803        match ch {
804            '"' => out.push_str("\\\""),
805            '\\' => out.push_str("\\\\"),
806            other => out.push(other),
807        }
808    }
809    out
810}
811
812fn escape_value(s: &str) -> String {
813    // Quote leading `-` so values are not mistaken for config options (Git does this for
814    // submodule paths like `-sub` in `.gitmodules`), but leave signed numeric values bare.
815    let leading_dash_needs_quoting = s.starts_with('-') && parse_i64(s).is_err();
816    let must_quote = leading_dash_needs_quoting
817        || s.starts_with(' ')
818        || s.starts_with('\t')
819        || s.ends_with(' ')
820        || s.ends_with('\t')
821        || s.contains('"')
822        || s.contains('#')
823        || s.contains(';');
824
825    if must_quote {
826        return escape_value_quoted(s);
827    }
828
829    if s.contains('\n') || s.contains('\r') || s.contains('\t') || s.contains('\\') {
830        let mut out = String::with_capacity(s.len() + 4);
831        for ch in s.chars() {
832            match ch {
833                '\n' => out.push_str("\\n"),
834                '\r' => out.push_str("\\r"),
835                '\t' => out.push_str("\\t"),
836                '\\' => out.push_str("\\\\"),
837                '"' => out.push_str("\\\""),
838                other => out.push(other),
839            }
840        }
841        return out;
842    }
843
844    s.to_owned()
845}
846
847/// Compile a Git config value-pattern (`drop`, `!keep`, or full regex).
848fn compile_config_value_pattern(pat: &str) -> std::result::Result<(regex::Regex, bool), Error> {
849    let (negated, actual_pat) = if let Some(rest) = pat.strip_prefix('!') {
850        (true, rest)
851    } else {
852        (false, pat)
853    };
854    let re = regex::Regex::new(actual_pat).map_err(|e| {
855        Error::Config(ConfigError::InvalidValuePattern {
856            pattern: pat.to_owned(),
857            reason: e.to_string(),
858        })
859    })?;
860    Ok((re, negated))
861}
862
863fn value_matches_config_pattern(re: &regex::Regex, negated: bool, value: &str) -> bool {
864    let matched = re.is_match(value);
865    if negated {
866        !matched
867    } else {
868        matched
869    }
870}
871
872fn escape_value_quoted(s: &str) -> String {
873    let mut out = String::with_capacity(s.len() + 4);
874    out.push('"');
875    for ch in s.chars() {
876        match ch {
877            '"' => out.push_str("\\\""),
878            '\\' => out.push_str("\\\\"),
879            '\n' => out.push_str("\\n"),
880            '\r' => out.push_str("\\r"),
881            '\t' => out.push_str("\\t"),
882            other => out.push(other),
883        }
884    }
885    out.push('"');
886    out
887}
888
889/// Format a comment suffix for appending to a config value line.
890///
891/// Matches Git's `git_config_prepare_comment_string` normalization:
892/// leading space/tab is preserved only when followed by `#`; otherwise ` # ` is inserted.
893///
894/// # Errors
895///
896/// Returns [`ConfigError::MultilineCommentNotAllowed`] when `comment` contains `\n` or `\r`.
897fn format_comment_suffix(comment: Option<&str>) -> Result<String> {
898    match comment {
899        None => Ok(String::new()),
900        Some(c) => {
901            if c.contains('\n') || c.contains('\r') {
902                return Err(Error::Config(ConfigError::MultilineCommentNotAllowed));
903            }
904            let leading_blanks = c.find(|ch| ch != ' ' && ch != '\t').unwrap_or(c.len());
905            let after_blanks = &c[leading_blanks..];
906            if leading_blanks > 0 && after_blanks.starts_with('#') {
907                Ok(c.to_owned())
908            } else if c.starts_with('#') {
909                Ok(format!(" {c}"))
910            } else {
911                Ok(format!(" # {c}"))
912            }
913        }
914    }
915}
916
917impl ConfigFile {
918    /// Parse a config file from its raw text content.
919    ///
920    /// # Parameters
921    ///
922    /// - `path` — the file path (stored for diagnostics and round-trip writes).
923    /// - `content` — the raw text of the file.
924    /// - `scope` — the [`ConfigScope`] this file represents.
925    ///
926    /// # Errors
927    ///
928    /// Returns [`Error::Config`] on malformed input.
929    pub fn parse(path: &Path, content: &str, scope: ConfigScope) -> Result<Self> {
930        let content = content.strip_prefix('\u{feff}').unwrap_or(content);
931        let raw_lines: Vec<String> = content
932            .lines()
933            .map(|l| l.strip_suffix('\r').unwrap_or(l))
934            .map(String::from)
935            .collect();
936        let mut entries = Vec::new();
937        let mut parser = Parser::new();
938
939        let mut idx = 0;
940        while idx < raw_lines.len() {
941            let start_idx = idx;
942            let line = &raw_lines[idx];
943            idx += 1;
944
945            // Pure comment lines don't continue even with trailing \
946            let trimmed = line.trim();
947            if trimmed.starts_with('#') || trimmed.starts_with(';') {
948                continue;
949            }
950
951            let mut inline_remainder = None;
952            if parser.try_parse_section_with_remainder(line, &mut inline_remainder) {
953                // Check if there's an inline key=value after the section header
954                if let Some(remainder) = inline_remainder {
955                    if let Some((key, value)) = parser.try_parse_entry(remainder) {
956                        reject_valueless_negotiation_algorithm(
957                            &key,
958                            value.as_deref(),
959                            path,
960                            start_idx + 1,
961                        )?;
962                        entries.push(ConfigEntry {
963                            key,
964                            value,
965                            scope,
966                            file: Some(path.to_path_buf()),
967                            line: start_idx + 1,
968                        });
969                    }
970                }
971                continue;
972            }
973
974            // For entry lines, we need to check continuation.
975            // Build a logical line by joining continuations.
976            let mut logical_line = Cow::Borrowed(line.as_str());
977            while value_line_continues(&logical_line) && idx < raw_lines.len() {
978                // Remove the trailing backslash
979                let t = logical_line.trim_end();
980                let mut joined = t[..t.len() - 1].to_string();
981                // Append next line (trimmed of leading whitespace)
982                joined.push_str(raw_lines[idx].trim_start());
983                logical_line = Cow::Owned(joined);
984                idx += 1;
985            }
986
987            let mut unclosed_quote = entry_line_value_has_unclosed_quote(&logical_line);
988            while unclosed_quote && idx < raw_lines.len() {
989                let next = raw_lines[idx].trim_start();
990                logical_line.to_mut().push_str(next);
991                idx += 1;
992                unclosed_quote = entry_line_value_has_unclosed_quote(&logical_line);
993            }
994            if unclosed_quote {
995                let file_disp = config_error_path_display(path);
996                return Err(ConfigError::BadConfigLineInFile {
997                    file: file_disp,
998                    line: start_idx + 1,
999                }
1000                .into());
1001            }
1002
1003            if let Some((key, value)) = parser.try_parse_entry(&logical_line) {
1004                reject_valueless_negotiation_algorithm(
1005                    &key,
1006                    value.as_deref(),
1007                    path,
1008                    start_idx + 1,
1009                )?;
1010                entries.push(ConfigEntry {
1011                    key,
1012                    value,
1013                    scope,
1014                    file: Some(path.to_path_buf()),
1015                    line: start_idx + 1,
1016                });
1017            } else if logical_line.trim().is_empty() {
1018                continue;
1019            } else {
1020                let file_disp = config_error_path_display(path);
1021                let location = if path == Path::new("-") {
1022                    file_disp
1023                } else {
1024                    format!("file {file_disp}")
1025                };
1026                return Err(ConfigError::BadConfigLine {
1027                    location,
1028                    line: start_idx + 1,
1029                }
1030                .into());
1031            }
1032        }
1033
1034        Ok(Self {
1035            path: path.to_path_buf(),
1036            scope,
1037            entries,
1038            raw_lines,
1039            include_origin: ConfigIncludeOrigin::Disk,
1040        })
1041    }
1042
1043    /// Like [`Self::parse`] for `.gitmodules`, but on an unclosed-quote / bad line returns entries
1044    /// parsed **before** that line plus the one-based line number of the bad logical line.
1045    ///
1046    /// Git streams config and still applies entries from valid preceding lines; submodule-config
1047    /// tests rely on that when a later `.gitmodules` line is malformed.
1048    pub fn parse_gitmodules_best_effort(
1049        path: &Path,
1050        content: &str,
1051        scope: ConfigScope,
1052    ) -> (Vec<ConfigEntry>, Option<usize>) {
1053        let raw_lines: Vec<String> = content
1054            .lines()
1055            .map(|l| l.strip_suffix('\r').unwrap_or(l))
1056            .map(String::from)
1057            .collect();
1058        let mut entries = Vec::new();
1059        let mut parser = Parser::new();
1060
1061        let mut idx = 0;
1062        while idx < raw_lines.len() {
1063            let start_idx = idx;
1064            let line = &raw_lines[idx];
1065            idx += 1;
1066
1067            let trimmed = line.trim();
1068            if trimmed.starts_with('#') || trimmed.starts_with(';') {
1069                continue;
1070            }
1071
1072            let mut inline_remainder = None;
1073            if parser.try_parse_section_with_remainder(line, &mut inline_remainder) {
1074                if let Some(remainder) = inline_remainder {
1075                    if let Some((key, value)) = parser.try_parse_entry(remainder) {
1076                        entries.push(ConfigEntry {
1077                            key,
1078                            value,
1079                            scope,
1080                            file: Some(path.to_path_buf()),
1081                            line: start_idx + 1,
1082                        });
1083                    }
1084                }
1085                continue;
1086            }
1087
1088            let mut logical_line = line.clone();
1089            while value_line_continues(&logical_line) && idx < raw_lines.len() {
1090                let t = logical_line.trim_end();
1091                logical_line = t[..t.len() - 1].to_string();
1092                let next = raw_lines[idx].trim_start();
1093                logical_line.push_str(next);
1094                idx += 1;
1095            }
1096
1097            while entry_line_value_has_unclosed_quote(&logical_line) && idx < raw_lines.len() {
1098                let next = raw_lines[idx].trim_start();
1099                logical_line.push_str(next);
1100                idx += 1;
1101            }
1102            if entry_line_value_has_unclosed_quote(&logical_line) {
1103                return (entries, Some(start_idx + 1));
1104            }
1105
1106            if let Some((key, value)) = parser.try_parse_entry(&logical_line) {
1107                entries.push(ConfigEntry {
1108                    key,
1109                    value,
1110                    scope,
1111                    file: Some(path.to_path_buf()),
1112                    line: start_idx + 1,
1113                });
1114            }
1115        }
1116
1117        (entries, None)
1118    }
1119
1120    /// Last value for `key` in this file only (canonical key, case-insensitive section/var like Git).
1121    #[must_use]
1122    pub fn get(&self, key: &str) -> Option<String> {
1123        let canon = canonical_key(key).ok()?;
1124        self.entries
1125            .iter()
1126            .rev()
1127            .find(|e| e.key == canon)
1128            .map(|e| e.value.clone().unwrap_or_else(|| "true".to_owned()))
1129    }
1130
1131    /// Parse like [`Self::parse`] but record a non-disk include origin (blob, stdin, command line).
1132    pub fn parse_with_origin(
1133        path: &Path,
1134        content: &str,
1135        scope: ConfigScope,
1136        include_origin: ConfigIncludeOrigin,
1137    ) -> Result<Self> {
1138        let mut f = Self::parse(path, content, scope)?;
1139        f.include_origin = include_origin;
1140        Ok(f)
1141    }
1142
1143    /// Build a synthetic [`ConfigFile`] from `GIT_CONFIG_PARAMETERS` / `git -c` payloads.
1144    ///
1145    /// Unlike [`Self::parse`], this accepts flat `key=value` assignments without `[section]`
1146    /// headers, matching how Git injects command-line configuration.
1147    pub fn from_git_config_parameters(path: &Path, raw: &str) -> Result<Self> {
1148        let mut entries = Vec::new();
1149        for entry in parse_config_parameters_strict(raw)? {
1150            match entry {
1151                ConfigParameter::Pair { key, value } => {
1152                    let canon = canonical_key(key.trim())?;
1153                    entries.push(ConfigEntry {
1154                        key: canon,
1155                        value,
1156                        scope: ConfigScope::Command,
1157                        file: None,
1158                        line: 0,
1159                    });
1160                }
1161                ConfigParameter::OldStyle(entry) => {
1162                    if let Some((key, val)) = entry.split_once('=') {
1163                        let canon = canonical_key(key.trim())?;
1164                        entries.push(ConfigEntry {
1165                            key: canon,
1166                            value: Some(val.to_owned()),
1167                            scope: ConfigScope::Command,
1168                            file: None,
1169                            line: 0,
1170                        });
1171                    } else {
1172                        let canon = canonical_key(entry.trim())?;
1173                        entries.push(ConfigEntry {
1174                            key: canon,
1175                            value: None,
1176                            scope: ConfigScope::Command,
1177                            file: None,
1178                            line: 0,
1179                        });
1180                    }
1181                }
1182            }
1183        }
1184        Ok(Self {
1185            path: path.to_path_buf(),
1186            scope: ConfigScope::Command,
1187            entries,
1188            raw_lines: Vec::new(),
1189            include_origin: ConfigIncludeOrigin::CommandLine,
1190        })
1191    }
1192
1193    /// Read and parse a config file from disk.
1194    ///
1195    /// Returns `Ok(None)` if the file does not exist.
1196    ///
1197    /// # Errors
1198    ///
1199    /// Returns [`Error::Io`] on read failure (other than not-found) or
1200    /// [`Error::Config`] on parse failure.
1201    pub fn from_path(path: &Path, scope: ConfigScope) -> Result<Option<Self>> {
1202        match fs::read_to_string(path) {
1203            Ok(content) => Ok(Some(Self::parse(path, &content, scope)?)),
1204            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1205            Err(e) => Err(Error::Io(e)),
1206        }
1207    }
1208
1209    /// Set a value in this config file, creating the section if needed.
1210    ///
1211    /// If the key already exists, its last occurrence is updated in-place.
1212    /// Otherwise a new entry is appended (creating the section header if
1213    /// necessary).
1214    ///
1215    /// # Parameters
1216    ///
1217    /// - `key` — canonical key (e.g. `core.bare`).
1218    /// - `value` — the value to set.
1219    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
1220        self.set_with_comment(key, value, None)
1221    }
1222
1223    /// Set a value in this config file, optionally appending an inline comment.
1224    pub fn set_with_comment(
1225        &mut self,
1226        key: &str,
1227        value: &str,
1228        comment: Option<&str>,
1229    ) -> Result<()> {
1230        let canon = canonical_key(key)?;
1231        if self.entries.iter().filter(|e| e.key == canon).count() > 1 {
1232            return Err(Error::Config(ConfigError::MultipleValues {
1233                key: key.to_owned(),
1234            }));
1235        }
1236        let raw_var = raw_variable_name(key);
1237        let comment_suffix = format_comment_suffix(comment)?;
1238
1239        // Find the last entry with this key to replace in-place.
1240        let existing_idx = self.entries.iter().rposition(|e| e.key == canon);
1241
1242        if let Some(idx) = existing_idx {
1243            let line_idx = self.entries[idx].line - 1;
1244            let raw_line = &self.raw_lines[line_idx];
1245            if is_section_header_with_inline_entry(raw_line) {
1246                // Entry is on the same line as a section header — split it
1247                let header_only = extract_section_header(raw_line);
1248                self.raw_lines[line_idx] = header_only;
1249                let new_line = format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
1250                self.raw_lines.insert(line_idx + 1, new_line);
1251                // Re-parse to fix up entries and line numbers
1252                let content = self.raw_lines.join("\n");
1253                let reparsed = Self::parse(&self.path, &content, self.scope)?;
1254                self.entries = reparsed.entries;
1255                self.raw_lines = reparsed.raw_lines;
1256            } else {
1257                self.raw_lines[line_idx] =
1258                    format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
1259                self.entries[idx].value = Some(value.to_owned());
1260            }
1261        } else {
1262            // Need to add: find or create the section
1263            let (section, subsection, _var) = split_key(&canon)?;
1264            let (raw_sec, raw_sub) = raw_section_parts(key);
1265            let section_line = self.find_or_create_section_preserving_case(
1266                &section,
1267                subsection.as_deref(),
1268                &raw_sec,
1269                raw_sub.as_deref(),
1270            );
1271            let new_line = format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
1272
1273            // Insert after the section header (or last entry in section)
1274            let insert_at = self.last_line_in_section(section_line) + 1;
1275            self.raw_lines.insert(insert_at, new_line);
1276
1277            // Re-parse to fix up line numbers
1278            let content = self.raw_lines.join("\n");
1279            let reparsed = Self::parse(&self.path, &content, self.scope)?;
1280            self.entries = reparsed.entries;
1281            self.raw_lines = reparsed.raw_lines;
1282        }
1283
1284        Ok(())
1285    }
1286
1287    /// Replace ALL occurrences of a key with a new value.
1288    ///
1289    /// Removes all but the last occurrence from the file, then updates
1290    /// the last occurrence with the new value (matching Git behaviour).
1291    pub fn replace_all(
1292        &mut self,
1293        key: &str,
1294        value: &str,
1295        value_pattern: Option<&str>,
1296    ) -> Result<()> {
1297        self.replace_all_with_comment(key, value, value_pattern, None)
1298    }
1299
1300    /// Replace all occurrences, optionally appending an inline comment.
1301    ///
1302    /// Value patterns starting with `!` are treated as negated regex
1303    /// (matching values that do NOT match the pattern).
1304    pub fn replace_all_with_comment(
1305        &mut self,
1306        key: &str,
1307        value: &str,
1308        value_pattern: Option<&str>,
1309        comment: Option<&str>,
1310    ) -> Result<()> {
1311        let canon = canonical_key(key)?;
1312        let comment_suffix = format_comment_suffix(comment)?;
1313
1314        let (re, negated) = match value_pattern {
1315            Some(pat) => {
1316                let (compiled, neg) = compile_config_value_pattern(pat)?;
1317                (Some(compiled), neg)
1318            }
1319            None => (None, false),
1320        };
1321
1322        // Find all matching entries (by key, and optionally by value pattern)
1323        let matching_indices: Vec<usize> = self
1324            .entries
1325            .iter()
1326            .enumerate()
1327            .filter(|(_, e)| {
1328                if e.key != canon {
1329                    return false;
1330                }
1331                if let Some(ref re) = re {
1332                    let v = e.value.as_deref().unwrap_or("");
1333                    value_matches_config_pattern(re, negated, v)
1334                } else {
1335                    true
1336                }
1337            })
1338            .map(|(i, _)| i)
1339            .collect();
1340
1341        let Some(&target_idx) = matching_indices.last() else {
1342            // No matching entries — add a new one at the end of the section
1343            return self.add_value_with_comment(key, value, comment);
1344        };
1345
1346        let raw_var = raw_variable_name(key);
1347
1348        let target_line_idx = self.entries[target_idx].line - 1;
1349        let raw_line = &self.raw_lines[target_line_idx];
1350        if is_section_header_with_inline_entry(raw_line) {
1351            let header = extract_section_header(raw_line);
1352            self.raw_lines[target_line_idx] = header;
1353            let new_line = format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
1354            self.raw_lines.insert(target_line_idx + 1, new_line);
1355        } else {
1356            self.raw_lines[target_line_idx] =
1357                format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
1358        }
1359
1360        for &idx in matching_indices.iter().rev() {
1361            if idx == target_idx {
1362                continue;
1363            }
1364            let line_idx = self.entries[idx].line - 1;
1365            self.remove_entry_line(line_idx);
1366        }
1367
1368        // Re-parse
1369        let content = self.raw_lines.join("\n");
1370        let reparsed = Self::parse(&self.path, &content, self.scope)?;
1371        self.entries = reparsed.entries;
1372        self.raw_lines = reparsed.raw_lines;
1373
1374        Ok(())
1375    }
1376
1377    /// Count how many entries exist for a key.
1378    pub fn count(&self, key: &str) -> Result<usize> {
1379        let canon = canonical_key(key)?;
1380        Ok(self.entries.iter().filter(|e| e.key == canon).count())
1381    }
1382
1383    /// Remove an entry at the given raw line index.
1384    ///
1385    /// If the line is a section header with an inline entry, only the inline
1386    /// portion is removed (the header is kept). Otherwise the entire line is
1387    /// removed. Also removes continuation lines following the entry.
1388    /// Remove an entry at the given raw line index.
1389    ///
1390    /// If the line is a section header with an inline entry, only the inline
1391    /// portion is removed (the header is kept). Otherwise the entire line
1392    /// (and any continuation lines) is removed.
1393    fn remove_entry_line(&mut self, line_idx: usize) {
1394        if is_section_header_with_inline_entry(&self.raw_lines[line_idx]) {
1395            // Keep the section header, strip the inline entry
1396            let header = extract_section_header(&self.raw_lines[line_idx]);
1397            self.raw_lines[line_idx] = header;
1398        } else {
1399            // Check if this line has continuation lines and remove them too
1400            let mut lines_to_remove = 1;
1401            let mut check_line = self.raw_lines[line_idx].clone();
1402            while value_line_continues(&check_line)
1403                && (line_idx + lines_to_remove) < self.raw_lines.len()
1404            {
1405                check_line = self.raw_lines[line_idx + lines_to_remove].clone();
1406                lines_to_remove += 1;
1407            }
1408            for _ in 0..lines_to_remove {
1409                self.raw_lines.remove(line_idx);
1410            }
1411        }
1412    }
1413
1414    /// Unset (remove) only the last occurrence of a key.
1415    ///
1416    /// Returns the number of entries removed (0 or 1).
1417    pub fn unset_last(&mut self, key: &str) -> Result<usize> {
1418        let canon = canonical_key(key)?;
1419        let last_idx = self.entries.iter().rposition(|e| e.key == canon);
1420
1421        if let Some(idx) = last_idx {
1422            let line_idx = self.entries[idx].line - 1;
1423            self.remove_entry_line(line_idx);
1424            let (section, subsection, _) = split_key(&canon)?;
1425            self.remove_empty_section_headers_matching(&section, subsection.as_deref());
1426            let content = self.raw_lines.join("\n");
1427            let reparsed = Self::parse(&self.path, &content, self.scope)?;
1428            self.entries = reparsed.entries;
1429            self.raw_lines = reparsed.raw_lines;
1430            Ok(1)
1431        } else {
1432            Ok(0)
1433        }
1434    }
1435
1436    /// Unset (remove) all occurrences of a key.
1437    ///
1438    /// # Parameters
1439    ///
1440    /// - `key` — canonical key (e.g. `core.bare`).
1441    ///
1442    /// # Returns
1443    ///
1444    /// The number of entries removed.
1445    pub fn unset(&mut self, key: &str) -> Result<usize> {
1446        let canon = canonical_key(key)?;
1447        let line_indices: Vec<usize> = self
1448            .entries
1449            .iter()
1450            .filter(|e| e.key == canon)
1451            .map(|e| e.line - 1)
1452            .collect();
1453
1454        let count = line_indices.len();
1455        // Remove from bottom to top to keep indices valid
1456        for &idx in line_indices.iter().rev() {
1457            self.remove_entry_line(idx);
1458        }
1459
1460        if count > 0 {
1461            if let Ok((section, subsection, _)) = split_key(&canon) {
1462                self.remove_empty_section_headers_matching(&section, subsection.as_deref());
1463            }
1464            let content = self.raw_lines.join("\n");
1465            let reparsed = Self::parse(&self.path, &content, self.scope)?;
1466            self.entries = reparsed.entries;
1467            self.raw_lines = reparsed.raw_lines;
1468        }
1469
1470        Ok(count)
1471    }
1472
1473    /// Unset entries matching a key and optional value-pattern regex.
1474    ///
1475    /// If `value_pattern` is `None`, removes all entries with the given key.
1476    /// If `value_pattern` is `Some(pat)`, only removes entries whose value matches the regex.
1477    ///
1478    /// When `preserve_empty_section_header` is `true`, a section header is kept even if the
1479    /// section has no remaining keys (Git's `config unset --all`). When `false`, empty sections
1480    /// are stripped (`config --unset`, `config --unset-all`, and value-pattern unsets).
1481    pub fn unset_matching(
1482        &mut self,
1483        key: &str,
1484        value_pattern: Option<&str>,
1485        preserve_empty_section_header: bool,
1486    ) -> Result<usize> {
1487        let canon = canonical_key(key)?;
1488        let pattern = match value_pattern {
1489            Some(pat) => {
1490                let (re, negated) = compile_config_value_pattern(pat)?;
1491                Some((re, negated))
1492            }
1493            None => None,
1494        };
1495
1496        let line_indices: Vec<usize> = self
1497            .entries
1498            .iter()
1499            .filter(|e| {
1500                if e.key != canon {
1501                    return false;
1502                }
1503                if let Some((ref re, negated)) = pattern {
1504                    let v = e.value.as_deref().unwrap_or("");
1505                    value_matches_config_pattern(re, negated, v)
1506                } else {
1507                    true
1508                }
1509            })
1510            .map(|e| e.line - 1)
1511            .collect();
1512
1513        let count = line_indices.len();
1514        for &idx in line_indices.iter().rev() {
1515            self.remove_entry_line(idx);
1516        }
1517
1518        if count > 0 {
1519            if !preserve_empty_section_header {
1520                let (section, subsection, _) = split_key(&canon)?;
1521                self.remove_empty_section_headers_matching(&section, subsection.as_deref());
1522            }
1523
1524            let content = self.raw_lines.join("\n");
1525            let reparsed = Self::parse(&self.path, &content, self.scope)?;
1526            self.entries = reparsed.entries;
1527            self.raw_lines = reparsed.raw_lines;
1528        }
1529
1530        Ok(count)
1531    }
1532
1533    /// Remove an entire section (and all its entries).
1534    ///
1535    /// # Parameters
1536    ///
1537    /// - `section` — section name (e.g. `"core"`, `"remote.origin"`).
1538    pub fn remove_section(&mut self, section: &str) -> Result<bool> {
1539        let (sec_name, sub_name) = parse_section_name(section);
1540        let sec_lower = sec_name.to_lowercase();
1541
1542        let mut remove = vec![false; self.raw_lines.len()];
1543        let mut removing = false;
1544        let mut found = false;
1545        let mut parser = Parser::new();
1546
1547        for (idx, line) in self.raw_lines.iter().enumerate() {
1548            if parser.try_parse_section(line) {
1549                removing = section_matches(&parser, &sec_lower, sub_name);
1550                found |= removing;
1551            }
1552            if removing {
1553                remove[idx] = true;
1554            }
1555        }
1556
1557        if found {
1558            self.raw_lines = self
1559                .raw_lines
1560                .iter()
1561                .enumerate()
1562                .filter_map(|(idx, line)| (!remove[idx]).then_some(line.clone()))
1563                .collect();
1564            let content = self.raw_lines.join("\n");
1565            let reparsed = Self::parse(&self.path, &content, self.scope)?;
1566            self.entries = reparsed.entries;
1567            self.raw_lines = reparsed.raw_lines;
1568            Ok(true)
1569        } else {
1570            Ok(false)
1571        }
1572    }
1573
1574    /// Rename a section.
1575    ///
1576    /// # Parameters
1577    ///
1578    /// - `old_name` — current section name (e.g. `"branch.main"`).
1579    /// - `new_name` — new section name (e.g. `"branch.develop"`).
1580    pub fn rename_section(&mut self, old_name: &str, new_name: &str) -> Result<bool> {
1581        let (old_sec, old_sub) = parse_section_name(old_name);
1582        let (new_sec, new_sub) = parse_section_name(new_name);
1583        validate_section_name(new_name, new_sec, new_sub)?;
1584        let old_lower = old_sec.to_lowercase();
1585
1586        let mut found = false;
1587        let mut parser = Parser::new();
1588
1589        let mut idx = 0usize;
1590        while idx < self.raw_lines.len() {
1591            let line = self.raw_lines[idx].clone();
1592            let mut inline_remainder = None;
1593            if parser.try_parse_section_with_remainder(&line, &mut inline_remainder)
1594                && section_matches(&parser, &old_lower, old_sub)
1595            {
1596                // Rewrite the section header
1597                let header = match new_sub {
1598                    Some(sub) => format!("[{} \"{}\"]", new_sec, sub),
1599                    None => format!("[{}]", new_sec),
1600                };
1601                self.raw_lines[idx] = header;
1602                if let Some(remainder) = inline_remainder {
1603                    self.raw_lines
1604                        .insert(idx + 1, format!("\t{}", remainder.trim()));
1605                    idx += 1;
1606                }
1607                found = true;
1608            }
1609            idx += 1;
1610        }
1611
1612        if found {
1613            let content = self.raw_lines.join("\n");
1614            let reparsed = Self::parse(&self.path, &content, self.scope)?;
1615            self.entries = reparsed.entries;
1616            self.raw_lines = reparsed.raw_lines;
1617        }
1618
1619        Ok(found)
1620    }
1621
1622    /// Append a new value for a key without removing existing entries.
1623    ///
1624    /// This is the behaviour of `git config --add section.key value`.
1625    /// If the section doesn't exist, it is created.
1626    pub fn add_value(&mut self, key: &str, value: &str) -> Result<()> {
1627        self.add_value_with_comment(key, value, None)
1628    }
1629
1630    /// Append a new value with an optional inline comment.
1631    pub fn add_value_with_comment(
1632        &mut self,
1633        key: &str,
1634        value: &str,
1635        comment: Option<&str>,
1636    ) -> Result<()> {
1637        let canon = canonical_key(key)?;
1638        let raw_var = raw_variable_name(key);
1639        let comment_suffix = format_comment_suffix(comment)?;
1640        let (section, subsection, _var) = split_key(&canon)?;
1641        let (raw_sec, raw_sub) = raw_section_parts(key);
1642
1643        let section_line = self.find_or_create_section_preserving_case(
1644            &section,
1645            subsection.as_deref(),
1646            &raw_sec,
1647            raw_sub.as_deref(),
1648        );
1649        let new_line = format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
1650        let insert_at = self.last_line_in_section(section_line) + 1;
1651        self.raw_lines.insert(insert_at, new_line);
1652
1653        // Re-parse to fix up entries and line numbers
1654        let content = self.raw_lines.join("\n");
1655        let reparsed = Self::parse(&self.path, &content, self.scope)?;
1656        self.entries = reparsed.entries;
1657        self.raw_lines = reparsed.raw_lines;
1658
1659        Ok(())
1660    }
1661
1662    /// Write the (possibly modified) config back to disk.
1663    /// Remove section headers that have no remaining entries or comments.
1664    fn remove_empty_section_headers_matching(&mut self, section: &str, subsection: Option<&str>) {
1665        let (Ok(section_re), Ok(comment_re)) = (
1666            regex::Regex::new(r"^\s*\["),
1667            regex::Regex::new(r"^\s*(#|;)"),
1668        ) else {
1669            // Static patterns: compilation cannot fail in practice; bail out safely.
1670            return;
1671        };
1672
1673        let mut to_remove: Vec<usize> = Vec::new();
1674        let len = self.raw_lines.len();
1675        let section_lower = section.to_lowercase();
1676        let mut parser = Parser::new();
1677
1678        for i in 0..len {
1679            let line = &self.raw_lines[i];
1680            if !section_re.is_match(line) {
1681                continue;
1682            }
1683            if !parser.try_parse_section(line)
1684                || !section_matches(&parser, &section_lower, subsection)
1685            {
1686                continue;
1687            }
1688            // Don't remove section headers that have inline key=value entries
1689            if is_section_header_with_inline_entry(line) {
1690                continue;
1691            }
1692            let has_attached_leading_comment = self.raw_lines[..i]
1693                .iter()
1694                .enumerate()
1695                .rev()
1696                .find(|(_, line)| !line.trim().is_empty())
1697                .is_some_and(|(idx, line)| {
1698                    comment_re.is_match(line)
1699                        && idx
1700                            .checked_sub(1)
1701                            .is_none_or(|prev| !value_line_continues(&self.raw_lines[prev]))
1702                });
1703            if has_attached_leading_comment {
1704                continue;
1705            }
1706            // Check if this section header is followed only by blank lines,
1707            // comments, or another section header (or end of file).
1708            let mut has_entries = false;
1709            for j in (i + 1)..len {
1710                let next = self.raw_lines[j].trim();
1711                if next.is_empty() {
1712                    continue;
1713                }
1714                if section_re.is_match(&self.raw_lines[j]) {
1715                    break;
1716                }
1717                if comment_re.is_match(&self.raw_lines[j]) {
1718                    // Has comments — keep the section
1719                    has_entries = true;
1720                    break;
1721                }
1722                // Has a key-value entry
1723                has_entries = true;
1724                break;
1725            }
1726            if !has_entries {
1727                to_remove.push(i);
1728            }
1729        }
1730
1731        // Remove in reverse to preserve indices
1732        for &idx in to_remove.iter().rev() {
1733            self.raw_lines.remove(idx);
1734        }
1735
1736        // Also remove trailing blank lines
1737        while self.raw_lines.last().is_some_and(|l| l.trim().is_empty()) {
1738            self.raw_lines.pop();
1739        }
1740    }
1741
1742    /// Write the file back to [`Self::path`] through `{path}.lock` and an atomic rename.
1743    ///
1744    /// The existing file's permission bits are kept. Like Git, which has no `core.fsync`
1745    /// component for config files, the lock file is not fsynced before the rename: readers
1746    /// always see either the old or the new file, but a crash may lose the update.
1747    ///
1748    /// # Errors
1749    ///
1750    /// Returns [`Error::Io`] on write failure, or [`ConfigError::ConfigFileLocked`] when
1751    /// `{path}.lock` already exists.
1752    pub fn write(&self) -> Result<()> {
1753        let lock_path = config_lock_path(&self.path);
1754        let content = self.raw_lines.join("\n");
1755        let bytes: Vec<u8> = if content.trim().is_empty() {
1756            Vec::new()
1757        } else if content.ends_with('\n') {
1758            content.into_bytes()
1759        } else {
1760            format!("{content}\n").into_bytes()
1761        };
1762
1763        let mut lock_file = fs::OpenOptions::new()
1764            .write(true)
1765            .create_new(true)
1766            .open(&lock_path)
1767            .map_err(|e| {
1768                if e.kind() == std::io::ErrorKind::AlreadyExists {
1769                    Error::Config(ConfigError::ConfigFileLocked {
1770                        path: lock_path.display().to_string(),
1771                    })
1772                } else {
1773                    Error::Io(e)
1774                }
1775            })?;
1776
1777        #[cfg(unix)]
1778        if let Ok(meta) = fs::metadata(&self.path) {
1779            let _ = lock_file.set_permissions(meta.permissions());
1780        }
1781
1782        use std::io::Write as _;
1783        lock_file.write_all(&bytes).map_err(|e| {
1784            let _ = fs::remove_file(&lock_path);
1785            Error::Io(e)
1786        })?;
1787        drop(lock_file);
1788
1789        fs::rename(&lock_path, &self.path).map_err(|e| {
1790            let _ = fs::remove_file(&lock_path);
1791            Error::Io(e)
1792        })?;
1793        Ok(())
1794    }
1795
1796    /// Find the line index of a section header (case-insensitive match),
1797    /// or create one using the original-case names from user input.
1798    fn find_or_create_section_preserving_case(
1799        &mut self,
1800        section: &str,
1801        subsection: Option<&str>,
1802        raw_section: &str,
1803        raw_subsection: Option<&str>,
1804    ) -> usize {
1805        let sec_lower = section.to_lowercase();
1806        let mut parser = Parser::new();
1807        let mut last_match = None;
1808
1809        for (idx, line) in self.raw_lines.iter().enumerate() {
1810            if parser.try_parse_section(line) && section_matches(&parser, &sec_lower, subsection) {
1811                last_match = Some(idx);
1812            }
1813        }
1814        if let Some(idx) = last_match {
1815            return idx;
1816        }
1817
1818        // Create new section at end of file, using original case
1819        let header = match raw_subsection {
1820            Some(sub) => {
1821                let escaped = escape_subsection(sub);
1822                format!("[{} \"{}\"]", raw_section, escaped)
1823            }
1824            None => format!("[{}]", raw_section),
1825        };
1826        self.raw_lines.push(header);
1827        self.raw_lines.len() - 1
1828    }
1829
1830    /// Find the last line that belongs to the section starting at `section_line`.
1831    fn last_line_in_section(&self, section_line: usize) -> usize {
1832        let mut last = section_line;
1833        for idx in (section_line + 1)..self.raw_lines.len() {
1834            let trimmed = self.raw_lines[idx].trim();
1835            if trimmed.starts_with('[') {
1836                break;
1837            }
1838            last = idx;
1839        }
1840        last
1841    }
1842}
1843
1844// ── ConfigSet ───────────────────────────────────────────────────────
1845
1846impl ConfigSet {
1847    /// Create an empty config set.
1848    #[must_use]
1849    pub fn new() -> Self {
1850        Self {
1851            entries: Vec::new(),
1852        }
1853    }
1854
1855    /// All merged entries in load order (for listing keys such as `alias.*`).
1856    #[must_use]
1857    pub fn entries(&self) -> &[ConfigEntry] {
1858        &self.entries
1859    }
1860
1861    /// Merge entries from a [`ConfigFile`] into this set.
1862    ///
1863    /// Entries are appended; later values override earlier ones for
1864    /// single-value lookups.
1865    pub fn merge(&mut self, file: &ConfigFile) {
1866        self.entries.extend(file.entries.iter().cloned());
1867    }
1868
1869    /// Merge another [`ConfigSet`] into this set (entries appended in order).
1870    pub fn merge_set(&mut self, other: &ConfigSet) {
1871        self.entries.extend(other.entries.iter().cloned());
1872    }
1873
1874    /// Add a command-line override (`-c key=value`).
1875    pub fn add_command_override(&mut self, key: &str, value: &str) -> Result<()> {
1876        let canon = canonical_key(key)?;
1877        self.entries.push(ConfigEntry {
1878            key: canon,
1879            value: Some(value.to_owned()),
1880            scope: ConfigScope::Command,
1881            file: None,
1882            line: 0,
1883        });
1884        Ok(())
1885    }
1886
1887    /// Get the last (highest-priority) value for a key.
1888    ///
1889    /// # Parameters
1890    ///
1891    /// - `key` — the key to look up (will be canonicalized).
1892    ///
1893    /// # Returns
1894    ///
1895    /// `Some(value)` for the last matching entry, or `None` if not found.
1896    /// Bare boolean keys return `Some("true")`.
1897    #[must_use]
1898    pub fn get(&self, key: &str) -> Option<String> {
1899        let canon = canonical_key(key).ok()?;
1900        self.entries
1901            .iter()
1902            .rev()
1903            .find(|e| e.key == canon)
1904            .map(|e| e.value.clone().unwrap_or_else(|| "true".to_owned()))
1905    }
1906
1907    /// Last (highest-priority) [`ConfigEntry`] for a key, including origin metadata.
1908    ///
1909    /// Bare boolean keys are returned with [`ConfigEntry::value`] set to `None` (same as `get`,
1910    /// which maps them to `"true"` for string lookups).
1911    #[must_use]
1912    pub fn get_last_entry(&self, key: &str) -> Option<ConfigEntry> {
1913        let canon = canonical_key(key).ok()?;
1914        self.entries.iter().rev().find(|e| e.key == canon).cloned()
1915    }
1916
1917    /// Winning value for `key` plus scope, source file, and line (Git `--show-origin --show-scope`).
1918    ///
1919    /// # Errors
1920    ///
1921    /// Returns [`Error::Config`] when `key` is not a valid Git config key.
1922    pub fn get_with_origin(&self, key: &str) -> Result<Option<ConfigValueOrigin>> {
1923        let _canon = canonical_key(key)?;
1924        Ok(self.get_last_entry(key).map(|e| ConfigValueOrigin {
1925            value: e.value.clone().or_else(|| Some("true".to_owned())),
1926            scope: e.scope,
1927            file: config_entry_origin_file(&e).map(Path::to_path_buf),
1928            line: config_entry_origin_line(&e),
1929        }))
1930    }
1931
1932    /// Every value for `key` in load order, with origin metadata (Git `--get-all`).
1933    ///
1934    /// # Errors
1935    ///
1936    /// Returns [`Error::Config`] when `key` is not a valid Git config key.
1937    pub fn get_all_with_origin(&self, key: &str) -> Result<Vec<ConfigValueOrigin>> {
1938        let canon = canonical_key(key)?;
1939        Ok(self
1940            .entries
1941            .iter()
1942            .filter(|e| e.key == canon)
1943            .map(|e| ConfigValueOrigin {
1944                value: e.value.clone().or_else(|| Some("true".to_owned())),
1945                scope: e.scope,
1946                file: config_entry_origin_file(e).map(Path::to_path_buf),
1947                line: config_entry_origin_line(e),
1948            })
1949            .collect())
1950    }
1951
1952    /// Get all values for a key (multi-valued; in load order).
1953    #[must_use]
1954    pub fn get_all(&self, key: &str) -> Vec<String> {
1955        let canon = match canonical_key(key) {
1956            Ok(c) => c,
1957            Err(_) => return Vec::new(),
1958        };
1959        self.entries
1960            .iter()
1961            .filter(|e| e.key == canon)
1962            .map(|e| e.value.clone().unwrap_or_default())
1963            .collect()
1964    }
1965
1966    /// All raw values for a key in load order, preserving `None` for bare boolean keys.
1967    ///
1968    /// Matches Git's multi-value list where `NULL` means a value-less / boolean-true key.
1969    #[must_use]
1970    pub fn get_all_raw(&self, key: &str) -> Vec<Option<String>> {
1971        let canon = match canonical_key(key) {
1972            Ok(c) => c,
1973            Err(_) => return Vec::new(),
1974        };
1975        self.entries
1976            .iter()
1977            .filter(|e| e.key == canon)
1978            .map(|e| e.value.clone())
1979            .collect()
1980    }
1981
1982    /// True if any config entry uses `key` (after canonicalization), including bare boolean keys.
1983    ///
1984    /// Unlike [`Self::get`], this does not treat a missing value as `"true"` — it reports whether
1985    /// the key appears in the merged config at all (Git `repo_config_get` / `git_configset_get`).
1986    #[must_use]
1987    pub fn has_key(&self, key: &str) -> bool {
1988        let Ok(canon) = canonical_key(key) else {
1989            return false;
1990        };
1991        self.entries.iter().any(|e| e.key == canon)
1992    }
1993
1994    /// Get a boolean value, interpreting `true`/`yes`/`on`/`1` as true and
1995    /// `false`/`no`/`off`/`0` as false.
1996    ///
1997    /// `pack.allowPackReuse` may be `single` or `multi` (Git enum, not a bool). Those values are
1998    /// treated as unset for boolean lookup so `get_bool` does not error during broad config scans.
1999    ///
2000    /// # Returns
2001    ///
2002    /// `None` when `key` is unset, otherwise the parsed value.
2003    ///
2004    /// # Errors
2005    ///
2006    /// [`ConfigError::InvalidValue`] naming `key` when the value is not a Git boolean.
2007    pub fn get_bool(&self, key: &str) -> Option<std::result::Result<bool, ConfigError>> {
2008        let v = self.get(key)?;
2009        if canonical_key(key).ok().as_deref() == Some("pack.allowpackreuse") {
2010            let lower = v.trim().to_ascii_lowercase();
2011            if lower == "single" || lower == "multi" {
2012                return None;
2013            }
2014        }
2015        Some(parse_bool(&v).map_err(|e| e.for_key(key)))
2016    }
2017
2018    /// Get a boolean value, falling back to `default` when `key` is unset or its value is not a
2019    /// Git boolean.
2020    ///
2021    /// Use this for settings where Git also ignores a malformed value; use [`Self::get_bool`]
2022    /// with `transpose()?` when a bad value must be reported.
2023    ///
2024    /// # Parameters
2025    ///
2026    /// - `key`: dotted config key, matched case-insensitively in its section and variable name.
2027    /// - `default`: the value Git uses when the key is unset.
2028    #[must_use]
2029    pub fn bool_or(&self, key: &str, default: bool) -> bool {
2030        self.get_bool(key)
2031            .and_then(std::result::Result::ok)
2032            .unwrap_or(default)
2033    }
2034
2035    /// Whether path comparisons fold ASCII case (`core.ignoreCase`, default `false`).
2036    #[must_use]
2037    pub fn ignore_case(&self) -> bool {
2038        self.bool_or("core.ignoreCase", false)
2039    }
2040
2041    /// Whether paths are stored NFC-precomposed (`core.precomposeUnicode`, default `false`).
2042    ///
2043    /// This is the value in this set only; callers that may not have a loaded set use
2044    /// `effective_core_precomposeunicode_with_config`.
2045    #[must_use]
2046    pub fn precompose_unicode(&self) -> bool {
2047        self.bool_or("core.precomposeUnicode", false)
2048    }
2049
2050    /// Whether index refresh may stat entries in parallel (`core.preloadIndex`, default `true`).
2051    #[must_use]
2052    pub fn preload_index(&self) -> bool {
2053        self.bool_or("core.preloadIndex", true)
2054    }
2055
2056    /// Whether the executable bit in the work tree is trusted (`core.fileMode`, default `true`).
2057    #[must_use]
2058    pub fn file_mode(&self) -> bool {
2059        self.bool_or("core.fileMode", true)
2060    }
2061
2062    /// Whether pathnames in human-readable output should fully C-quote non-ASCII bytes as octal.
2063    ///
2064    /// Maps to Git's `quote_path_fully` (`core.quotePath`, default true). When false, UTF-8 and
2065    /// other high bytes are emitted literally; only ASCII specials are escaped.
2066    #[must_use]
2067    pub fn quote_path_fully(&self) -> bool {
2068        self.bool_or("core.quotePath", true)
2069    }
2070
2071    /// Default for `pack.writeBitmapHashCache` (Git default: true).
2072    #[must_use]
2073    pub fn pack_write_bitmap_hash_cache(&self) -> bool {
2074        self.bool_or("pack.writeBitmapHashCache", true)
2075    }
2076
2077    /// Default for `pack.writeBitmapLookupTable` (Git default: false).
2078    #[must_use]
2079    pub fn pack_write_bitmap_lookup_table(&self) -> bool {
2080        self.bool_or("pack.writeBitmapLookupTable", false)
2081    }
2082
2083    /// Values of `pack.preferBitmapTips` (may appear multiple times).
2084    #[must_use]
2085    pub fn pack_prefer_bitmap_tips(&self) -> Vec<String> {
2086        let mut out = self.get_all("pack.preferbitmaptips");
2087        if out.is_empty() {
2088            out = self.get_all("pack.preferBitmapTips");
2089        }
2090        out
2091    }
2092
2093    /// Build [`crate::pack_bitmap::PackBitmapWriteOptions`] from config keys.
2094    #[must_use]
2095    pub fn pack_bitmap_write_options(&self) -> crate::pack_bitmap::PackBitmapWriteOptions {
2096        crate::pack_bitmap::PackBitmapWriteOptions {
2097            full_dag: true,
2098            hash_cache: self.pack_write_bitmap_hash_cache(),
2099            lookup_table: self.pack_write_bitmap_lookup_table(),
2100            prefer_bitmap_tips: self.pack_prefer_bitmap_tips(),
2101            verify_reachability_closure: true,
2102            reachability_roots: None,
2103        }
2104    }
2105
2106    /// Default for `pack.writeReverseIndex` / `pack.writereverseindex` (Git default: true).
2107    #[must_use]
2108    pub fn pack_write_reverse_index_default(&self) -> bool {
2109        if self
2110            .get("pack.writeReverseIndex")
2111            .is_some_and(|v| v.trim().is_empty())
2112        {
2113            return false;
2114        }
2115        self.bool_or("pack.writeReverseIndex", true)
2116    }
2117
2118    /// Resolved worker count for pack indexing (`pack.threads`, Git default: unset → all CPUs).
2119    #[must_use]
2120    pub fn pack_index_threads(&self) -> Option<usize> {
2121        self.get_i64("pack.threads")
2122            .and_then(|r| r.ok())
2123            .and_then(|n| usize::try_from(n).ok())
2124    }
2125
2126    /// In-pack delta window (`pack.window`, Git default: 10 when unset).
2127    ///
2128    /// An explicit `0` disables newly computed deltas (on-disk reuse may still apply).
2129    #[must_use]
2130    pub fn pack_object_window(&self) -> usize {
2131        match self.get_i64("pack.window") {
2132            None => 10,
2133            Some(Ok(n)) if n >= 0 => usize::try_from(n).unwrap_or(10),
2134            Some(Ok(_)) | Some(Err(_)) => 10,
2135        }
2136    }
2137
2138    /// In-pack delta chain depth cap (`pack.depth`, Git default: 50 when unset).
2139    ///
2140    /// An explicit `0` disables newly computed deltas (on-disk reuse may still apply).
2141    #[must_use]
2142    /// Sliding-window memory cap for delta search (`pack.windowMemory`, Git default: unlimited).
2143    pub fn pack_window_memory_bytes(&self) -> u64 {
2144        match self.get_i64("pack.windowmemory") {
2145            None => 0,
2146            Some(Ok(n)) if n >= 0 => u64::try_from(n).unwrap_or(0),
2147            Some(Ok(_)) | Some(Err(_)) => 0,
2148        }
2149    }
2150
2151    /// Maximum delta depth for `git pack-objects` (`pack.depth`, Git default 50).
2152    pub fn pack_object_depth(&self) -> usize {
2153        match self.get_i64("pack.depth") {
2154            None => crate::pack::DEFAULT_PACK_DEPTH,
2155            Some(Ok(n)) if n >= 0 => usize::try_from(n).unwrap_or(crate::pack::DEFAULT_PACK_DEPTH),
2156            Some(Ok(_)) | Some(Err(_)) => crate::pack::DEFAULT_PACK_DEPTH,
2157        }
2158    }
2159
2160    /// Approximate loose-object count above which automatic maintenance packs loose objects
2161    /// (`gc.auto`, Git default 6700).
2162    ///
2163    /// Returns `None` when `gc.auto` is zero or negative, which turns off automatic maintenance
2164    /// entirely (including the [`Self::gc_auto_pack_limit`] trigger). Unparsable values fall
2165    /// back to the default.
2166    #[must_use]
2167    pub fn gc_auto_threshold(&self) -> Option<usize> {
2168        match self.get_i64("gc.auto") {
2169            Some(Ok(n)) if n <= 0 => None,
2170            Some(Ok(n)) => Some(usize::try_from(n).unwrap_or(usize::MAX)),
2171            None | Some(Err(_)) => Some(DEFAULT_GC_AUTO_THRESHOLD),
2172        }
2173    }
2174
2175    /// Number of non-`.keep` local packs above which automatic maintenance consolidates every
2176    /// pack into one (`gc.autoPackLimit`, Git default 50).
2177    ///
2178    /// Returns `None` when the value is zero or negative (pack consolidation trigger off).
2179    /// Unparsable values fall back to the default.
2180    #[must_use]
2181    pub fn gc_auto_pack_limit(&self) -> Option<usize> {
2182        match self.get_i64("gc.autoPackLimit") {
2183            Some(Ok(n)) if n <= 0 => None,
2184            Some(Ok(n)) => Some(usize::try_from(n).unwrap_or(usize::MAX)),
2185            None | Some(Err(_)) => Some(DEFAULT_GC_AUTO_PACK_LIMIT),
2186        }
2187    }
2188
2189    /// Whether automatic maintenance runs in a detached background process
2190    /// (`gc.autoDetach`, default `true`).
2191    #[must_use]
2192    pub fn gc_auto_detach(&self) -> bool {
2193        self.bool_or("gc.autoDetach", true)
2194    }
2195
2196    /// Whether commands may start automatic maintenance at all (`maintenance.auto`, default
2197    /// `true`).
2198    #[must_use]
2199    pub fn maintenance_auto(&self) -> bool {
2200        self.bool_or("maintenance.auto", true)
2201    }
2202
2203    /// [`crate::hash::Parallelism`] for pack indexing from merged config.
2204    ///
2205    /// # Errors
2206    ///
2207    /// Returns [`ConfigError`] when `pack.threads` is set to a value Git rejects.
2208    pub fn try_pack_index_parallelism(
2209        &self,
2210    ) -> std::result::Result<crate::parallel::Parallelism, ConfigError> {
2211        Ok(crate::parallel::ThreadPolicy::default()
2212            .try_resolve(crate::parallel::ParallelOp::IndexPack, self)?
2213            .parallelism())
2214    }
2215
2216    /// Like [`Self::try_pack_index_parallelism`], but an invalid `pack.threads`
2217    /// falls back to the default (all available cores) instead of an error.
2218    #[must_use]
2219    pub fn pack_index_parallelism(&self) -> crate::parallel::Parallelism {
2220        self.try_pack_index_parallelism()
2221            .unwrap_or_else(|_| crate::parallel::Parallelism::resolve(None))
2222    }
2223
2224    /// Load config for `git_dir` (if any) and resolve [`crate::hash::Parallelism`] for index-pack.
2225    ///
2226    /// # Errors
2227    ///
2228    /// Propagates config load failures and invalid `pack.threads`.
2229    pub fn try_pack_index_parallelism_for_git_dir(
2230        env: &Environment,
2231        git_dir: Option<&std::path::Path>,
2232    ) -> Result<crate::parallel::Parallelism> {
2233        match git_dir {
2234            Some(d) => {
2235                let cfg = Self::load(env, Some(d), true)?;
2236                Ok(cfg.try_pack_index_parallelism()?)
2237            }
2238            None => Ok(crate::parallel::Parallelism::resolve(None)),
2239        }
2240    }
2241
2242    /// Load config for `git_dir` (if any) and resolve [`crate::hash::Parallelism`] for index-pack.
2243    #[must_use]
2244    pub fn pack_index_parallelism_for_git_dir(
2245        env: &Environment,
2246        git_dir: Option<&std::path::Path>,
2247    ) -> crate::parallel::Parallelism {
2248        Self::try_pack_index_parallelism_for_git_dir(env, git_dir)
2249            .unwrap_or_else(|_| crate::parallel::Parallelism::resolve(None))
2250    }
2251
2252    /// Default for `pack.readReverseIndex` / `pack.readreverseindex` (Git default: true).
2253    #[must_use]
2254    pub fn pack_read_reverse_index_default(&self) -> bool {
2255        self.bool_or("pack.readReverseIndex", true)
2256    }
2257
2258    /// `core.logAllRefUpdates` as configured in this set, or [`refs::LogRefsConfig::Unset`] when
2259    /// the key is absent or holds an unrecognised value.
2260    #[must_use]
2261    pub(crate) fn log_refs_config(&self) -> refs::LogRefsConfig {
2262        self.get("core.logAllRefUpdates")
2263            .and_then(|v| refs::LogRefsConfig::parse(&v))
2264            .unwrap_or(refs::LogRefsConfig::Unset)
2265    }
2266
2267    /// Resolved `core.logAllRefUpdates` using this merged set (includes `git -c` / env), then Git's
2268    /// bare-repo default when the key is unset everywhere.
2269    ///
2270    /// Never returns [`refs::LogRefsConfig::Unset`]: an unset (or unrecognised) value resolves to
2271    /// [`refs::LogRefsConfig::None`] in a bare repository and [`refs::LogRefsConfig::Normal`]
2272    /// otherwise.
2273    #[must_use]
2274    pub fn effective_log_refs_config(&self) -> refs::LogRefsConfig {
2275        match self.log_refs_config() {
2276            refs::LogRefsConfig::Unset if self.bool_or("core.bare", false) => {
2277                refs::LogRefsConfig::None
2278            }
2279            refs::LogRefsConfig::Unset => refs::LogRefsConfig::Normal,
2280            mode => mode,
2281        }
2282    }
2283
2284    /// Build a set from already-parsed entries (in load order).
2285    pub(crate) fn from_entries(entries: Vec<ConfigEntry>) -> Self {
2286        Self { entries }
2287    }
2288
2289    /// Get an integer value, supporting Git's `k`/`m`/`g` suffixes.
2290    ///
2291    /// # Returns
2292    ///
2293    /// `None` when `key` is unset, otherwise the parsed value.
2294    ///
2295    /// # Errors
2296    ///
2297    /// [`ConfigError::InvalidValue`] naming `key` when the value is not an integer or
2298    /// overflows `i64`.
2299    pub fn get_i64(&self, key: &str) -> Option<std::result::Result<i64, ConfigError>> {
2300        self.get(key)
2301            .map(|v| parse_i64(&v).map_err(|e| e.for_key(key)))
2302    }
2303
2304    /// Parse `core.compression` / `core.looseCompression` style zlib level integers.
2305    ///
2306    /// `-1` selects zlib's default (level 6). Valid values are `-1` or `0..=9`; anything else
2307    /// is a configuration error.
2308    fn parse_zlib_compression_level(key: &str, raw: &str) -> Result<i32> {
2309        const Z_DEFAULT_COMPRESSION: i32 = 6;
2310        const Z_BEST_COMPRESSION: i32 = 9;
2311
2312        let bad = |reason| {
2313            Error::Config(ConfigError::BadNumericValue {
2314                key: key.to_owned(),
2315                value: raw.to_owned(),
2316                reason,
2317            })
2318        };
2319        let v = parse_git_config_int_strict(raw.trim()).map_err(|e| bad(e.into()))?;
2320        if v == -1 {
2321            return Ok(Z_DEFAULT_COMPRESSION);
2322        }
2323        if v < 0 || v > i64::from(Z_BEST_COMPRESSION) {
2324            return Err(bad(BadNumericSource::OutOfRange));
2325        }
2326        Ok(v as i32)
2327    }
2328
2329    /// Zlib deflate level for loose objects (Git's `zlib_compression_level`).
2330    ///
2331    /// Walks [`Self::entries`] in load order. Every `core.compression` and `core.looseCompression`
2332    /// entry is parsed and validated (including shadowed or overridden values); bare keys and
2333    /// invalid numbers return [`Error::Config`]. `core.compression` sets the loose level until
2334    /// the first valid `core.looseCompression` is seen; later `core.compression` entries are still
2335    /// validated but ignored for the loose level. When neither key appears, returns
2336    /// [`Self::LOOSE_OBJECTS_ZLIB_DEFAULT`] (`Z_BEST_SPEED`, level 1).
2337    ///
2338    /// `-1` means zlib default (level 6). Valid values are `-1` or `0..=9`.
2339    pub fn loose_objects_zlib_level(&self) -> Result<i32> {
2340        let mut loose_level = Self::LOOSE_OBJECTS_ZLIB_DEFAULT;
2341        let mut loose_compression_seen = false;
2342        let missing_value = |key: &str| {
2343            Error::Config(ConfigError::MissingValue {
2344                key: key.to_owned(),
2345            })
2346        };
2347
2348        for e in self.entries() {
2349            match e.key.as_str() {
2350                "core.compression" => {
2351                    let val = e.value.as_deref().ok_or_else(|| missing_value(&e.key))?;
2352                    let level = Self::parse_zlib_compression_level(&e.key, val)?;
2353                    if !loose_compression_seen {
2354                        loose_level = level;
2355                    }
2356                }
2357                "core.loosecompression" => {
2358                    let val = e.value.as_deref().ok_or_else(|| missing_value(&e.key))?;
2359                    let level = Self::parse_zlib_compression_level(&e.key, val)?;
2360                    loose_level = level;
2361                    loose_compression_seen = true;
2362                }
2363                _ => {}
2364            }
2365        }
2366
2367        Ok(loose_level)
2368    }
2369
2370    /// Git's default zlib level for loose objects when neither `core.looseCompression` nor
2371    /// `core.compression` is set (`Z_BEST_SPEED`).
2372    pub const LOOSE_OBJECTS_ZLIB_DEFAULT: i32 = 1;
2373
2374    /// Git's default `core.bigFileThreshold` (512 MiB).
2375    pub const BIG_FILE_THRESHOLD_DEFAULT: u64 = 512 * 1024 * 1024;
2376
2377    /// Size in bytes at or above which files are treated as big (`core.bigFileThreshold`).
2378    ///
2379    /// Big files are hashed and stored as a stream instead of being read into memory, and
2380    /// are written straight into a pack. The value accepts Git's `k`/`m`/`g` suffixes; the
2381    /// last entry wins. When unset, returns [`Self::BIG_FILE_THRESHOLD_DEFAULT`].
2382    ///
2383    /// # Errors
2384    ///
2385    /// Returns [`ConfigError::BadNumericValue`] when the value is missing, not a number, has
2386    /// an unknown unit, or is negative.
2387    pub fn big_file_threshold(&self) -> Result<u64> {
2388        const KEY: &str = "core.bigfilethreshold";
2389        let Some(entry) = self.get_last_entry(KEY) else {
2390            return Ok(Self::BIG_FILE_THRESHOLD_DEFAULT);
2391        };
2392        let raw = entry.value.as_deref().unwrap_or_default();
2393        let bad = |reason| {
2394            Error::Config(ConfigError::BadNumericValue {
2395                key: KEY.to_owned(),
2396                value: raw.to_owned(),
2397                reason,
2398            })
2399        };
2400        let value = parse_git_config_int_strict(raw.trim()).map_err(|e| bad(e.into()))?;
2401        u64::try_from(value).map_err(|_| bad(BadNumericSource::OutOfRange))
2402    }
2403
2404    /// Zlib deflate level for `git pack-objects` (Git's `pack_compression_level`).
2405    ///
2406    /// Entries are applied in [`Self::entries`] order. `core.compression` sets the pack level
2407    /// until a `pack.compression` appears (Git `pack_compression_seen`). `core.loosecompression`
2408    /// is ignored here — it only affects loose-object zlib, not packs.
2409    ///
2410    /// `-1` means zlib default (level 6). Valid values are `-1` or `0..=9`.
2411    pub fn pack_objects_zlib_level(&self) -> Result<i32> {
2412        const Z_DEFAULT_COMPRESSION: i32 = 6;
2413
2414        // `core.loosecompression` affects loose objects only (Git `zlib_compression_level`), not pack.
2415        let mut pack_level = Z_DEFAULT_COMPRESSION;
2416        let mut pack_compression_seen = false;
2417
2418        for e in self.entries() {
2419            match e.key.as_str() {
2420                "core.compression" => {
2421                    let Some(val) = e.value.as_deref() else {
2422                        continue;
2423                    };
2424                    let level = Self::parse_zlib_compression_level(&e.key, val)?;
2425                    if !pack_compression_seen {
2426                        pack_level = level;
2427                    }
2428                }
2429                "pack.compression" => {
2430                    let Some(val) = e.value.as_deref() else {
2431                        continue;
2432                    };
2433                    pack_level = Self::parse_zlib_compression_level(&e.key, val)?;
2434                    pack_compression_seen = true;
2435                }
2436                _ => {}
2437            }
2438        }
2439
2440        Ok(pack_level)
2441    }
2442
2443    /// Get all entries matching a key pattern (regex).
2444    ///
2445    /// Used by `git config --get-regexp`. Returns an error if the pattern
2446    /// is not a valid regex.
2447    pub fn get_regexp(&self, pattern: &str) -> std::result::Result<Vec<&ConfigEntry>, String> {
2448        let re = regex::Regex::new(pattern).map_err(|e| format!("invalid key pattern: {e}"))?;
2449        Ok(self
2450            .entries
2451            .iter()
2452            .filter(|e| re.is_match(&e.key))
2453            .collect())
2454    }
2455
2456    /// Load the standard Git configuration file cascade for a repository.
2457    ///
2458    /// # Parameters
2459    ///
2460    /// - `git_dir` — path to the `.git` directory (for local/worktree config).
2461    /// - `include_system` — whether to load system config.
2462    ///
2463    /// # Errors
2464    ///
2465    /// Returns errors from file I/O or parsing.
2466    pub fn load(env: &Environment, git_dir: Option<&Path>, include_system: bool) -> Result<Self> {
2467        let opts = LoadConfigOptions {
2468            include_system,
2469            include_ctx: IncludeContext {
2470                git_dir: git_dir.map(PathBuf::from),
2471                cwd: env.cwd.clone(),
2472                pwd: env.pwd.clone(),
2473                env: std::sync::Arc::new(env.clone()),
2474                ..Default::default()
2475            },
2476            ..Default::default()
2477        };
2478        Self::load_with_options(env, git_dir, &opts)
2479    }
2480
2481    /// Load the standard configuration cascade with explicit include and scope control.
2482    ///
2483    /// See [`LoadConfigOptions`] for `GIT_CONFIG_PARAMETERS` / `-c` include behaviour.
2484    ///
2485    /// Uncached load: parses the cascade from disk every call. Repository-scoped memoization
2486    /// lives on [`crate::repo_caches::RepoCaches::load_config_with_options`].
2487    pub fn load_with_options(
2488        env: &Environment,
2489        git_dir: Option<&Path>,
2490        opts: &LoadConfigOptions,
2491    ) -> Result<Self> {
2492        Self::load_with_options_uncached(env, git_dir, opts, &mut Vec::new())
2493    }
2494
2495    fn read_local_config_text_optional(
2496        path: &Path,
2497        caches: Option<&std::sync::Arc<crate::repo_caches::RepoCaches>>,
2498    ) -> Result<Option<String>> {
2499        let read = |p: &Path| -> Result<Option<String>> {
2500            match fs::read_to_string(p) {
2501                Ok(text) => Ok(Some(text)),
2502                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
2503                Err(e) => Err(Error::Io(e)),
2504            }
2505        };
2506        let Some(c) = caches else {
2507            return read(path);
2508        };
2509        match c.local_config_text(path) {
2510            Ok(text) => Ok(Some(text)),
2511            Err(Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
2512            Err(e) => Err(e),
2513        }
2514    }
2515
2516    pub(crate) fn load_with_options_uncached(
2517        env: &Environment,
2518        git_dir: Option<&Path>,
2519        opts: &LoadConfigOptions,
2520        included_files: &mut Vec<PathBuf>,
2521    ) -> Result<Self> {
2522        let proc = opts.process_includes;
2523        let mut ctx = opts.include_ctx.clone();
2524        if ctx.cwd.as_os_str().is_empty() {
2525            ctx.cwd = env.cwd.clone();
2526        }
2527        ctx.pwd = env.pwd.clone();
2528        ctx.env = std::sync::Arc::new(env.clone());
2529        let mut layers = Vec::new();
2530
2531        if opts.include_system && !git_config_nosystem_enabled(env) {
2532            let system_path = system_config_path(env);
2533            if let Some(f) = ConfigFile::from_path(&system_path, ConfigScope::System)? {
2534                layers.push(ConfigLayer::file(f));
2535            }
2536        }
2537
2538        // Global config (Git merges every existing file: XDG then ~/.gitconfig).
2539        for path in global_config_paths(env) {
2540            if let Some(f) = ConfigFile::from_path(&path, ConfigScope::Global)? {
2541                layers.push(ConfigLayer::file(f));
2542            }
2543        }
2544
2545        // Local config — linked worktrees read `commondir/config`, not the admin `config`.
2546        if let Some(gd) = git_dir {
2547            let common_dir = crate::repo::common_git_dir_for_config(gd);
2548            let local_path = common_dir.join("config");
2549            let local_text =
2550                Self::read_local_config_text_optional(&local_path, opts.repo_caches.as_ref())?;
2551            if let Some(msg) =
2552                crate::repo::early_config_ignore_repo_reason_for_text(local_text.as_deref())
2553            {
2554                if let Some(sink) = opts.diagnostics.as_ref() {
2555                    sink.warn(crate::diagnostics::Warning::IgnoredGitDir {
2556                        path: gd.to_path_buf(),
2557                        reason: msg,
2558                    });
2559                }
2560            } else if let Some(text) = local_text.as_deref() {
2561                let f = ConfigFile::parse(&local_path, text, ConfigScope::Local)?;
2562                layers.push(ConfigLayer::file(f));
2563            }
2564
2565            // Worktree config — Git only reads `config.worktree` when
2566            // `extensions.worktreeConfig` is enabled in the common repository `config`.
2567            let wt_path = gd.join("config.worktree");
2568            if crate::repo::worktree_config_enabled_for_text(local_text.as_deref()) {
2569                if let Some(f) = ConfigFile::from_path(&wt_path, ConfigScope::Worktree)? {
2570                    layers.push(ConfigLayer::file(f));
2571                }
2572            }
2573        }
2574
2575        if let Some(path) = env.git_config.as_deref() {
2576            let path = resolve_config_override_path(env, path);
2577            if let Some(f) = ConfigFile::from_path(&path, ConfigScope::Command)? {
2578                layers.push(ConfigLayer::file(f));
2579            }
2580        }
2581
2582        layers.push(ConfigLayer::Entries(environment_config_pair_entries(env)?));
2583
2584        // GIT_CONFIG_PARAMETERS — used by `git -c key=value`.
2585        if let Some(params) = env
2586            .git_config_parameters
2587            .as_deref()
2588            .filter(|p| !p.trim().is_empty())
2589        {
2590            if proc && opts.command_includes {
2591                let pseudo = Path::new(":GIT_CONFIG_PARAMETERS");
2592                let cmd_file = ConfigFile::from_git_config_parameters(pseudo, params)?;
2593                layers.push(ConfigLayer::file(cmd_file));
2594            } else {
2595                layers.push(ConfigLayer::Entries(command_override_entries(params)));
2596            }
2597        }
2598
2599        let mut set = Self::new();
2600        set.merge_layers(&layers, proc, &ctx, included_files)?;
2601        Ok(set)
2602    }
2603
2604    /// Read configuration the way Git's `read_early_config` / `do_git_config_sequence` does:
2605    /// system (unless disabled), global files in Git order, optional repository `config` /
2606    /// `config.worktree`, then `GIT_CONFIG_PARAMETERS`.
2607    ///
2608    /// When `git_dir` is `None` (no discovered repository, e.g. `GIT_CEILING_DIRECTORIES`), only
2609    /// non-repo layers are read — matching Git when discovery returns no gitdir (t1309 ceiling #2).
2610    ///
2611    /// Returns all values for `key` in load order (Git's `read_early_config` callback runs once per
2612    /// occurrence).
2613    ///
2614    /// This matches upstream ordering for `test-tool config read_early_config` (t1309, t1305).
2615    pub fn read_early_config(
2616        env: &Environment,
2617        git_dir: Option<&Path>,
2618        key: &str,
2619    ) -> Result<Vec<String>> {
2620        let ctx = IncludeContext {
2621            git_dir: git_dir.map(PathBuf::from),
2622            command_line_relative_include_is_error: false,
2623            cwd: env.cwd.clone(),
2624            pwd: env.pwd.clone(),
2625            env: std::sync::Arc::new(env.clone()),
2626        };
2627        let mut layers = Vec::new();
2628
2629        if !git_config_nosystem_enabled(env) {
2630            let system_path = system_config_path(env);
2631            if let Ok(Some(f)) = ConfigFile::from_path(&system_path, ConfigScope::System) {
2632                layers.push(ConfigLayer::file(f));
2633            }
2634        }
2635
2636        // Global: all existing candidates (Git merges every readable file).
2637        for path in global_config_paths(env) {
2638            if let Ok(Some(f)) = ConfigFile::from_path(&path, ConfigScope::Global) {
2639                layers.push(ConfigLayer::file(f));
2640            }
2641        }
2642
2643        if let Some(gd) = git_dir {
2644            let common_dir = crate::repo::common_git_dir_for_config(gd);
2645            // Local (commondir) — skip when format is newer than supported (t1309).
2646            let local_path = common_dir.join("config");
2647            if crate::repo::early_config_ignore_repo_reason(&common_dir).is_some() {
2648                // Local config skipped for unsupported repository format (see `Warning::IgnoredGitDir`).
2649            } else if let Ok(Some(f)) = ConfigFile::from_path(&local_path, ConfigScope::Local) {
2650                layers.push(ConfigLayer::file(f));
2651            }
2652
2653            // Worktree-specific config (when enabled for this repo).
2654            let wt_path = gd.join("config.worktree");
2655            if crate::repo::worktree_config_enabled(&common_dir) {
2656                if let Ok(Some(f)) = ConfigFile::from_path(&wt_path, ConfigScope::Worktree) {
2657                    layers.push(ConfigLayer::file(f));
2658                }
2659            }
2660        }
2661
2662        // GIT_CONFIG_PARAMETERS — same as full load (`load_with_options` default).
2663        if let Some(params) = env
2664            .git_config_parameters
2665            .as_deref()
2666            .filter(|p| !p.trim().is_empty())
2667        {
2668            let pseudo = Path::new(":GIT_CONFIG_PARAMETERS");
2669            let cmd_file = ConfigFile::from_git_config_parameters(pseudo, params)?;
2670            layers.push(ConfigLayer::file(cmd_file));
2671        }
2672
2673        let mut set = Self::new();
2674        set.merge_layers(&layers, true, &ctx, &mut Vec::new())?;
2675        Ok(set.get_all(key))
2676    }
2677
2678    /// Merge a single config file, optionally expanding `[include]` / `[includeIf]`.
2679    ///
2680    /// Used by `grit config -f` and scoped reads; [`ConfigSet::load_with_options`] uses the same
2681    /// internal routine for the standard cascade.
2682    ///
2683    /// `hasconfig:remote.*.url:` conditions match only remote URLs reachable from `file`
2684    /// itself (like `git config -f <file> --includes`), not URLs already in `self`.
2685    ///
2686    /// # Errors
2687    ///
2688    /// Unreadable or unparsable include targets, include recursion past Git's depth limit,
2689    /// and [`ConfigError::RemoteUrlInHasconfigInclude`].
2690    pub fn merge_file_with_includes(
2691        &mut self,
2692        file: &ConfigFile,
2693        process_includes: bool,
2694        ctx: &IncludeContext,
2695    ) -> Result<()> {
2696        self.merge_layers(
2697            &[ConfigLayer::File(Cow::Borrowed(file))],
2698            process_includes,
2699            ctx,
2700            &mut Vec::new(),
2701        )
2702    }
2703
2704    /// Load only the repository's own `config` file (plus any `[include]` targets).
2705    ///
2706    /// Unlike [`Self::load`], this ignores system/global config and environment
2707    /// overrides. Used for receive-side options (e.g. `transfer.fsckObjects`) so a
2708    /// pusher's global configuration cannot weaken the remote repository's policy.
2709    pub fn load_repo_local_only(git_dir: &Path) -> Result<Self> {
2710        let local_path = git_dir.join("config");
2711        let ctx = IncludeContext {
2712            git_dir: Some(git_dir.to_path_buf()),
2713            command_line_relative_include_is_error: false,
2714            cwd: PathBuf::from("."),
2715            pwd: None,
2716            env: std::sync::Arc::new(Environment::empty()),
2717        };
2718        let mut set = Self::new();
2719        if let Ok(Some(f)) = ConfigFile::from_path(&local_path, ConfigScope::Local) {
2720            set.merge_layers(&[ConfigLayer::file(f)], true, &ctx, &mut Vec::new())?;
2721        }
2722        Ok(set)
2723    }
2724
2725    /// Load configuration the way Git loads **protected** config (e.g. `uploadpack.packObjectsHook`).
2726    ///
2727    /// This matches Git's `read_protected_config`: system (optional), global files only (no
2728    /// repository or worktree `config`), then command-line overrides from `GIT_CONFIG_COUNT` /
2729    /// `GIT_CONFIG_PARAMETERS`. It does **not** read `$GIT_CONFIG` (Git omits that for protected
2730    /// config).
2731    ///
2732    /// Global file order matches Git: XDG `git/config` first (when present), then `~/.gitconfig`,
2733    /// unless `GIT_CONFIG_GLOBAL` is set (single file). When both global files exist, both are
2734    /// merged so later entries win for duplicate keys.
2735    pub fn load_protected(env: &Environment, include_system: bool) -> Result<Self> {
2736        let ctx = IncludeContext {
2737            git_dir: None,
2738            command_line_relative_include_is_error: false,
2739            cwd: env.cwd.clone(),
2740            pwd: env.pwd.clone(),
2741            env: std::sync::Arc::new(env.clone()),
2742        };
2743        let mut layers = Vec::new();
2744
2745        if include_system && !git_config_nosystem_enabled(env) {
2746            let system_path = system_config_path(env);
2747            if let Ok(Some(f)) = ConfigFile::from_path(&system_path, ConfigScope::System) {
2748                layers.push(ConfigLayer::file(f));
2749            }
2750        }
2751
2752        for path in global_config_paths(env) {
2753            if let Ok(Some(f)) = ConfigFile::from_path(&path, ConfigScope::Global) {
2754                layers.push(ConfigLayer::file(f));
2755            }
2756        }
2757
2758        layers.push(ConfigLayer::Entries(environment_config_pair_entries(env)?));
2759        if let Some(params) = env.git_config_parameters.as_deref() {
2760            layers.push(ConfigLayer::Entries(command_override_entries(params)));
2761        }
2762
2763        let mut set = Self::new();
2764        set.merge_layers(&layers, true, &ctx, &mut Vec::new())?;
2765        Ok(set)
2766    }
2767
2768    /// Merge `layers` in order, expanding includes in file layers when `process_includes`.
2769    ///
2770    /// `hasconfig:remote.*.url:` conditions anywhere in the load match against the remote
2771    /// URLs of *all* layers (Git evaluates them against the complete configuration), so a
2772    /// global include can depend on a remote defined in the repository `config`. Every
2773    /// include target opened is recorded in `included_files` so the cascade cache can stamp it.
2774    fn merge_layers(
2775        &mut self,
2776        layers: &[ConfigLayer<'_>],
2777        process_includes: bool,
2778        ctx: &IncludeContext,
2779        included_files: &mut Vec<PathBuf>,
2780    ) -> Result<()> {
2781        // Headroom for the entries of typical small include files (identities, aliases), so
2782        // merging one does not double a vector sized exactly to the top-level layers.
2783        const INCLUDE_HEADROOM: usize = 64;
2784        let mut remote_urls = RemoteUrls::new(layers);
2785        let top_level: usize = layers
2786            .iter()
2787            .map(|layer| match layer {
2788                ConfigLayer::File(file) => file.entries.len(),
2789                ConfigLayer::Entries(entries) => entries.len(),
2790            })
2791            .sum();
2792        self.entries.reserve(top_level + INCLUDE_HEADROOM);
2793        for layer in layers {
2794            match layer {
2795                ConfigLayer::File(file) => Self::merge_with_includes(
2796                    self,
2797                    file,
2798                    process_includes,
2799                    0,
2800                    ctx,
2801                    &mut remote_urls,
2802                    included_files,
2803                )?,
2804                ConfigLayer::Entries(entries) => self.entries.extend(entries.iter().cloned()),
2805            }
2806        }
2807        Ok(())
2808    }
2809
2810    /// Merge `file`, expanding `[include]` / `[includeIf]` targets in place.
2811    fn merge_with_includes(
2812        set: &mut Self,
2813        file: &ConfigFile,
2814        process_includes: bool,
2815        depth: usize,
2816        ctx: &IncludeContext,
2817        remote_urls: &mut RemoteUrls<'_>,
2818        included_files: &mut Vec<PathBuf>,
2819    ) -> Result<()> {
2820        check_include_depth(depth)?;
2821        if !process_includes {
2822            set.merge(file);
2823            return Ok(());
2824        }
2825
2826        for entry in &file.entries {
2827            set.entries.push(entry.clone());
2828
2829            let Some((inc_path, condition)) = include_directive_for_entry(entry) else {
2830                continue;
2831            };
2832            if let Some(cond) = condition.as_deref() {
2833                let holds = match hasconfig_remote_url_pattern(cond) {
2834                    Some(pattern) => remote_urls.any_matches(pattern, ctx, included_files)?,
2835                    None => evaluate_include_condition(cond, file, ctx),
2836                };
2837                if !holds {
2838                    continue;
2839                }
2840            }
2841            let Some(inc_file) = open_include_target(&inc_path, file, ctx, included_files)? else {
2842                continue;
2843            };
2844            Self::merge_with_includes(
2845                set,
2846                &inc_file,
2847                process_includes,
2848                depth + 1,
2849                ctx,
2850                remote_urls,
2851                included_files,
2852            )?;
2853        }
2854
2855        Ok(())
2856    }
2857}
2858
2859/// One step of a configuration load, merged in order by [`ConfigSet::merge_layers`].
2860enum ConfigLayer<'a> {
2861    /// A config file whose includes are expanded when the load processes includes.
2862    File(Cow<'a, ConfigFile>),
2863    /// Entries appended verbatim (`GIT_CONFIG_COUNT` pairs, `-c` overrides read without includes).
2864    Entries(Vec<ConfigEntry>),
2865}
2866
2867impl ConfigLayer<'_> {
2868    fn file(file: ConfigFile) -> Self {
2869        Self::File(Cow::Owned(file))
2870    }
2871}
2872
2873/// Remote URLs that `hasconfig:remote.*.url:` conditions match against, collected from every
2874/// layer of the current load on the first such condition and reused for the rest of it.
2875struct RemoteUrls<'a> {
2876    layers: &'a [ConfigLayer<'a>],
2877    urls: Option<Vec<String>>,
2878}
2879
2880impl<'a> RemoteUrls<'a> {
2881    fn new(layers: &'a [ConfigLayer<'a>]) -> Self {
2882        Self { layers, urls: None }
2883    }
2884
2885    /// Whether any `remote.<name>.url` value in the load matches `pattern` (`WM_PATHNAME`).
2886    fn any_matches(
2887        &mut self,
2888        pattern: &str,
2889        ctx: &IncludeContext,
2890        included_files: &mut Vec<PathBuf>,
2891    ) -> Result<bool> {
2892        let urls = match self.urls.take() {
2893            Some(urls) => urls,
2894            None => self.collect(ctx, included_files)?,
2895        };
2896        let hit = urls
2897            .iter()
2898            .any(|url| wildmatch(pattern.as_bytes(), url.as_bytes(), WM_PATHNAME));
2899        self.urls = Some(urls);
2900        Ok(hit)
2901    }
2902
2903    fn collect(
2904        &self,
2905        ctx: &IncludeContext,
2906        included_files: &mut Vec<PathBuf>,
2907    ) -> Result<Vec<String>> {
2908        let mut urls = Vec::new();
2909        for layer in self.layers {
2910            match layer {
2911                ConfigLayer::File(file) => {
2912                    collect_remote_urls(file, false, 0, ctx, &mut urls, included_files)?;
2913                }
2914                ConfigLayer::Entries(entries) => urls.extend(
2915                    entries
2916                        .iter()
2917                        .filter(|entry| is_remote_url_entry(entry))
2918                        .filter_map(|entry| entry.value.clone()),
2919                ),
2920            }
2921        }
2922        Ok(urls)
2923    }
2924}
2925
2926/// Collect `remote.<name>.url` values from `file` and everything it includes.
2927///
2928/// As in Git, this pass opens every `[includeIf]` target whose condition holds, treating every
2929/// `hasconfig:remote.*.url:` condition as holding. Remote URLs may not come from a conditional
2930/// include (directly or through nested includes), since they could change which conditions hold:
2931/// finding one there is [`ConfigError::RemoteUrlInHasconfigInclude`].
2932fn collect_remote_urls(
2933    file: &ConfigFile,
2934    in_conditional_include: bool,
2935    depth: usize,
2936    ctx: &IncludeContext,
2937    urls: &mut Vec<String>,
2938    included_files: &mut Vec<PathBuf>,
2939) -> Result<()> {
2940    check_include_depth(depth)?;
2941    for entry in &file.entries {
2942        if is_remote_url_entry(entry) {
2943            if in_conditional_include {
2944                return Err(ConfigError::RemoteUrlInHasconfigInclude.into());
2945            }
2946            urls.extend(entry.value.clone());
2947            continue;
2948        }
2949        let Some((inc_path, condition)) = include_directive_for_entry(entry) else {
2950            continue;
2951        };
2952        let conditional = match condition.as_deref() {
2953            None => in_conditional_include,
2954            Some(cond) => {
2955                if hasconfig_remote_url_pattern(cond).is_none()
2956                    && !evaluate_include_condition(cond, file, ctx)
2957                {
2958                    continue;
2959                }
2960                true
2961            }
2962        };
2963        let Some(inc_file) = open_include_target(&inc_path, file, ctx, included_files)? else {
2964            continue;
2965        };
2966        collect_remote_urls(&inc_file, conditional, depth + 1, ctx, urls, included_files)?;
2967    }
2968    Ok(())
2969}
2970
2971/// Fail once include nesting exceeds Git's limit, which stops runaway recursion.
2972fn check_include_depth(depth: usize) -> Result<()> {
2973    const MAX_INCLUDE_DEPTH: usize = 10;
2974    if depth > MAX_INCLUDE_DEPTH {
2975        return Err(ConfigError::IncludeDepthExceeded {
2976            depth,
2977            limit: MAX_INCLUDE_DEPTH,
2978        }
2979        .into());
2980    }
2981    Ok(())
2982}
2983
2984/// Resolve and parse the target of an include directive in `file`, recording the resolved path
2985/// in `included_files` (even when missing) so the cascade cache stamps it.
2986///
2987/// Returns `None` for a missing target or a path Git skips. Parse errors in the target are
2988/// fatal, as in Git (t0001 `re-init reads matching includeIf.onbranch`).
2989fn open_include_target(
2990    inc_path: &str,
2991    file: &ConfigFile,
2992    ctx: &IncludeContext,
2993    included_files: &mut Vec<PathBuf>,
2994) -> Result<Option<ConfigFile>> {
2995    let Some(resolved) = resolve_include_file_path(inc_path, file, ctx)? else {
2996        return Ok(None);
2997    };
2998    included_files.push(resolved.clone());
2999    ConfigFile::from_path(&resolved, file.scope)
3000}
3001
3002fn include_directive_for_entry(entry: &ConfigEntry) -> Option<(String, Option<String>)> {
3003    let val = entry.value.as_ref()?;
3004    if entry.key == "include.path" {
3005        return Some((val.clone(), None));
3006    }
3007    if entry.key.starts_with("includeif.") && entry.key.ends_with(".path") {
3008        let mid = &entry.key["includeif.".len()..entry.key.len() - ".path".len()];
3009        return Some((val.clone(), Some(mid.to_owned())));
3010    }
3011    None
3012}
3013
3014// ── Process-lifetime config cascade cache ───────────────────────────
3015//
3016// `ConfigSet::load` / `load_with_options` are called per file in hot loops
3017// (grep/diff/add re-load the cascade for every path), so the parsed cascade
3018// is memoized for the lifetime of the process. A cached entry is served only
3019// when every input that fed it is provably unchanged:
3020//
3021// - the stat stamps (mtime + size, or "absent") of every base cascade file,
3022//   recorded *before* the parse, still match. `HEAD` is stamped too so that
3023//   `includeIf.onbranch:` condition flips invalidate;
3024// - every include target the parse resolved (even ones that were missing)
3025//   still carries the stamp it had at parse time;
3026// - the config-relevant environment is byte-identical.
3027//
3028// The remaining `includeIf` condition inputs are covered transitively:
3029// `gitdir:` depends only on the cache key and environment, and
3030// `hasconfig:remote.*.url:` depends only on the (stamped) file contents —
3031// every file its URL scan opens is recorded as an include target — plus the
3032// command-line config already in the environment fingerprint.
3033//
3034// In-process writers ([`ConfigFile::write`]) additionally evict every entry
3035// whose cascade contains the written path, closing the window where a
3036// rewrite lands within one mtime tick at an unchanged size. External writers
3037// in that same window are not detectable by stat; C git parses the cascade
3038// once per process with no revalidation at all, so serving a stamped copy is
3039// strictly more conservative than upstream.
3040
3041/// Stat snapshot of one cascade file (`None` = file absent).
3042pub(crate) type ConfigFileStamp = (PathBuf, Option<(SystemTime, u64)>);
3043
3044/// Stamp metadata stored with a memoized config cascade entry.
3045pub(crate) type ConfigCacheEntryStamps = (
3046    Vec<(String, Option<String>)>,
3047    Vec<ConfigFileStamp>,
3048    Vec<ConfigFileStamp>,
3049);
3050
3051#[derive(Clone, PartialEq, Eq, Hash)]
3052pub(crate) struct ConfigCacheKey {
3053    pub(crate) git_dir: Option<PathBuf>,
3054    include_system: bool,
3055    process_includes: bool,
3056    command_includes: bool,
3057    ctx_git_dir: Option<PathBuf>,
3058    ctx_relative_include_is_error: bool,
3059}
3060
3061impl ConfigCacheKey {
3062    pub fn new(git_dir: Option<&Path>, opts: &LoadConfigOptions) -> Self {
3063        Self {
3064            git_dir: git_dir.map(Path::to_path_buf),
3065            include_system: opts.include_system,
3066            process_includes: opts.process_includes,
3067            command_includes: opts.command_includes,
3068            ctx_git_dir: opts.include_ctx.git_dir.clone(),
3069            ctx_relative_include_is_error: opts.include_ctx.command_line_relative_include_is_error,
3070        }
3071    }
3072}
3073
3074pub(crate) struct ConfigCacheEntry {
3075    pub(crate) env_fingerprint: Vec<(String, Option<String>)>,
3076    /// Stamps of the fixed cascade files (recomputable from key + env).
3077    pub(crate) base_stamps: Vec<ConfigFileStamp>,
3078    /// Stamps of the include targets this parse resolved (revalidated by
3079    /// re-statting each stored path).
3080    pub(crate) extra_stamps: Vec<ConfigFileStamp>,
3081    pub(crate) set: Arc<ConfigSet>,
3082}
3083
3084/// The on-disk files [`ConfigSet::load_with_options_uncached`] would consult,
3085/// in cascade order.
3086fn config_cascade_file_paths(
3087    env: &Environment,
3088    git_dir: Option<&Path>,
3089    opts: &LoadConfigOptions,
3090) -> Vec<PathBuf> {
3091    let mut paths = Vec::new();
3092    if opts.include_system && !git_config_nosystem_enabled(env) {
3093        paths.push(system_config_path(env));
3094    }
3095    paths.extend(global_config_paths(env));
3096    if let Some(gd) = git_dir {
3097        let common_dir = crate::repo::common_git_dir_for_config(gd);
3098        paths.push(common_dir.join("config"));
3099        // Stamped unconditionally: whether it is merged depends on
3100        // `extensions.worktreeConfig` in the (stamped) common config.
3101        paths.push(gd.join("config.worktree"));
3102        // `includeIf.onbranch:` conditions read HEAD; stamping it means a
3103        // branch switch invalidates cascades that could depend on it.
3104        paths.push(gd.join("HEAD"));
3105    }
3106    if let Some(cgd) = &opts.include_ctx.git_dir {
3107        if Some(cgd.as_path()) != git_dir {
3108            paths.push(cgd.join("HEAD"));
3109        }
3110    }
3111    if let Some(p) = env.git_config.as_deref() {
3112        paths.push(resolve_config_override_path(env, p));
3113    }
3114    paths
3115}
3116
3117pub(crate) fn stamp_for_path(path: &Path) -> Option<(SystemTime, u64)> {
3118    fs::metadata(path)
3119        .ok()
3120        .and_then(|m| Some((m.modified().ok()?, m.len())))
3121}
3122
3123pub(crate) fn stamp_paths(paths: Vec<PathBuf>) -> Vec<ConfigFileStamp> {
3124    paths
3125        .into_iter()
3126        .map(|path| {
3127            let stamp = stamp_for_path(&path);
3128            (path, stamp)
3129        })
3130        .collect()
3131}
3132
3133pub(crate) fn config_file_stamps(
3134    env: &Environment,
3135    git_dir: Option<&Path>,
3136    opts: &LoadConfigOptions,
3137) -> Vec<ConfigFileStamp> {
3138    stamp_paths(config_cascade_file_paths(env, git_dir, opts))
3139}
3140
3141pub(crate) fn config_cache_lookup(
3142    cache: &HashMap<ConfigCacheKey, ConfigCacheEntry>,
3143    key: &ConfigCacheKey,
3144    env_fp: &[(String, Option<String>)],
3145    base_stamps: &[ConfigFileStamp],
3146) -> Option<Arc<ConfigSet>> {
3147    let entry = cache.get(key)?;
3148    if entry.env_fingerprint.as_slice() != env_fp || entry.base_stamps.as_slice() != base_stamps {
3149        return None;
3150    }
3151    for (path, stamp) in &entry.extra_stamps {
3152        if stamp_for_path(path) != *stamp {
3153            return None;
3154        }
3155    }
3156    Some(Arc::clone(&entry.set))
3157}
3158
3159pub(crate) fn config_cache_store(
3160    cache: &mut HashMap<ConfigCacheKey, ConfigCacheEntry>,
3161    key: ConfigCacheKey,
3162    env_fingerprint: Vec<(String, Option<String>)>,
3163    base_stamps: Vec<ConfigFileStamp>,
3164    extra_stamps: Vec<ConfigFileStamp>,
3165    set: Arc<ConfigSet>,
3166) {
3167    cache.insert(
3168        key,
3169        ConfigCacheEntry {
3170            env_fingerprint,
3171            base_stamps,
3172            extra_stamps,
3173            set,
3174        },
3175    );
3176}
3177
3178fn git_config_nosystem_enabled(env: &Environment) -> bool {
3179    env.git_config_nosystem
3180        .as_deref()
3181        .map(|value| parse_bool(value).unwrap_or(true))
3182        .unwrap_or(false)
3183}
3184
3185/// Entries from `GIT_CONFIG_COUNT` / `GIT_CONFIG_KEY_<n>` / `GIT_CONFIG_VALUE_<n>`.
3186fn environment_config_pair_entries(env: &Environment) -> Result<Vec<ConfigEntry>> {
3187    let Some(count_str) = env.git_config_count.as_deref() else {
3188        return Ok(Vec::new());
3189    };
3190    if count_str.is_empty() {
3191        return Ok(Vec::new());
3192    }
3193
3194    let count = count_str
3195        .parse::<usize>()
3196        .ok()
3197        .filter(|&n| n <= i32::MAX as usize)
3198        .ok_or_else(|| {
3199            Error::Config(ConfigError::InvalidEnvironmentCount {
3200                value: count_str.to_owned(),
3201            })
3202        })?;
3203
3204    let mut set = ConfigSet::new();
3205    for i in 0..count {
3206        let (key, value) = env.git_config_pairs.get(i).ok_or(Error::Config(
3207            ConfigError::MissingEnvironmentKey { index: i },
3208        ))?;
3209        set.add_command_override(key, value)?;
3210    }
3211    Ok(set.entries)
3212}
3213
3214/// `GIT_CONFIG_PARAMETERS` as plain command-scope entries (no include processing); entries
3215/// with an invalid key are skipped.
3216fn command_override_entries(params: &str) -> Vec<ConfigEntry> {
3217    let mut set = ConfigSet::new();
3218    for entry in parse_config_parameters(params) {
3219        let (key, val) = entry
3220            .split_once('\u{1}')
3221            .or_else(|| entry.split_once('='))
3222            .unwrap_or((entry.as_str(), "true"));
3223        let _ = set.add_command_override(key.trim(), val);
3224    }
3225    set.entries
3226}
3227
3228// ── Type coercion helpers ───────────────────────────────────────────
3229
3230/// Parse a Git boolean value.
3231///
3232/// Accepts: `true`, `yes`, `on`, `1` as true.
3233/// Accepts: `false`, `no`, `off`, `0` as false.
3234///
3235/// Note: bare config keys are represented as `None` in [`ConfigEntry`] and
3236/// are normalized to `"true"` by higher-level readers (`ConfigSet::get`).
3237/// An explicit empty assignment (`key =` with no value) is stored as `""` and
3238/// is treated as false for `--bool` / [`parse_bool`]. Bare keys are represented
3239/// as `None` and normalized to `"true"` by callers before reaching this parser.
3240///
3241/// # Errors
3242///
3243/// [`ConfigValueError::InvalidBool`] when `s` is neither a boolean word nor an integer.
3244pub fn parse_bool(s: &str) -> std::result::Result<bool, ConfigValueError> {
3245    match s.to_lowercase().as_str() {
3246        "true" | "yes" | "on" => Ok(true),
3247        "" => Ok(false),
3248        "false" | "no" | "off" => Ok(false),
3249        _ => parse_git_config_maybe_bool(s).ok_or_else(|| ConfigValueError::InvalidBool {
3250            value: s.to_owned(),
3251        }),
3252    }
3253}
3254
3255/// Parse a Git integer value with optional `k`/`m`/`g` suffix.
3256///
3257/// # Errors
3258///
3259/// [`ConfigValueError::EmptyInteger`] for an empty value,
3260/// [`ConfigValueError::InvalidInteger`] when it is not a number, and
3261/// [`ConfigValueError::IntegerOverflow`] when the scaled value does not fit in `i64`.
3262pub fn parse_i64(s: &str) -> std::result::Result<i64, ConfigValueError> {
3263    let s = s.trim();
3264    if s.is_empty() {
3265        return Err(ConfigValueError::EmptyInteger);
3266    }
3267
3268    let (num_str, multiplier) = match s.as_bytes().last() {
3269        Some(b'k' | b'K') => (&s[..s.len() - 1], 1024_i64),
3270        Some(b'm' | b'M') => (&s[..s.len() - 1], 1024 * 1024),
3271        Some(b'g' | b'G') => (&s[..s.len() - 1], 1024 * 1024 * 1024),
3272        _ => (s, 1_i64),
3273    };
3274
3275    let base: i64 = num_str
3276        .parse()
3277        .map_err(|_| ConfigValueError::InvalidInteger {
3278            value: s.to_owned(),
3279        })?;
3280    base.checked_mul(multiplier)
3281        .ok_or_else(|| ConfigValueError::IntegerOverflow {
3282            value: s.to_owned(),
3283        })
3284}
3285
3286/// Why [`parse_git_config_int_strict`] failed (mirrors Git `errno` after `git_parse_signed`).
3287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3288pub enum GitConfigIntStrictError {
3289    /// `EINVAL` — trailing junk, unknown unit suffix, or not a number.
3290    InvalidUnit,
3291    /// `ERANGE` — value does not fit in `i64` after scaling.
3292    OutOfRange,
3293}
3294
3295/// Parse a signed decimal integer with optional `k`/`m`/`g` multiplier suffix, requiring the
3296/// entire input (trimmed) to be consumed — same constraints as Git's `git_parse_signed` used by
3297/// `git_config_int` (so `no` and `1foo` are rejected, unlike [`parse_i64`]).
3298pub fn parse_git_config_int_strict(raw: &str) -> std::result::Result<i64, GitConfigIntStrictError> {
3299    let s = raw.trim();
3300    if s.is_empty() {
3301        return Err(GitConfigIntStrictError::InvalidUnit);
3302    }
3303
3304    let bytes = s.as_bytes();
3305    let mut idx = 0usize;
3306    if matches!(bytes.first(), Some(b'+') | Some(b'-')) {
3307        idx = 1;
3308    }
3309    if idx >= bytes.len() {
3310        return Err(GitConfigIntStrictError::InvalidUnit);
3311    }
3312    let digit_start = idx;
3313    while idx < bytes.len() && bytes[idx].is_ascii_digit() {
3314        idx += 1;
3315    }
3316    if idx == digit_start {
3317        return Err(GitConfigIntStrictError::InvalidUnit);
3318    }
3319
3320    let num_part =
3321        std::str::from_utf8(&bytes[..idx]).map_err(|_| GitConfigIntStrictError::InvalidUnit)?;
3322    let suffix =
3323        std::str::from_utf8(&bytes[idx..]).map_err(|_| GitConfigIntStrictError::InvalidUnit)?;
3324    let mult: i64 = match suffix {
3325        "" => 1,
3326        "k" | "K" => 1024,
3327        "m" | "M" => 1024 * 1024,
3328        "g" | "G" => 1024_i64
3329            .checked_mul(1024)
3330            .and_then(|x| x.checked_mul(1024))
3331            .ok_or(GitConfigIntStrictError::OutOfRange)?,
3332        _ => return Err(GitConfigIntStrictError::InvalidUnit),
3333    };
3334
3335    let val: i64 = num_part
3336        .parse()
3337        .map_err(|_| GitConfigIntStrictError::InvalidUnit)?;
3338    val.checked_mul(mult)
3339        .ok_or(GitConfigIntStrictError::OutOfRange)
3340}
3341
3342fn is_c_locale_space(b: u8) -> bool {
3343    matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c)
3344}
3345
3346fn skip_c_locale_leading_whitespace(s: &str) -> &str {
3347    let bytes = s.as_bytes();
3348    let mut i = 0usize;
3349    while i < bytes.len() && is_c_locale_space(bytes[i]) {
3350        i += 1;
3351    }
3352    &s[i..]
3353}
3354
3355fn git_unit_factor(suffix: &str) -> std::result::Result<i64, GitConfigIntStrictError> {
3356    match suffix {
3357        "" => Ok(1),
3358        "k" | "K" => Ok(1024),
3359        "m" | "M" => Ok(1024 * 1024),
3360        "g" | "G" => Ok(1024_i64 * 1024 * 1024),
3361        _ => Err(GitConfigIntStrictError::InvalidUnit),
3362    }
3363}
3364
3365/// Parse a base-0 signed integer prefix (Git `strtoimax(..., 0)`), returning unconsumed suffix.
3366fn parse_base0_signed_prefix(s: &str) -> std::result::Result<(i64, &str), GitConfigIntStrictError> {
3367    let bytes = s.as_bytes();
3368    if bytes.is_empty() {
3369        return Err(GitConfigIntStrictError::InvalidUnit);
3370    }
3371    let mut i = 0usize;
3372    let mut sign: i64 = 1;
3373    if bytes[i] == b'+' {
3374        i += 1;
3375    } else if bytes[i] == b'-' {
3376        sign = -1;
3377        i += 1;
3378    }
3379    if i >= bytes.len() {
3380        return Err(GitConfigIntStrictError::InvalidUnit);
3381    }
3382
3383    let base = if bytes[i] == b'0' {
3384        if i + 1 < bytes.len() && matches!(bytes[i + 1], b'x' | b'X') {
3385            i += 2;
3386            if i >= bytes.len() {
3387                return Err(GitConfigIntStrictError::InvalidUnit);
3388            }
3389            16
3390        } else {
3391            8
3392        }
3393    } else {
3394        10
3395    };
3396
3397    let digit_start = i;
3398    let mut acc: i128 = 0;
3399    while i < bytes.len() {
3400        let ch = char::from(bytes[i]);
3401        let digit = match base {
3402            10 => ch.to_digit(10),
3403            8 => ch.to_digit(8),
3404            16 => ch.to_digit(16),
3405            _ => None,
3406        };
3407        let Some(d) = digit else {
3408            break;
3409        };
3410        acc = acc
3411            .checked_mul(base as i128)
3412            .and_then(|v| v.checked_add(i128::from(d)))
3413            .ok_or(GitConfigIntStrictError::OutOfRange)?;
3414        i += 1;
3415    }
3416    if i == digit_start {
3417        return Err(GitConfigIntStrictError::InvalidUnit);
3418    }
3419
3420    let signed = if sign < 0 { -acc } else { acc };
3421    let val = i64::try_from(signed).map_err(|_| GitConfigIntStrictError::OutOfRange)?;
3422    Ok((val, &s[i..]))
3423}
3424
3425/// Parse a signed integer with optional `k`/`m`/`g` scaling using Git base-0 rules (`git_parse_signed`).
3426pub fn parse_git_signed_config(
3427    raw: &str,
3428    max: i64,
3429) -> std::result::Result<i64, GitConfigIntStrictError> {
3430    if max < 0 {
3431        return Err(GitConfigIntStrictError::InvalidUnit);
3432    }
3433    let s = skip_c_locale_leading_whitespace(raw);
3434    if s.is_empty() {
3435        return Err(GitConfigIntStrictError::InvalidUnit);
3436    }
3437
3438    let (val, rest) = parse_base0_signed_prefix(s)?;
3439    let factor = git_unit_factor(rest)?;
3440
3441    if val < 0 {
3442        let min = -max - 1;
3443        if min / factor > val {
3444            return Err(GitConfigIntStrictError::OutOfRange);
3445        }
3446    } else if val > 0 && max / factor < val {
3447        return Err(GitConfigIntStrictError::OutOfRange);
3448    }
3449
3450    val.checked_mul(factor)
3451        .ok_or(GitConfigIntStrictError::OutOfRange)
3452}
3453
3454/// Parse a signed 32-bit config integer (`git_parse_int`).
3455pub fn parse_git_config_int(raw: &str) -> std::result::Result<i32, GitConfigIntStrictError> {
3456    let max = i32::MAX as i64;
3457    let n = parse_git_signed_config(raw, max)?;
3458    i32::try_from(n).map_err(|_| GitConfigIntStrictError::OutOfRange)
3459}
3460
3461/// Parse Git `git_parse_maybe_bool`: `Some(true/false)` or `None` if unrecognized.
3462pub fn parse_git_config_maybe_bool(raw: &str) -> Option<bool> {
3463    if raw.is_empty() {
3464        return Some(false);
3465    }
3466    let lower = raw.to_ascii_lowercase();
3467    match lower.as_str() {
3468        "true" | "yes" | "on" => Some(true),
3469        "false" | "no" | "off" => Some(false),
3470        _ => parse_git_config_int(raw).ok().map(|n| n != 0),
3471    }
3472}
3473
3474const DIFF_CONTEXT_KEY: &str = "diff.context";
3475
3476/// Git's documented default for `gc.auto`.
3477const DEFAULT_GC_AUTO_THRESHOLD: usize = 6700;
3478/// Git's documented default for `gc.autoPackLimit`.
3479const DEFAULT_GC_AUTO_PACK_LIMIT: usize = 50;
3480
3481fn bad_numeric_diff_context(
3482    value: &str,
3483    err: GitConfigIntStrictError,
3484    entry: &ConfigEntry,
3485) -> ConfigError {
3486    let reason = match err {
3487        GitConfigIntStrictError::InvalidUnit => crate::error::BadNumericSource::InvalidUnit,
3488        GitConfigIntStrictError::OutOfRange => crate::error::BadNumericSource::OutOfRange,
3489    };
3490    if entry.scope == ConfigScope::Command || entry.file.is_none() {
3491        ConfigError::BadNumericValue {
3492            key: DIFF_CONTEXT_KEY.to_owned(),
3493            value: value.to_owned(),
3494            reason,
3495        }
3496    } else {
3497        let path = entry
3498            .file
3499            .as_deref()
3500            .map(config_error_path_display)
3501            .unwrap_or_default();
3502        ConfigError::BadNumericValueInFile {
3503            key: DIFF_CONTEXT_KEY.to_owned(),
3504            value: value.to_owned(),
3505            file: path,
3506            reason,
3507        }
3508    }
3509}
3510
3511fn bad_diff_context_variable(entry: &ConfigEntry) -> ConfigError {
3512    if entry.scope == ConfigScope::Command || entry.file.is_none() {
3513        ConfigError::UnableToParseFromCommandLine {
3514            key: DIFF_CONTEXT_KEY.to_owned(),
3515        }
3516    } else {
3517        let path = entry
3518            .file
3519            .as_deref()
3520            .map(config_error_path_display)
3521            .unwrap_or_default();
3522        ConfigError::BadConfigVariableAtLine {
3523            key: DIFF_CONTEXT_KEY.to_owned(),
3524            file: path,
3525            line: entry.line,
3526        }
3527    }
3528}
3529
3530/// Read `diff.context` from a loaded [`ConfigSet`] with Git-compatible validation.
3531///
3532/// Returns `Ok(None)` when the key is unset. When set, the value must be a non-negative integer
3533/// acceptable to Git's diff machinery (same rules as `git diff` / `git log -p`).
3534pub fn resolve_diff_context_lines(cfg: &ConfigSet) -> Result<Option<usize>> {
3535    let Some(entry) = cfg.get_last_entry(DIFF_CONTEXT_KEY) else {
3536        return Ok(None);
3537    };
3538    let value_src = entry.value.as_deref().unwrap_or("").trim();
3539    match parse_git_config_int_strict(value_src) {
3540        Ok(n) if n < 0 => Err(bad_diff_context_variable(&entry).into()),
3541        Ok(n) => Ok(Some(usize::try_from(n).map_err(|_| {
3542            bad_numeric_diff_context(value_src, GitConfigIntStrictError::OutOfRange, &entry)
3543        })?)),
3544        Err(e) => Err(bad_numeric_diff_context(value_src, e, &entry).into()),
3545    }
3546}
3547
3548/// Parse a Git color value and return the ANSI escape sequence.
3549///
3550/// Matches Git's `color_parse_mem` (`git/color.c`): whitespace-separated words,
3551/// optional leading `reset`, up to two color tokens (foreground then background),
3552/// then graphic rendition attributes. Attribute codes are accumulated as a
3553/// bitmask keyed by SGR number (so `bold` sets bit 1, `nobold` sets bit 22).
3554///
3555/// # Errors
3556///
3557/// [`ConfigValueError::InvalidColor`] when a word is neither a color nor an attribute, more
3558/// than two colors are given, or nothing usable is left.
3559pub fn parse_color(s: &str) -> std::result::Result<String, ConfigValueError> {
3560    const COLOR_BACKGROUND_OFFSET: i32 = 10;
3561    const COLOR_FOREGROUND_ANSI: i32 = 30;
3562    const COLOR_FOREGROUND_RGB: i32 = 38;
3563    const COLOR_FOREGROUND_256: i32 = 38;
3564    const COLOR_FOREGROUND_BRIGHT_ANSI: i32 = 90;
3565
3566    #[derive(Clone, Copy, Default)]
3567    struct Color {
3568        kind: u8,
3569        value: u8,
3570        red: u8,
3571        green: u8,
3572        blue: u8,
3573    }
3574
3575    const COLOR_UNSPECIFIED: u8 = 0;
3576    const COLOR_NORMAL: u8 = 1;
3577    const COLOR_ANSI: u8 = 2;
3578    const COLOR_256: u8 = 3;
3579    const COLOR_RGB: u8 = 4;
3580
3581    fn color_empty(c: &Color) -> bool {
3582        c.kind == COLOR_UNSPECIFIED || c.kind == COLOR_NORMAL
3583    }
3584
3585    fn parse_ansi_color(name: &str) -> Option<Color> {
3586        let color_names = [
3587            "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
3588        ];
3589        let color_offset = COLOR_FOREGROUND_ANSI;
3590
3591        if name.eq_ignore_ascii_case("default") {
3592            return Some(Color {
3593                kind: COLOR_ANSI,
3594                value: (9 + color_offset) as u8,
3595                ..Default::default()
3596            });
3597        }
3598
3599        let (name, color_offset) = if name.len() >= 6 && name[..6].eq_ignore_ascii_case("bright") {
3600            (&name[6..], COLOR_FOREGROUND_BRIGHT_ANSI)
3601        } else {
3602            (name, COLOR_FOREGROUND_ANSI)
3603        };
3604
3605        for (i, cn) in color_names.iter().enumerate() {
3606            if name.eq_ignore_ascii_case(cn) {
3607                return Some(Color {
3608                    kind: COLOR_ANSI,
3609                    value: (i as i32 + color_offset) as u8,
3610                    ..Default::default()
3611                });
3612            }
3613        }
3614        None
3615    }
3616
3617    fn hex_val(b: u8) -> Option<u8> {
3618        match b {
3619            b'0'..=b'9' => Some(b - b'0'),
3620            b'a'..=b'f' => Some(b - b'a' + 10),
3621            b'A'..=b'F' => Some(b - b'A' + 10),
3622            _ => None,
3623        }
3624    }
3625
3626    fn get_hex_color(chars: &[u8], width: usize) -> Option<(u8, usize)> {
3627        assert!(width == 1 || width == 2);
3628        if chars.len() < width {
3629            return None;
3630        }
3631        let v = if width == 2 {
3632            let hi = hex_val(chars[0])?;
3633            let lo = hex_val(chars[1])?;
3634            (hi << 4) | lo
3635        } else {
3636            let n = hex_val(chars[0])?;
3637            (n << 4) | n
3638        };
3639        Some((v, width))
3640    }
3641
3642    fn parse_single_color(word: &str) -> Option<Color> {
3643        if word.eq_ignore_ascii_case("normal") {
3644            return Some(Color {
3645                kind: COLOR_NORMAL,
3646                ..Default::default()
3647            });
3648        }
3649
3650        let bytes = word.as_bytes();
3651        if (bytes.len() == 7 || bytes.len() == 4) && bytes.first() == Some(&b'#') {
3652            let width = if bytes.len() == 7 { 2 } else { 1 };
3653            let mut idx = 1;
3654            let (r, n1) = get_hex_color(&bytes[idx..], width)?;
3655            idx += n1;
3656            let (g, n2) = get_hex_color(&bytes[idx..], width)?;
3657            idx += n2;
3658            let (b, n3) = get_hex_color(&bytes[idx..], width)?;
3659            idx += n3;
3660            if idx != bytes.len() {
3661                return None;
3662            }
3663            return Some(Color {
3664                kind: COLOR_RGB,
3665                red: r,
3666                green: g,
3667                blue: b,
3668                ..Default::default()
3669            });
3670        }
3671
3672        if let Some(c) = parse_ansi_color(word) {
3673            return Some(c);
3674        }
3675
3676        let Ok(val) = word.parse::<i64>() else {
3677            return None;
3678        };
3679        if val < -1 {
3680            return None;
3681        }
3682        if val < 0 {
3683            return Some(Color {
3684                kind: COLOR_NORMAL,
3685                ..Default::default()
3686            });
3687        }
3688        if val < 8 {
3689            return Some(Color {
3690                kind: COLOR_ANSI,
3691                value: (val as i32 + COLOR_FOREGROUND_ANSI) as u8,
3692                ..Default::default()
3693            });
3694        }
3695        if val < 16 {
3696            return Some(Color {
3697                kind: COLOR_ANSI,
3698                value: (val as i32 - 8 + COLOR_FOREGROUND_BRIGHT_ANSI) as u8,
3699                ..Default::default()
3700            });
3701        }
3702        if val < 256 {
3703            return Some(Color {
3704                kind: COLOR_256,
3705                value: val as u8,
3706                ..Default::default()
3707            });
3708        }
3709        None
3710    }
3711
3712    fn parse_attr(word: &str) -> Option<u8> {
3713        const ATTRS: [(&str, u8, u8); 8] = [
3714            ("bold", 1, 22),
3715            ("dim", 2, 22),
3716            ("italic", 3, 23),
3717            ("ul", 4, 24),
3718            ("underline", 4, 24),
3719            ("blink", 5, 25),
3720            ("reverse", 7, 27),
3721            ("strike", 9, 29),
3722        ];
3723
3724        let mut negate = false;
3725        let mut rest = word;
3726        if let Some(stripped) = rest.strip_prefix("no") {
3727            negate = true;
3728            rest = stripped;
3729            if let Some(s) = rest.strip_prefix('-') {
3730                rest = s;
3731            }
3732        }
3733
3734        for (name, val, neg) in ATTRS {
3735            if rest == name {
3736                return Some(if negate { neg } else { val });
3737            }
3738        }
3739        None
3740    }
3741
3742    fn append_color_output(out: &mut String, c: &Color, background: bool) {
3743        let offset = if background {
3744            COLOR_BACKGROUND_OFFSET
3745        } else {
3746            0
3747        };
3748        match c.kind {
3749            COLOR_UNSPECIFIED | COLOR_NORMAL => {}
3750            COLOR_ANSI => {
3751                use std::fmt::Write;
3752                let _ = write!(out, "{}", i32::from(c.value) + offset);
3753            }
3754            COLOR_256 => {
3755                use std::fmt::Write;
3756                let _ = write!(out, "{};5;{}", COLOR_FOREGROUND_256 + offset, c.value);
3757            }
3758            COLOR_RGB => {
3759                use std::fmt::Write;
3760                let _ = write!(
3761                    out,
3762                    "{};2;{};{};{}",
3763                    COLOR_FOREGROUND_RGB + offset,
3764                    c.red,
3765                    c.green,
3766                    c.blue
3767                );
3768            }
3769            _ => {}
3770        }
3771    }
3772
3773    let s = s.trim();
3774    let bad_color = || ConfigValueError::InvalidColor {
3775        value: s.to_owned(),
3776    };
3777    if s.is_empty() {
3778        return Ok(String::new());
3779    }
3780
3781    let mut has_reset = false;
3782    let mut attr: u64 = 0;
3783    let mut fg = Color::default();
3784    let mut bg = Color::default();
3785    fg.kind = COLOR_UNSPECIFIED;
3786    bg.kind = COLOR_UNSPECIFIED;
3787
3788    for word in s.split_whitespace() {
3789        if word.eq_ignore_ascii_case("reset") {
3790            has_reset = true;
3791            continue;
3792        }
3793
3794        if let Some(c) = parse_single_color(word) {
3795            if fg.kind == COLOR_UNSPECIFIED {
3796                fg = c;
3797                continue;
3798            }
3799            if bg.kind == COLOR_UNSPECIFIED {
3800                bg = c;
3801                continue;
3802            }
3803            return Err(bad_color());
3804        }
3805
3806        if let Some(code) = parse_attr(word) {
3807            attr |= 1u64 << u64::from(code);
3808            continue;
3809        }
3810
3811        return Err(bad_color());
3812    }
3813
3814    if !has_reset && attr == 0 && color_empty(&fg) && color_empty(&bg) {
3815        return Err(bad_color());
3816    }
3817
3818    let mut out = String::from("\x1b[");
3819    let mut sep = if has_reset { 1u32 } else { 0u32 };
3820
3821    let mut attr_bits = attr;
3822    let mut i = 0u32;
3823    while attr_bits != 0 {
3824        let bit = 1u64 << i;
3825        if attr_bits & bit == 0 {
3826            i += 1;
3827            continue;
3828        }
3829        attr_bits &= !bit;
3830        if sep > 0 {
3831            out.push(';');
3832        }
3833        sep += 1;
3834        use std::fmt::Write;
3835        let _ = write!(out, "{i}");
3836        i += 1;
3837    }
3838
3839    if !color_empty(&fg) {
3840        if sep > 0 {
3841            out.push(';');
3842        }
3843        sep += 1;
3844        append_color_output(&mut out, &fg, false);
3845    }
3846    if !color_empty(&bg) {
3847        if sep > 0 {
3848            out.push(';');
3849        }
3850        append_color_output(&mut out, &bg, true);
3851    }
3852    out.push('m');
3853    Ok(out)
3854}
3855
3856#[derive(Debug, Clone)]
3857struct UrlParts {
3858    scheme: String,
3859    user: Option<String>,
3860    host: String,
3861    port: Option<String>,
3862    path: String,
3863}
3864
3865#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
3866struct UrlMatchScore {
3867    host_len: usize,
3868    path_len: usize,
3869    user_matched: bool,
3870}
3871
3872fn parse_config_url(url: &str) -> Option<UrlParts> {
3873    let (scheme, rest) = url.split_once("://")?;
3874    let (authority, path) = match rest.find('/') {
3875        Some(idx) => (&rest[..idx], &rest[idx..]),
3876        None => (rest, "/"),
3877    };
3878    let (user, host_port) = match authority.rsplit_once('@') {
3879        Some((user, host)) => (Some(user.to_owned()), host),
3880        None => (None, authority),
3881    };
3882    let (host, port) = match host_port.rsplit_once(':') {
3883        Some((host, port)) if !host.contains(']') => (host, Some(port.to_owned())),
3884        _ => (host_port, None),
3885    };
3886    Some(UrlParts {
3887        scheme: scheme.to_lowercase(),
3888        user,
3889        host: host.to_lowercase(),
3890        port,
3891        path: if path.is_empty() {
3892            "/".to_owned()
3893        } else {
3894            path.trim_end_matches('/').to_owned()
3895        },
3896    })
3897}
3898
3899fn host_matches(pattern: &str, target: &str) -> bool {
3900    let pattern_parts: Vec<&str> = pattern.split('.').collect();
3901    let target_parts: Vec<&str> = target.split('.').collect();
3902    pattern_parts.len() == target_parts.len()
3903        && pattern_parts
3904            .iter()
3905            .zip(target_parts)
3906            .all(|(pattern, target)| *pattern == "*" || *pattern == target)
3907}
3908
3909fn path_match_len(pattern: &str, target: &str) -> Option<usize> {
3910    let pattern = if pattern.is_empty() { "/" } else { pattern };
3911    let target = if target.is_empty() { "/" } else { target };
3912    if pattern == "/" {
3913        return Some(1);
3914    }
3915    let pattern = pattern.trim_end_matches('/');
3916    if target == pattern
3917        || target
3918            .strip_prefix(pattern)
3919            .is_some_and(|rest| rest.starts_with('/'))
3920    {
3921        Some(pattern.len() + 1)
3922    } else {
3923        None
3924    }
3925}
3926
3927fn url_match_score(pattern_url: &str, target_url: &str) -> Option<UrlMatchScore> {
3928    let pattern = parse_config_url(pattern_url)?;
3929    let target = parse_config_url(target_url)?;
3930    if pattern.scheme != target.scheme {
3931        return None;
3932    }
3933    let user_matched = match pattern.user.as_deref() {
3934        Some(user) if target.user.as_deref() == Some(user) => true,
3935        Some(_) => return None,
3936        None => false,
3937    };
3938    if !host_matches(&pattern.host, &target.host) || pattern.port != target.port {
3939        return None;
3940    }
3941    let path_len = path_match_len(&pattern.path, &target.path)?;
3942    Some(UrlMatchScore {
3943        host_len: pattern.host.len(),
3944        path_len,
3945        user_matched,
3946    })
3947}
3948
3949/// Match a URL against a URL pattern from config.
3950pub fn url_matches(pattern_url: &str, target_url: &str) -> bool {
3951    url_match_score(pattern_url, target_url).is_some()
3952}
3953
3954/// Get the best URL match for a specific key.
3955pub fn get_urlmatch_entries<'a>(
3956    entries: &'a [ConfigEntry],
3957    section: &str,
3958    variable: &str,
3959    url: &str,
3960) -> Vec<&'a ConfigEntry> {
3961    let section_lower = section.to_lowercase();
3962    let variable_lower = variable.to_lowercase();
3963    let mut matches: Vec<(UrlMatchScore, &'a ConfigEntry)> = Vec::new();
3964
3965    for entry in entries {
3966        let key = &entry.key;
3967        let first_dot = match key.find('.') {
3968            Some(i) => i,
3969            None => continue,
3970        };
3971        let last_dot = match key.rfind('.') {
3972            Some(i) => i,
3973            None => continue,
3974        };
3975        let entry_section = &key[..first_dot];
3976        let entry_variable = &key[last_dot + 1..];
3977        if entry_section.to_lowercase() != section_lower
3978            || entry_variable.to_lowercase() != variable_lower
3979        {
3980            continue;
3981        }
3982        if first_dot == last_dot {
3983            matches.push((
3984                UrlMatchScore {
3985                    host_len: 0,
3986                    path_len: 0,
3987                    user_matched: false,
3988                },
3989                entry,
3990            ));
3991        } else {
3992            let subsection = &key[first_dot + 1..last_dot];
3993            if let Some(score) = url_match_score(subsection, url) {
3994                matches.push((score, entry));
3995            }
3996        }
3997    }
3998    matches.sort_by_key(|a| a.0);
3999    matches.into_iter().map(|(_, e)| e).collect()
4000}
4001
4002/// Get all matching variables in a section for a given URL.
4003pub fn get_urlmatch_all_in_section(
4004    entries: &[ConfigEntry],
4005    section: &str,
4006    url: &str,
4007) -> Vec<(String, String, ConfigScope)> {
4008    let section_lower = section.to_lowercase();
4009    let mut matches: Vec<(String, UrlMatchScore, String, String, ConfigScope)> = Vec::new();
4010
4011    for entry in entries {
4012        let key = &entry.key;
4013        let first_dot = match key.find('.') {
4014            Some(i) => i,
4015            None => continue,
4016        };
4017        let last_dot = match key.rfind('.') {
4018            Some(i) => i,
4019            None => continue,
4020        };
4021        let entry_section = &key[..first_dot];
4022        if entry_section.to_lowercase() != section_lower {
4023            continue;
4024        }
4025        let entry_variable = &key[last_dot + 1..];
4026        let val = entry.value.as_deref().unwrap_or("");
4027        if first_dot == last_dot {
4028            let canonical = format!("{}.{}", section_lower, entry_variable);
4029            matches.push((
4030                entry_variable.to_lowercase(),
4031                UrlMatchScore {
4032                    host_len: 0,
4033                    path_len: 0,
4034                    user_matched: false,
4035                },
4036                val.to_owned(),
4037                canonical,
4038                entry.scope,
4039            ));
4040        } else {
4041            let subsection = &key[first_dot + 1..last_dot];
4042            if let Some(score) = url_match_score(subsection, url) {
4043                let canonical = format!("{}.{}", section_lower, entry_variable);
4044                matches.push((
4045                    entry_variable.to_lowercase(),
4046                    score,
4047                    val.to_owned(),
4048                    canonical,
4049                    entry.scope,
4050                ));
4051            }
4052        }
4053    }
4054
4055    let mut best: std::collections::BTreeMap<String, (UrlMatchScore, String, String, ConfigScope)> =
4056        std::collections::BTreeMap::new();
4057    for (var, specificity, val, canonical, scope) in matches {
4058        let entry = best.entry(var).or_insert((
4059            UrlMatchScore {
4060                host_len: 0,
4061                path_len: 0,
4062                user_matched: false,
4063            },
4064            String::new(),
4065            String::new(),
4066            scope,
4067        ));
4068        if specificity >= entry.0 {
4069            *entry = (specificity, val, canonical, scope);
4070        }
4071    }
4072    best.into_values()
4073        .map(|(_, val, canonical, scope)| (canonical, val, scope))
4074        .collect()
4075}
4076
4077/// Parse a Git path value (expand `~/` to home directory).
4078/// Parse a path value. Returns the resolved path string.
4079/// Does NOT handle :(optional) prefix — use `parse_path_optional` for that.
4080pub fn parse_path(env: &Environment, s: &str) -> String {
4081    if let Some(rest) = s.strip_prefix("~/") {
4082        if let Some(home) = home_dir(env) {
4083            return home.join(rest).to_string_lossy().to_string();
4084        }
4085    }
4086    s.to_owned()
4087}
4088
4089/// Parse a path value that may have an `:(optional)` prefix.
4090///
4091/// Returns `Some(path)` if the path should be used, `None` if the path
4092/// is optional and does not exist (meaning the entry should be skipped).
4093pub fn parse_path_optional(env: &Environment, s: &str) -> Option<String> {
4094    if let Some(rest) = s.strip_prefix(":(optional)") {
4095        let resolved = parse_path(env, rest);
4096        if std::path::Path::new(&resolved).exists() {
4097            Some(resolved)
4098        } else {
4099            None // optional and missing → skip
4100        }
4101    } else {
4102        Some(parse_path(env, s))
4103    }
4104}
4105
4106// ── Helpers ─────────────────────────────────────────────────────────
4107
4108/// Parse `GIT_CONFIG_PARAMETERS` payloads.
4109///
4110/// We support the common formats seen in tests and wrappers:
4111/// - single-quoted entries: `'key=value'`
4112/// - double-quoted entries: `"key=value"`
4113/// - unquoted `key=value` tokens separated by whitespace
4114///
4115/// Backslash escapes are interpreted minimally inside double quotes.
4116///
4117/// Return the last `key=value` assignment for `key` in a `GIT_CONFIG_PARAMETERS` payload.
4118///
4119/// Matches Git's command-line config layering: later tokens win. Keys are canonicalized the same
4120/// way as file-backed config (`fetch.output` and `FETCH.Output` both match `fetch.output`).
4121#[must_use]
4122pub fn git_config_parameters_last_value(raw: &str, key: &str) -> Option<String> {
4123    let Ok(canon) = canonical_key(key) else {
4124        return None;
4125    };
4126    let mut last: Option<String> = None;
4127    for entry in parse_config_parameters_strict(raw).ok()? {
4128        match entry {
4129            ConfigParameter::Pair { key, value } => {
4130                if canonical_key(key.trim()).ok().as_ref() == Some(&canon) {
4131                    last = Some(value.unwrap_or_else(|| "true".to_owned()));
4132                }
4133            }
4134            ConfigParameter::OldStyle(entry) => {
4135                if let Some((k, v)) = entry.split_once('=') {
4136                    if canonical_key(k.trim()).ok().as_ref() == Some(&canon) {
4137                        last = Some(v.to_owned());
4138                    }
4139                } else if canonical_key(entry.trim()).ok().as_ref() == Some(&canon) {
4140                    last = Some("true".to_owned());
4141                }
4142            }
4143        }
4144    }
4145    last
4146}
4147
4148#[derive(Debug, Clone, PartialEq, Eq)]
4149enum ConfigParameter {
4150    OldStyle(String),
4151    Pair { key: String, value: Option<String> },
4152}
4153
4154/// Parse `GIT_CONFIG_PARAMETERS` / `-c key=value` style parameter strings.
4155///
4156/// Returns Git's internal NUL-delimited representation used when bootstrapping config.
4157pub fn parse_config_parameters(raw: &str) -> Vec<String> {
4158    parse_config_parameters_strict(raw)
4159        .map(|entries| {
4160            entries
4161                .into_iter()
4162                .map(|entry| match entry {
4163                    ConfigParameter::OldStyle(entry) => entry,
4164                    ConfigParameter::Pair {
4165                        key,
4166                        value: Some(value),
4167                    } => format!("{key}\u{1}{value}"),
4168                    ConfigParameter::Pair { key, value: None } => format!("{key}\u{1}"),
4169                })
4170                .collect()
4171        })
4172        .unwrap_or_default()
4173}
4174
4175fn parse_config_parameters_strict(raw: &str) -> Result<Vec<ConfigParameter>> {
4176    let mut out: Vec<ConfigParameter> = Vec::new();
4177    let chars: Vec<char> = raw.chars().collect();
4178    let mut idx = skip_config_parameter_spaces(&chars, 0);
4179
4180    while idx < chars.len() {
4181        let (key, next) = sq_dequote_step_chars(&chars, idx)?;
4182        let Some(next_idx) = next else {
4183            out.push(ConfigParameter::OldStyle(key));
4184            break;
4185        };
4186
4187        if chars[next_idx].is_whitespace() {
4188            out.push(ConfigParameter::OldStyle(key));
4189            idx = skip_config_parameter_spaces(&chars, next_idx);
4190            continue;
4191        }
4192
4193        if chars[next_idx] != '=' {
4194            return Err(Error::Config(ConfigError::MalformedConfigParameters));
4195        }
4196
4197        let value_start = next_idx + 1;
4198        if value_start >= chars.len() || chars[value_start].is_whitespace() {
4199            out.push(ConfigParameter::Pair { key, value: None });
4200            idx = skip_config_parameter_spaces(&chars, value_start);
4201            continue;
4202        }
4203
4204        if chars[value_start] != '\'' {
4205            return Err(Error::Config(ConfigError::MalformedConfigParameters));
4206        }
4207        let (value, value_next) = sq_dequote_step_chars(&chars, value_start)?;
4208        if let Some(value_next) = value_next {
4209            if !chars[value_next].is_whitespace() {
4210                return Err(Error::Config(ConfigError::MalformedConfigParameters));
4211            }
4212            idx = skip_config_parameter_spaces(&chars, value_next);
4213        } else {
4214            idx = chars.len();
4215        }
4216        out.push(ConfigParameter::Pair {
4217            key,
4218            value: Some(value),
4219        });
4220    }
4221
4222    Ok(out)
4223}
4224
4225fn skip_config_parameter_spaces(chars: &[char], mut idx: usize) -> usize {
4226    while idx < chars.len() && chars[idx].is_whitespace() {
4227        idx += 1;
4228    }
4229    idx
4230}
4231
4232fn sq_dequote_step_chars(chars: &[char], start: usize) -> Result<(String, Option<usize>)> {
4233    if chars.get(start) != Some(&'\'') {
4234        return Err(Error::Config(ConfigError::MalformedConfigParameters));
4235    }
4236
4237    let mut out = String::new();
4238    let mut idx = start + 1;
4239    loop {
4240        let Some(&ch) = chars.get(idx) else {
4241            return Err(Error::Config(ConfigError::MalformedConfigParameters));
4242        };
4243        if ch != '\'' {
4244            out.push(ch);
4245            idx += 1;
4246            continue;
4247        }
4248
4249        idx += 1;
4250        match chars.get(idx).copied() {
4251            None => return Ok((out, None)),
4252            Some('\\')
4253                if chars
4254                    .get(idx + 1)
4255                    .copied()
4256                    .is_some_and(needs_sq_backslash_quote)
4257                    && chars.get(idx + 2) == Some(&'\'') =>
4258            {
4259                if let Some(escaped) = chars.get(idx + 1) {
4260                    out.push(*escaped);
4261                }
4262                idx += 3;
4263            }
4264            _ => return Ok((out, Some(idx))),
4265        }
4266    }
4267}
4268
4269fn needs_sq_backslash_quote(ch: char) -> bool {
4270    ch == '\'' || ch == '!'
4271}
4272
4273/// Resolve a config file path from `GIT_CONFIG*` overrides relative to [`Environment::cwd`].
4274pub(crate) fn resolve_config_override_path(env: &Environment, path: &str) -> PathBuf {
4275    let p = Path::new(path);
4276    if p.is_absolute() {
4277        p.to_path_buf()
4278    } else {
4279        env.cwd.join(p)
4280    }
4281}
4282
4283/// Return candidate paths for the global config file, in priority order.
4284/// Public accessor for the ordered list of global config file paths.
4285pub fn global_config_paths_pub(env: &Environment) -> Vec<PathBuf> {
4286    global_config_paths(env)
4287}
4288
4289fn global_config_paths(env: &Environment) -> Vec<PathBuf> {
4290    let mut paths = Vec::new();
4291
4292    // $GIT_CONFIG_GLOBAL overrides
4293    if let Some(p) = env.git_config_global.as_deref() {
4294        paths.push(resolve_config_override_path(env, p));
4295        return paths;
4296    }
4297
4298    // Git order: XDG `git/config` first, then `~/.gitconfig` (see `git_global_config_paths`).
4299    if let Some(xdg) = env.xdg_config_home.as_deref() {
4300        paths.push(PathBuf::from(xdg).join("git/config"));
4301    } else if let Some(home) = home_dir(env) {
4302        paths.push(home.join(".config/git/config"));
4303    }
4304    if let Some(home) = home_dir(env) {
4305        paths.push(home.join(".gitconfig"));
4306    }
4307
4308    paths
4309}
4310
4311/// Path to the system-wide Git configuration file.
4312///
4313/// Honors `GIT_CONFIG_SYSTEM`. On Windows, falls back to Git for Windows'
4314/// `etc/gitconfig` when `/etc/gitconfig` is absent (stock shells, not MSYS).
4315#[must_use]
4316pub fn system_config_path(env: &Environment) -> PathBuf {
4317    if let Some(p) = env.git_config_system.as_deref() {
4318        return resolve_config_override_path(env, p);
4319    }
4320    let unix_default = PathBuf::from("/etc/gitconfig");
4321    #[cfg(windows)]
4322    {
4323        if unix_default.is_file() {
4324            return unix_default;
4325        }
4326        if let Some(root) = env.git_install_root.as_ref() {
4327            let candidate = PathBuf::from(root).join("etc").join("gitconfig");
4328            if candidate.is_file() {
4329                return candidate;
4330            }
4331        }
4332        for pf in [
4333            env.program_files.as_deref(),
4334            env.program_files_x86.as_deref(),
4335        ]
4336        .into_iter()
4337        .flatten()
4338        {
4339            let candidate = PathBuf::from(pf).join("Git").join("etc").join("gitconfig");
4340            if candidate.is_file() {
4341                return candidate;
4342            }
4343        }
4344    }
4345    unix_default
4346}
4347
4348/// Return the user's home directory.
4349///
4350/// `$HOME` is honored on every platform. On Windows, where `$HOME` is usually
4351/// unset outside a Git-for-Windows/MSYS shell, we fall back to the same sources
4352/// Git does: `%HOMEDRIVE%%HOMEPATH%`, then `%USERPROFILE%`. Without this, global
4353/// config (`~/.gitconfig`) would be invisible on a stock Windows shell.
4354fn home_dir(env: &Environment) -> Option<PathBuf> {
4355    if let Some(home) = env.home.as_ref().filter(|h| !h.is_empty()) {
4356        return Some(PathBuf::from(home));
4357    }
4358    #[cfg(windows)]
4359    {
4360        if let (Some(drive), Some(path)) = (
4361            env.homedrive.as_ref().filter(|d| !d.is_empty()),
4362            env.homepath.as_ref().filter(|p| !p.is_empty()),
4363        ) {
4364            let mut combined = drive.clone();
4365            combined.push(path);
4366            return Some(PathBuf::from(combined));
4367        }
4368        if let Some(profile) = env.userprofile.as_ref().filter(|p| !p.is_empty()) {
4369            return Some(PathBuf::from(profile));
4370        }
4371    }
4372    None
4373}
4374
4375/// True when Git would treat the config source as `CONFIG_ORIGIN_FILE` for includes.
4376fn include_source_is_disk_file(file: &ConfigFile) -> bool {
4377    file.include_origin == ConfigIncludeOrigin::Disk
4378}
4379
4380/// Resolve an include file path (Git `handle_path_include` semantics).
4381///
4382/// Relative paths are only allowed when the including config came from a real on-disk file.
4383/// Returns `None` for a relative include from command-line config when the context allows
4384/// skipping it.
4385fn resolve_include_file_path(
4386    path: &str,
4387    file: &ConfigFile,
4388    ctx: &IncludeContext,
4389) -> Result<Option<PathBuf>> {
4390    let expanded = parse_path(ctx.env.as_ref(), path);
4391    let p = Path::new(&expanded);
4392    if p.is_absolute() {
4393        return Ok(Some(p.to_path_buf()));
4394    }
4395    if !include_source_is_disk_file(file) {
4396        if file.include_origin == ConfigIncludeOrigin::CommandLine
4397            && !ctx.command_line_relative_include_is_error
4398        {
4399            return Ok(None);
4400        }
4401        return Err(Error::Config(ConfigError::RelativeIncludeOutsideFile));
4402    }
4403    let base = match file.path.parent() {
4404        Some(p) if !p.as_os_str().is_empty() => p,
4405        Some(_) | None => Path::new("."),
4406    };
4407    Ok(Some(base.join(p)))
4408}
4409
4410fn is_dir_sep(b: u8) -> bool {
4411    b == b'/' || b == b'\\'
4412}
4413
4414fn add_trailing_starstar_for_dir(pat: &mut String) {
4415    let bytes = pat.as_bytes();
4416    if bytes.last().is_some_and(|&b| is_dir_sep(b)) {
4417        pat.push_str("**");
4418    }
4419}
4420
4421/// Prepare a `gitdir:` / `gitdir/i:` pattern (Git `prepare_include_condition_pattern`).
4422///
4423/// Returns `None` when a relative (`./`) pattern cannot be anchored: the config did not come
4424/// from a file on disk, or its directory cannot be resolved.
4425fn prepare_gitdir_pattern(
4426    condition: &str,
4427    file: &ConfigFile,
4428    ctx: &IncludeContext,
4429) -> Option<(String, usize)> {
4430    // Git `interpolate_path`: expand `~/` in the condition before pattern rules.
4431    let mut pat = parse_path(ctx.env.as_ref(), condition);
4432    if pat.starts_with("./") || pat.starts_with(".\\") {
4433        let parent = file
4434            .path
4435            .parent()
4436            .filter(|_| include_source_is_disk_file(file))?;
4437        let real = parent.canonicalize_simplified().ok()?;
4438        let mut dir = real.to_string_lossy().into_owned();
4439        if !dir.ends_with('/') && !dir.ends_with('\\') {
4440            dir.push('/');
4441        }
4442        let rest = &pat[2..];
4443        pat = format!("{dir}{rest}");
4444        let prefix_len = dir.len();
4445        add_trailing_starstar_for_dir(&mut pat);
4446        return Some((pat, prefix_len));
4447    }
4448    let p = Path::new(&pat);
4449    if !p.is_absolute() {
4450        pat.insert_str(0, "**/");
4451    }
4452    add_trailing_starstar_for_dir(&mut pat);
4453    Some((pat, 0))
4454}
4455
4456/// Git `include_by_gitdir` tries `strbuf_realpath` first, then `strbuf_add_absolute_path` if no match.
4457///
4458/// `text_abs` uses `$PWD` (which preserves symlinks) when available, matching Git's
4459/// `strbuf_add_absolute_path` behaviour. This lets `gitdir:bar/` match when `bar` is a symlink.
4460fn git_dir_match_texts(ctx: &IncludeContext, git_dir: &Path) -> (String, String) {
4461    let real = git_dir
4462        .canonicalize_simplified()
4463        .map(|p| p.to_string_lossy().into_owned())
4464        .unwrap_or_else(|_| git_dir.to_string_lossy().into_owned());
4465    // Build the non-canonical absolute path using $PWD (symlink-preserving) when available.
4466    // Git C uses `strbuf_add_absolute_path` which prefers $PWD over getcwd() to preserve symlinks.
4467    let abs = if git_dir.is_absolute() {
4468        // If git_dir is already canonical, try to reconstruct the symlink-preserving variant
4469        // by replacing the canonical cwd prefix with $PWD.
4470        let pwd_abs = ctx.pwd.as_deref().and_then(|pwd| {
4471            let pwd_path = std::path::Path::new(pwd);
4472            if !pwd_path.is_absolute() {
4473                return None;
4474            }
4475            let pwd_canon = pwd_path.canonicalize_simplified().ok()?;
4476            let git_dir_str = git_dir.to_string_lossy();
4477            let pwd_canon_str = pwd_canon.to_string_lossy();
4478            // If git_dir starts with the canonical cwd, replace that prefix with $PWD
4479            let suffix = git_dir_str.strip_prefix(pwd_canon_str.as_ref())?;
4480            Some(format!("{pwd}{suffix}"))
4481        });
4482        pwd_abs.unwrap_or_else(|| git_dir.to_string_lossy().into_owned())
4483    } else {
4484        ctx.cwd.join(git_dir).to_string_lossy().into_owned()
4485    };
4486    (real, abs)
4487}
4488
4489fn include_by_gitdir(
4490    condition: &str,
4491    file: &ConfigFile,
4492    ctx: &IncludeContext,
4493    icase: bool,
4494) -> bool {
4495    let Some(git_dir) = ctx.git_dir.as_ref() else {
4496        return false;
4497    };
4498    let Some((pattern, prefix)) = prepare_gitdir_pattern(condition, file, ctx) else {
4499        return false;
4500    };
4501    let flags = WM_PATHNAME | if icase { WM_CASEFOLD } else { 0 };
4502    let (text_real, text_abs) = git_dir_match_texts(ctx, git_dir);
4503    let try_match = |text: &str| -> bool {
4504        let t = text.as_bytes();
4505        let p = pattern.as_bytes();
4506        if prefix > 0 {
4507            if t.len() < prefix {
4508                return false;
4509            }
4510            let pre = &p[..prefix];
4511            let te = &t[..prefix];
4512            let ok = if icase {
4513                pre.eq_ignore_ascii_case(te)
4514            } else {
4515                pre == te
4516            };
4517            if !ok {
4518                return false;
4519            }
4520            return wildmatch(&p[prefix..], &t[prefix..], flags);
4521        }
4522        wildmatch(p, t, flags)
4523    };
4524    if try_match(&text_real) {
4525        return true;
4526    }
4527    text_real != text_abs && try_match(&text_abs)
4528}
4529
4530fn current_branch_short_name(git_dir: Option<&Path>) -> Option<String> {
4531    let gd = git_dir?;
4532    let target = refs::read_symbolic_ref(gd, "HEAD").ok()??;
4533    let rest = target.strip_prefix("refs/heads/")?;
4534    Some(rest.to_owned())
4535}
4536
4537fn include_by_onbranch(condition: &str, ctx: &IncludeContext) -> bool {
4538    let Some(short) = current_branch_short_name(ctx.git_dir.as_deref()) else {
4539        return false;
4540    };
4541    let mut pattern = condition.to_owned();
4542    add_trailing_starstar_for_dir(&mut pattern);
4543    wildmatch(pattern.as_bytes(), short.as_bytes(), WM_PATHNAME)
4544}
4545
4546/// Whether `entry` is `remote.<name>.url` (keys are canonical: section and variable lowercase).
4547fn is_remote_url_entry(entry: &ConfigEntry) -> bool {
4548    entry
4549        .key
4550        .strip_prefix("remote.")
4551        .is_some_and(|rest| rest.ends_with(".url"))
4552}
4553
4554/// The URL glob of a `hasconfig:remote.*.url:<glob>` include condition.
4555fn hasconfig_remote_url_pattern(condition: &str) -> Option<&str> {
4556    condition.strip_prefix("hasconfig:remote.*.url:")
4557}
4558
4559/// Evaluate an `[includeIf]` condition.
4560///
4561/// Supports `gitdir:`, `gitdir/i:` and `onbranch:` like Git. `hasconfig:remote.*.url:` needs
4562/// the whole load's remote URLs and is evaluated by [`RemoteUrls::any_matches`]. Unknown
4563/// prefixes are false.
4564fn evaluate_include_condition(condition: &str, file: &ConfigFile, ctx: &IncludeContext) -> bool {
4565    if let Some(rest) = condition.strip_prefix("gitdir/i:") {
4566        return include_by_gitdir(rest, file, ctx, true);
4567    }
4568    if let Some(rest) = condition.strip_prefix("gitdir:") {
4569        return include_by_gitdir(rest, file, ctx, false);
4570    }
4571    if let Some(rest) = condition.strip_prefix("onbranch:") {
4572        return include_by_onbranch(rest, ctx);
4573    }
4574    false
4575}
4576
4577/// Split a canonical key into (section, subsection, variable).
4578fn split_key(key: &str) -> Result<(String, Option<String>, String)> {
4579    let (Some(first_dot), Some(last_dot)) = (key.find('.'), key.rfind('.')) else {
4580        return Err(Error::Config(ConfigError::InvalidKey {
4581            key: key.to_owned(),
4582            reason: InvalidKeyReason::MissingSection,
4583        }));
4584    };
4585
4586    let section = key[..first_dot].to_owned();
4587    let variable = key[last_dot + 1..].to_owned();
4588
4589    let subsection = if first_dot == last_dot {
4590        None
4591    } else {
4592        Some(key[first_dot + 1..last_dot].to_owned())
4593    };
4594
4595    Ok((section, subsection, variable))
4596}
4597
4598/// Parse a section name that may contain a subsection (e.g. `"remote.origin"`).
4599///
4600/// Returns (section, subsection).
4601fn parse_section_name(name: &str) -> (&str, Option<&str>) {
4602    match name.find('.') {
4603        Some(i) => (&name[..i], Some(&name[i + 1..])),
4604        None => (name, None),
4605    }
4606}
4607
4608fn section_matches(parser: &Parser, section_lower: &str, subsection: Option<&str>) -> bool {
4609    if parser.section.to_lowercase() == section_lower && parser.subsection.as_deref() == subsection
4610    {
4611        return true;
4612    }
4613    let Some(subsection) = subsection else {
4614        return false;
4615    };
4616    parser.subsection.is_none()
4617        && parser.section.to_lowercase() == format!("{section_lower}.{}", subsection.to_lowercase())
4618}
4619
4620/// Path to the lock file Git uses when updating a config file (`config.lock`).
4621#[must_use]
4622pub fn config_lock_path(path: &Path) -> PathBuf {
4623    let file_name = path
4624        .file_name()
4625        .and_then(|s| s.to_str())
4626        .unwrap_or("config");
4627    path.with_file_name(format!("{file_name}.lock"))
4628}
4629
4630/// Reject a section name whose section part is not ASCII letters, digits and `-`, or whose
4631/// subsection is present but empty. `name` is the caller's full input, kept for the error.
4632fn validate_section_name(name: &str, section: &str, subsection: Option<&str>) -> Result<()> {
4633    if section.is_empty()
4634        || !section
4635            .chars()
4636            .all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
4637        || subsection.is_some_and(str::is_empty)
4638    {
4639        return Err(Error::Config(ConfigError::InvalidSectionName {
4640            section: name.to_owned(),
4641        }));
4642    }
4643    Ok(())
4644}
4645
4646/// Extract the original-case variable name from a raw (user-typed) key.
4647///
4648/// E.g. `"Section.Movie"` → `"Movie"`, `"a.b.CamelCase"` → `"CamelCase"`.
4649fn raw_variable_name(raw_key: &str) -> &str {
4650    match raw_key.rfind('.') {
4651        Some(i) => &raw_key[i + 1..],
4652        None => raw_key,
4653    }
4654}
4655
4656/// Extract the original-case section and subsection from a raw (user-typed) key.
4657///
4658/// E.g. `"Section.key"` → `("Section", None)`,
4659///      `"Remote.origin.url"` → `("Remote", Some("origin"))`.
4660fn raw_section_parts(raw_key: &str) -> (String, Option<String>) {
4661    let first_dot = match raw_key.find('.') {
4662        Some(i) => i,
4663        None => return (raw_key.to_owned(), None),
4664    };
4665    // rfind always succeeds here since we already found at least one dot above.
4666    let last_dot = match raw_key.rfind('.') {
4667        Some(i) => i,
4668        None => return (raw_key[..first_dot].to_owned(), None),
4669    };
4670    let section = raw_key[..first_dot].to_owned();
4671    if first_dot == last_dot {
4672        (section, None)
4673    } else {
4674        let subsection = raw_key[first_dot + 1..last_dot].to_owned();
4675        (section, Some(subsection))
4676    }
4677}
4678
4679/// Check if a raw line is a section header that also contains an inline key=value.
4680fn is_section_header_with_inline_entry(line: &str) -> bool {
4681    let trimmed = line.trim();
4682    if !trimmed.starts_with('[') {
4683        return false;
4684    }
4685    let end = match trimmed.find(']') {
4686        Some(i) => i,
4687        None => return false,
4688    };
4689    let after = trimmed[end + 1..].trim();
4690    // Has non-comment content after the ]
4691    !after.is_empty() && !after.starts_with('#') && !after.starts_with(';')
4692}
4693
4694/// Extract just the section header portion (up to and including `]` and any
4695/// comment after it, but not any inline key=value) from a raw line.
4696fn extract_section_header(line: &str) -> String {
4697    let trimmed = line.trim();
4698    let end = match trimmed.find(']') {
4699        Some(i) => i,
4700        None => return line.to_owned(),
4701    };
4702    // Preserve any comment on the section header itself (between ] and key),
4703    // but git doesn't really do this. Just return up to ].
4704    trimmed[..=end].to_owned()
4705}
4706
4707#[cfg(test)]
4708mod pack_object_config_tests {
4709    use super::{ConfigFile, ConfigScope, ConfigSet};
4710    use std::path::Path;
4711
4712    fn set_from_snippet(text: &str) -> ConfigSet {
4713        let path = Path::new(".git/config");
4714        let file = ConfigFile::parse(path, text, ConfigScope::Local).expect("parse config snippet");
4715        let mut set = ConfigSet::new();
4716        set.merge(&file);
4717        set
4718    }
4719
4720    #[test]
4721    fn pack_object_window_and_depth_defaults() {
4722        let set = ConfigSet::new();
4723        assert_eq!(set.pack_object_window(), 10);
4724        assert_eq!(set.pack_object_depth(), crate::pack::DEFAULT_PACK_DEPTH);
4725    }
4726
4727    #[test]
4728    fn pack_object_window_and_depth_preserve_explicit_zero() {
4729        let set = set_from_snippet("[pack]\n\twindow = 0\n\tdepth = 0\n");
4730        assert_eq!(set.pack_object_window(), 0);
4731        assert_eq!(set.pack_object_depth(), 0);
4732    }
4733
4734    #[test]
4735    fn gc_auto_limits_from_config() {
4736        let set = set_from_snippet("[gc]\n\tauto = 100\n\tautoPackLimit = 3\n");
4737        assert_eq!(set.gc_auto_threshold(), Some(100));
4738        assert_eq!(set.gc_auto_pack_limit(), Some(3));
4739        let defaults = ConfigSet::new();
4740        assert_eq!(defaults.gc_auto_threshold(), Some(6700));
4741        assert_eq!(defaults.gc_auto_pack_limit(), Some(50));
4742        assert!(defaults.gc_auto_detach());
4743        assert!(defaults.maintenance_auto());
4744        let disabled = set_from_snippet("[gc]\n\tautoPackLimit = 0\n");
4745        assert_eq!(disabled.gc_auto_pack_limit(), None);
4746        assert_eq!(disabled.gc_auto_threshold(), Some(6700));
4747        let auto_off = set_from_snippet("[gc]\n\tauto = 0\n");
4748        assert_eq!(auto_off.gc_auto_threshold(), None);
4749        let negative = set_from_snippet("[gc]\n\tauto = -1\n\tautoPackLimit = -1\n");
4750        assert_eq!(negative.gc_auto_threshold(), None);
4751        assert_eq!(negative.gc_auto_pack_limit(), None);
4752        let unparsable = set_from_snippet("[gc]\n\tauto = lots\n");
4753        assert_eq!(unparsable.gc_auto_threshold(), Some(6700));
4754        let foreground =
4755            set_from_snippet("[gc]\n\tautoDetach = false\n[maintenance]\n\tauto = false\n");
4756        assert!(!foreground.gc_auto_detach());
4757        assert!(!foreground.maintenance_auto());
4758    }
4759}
4760
4761#[cfg(test)]
4762mod value_error_tests {
4763    use super::{parse_bool, parse_color, parse_i64, ConfigFile, ConfigScope, ConfigSet};
4764    use crate::error::{ConfigError, ConfigValueError, Error};
4765    use std::path::Path;
4766
4767    fn set_from_snippet(text: &str) -> ConfigSet {
4768        let file = ConfigFile::parse(Path::new(".git/config"), text, ConfigScope::Local)
4769            .expect("parse config snippet");
4770        let mut set = ConfigSet::new();
4771        set.merge(&file);
4772        set
4773    }
4774
4775    #[test]
4776    fn parse_bool_rejects_non_boolean_with_the_value() {
4777        assert_eq!(
4778            parse_bool("maybe"),
4779            Err(ConfigValueError::InvalidBool {
4780                value: "maybe".to_owned()
4781            })
4782        );
4783        assert_eq!(parse_bool("2"), Ok(true));
4784        assert_eq!(parse_bool(""), Ok(false));
4785    }
4786
4787    #[test]
4788    fn parse_i64_distinguishes_empty_invalid_and_overflow() {
4789        assert_eq!(parse_i64("  "), Err(ConfigValueError::EmptyInteger));
4790        assert_eq!(
4791            parse_i64(" 12x "),
4792            Err(ConfigValueError::InvalidInteger {
4793                value: "12x".to_owned()
4794            })
4795        );
4796        assert_eq!(
4797            parse_i64("9223372036854775807k"),
4798            Err(ConfigValueError::IntegerOverflow {
4799                value: "9223372036854775807k".to_owned()
4800            })
4801        );
4802        assert_eq!(parse_i64("2m"), Ok(2 * 1024 * 1024));
4803    }
4804
4805    #[test]
4806    fn parse_color_reports_the_trimmed_value() {
4807        let bad = ConfigValueError::InvalidColor {
4808            value: "red green blue".to_owned(),
4809        };
4810        assert_eq!(parse_color(" red green blue "), Err(bad));
4811        assert_eq!(
4812            parse_color("nonsense").map_err(|e| e.to_string()),
4813            Err("bad color value 'nonsense'".to_owned())
4814        );
4815        assert_eq!(parse_color("  "), Ok(String::new()));
4816    }
4817
4818    #[test]
4819    fn get_bool_and_get_i64_name_the_key() {
4820        let set = set_from_snippet("[core]\n\tbare = sometimes\n[pack]\n\tthreads = many\n");
4821        let bool_err = set
4822            .get_bool("core.bare")
4823            .and_then(Result::err)
4824            .expect("invalid bool");
4825        assert_eq!(
4826            bool_err,
4827            ConfigError::InvalidValue {
4828                key: "core.bare".to_owned(),
4829                source: ConfigValueError::InvalidBool {
4830                    value: "sometimes".to_owned()
4831                },
4832            }
4833        );
4834        assert_eq!(
4835            bool_err.to_string(),
4836            "bad boolean config value 'sometimes' for 'core.bare'"
4837        );
4838        let int_err = set
4839            .get_i64("pack.threads")
4840            .and_then(Result::err)
4841            .expect("invalid int");
4842        assert!(matches!(
4843            &int_err,
4844            ConfigError::InvalidValue { key, source: ConfigValueError::InvalidInteger { value } }
4845                if key == "pack.threads" && value == "many"
4846        ));
4847        assert!(matches!(Error::from(int_err), Error::Config(_)));
4848        assert!(set.get_bool("core.missing").is_none());
4849    }
4850
4851    #[test]
4852    fn typed_readers_match_any_casing_of_the_key() {
4853        let set = set_from_snippet(
4854            "[CORE]\n\tQUOTEPATH = false\n[pack]\n\twritebitmaphashcache = false\n\tREADREVERSEINDEX = false\n",
4855        );
4856        assert!(!set.quote_path_fully());
4857        assert!(!set.pack_write_bitmap_hash_cache());
4858        assert!(!set.pack_read_reverse_index_default());
4859        assert_eq!(set.get_bool("Core.QuotePath"), Some(Ok(false)));
4860    }
4861
4862    #[test]
4863    fn bool_or_uses_the_default_only_when_unset_or_invalid() {
4864        let set = set_from_snippet(
4865            "[core]\n\tbare = sometimes\n\tlogAllRefUpdates\n[feature]\n\tmanyFiles = off\n",
4866        );
4867        for default in [false, true] {
4868            assert_eq!(set.bool_or("core.missing", default), default);
4869            assert_eq!(set.bool_or("core.bare", default), default);
4870            assert!(set.bool_or("core.logallrefupdates", default));
4871            assert!(!set.bool_or("FEATURE.MANYFILES", default));
4872        }
4873    }
4874
4875    #[test]
4876    fn core_bool_accessors_use_git_defaults() {
4877        let unset = set_from_snippet("");
4878        assert!(!unset.ignore_case());
4879        assert!(!unset.precompose_unicode());
4880        assert!(unset.preload_index());
4881        assert!(unset.file_mode());
4882
4883        let flipped = set_from_snippet(
4884            "[core]\n\tignorecase = true\n\tprecomposeunicode = yes\n\tpreloadindex = 0\n\tfilemode = off\n",
4885        );
4886        assert!(flipped.ignore_case());
4887        assert!(flipped.precompose_unicode());
4888        assert!(!flipped.preload_index());
4889        assert!(!flipped.file_mode());
4890
4891        let invalid = set_from_snippet(
4892            "[core]\n\tignoreCase = maybe\n\tprecomposeUnicode = maybe\n\tpreloadIndex = maybe\n\tfileMode = maybe\n",
4893        );
4894        assert!(!invalid.ignore_case());
4895        assert!(!invalid.precompose_unicode());
4896        assert!(invalid.preload_index());
4897        assert!(invalid.file_mode());
4898    }
4899
4900    #[test]
4901    fn for_key_keeps_the_source_error() {
4902        let err = ConfigValueError::EmptyInteger.for_key("gc.auto");
4903        assert_eq!(err.to_string(), "empty integer value for 'gc.auto'");
4904        let source = std::error::Error::source(&err).map(ToString::to_string);
4905        assert_eq!(source.as_deref(), Some("empty integer value"));
4906    }
4907}
4908
4909#[cfg(test)]
4910mod get_regexp_tests {
4911    use super::{ConfigFile, ConfigScope, ConfigSet};
4912    use std::path::Path;
4913
4914    fn set_from_snippet(text: &str) -> ConfigSet {
4915        let path = Path::new(".git/config");
4916        let file = ConfigFile::parse(path, text, ConfigScope::Local).expect("parse config snippet");
4917        let mut set = ConfigSet::new();
4918        set.merge(&file);
4919        set
4920    }
4921
4922    #[test]
4923    fn get_regexp_matches_section_prefix_like_git_config() {
4924        let text = r#"
4925[user]
4926    email = alice@example.com
4927    name = Alice
4928[core]
4929    bare = false
4930"#;
4931        let set = set_from_snippet(text);
4932        let keys: Vec<_> = set
4933            .get_regexp("user")
4934            .expect("valid pattern")
4935            .into_iter()
4936            .map(|e| e.key.as_str())
4937            .collect();
4938        assert!(keys.contains(&"user.email"));
4939        assert!(keys.contains(&"user.name"));
4940        assert!(!keys.iter().any(|k| k.starts_with("core.")));
4941    }
4942
4943    #[test]
4944    fn get_regexp_returns_all_multi_value_entries_in_order() {
4945        let text = r#"
4946[remote "origin"]
4947    url = https://example.com/repo.git
4948    fetch = +refs/heads/*:refs/remotes/origin/*
4949    push = +refs/heads/main:refs/heads/main
4950    push = +refs/heads/develop:refs/heads/develop
4951"#;
4952        let set = set_from_snippet(text);
4953        let matches = set.get_regexp("remote.origin").expect("valid pattern");
4954        let push_vals: Vec<_> = matches
4955            .iter()
4956            .filter(|e| e.key == "remote.origin.push")
4957            .map(|e| e.value.as_deref().unwrap_or(""))
4958            .collect();
4959        assert_eq!(push_vals.len(), 2);
4960        assert_eq!(push_vals[0], "+refs/heads/main:refs/heads/main");
4961        assert_eq!(push_vals[1], "+refs/heads/develop:refs/heads/develop");
4962    }
4963
4964    #[test]
4965    fn get_regexp_dot_matches_any_key() {
4966        let text = r#"
4967[a]
4968    x = 1
4969[b]
4970    y = 2
4971"#;
4972        let set = set_from_snippet(text);
4973        let m = set.get_regexp(".").expect("valid pattern");
4974        assert_eq!(m.len(), 2);
4975    }
4976
4977    #[test]
4978    fn get_regexp_no_match_returns_empty_vec() {
4979        let set = set_from_snippet("[user]\n\tname = x\n");
4980        let m = set.get_regexp("zzz").expect("valid pattern");
4981        assert!(m.is_empty());
4982    }
4983
4984    #[test]
4985    fn get_regexp_invalid_pattern_is_error() {
4986        let set = set_from_snippet("[user]\n\tname = x\n");
4987        let err = set.get_regexp("(").expect_err("unclosed group");
4988        assert!(err.contains("invalid key pattern"), "got: {err}");
4989    }
4990
4991    #[test]
4992    fn valueless_negotiation_algorithm_is_a_typed_config_error() {
4993        use crate::error::{ConfigError, Error};
4994
4995        let path = Path::new("/repo/.git/config");
4996        for (text, line) in [
4997            (
4998                "[core]\n\tbare = false\n[fetch]\n\tnegotiationAlgorithm\n",
4999                4,
5000            ),
5001            ("[fetch] negotiationAlgorithm\n", 1),
5002        ] {
5003            let err = ConfigFile::parse(path, text, ConfigScope::Local).expect_err(text);
5004            let Error::Config(config_err) = err else {
5005                panic!("expected a config error for {text:?}, got {err:?}");
5006            };
5007            assert_eq!(
5008                config_err,
5009                ConfigError::BadConfigVariable {
5010                    key: "fetch.negotiationalgorithm".to_owned(),
5011                    file: ".git/config".to_owned(),
5012                    line,
5013                },
5014                "{text:?}"
5015            );
5016        }
5017
5018        let ok = ConfigFile::parse(
5019            path,
5020            "[fetch]\n\tnegotiationAlgorithm = skipping\n",
5021            ConfigScope::Local,
5022        )
5023        .expect("valued key parses");
5024        assert_eq!(ok.entries[0].value.as_deref(), Some("skipping"));
5025    }
5026}
5027
5028#[cfg(test)]
5029mod loose_compression_tests {
5030    use std::path::Path;
5031
5032    use super::{ConfigFile, ConfigScope, ConfigSet, Error};
5033
5034    fn set_from_snippet(text: &str) -> ConfigSet {
5035        let path = Path::new(".git/config");
5036        let file = ConfigFile::parse(path, text, ConfigScope::Local).expect("parse config snippet");
5037        let mut set = ConfigSet::new();
5038        set.merge(&file);
5039        set
5040    }
5041
5042    #[test]
5043    fn big_file_threshold_default_suffixes_and_errors() {
5044        assert_eq!(
5045            ConfigSet::new().big_file_threshold().unwrap(),
5046            512 * 1024 * 1024
5047        );
5048        let cases = [
5049            ("1000", 1000),
5050            ("0", 0),
5051            ("4k", 4096),
5052            ("2m", 2 * 1024 * 1024),
5053            ("1g", 1024 * 1024 * 1024),
5054            ("8G", 8 * 1024 * 1024 * 1024),
5055        ];
5056        for (raw, want) in cases {
5057            let set = set_from_snippet(&format!("[core]\n\tbigFileThreshold = {raw}\n"));
5058            assert_eq!(set.big_file_threshold().unwrap(), want, "value {raw}");
5059        }
5060        let set = set_from_snippet("[core]\n\tbigfilethreshold = 1k\n\tbigFileThreshold = 3k\n");
5061        assert_eq!(set.big_file_threshold().unwrap(), 3 * 1024);
5062
5063        for raw in ["-1", "12x", "lots", ""] {
5064            let snippet = if raw.is_empty() {
5065                "[core]\n\tbigFileThreshold\n".to_owned()
5066            } else {
5067                format!("[core]\n\tbigFileThreshold = {raw}\n")
5068            };
5069            let err = set_from_snippet(&snippet).big_file_threshold().unwrap_err();
5070            assert!(
5071                matches!(
5072                    err,
5073                    Error::Config(super::ConfigError::BadNumericValue { ref key, .. })
5074                        if key == "core.bigfilethreshold"
5075                ),
5076                "value {raw:?}: {err:?}"
5077            );
5078        }
5079    }
5080
5081    #[test]
5082    fn loose_compression_cascade() {
5083        let set = ConfigSet::new();
5084        assert_eq!(
5085            set.loose_objects_zlib_level().unwrap(),
5086            ConfigSet::LOOSE_OBJECTS_ZLIB_DEFAULT
5087        );
5088
5089        let set = set_from_snippet("[core]\n\tcompression = 0\n");
5090        assert_eq!(set.loose_objects_zlib_level().unwrap(), 0);
5091
5092        let set = set_from_snippet("[core]\n\tcompression = 9\n");
5093        assert_eq!(set.loose_objects_zlib_level().unwrap(), 9);
5094
5095        let set = set_from_snippet("[core]\n\tcompression = 0\n\tloosecompression = 9\n");
5096        assert_eq!(set.loose_objects_zlib_level().unwrap(), 9);
5097
5098        let set = set_from_snippet("[core]\n\tloosecompression = 3\n");
5099        assert_eq!(set.loose_objects_zlib_level().unwrap(), 3);
5100
5101        let set = set_from_snippet("[core]\n\tcompression = -1\n");
5102        assert_eq!(set.loose_objects_zlib_level().unwrap(), 6);
5103
5104        for level in 0..=9 {
5105            let set = set_from_snippet(&format!("[core]\n\tloosecompression = {level}\n"));
5106            assert_eq!(set.loose_objects_zlib_level().unwrap(), level);
5107        }
5108
5109        let set = set_from_snippet("[core]\n\tloosecompression = -1\n");
5110        assert_eq!(set.loose_objects_zlib_level().unwrap(), 6);
5111
5112        let err = set_from_snippet("[core]\n\tloosecompression = 10\n")
5113            .loose_objects_zlib_level()
5114            .unwrap_err();
5115        assert!(matches!(err, Error::Config(_)));
5116
5117        let err = set_from_snippet("[core]\n\tloosecompression = not-a-number\n")
5118            .loose_objects_zlib_level()
5119            .unwrap_err();
5120        assert!(matches!(err, Error::Config(_)));
5121
5122        let err = set_from_snippet("[core]\n\tcompression = 42\n")
5123            .loose_objects_zlib_level()
5124            .unwrap_err();
5125        assert!(matches!(err, Error::Config(_)));
5126
5127        let err = set_from_snippet("[core]\n\tcompression = nope\n\tloosecompression = 1\n")
5128            .loose_objects_zlib_level()
5129            .unwrap_err();
5130        assert!(matches!(err, Error::Config(_)));
5131
5132        let err = set_from_snippet("[core]\n\tloosecompression = nope\n\tloosecompression = 1\n")
5133            .loose_objects_zlib_level()
5134            .unwrap_err();
5135        assert!(matches!(err, Error::Config(_)));
5136
5137        let set = set_from_snippet("[core]\n\tloosecompression = 1\n\tcompression = 9\n");
5138        assert_eq!(set.loose_objects_zlib_level().unwrap(), 1);
5139
5140        let err = set_from_snippet("[core]\n\tloosecompression\n")
5141            .loose_objects_zlib_level()
5142            .unwrap_err();
5143        assert!(matches!(err, Error::Config(_)));
5144    }
5145}
5146
5147#[cfg(test)]
5148mod pack_compression_tests {
5149    use super::{ConfigFile, ConfigScope, ConfigSet};
5150    use std::path::Path;
5151
5152    fn set_from_snippet(text: &str) -> ConfigSet {
5153        let path = Path::new(".git/config");
5154        let file = ConfigFile::parse(path, text, ConfigScope::Local).expect("parse config snippet");
5155        let mut set = ConfigSet::new();
5156        set.merge(&file);
5157        set
5158    }
5159
5160    #[test]
5161    fn pack_objects_zlib_level_defaults_to_six() {
5162        let set = ConfigSet::new();
5163        assert_eq!(set.pack_objects_zlib_level().unwrap(), 6);
5164    }
5165
5166    #[test]
5167    fn pack_objects_zlib_level_core_compression() {
5168        let set = set_from_snippet("[core]\n\tcompression = 0\n");
5169        assert_eq!(set.pack_objects_zlib_level().unwrap(), 0);
5170        let set = set_from_snippet("[core]\n\tcompression = 9\n");
5171        assert_eq!(set.pack_objects_zlib_level().unwrap(), 9);
5172    }
5173
5174    #[test]
5175    fn pack_objects_zlib_level_pack_overrides_core() {
5176        let set = set_from_snippet("[core]\n\tcompression = 9\n[pack]\n\tcompression = 0\n");
5177        assert_eq!(set.pack_objects_zlib_level().unwrap(), 0);
5178        let set = set_from_snippet("[core]\n\tcompression = 0\n[pack]\n\tcompression = 9\n");
5179        assert_eq!(set.pack_objects_zlib_level().unwrap(), 9);
5180    }
5181
5182    #[test]
5183    fn pack_objects_zlib_level_later_core_does_not_override_earlier_pack() {
5184        let mut set = ConfigSet::new();
5185        set.merge(
5186            &ConfigFile::parse(
5187                Path::new("a"),
5188                "[pack]\n\tcompression = 9\n",
5189                ConfigScope::Local,
5190            )
5191            .unwrap(),
5192        );
5193        set.merge(
5194            &ConfigFile::parse(
5195                Path::new("b"),
5196                "[core]\n\tcompression = 0\n",
5197                ConfigScope::Local,
5198            )
5199            .unwrap(),
5200        );
5201        assert_eq!(set.pack_objects_zlib_level().unwrap(), 9);
5202    }
5203
5204    #[test]
5205    fn pack_objects_zlib_level_loosecompression_does_not_block_core_pack_level() {
5206        let set = set_from_snippet("[core]\n\tloosecompression = 1\n\tcompression = 0\n");
5207        assert_eq!(set.pack_objects_zlib_level().unwrap(), 0);
5208    }
5209
5210    #[test]
5211    fn pack_objects_zlib_level_pack_wins_after_loose_and_core() {
5212        let set = set_from_snippet(
5213            "[core]\n\tloosecompression = 1\n\tcompression = 0\n[pack]\n\tcompression = 9\n",
5214        );
5215        assert_eq!(set.pack_objects_zlib_level().unwrap(), 9);
5216    }
5217}
5218
5219#[cfg(test)]
5220mod config_cache_tests {
5221    use super::*;
5222    use filetime::FileTime;
5223
5224    fn local_opts(git_dir: &Path) -> LoadConfigOptions {
5225        LoadConfigOptions {
5226            include_system: false,
5227            include_ctx: IncludeContext {
5228                git_dir: Some(git_dir.to_path_buf()),
5229                ..Default::default()
5230            },
5231            ..Default::default()
5232        }
5233    }
5234
5235    fn load_value(caches: &crate::repo_caches::RepoCaches, git_dir: &Path) -> Option<String> {
5236        let opts = local_opts(git_dir);
5237        let set = caches
5238            .load_config_with_options(&Environment::empty(), Some(git_dir), &opts)
5239            .expect("load cascade");
5240        set.get("gritcachetest.value")
5241    }
5242
5243    fn mtime_of(path: &Path) -> FileTime {
5244        FileTime::from_last_modification_time(&fs::metadata(path).expect("stat config"))
5245    }
5246
5247    fn restore_mtime(path: &Path, stamp: FileTime) {
5248        filetime::set_file_mtime(path, stamp).expect("restore mtime");
5249    }
5250
5251    #[test]
5252    fn cache_serves_same_stamp_and_config_write_evicts() {
5253        let caches =
5254            crate::repo_caches::RepoCaches::new(crate::command_runner::system_command_runner());
5255        let td = tempfile::tempdir().expect("tempdir");
5256        let gd = td.path();
5257        let cfg = gd.join("config");
5258        fs::write(&cfg, "[gritcachetest]\n\tvalue = aaa\n").expect("write v1");
5259        let t0 = mtime_of(&cfg);
5260        assert_eq!(load_value(&caches, gd).as_deref(), Some("aaa"));
5261
5262        // Same size + restored mtime: indistinguishable by stat, so the cache
5263        // serves the old parse. (This is the window explicit eviction closes
5264        // for in-process writers; C git would not re-read at all.)
5265        fs::write(&cfg, "[gritcachetest]\n\tvalue = bbb\n").expect("write v2");
5266        restore_mtime(&cfg, t0);
5267        assert_eq!(load_value(&caches, gd).as_deref(), Some("aaa"));
5268
5269        // An in-process `ConfigFile::write` plus eviction sees new content even with identical stamps.
5270        let mut file = ConfigFile::from_path(&cfg, ConfigScope::Local)
5271            .expect("read config")
5272            .expect("config exists");
5273        file.set("gritcachetest.value", "ccc").expect("set value");
5274        file.write().expect("persist");
5275        caches.evict_config_for_path(&cfg);
5276        restore_mtime(&cfg, t0);
5277        assert_eq!(load_value(&caches, gd).as_deref(), Some("ccc"));
5278    }
5279
5280    #[test]
5281    fn cache_invalidates_on_size_or_existence_change() {
5282        let caches =
5283            crate::repo_caches::RepoCaches::new(crate::command_runner::system_command_runner());
5284        let td = tempfile::tempdir().expect("tempdir");
5285        let gd = td.path();
5286        let cfg = gd.join("config");
5287        // Absent local config is cached as "absent"...
5288        assert_eq!(load_value(&caches, gd), None);
5289        // ...and creating the file is a stamp change (None -> Some).
5290        fs::write(&cfg, "[gritcachetest]\n\tvalue = first\n").expect("create");
5291        assert_eq!(load_value(&caches, gd).as_deref(), Some("first"));
5292        // A size change invalidates even with a restored mtime.
5293        let t0 = mtime_of(&cfg);
5294        fs::write(&cfg, "[gritcachetest]\n\tvalue = second-longer\n").expect("rewrite");
5295        restore_mtime(&cfg, t0);
5296        assert_eq!(load_value(&caches, gd).as_deref(), Some("second-longer"));
5297    }
5298
5299    #[test]
5300    fn include_targets_are_stamped_and_invalidate() {
5301        let caches =
5302            crate::repo_caches::RepoCaches::new(crate::command_runner::system_command_runner());
5303        let td = tempfile::tempdir().expect("tempdir");
5304        let gd = td.path();
5305        fs::write(gd.join("config"), "[include]\n\tpath = extra.conf\n").expect("write parent");
5306        let inc = gd.join("extra.conf");
5307        fs::write(&inc, "[gritcachetest]\n\tvalue = one\n").expect("write include v1");
5308        assert_eq!(load_value(&caches, gd).as_deref(), Some("one"));
5309
5310        // Same-size + restored-mtime rewrite of only the included file: the
5311        // parse result is served from cache (proving the include target is
5312        // stamped rather than re-read)...
5313        let t0 = mtime_of(&inc);
5314        fs::write(&inc, "[gritcachetest]\n\tvalue = two\n").expect("write include v2");
5315        restore_mtime(&inc, t0);
5316        assert_eq!(load_value(&caches, gd).as_deref(), Some("one"));
5317
5318        // ...while a stat-visible change to the included file invalidates.
5319        fs::write(&inc, "[gritcachetest]\n\tvalue = two-longer\n").expect("write include v3");
5320        assert_eq!(load_value(&caches, gd).as_deref(), Some("two-longer"));
5321    }
5322
5323    #[test]
5324    fn missing_include_target_is_watched() {
5325        let caches =
5326            crate::repo_caches::RepoCaches::new(crate::command_runner::system_command_runner());
5327        let td = tempfile::tempdir().expect("tempdir");
5328        let gd = td.path();
5329        fs::write(gd.join("config"), "[include]\n\tpath = extra.conf\n").expect("write parent");
5330        assert_eq!(load_value(&caches, gd), None);
5331
5332        // The resolved-but-missing target was stamped as absent; creating it
5333        // must invalidate the cached parse.
5334        fs::write(gd.join("extra.conf"), "[gritcachetest]\n\tvalue = born\n").expect("create");
5335        assert_eq!(load_value(&caches, gd).as_deref(), Some("born"));
5336    }
5337
5338    #[test]
5339    fn onbranch_condition_follows_head() {
5340        let caches =
5341            crate::repo_caches::RepoCaches::new(crate::command_runner::system_command_runner());
5342        let td = tempfile::tempdir().expect("tempdir");
5343        let gd = td.path();
5344        fs::write(gd.join("HEAD"), "ref: refs/heads/main\n").expect("write HEAD");
5345        fs::write(
5346            gd.join("config"),
5347            "[includeIf \"onbranch:main\"]\n\tpath = branch.conf\n",
5348        )
5349        .expect("write config");
5350        fs::write(
5351            gd.join("branch.conf"),
5352            "[gritcachetest]\n\tvalue = onmain\n",
5353        )
5354        .expect("write branch config");
5355        assert_eq!(load_value(&caches, gd).as_deref(), Some("onmain"));
5356
5357        // Switching branches rewrites HEAD; the stamped HEAD must invalidate
5358        // the cached cascade so the condition is re-evaluated.
5359        fs::write(gd.join("HEAD"), "ref: refs/heads/dev\n").expect("rewrite HEAD");
5360        assert_eq!(load_value(&caches, gd), None);
5361    }
5362
5363    #[test]
5364    #[cfg(unix)]
5365    fn load_propagates_local_config_io_errors() {
5366        use std::os::unix::fs::symlink;
5367        let td = tempfile::tempdir().expect("tempdir");
5368        let git_dir = td.path();
5369        let config = git_dir.join("config");
5370        symlink("config", &config).expect("self-referential symlink");
5371        let err = ConfigSet::load(&Environment::empty(), Some(git_dir), false).unwrap_err();
5372        assert!(
5373            matches!(err, Error::Io(_)),
5374            "expected I/O error for symlink loop, got {err:?}"
5375        );
5376    }
5377}
5378
5379#[cfg(test)]
5380mod line_scan_tests {
5381    use super::*;
5382
5383    /// The char-at-a-time comment stripper the byte scan replaced, kept as the reference.
5384    fn strip_inline_comment_by_chars(s: &str) -> String {
5385        let mut in_quote = false;
5386        let mut last_was_backslash = false;
5387        let mut result = String::with_capacity(s.len());
5388        let mut chars = s.chars().peekable();
5389        while let Some(ch) = chars.next() {
5390            match ch {
5391                '"' if !last_was_backslash => {
5392                    in_quote = !in_quote;
5393                    result.push(ch);
5394                    last_was_backslash = false;
5395                }
5396                '\\' if !last_was_backslash => {
5397                    last_was_backslash = true;
5398                    result.push(ch);
5399                    if in_quote {
5400                        if let Some(&next) = chars.peek() {
5401                            result.push(next);
5402                            chars.next();
5403                            last_was_backslash = false;
5404                        }
5405                    }
5406                }
5407                '#' | ';' if !in_quote && !last_was_backslash => break,
5408                _ => {
5409                    last_was_backslash = false;
5410                    result.push(ch);
5411                }
5412            }
5413        }
5414        result.trim_end().to_owned()
5415    }
5416
5417    fn unclosed_quote_by_chars(line: &str) -> bool {
5418        let trimmed = line.trim();
5419        let Some(eq_pos) = trimmed.find('=') else {
5420            return false;
5421        };
5422        let mut in_quote = false;
5423        let mut last_was_backslash = false;
5424        for ch in trimmed[eq_pos + 1..].trim_start().chars() {
5425            match ch {
5426                '"' if !last_was_backslash => {
5427                    in_quote = !in_quote;
5428                    last_was_backslash = false;
5429                }
5430                '\\' if !last_was_backslash => {
5431                    last_was_backslash = true;
5432                    continue;
5433                }
5434                '#' | ';' if !in_quote && !last_was_backslash => return false,
5435                _ => last_was_backslash = false,
5436            }
5437        }
5438        in_quote
5439    }
5440
5441    fn continues_by_chars(line: &str) -> bool {
5442        let trimmed = line.trim();
5443        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
5444            return false;
5445        }
5446        let Some(pos) = trimmed.find('=') else {
5447            return false;
5448        };
5449        let mut in_quote = false;
5450        let mut last_was_backslash = false;
5451        let mut in_comment = false;
5452        for ch in trimmed[pos + 1..].chars() {
5453            if in_comment {
5454                last_was_backslash = false;
5455                continue;
5456            }
5457            match ch {
5458                '"' if !last_was_backslash => {
5459                    in_quote = !in_quote;
5460                    last_was_backslash = false;
5461                }
5462                '\\' if !last_was_backslash => {
5463                    last_was_backslash = true;
5464                    continue;
5465                }
5466                '#' | ';' if !in_quote && !last_was_backslash => {
5467                    in_comment = true;
5468                    last_was_backslash = false;
5469                }
5470                _ => last_was_backslash = false,
5471            }
5472        }
5473        last_was_backslash && !in_comment
5474    }
5475
5476    /// Every string of up to five tokens drawn from the characters the scanners treat
5477    /// specially, plus ASCII and multi-byte text and Unicode whitespace.
5478    fn corpus() -> Vec<String> {
5479        const TOKENS: [&str; 11] = [
5480            "a", "\"", "\\", "#", ";", " ", "=", "\t", "é", "日", "\u{2003}",
5481        ];
5482        let mut out = vec![String::new()];
5483        let mut frontier = vec![String::new()];
5484        for _ in 0..5 {
5485            let mut next = Vec::with_capacity(frontier.len() * TOKENS.len());
5486            for prefix in &frontier {
5487                for token in TOKENS {
5488                    next.push(format!("{prefix}{token}"));
5489                }
5490            }
5491            out.extend(next.iter().cloned());
5492            frontier = next;
5493        }
5494        out
5495    }
5496
5497    #[test]
5498    fn byte_scanners_match_char_scanners_on_every_short_line() {
5499        for s in corpus() {
5500            assert_eq!(
5501                strip_inline_comment(&s),
5502                strip_inline_comment_by_chars(&s),
5503                "strip_inline_comment({s:?})"
5504            );
5505            let line = format!("k = {s}");
5506            assert_eq!(
5507                entry_line_value_has_unclosed_quote(&line),
5508                unclosed_quote_by_chars(&line),
5509                "unclosed quote in {line:?}"
5510            );
5511            assert_eq!(
5512                value_line_continues(&line),
5513                continues_by_chars(&line),
5514                "continuation of {line:?}"
5515            );
5516            assert_eq!(
5517                entry_line_value_has_unclosed_quote(&s),
5518                unclosed_quote_by_chars(&s),
5519                "unclosed quote in {s:?}"
5520            );
5521            assert_eq!(
5522                value_line_continues(&s),
5523                continues_by_chars(&s),
5524                "continuation of {s:?}"
5525            );
5526        }
5527    }
5528
5529    #[test]
5530    fn strip_inline_comment_borrows_a_prefix() {
5531        let s = "\"a # b\" tail ; comment";
5532        let stripped = strip_inline_comment(s);
5533        assert_eq!(stripped, "\"a # b\" tail");
5534        assert_eq!(stripped.as_ptr(), s.as_ptr());
5535        assert_eq!(strip_inline_comment("\"\\\"#\" x"), "\"\\\"#\" x");
5536        assert_eq!(strip_inline_comment("v\\#kept # gone"), "v\\#kept");
5537    }
5538
5539    #[test]
5540    fn canonical_key_lowercases_section_and_name_only() {
5541        assert_eq!(canonical_key("core.bare").unwrap(), "core.bare");
5542        assert_eq!(canonical_key("CORE.BARE").unwrap(), "core.bare");
5543        assert_eq!(
5544            canonical_key("Remote.Origin.URL").unwrap(),
5545            "remote.Origin.url"
5546        );
5547        assert_eq!(canonical_key("a.B.C.D").unwrap(), "a.B.C.d");
5548        assert!(canonical_key("nodot").is_err());
5549        assert!(canonical_key("core.").is_err());
5550        assert!(canonical_key("core.1bad").is_err());
5551        assert!(canonical_key("core.ba\nre").is_err());
5552    }
5553
5554    #[test]
5555    fn parsed_keys_reuse_the_section_prefix_per_header() {
5556        let text = "[Core]\n\tBare = false\n\tFileMode\n\
5557                    [Remote \"Origin\"]\n\tURL = /x\n\
5558                    [branch \"Main\"] Remote = origin\n\
5559                    [core]\n\tEditor = vi ; trailing\n";
5560        let file = ConfigFile::parse(Path::new("cfg"), text, ConfigScope::Local).unwrap();
5561        let keys: Vec<(&str, Option<&str>)> = file
5562            .entries
5563            .iter()
5564            .map(|e| (e.key.as_str(), e.value.as_deref()))
5565            .collect();
5566        assert_eq!(
5567            keys,
5568            [
5569                ("core.bare", Some("false")),
5570                ("core.filemode", None),
5571                ("remote.Origin.url", Some("/x")),
5572                ("branch.Main.remote", Some("origin")),
5573                ("core.editor", Some("vi")),
5574            ]
5575        );
5576    }
5577
5578    #[test]
5579    fn push_lowercase_matches_to_lowercase() {
5580        for s in ["", "core", "MiXeD-09", "ÄBC", "İx", "ΣΑΣ"] {
5581            let mut out = String::from("p.");
5582            push_lowercase(&mut out, s);
5583            assert_eq!(out, format!("p.{}", s.to_lowercase()));
5584        }
5585    }
5586}