Skip to main content

grit_lib/config/
core.rs

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