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    ///
1746    /// # Errors
1747    ///
1748    /// Returns [`Error::Io`] on write failure, or [`ConfigError::ConfigFileLocked`] when
1749    /// `{path}.lock` already exists.
1750    pub fn write(&self) -> Result<()> {
1751        let lock_path = config_lock_path(&self.path);
1752        if lock_path.exists() {
1753            return Err(Error::Config(ConfigError::ConfigFileLocked {
1754                path: lock_path.display().to_string(),
1755            }));
1756        }
1757
1758        let content = self.raw_lines.join("\n");
1759        let bytes: Vec<u8> = if content.trim().is_empty() {
1760            Vec::new()
1761        } else if content.ends_with('\n') {
1762            content.into_bytes()
1763        } else {
1764            format!("{content}\n").into_bytes()
1765        };
1766
1767        let mut lock_file = fs::OpenOptions::new()
1768            .write(true)
1769            .create_new(true)
1770            .open(&lock_path)
1771            .map_err(|e| {
1772                if e.kind() == std::io::ErrorKind::AlreadyExists {
1773                    Error::Config(ConfigError::ConfigFileLocked {
1774                        path: lock_path.display().to_string(),
1775                    })
1776                } else {
1777                    Error::Io(e)
1778                }
1779            })?;
1780
1781        #[cfg(unix)]
1782        if let Ok(meta) = fs::metadata(&self.path) {
1783            use std::os::unix::fs::PermissionsExt;
1784            let mode = meta.permissions().mode();
1785            let _ = fs::set_permissions(&lock_path, fs::Permissions::from_mode(mode));
1786        }
1787
1788        use std::io::Write as _;
1789        lock_file.write_all(&bytes).map_err(|e| {
1790            let _ = fs::remove_file(&lock_path);
1791            Error::Io(e)
1792        })?;
1793        lock_file.sync_all().map_err(|e| {
1794            let _ = fs::remove_file(&lock_path);
1795            Error::Io(e)
1796        })?;
1797        drop(lock_file);
1798
1799        fs::rename(&lock_path, &self.path).map_err(|e| {
1800            let _ = fs::remove_file(&lock_path);
1801            Error::Io(e)
1802        })?;
1803        Ok(())
1804    }
1805
1806    /// Find the line index of a section header (case-insensitive match),
1807    /// or create one using the original-case names from user input.
1808    fn find_or_create_section_preserving_case(
1809        &mut self,
1810        section: &str,
1811        subsection: Option<&str>,
1812        raw_section: &str,
1813        raw_subsection: Option<&str>,
1814    ) -> usize {
1815        let sec_lower = section.to_lowercase();
1816        let mut parser = Parser::new();
1817        let mut last_match = None;
1818
1819        for (idx, line) in self.raw_lines.iter().enumerate() {
1820            if parser.try_parse_section(line) && section_matches(&parser, &sec_lower, subsection) {
1821                last_match = Some(idx);
1822            }
1823        }
1824        if let Some(idx) = last_match {
1825            return idx;
1826        }
1827
1828        // Create new section at end of file, using original case
1829        let header = match raw_subsection {
1830            Some(sub) => {
1831                let escaped = escape_subsection(sub);
1832                format!("[{} \"{}\"]", raw_section, escaped)
1833            }
1834            None => format!("[{}]", raw_section),
1835        };
1836        self.raw_lines.push(header);
1837        self.raw_lines.len() - 1
1838    }
1839
1840    /// Find the last line that belongs to the section starting at `section_line`.
1841    fn last_line_in_section(&self, section_line: usize) -> usize {
1842        let mut last = section_line;
1843        for idx in (section_line + 1)..self.raw_lines.len() {
1844            let trimmed = self.raw_lines[idx].trim();
1845            if trimmed.starts_with('[') {
1846                break;
1847            }
1848            last = idx;
1849        }
1850        last
1851    }
1852}
1853
1854// ── ConfigSet ───────────────────────────────────────────────────────
1855
1856impl ConfigSet {
1857    /// Create an empty config set.
1858    #[must_use]
1859    pub fn new() -> Self {
1860        Self {
1861            entries: Vec::new(),
1862        }
1863    }
1864
1865    /// All merged entries in load order (for listing keys such as `alias.*`).
1866    #[must_use]
1867    pub fn entries(&self) -> &[ConfigEntry] {
1868        &self.entries
1869    }
1870
1871    /// Merge entries from a [`ConfigFile`] into this set.
1872    ///
1873    /// Entries are appended; later values override earlier ones for
1874    /// single-value lookups.
1875    pub fn merge(&mut self, file: &ConfigFile) {
1876        self.entries.extend(file.entries.iter().cloned());
1877    }
1878
1879    /// Merge another [`ConfigSet`] into this set (entries appended in order).
1880    pub fn merge_set(&mut self, other: &ConfigSet) {
1881        self.entries.extend(other.entries.iter().cloned());
1882    }
1883
1884    /// Add a command-line override (`-c key=value`).
1885    pub fn add_command_override(&mut self, key: &str, value: &str) -> Result<()> {
1886        let canon = canonical_key(key)?;
1887        self.entries.push(ConfigEntry {
1888            key: canon,
1889            value: Some(value.to_owned()),
1890            scope: ConfigScope::Command,
1891            file: None,
1892            line: 0,
1893        });
1894        Ok(())
1895    }
1896
1897    /// Get the last (highest-priority) value for a key.
1898    ///
1899    /// # Parameters
1900    ///
1901    /// - `key` — the key to look up (will be canonicalized).
1902    ///
1903    /// # Returns
1904    ///
1905    /// `Some(value)` for the last matching entry, or `None` if not found.
1906    /// Bare boolean keys return `Some("true")`.
1907    #[must_use]
1908    pub fn get(&self, key: &str) -> Option<String> {
1909        let canon = canonical_key(key).ok()?;
1910        self.entries
1911            .iter()
1912            .rev()
1913            .find(|e| e.key == canon)
1914            .map(|e| e.value.clone().unwrap_or_else(|| "true".to_owned()))
1915    }
1916
1917    /// Last (highest-priority) [`ConfigEntry`] for a key, including origin metadata.
1918    ///
1919    /// Bare boolean keys are returned with [`ConfigEntry::value`] set to `None` (same as `get`,
1920    /// which maps them to `"true"` for string lookups).
1921    #[must_use]
1922    pub fn get_last_entry(&self, key: &str) -> Option<ConfigEntry> {
1923        let canon = canonical_key(key).ok()?;
1924        self.entries.iter().rev().find(|e| e.key == canon).cloned()
1925    }
1926
1927    /// Winning value for `key` plus scope, source file, and line (Git `--show-origin --show-scope`).
1928    ///
1929    /// # Errors
1930    ///
1931    /// Returns [`Error::Config`] when `key` is not a valid Git config key.
1932    pub fn get_with_origin(&self, key: &str) -> Result<Option<ConfigValueOrigin>> {
1933        let _canon = canonical_key(key)?;
1934        Ok(self.get_last_entry(key).map(|e| ConfigValueOrigin {
1935            value: e.value.clone().or_else(|| Some("true".to_owned())),
1936            scope: e.scope,
1937            file: config_entry_origin_file(&e).map(Path::to_path_buf),
1938            line: config_entry_origin_line(&e),
1939        }))
1940    }
1941
1942    /// Every value for `key` in load order, with origin metadata (Git `--get-all`).
1943    ///
1944    /// # Errors
1945    ///
1946    /// Returns [`Error::Config`] when `key` is not a valid Git config key.
1947    pub fn get_all_with_origin(&self, key: &str) -> Result<Vec<ConfigValueOrigin>> {
1948        let canon = canonical_key(key)?;
1949        Ok(self
1950            .entries
1951            .iter()
1952            .filter(|e| e.key == canon)
1953            .map(|e| ConfigValueOrigin {
1954                value: e.value.clone().or_else(|| Some("true".to_owned())),
1955                scope: e.scope,
1956                file: config_entry_origin_file(e).map(Path::to_path_buf),
1957                line: config_entry_origin_line(e),
1958            })
1959            .collect())
1960    }
1961
1962    /// Get all values for a key (multi-valued; in load order).
1963    #[must_use]
1964    pub fn get_all(&self, key: &str) -> Vec<String> {
1965        let canon = match canonical_key(key) {
1966            Ok(c) => c,
1967            Err(_) => return Vec::new(),
1968        };
1969        self.entries
1970            .iter()
1971            .filter(|e| e.key == canon)
1972            .map(|e| e.value.clone().unwrap_or_default())
1973            .collect()
1974    }
1975
1976    /// All raw values for a key in load order, preserving `None` for bare boolean keys.
1977    ///
1978    /// Matches Git's multi-value list where `NULL` means a value-less / boolean-true key.
1979    #[must_use]
1980    pub fn get_all_raw(&self, key: &str) -> Vec<Option<String>> {
1981        let canon = match canonical_key(key) {
1982            Ok(c) => c,
1983            Err(_) => return Vec::new(),
1984        };
1985        self.entries
1986            .iter()
1987            .filter(|e| e.key == canon)
1988            .map(|e| e.value.clone())
1989            .collect()
1990    }
1991
1992    /// True if any config entry uses `key` (after canonicalization), including bare boolean keys.
1993    ///
1994    /// Unlike [`Self::get`], this does not treat a missing value as `"true"` — it reports whether
1995    /// the key appears in the merged config at all (Git `repo_config_get` / `git_configset_get`).
1996    #[must_use]
1997    pub fn has_key(&self, key: &str) -> bool {
1998        let Ok(canon) = canonical_key(key) else {
1999            return false;
2000        };
2001        self.entries.iter().any(|e| e.key == canon)
2002    }
2003
2004    /// Get a boolean value, interpreting `true`/`yes`/`on`/`1` as true and
2005    /// `false`/`no`/`off`/`0` as false.
2006    ///
2007    /// `pack.allowPackReuse` may be `single` or `multi` (Git enum, not a bool). Those values are
2008    /// treated as unset for boolean lookup so `get_bool` does not error during broad config scans.
2009    pub fn get_bool(&self, key: &str) -> Option<std::result::Result<bool, String>> {
2010        let v = self.get(key)?;
2011        if canonical_key(key).ok().as_deref() == Some("pack.allowpackreuse") {
2012            let lower = v.trim().to_ascii_lowercase();
2013            if lower == "single" || lower == "multi" {
2014                return None;
2015            }
2016        }
2017        Some(parse_bool(&v))
2018    }
2019
2020    /// Whether pathnames in human-readable output should fully C-quote non-ASCII bytes as octal.
2021    ///
2022    /// Maps to Git's `quote_path_fully` (`core.quotepath`, default true). When false, UTF-8 and
2023    /// other high bytes are emitted literally; only ASCII specials are escaped. Also honors
2024    /// `core.quotePath` as an alternate spelling.
2025    #[must_use]
2026    pub fn quote_path_fully(&self) -> bool {
2027        let from_key = |key: &str| self.get_bool(key).and_then(|r| r.ok());
2028        from_key("core.quotepath")
2029            .or_else(|| from_key("core.quotePath"))
2030            .unwrap_or(true)
2031    }
2032
2033    /// Default for `pack.writeBitmapHashCache` (Git default: true).
2034    #[must_use]
2035    pub fn pack_write_bitmap_hash_cache(&self) -> bool {
2036        self.get_bool("pack.writebitmaphashcache")
2037            .or_else(|| self.get_bool("pack.writeBitmapHashCache"))
2038            .and_then(|r| r.ok())
2039            .unwrap_or(true)
2040    }
2041
2042    /// Default for `pack.writeBitmapLookupTable` (Git default: false).
2043    #[must_use]
2044    pub fn pack_write_bitmap_lookup_table(&self) -> bool {
2045        self.get_bool("pack.writebitmaplookuptable")
2046            .or_else(|| self.get_bool("pack.writeBitmapLookupTable"))
2047            .and_then(|r| r.ok())
2048            .unwrap_or(false)
2049    }
2050
2051    /// Values of `pack.preferBitmapTips` (may appear multiple times).
2052    #[must_use]
2053    pub fn pack_prefer_bitmap_tips(&self) -> Vec<String> {
2054        let mut out = self.get_all("pack.preferbitmaptips");
2055        if out.is_empty() {
2056            out = self.get_all("pack.preferBitmapTips");
2057        }
2058        out
2059    }
2060
2061    /// Build [`crate::pack_bitmap::PackBitmapWriteOptions`] from config keys.
2062    #[must_use]
2063    pub fn pack_bitmap_write_options(&self) -> crate::pack_bitmap::PackBitmapWriteOptions {
2064        crate::pack_bitmap::PackBitmapWriteOptions {
2065            full_dag: true,
2066            hash_cache: self.pack_write_bitmap_hash_cache(),
2067            lookup_table: self.pack_write_bitmap_lookup_table(),
2068            prefer_bitmap_tips: self.pack_prefer_bitmap_tips(),
2069            verify_reachability_closure: true,
2070            reachability_roots: None,
2071        }
2072    }
2073
2074    /// Default for `pack.writeReverseIndex` / `pack.writereverseindex` (Git default: true).
2075    ///
2076    /// Tests set `GIT_TEST_NO_WRITE_REV_INDEX` to force no `.rev` output.
2077    #[must_use]
2078    pub fn pack_write_reverse_index_default(&self, env: &Environment) -> bool {
2079        if env.git_test_no_write_rev_index.as_deref().is_some_and(|v| {
2080            let s = v.trim().to_ascii_lowercase();
2081            matches!(s.as_str(), "1" | "true" | "yes" | "on")
2082        }) {
2083            return false;
2084        }
2085        if self
2086            .get("pack.writereverseindex")
2087            .or_else(|| self.get("pack.writeReverseIndex"))
2088            .is_some_and(|v| v.trim().is_empty())
2089        {
2090            return false;
2091        }
2092        self.get_bool("pack.writereverseindex")
2093            .or_else(|| self.get_bool("pack.writeReverseIndex"))
2094            .and_then(|r| r.ok())
2095            .unwrap_or(true)
2096    }
2097
2098    /// Resolved worker count for pack indexing (`pack.threads`, Git default: unset → all CPUs).
2099    #[must_use]
2100    pub fn pack_index_threads(&self) -> Option<usize> {
2101        self.get_i64("pack.threads")
2102            .and_then(|r| r.ok())
2103            .and_then(|n| usize::try_from(n).ok())
2104    }
2105
2106    /// In-pack delta window (`pack.window`, Git default: 10 when unset).
2107    ///
2108    /// An explicit `0` disables newly computed deltas (on-disk reuse may still apply).
2109    #[must_use]
2110    pub fn pack_object_window(&self) -> usize {
2111        match self.get_i64("pack.window") {
2112            None => 10,
2113            Some(Ok(n)) if n >= 0 => usize::try_from(n).unwrap_or(10),
2114            Some(Ok(_)) | Some(Err(_)) => 10,
2115        }
2116    }
2117
2118    /// In-pack delta chain depth cap (`pack.depth`, Git default: 50 when unset).
2119    ///
2120    /// An explicit `0` disables newly computed deltas (on-disk reuse may still apply).
2121    #[must_use]
2122    /// Sliding-window memory cap for delta search (`pack.windowMemory`, Git default: unlimited).
2123    pub fn pack_window_memory_bytes(&self) -> u64 {
2124        match self.get_i64("pack.windowmemory") {
2125            None => 0,
2126            Some(Ok(n)) if n >= 0 => u64::try_from(n).unwrap_or(0),
2127            Some(Ok(_)) | Some(Err(_)) => 0,
2128        }
2129    }
2130
2131    /// Maximum delta depth for `git pack-objects` (`pack.depth`, Git default 50).
2132    pub fn pack_object_depth(&self) -> usize {
2133        match self.get_i64("pack.depth") {
2134            None => crate::pack::DEFAULT_PACK_DEPTH,
2135            Some(Ok(n)) if n >= 0 => usize::try_from(n).unwrap_or(crate::pack::DEFAULT_PACK_DEPTH),
2136            Some(Ok(_)) | Some(Err(_)) => crate::pack::DEFAULT_PACK_DEPTH,
2137        }
2138    }
2139
2140    /// Approximate loose-object count above which automatic maintenance packs loose objects
2141    /// (`gc.auto`, Git default 6700).
2142    ///
2143    /// Returns `None` when `gc.auto` is zero or negative, which turns off automatic maintenance
2144    /// entirely (including the [`Self::gc_auto_pack_limit`] trigger). Unparsable values fall
2145    /// back to the default.
2146    #[must_use]
2147    pub fn gc_auto_threshold(&self) -> Option<usize> {
2148        match self.get_i64("gc.auto") {
2149            Some(Ok(n)) if n <= 0 => None,
2150            Some(Ok(n)) => Some(usize::try_from(n).unwrap_or(usize::MAX)),
2151            None | Some(Err(_)) => Some(DEFAULT_GC_AUTO_THRESHOLD),
2152        }
2153    }
2154
2155    /// Number of non-`.keep` local packs above which automatic maintenance consolidates every
2156    /// pack into one (`gc.autoPackLimit`, Git default 50).
2157    ///
2158    /// Returns `None` when the value is zero or negative (pack consolidation trigger off).
2159    /// Unparsable values fall back to the default.
2160    #[must_use]
2161    pub fn gc_auto_pack_limit(&self) -> Option<usize> {
2162        match self.get_i64("gc.autoPackLimit") {
2163            Some(Ok(n)) if n <= 0 => None,
2164            Some(Ok(n)) => Some(usize::try_from(n).unwrap_or(usize::MAX)),
2165            None | Some(Err(_)) => Some(DEFAULT_GC_AUTO_PACK_LIMIT),
2166        }
2167    }
2168
2169    /// Whether automatic maintenance runs in a detached background process
2170    /// (`gc.autoDetach`, default `true`).
2171    #[must_use]
2172    pub fn gc_auto_detach(&self) -> bool {
2173        self.get_bool("gc.autoDetach")
2174            .and_then(|v| v.ok())
2175            .unwrap_or(true)
2176    }
2177
2178    /// Whether commands may start automatic maintenance at all (`maintenance.auto`, default
2179    /// `true`).
2180    #[must_use]
2181    pub fn maintenance_auto(&self) -> bool {
2182        self.get_bool("maintenance.auto")
2183            .and_then(|v| v.ok())
2184            .unwrap_or(true)
2185    }
2186
2187    /// [`crate::hash::Parallelism`] for pack indexing from merged config.
2188    ///
2189    /// # Errors
2190    ///
2191    /// Returns [`ConfigError`] when `pack.threads` is set to a value Git rejects.
2192    pub fn try_pack_index_parallelism(
2193        &self,
2194    ) -> std::result::Result<crate::parallel::Parallelism, ConfigError> {
2195        Ok(crate::parallel::ThreadPolicy::default()
2196            .try_resolve(crate::parallel::ParallelOp::IndexPack, self)?
2197            .parallelism())
2198    }
2199
2200    /// Like [`Self::try_pack_index_parallelism`], but an invalid `pack.threads`
2201    /// falls back to the default (all available cores) instead of an error.
2202    #[must_use]
2203    pub fn pack_index_parallelism(&self) -> crate::parallel::Parallelism {
2204        self.try_pack_index_parallelism()
2205            .unwrap_or_else(|_| crate::parallel::Parallelism::resolve(None))
2206    }
2207
2208    /// Load config for `git_dir` (if any) and resolve [`crate::hash::Parallelism`] for index-pack.
2209    ///
2210    /// # Errors
2211    ///
2212    /// Propagates config load failures and invalid `pack.threads`.
2213    pub fn try_pack_index_parallelism_for_git_dir(
2214        env: &Environment,
2215        git_dir: Option<&std::path::Path>,
2216    ) -> Result<crate::parallel::Parallelism> {
2217        match git_dir {
2218            Some(d) => {
2219                let cfg = Self::load(env, Some(d), true)?;
2220                Ok(cfg.try_pack_index_parallelism()?)
2221            }
2222            None => Ok(crate::parallel::Parallelism::resolve(None)),
2223        }
2224    }
2225
2226    /// Load config for `git_dir` (if any) and resolve [`crate::hash::Parallelism`] for index-pack.
2227    #[must_use]
2228    pub fn pack_index_parallelism_for_git_dir(
2229        env: &Environment,
2230        git_dir: Option<&std::path::Path>,
2231    ) -> crate::parallel::Parallelism {
2232        Self::try_pack_index_parallelism_for_git_dir(env, git_dir)
2233            .unwrap_or_else(|_| crate::parallel::Parallelism::resolve(None))
2234    }
2235
2236    /// Default for `pack.readReverseIndex` / `pack.readreverseindex` (Git default: true).
2237    #[must_use]
2238    pub fn pack_read_reverse_index_default(&self) -> bool {
2239        self.get_bool("pack.readreverseindex")
2240            .or_else(|| self.get_bool("pack.readReverseIndex"))
2241            .and_then(|r| r.ok())
2242            .unwrap_or(true)
2243    }
2244
2245    /// `core.logAllRefUpdates` as configured in this set, or [`refs::LogRefsConfig::Unset`] when
2246    /// the key is absent or holds an unrecognised value.
2247    #[must_use]
2248    pub(crate) fn log_refs_config(&self) -> refs::LogRefsConfig {
2249        self.get("core.logAllRefUpdates")
2250            .and_then(|v| refs::LogRefsConfig::parse(&v))
2251            .unwrap_or(refs::LogRefsConfig::Unset)
2252    }
2253
2254    /// Resolved `core.logAllRefUpdates` using this merged set (includes `git -c` / env), then Git's
2255    /// bare-repo default when the key is unset everywhere.
2256    ///
2257    /// Never returns [`refs::LogRefsConfig::Unset`]: an unset (or unrecognised) value resolves to
2258    /// [`refs::LogRefsConfig::None`] in a bare repository and [`refs::LogRefsConfig::Normal`]
2259    /// otherwise.
2260    #[must_use]
2261    pub fn effective_log_refs_config(&self) -> refs::LogRefsConfig {
2262        match self.log_refs_config() {
2263            refs::LogRefsConfig::Unset
2264                if self
2265                    .get_bool("core.bare")
2266                    .and_then(|r| r.ok())
2267                    .unwrap_or(false) =>
2268            {
2269                refs::LogRefsConfig::None
2270            }
2271            refs::LogRefsConfig::Unset => refs::LogRefsConfig::Normal,
2272            mode => mode,
2273        }
2274    }
2275
2276    /// Build a set from already-parsed entries (in load order).
2277    pub(crate) fn from_entries(entries: Vec<ConfigEntry>) -> Self {
2278        Self { entries }
2279    }
2280
2281    /// Get an integer value, supporting Git's `k`/`m`/`g` suffixes.
2282    pub fn get_i64(&self, key: &str) -> Option<std::result::Result<i64, String>> {
2283        self.get(key).map(|v| parse_i64(&v))
2284    }
2285
2286    /// Parse `core.compression` / `core.looseCompression` style zlib level integers.
2287    ///
2288    /// `-1` selects zlib's default (level 6). Valid values are `-1` or `0..=9`; anything else
2289    /// is a configuration error.
2290    fn parse_zlib_compression_level(raw: &str) -> Result<i32> {
2291        const Z_DEFAULT_COMPRESSION: i32 = 6;
2292        const Z_BEST_COMPRESSION: i32 = 9;
2293
2294        let v = parse_git_config_int_strict(raw.trim()).map_err(|_| {
2295            Error::Config(format!("bad numeric config value '{raw}' for compression").into())
2296        })?;
2297        if v == -1 {
2298            return Ok(Z_DEFAULT_COMPRESSION);
2299        }
2300        if v < 0 || v > i64::from(Z_BEST_COMPRESSION) {
2301            return Err(Error::Config(
2302                format!("bad zlib compression level {v}").into(),
2303            ));
2304        }
2305        Ok(v as i32)
2306    }
2307
2308    /// Zlib deflate level for loose objects (Git's `zlib_compression_level`).
2309    ///
2310    /// Walks [`Self::entries`] in load order. Every `core.compression` and `core.looseCompression`
2311    /// entry is parsed and validated (including shadowed or overridden values); bare keys and
2312    /// invalid numbers return [`Error::Config`]. `core.compression` sets the loose level until
2313    /// the first valid `core.looseCompression` is seen; later `core.compression` entries are still
2314    /// validated but ignored for the loose level. When neither key appears, returns
2315    /// [`Self::LOOSE_OBJECTS_ZLIB_DEFAULT`] (`Z_BEST_SPEED`, level 1).
2316    ///
2317    /// `-1` means zlib default (level 6). Valid values are `-1` or `0..=9`.
2318    pub fn loose_objects_zlib_level(&self) -> Result<i32> {
2319        let mut loose_level = Self::LOOSE_OBJECTS_ZLIB_DEFAULT;
2320        let mut loose_compression_seen = false;
2321
2322        for e in self.entries() {
2323            match e.key.as_str() {
2324                "core.compression" => {
2325                    let val = e.value.as_deref().ok_or_else(|| {
2326                        Error::Config(
2327                            "bad numeric config value '' for compression"
2328                                .to_owned()
2329                                .into(),
2330                        )
2331                    })?;
2332                    let level = Self::parse_zlib_compression_level(val)?;
2333                    if !loose_compression_seen {
2334                        loose_level = level;
2335                    }
2336                }
2337                "core.loosecompression" => {
2338                    let val = e.value.as_deref().ok_or_else(|| {
2339                        Error::Config(
2340                            "bad numeric config value '' for compression"
2341                                .to_owned()
2342                                .into(),
2343                        )
2344                    })?;
2345                    let level = Self::parse_zlib_compression_level(val)?;
2346                    loose_level = level;
2347                    loose_compression_seen = true;
2348                }
2349                _ => {}
2350            }
2351        }
2352
2353        Ok(loose_level)
2354    }
2355
2356    /// Git's default zlib level for loose objects when neither `core.looseCompression` nor
2357    /// `core.compression` is set (`Z_BEST_SPEED`).
2358    pub const LOOSE_OBJECTS_ZLIB_DEFAULT: i32 = 1;
2359
2360    /// Git's default `core.bigFileThreshold` (512 MiB).
2361    pub const BIG_FILE_THRESHOLD_DEFAULT: u64 = 512 * 1024 * 1024;
2362
2363    /// Size in bytes at or above which files are treated as big (`core.bigFileThreshold`).
2364    ///
2365    /// Big files are hashed and stored as a stream instead of being read into memory, and
2366    /// are written straight into a pack. The value accepts Git's `k`/`m`/`g` suffixes; the
2367    /// last entry wins. When unset, returns [`Self::BIG_FILE_THRESHOLD_DEFAULT`].
2368    ///
2369    /// # Errors
2370    ///
2371    /// Returns [`ConfigError::BadNumericValue`] when the value is missing, not a number, has
2372    /// an unknown unit, or is negative.
2373    pub fn big_file_threshold(&self) -> Result<u64> {
2374        const KEY: &str = "core.bigfilethreshold";
2375        let Some(entry) = self.get_last_entry(KEY) else {
2376            return Ok(Self::BIG_FILE_THRESHOLD_DEFAULT);
2377        };
2378        let raw = entry.value.as_deref().unwrap_or_default();
2379        let bad = |reason| {
2380            Error::Config(ConfigError::BadNumericValue {
2381                key: KEY.to_owned(),
2382                value: raw.to_owned(),
2383                reason,
2384            })
2385        };
2386        let value = parse_git_config_int_strict(raw.trim()).map_err(|e| {
2387            bad(match e {
2388                GitConfigIntStrictError::InvalidUnit => BadNumericSource::InvalidUnit,
2389                GitConfigIntStrictError::OutOfRange => BadNumericSource::OutOfRange,
2390            })
2391        })?;
2392        u64::try_from(value).map_err(|_| bad(BadNumericSource::OutOfRange))
2393    }
2394
2395    /// Zlib deflate level for `git pack-objects` (Git's `pack_compression_level`).
2396    ///
2397    /// Entries are applied in [`Self::entries`] order. `core.compression` sets the pack level
2398    /// until a `pack.compression` appears (Git `pack_compression_seen`). `core.loosecompression`
2399    /// is ignored here — it only affects loose-object zlib, not packs.
2400    ///
2401    /// `-1` means zlib default (level 6). Valid values are `-1` or `0..=9`.
2402    pub fn pack_objects_zlib_level(&self) -> Result<i32> {
2403        const Z_DEFAULT_COMPRESSION: i32 = 6;
2404
2405        let parse_compression =
2406            |raw: &str| -> Result<i32> { Self::parse_zlib_compression_level(raw) };
2407
2408        // `core.loosecompression` affects loose objects only (Git `zlib_compression_level`), not pack.
2409        let mut pack_level = Z_DEFAULT_COMPRESSION;
2410        let mut pack_compression_seen = false;
2411
2412        for e in self.entries() {
2413            match e.key.as_str() {
2414                "core.compression" => {
2415                    let Some(val) = e.value.as_deref() else {
2416                        continue;
2417                    };
2418                    let level = parse_compression(val)?;
2419                    if !pack_compression_seen {
2420                        pack_level = level;
2421                    }
2422                }
2423                "pack.compression" => {
2424                    let Some(val) = e.value.as_deref() else {
2425                        continue;
2426                    };
2427                    pack_level = parse_compression(val)?;
2428                    pack_compression_seen = true;
2429                }
2430                _ => {}
2431            }
2432        }
2433
2434        Ok(pack_level)
2435    }
2436
2437    /// Get all entries matching a key pattern (regex).
2438    ///
2439    /// Used by `git config --get-regexp`. Returns an error if the pattern
2440    /// is not a valid regex.
2441    pub fn get_regexp(&self, pattern: &str) -> std::result::Result<Vec<&ConfigEntry>, String> {
2442        let re = regex::Regex::new(pattern).map_err(|e| format!("invalid key pattern: {e}"))?;
2443        Ok(self
2444            .entries
2445            .iter()
2446            .filter(|e| re.is_match(&e.key))
2447            .collect())
2448    }
2449
2450    /// Load the standard Git configuration file cascade for a repository.
2451    ///
2452    /// # Parameters
2453    ///
2454    /// - `git_dir` — path to the `.git` directory (for local/worktree config).
2455    /// - `include_system` — whether to load system config.
2456    ///
2457    /// # Errors
2458    ///
2459    /// Returns errors from file I/O or parsing.
2460    pub fn load(env: &Environment, git_dir: Option<&Path>, include_system: bool) -> Result<Self> {
2461        let opts = LoadConfigOptions {
2462            include_system,
2463            include_ctx: IncludeContext {
2464                git_dir: git_dir.map(PathBuf::from),
2465                cwd: env.cwd.clone(),
2466                pwd: env.pwd.clone(),
2467                env: std::sync::Arc::new(env.clone()),
2468                ..Default::default()
2469            },
2470            ..Default::default()
2471        };
2472        Self::load_with_options(env, git_dir, &opts)
2473    }
2474
2475    /// Load the standard configuration cascade with explicit include and scope control.
2476    ///
2477    /// See [`LoadConfigOptions`] for `GIT_CONFIG_PARAMETERS` / `-c` include behaviour.
2478    ///
2479    /// Uncached load: parses the cascade from disk every call. Repository-scoped memoization
2480    /// lives on [`crate::repo_caches::RepoCaches::load_config_with_options`].
2481    pub fn load_with_options(
2482        env: &Environment,
2483        git_dir: Option<&Path>,
2484        opts: &LoadConfigOptions,
2485    ) -> Result<Self> {
2486        Self::load_with_options_uncached(env, git_dir, opts, &mut Vec::new())
2487    }
2488
2489    fn read_local_config_text_optional(
2490        path: &Path,
2491        caches: Option<&std::sync::Arc<crate::repo_caches::RepoCaches>>,
2492    ) -> Result<Option<String>> {
2493        let read = |p: &Path| -> Result<Option<String>> {
2494            match fs::read_to_string(p) {
2495                Ok(text) => Ok(Some(text)),
2496                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
2497                Err(e) => Err(Error::Io(e)),
2498            }
2499        };
2500        let Some(c) = caches else {
2501            return read(path);
2502        };
2503        match c.local_config_text(path) {
2504            Ok(text) => Ok(Some(text)),
2505            Err(Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
2506            Err(e) => Err(e),
2507        }
2508    }
2509
2510    pub(crate) fn load_with_options_uncached(
2511        env: &Environment,
2512        git_dir: Option<&Path>,
2513        opts: &LoadConfigOptions,
2514        included_files: &mut Vec<PathBuf>,
2515    ) -> Result<Self> {
2516        let proc = opts.process_includes;
2517        let mut ctx = opts.include_ctx.clone();
2518        if ctx.cwd.as_os_str().is_empty() {
2519            ctx.cwd = env.cwd.clone();
2520        }
2521        ctx.pwd = env.pwd.clone();
2522        ctx.env = std::sync::Arc::new(env.clone());
2523        let mut layers = Vec::new();
2524
2525        if opts.include_system && !git_config_nosystem_enabled(env) {
2526            let system_path = system_config_path(env);
2527            if let Some(f) = ConfigFile::from_path(&system_path, ConfigScope::System)? {
2528                layers.push(ConfigLayer::file(f));
2529            }
2530        }
2531
2532        // Global config (Git merges every existing file: XDG then ~/.gitconfig).
2533        for path in global_config_paths(env) {
2534            if let Some(f) = ConfigFile::from_path(&path, ConfigScope::Global)? {
2535                layers.push(ConfigLayer::file(f));
2536            }
2537        }
2538
2539        // Local config — linked worktrees read `commondir/config`, not the admin `config`.
2540        if let Some(gd) = git_dir {
2541            let common_dir = crate::repo::common_git_dir_for_config(gd);
2542            let local_path = common_dir.join("config");
2543            let local_text =
2544                Self::read_local_config_text_optional(&local_path, opts.repo_caches.as_ref())?;
2545            if let Some(msg) =
2546                crate::repo::early_config_ignore_repo_reason_for_text(local_text.as_deref())
2547            {
2548                if let Some(sink) = opts.diagnostics.as_ref() {
2549                    sink.warn(crate::diagnostics::Warning::IgnoredGitDir {
2550                        path: gd.to_path_buf(),
2551                        reason: msg,
2552                    });
2553                }
2554            } else if let Some(text) = local_text.as_deref() {
2555                let f = ConfigFile::parse(&local_path, text, ConfigScope::Local)?;
2556                layers.push(ConfigLayer::file(f));
2557            }
2558
2559            // Worktree config — Git only reads `config.worktree` when
2560            // `extensions.worktreeConfig` is enabled in the common repository `config`.
2561            let wt_path = gd.join("config.worktree");
2562            if crate::repo::worktree_config_enabled_for_text(local_text.as_deref()) {
2563                if let Some(f) = ConfigFile::from_path(&wt_path, ConfigScope::Worktree)? {
2564                    layers.push(ConfigLayer::file(f));
2565                }
2566            }
2567        }
2568
2569        if let Some(path) = env.git_config.as_deref() {
2570            let path = resolve_config_override_path(env, path);
2571            if let Some(f) = ConfigFile::from_path(&path, ConfigScope::Command)? {
2572                layers.push(ConfigLayer::file(f));
2573            }
2574        }
2575
2576        layers.push(ConfigLayer::Entries(environment_config_pair_entries(env)?));
2577
2578        // GIT_CONFIG_PARAMETERS — used by `git -c key=value`.
2579        if let Some(params) = env
2580            .git_config_parameters
2581            .as_deref()
2582            .filter(|p| !p.trim().is_empty())
2583        {
2584            if proc && opts.command_includes {
2585                let pseudo = Path::new(":GIT_CONFIG_PARAMETERS");
2586                let cmd_file = ConfigFile::from_git_config_parameters(pseudo, params)?;
2587                layers.push(ConfigLayer::file(cmd_file));
2588            } else {
2589                layers.push(ConfigLayer::Entries(command_override_entries(params)));
2590            }
2591        }
2592
2593        let mut set = Self::new();
2594        set.merge_layers(&layers, proc, &ctx, included_files)?;
2595        Ok(set)
2596    }
2597
2598    /// Read configuration the way Git's `read_early_config` / `do_git_config_sequence` does:
2599    /// system (unless disabled), global files in Git order, optional repository `config` /
2600    /// `config.worktree`, then `GIT_CONFIG_PARAMETERS`.
2601    ///
2602    /// When `git_dir` is `None` (no discovered repository, e.g. `GIT_CEILING_DIRECTORIES`), only
2603    /// non-repo layers are read — matching Git when discovery returns no gitdir (t1309 ceiling #2).
2604    ///
2605    /// Returns all values for `key` in load order (Git's `read_early_config` callback runs once per
2606    /// occurrence).
2607    ///
2608    /// This matches upstream ordering for `test-tool config read_early_config` (t1309, t1305).
2609    pub fn read_early_config(
2610        env: &Environment,
2611        git_dir: Option<&Path>,
2612        key: &str,
2613    ) -> Result<Vec<String>> {
2614        let ctx = IncludeContext {
2615            git_dir: git_dir.map(PathBuf::from),
2616            command_line_relative_include_is_error: false,
2617            cwd: env.cwd.clone(),
2618            pwd: env.pwd.clone(),
2619            env: std::sync::Arc::new(env.clone()),
2620        };
2621        let mut layers = Vec::new();
2622
2623        if !git_config_nosystem_enabled(env) {
2624            let system_path = system_config_path(env);
2625            if let Ok(Some(f)) = ConfigFile::from_path(&system_path, ConfigScope::System) {
2626                layers.push(ConfigLayer::file(f));
2627            }
2628        }
2629
2630        // Global: all existing candidates (Git merges every readable file).
2631        for path in global_config_paths(env) {
2632            if let Ok(Some(f)) = ConfigFile::from_path(&path, ConfigScope::Global) {
2633                layers.push(ConfigLayer::file(f));
2634            }
2635        }
2636
2637        if let Some(gd) = git_dir {
2638            let common_dir = crate::repo::common_git_dir_for_config(gd);
2639            // Local (commondir) — skip when format is newer than supported (t1309).
2640            let local_path = common_dir.join("config");
2641            if crate::repo::early_config_ignore_repo_reason(&common_dir).is_some() {
2642                // Local config skipped for unsupported repository format (see `Warning::IgnoredGitDir`).
2643            } else if let Ok(Some(f)) = ConfigFile::from_path(&local_path, ConfigScope::Local) {
2644                layers.push(ConfigLayer::file(f));
2645            }
2646
2647            // Worktree-specific config (when enabled for this repo).
2648            let wt_path = gd.join("config.worktree");
2649            if crate::repo::worktree_config_enabled(&common_dir) {
2650                if let Ok(Some(f)) = ConfigFile::from_path(&wt_path, ConfigScope::Worktree) {
2651                    layers.push(ConfigLayer::file(f));
2652                }
2653            }
2654        }
2655
2656        // GIT_CONFIG_PARAMETERS — same as full load (`load_with_options` default).
2657        if let Some(params) = env
2658            .git_config_parameters
2659            .as_deref()
2660            .filter(|p| !p.trim().is_empty())
2661        {
2662            let pseudo = Path::new(":GIT_CONFIG_PARAMETERS");
2663            let cmd_file = ConfigFile::from_git_config_parameters(pseudo, params)?;
2664            layers.push(ConfigLayer::file(cmd_file));
2665        }
2666
2667        let mut set = Self::new();
2668        set.merge_layers(&layers, true, &ctx, &mut Vec::new())?;
2669        Ok(set.get_all(key))
2670    }
2671
2672    /// Merge a single config file, optionally expanding `[include]` / `[includeIf]`.
2673    ///
2674    /// Used by `grit config -f` and scoped reads; [`ConfigSet::load_with_options`] uses the same
2675    /// internal routine for the standard cascade.
2676    ///
2677    /// `hasconfig:remote.*.url:` conditions match only remote URLs reachable from `file`
2678    /// itself (like `git config -f <file> --includes`), not URLs already in `self`.
2679    ///
2680    /// # Errors
2681    ///
2682    /// Unreadable or unparsable include targets, include recursion past Git's depth limit,
2683    /// and [`ConfigError::RemoteUrlInHasconfigInclude`].
2684    pub fn merge_file_with_includes(
2685        &mut self,
2686        file: &ConfigFile,
2687        process_includes: bool,
2688        ctx: &IncludeContext,
2689    ) -> Result<()> {
2690        self.merge_layers(
2691            &[ConfigLayer::File(Cow::Borrowed(file))],
2692            process_includes,
2693            ctx,
2694            &mut Vec::new(),
2695        )
2696    }
2697
2698    /// Load only the repository's own `config` file (plus any `[include]` targets).
2699    ///
2700    /// Unlike [`Self::load`], this ignores system/global config and environment
2701    /// overrides. Used for receive-side options (e.g. `transfer.fsckObjects`) so a
2702    /// pusher's global configuration cannot weaken the remote repository's policy.
2703    pub fn load_repo_local_only(git_dir: &Path) -> Result<Self> {
2704        let local_path = git_dir.join("config");
2705        let ctx = IncludeContext {
2706            git_dir: Some(git_dir.to_path_buf()),
2707            command_line_relative_include_is_error: false,
2708            cwd: PathBuf::from("."),
2709            pwd: None,
2710            env: std::sync::Arc::new(Environment::empty()),
2711        };
2712        let mut set = Self::new();
2713        if let Ok(Some(f)) = ConfigFile::from_path(&local_path, ConfigScope::Local) {
2714            set.merge_layers(&[ConfigLayer::file(f)], true, &ctx, &mut Vec::new())?;
2715        }
2716        Ok(set)
2717    }
2718
2719    /// Load configuration the way Git loads **protected** config (e.g. `uploadpack.packObjectsHook`).
2720    ///
2721    /// This matches Git's `read_protected_config`: system (optional), global files only (no
2722    /// repository or worktree `config`), then command-line overrides from `GIT_CONFIG_COUNT` /
2723    /// `GIT_CONFIG_PARAMETERS`. It does **not** read `$GIT_CONFIG` (Git omits that for protected
2724    /// config).
2725    ///
2726    /// Global file order matches Git: XDG `git/config` first (when present), then `~/.gitconfig`,
2727    /// unless `GIT_CONFIG_GLOBAL` is set (single file). When both global files exist, both are
2728    /// merged so later entries win for duplicate keys.
2729    pub fn load_protected(env: &Environment, include_system: bool) -> Result<Self> {
2730        let ctx = IncludeContext {
2731            git_dir: None,
2732            command_line_relative_include_is_error: false,
2733            cwd: env.cwd.clone(),
2734            pwd: env.pwd.clone(),
2735            env: std::sync::Arc::new(env.clone()),
2736        };
2737        let mut layers = Vec::new();
2738
2739        if include_system && !git_config_nosystem_enabled(env) {
2740            let system_path = system_config_path(env);
2741            if let Ok(Some(f)) = ConfigFile::from_path(&system_path, ConfigScope::System) {
2742                layers.push(ConfigLayer::file(f));
2743            }
2744        }
2745
2746        for path in global_config_paths(env) {
2747            if let Ok(Some(f)) = ConfigFile::from_path(&path, ConfigScope::Global) {
2748                layers.push(ConfigLayer::file(f));
2749            }
2750        }
2751
2752        layers.push(ConfigLayer::Entries(environment_config_pair_entries(env)?));
2753        if let Some(params) = env.git_config_parameters.as_deref() {
2754            layers.push(ConfigLayer::Entries(command_override_entries(params)));
2755        }
2756
2757        let mut set = Self::new();
2758        set.merge_layers(&layers, true, &ctx, &mut Vec::new())?;
2759        Ok(set)
2760    }
2761
2762    /// Merge `layers` in order, expanding includes in file layers when `process_includes`.
2763    ///
2764    /// `hasconfig:remote.*.url:` conditions anywhere in the load match against the remote
2765    /// URLs of *all* layers (Git evaluates them against the complete configuration), so a
2766    /// global include can depend on a remote defined in the repository `config`. Every
2767    /// include target opened is recorded in `included_files` so the cascade cache can stamp it.
2768    fn merge_layers(
2769        &mut self,
2770        layers: &[ConfigLayer<'_>],
2771        process_includes: bool,
2772        ctx: &IncludeContext,
2773        included_files: &mut Vec<PathBuf>,
2774    ) -> Result<()> {
2775        // Headroom for the entries of typical small include files (identities, aliases), so
2776        // merging one does not double a vector sized exactly to the top-level layers.
2777        const INCLUDE_HEADROOM: usize = 64;
2778        let mut remote_urls = RemoteUrls::new(layers);
2779        let top_level: usize = layers
2780            .iter()
2781            .map(|layer| match layer {
2782                ConfigLayer::File(file) => file.entries.len(),
2783                ConfigLayer::Entries(entries) => entries.len(),
2784            })
2785            .sum();
2786        self.entries.reserve(top_level + INCLUDE_HEADROOM);
2787        for layer in layers {
2788            match layer {
2789                ConfigLayer::File(file) => Self::merge_with_includes(
2790                    self,
2791                    file,
2792                    process_includes,
2793                    0,
2794                    ctx,
2795                    &mut remote_urls,
2796                    included_files,
2797                )?,
2798                ConfigLayer::Entries(entries) => self.entries.extend(entries.iter().cloned()),
2799            }
2800        }
2801        Ok(())
2802    }
2803
2804    /// Merge `file`, expanding `[include]` / `[includeIf]` targets in place.
2805    fn merge_with_includes(
2806        set: &mut Self,
2807        file: &ConfigFile,
2808        process_includes: bool,
2809        depth: usize,
2810        ctx: &IncludeContext,
2811        remote_urls: &mut RemoteUrls<'_>,
2812        included_files: &mut Vec<PathBuf>,
2813    ) -> Result<()> {
2814        check_include_depth(depth)?;
2815        if !process_includes {
2816            set.merge(file);
2817            return Ok(());
2818        }
2819
2820        for entry in &file.entries {
2821            set.entries.push(entry.clone());
2822
2823            let Some((inc_path, condition)) = include_directive_for_entry(entry) else {
2824                continue;
2825            };
2826            if let Some(cond) = condition.as_deref() {
2827                let holds = match hasconfig_remote_url_pattern(cond) {
2828                    Some(pattern) => remote_urls.any_matches(pattern, ctx, included_files)?,
2829                    None => evaluate_include_condition(cond, file, ctx),
2830                };
2831                if !holds {
2832                    continue;
2833                }
2834            }
2835            let Some(inc_file) = open_include_target(&inc_path, file, ctx, included_files)? else {
2836                continue;
2837            };
2838            Self::merge_with_includes(
2839                set,
2840                &inc_file,
2841                process_includes,
2842                depth + 1,
2843                ctx,
2844                remote_urls,
2845                included_files,
2846            )?;
2847        }
2848
2849        Ok(())
2850    }
2851}
2852
2853/// One step of a configuration load, merged in order by [`ConfigSet::merge_layers`].
2854enum ConfigLayer<'a> {
2855    /// A config file whose includes are expanded when the load processes includes.
2856    File(Cow<'a, ConfigFile>),
2857    /// Entries appended verbatim (`GIT_CONFIG_COUNT` pairs, `-c` overrides read without includes).
2858    Entries(Vec<ConfigEntry>),
2859}
2860
2861impl ConfigLayer<'_> {
2862    fn file(file: ConfigFile) -> Self {
2863        Self::File(Cow::Owned(file))
2864    }
2865}
2866
2867/// Remote URLs that `hasconfig:remote.*.url:` conditions match against, collected from every
2868/// layer of the current load on the first such condition and reused for the rest of it.
2869struct RemoteUrls<'a> {
2870    layers: &'a [ConfigLayer<'a>],
2871    urls: Option<Vec<String>>,
2872}
2873
2874impl<'a> RemoteUrls<'a> {
2875    fn new(layers: &'a [ConfigLayer<'a>]) -> Self {
2876        Self { layers, urls: None }
2877    }
2878
2879    /// Whether any `remote.<name>.url` value in the load matches `pattern` (`WM_PATHNAME`).
2880    fn any_matches(
2881        &mut self,
2882        pattern: &str,
2883        ctx: &IncludeContext,
2884        included_files: &mut Vec<PathBuf>,
2885    ) -> Result<bool> {
2886        let urls = match self.urls.take() {
2887            Some(urls) => urls,
2888            None => self.collect(ctx, included_files)?,
2889        };
2890        let hit = urls
2891            .iter()
2892            .any(|url| wildmatch(pattern.as_bytes(), url.as_bytes(), WM_PATHNAME));
2893        self.urls = Some(urls);
2894        Ok(hit)
2895    }
2896
2897    fn collect(
2898        &self,
2899        ctx: &IncludeContext,
2900        included_files: &mut Vec<PathBuf>,
2901    ) -> Result<Vec<String>> {
2902        let mut urls = Vec::new();
2903        for layer in self.layers {
2904            match layer {
2905                ConfigLayer::File(file) => {
2906                    collect_remote_urls(file, false, 0, ctx, &mut urls, included_files)?;
2907                }
2908                ConfigLayer::Entries(entries) => urls.extend(
2909                    entries
2910                        .iter()
2911                        .filter(|entry| is_remote_url_entry(entry))
2912                        .filter_map(|entry| entry.value.clone()),
2913                ),
2914            }
2915        }
2916        Ok(urls)
2917    }
2918}
2919
2920/// Collect `remote.<name>.url` values from `file` and everything it includes.
2921///
2922/// As in Git, this pass opens every `[includeIf]` target whose condition holds, treating every
2923/// `hasconfig:remote.*.url:` condition as holding. Remote URLs may not come from a conditional
2924/// include (directly or through nested includes), since they could change which conditions hold:
2925/// finding one there is [`ConfigError::RemoteUrlInHasconfigInclude`].
2926fn collect_remote_urls(
2927    file: &ConfigFile,
2928    in_conditional_include: bool,
2929    depth: usize,
2930    ctx: &IncludeContext,
2931    urls: &mut Vec<String>,
2932    included_files: &mut Vec<PathBuf>,
2933) -> Result<()> {
2934    check_include_depth(depth)?;
2935    for entry in &file.entries {
2936        if is_remote_url_entry(entry) {
2937            if in_conditional_include {
2938                return Err(ConfigError::RemoteUrlInHasconfigInclude.into());
2939            }
2940            urls.extend(entry.value.clone());
2941            continue;
2942        }
2943        let Some((inc_path, condition)) = include_directive_for_entry(entry) else {
2944            continue;
2945        };
2946        let conditional = match condition.as_deref() {
2947            None => in_conditional_include,
2948            Some(cond) => {
2949                if hasconfig_remote_url_pattern(cond).is_none()
2950                    && !evaluate_include_condition(cond, file, ctx)
2951                {
2952                    continue;
2953                }
2954                true
2955            }
2956        };
2957        let Some(inc_file) = open_include_target(&inc_path, file, ctx, included_files)? else {
2958            continue;
2959        };
2960        collect_remote_urls(&inc_file, conditional, depth + 1, ctx, urls, included_files)?;
2961    }
2962    Ok(())
2963}
2964
2965/// Fail once include nesting exceeds Git's limit, which stops runaway recursion.
2966fn check_include_depth(depth: usize) -> Result<()> {
2967    const MAX_INCLUDE_DEPTH: usize = 10;
2968    if depth > MAX_INCLUDE_DEPTH {
2969        return Err(ConfigError::IncludeDepthExceeded {
2970            depth,
2971            limit: MAX_INCLUDE_DEPTH,
2972        }
2973        .into());
2974    }
2975    Ok(())
2976}
2977
2978/// Resolve and parse the target of an include directive in `file`, recording the resolved path
2979/// in `included_files` (even when missing) so the cascade cache stamps it.
2980///
2981/// Returns `None` for a missing target or a path Git skips. Parse errors in the target are
2982/// fatal, as in Git (t0001 `re-init reads matching includeIf.onbranch`).
2983fn open_include_target(
2984    inc_path: &str,
2985    file: &ConfigFile,
2986    ctx: &IncludeContext,
2987    included_files: &mut Vec<PathBuf>,
2988) -> Result<Option<ConfigFile>> {
2989    let resolved = match resolve_include_file_path(inc_path, file, ctx) {
2990        Ok(p) => p,
2991        Err(Error::Config(ConfigError::Other(msg))) if msg.is_empty() => return Ok(None),
2992        Err(e) => return Err(e),
2993    };
2994    included_files.push(resolved.clone());
2995    ConfigFile::from_path(&resolved, file.scope)
2996}
2997
2998fn include_directive_for_entry(entry: &ConfigEntry) -> Option<(String, Option<String>)> {
2999    let val = entry.value.as_ref()?;
3000    if entry.key == "include.path" {
3001        return Some((val.clone(), None));
3002    }
3003    if entry.key.starts_with("includeif.") && entry.key.ends_with(".path") {
3004        let mid = &entry.key["includeif.".len()..entry.key.len() - ".path".len()];
3005        return Some((val.clone(), Some(mid.to_owned())));
3006    }
3007    None
3008}
3009
3010// ── Process-lifetime config cascade cache ───────────────────────────
3011//
3012// `ConfigSet::load` / `load_with_options` are called per file in hot loops
3013// (grep/diff/add re-load the cascade for every path), so the parsed cascade
3014// is memoized for the lifetime of the process. A cached entry is served only
3015// when every input that fed it is provably unchanged:
3016//
3017// - the stat stamps (mtime + size, or "absent") of every base cascade file,
3018//   recorded *before* the parse, still match. `HEAD` is stamped too so that
3019//   `includeIf.onbranch:` condition flips invalidate;
3020// - every include target the parse resolved (even ones that were missing)
3021//   still carries the stamp it had at parse time;
3022// - the config-relevant environment is byte-identical.
3023//
3024// The remaining `includeIf` condition inputs are covered transitively:
3025// `gitdir:` depends only on the cache key and environment, and
3026// `hasconfig:remote.*.url:` depends only on the (stamped) file contents —
3027// every file its URL scan opens is recorded as an include target — plus the
3028// command-line config already in the environment fingerprint.
3029//
3030// In-process writers ([`ConfigFile::write`]) additionally evict every entry
3031// whose cascade contains the written path, closing the window where a
3032// rewrite lands within one mtime tick at an unchanged size. External writers
3033// in that same window are not detectable by stat; C git parses the cascade
3034// once per process with no revalidation at all, so serving a stamped copy is
3035// strictly more conservative than upstream.
3036
3037/// Stat snapshot of one cascade file (`None` = file absent).
3038pub(crate) type ConfigFileStamp = (PathBuf, Option<(SystemTime, u64)>);
3039
3040/// Stamp metadata stored with a memoized config cascade entry.
3041pub(crate) type ConfigCacheEntryStamps = (
3042    Vec<(String, Option<String>)>,
3043    Vec<ConfigFileStamp>,
3044    Vec<ConfigFileStamp>,
3045);
3046
3047#[derive(Clone, PartialEq, Eq, Hash)]
3048pub(crate) struct ConfigCacheKey {
3049    pub(crate) git_dir: Option<PathBuf>,
3050    include_system: bool,
3051    process_includes: bool,
3052    command_includes: bool,
3053    ctx_git_dir: Option<PathBuf>,
3054    ctx_relative_include_is_error: bool,
3055}
3056
3057impl ConfigCacheKey {
3058    pub(crate) fn new(git_dir: Option<&Path>, opts: &LoadConfigOptions) -> Self {
3059        Self {
3060            git_dir: git_dir.map(Path::to_path_buf),
3061            include_system: opts.include_system,
3062            process_includes: opts.process_includes,
3063            command_includes: opts.command_includes,
3064            ctx_git_dir: opts.include_ctx.git_dir.clone(),
3065            ctx_relative_include_is_error: opts.include_ctx.command_line_relative_include_is_error,
3066        }
3067    }
3068}
3069
3070pub(crate) struct ConfigCacheEntry {
3071    pub(crate) env_fingerprint: Vec<(String, Option<String>)>,
3072    /// Stamps of the fixed cascade files (recomputable from key + env).
3073    pub(crate) base_stamps: Vec<ConfigFileStamp>,
3074    /// Stamps of the include targets this parse resolved (revalidated by
3075    /// re-statting each stored path).
3076    pub(crate) extra_stamps: Vec<ConfigFileStamp>,
3077    pub(crate) set: Arc<ConfigSet>,
3078}
3079
3080/// The on-disk files [`ConfigSet::load_with_options_uncached`] would consult,
3081/// in cascade order.
3082fn config_cascade_file_paths(
3083    env: &Environment,
3084    git_dir: Option<&Path>,
3085    opts: &LoadConfigOptions,
3086) -> Vec<PathBuf> {
3087    let mut paths = Vec::new();
3088    if opts.include_system && !git_config_nosystem_enabled(env) {
3089        paths.push(system_config_path(env));
3090    }
3091    paths.extend(global_config_paths(env));
3092    if let Some(gd) = git_dir {
3093        let common_dir = crate::repo::common_git_dir_for_config(gd);
3094        paths.push(common_dir.join("config"));
3095        // Stamped unconditionally: whether it is merged depends on
3096        // `extensions.worktreeConfig` in the (stamped) common config.
3097        paths.push(gd.join("config.worktree"));
3098        // `includeIf.onbranch:` conditions read HEAD; stamping it means a
3099        // branch switch invalidates cascades that could depend on it.
3100        paths.push(gd.join("HEAD"));
3101    }
3102    if let Some(cgd) = &opts.include_ctx.git_dir {
3103        if Some(cgd.as_path()) != git_dir {
3104            paths.push(cgd.join("HEAD"));
3105        }
3106    }
3107    if let Some(p) = env.git_config.as_deref() {
3108        paths.push(resolve_config_override_path(env, p));
3109    }
3110    paths
3111}
3112
3113pub(crate) fn stamp_for_path(path: &Path) -> Option<(SystemTime, u64)> {
3114    fs::metadata(path)
3115        .ok()
3116        .and_then(|m| Some((m.modified().ok()?, m.len())))
3117}
3118
3119pub(crate) fn stamp_paths(paths: Vec<PathBuf>) -> Vec<ConfigFileStamp> {
3120    paths
3121        .into_iter()
3122        .map(|path| {
3123            let stamp = stamp_for_path(&path);
3124            (path, stamp)
3125        })
3126        .collect()
3127}
3128
3129pub(crate) fn config_file_stamps(
3130    env: &Environment,
3131    git_dir: Option<&Path>,
3132    opts: &LoadConfigOptions,
3133) -> Vec<ConfigFileStamp> {
3134    stamp_paths(config_cascade_file_paths(env, git_dir, opts))
3135}
3136
3137pub(crate) fn config_cache_lookup(
3138    cache: &HashMap<ConfigCacheKey, ConfigCacheEntry>,
3139    key: &ConfigCacheKey,
3140    env_fp: &[(String, Option<String>)],
3141    base_stamps: &[ConfigFileStamp],
3142) -> Option<Arc<ConfigSet>> {
3143    let entry = cache.get(key)?;
3144    if entry.env_fingerprint.as_slice() != env_fp || entry.base_stamps.as_slice() != base_stamps {
3145        return None;
3146    }
3147    for (path, stamp) in &entry.extra_stamps {
3148        if stamp_for_path(path) != *stamp {
3149            return None;
3150        }
3151    }
3152    Some(Arc::clone(&entry.set))
3153}
3154
3155pub(crate) fn config_cache_store(
3156    cache: &mut HashMap<ConfigCacheKey, ConfigCacheEntry>,
3157    key: ConfigCacheKey,
3158    env_fingerprint: Vec<(String, Option<String>)>,
3159    base_stamps: Vec<ConfigFileStamp>,
3160    extra_stamps: Vec<ConfigFileStamp>,
3161    set: Arc<ConfigSet>,
3162) {
3163    cache.insert(
3164        key,
3165        ConfigCacheEntry {
3166            env_fingerprint,
3167            base_stamps,
3168            extra_stamps,
3169            set,
3170        },
3171    );
3172}
3173
3174fn git_config_nosystem_enabled(env: &Environment) -> bool {
3175    env.git_config_nosystem
3176        .as_deref()
3177        .map(|value| parse_bool(value).unwrap_or(true))
3178        .unwrap_or(false)
3179}
3180
3181/// Entries from `GIT_CONFIG_COUNT` / `GIT_CONFIG_KEY_<n>` / `GIT_CONFIG_VALUE_<n>`.
3182fn environment_config_pair_entries(env: &Environment) -> Result<Vec<ConfigEntry>> {
3183    let Some(count_str) = env.git_config_count.as_deref() else {
3184        return Ok(Vec::new());
3185    };
3186    if count_str.is_empty() {
3187        return Ok(Vec::new());
3188    }
3189
3190    let count = count_str
3191        .parse::<usize>()
3192        .map_err(|_| Error::Config("bogus count in GIT_CONFIG_COUNT".to_owned().into()))?;
3193    if count > i32::MAX as usize {
3194        return Err(Error::Config(
3195            "too many entries in GIT_CONFIG_COUNT".to_owned().into(),
3196        ));
3197    }
3198
3199    let mut set = ConfigSet::new();
3200    for i in 0..count {
3201        let (key, value) = env.git_config_pairs.get(i).ok_or_else(|| {
3202            Error::Config(format!("missing config key GIT_CONFIG_KEY_{i}").into())
3203        })?;
3204        set.add_command_override(key, value)?;
3205    }
3206    Ok(set.entries)
3207}
3208
3209/// `GIT_CONFIG_PARAMETERS` as plain command-scope entries (no include processing); entries
3210/// with an invalid key are skipped.
3211fn command_override_entries(params: &str) -> Vec<ConfigEntry> {
3212    let mut set = ConfigSet::new();
3213    for entry in parse_config_parameters(params) {
3214        let (key, val) = entry
3215            .split_once('\u{1}')
3216            .or_else(|| entry.split_once('='))
3217            .unwrap_or((entry.as_str(), "true"));
3218        let _ = set.add_command_override(key.trim(), val);
3219    }
3220    set.entries
3221}
3222
3223// ── Type coercion helpers ───────────────────────────────────────────
3224
3225/// Parse a Git boolean value.
3226///
3227/// Accepts: `true`, `yes`, `on`, `1` as true.
3228/// Accepts: `false`, `no`, `off`, `0` as false.
3229///
3230/// Note: bare config keys are represented as `None` in [`ConfigEntry`] and
3231/// are normalized to `"true"` by higher-level readers (`ConfigSet::get`).
3232/// An explicit empty assignment (`key =` with no value) is stored as `""` and
3233/// is treated as false for `--bool` / [`parse_bool`]. Bare keys are represented
3234/// as `None` and normalized to `"true"` by callers before reaching this parser.
3235pub fn parse_bool(s: &str) -> std::result::Result<bool, String> {
3236    match s.to_lowercase().as_str() {
3237        "true" | "yes" | "on" => Ok(true),
3238        "" => Ok(false),
3239        "false" | "no" | "off" => Ok(false),
3240        _ => {
3241            parse_git_config_maybe_bool(s).ok_or_else(|| format!("bad boolean config value '{s}'"))
3242        }
3243    }
3244}
3245
3246/// Parse a Git integer value with optional `k`/`m`/`g` suffix.
3247pub fn parse_i64(s: &str) -> std::result::Result<i64, String> {
3248    let s = s.trim();
3249    if s.is_empty() {
3250        return Err("empty integer value".to_owned());
3251    }
3252
3253    let (num_str, multiplier) = match s.as_bytes().last() {
3254        Some(b'k' | b'K') => (&s[..s.len() - 1], 1024_i64),
3255        Some(b'm' | b'M') => (&s[..s.len() - 1], 1024 * 1024),
3256        Some(b'g' | b'G') => (&s[..s.len() - 1], 1024 * 1024 * 1024),
3257        _ => (s, 1_i64),
3258    };
3259
3260    let base: i64 = num_str
3261        .parse()
3262        .map_err(|_| format!("invalid integer: '{s}'"))?;
3263    base.checked_mul(multiplier)
3264        .ok_or_else(|| format!("integer overflow: '{s}'"))
3265}
3266
3267/// Why [`parse_git_config_int_strict`] failed (mirrors Git `errno` after `git_parse_signed`).
3268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3269pub enum GitConfigIntStrictError {
3270    /// `EINVAL` — trailing junk, unknown unit suffix, or not a number.
3271    InvalidUnit,
3272    /// `ERANGE` — value does not fit in `i64` after scaling.
3273    OutOfRange,
3274}
3275
3276/// Parse a signed decimal integer with optional `k`/`m`/`g` multiplier suffix, requiring the
3277/// entire input (trimmed) to be consumed — same constraints as Git's `git_parse_signed` used by
3278/// `git_config_int` (so `no` and `1foo` are rejected, unlike [`parse_i64`]).
3279pub fn parse_git_config_int_strict(raw: &str) -> std::result::Result<i64, GitConfigIntStrictError> {
3280    let s = raw.trim();
3281    if s.is_empty() {
3282        return Err(GitConfigIntStrictError::InvalidUnit);
3283    }
3284
3285    let bytes = s.as_bytes();
3286    let mut idx = 0usize;
3287    if matches!(bytes.first(), Some(b'+') | Some(b'-')) {
3288        idx = 1;
3289    }
3290    if idx >= bytes.len() {
3291        return Err(GitConfigIntStrictError::InvalidUnit);
3292    }
3293    let digit_start = idx;
3294    while idx < bytes.len() && bytes[idx].is_ascii_digit() {
3295        idx += 1;
3296    }
3297    if idx == digit_start {
3298        return Err(GitConfigIntStrictError::InvalidUnit);
3299    }
3300
3301    let num_part =
3302        std::str::from_utf8(&bytes[..idx]).map_err(|_| GitConfigIntStrictError::InvalidUnit)?;
3303    let suffix =
3304        std::str::from_utf8(&bytes[idx..]).map_err(|_| GitConfigIntStrictError::InvalidUnit)?;
3305    let mult: i64 = match suffix {
3306        "" => 1,
3307        "k" | "K" => 1024,
3308        "m" | "M" => 1024 * 1024,
3309        "g" | "G" => 1024_i64
3310            .checked_mul(1024)
3311            .and_then(|x| x.checked_mul(1024))
3312            .ok_or(GitConfigIntStrictError::OutOfRange)?,
3313        _ => return Err(GitConfigIntStrictError::InvalidUnit),
3314    };
3315
3316    let val: i64 = num_part
3317        .parse()
3318        .map_err(|_| GitConfigIntStrictError::InvalidUnit)?;
3319    val.checked_mul(mult)
3320        .ok_or(GitConfigIntStrictError::OutOfRange)
3321}
3322
3323fn is_c_locale_space(b: u8) -> bool {
3324    matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c)
3325}
3326
3327fn skip_c_locale_leading_whitespace(s: &str) -> &str {
3328    let bytes = s.as_bytes();
3329    let mut i = 0usize;
3330    while i < bytes.len() && is_c_locale_space(bytes[i]) {
3331        i += 1;
3332    }
3333    &s[i..]
3334}
3335
3336fn git_unit_factor(suffix: &str) -> std::result::Result<i64, GitConfigIntStrictError> {
3337    match suffix {
3338        "" => Ok(1),
3339        "k" | "K" => Ok(1024),
3340        "m" | "M" => Ok(1024 * 1024),
3341        "g" | "G" => Ok(1024_i64 * 1024 * 1024),
3342        _ => Err(GitConfigIntStrictError::InvalidUnit),
3343    }
3344}
3345
3346/// Parse a base-0 signed integer prefix (Git `strtoimax(..., 0)`), returning unconsumed suffix.
3347fn parse_base0_signed_prefix(s: &str) -> std::result::Result<(i64, &str), GitConfigIntStrictError> {
3348    let bytes = s.as_bytes();
3349    if bytes.is_empty() {
3350        return Err(GitConfigIntStrictError::InvalidUnit);
3351    }
3352    let mut i = 0usize;
3353    let mut sign: i64 = 1;
3354    if bytes[i] == b'+' {
3355        i += 1;
3356    } else if bytes[i] == b'-' {
3357        sign = -1;
3358        i += 1;
3359    }
3360    if i >= bytes.len() {
3361        return Err(GitConfigIntStrictError::InvalidUnit);
3362    }
3363
3364    let base = if bytes[i] == b'0' {
3365        if i + 1 < bytes.len() && matches!(bytes[i + 1], b'x' | b'X') {
3366            i += 2;
3367            if i >= bytes.len() {
3368                return Err(GitConfigIntStrictError::InvalidUnit);
3369            }
3370            16
3371        } else {
3372            8
3373        }
3374    } else {
3375        10
3376    };
3377
3378    let digit_start = i;
3379    let mut acc: i128 = 0;
3380    while i < bytes.len() {
3381        let ch = char::from(bytes[i]);
3382        let digit = match base {
3383            10 => ch.to_digit(10),
3384            8 => ch.to_digit(8),
3385            16 => ch.to_digit(16),
3386            _ => None,
3387        };
3388        let Some(d) = digit else {
3389            break;
3390        };
3391        acc = acc
3392            .checked_mul(base as i128)
3393            .and_then(|v| v.checked_add(i128::from(d)))
3394            .ok_or(GitConfigIntStrictError::OutOfRange)?;
3395        i += 1;
3396    }
3397    if i == digit_start {
3398        return Err(GitConfigIntStrictError::InvalidUnit);
3399    }
3400
3401    let signed = if sign < 0 { -acc } else { acc };
3402    let val = i64::try_from(signed).map_err(|_| GitConfigIntStrictError::OutOfRange)?;
3403    Ok((val, &s[i..]))
3404}
3405
3406/// Parse a signed integer with optional `k`/`m`/`g` scaling using Git base-0 rules (`git_parse_signed`).
3407pub fn parse_git_signed_config(
3408    raw: &str,
3409    max: i64,
3410) -> std::result::Result<i64, GitConfigIntStrictError> {
3411    if max < 0 {
3412        return Err(GitConfigIntStrictError::InvalidUnit);
3413    }
3414    let s = skip_c_locale_leading_whitespace(raw);
3415    if s.is_empty() {
3416        return Err(GitConfigIntStrictError::InvalidUnit);
3417    }
3418
3419    let (val, rest) = parse_base0_signed_prefix(s)?;
3420    let factor = git_unit_factor(rest)?;
3421
3422    if val < 0 {
3423        let min = -max - 1;
3424        if min / factor > val {
3425            return Err(GitConfigIntStrictError::OutOfRange);
3426        }
3427    } else if val > 0 && max / factor < val {
3428        return Err(GitConfigIntStrictError::OutOfRange);
3429    }
3430
3431    val.checked_mul(factor)
3432        .ok_or(GitConfigIntStrictError::OutOfRange)
3433}
3434
3435/// Parse a signed 32-bit config integer (`git_parse_int`).
3436pub fn parse_git_config_int(raw: &str) -> std::result::Result<i32, GitConfigIntStrictError> {
3437    let max = i32::MAX as i64;
3438    let n = parse_git_signed_config(raw, max)?;
3439    i32::try_from(n).map_err(|_| GitConfigIntStrictError::OutOfRange)
3440}
3441
3442/// Parse Git `git_parse_maybe_bool`: `Some(true/false)` or `None` if unrecognized.
3443pub fn parse_git_config_maybe_bool(raw: &str) -> Option<bool> {
3444    if raw.is_empty() {
3445        return Some(false);
3446    }
3447    let lower = raw.to_ascii_lowercase();
3448    match lower.as_str() {
3449        "true" | "yes" | "on" => Some(true),
3450        "false" | "no" | "off" => Some(false),
3451        _ => parse_git_config_int(raw).ok().map(|n| n != 0),
3452    }
3453}
3454
3455const DIFF_CONTEXT_KEY: &str = "diff.context";
3456
3457/// Git's documented default for `gc.auto`.
3458const DEFAULT_GC_AUTO_THRESHOLD: usize = 6700;
3459/// Git's documented default for `gc.autoPackLimit`.
3460const DEFAULT_GC_AUTO_PACK_LIMIT: usize = 50;
3461
3462fn bad_numeric_diff_context(
3463    value: &str,
3464    err: GitConfigIntStrictError,
3465    entry: &ConfigEntry,
3466) -> ConfigError {
3467    let reason = match err {
3468        GitConfigIntStrictError::InvalidUnit => crate::error::BadNumericSource::InvalidUnit,
3469        GitConfigIntStrictError::OutOfRange => crate::error::BadNumericSource::OutOfRange,
3470    };
3471    if entry.scope == ConfigScope::Command || entry.file.is_none() {
3472        ConfigError::BadNumericValue {
3473            key: DIFF_CONTEXT_KEY.to_owned(),
3474            value: value.to_owned(),
3475            reason,
3476        }
3477    } else {
3478        let path = entry
3479            .file
3480            .as_deref()
3481            .map(config_error_path_display)
3482            .unwrap_or_default();
3483        ConfigError::BadNumericValueInFile {
3484            key: DIFF_CONTEXT_KEY.to_owned(),
3485            value: value.to_owned(),
3486            file: path,
3487            reason,
3488        }
3489    }
3490}
3491
3492fn bad_diff_context_variable(entry: &ConfigEntry) -> ConfigError {
3493    if entry.scope == ConfigScope::Command || entry.file.is_none() {
3494        ConfigError::UnableToParseFromCommandLine {
3495            key: DIFF_CONTEXT_KEY.to_owned(),
3496        }
3497    } else {
3498        let path = entry
3499            .file
3500            .as_deref()
3501            .map(config_error_path_display)
3502            .unwrap_or_default();
3503        ConfigError::BadConfigVariableAtLine {
3504            key: DIFF_CONTEXT_KEY.to_owned(),
3505            file: path,
3506            line: entry.line,
3507        }
3508    }
3509}
3510
3511/// Read `diff.context` from a loaded [`ConfigSet`] with Git-compatible validation.
3512///
3513/// Returns `Ok(None)` when the key is unset. When set, the value must be a non-negative integer
3514/// acceptable to Git's diff machinery (same rules as `git diff` / `git log -p`).
3515pub fn resolve_diff_context_lines(cfg: &ConfigSet) -> Result<Option<usize>> {
3516    let Some(entry) = cfg.get_last_entry(DIFF_CONTEXT_KEY) else {
3517        return Ok(None);
3518    };
3519    let value_src = entry.value.as_deref().unwrap_or("").trim();
3520    match parse_git_config_int_strict(value_src) {
3521        Ok(n) if n < 0 => Err(bad_diff_context_variable(&entry).into()),
3522        Ok(n) => Ok(Some(usize::try_from(n).map_err(|_| {
3523            bad_numeric_diff_context(value_src, GitConfigIntStrictError::OutOfRange, &entry)
3524        })?)),
3525        Err(e) => Err(bad_numeric_diff_context(value_src, e, &entry).into()),
3526    }
3527}
3528
3529/// Parse a Git color value and return the ANSI escape sequence.
3530///
3531/// Matches Git's `color_parse_mem` (`git/color.c`): whitespace-separated words,
3532/// optional leading `reset`, up to two color tokens (foreground then background),
3533/// then graphic rendition attributes. Attribute codes are accumulated as a
3534/// bitmask keyed by SGR number (so `bold` sets bit 1, `nobold` sets bit 22).
3535pub fn parse_color(s: &str) -> std::result::Result<String, String> {
3536    const COLOR_BACKGROUND_OFFSET: i32 = 10;
3537    const COLOR_FOREGROUND_ANSI: i32 = 30;
3538    const COLOR_FOREGROUND_RGB: i32 = 38;
3539    const COLOR_FOREGROUND_256: i32 = 38;
3540    const COLOR_FOREGROUND_BRIGHT_ANSI: i32 = 90;
3541
3542    #[derive(Clone, Copy, Default)]
3543    struct Color {
3544        kind: u8,
3545        value: u8,
3546        red: u8,
3547        green: u8,
3548        blue: u8,
3549    }
3550
3551    const COLOR_UNSPECIFIED: u8 = 0;
3552    const COLOR_NORMAL: u8 = 1;
3553    const COLOR_ANSI: u8 = 2;
3554    const COLOR_256: u8 = 3;
3555    const COLOR_RGB: u8 = 4;
3556
3557    fn color_empty(c: &Color) -> bool {
3558        c.kind == COLOR_UNSPECIFIED || c.kind == COLOR_NORMAL
3559    }
3560
3561    fn parse_ansi_color(name: &str) -> Option<Color> {
3562        let color_names = [
3563            "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
3564        ];
3565        let color_offset = COLOR_FOREGROUND_ANSI;
3566
3567        if name.eq_ignore_ascii_case("default") {
3568            return Some(Color {
3569                kind: COLOR_ANSI,
3570                value: (9 + color_offset) as u8,
3571                ..Default::default()
3572            });
3573        }
3574
3575        let (name, color_offset) = if name.len() >= 6 && name[..6].eq_ignore_ascii_case("bright") {
3576            (&name[6..], COLOR_FOREGROUND_BRIGHT_ANSI)
3577        } else {
3578            (name, COLOR_FOREGROUND_ANSI)
3579        };
3580
3581        for (i, cn) in color_names.iter().enumerate() {
3582            if name.eq_ignore_ascii_case(cn) {
3583                return Some(Color {
3584                    kind: COLOR_ANSI,
3585                    value: (i as i32 + color_offset) as u8,
3586                    ..Default::default()
3587                });
3588            }
3589        }
3590        None
3591    }
3592
3593    fn hex_val(b: u8) -> Option<u8> {
3594        match b {
3595            b'0'..=b'9' => Some(b - b'0'),
3596            b'a'..=b'f' => Some(b - b'a' + 10),
3597            b'A'..=b'F' => Some(b - b'A' + 10),
3598            _ => None,
3599        }
3600    }
3601
3602    fn get_hex_color(chars: &[u8], width: usize) -> Option<(u8, usize)> {
3603        assert!(width == 1 || width == 2);
3604        if chars.len() < width {
3605            return None;
3606        }
3607        let v = if width == 2 {
3608            let hi = hex_val(chars[0])?;
3609            let lo = hex_val(chars[1])?;
3610            (hi << 4) | lo
3611        } else {
3612            let n = hex_val(chars[0])?;
3613            (n << 4) | n
3614        };
3615        Some((v, width))
3616    }
3617
3618    fn parse_single_color(word: &str) -> Option<Color> {
3619        if word.eq_ignore_ascii_case("normal") {
3620            return Some(Color {
3621                kind: COLOR_NORMAL,
3622                ..Default::default()
3623            });
3624        }
3625
3626        let bytes = word.as_bytes();
3627        if (bytes.len() == 7 || bytes.len() == 4) && bytes.first() == Some(&b'#') {
3628            let width = if bytes.len() == 7 { 2 } else { 1 };
3629            let mut idx = 1;
3630            let (r, n1) = get_hex_color(&bytes[idx..], width)?;
3631            idx += n1;
3632            let (g, n2) = get_hex_color(&bytes[idx..], width)?;
3633            idx += n2;
3634            let (b, n3) = get_hex_color(&bytes[idx..], width)?;
3635            idx += n3;
3636            if idx != bytes.len() {
3637                return None;
3638            }
3639            return Some(Color {
3640                kind: COLOR_RGB,
3641                red: r,
3642                green: g,
3643                blue: b,
3644                ..Default::default()
3645            });
3646        }
3647
3648        if let Some(c) = parse_ansi_color(word) {
3649            return Some(c);
3650        }
3651
3652        let Ok(val) = word.parse::<i64>() else {
3653            return None;
3654        };
3655        if val < -1 {
3656            return None;
3657        }
3658        if val < 0 {
3659            return Some(Color {
3660                kind: COLOR_NORMAL,
3661                ..Default::default()
3662            });
3663        }
3664        if val < 8 {
3665            return Some(Color {
3666                kind: COLOR_ANSI,
3667                value: (val as i32 + COLOR_FOREGROUND_ANSI) as u8,
3668                ..Default::default()
3669            });
3670        }
3671        if val < 16 {
3672            return Some(Color {
3673                kind: COLOR_ANSI,
3674                value: (val as i32 - 8 + COLOR_FOREGROUND_BRIGHT_ANSI) as u8,
3675                ..Default::default()
3676            });
3677        }
3678        if val < 256 {
3679            return Some(Color {
3680                kind: COLOR_256,
3681                value: val as u8,
3682                ..Default::default()
3683            });
3684        }
3685        None
3686    }
3687
3688    fn parse_attr(word: &str) -> Option<u8> {
3689        const ATTRS: [(&str, u8, u8); 8] = [
3690            ("bold", 1, 22),
3691            ("dim", 2, 22),
3692            ("italic", 3, 23),
3693            ("ul", 4, 24),
3694            ("underline", 4, 24),
3695            ("blink", 5, 25),
3696            ("reverse", 7, 27),
3697            ("strike", 9, 29),
3698        ];
3699
3700        let mut negate = false;
3701        let mut rest = word;
3702        if let Some(stripped) = rest.strip_prefix("no") {
3703            negate = true;
3704            rest = stripped;
3705            if let Some(s) = rest.strip_prefix('-') {
3706                rest = s;
3707            }
3708        }
3709
3710        for (name, val, neg) in ATTRS {
3711            if rest == name {
3712                return Some(if negate { neg } else { val });
3713            }
3714        }
3715        None
3716    }
3717
3718    fn append_color_output(out: &mut String, c: &Color, background: bool) {
3719        let offset = if background {
3720            COLOR_BACKGROUND_OFFSET
3721        } else {
3722            0
3723        };
3724        match c.kind {
3725            COLOR_UNSPECIFIED | COLOR_NORMAL => {}
3726            COLOR_ANSI => {
3727                use std::fmt::Write;
3728                let _ = write!(out, "{}", i32::from(c.value) + offset);
3729            }
3730            COLOR_256 => {
3731                use std::fmt::Write;
3732                let _ = write!(out, "{};5;{}", COLOR_FOREGROUND_256 + offset, c.value);
3733            }
3734            COLOR_RGB => {
3735                use std::fmt::Write;
3736                let _ = write!(
3737                    out,
3738                    "{};2;{};{};{}",
3739                    COLOR_FOREGROUND_RGB + offset,
3740                    c.red,
3741                    c.green,
3742                    c.blue
3743                );
3744            }
3745            _ => {}
3746        }
3747    }
3748
3749    let s = s.trim();
3750    if s.is_empty() {
3751        return Ok(String::new());
3752    }
3753
3754    let mut has_reset = false;
3755    let mut attr: u64 = 0;
3756    let mut fg = Color::default();
3757    let mut bg = Color::default();
3758    fg.kind = COLOR_UNSPECIFIED;
3759    bg.kind = COLOR_UNSPECIFIED;
3760
3761    for word in s.split_whitespace() {
3762        if word.eq_ignore_ascii_case("reset") {
3763            has_reset = true;
3764            continue;
3765        }
3766
3767        if let Some(c) = parse_single_color(word) {
3768            if fg.kind == COLOR_UNSPECIFIED {
3769                fg = c;
3770                continue;
3771            }
3772            if bg.kind == COLOR_UNSPECIFIED {
3773                bg = c;
3774                continue;
3775            }
3776            return Err(format!("bad color value '{s}'"));
3777        }
3778
3779        if let Some(code) = parse_attr(word) {
3780            attr |= 1u64 << u64::from(code);
3781            continue;
3782        }
3783
3784        return Err(format!("bad color value '{s}'"));
3785    }
3786
3787    if !has_reset && attr == 0 && color_empty(&fg) && color_empty(&bg) {
3788        return Err(format!("bad color value '{s}'"));
3789    }
3790
3791    let mut out = String::from("\x1b[");
3792    let mut sep = if has_reset { 1u32 } else { 0u32 };
3793
3794    let mut attr_bits = attr;
3795    let mut i = 0u32;
3796    while attr_bits != 0 {
3797        let bit = 1u64 << i;
3798        if attr_bits & bit == 0 {
3799            i += 1;
3800            continue;
3801        }
3802        attr_bits &= !bit;
3803        if sep > 0 {
3804            out.push(';');
3805        }
3806        sep += 1;
3807        use std::fmt::Write;
3808        let _ = write!(out, "{i}");
3809        i += 1;
3810    }
3811
3812    if !color_empty(&fg) {
3813        if sep > 0 {
3814            out.push(';');
3815        }
3816        sep += 1;
3817        append_color_output(&mut out, &fg, false);
3818    }
3819    if !color_empty(&bg) {
3820        if sep > 0 {
3821            out.push(';');
3822        }
3823        append_color_output(&mut out, &bg, true);
3824    }
3825    out.push('m');
3826    Ok(out)
3827}
3828
3829#[derive(Debug, Clone)]
3830struct UrlParts {
3831    scheme: String,
3832    user: Option<String>,
3833    host: String,
3834    port: Option<String>,
3835    path: String,
3836}
3837
3838#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
3839struct UrlMatchScore {
3840    host_len: usize,
3841    path_len: usize,
3842    user_matched: bool,
3843}
3844
3845fn parse_config_url(url: &str) -> Option<UrlParts> {
3846    let (scheme, rest) = url.split_once("://")?;
3847    let (authority, path) = match rest.find('/') {
3848        Some(idx) => (&rest[..idx], &rest[idx..]),
3849        None => (rest, "/"),
3850    };
3851    let (user, host_port) = match authority.rsplit_once('@') {
3852        Some((user, host)) => (Some(user.to_owned()), host),
3853        None => (None, authority),
3854    };
3855    let (host, port) = match host_port.rsplit_once(':') {
3856        Some((host, port)) if !host.contains(']') => (host, Some(port.to_owned())),
3857        _ => (host_port, None),
3858    };
3859    Some(UrlParts {
3860        scheme: scheme.to_lowercase(),
3861        user,
3862        host: host.to_lowercase(),
3863        port,
3864        path: if path.is_empty() {
3865            "/".to_owned()
3866        } else {
3867            path.trim_end_matches('/').to_owned()
3868        },
3869    })
3870}
3871
3872fn host_matches(pattern: &str, target: &str) -> bool {
3873    let pattern_parts: Vec<&str> = pattern.split('.').collect();
3874    let target_parts: Vec<&str> = target.split('.').collect();
3875    pattern_parts.len() == target_parts.len()
3876        && pattern_parts
3877            .iter()
3878            .zip(target_parts)
3879            .all(|(pattern, target)| *pattern == "*" || *pattern == target)
3880}
3881
3882fn path_match_len(pattern: &str, target: &str) -> Option<usize> {
3883    let pattern = if pattern.is_empty() { "/" } else { pattern };
3884    let target = if target.is_empty() { "/" } else { target };
3885    if pattern == "/" {
3886        return Some(1);
3887    }
3888    let pattern = pattern.trim_end_matches('/');
3889    if target == pattern
3890        || target
3891            .strip_prefix(pattern)
3892            .is_some_and(|rest| rest.starts_with('/'))
3893    {
3894        Some(pattern.len() + 1)
3895    } else {
3896        None
3897    }
3898}
3899
3900fn url_match_score(pattern_url: &str, target_url: &str) -> Option<UrlMatchScore> {
3901    let pattern = parse_config_url(pattern_url)?;
3902    let target = parse_config_url(target_url)?;
3903    if pattern.scheme != target.scheme {
3904        return None;
3905    }
3906    let user_matched = match pattern.user.as_deref() {
3907        Some(user) if target.user.as_deref() == Some(user) => true,
3908        Some(_) => return None,
3909        None => false,
3910    };
3911    if !host_matches(&pattern.host, &target.host) || pattern.port != target.port {
3912        return None;
3913    }
3914    let path_len = path_match_len(&pattern.path, &target.path)?;
3915    Some(UrlMatchScore {
3916        host_len: pattern.host.len(),
3917        path_len,
3918        user_matched,
3919    })
3920}
3921
3922/// Match a URL against a URL pattern from config.
3923pub fn url_matches(pattern_url: &str, target_url: &str) -> bool {
3924    url_match_score(pattern_url, target_url).is_some()
3925}
3926
3927/// Get the best URL match for a specific key.
3928pub fn get_urlmatch_entries<'a>(
3929    entries: &'a [ConfigEntry],
3930    section: &str,
3931    variable: &str,
3932    url: &str,
3933) -> Vec<&'a ConfigEntry> {
3934    let section_lower = section.to_lowercase();
3935    let variable_lower = variable.to_lowercase();
3936    let mut matches: Vec<(UrlMatchScore, &'a ConfigEntry)> = Vec::new();
3937
3938    for entry in entries {
3939        let key = &entry.key;
3940        let first_dot = match key.find('.') {
3941            Some(i) => i,
3942            None => continue,
3943        };
3944        let last_dot = match key.rfind('.') {
3945            Some(i) => i,
3946            None => continue,
3947        };
3948        let entry_section = &key[..first_dot];
3949        let entry_variable = &key[last_dot + 1..];
3950        if entry_section.to_lowercase() != section_lower
3951            || entry_variable.to_lowercase() != variable_lower
3952        {
3953            continue;
3954        }
3955        if first_dot == last_dot {
3956            matches.push((
3957                UrlMatchScore {
3958                    host_len: 0,
3959                    path_len: 0,
3960                    user_matched: false,
3961                },
3962                entry,
3963            ));
3964        } else {
3965            let subsection = &key[first_dot + 1..last_dot];
3966            if let Some(score) = url_match_score(subsection, url) {
3967                matches.push((score, entry));
3968            }
3969        }
3970    }
3971    matches.sort_by_key(|a| a.0);
3972    matches.into_iter().map(|(_, e)| e).collect()
3973}
3974
3975/// Get all matching variables in a section for a given URL.
3976pub fn get_urlmatch_all_in_section(
3977    entries: &[ConfigEntry],
3978    section: &str,
3979    url: &str,
3980) -> Vec<(String, String, ConfigScope)> {
3981    let section_lower = section.to_lowercase();
3982    let mut matches: Vec<(String, UrlMatchScore, String, String, ConfigScope)> = Vec::new();
3983
3984    for entry in entries {
3985        let key = &entry.key;
3986        let first_dot = match key.find('.') {
3987            Some(i) => i,
3988            None => continue,
3989        };
3990        let last_dot = match key.rfind('.') {
3991            Some(i) => i,
3992            None => continue,
3993        };
3994        let entry_section = &key[..first_dot];
3995        if entry_section.to_lowercase() != section_lower {
3996            continue;
3997        }
3998        let entry_variable = &key[last_dot + 1..];
3999        let val = entry.value.as_deref().unwrap_or("");
4000        if first_dot == last_dot {
4001            let canonical = format!("{}.{}", section_lower, entry_variable);
4002            matches.push((
4003                entry_variable.to_lowercase(),
4004                UrlMatchScore {
4005                    host_len: 0,
4006                    path_len: 0,
4007                    user_matched: false,
4008                },
4009                val.to_owned(),
4010                canonical,
4011                entry.scope,
4012            ));
4013        } else {
4014            let subsection = &key[first_dot + 1..last_dot];
4015            if let Some(score) = url_match_score(subsection, url) {
4016                let canonical = format!("{}.{}", section_lower, entry_variable);
4017                matches.push((
4018                    entry_variable.to_lowercase(),
4019                    score,
4020                    val.to_owned(),
4021                    canonical,
4022                    entry.scope,
4023                ));
4024            }
4025        }
4026    }
4027
4028    let mut best: std::collections::BTreeMap<String, (UrlMatchScore, String, String, ConfigScope)> =
4029        std::collections::BTreeMap::new();
4030    for (var, specificity, val, canonical, scope) in matches {
4031        let entry = best.entry(var).or_insert((
4032            UrlMatchScore {
4033                host_len: 0,
4034                path_len: 0,
4035                user_matched: false,
4036            },
4037            String::new(),
4038            String::new(),
4039            scope,
4040        ));
4041        if specificity >= entry.0 {
4042            *entry = (specificity, val, canonical, scope);
4043        }
4044    }
4045    best.into_values()
4046        .map(|(_, val, canonical, scope)| (canonical, val, scope))
4047        .collect()
4048}
4049
4050/// Parse a Git path value (expand `~/` to home directory).
4051/// Parse a path value. Returns the resolved path string.
4052/// Does NOT handle :(optional) prefix — use `parse_path_optional` for that.
4053pub fn parse_path(env: &Environment, s: &str) -> String {
4054    if let Some(rest) = s.strip_prefix("~/") {
4055        if let Some(home) = home_dir(env) {
4056            return home.join(rest).to_string_lossy().to_string();
4057        }
4058    }
4059    s.to_owned()
4060}
4061
4062/// Parse a path value that may have an `:(optional)` prefix.
4063///
4064/// Returns `Some(path)` if the path should be used, `None` if the path
4065/// is optional and does not exist (meaning the entry should be skipped).
4066pub fn parse_path_optional(env: &Environment, s: &str) -> Option<String> {
4067    if let Some(rest) = s.strip_prefix(":(optional)") {
4068        let resolved = parse_path(env, rest);
4069        if std::path::Path::new(&resolved).exists() {
4070            Some(resolved)
4071        } else {
4072            None // optional and missing → skip
4073        }
4074    } else {
4075        Some(parse_path(env, s))
4076    }
4077}
4078
4079// ── Helpers ─────────────────────────────────────────────────────────
4080
4081/// Parse `GIT_CONFIG_PARAMETERS` payloads.
4082///
4083/// We support the common formats seen in tests and wrappers:
4084/// - single-quoted entries: `'key=value'`
4085/// - double-quoted entries: `"key=value"`
4086/// - unquoted `key=value` tokens separated by whitespace
4087///
4088/// Backslash escapes are interpreted minimally inside double quotes.
4089///
4090/// Return the last `key=value` assignment for `key` in a `GIT_CONFIG_PARAMETERS` payload.
4091///
4092/// Matches Git's command-line config layering: later tokens win. Keys are canonicalized the same
4093/// way as file-backed config (`fetch.output` and `FETCH.Output` both match `fetch.output`).
4094#[must_use]
4095pub fn git_config_parameters_last_value(raw: &str, key: &str) -> Option<String> {
4096    let Ok(canon) = canonical_key(key) else {
4097        return None;
4098    };
4099    let mut last: Option<String> = None;
4100    for entry in parse_config_parameters_strict(raw).ok()? {
4101        match entry {
4102            ConfigParameter::Pair { key, value } => {
4103                if canonical_key(key.trim()).ok().as_ref() == Some(&canon) {
4104                    last = Some(value.unwrap_or_else(|| "true".to_owned()));
4105                }
4106            }
4107            ConfigParameter::OldStyle(entry) => {
4108                if let Some((k, v)) = entry.split_once('=') {
4109                    if canonical_key(k.trim()).ok().as_ref() == Some(&canon) {
4110                        last = Some(v.to_owned());
4111                    }
4112                } else if canonical_key(entry.trim()).ok().as_ref() == Some(&canon) {
4113                    last = Some("true".to_owned());
4114                }
4115            }
4116        }
4117    }
4118    last
4119}
4120
4121#[derive(Debug, Clone, PartialEq, Eq)]
4122enum ConfigParameter {
4123    OldStyle(String),
4124    Pair { key: String, value: Option<String> },
4125}
4126
4127/// Parse `GIT_CONFIG_PARAMETERS` / `-c key=value` style parameter strings.
4128///
4129/// Returns Git's internal NUL-delimited representation used when bootstrapping config.
4130pub fn parse_config_parameters(raw: &str) -> Vec<String> {
4131    parse_config_parameters_strict(raw)
4132        .map(|entries| {
4133            entries
4134                .into_iter()
4135                .map(|entry| match entry {
4136                    ConfigParameter::OldStyle(entry) => entry,
4137                    ConfigParameter::Pair {
4138                        key,
4139                        value: Some(value),
4140                    } => format!("{key}\u{1}{value}"),
4141                    ConfigParameter::Pair { key, value: None } => format!("{key}\u{1}"),
4142                })
4143                .collect()
4144        })
4145        .unwrap_or_default()
4146}
4147
4148fn parse_config_parameters_strict(raw: &str) -> Result<Vec<ConfigParameter>> {
4149    let mut out: Vec<ConfigParameter> = Vec::new();
4150    let chars: Vec<char> = raw.chars().collect();
4151    let mut idx = skip_config_parameter_spaces(&chars, 0);
4152
4153    while idx < chars.len() {
4154        let (key, next) = sq_dequote_step_chars(&chars, idx)?;
4155        let Some(next_idx) = next else {
4156            out.push(ConfigParameter::OldStyle(key));
4157            break;
4158        };
4159
4160        if chars[next_idx].is_whitespace() {
4161            out.push(ConfigParameter::OldStyle(key));
4162            idx = skip_config_parameter_spaces(&chars, next_idx);
4163            continue;
4164        }
4165
4166        if chars[next_idx] != '=' {
4167            return Err(Error::Config(
4168                "bogus format in GIT_CONFIG_PARAMETERS".to_owned().into(),
4169            ));
4170        }
4171
4172        let value_start = next_idx + 1;
4173        if value_start >= chars.len() || chars[value_start].is_whitespace() {
4174            out.push(ConfigParameter::Pair { key, value: None });
4175            idx = skip_config_parameter_spaces(&chars, value_start);
4176            continue;
4177        }
4178
4179        if chars[value_start] != '\'' {
4180            return Err(Error::Config(
4181                "bogus format in GIT_CONFIG_PARAMETERS".to_owned().into(),
4182            ));
4183        }
4184        let (value, value_next) = sq_dequote_step_chars(&chars, value_start)?;
4185        if let Some(value_next) = value_next {
4186            if !chars[value_next].is_whitespace() {
4187                return Err(Error::Config(
4188                    "bogus format in GIT_CONFIG_PARAMETERS".to_owned().into(),
4189                ));
4190            }
4191            idx = skip_config_parameter_spaces(&chars, value_next);
4192        } else {
4193            idx = chars.len();
4194        }
4195        out.push(ConfigParameter::Pair {
4196            key,
4197            value: Some(value),
4198        });
4199    }
4200
4201    Ok(out)
4202}
4203
4204fn skip_config_parameter_spaces(chars: &[char], mut idx: usize) -> usize {
4205    while idx < chars.len() && chars[idx].is_whitespace() {
4206        idx += 1;
4207    }
4208    idx
4209}
4210
4211fn sq_dequote_step_chars(chars: &[char], start: usize) -> Result<(String, Option<usize>)> {
4212    if chars.get(start) != Some(&'\'') {
4213        return Err(Error::Config(
4214            "bogus format in GIT_CONFIG_PARAMETERS".to_owned().into(),
4215        ));
4216    }
4217
4218    let mut out = String::new();
4219    let mut idx = start + 1;
4220    loop {
4221        let Some(&ch) = chars.get(idx) else {
4222            return Err(Error::Config(
4223                "bogus format in GIT_CONFIG_PARAMETERS".to_owned().into(),
4224            ));
4225        };
4226        if ch != '\'' {
4227            out.push(ch);
4228            idx += 1;
4229            continue;
4230        }
4231
4232        idx += 1;
4233        match chars.get(idx).copied() {
4234            None => return Ok((out, None)),
4235            Some('\\')
4236                if chars
4237                    .get(idx + 1)
4238                    .copied()
4239                    .is_some_and(needs_sq_backslash_quote)
4240                    && chars.get(idx + 2) == Some(&'\'') =>
4241            {
4242                if let Some(escaped) = chars.get(idx + 1) {
4243                    out.push(*escaped);
4244                }
4245                idx += 3;
4246            }
4247            _ => return Ok((out, Some(idx))),
4248        }
4249    }
4250}
4251
4252fn needs_sq_backslash_quote(ch: char) -> bool {
4253    ch == '\'' || ch == '!'
4254}
4255
4256/// Resolve a config file path from `GIT_CONFIG*` overrides relative to [`Environment::cwd`].
4257pub(crate) fn resolve_config_override_path(env: &Environment, path: &str) -> PathBuf {
4258    let p = Path::new(path);
4259    if p.is_absolute() {
4260        p.to_path_buf()
4261    } else {
4262        env.cwd.join(p)
4263    }
4264}
4265
4266/// Return candidate paths for the global config file, in priority order.
4267/// Public accessor for the ordered list of global config file paths.
4268pub fn global_config_paths_pub(env: &Environment) -> Vec<PathBuf> {
4269    global_config_paths(env)
4270}
4271
4272fn global_config_paths(env: &Environment) -> Vec<PathBuf> {
4273    let mut paths = Vec::new();
4274
4275    // $GIT_CONFIG_GLOBAL overrides
4276    if let Some(p) = env.git_config_global.as_deref() {
4277        paths.push(resolve_config_override_path(env, p));
4278        return paths;
4279    }
4280
4281    // Git order: XDG `git/config` first, then `~/.gitconfig` (see `git_global_config_paths`).
4282    if let Some(xdg) = env.xdg_config_home.as_deref() {
4283        paths.push(PathBuf::from(xdg).join("git/config"));
4284    } else if let Some(home) = home_dir(env) {
4285        paths.push(home.join(".config/git/config"));
4286    }
4287    if let Some(home) = home_dir(env) {
4288        paths.push(home.join(".gitconfig"));
4289    }
4290
4291    paths
4292}
4293
4294/// Path to the system-wide Git configuration file.
4295///
4296/// Honors `GIT_CONFIG_SYSTEM`. On Windows, falls back to Git for Windows'
4297/// `etc/gitconfig` when `/etc/gitconfig` is absent (stock shells, not MSYS).
4298#[must_use]
4299pub fn system_config_path(env: &Environment) -> PathBuf {
4300    if let Some(p) = env.git_config_system.as_deref() {
4301        return resolve_config_override_path(env, p);
4302    }
4303    let unix_default = PathBuf::from("/etc/gitconfig");
4304    #[cfg(windows)]
4305    {
4306        if unix_default.is_file() {
4307            return unix_default;
4308        }
4309        if let Some(root) = env.git_install_root.as_ref() {
4310            let candidate = PathBuf::from(root).join("etc").join("gitconfig");
4311            if candidate.is_file() {
4312                return candidate;
4313            }
4314        }
4315        for pf in [
4316            env.program_files.as_deref(),
4317            env.program_files_x86.as_deref(),
4318        ]
4319        .into_iter()
4320        .flatten()
4321        {
4322            let candidate = PathBuf::from(pf).join("Git").join("etc").join("gitconfig");
4323            if candidate.is_file() {
4324                return candidate;
4325            }
4326        }
4327    }
4328    unix_default
4329}
4330
4331/// Return the user's home directory.
4332///
4333/// `$HOME` is honored on every platform. On Windows, where `$HOME` is usually
4334/// unset outside a Git-for-Windows/MSYS shell, we fall back to the same sources
4335/// Git does: `%HOMEDRIVE%%HOMEPATH%`, then `%USERPROFILE%`. Without this, global
4336/// config (`~/.gitconfig`) would be invisible on a stock Windows shell.
4337fn home_dir(env: &Environment) -> Option<PathBuf> {
4338    if let Some(home) = env.home.as_ref().filter(|h| !h.is_empty()) {
4339        return Some(PathBuf::from(home));
4340    }
4341    #[cfg(windows)]
4342    {
4343        if let (Some(drive), Some(path)) = (
4344            env.homedrive.as_ref().filter(|d| !d.is_empty()),
4345            env.homepath.as_ref().filter(|p| !p.is_empty()),
4346        ) {
4347            let mut combined = drive.clone();
4348            combined.push(path);
4349            return Some(PathBuf::from(combined));
4350        }
4351        if let Some(profile) = env.userprofile.as_ref().filter(|p| !p.is_empty()) {
4352            return Some(PathBuf::from(profile));
4353        }
4354    }
4355    None
4356}
4357
4358/// True when Git would treat the config source as `CONFIG_ORIGIN_FILE` for includes.
4359fn include_source_is_disk_file(file: &ConfigFile) -> bool {
4360    file.include_origin == ConfigIncludeOrigin::Disk
4361}
4362
4363/// Resolve an include file path (Git `handle_path_include` semantics).
4364///
4365/// Relative paths are only allowed when the including config came from a real on-disk file.
4366fn resolve_include_file_path(
4367    path: &str,
4368    file: &ConfigFile,
4369    ctx: &IncludeContext,
4370) -> Result<PathBuf> {
4371    let expanded = parse_path(ctx.env.as_ref(), path);
4372    let p = Path::new(&expanded);
4373    if p.is_absolute() {
4374        return Ok(p.to_path_buf());
4375    }
4376    if !include_source_is_disk_file(file) {
4377        if file.include_origin == ConfigIncludeOrigin::CommandLine {
4378            if ctx.command_line_relative_include_is_error {
4379                return Err(Error::Config(
4380                    "relative config includes must come from files"
4381                        .to_owned()
4382                        .into(),
4383                ));
4384            }
4385            return Err(Error::Config(String::new().into()));
4386        }
4387        return Err(Error::Config(
4388            "relative config includes must come from files"
4389                .to_owned()
4390                .into(),
4391        ));
4392    }
4393    let base = match file.path.parent() {
4394        Some(p) if !p.as_os_str().is_empty() => p,
4395        Some(_) | None => Path::new("."),
4396    };
4397    Ok(base.join(p))
4398}
4399
4400fn is_dir_sep(b: u8) -> bool {
4401    b == b'/' || b == b'\\'
4402}
4403
4404fn add_trailing_starstar_for_dir(pat: &mut String) {
4405    let bytes = pat.as_bytes();
4406    if bytes.last().is_some_and(|&b| is_dir_sep(b)) {
4407        pat.push_str("**");
4408    }
4409}
4410
4411/// Prepare a `gitdir:` / `gitdir/i:` pattern (Git `prepare_include_condition_pattern`).
4412fn prepare_gitdir_pattern(
4413    condition: &str,
4414    file: &ConfigFile,
4415    ctx: &IncludeContext,
4416) -> Result<(String, usize)> {
4417    // Git `interpolate_path`: expand `~/` in the condition before pattern rules.
4418    let mut pat = parse_path(ctx.env.as_ref(), condition);
4419    if pat.starts_with("./") || pat.starts_with(".\\") {
4420        if !include_source_is_disk_file(file) {
4421            return Err(Error::Config(
4422                "relative config include conditionals must come from files"
4423                    .to_owned()
4424                    .into(),
4425            ));
4426        }
4427        let parent = file.path.parent().ok_or_else(|| {
4428            Error::Config(
4429                "relative config include conditionals must come from files"
4430                    .to_owned()
4431                    .into(),
4432            )
4433        })?;
4434        let real = parent.canonicalize_simplified().map_err(Error::Io)?;
4435        let mut dir = real.to_string_lossy().into_owned();
4436        if !dir.ends_with('/') && !dir.ends_with('\\') {
4437            dir.push('/');
4438        }
4439        let rest = &pat[2..];
4440        pat = format!("{dir}{rest}");
4441        let prefix_len = dir.len();
4442        add_trailing_starstar_for_dir(&mut pat);
4443        return Ok((pat, prefix_len));
4444    }
4445    let p = Path::new(&pat);
4446    if !p.is_absolute() {
4447        pat.insert_str(0, "**/");
4448    }
4449    add_trailing_starstar_for_dir(&mut pat);
4450    Ok((pat, 0))
4451}
4452
4453/// Git `include_by_gitdir` tries `strbuf_realpath` first, then `strbuf_add_absolute_path` if no match.
4454///
4455/// `text_abs` uses `$PWD` (which preserves symlinks) when available, matching Git's
4456/// `strbuf_add_absolute_path` behaviour. This lets `gitdir:bar/` match when `bar` is a symlink.
4457fn git_dir_match_texts(ctx: &IncludeContext, git_dir: &Path) -> (String, String) {
4458    let real = git_dir
4459        .canonicalize_simplified()
4460        .map(|p| p.to_string_lossy().into_owned())
4461        .unwrap_or_else(|_| git_dir.to_string_lossy().into_owned());
4462    // Build the non-canonical absolute path using $PWD (symlink-preserving) when available.
4463    // Git C uses `strbuf_add_absolute_path` which prefers $PWD over getcwd() to preserve symlinks.
4464    let abs = if git_dir.is_absolute() {
4465        // If git_dir is already canonical, try to reconstruct the symlink-preserving variant
4466        // by replacing the canonical cwd prefix with $PWD.
4467        let pwd_abs = ctx.pwd.as_deref().and_then(|pwd| {
4468            let pwd_path = std::path::Path::new(pwd);
4469            if !pwd_path.is_absolute() {
4470                return None;
4471            }
4472            let pwd_canon = pwd_path.canonicalize_simplified().ok()?;
4473            let git_dir_str = git_dir.to_string_lossy();
4474            let pwd_canon_str = pwd_canon.to_string_lossy();
4475            // If git_dir starts with the canonical cwd, replace that prefix with $PWD
4476            let suffix = git_dir_str.strip_prefix(pwd_canon_str.as_ref())?;
4477            Some(format!("{pwd}{suffix}"))
4478        });
4479        pwd_abs.unwrap_or_else(|| git_dir.to_string_lossy().into_owned())
4480    } else {
4481        ctx.cwd.join(git_dir).to_string_lossy().into_owned()
4482    };
4483    (real, abs)
4484}
4485
4486fn include_by_gitdir(
4487    condition: &str,
4488    file: &ConfigFile,
4489    ctx: &IncludeContext,
4490    icase: bool,
4491) -> bool {
4492    let Some(git_dir) = ctx.git_dir.as_ref() else {
4493        return false;
4494    };
4495    let (pattern, prefix) = match prepare_gitdir_pattern(condition, file, ctx) {
4496        Ok(x) => x,
4497        Err(_) => return false,
4498    };
4499    let flags = WM_PATHNAME | if icase { WM_CASEFOLD } else { 0 };
4500    let (text_real, text_abs) = git_dir_match_texts(ctx, git_dir);
4501    let try_match = |text: &str| -> bool {
4502        let t = text.as_bytes();
4503        let p = pattern.as_bytes();
4504        if prefix > 0 {
4505            if t.len() < prefix {
4506                return false;
4507            }
4508            let pre = &p[..prefix];
4509            let te = &t[..prefix];
4510            let ok = if icase {
4511                pre.eq_ignore_ascii_case(te)
4512            } else {
4513                pre == te
4514            };
4515            if !ok {
4516                return false;
4517            }
4518            return wildmatch(&p[prefix..], &t[prefix..], flags);
4519        }
4520        wildmatch(p, t, flags)
4521    };
4522    if try_match(&text_real) {
4523        return true;
4524    }
4525    text_real != text_abs && try_match(&text_abs)
4526}
4527
4528fn current_branch_short_name(git_dir: Option<&Path>) -> Option<String> {
4529    let gd = git_dir?;
4530    let target = refs::read_symbolic_ref(gd, "HEAD").ok()??;
4531    let rest = target.strip_prefix("refs/heads/")?;
4532    Some(rest.to_owned())
4533}
4534
4535fn include_by_onbranch(condition: &str, ctx: &IncludeContext) -> bool {
4536    let Some(short) = current_branch_short_name(ctx.git_dir.as_deref()) else {
4537        return false;
4538    };
4539    let mut pattern = condition.to_owned();
4540    add_trailing_starstar_for_dir(&mut pattern);
4541    wildmatch(pattern.as_bytes(), short.as_bytes(), WM_PATHNAME)
4542}
4543
4544/// Whether `entry` is `remote.<name>.url` (keys are canonical: section and variable lowercase).
4545fn is_remote_url_entry(entry: &ConfigEntry) -> bool {
4546    entry
4547        .key
4548        .strip_prefix("remote.")
4549        .is_some_and(|rest| rest.ends_with(".url"))
4550}
4551
4552/// The URL glob of a `hasconfig:remote.*.url:<glob>` include condition.
4553fn hasconfig_remote_url_pattern(condition: &str) -> Option<&str> {
4554    condition.strip_prefix("hasconfig:remote.*.url:")
4555}
4556
4557/// Evaluate an `[includeIf]` condition.
4558///
4559/// Supports `gitdir:`, `gitdir/i:` and `onbranch:` like Git. `hasconfig:remote.*.url:` needs
4560/// the whole load's remote URLs and is evaluated by [`RemoteUrls::any_matches`]. Unknown
4561/// prefixes are false.
4562fn evaluate_include_condition(condition: &str, file: &ConfigFile, ctx: &IncludeContext) -> bool {
4563    if let Some(rest) = condition.strip_prefix("gitdir/i:") {
4564        return include_by_gitdir(rest, file, ctx, true);
4565    }
4566    if let Some(rest) = condition.strip_prefix("gitdir:") {
4567        return include_by_gitdir(rest, file, ctx, false);
4568    }
4569    if let Some(rest) = condition.strip_prefix("onbranch:") {
4570        return include_by_onbranch(rest, ctx);
4571    }
4572    false
4573}
4574
4575/// Split a canonical key into (section, subsection, variable).
4576fn split_key(key: &str) -> Result<(String, Option<String>, String)> {
4577    let first_dot = key
4578        .find('.')
4579        .ok_or_else(|| Error::Config(format!("invalid key: '{key}'").into()))?;
4580    let last_dot = key
4581        .rfind('.')
4582        .ok_or_else(|| Error::Config(format!("invalid key: '{key}'").into()))?;
4583
4584    let section = key[..first_dot].to_owned();
4585    let variable = key[last_dot + 1..].to_owned();
4586
4587    let subsection = if first_dot == last_dot {
4588        None
4589    } else {
4590        Some(key[first_dot + 1..last_dot].to_owned())
4591    };
4592
4593    Ok((section, subsection, variable))
4594}
4595
4596/// Parse a section name that may contain a subsection (e.g. `"remote.origin"`).
4597///
4598/// Returns (section, subsection).
4599fn parse_section_name(name: &str) -> (&str, Option<&str>) {
4600    match name.find('.') {
4601        Some(i) => (&name[..i], Some(&name[i + 1..])),
4602        None => (name, None),
4603    }
4604}
4605
4606fn section_matches(parser: &Parser, section_lower: &str, subsection: Option<&str>) -> bool {
4607    if parser.section.to_lowercase() == section_lower && parser.subsection.as_deref() == subsection
4608    {
4609        return true;
4610    }
4611    let Some(subsection) = subsection else {
4612        return false;
4613    };
4614    parser.subsection.is_none()
4615        && parser.section.to_lowercase() == format!("{section_lower}.{}", subsection.to_lowercase())
4616}
4617
4618/// Path to the lock file Git uses when updating a config file (`config.lock`).
4619#[must_use]
4620pub fn config_lock_path(path: &Path) -> PathBuf {
4621    let file_name = path
4622        .file_name()
4623        .and_then(|s| s.to_str())
4624        .unwrap_or("config");
4625    path.with_file_name(format!("{file_name}.lock"))
4626}
4627
4628fn validate_section_name(section: &str, subsection: Option<&str>) -> Result<()> {
4629    if section.is_empty()
4630        || !section
4631            .chars()
4632            .all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
4633        || subsection.is_some_and(str::is_empty)
4634    {
4635        return Err(Error::Config(
4636            format!("invalid section name: {section}").into(),
4637        ));
4638    }
4639    Ok(())
4640}
4641
4642/// Extract the original-case variable name from a raw (user-typed) key.
4643///
4644/// E.g. `"Section.Movie"` → `"Movie"`, `"a.b.CamelCase"` → `"CamelCase"`.
4645fn raw_variable_name(raw_key: &str) -> &str {
4646    match raw_key.rfind('.') {
4647        Some(i) => &raw_key[i + 1..],
4648        None => raw_key,
4649    }
4650}
4651
4652/// Extract the original-case section and subsection from a raw (user-typed) key.
4653///
4654/// E.g. `"Section.key"` → `("Section", None)`,
4655///      `"Remote.origin.url"` → `("Remote", Some("origin"))`.
4656fn raw_section_parts(raw_key: &str) -> (String, Option<String>) {
4657    let first_dot = match raw_key.find('.') {
4658        Some(i) => i,
4659        None => return (raw_key.to_owned(), None),
4660    };
4661    // rfind always succeeds here since we already found at least one dot above.
4662    let last_dot = match raw_key.rfind('.') {
4663        Some(i) => i,
4664        None => return (raw_key[..first_dot].to_owned(), None),
4665    };
4666    let section = raw_key[..first_dot].to_owned();
4667    if first_dot == last_dot {
4668        (section, None)
4669    } else {
4670        let subsection = raw_key[first_dot + 1..last_dot].to_owned();
4671        (section, Some(subsection))
4672    }
4673}
4674
4675/// Check if a raw line is a section header that also contains an inline key=value.
4676fn is_section_header_with_inline_entry(line: &str) -> bool {
4677    let trimmed = line.trim();
4678    if !trimmed.starts_with('[') {
4679        return false;
4680    }
4681    let end = match trimmed.find(']') {
4682        Some(i) => i,
4683        None => return false,
4684    };
4685    let after = trimmed[end + 1..].trim();
4686    // Has non-comment content after the ]
4687    !after.is_empty() && !after.starts_with('#') && !after.starts_with(';')
4688}
4689
4690/// Extract just the section header portion (up to and including `]` and any
4691/// comment after it, but not any inline key=value) from a raw line.
4692fn extract_section_header(line: &str) -> String {
4693    let trimmed = line.trim();
4694    let end = match trimmed.find(']') {
4695        Some(i) => i,
4696        None => return line.to_owned(),
4697    };
4698    // Preserve any comment on the section header itself (between ] and key),
4699    // but git doesn't really do this. Just return up to ].
4700    trimmed[..=end].to_owned()
4701}
4702
4703#[cfg(test)]
4704mod pack_object_config_tests {
4705    use super::{ConfigFile, ConfigScope, ConfigSet};
4706    use std::path::Path;
4707
4708    fn set_from_snippet(text: &str) -> ConfigSet {
4709        let path = Path::new(".git/config");
4710        let file = ConfigFile::parse(path, text, ConfigScope::Local).expect("parse config snippet");
4711        let mut set = ConfigSet::new();
4712        set.merge(&file);
4713        set
4714    }
4715
4716    #[test]
4717    fn pack_object_window_and_depth_defaults() {
4718        let set = ConfigSet::new();
4719        assert_eq!(set.pack_object_window(), 10);
4720        assert_eq!(set.pack_object_depth(), crate::pack::DEFAULT_PACK_DEPTH);
4721    }
4722
4723    #[test]
4724    fn pack_object_window_and_depth_preserve_explicit_zero() {
4725        let set = set_from_snippet("[pack]\n\twindow = 0\n\tdepth = 0\n");
4726        assert_eq!(set.pack_object_window(), 0);
4727        assert_eq!(set.pack_object_depth(), 0);
4728    }
4729
4730    #[test]
4731    fn gc_auto_limits_from_config() {
4732        let set = set_from_snippet("[gc]\n\tauto = 100\n\tautoPackLimit = 3\n");
4733        assert_eq!(set.gc_auto_threshold(), Some(100));
4734        assert_eq!(set.gc_auto_pack_limit(), Some(3));
4735        let defaults = ConfigSet::new();
4736        assert_eq!(defaults.gc_auto_threshold(), Some(6700));
4737        assert_eq!(defaults.gc_auto_pack_limit(), Some(50));
4738        assert!(defaults.gc_auto_detach());
4739        assert!(defaults.maintenance_auto());
4740        let disabled = set_from_snippet("[gc]\n\tautoPackLimit = 0\n");
4741        assert_eq!(disabled.gc_auto_pack_limit(), None);
4742        assert_eq!(disabled.gc_auto_threshold(), Some(6700));
4743        let auto_off = set_from_snippet("[gc]\n\tauto = 0\n");
4744        assert_eq!(auto_off.gc_auto_threshold(), None);
4745        let negative = set_from_snippet("[gc]\n\tauto = -1\n\tautoPackLimit = -1\n");
4746        assert_eq!(negative.gc_auto_threshold(), None);
4747        assert_eq!(negative.gc_auto_pack_limit(), None);
4748        let unparsable = set_from_snippet("[gc]\n\tauto = lots\n");
4749        assert_eq!(unparsable.gc_auto_threshold(), Some(6700));
4750        let foreground =
4751            set_from_snippet("[gc]\n\tautoDetach = false\n[maintenance]\n\tauto = false\n");
4752        assert!(!foreground.gc_auto_detach());
4753        assert!(!foreground.maintenance_auto());
4754    }
4755}
4756
4757#[cfg(test)]
4758mod get_regexp_tests {
4759    use super::{ConfigFile, ConfigScope, ConfigSet};
4760    use std::path::Path;
4761
4762    fn set_from_snippet(text: &str) -> ConfigSet {
4763        let path = Path::new(".git/config");
4764        let file = ConfigFile::parse(path, text, ConfigScope::Local).expect("parse config snippet");
4765        let mut set = ConfigSet::new();
4766        set.merge(&file);
4767        set
4768    }
4769
4770    #[test]
4771    fn get_regexp_matches_section_prefix_like_git_config() {
4772        let text = r#"
4773[user]
4774    email = alice@example.com
4775    name = Alice
4776[core]
4777    bare = false
4778"#;
4779        let set = set_from_snippet(text);
4780        let keys: Vec<_> = set
4781            .get_regexp("user")
4782            .expect("valid pattern")
4783            .into_iter()
4784            .map(|e| e.key.as_str())
4785            .collect();
4786        assert!(keys.contains(&"user.email"));
4787        assert!(keys.contains(&"user.name"));
4788        assert!(!keys.iter().any(|k| k.starts_with("core.")));
4789    }
4790
4791    #[test]
4792    fn get_regexp_returns_all_multi_value_entries_in_order() {
4793        let text = r#"
4794[remote "origin"]
4795    url = https://example.com/repo.git
4796    fetch = +refs/heads/*:refs/remotes/origin/*
4797    push = +refs/heads/main:refs/heads/main
4798    push = +refs/heads/develop:refs/heads/develop
4799"#;
4800        let set = set_from_snippet(text);
4801        let matches = set.get_regexp("remote.origin").expect("valid pattern");
4802        let push_vals: Vec<_> = matches
4803            .iter()
4804            .filter(|e| e.key == "remote.origin.push")
4805            .map(|e| e.value.as_deref().unwrap_or(""))
4806            .collect();
4807        assert_eq!(push_vals.len(), 2);
4808        assert_eq!(push_vals[0], "+refs/heads/main:refs/heads/main");
4809        assert_eq!(push_vals[1], "+refs/heads/develop:refs/heads/develop");
4810    }
4811
4812    #[test]
4813    fn get_regexp_dot_matches_any_key() {
4814        let text = r#"
4815[a]
4816    x = 1
4817[b]
4818    y = 2
4819"#;
4820        let set = set_from_snippet(text);
4821        let m = set.get_regexp(".").expect("valid pattern");
4822        assert_eq!(m.len(), 2);
4823    }
4824
4825    #[test]
4826    fn get_regexp_no_match_returns_empty_vec() {
4827        let set = set_from_snippet("[user]\n\tname = x\n");
4828        let m = set.get_regexp("zzz").expect("valid pattern");
4829        assert!(m.is_empty());
4830    }
4831
4832    #[test]
4833    fn get_regexp_invalid_pattern_is_error() {
4834        let set = set_from_snippet("[user]\n\tname = x\n");
4835        let err = set.get_regexp("(").expect_err("unclosed group");
4836        assert!(err.contains("invalid key pattern"), "got: {err}");
4837    }
4838
4839    #[test]
4840    fn valueless_negotiation_algorithm_is_a_typed_config_error() {
4841        use crate::error::{ConfigError, Error};
4842
4843        let path = Path::new("/repo/.git/config");
4844        for (text, line) in [
4845            (
4846                "[core]\n\tbare = false\n[fetch]\n\tnegotiationAlgorithm\n",
4847                4,
4848            ),
4849            ("[fetch] negotiationAlgorithm\n", 1),
4850        ] {
4851            let err = ConfigFile::parse(path, text, ConfigScope::Local).expect_err(text);
4852            let Error::Config(config_err) = err else {
4853                panic!("expected a config error for {text:?}, got {err:?}");
4854            };
4855            assert_eq!(
4856                config_err,
4857                ConfigError::BadConfigVariable {
4858                    key: "fetch.negotiationalgorithm".to_owned(),
4859                    file: ".git/config".to_owned(),
4860                    line,
4861                },
4862                "{text:?}"
4863            );
4864        }
4865
4866        let ok = ConfigFile::parse(
4867            path,
4868            "[fetch]\n\tnegotiationAlgorithm = skipping\n",
4869            ConfigScope::Local,
4870        )
4871        .expect("valued key parses");
4872        assert_eq!(ok.entries[0].value.as_deref(), Some("skipping"));
4873    }
4874}
4875
4876#[cfg(test)]
4877mod loose_compression_tests {
4878    use std::path::Path;
4879
4880    use super::{ConfigFile, ConfigScope, ConfigSet, Error};
4881
4882    fn set_from_snippet(text: &str) -> ConfigSet {
4883        let path = Path::new(".git/config");
4884        let file = ConfigFile::parse(path, text, ConfigScope::Local).expect("parse config snippet");
4885        let mut set = ConfigSet::new();
4886        set.merge(&file);
4887        set
4888    }
4889
4890    #[test]
4891    fn big_file_threshold_default_suffixes_and_errors() {
4892        assert_eq!(
4893            ConfigSet::new().big_file_threshold().unwrap(),
4894            512 * 1024 * 1024
4895        );
4896        let cases = [
4897            ("1000", 1000),
4898            ("0", 0),
4899            ("4k", 4096),
4900            ("2m", 2 * 1024 * 1024),
4901            ("1g", 1024 * 1024 * 1024),
4902            ("8G", 8 * 1024 * 1024 * 1024),
4903        ];
4904        for (raw, want) in cases {
4905            let set = set_from_snippet(&format!("[core]\n\tbigFileThreshold = {raw}\n"));
4906            assert_eq!(set.big_file_threshold().unwrap(), want, "value {raw}");
4907        }
4908        let set = set_from_snippet("[core]\n\tbigfilethreshold = 1k\n\tbigFileThreshold = 3k\n");
4909        assert_eq!(set.big_file_threshold().unwrap(), 3 * 1024);
4910
4911        for raw in ["-1", "12x", "lots", ""] {
4912            let snippet = if raw.is_empty() {
4913                "[core]\n\tbigFileThreshold\n".to_owned()
4914            } else {
4915                format!("[core]\n\tbigFileThreshold = {raw}\n")
4916            };
4917            let err = set_from_snippet(&snippet).big_file_threshold().unwrap_err();
4918            assert!(
4919                matches!(
4920                    err,
4921                    Error::Config(super::ConfigError::BadNumericValue { ref key, .. })
4922                        if key == "core.bigfilethreshold"
4923                ),
4924                "value {raw:?}: {err:?}"
4925            );
4926        }
4927    }
4928
4929    #[test]
4930    fn loose_compression_cascade() {
4931        let set = ConfigSet::new();
4932        assert_eq!(
4933            set.loose_objects_zlib_level().unwrap(),
4934            ConfigSet::LOOSE_OBJECTS_ZLIB_DEFAULT
4935        );
4936
4937        let set = set_from_snippet("[core]\n\tcompression = 0\n");
4938        assert_eq!(set.loose_objects_zlib_level().unwrap(), 0);
4939
4940        let set = set_from_snippet("[core]\n\tcompression = 9\n");
4941        assert_eq!(set.loose_objects_zlib_level().unwrap(), 9);
4942
4943        let set = set_from_snippet("[core]\n\tcompression = 0\n\tloosecompression = 9\n");
4944        assert_eq!(set.loose_objects_zlib_level().unwrap(), 9);
4945
4946        let set = set_from_snippet("[core]\n\tloosecompression = 3\n");
4947        assert_eq!(set.loose_objects_zlib_level().unwrap(), 3);
4948
4949        let set = set_from_snippet("[core]\n\tcompression = -1\n");
4950        assert_eq!(set.loose_objects_zlib_level().unwrap(), 6);
4951
4952        for level in 0..=9 {
4953            let set = set_from_snippet(&format!("[core]\n\tloosecompression = {level}\n"));
4954            assert_eq!(set.loose_objects_zlib_level().unwrap(), level);
4955        }
4956
4957        let set = set_from_snippet("[core]\n\tloosecompression = -1\n");
4958        assert_eq!(set.loose_objects_zlib_level().unwrap(), 6);
4959
4960        let err = set_from_snippet("[core]\n\tloosecompression = 10\n")
4961            .loose_objects_zlib_level()
4962            .unwrap_err();
4963        assert!(matches!(err, Error::Config(_)));
4964
4965        let err = set_from_snippet("[core]\n\tloosecompression = not-a-number\n")
4966            .loose_objects_zlib_level()
4967            .unwrap_err();
4968        assert!(matches!(err, Error::Config(_)));
4969
4970        let err = set_from_snippet("[core]\n\tcompression = 42\n")
4971            .loose_objects_zlib_level()
4972            .unwrap_err();
4973        assert!(matches!(err, Error::Config(_)));
4974
4975        let err = set_from_snippet("[core]\n\tcompression = nope\n\tloosecompression = 1\n")
4976            .loose_objects_zlib_level()
4977            .unwrap_err();
4978        assert!(matches!(err, Error::Config(_)));
4979
4980        let err = set_from_snippet("[core]\n\tloosecompression = nope\n\tloosecompression = 1\n")
4981            .loose_objects_zlib_level()
4982            .unwrap_err();
4983        assert!(matches!(err, Error::Config(_)));
4984
4985        let set = set_from_snippet("[core]\n\tloosecompression = 1\n\tcompression = 9\n");
4986        assert_eq!(set.loose_objects_zlib_level().unwrap(), 1);
4987
4988        let err = set_from_snippet("[core]\n\tloosecompression\n")
4989            .loose_objects_zlib_level()
4990            .unwrap_err();
4991        assert!(matches!(err, Error::Config(_)));
4992    }
4993}
4994
4995#[cfg(test)]
4996mod pack_compression_tests {
4997    use super::{ConfigFile, ConfigScope, ConfigSet};
4998    use std::path::Path;
4999
5000    fn set_from_snippet(text: &str) -> ConfigSet {
5001        let path = Path::new(".git/config");
5002        let file = ConfigFile::parse(path, text, ConfigScope::Local).expect("parse config snippet");
5003        let mut set = ConfigSet::new();
5004        set.merge(&file);
5005        set
5006    }
5007
5008    #[test]
5009    fn pack_objects_zlib_level_defaults_to_six() {
5010        let set = ConfigSet::new();
5011        assert_eq!(set.pack_objects_zlib_level().unwrap(), 6);
5012    }
5013
5014    #[test]
5015    fn pack_objects_zlib_level_core_compression() {
5016        let set = set_from_snippet("[core]\n\tcompression = 0\n");
5017        assert_eq!(set.pack_objects_zlib_level().unwrap(), 0);
5018        let set = set_from_snippet("[core]\n\tcompression = 9\n");
5019        assert_eq!(set.pack_objects_zlib_level().unwrap(), 9);
5020    }
5021
5022    #[test]
5023    fn pack_objects_zlib_level_pack_overrides_core() {
5024        let set = set_from_snippet("[core]\n\tcompression = 9\n[pack]\n\tcompression = 0\n");
5025        assert_eq!(set.pack_objects_zlib_level().unwrap(), 0);
5026        let set = set_from_snippet("[core]\n\tcompression = 0\n[pack]\n\tcompression = 9\n");
5027        assert_eq!(set.pack_objects_zlib_level().unwrap(), 9);
5028    }
5029
5030    #[test]
5031    fn pack_objects_zlib_level_later_core_does_not_override_earlier_pack() {
5032        let mut set = ConfigSet::new();
5033        set.merge(
5034            &ConfigFile::parse(
5035                Path::new("a"),
5036                "[pack]\n\tcompression = 9\n",
5037                ConfigScope::Local,
5038            )
5039            .unwrap(),
5040        );
5041        set.merge(
5042            &ConfigFile::parse(
5043                Path::new("b"),
5044                "[core]\n\tcompression = 0\n",
5045                ConfigScope::Local,
5046            )
5047            .unwrap(),
5048        );
5049        assert_eq!(set.pack_objects_zlib_level().unwrap(), 9);
5050    }
5051
5052    #[test]
5053    fn pack_objects_zlib_level_loosecompression_does_not_block_core_pack_level() {
5054        let set = set_from_snippet("[core]\n\tloosecompression = 1\n\tcompression = 0\n");
5055        assert_eq!(set.pack_objects_zlib_level().unwrap(), 0);
5056    }
5057
5058    #[test]
5059    fn pack_objects_zlib_level_pack_wins_after_loose_and_core() {
5060        let set = set_from_snippet(
5061            "[core]\n\tloosecompression = 1\n\tcompression = 0\n[pack]\n\tcompression = 9\n",
5062        );
5063        assert_eq!(set.pack_objects_zlib_level().unwrap(), 9);
5064    }
5065}
5066
5067#[cfg(test)]
5068mod config_cache_tests {
5069    use super::*;
5070    use filetime::FileTime;
5071
5072    fn local_opts(git_dir: &Path) -> LoadConfigOptions {
5073        LoadConfigOptions {
5074            include_system: false,
5075            include_ctx: IncludeContext {
5076                git_dir: Some(git_dir.to_path_buf()),
5077                ..Default::default()
5078            },
5079            ..Default::default()
5080        }
5081    }
5082
5083    fn load_value(caches: &crate::repo_caches::RepoCaches, git_dir: &Path) -> Option<String> {
5084        let opts = local_opts(git_dir);
5085        let set = caches
5086            .load_config_with_options(&Environment::empty(), Some(git_dir), &opts)
5087            .expect("load cascade");
5088        set.get("gritcachetest.value")
5089    }
5090
5091    fn mtime_of(path: &Path) -> FileTime {
5092        FileTime::from_last_modification_time(&fs::metadata(path).expect("stat config"))
5093    }
5094
5095    fn restore_mtime(path: &Path, stamp: FileTime) {
5096        filetime::set_file_mtime(path, stamp).expect("restore mtime");
5097    }
5098
5099    #[test]
5100    fn cache_serves_same_stamp_and_config_write_evicts() {
5101        let caches =
5102            crate::repo_caches::RepoCaches::new(crate::command_runner::system_command_runner());
5103        let td = tempfile::tempdir().expect("tempdir");
5104        let gd = td.path();
5105        let cfg = gd.join("config");
5106        fs::write(&cfg, "[gritcachetest]\n\tvalue = aaa\n").expect("write v1");
5107        let t0 = mtime_of(&cfg);
5108        assert_eq!(load_value(&caches, gd).as_deref(), Some("aaa"));
5109
5110        // Same size + restored mtime: indistinguishable by stat, so the cache
5111        // serves the old parse. (This is the window explicit eviction closes
5112        // for in-process writers; C git would not re-read at all.)
5113        fs::write(&cfg, "[gritcachetest]\n\tvalue = bbb\n").expect("write v2");
5114        restore_mtime(&cfg, t0);
5115        assert_eq!(load_value(&caches, gd).as_deref(), Some("aaa"));
5116
5117        // An in-process `ConfigFile::write` plus eviction sees new content even with identical stamps.
5118        let mut file = ConfigFile::from_path(&cfg, ConfigScope::Local)
5119            .expect("read config")
5120            .expect("config exists");
5121        file.set("gritcachetest.value", "ccc").expect("set value");
5122        file.write().expect("persist");
5123        caches.evict_config_for_path(&cfg);
5124        restore_mtime(&cfg, t0);
5125        assert_eq!(load_value(&caches, gd).as_deref(), Some("ccc"));
5126    }
5127
5128    #[test]
5129    fn cache_invalidates_on_size_or_existence_change() {
5130        let caches =
5131            crate::repo_caches::RepoCaches::new(crate::command_runner::system_command_runner());
5132        let td = tempfile::tempdir().expect("tempdir");
5133        let gd = td.path();
5134        let cfg = gd.join("config");
5135        // Absent local config is cached as "absent"...
5136        assert_eq!(load_value(&caches, gd), None);
5137        // ...and creating the file is a stamp change (None -> Some).
5138        fs::write(&cfg, "[gritcachetest]\n\tvalue = first\n").expect("create");
5139        assert_eq!(load_value(&caches, gd).as_deref(), Some("first"));
5140        // A size change invalidates even with a restored mtime.
5141        let t0 = mtime_of(&cfg);
5142        fs::write(&cfg, "[gritcachetest]\n\tvalue = second-longer\n").expect("rewrite");
5143        restore_mtime(&cfg, t0);
5144        assert_eq!(load_value(&caches, gd).as_deref(), Some("second-longer"));
5145    }
5146
5147    #[test]
5148    fn include_targets_are_stamped_and_invalidate() {
5149        let caches =
5150            crate::repo_caches::RepoCaches::new(crate::command_runner::system_command_runner());
5151        let td = tempfile::tempdir().expect("tempdir");
5152        let gd = td.path();
5153        fs::write(gd.join("config"), "[include]\n\tpath = extra.conf\n").expect("write parent");
5154        let inc = gd.join("extra.conf");
5155        fs::write(&inc, "[gritcachetest]\n\tvalue = one\n").expect("write include v1");
5156        assert_eq!(load_value(&caches, gd).as_deref(), Some("one"));
5157
5158        // Same-size + restored-mtime rewrite of only the included file: the
5159        // parse result is served from cache (proving the include target is
5160        // stamped rather than re-read)...
5161        let t0 = mtime_of(&inc);
5162        fs::write(&inc, "[gritcachetest]\n\tvalue = two\n").expect("write include v2");
5163        restore_mtime(&inc, t0);
5164        assert_eq!(load_value(&caches, gd).as_deref(), Some("one"));
5165
5166        // ...while a stat-visible change to the included file invalidates.
5167        fs::write(&inc, "[gritcachetest]\n\tvalue = two-longer\n").expect("write include v3");
5168        assert_eq!(load_value(&caches, gd).as_deref(), Some("two-longer"));
5169    }
5170
5171    #[test]
5172    fn missing_include_target_is_watched() {
5173        let caches =
5174            crate::repo_caches::RepoCaches::new(crate::command_runner::system_command_runner());
5175        let td = tempfile::tempdir().expect("tempdir");
5176        let gd = td.path();
5177        fs::write(gd.join("config"), "[include]\n\tpath = extra.conf\n").expect("write parent");
5178        assert_eq!(load_value(&caches, gd), None);
5179
5180        // The resolved-but-missing target was stamped as absent; creating it
5181        // must invalidate the cached parse.
5182        fs::write(gd.join("extra.conf"), "[gritcachetest]\n\tvalue = born\n").expect("create");
5183        assert_eq!(load_value(&caches, gd).as_deref(), Some("born"));
5184    }
5185
5186    #[test]
5187    fn onbranch_condition_follows_head() {
5188        let caches =
5189            crate::repo_caches::RepoCaches::new(crate::command_runner::system_command_runner());
5190        let td = tempfile::tempdir().expect("tempdir");
5191        let gd = td.path();
5192        fs::write(gd.join("HEAD"), "ref: refs/heads/main\n").expect("write HEAD");
5193        fs::write(
5194            gd.join("config"),
5195            "[includeIf \"onbranch:main\"]\n\tpath = branch.conf\n",
5196        )
5197        .expect("write config");
5198        fs::write(
5199            gd.join("branch.conf"),
5200            "[gritcachetest]\n\tvalue = onmain\n",
5201        )
5202        .expect("write branch config");
5203        assert_eq!(load_value(&caches, gd).as_deref(), Some("onmain"));
5204
5205        // Switching branches rewrites HEAD; the stamped HEAD must invalidate
5206        // the cached cascade so the condition is re-evaluated.
5207        fs::write(gd.join("HEAD"), "ref: refs/heads/dev\n").expect("rewrite HEAD");
5208        assert_eq!(load_value(&caches, gd), None);
5209    }
5210
5211    #[test]
5212    #[cfg(unix)]
5213    fn load_propagates_local_config_io_errors() {
5214        use std::os::unix::fs::symlink;
5215        let td = tempfile::tempdir().expect("tempdir");
5216        let git_dir = td.path();
5217        let config = git_dir.join("config");
5218        symlink("config", &config).expect("self-referential symlink");
5219        let err = ConfigSet::load(&Environment::empty(), Some(git_dir), false).unwrap_err();
5220        assert!(
5221            matches!(err, Error::Io(_)),
5222            "expected I/O error for symlink loop, got {err:?}"
5223        );
5224    }
5225}
5226
5227#[cfg(test)]
5228mod line_scan_tests {
5229    use super::*;
5230
5231    /// The char-at-a-time comment stripper the byte scan replaced, kept as the reference.
5232    fn strip_inline_comment_by_chars(s: &str) -> String {
5233        let mut in_quote = false;
5234        let mut last_was_backslash = false;
5235        let mut result = String::with_capacity(s.len());
5236        let mut chars = s.chars().peekable();
5237        while let Some(ch) = chars.next() {
5238            match ch {
5239                '"' if !last_was_backslash => {
5240                    in_quote = !in_quote;
5241                    result.push(ch);
5242                    last_was_backslash = false;
5243                }
5244                '\\' if !last_was_backslash => {
5245                    last_was_backslash = true;
5246                    result.push(ch);
5247                    if in_quote {
5248                        if let Some(&next) = chars.peek() {
5249                            result.push(next);
5250                            chars.next();
5251                            last_was_backslash = false;
5252                        }
5253                    }
5254                }
5255                '#' | ';' if !in_quote && !last_was_backslash => break,
5256                _ => {
5257                    last_was_backslash = false;
5258                    result.push(ch);
5259                }
5260            }
5261        }
5262        result.trim_end().to_owned()
5263    }
5264
5265    fn unclosed_quote_by_chars(line: &str) -> bool {
5266        let trimmed = line.trim();
5267        let Some(eq_pos) = trimmed.find('=') else {
5268            return false;
5269        };
5270        let mut in_quote = false;
5271        let mut last_was_backslash = false;
5272        for ch in trimmed[eq_pos + 1..].trim_start().chars() {
5273            match ch {
5274                '"' if !last_was_backslash => {
5275                    in_quote = !in_quote;
5276                    last_was_backslash = false;
5277                }
5278                '\\' if !last_was_backslash => {
5279                    last_was_backslash = true;
5280                    continue;
5281                }
5282                '#' | ';' if !in_quote && !last_was_backslash => return false,
5283                _ => last_was_backslash = false,
5284            }
5285        }
5286        in_quote
5287    }
5288
5289    fn continues_by_chars(line: &str) -> bool {
5290        let trimmed = line.trim();
5291        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
5292            return false;
5293        }
5294        let Some(pos) = trimmed.find('=') else {
5295            return false;
5296        };
5297        let mut in_quote = false;
5298        let mut last_was_backslash = false;
5299        let mut in_comment = false;
5300        for ch in trimmed[pos + 1..].chars() {
5301            if in_comment {
5302                last_was_backslash = false;
5303                continue;
5304            }
5305            match ch {
5306                '"' if !last_was_backslash => {
5307                    in_quote = !in_quote;
5308                    last_was_backslash = false;
5309                }
5310                '\\' if !last_was_backslash => {
5311                    last_was_backslash = true;
5312                    continue;
5313                }
5314                '#' | ';' if !in_quote && !last_was_backslash => {
5315                    in_comment = true;
5316                    last_was_backslash = false;
5317                }
5318                _ => last_was_backslash = false,
5319            }
5320        }
5321        last_was_backslash && !in_comment
5322    }
5323
5324    /// Every string of up to five tokens drawn from the characters the scanners treat
5325    /// specially, plus ASCII and multi-byte text and Unicode whitespace.
5326    fn corpus() -> Vec<String> {
5327        const TOKENS: [&str; 11] = [
5328            "a", "\"", "\\", "#", ";", " ", "=", "\t", "é", "日", "\u{2003}",
5329        ];
5330        let mut out = vec![String::new()];
5331        let mut frontier = vec![String::new()];
5332        for _ in 0..5 {
5333            let mut next = Vec::with_capacity(frontier.len() * TOKENS.len());
5334            for prefix in &frontier {
5335                for token in TOKENS {
5336                    next.push(format!("{prefix}{token}"));
5337                }
5338            }
5339            out.extend(next.iter().cloned());
5340            frontier = next;
5341        }
5342        out
5343    }
5344
5345    #[test]
5346    fn byte_scanners_match_char_scanners_on_every_short_line() {
5347        for s in corpus() {
5348            assert_eq!(
5349                strip_inline_comment(&s),
5350                strip_inline_comment_by_chars(&s),
5351                "strip_inline_comment({s:?})"
5352            );
5353            let line = format!("k = {s}");
5354            assert_eq!(
5355                entry_line_value_has_unclosed_quote(&line),
5356                unclosed_quote_by_chars(&line),
5357                "unclosed quote in {line:?}"
5358            );
5359            assert_eq!(
5360                value_line_continues(&line),
5361                continues_by_chars(&line),
5362                "continuation of {line:?}"
5363            );
5364            assert_eq!(
5365                entry_line_value_has_unclosed_quote(&s),
5366                unclosed_quote_by_chars(&s),
5367                "unclosed quote in {s:?}"
5368            );
5369            assert_eq!(
5370                value_line_continues(&s),
5371                continues_by_chars(&s),
5372                "continuation of {s:?}"
5373            );
5374        }
5375    }
5376
5377    #[test]
5378    fn strip_inline_comment_borrows_a_prefix() {
5379        let s = "\"a # b\" tail ; comment";
5380        let stripped = strip_inline_comment(s);
5381        assert_eq!(stripped, "\"a # b\" tail");
5382        assert_eq!(stripped.as_ptr(), s.as_ptr());
5383        assert_eq!(strip_inline_comment("\"\\\"#\" x"), "\"\\\"#\" x");
5384        assert_eq!(strip_inline_comment("v\\#kept # gone"), "v\\#kept");
5385    }
5386
5387    #[test]
5388    fn canonical_key_lowercases_section_and_name_only() {
5389        assert_eq!(canonical_key("core.bare").unwrap(), "core.bare");
5390        assert_eq!(canonical_key("CORE.BARE").unwrap(), "core.bare");
5391        assert_eq!(
5392            canonical_key("Remote.Origin.URL").unwrap(),
5393            "remote.Origin.url"
5394        );
5395        assert_eq!(canonical_key("a.B.C.D").unwrap(), "a.B.C.d");
5396        assert!(canonical_key("nodot").is_err());
5397        assert!(canonical_key("core.").is_err());
5398        assert!(canonical_key("core.1bad").is_err());
5399        assert!(canonical_key("core.ba\nre").is_err());
5400    }
5401
5402    #[test]
5403    fn parsed_keys_reuse_the_section_prefix_per_header() {
5404        let text = "[Core]\n\tBare = false\n\tFileMode\n\
5405                    [Remote \"Origin\"]\n\tURL = /x\n\
5406                    [branch \"Main\"] Remote = origin\n\
5407                    [core]\n\tEditor = vi ; trailing\n";
5408        let file = ConfigFile::parse(Path::new("cfg"), text, ConfigScope::Local).unwrap();
5409        let keys: Vec<(&str, Option<&str>)> = file
5410            .entries
5411            .iter()
5412            .map(|e| (e.key.as_str(), e.value.as_deref()))
5413            .collect();
5414        assert_eq!(
5415            keys,
5416            [
5417                ("core.bare", Some("false")),
5418                ("core.filemode", None),
5419                ("remote.Origin.url", Some("/x")),
5420                ("branch.Main.remote", Some("origin")),
5421                ("core.editor", Some("vi")),
5422            ]
5423        );
5424    }
5425
5426    #[test]
5427    fn push_lowercase_matches_to_lowercase() {
5428        for s in ["", "core", "MiXeD-09", "ÄBC", "İx", "ΣΑΣ"] {
5429            let mut out = String::from("p.");
5430            push_lowercase(&mut out, s);
5431            assert_eq!(out, format!("p.{}", s.to_lowercase()));
5432        }
5433    }
5434}