Skip to main content

grit_lib/config/
core.rs

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