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