Skip to main content

grit_lib/config/
core.rs

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