Skip to main content

grit_lib/
config.rs

1//! Git-compatible configuration file parser and accessor.
2//!
3//! Supports the standard Git config file format:
4//!
5//! ```text
6//! [section]
7//!     key = value
8//! [section "subsection"]
9//!     key = value
10//! ```
11//!
12//! # Multi-file layering
13//!
14//! Git reads configuration from several files in priority order:
15//!
16//! 1. System (`/etc/gitconfig`)
17//! 2. Global (`~/.gitconfig` or `$XDG_CONFIG_HOME/git/config`)
18//! 3. Local (`.git/config`)
19//! 4. Worktree (`.git/config.worktree`)
20//! 5. Command-line (`-c key=value` or `GIT_CONFIG_*`)
21//!
22//! [`ConfigSet`] merges all layers; last-wins for single-valued keys.
23//!
24//! # Include directives
25//!
26//! `[include] path = <path>` and `[includeIf "<condition>"] path = <path>`
27//! are supported. Conditions: `gitdir:`, `gitdir/i:`, `onbranch:`,
28//! and `hasconfig:remote.*.url:`.
29
30use std::collections::HashMap;
31use std::fmt;
32use std::fs;
33use std::path::{Path, PathBuf};
34use std::sync::{Arc, Mutex, OnceLock};
35use std::time::SystemTime;
36
37use crate::error::{Error, Result};
38use crate::refs;
39use crate::wildmatch::{wildmatch, WM_CASEFOLD, WM_PATHNAME};
40
41/// The scope (origin) of a configuration value.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
43pub enum ConfigScope {
44    /// System-wide configuration (`/etc/gitconfig`).
45    System,
46    /// Per-user global configuration (`~/.gitconfig` or XDG).
47    Global,
48    /// Repository-local configuration (`.git/config`).
49    Local,
50    /// Per-worktree configuration (`.git/config.worktree`).
51    Worktree,
52    /// Command-line overrides (`-c key=value`).
53    Command,
54}
55
56impl fmt::Display for ConfigScope {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        match self {
59            Self::System => write!(f, "system"),
60            Self::Global => write!(f, "global"),
61            Self::Local => write!(f, "local"),
62            Self::Worktree => write!(f, "worktree"),
63            Self::Command => write!(f, "command"),
64        }
65    }
66}
67
68/// A single configuration entry with its origin metadata.
69#[derive(Debug, Clone)]
70pub struct ConfigEntry {
71    /// Fully-qualified key in canonical form: `section.subsection.name`
72    /// (section and name lowercased; subsection preserves case).
73    pub key: String,
74    /// The raw string value, or `None` for a boolean-true bare key.
75    pub value: Option<String>,
76    /// Which scope this entry came from.
77    pub scope: ConfigScope,
78    /// The file this entry was read from (if file-backed).
79    pub file: Option<PathBuf>,
80    /// One-based line number in the source file.
81    pub line: usize,
82}
83
84/// Where a [`ConfigFile`] was loaded from for Git include semantics.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum ConfigIncludeOrigin {
87    /// Normal path on disk (`-f`, global/local config files, etc.).
88    Disk,
89    /// `--file -` (stdin).
90    Stdin,
91    /// Synthetic file built from `GIT_CONFIG_PARAMETERS` / `git -c`.
92    CommandLine,
93    /// `git config --blob=…`.
94    Blob,
95}
96
97/// A parsed configuration file that preserves the raw text for round-trip
98/// editing (set/unset/rename-section/remove-section).
99#[derive(Debug, Clone)]
100pub struct ConfigFile {
101    /// The path to this config file on disk.
102    pub path: PathBuf,
103    /// The scope this file represents.
104    pub scope: ConfigScope,
105    /// Parsed entries (in file order).
106    pub entries: Vec<ConfigEntry>,
107    /// Raw lines of the file (for round-trip editing).
108    raw_lines: Vec<String>,
109    /// Source kind for `[include]` resolution (Git `CONFIG_ORIGIN_*`).
110    pub include_origin: ConfigIncludeOrigin,
111}
112
113/// A merged view across all configuration scopes.
114///
115/// Entries are stored in file-order within each scope; scopes are layered
116/// in priority order (system < global < local < worktree < command).
117#[derive(Debug, Clone, Default)]
118pub struct ConfigSet {
119    /// All entries across all scopes, in load order.
120    entries: Vec<ConfigEntry>,
121}
122
123/// Context for evaluating `[includeIf]` conditions (`gitdir:`, `onbranch:`, `hasconfig:`).
124#[derive(Debug, Clone, Default)]
125pub struct IncludeContext {
126    /// Git directory path used for `gitdir:` matching (may contain unresolved symlinks).
127    pub git_dir: Option<PathBuf>,
128    /// When true, `git -c include.path=relative` fails instead of ignoring the include.
129    pub command_line_relative_include_is_error: bool,
130}
131
132/// Options controlling how [`ConfigSet::load_with_options`] merges files and includes.
133#[derive(Debug, Clone)]
134pub struct LoadConfigOptions {
135    /// Load `/etc/gitconfig` (unless `GIT_CONFIG_NOSYSTEM` is enabled).
136    pub include_system: bool,
137    /// Expand `[include]` / `[includeIf]` while reading file-backed layers.
138    pub process_includes: bool,
139    /// Expand includes for synthetic command-line config built from `GIT_CONFIG_PARAMETERS`.
140    pub command_includes: bool,
141    pub include_ctx: IncludeContext,
142}
143
144impl Default for LoadConfigOptions {
145    fn default() -> Self {
146        Self {
147            include_system: true,
148            process_includes: true,
149            command_includes: true,
150            include_ctx: IncludeContext::default(),
151        }
152    }
153}
154
155// ── Canonical key helpers ────────────────────────────────────────────
156
157/// Normalise a config key to canonical form.
158///
159/// - Section name is lowercased.
160/// - Variable name (last dot-separated component) is lowercased.
161/// - Subsection (middle components) preserves original case.
162///
163/// Returns `Err` if the key has fewer than two dot-separated parts.
164///
165/// # Examples
166///
167/// - `core.bare` → `core.bare`
168/// - `Section.SubSection.Key` → `section.SubSection.key`
169/// - `CORE.BARE` → `core.bare`
170pub fn canonical_key(raw: &str) -> Result<String> {
171    // Reject keys containing newlines
172    if raw.contains('\n') || raw.contains('\r') {
173        return Err(Error::ConfigError(format!(
174            "invalid key: '{}'",
175            raw.replace('\n', "\\n")
176        )));
177    }
178
179    let first_dot = raw
180        .find('.')
181        .ok_or_else(|| Error::ConfigError(format!("key does not contain a section: '{raw}'")))?;
182    let last_dot = raw
183        .rfind('.')
184        .ok_or_else(|| Error::ConfigError(format!("key does not contain a section: '{raw}'")))?;
185
186    if last_dot == raw.len() - 1 {
187        return Err(Error::ConfigError(format!(
188            "key does not contain variable name: '{raw}'"
189        )));
190    }
191
192    let section = &raw[..first_dot];
193    let name = &raw[last_dot + 1..];
194
195    // Validate section name: must be alphanumeric or hyphen
196    if section.is_empty() || !section.chars().all(|c| c.is_alphanumeric() || c == '-') {
197        return Err(Error::ConfigError(format!(
198            "invalid key (bad section): '{raw}'"
199        )));
200    }
201
202    // Validate variable name: must start with alpha, rest alphanumeric or hyphen
203    if !name.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
204        || !name.chars().all(|c| c.is_alphanumeric() || c == '-')
205    {
206        return Err(Error::ConfigError(format!(
207            "invalid key (bad variable name): '{raw}'"
208        )));
209    }
210
211    if first_dot == last_dot {
212        // No subsection: section.name
213        Ok(format!(
214            "{}.{}",
215            section.to_lowercase(),
216            name.to_lowercase()
217        ))
218    } else {
219        // section.subsection.name
220        let subsection = &raw[first_dot + 1..last_dot];
221        Ok(format!(
222            "{}.{}.{}",
223            section.to_lowercase(),
224            subsection,
225            name.to_lowercase()
226        ))
227    }
228}
229
230// ── Parser ──────────────────────────────────────────────────────────
231
232/// Display path for config diagnostics (matches [`config_error_path_display`] for public callers).
233#[must_use]
234pub fn config_file_display_for_error(path: &Path) -> String {
235    config_error_path_display(path)
236}
237
238fn config_error_path_display(path: &Path) -> String {
239    if path == Path::new("-") {
240        return "standard input".to_owned();
241    }
242    if path.file_name().and_then(|s| s.to_str()) == Some("config")
243        && path
244            .parent()
245            .and_then(|p| p.file_name())
246            .and_then(|s| s.to_str())
247            == Some(".git")
248    {
249        return ".git/config".to_owned();
250    }
251    path.display().to_string()
252}
253
254/// State tracked while parsing a config file line-by-line.
255struct Parser {
256    section: String,
257    subsection: Option<String>,
258}
259
260impl Parser {
261    fn new() -> Self {
262        Self {
263            section: String::new(),
264            subsection: None,
265        }
266    }
267
268    /// Build the canonical key for a variable name in the current section.
269    fn make_key(&self, name: &str) -> String {
270        let sec = self.section.to_lowercase();
271        let var = name.to_lowercase();
272        match &self.subsection {
273            Some(sub) => format!("{sec}.{sub}.{var}"),
274            None => format!("{sec}.{var}"),
275        }
276    }
277
278    /// Parse a section header line like `[section]` or `[section "subsection"]`.
279    ///
280    /// Returns `true` if the line was a section header.
281    /// If there is content after `]` (an inline key=value), it is returned
282    /// via the `inline_remainder` parameter.
283    fn try_parse_section_with_remainder<'a>(
284        &mut self,
285        line: &'a str,
286        inline_remainder: &mut Option<&'a str>,
287    ) -> bool {
288        let trimmed = line.trim();
289        if !trimmed.starts_with('[') {
290            return false;
291        }
292        // Find the closing `]` — but for subsection headers like
293        // [section "sub\"escaped"], we need to skip escaped chars
294        // inside quotes.
295        let end = {
296            let bytes = trimmed.as_bytes();
297            let mut i = 1; // skip opening '['
298            let mut in_quotes = false;
299            let mut found = None;
300            while i < bytes.len() {
301                if in_quotes {
302                    if bytes[i] == b'\\' {
303                        i += 2; // skip escaped char
304                        continue;
305                    }
306                    if bytes[i] == b'"' {
307                        in_quotes = false;
308                    }
309                } else {
310                    if bytes[i] == b'"' {
311                        in_quotes = true;
312                    }
313                    if bytes[i] == b']' {
314                        found = Some(i);
315                        break;
316                    }
317                }
318                i += 1;
319            }
320            match found {
321                Some(i) => i,
322                None => return false,
323            }
324        };
325        let inside = &trimmed[1..end];
326        // Check for subsection: [section "subsection"]
327        if let Some(quote_start) = inside.find('"') {
328            self.section = inside[..quote_start].trim().to_owned();
329            let rest = &inside[quote_start + 1..];
330            // Find unescaped closing quote
331            let mut sub = String::new();
332            let mut chars = rest.chars();
333            while let Some(ch) = chars.next() {
334                if ch == '\\' {
335                    if let Some(escaped) = chars.next() {
336                        sub.push(escaped);
337                    }
338                } else if ch == '"' {
339                    break;
340                } else {
341                    sub.push(ch);
342                }
343            }
344            self.subsection = Some(sub);
345        } else {
346            self.section = inside.trim().to_owned();
347            self.subsection = None;
348        }
349        // Check for inline content after the closing `]`
350        let after = trimmed[end + 1..].trim();
351        if !after.is_empty() && !after.starts_with('#') && !after.starts_with(';') {
352            *inline_remainder = Some(after);
353        } else {
354            *inline_remainder = None;
355        }
356        true
357    }
358
359    /// Parse a section header line (without inline remainder tracking).
360    fn try_parse_section(&mut self, line: &str) -> bool {
361        let mut _remainder = None;
362        self.try_parse_section_with_remainder(line, &mut _remainder)
363    }
364
365    /// Parse a `key = value` or bare `key` line.
366    ///
367    /// Returns `Some((canonical_key, value))` if this is a variable line.
368    fn try_parse_entry(&self, line: &str) -> Option<(String, Option<String>)> {
369        let trimmed = line.trim();
370        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
371            return None;
372        }
373        if trimmed.starts_with('[') {
374            return None;
375        }
376        if self.section.is_empty() {
377            return None;
378        }
379
380        if let Some(eq_pos) = trimmed.find('=') {
381            let raw_name = trimmed[..eq_pos].trim();
382            let raw_value = trimmed[eq_pos + 1..].trim();
383            // Strip inline comment (not inside quotes)
384            let value = strip_inline_comment(raw_value);
385            let value = unescape_value(&value);
386            let key = self.make_key(raw_name);
387            Some((key, Some(value)))
388        } else {
389            // Bare key (boolean true)
390            let raw_name = strip_inline_comment(trimmed);
391            if raw_name.split_whitespace().count() > 1 {
392                return None;
393            }
394            let key = self.make_key(raw_name.trim());
395            Some((key, None))
396        }
397    }
398}
399
400/// Check if a value line ends with a continuation backslash.
401///
402/// This checks the value portion (after `=`) for a trailing `\` that is
403/// outside quotes and outside an inline comment. If the `\` is after
404/// a `#` or `;` that starts a comment, it does NOT count as continuation.
405/// True when the value portion (after the first `=`) ends inside an unclosed double-quoted span.
406///
407/// Mirrors Git config continuation rules: a line ending with an open `"` continues on the next
408/// physical line. Outside quotes, `#` / `;` start comments and the line is complete.
409fn entry_line_value_has_unclosed_quote(line: &str) -> bool {
410    let trimmed = line.trim();
411    let Some(eq_pos) = trimmed.find('=') else {
412        return false;
413    };
414    let raw_value = trimmed[eq_pos + 1..].trim_start();
415    let mut in_quote = false;
416    let mut last_was_backslash = false;
417    for ch in raw_value.chars() {
418        match ch {
419            '"' if !last_was_backslash => {
420                in_quote = !in_quote;
421                last_was_backslash = false;
422            }
423            '\\' if in_quote && !last_was_backslash => {
424                last_was_backslash = true;
425                continue;
426            }
427            '#' | ';' if !in_quote && !last_was_backslash => return false,
428            _ => {
429                last_was_backslash = false;
430            }
431        }
432    }
433    in_quote
434}
435
436fn value_line_continues(line: &str) -> bool {
437    let trimmed = line.trim();
438    if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
439        return false;
440    }
441    // Find the value portion (after '=')
442    // If no '=', this is a bare key — no continuation
443    let value_part = match trimmed.find('=') {
444        Some(pos) => &trimmed[pos + 1..],
445        None => return false,
446    };
447    // Walk the value portion tracking quotes and comments
448    let mut in_quote = false;
449    let mut last_was_backslash = false;
450    let mut in_comment = false;
451    for ch in value_part.chars() {
452        if in_comment {
453            // Inside comment, backslash doesn't matter
454            last_was_backslash = false;
455            continue;
456        }
457        match ch {
458            '"' if !last_was_backslash => {
459                in_quote = !in_quote;
460                last_was_backslash = false;
461            }
462            '\\' if !last_was_backslash => {
463                last_was_backslash = true;
464                continue;
465            }
466            '#' | ';' if !in_quote && !last_was_backslash => {
467                in_comment = true;
468                last_was_backslash = false;
469            }
470            _ => {
471                last_was_backslash = false;
472            }
473        }
474    }
475    // The line continues if it ends with an unescaped backslash outside comments
476    last_was_backslash && !in_comment
477}
478
479/// Strip an inline comment (`#` or `;`) that is not inside quotes.
480fn strip_inline_comment(s: &str) -> String {
481    let mut in_quote = false;
482    let mut result = String::with_capacity(s.len());
483    let mut chars = s.chars().peekable();
484    while let Some(ch) = chars.next() {
485        match ch {
486            '"' => {
487                in_quote = !in_quote;
488                result.push(ch);
489            }
490            '\\' if in_quote => {
491                result.push(ch);
492                if let Some(&next) = chars.peek() {
493                    result.push(next);
494                    chars.next();
495                }
496            }
497            '#' | ';' if !in_quote => break,
498            _ => result.push(ch),
499        }
500    }
501    // Trim trailing whitespace that was before the comment
502    let trimmed = result.trim_end();
503    trimmed.to_owned()
504}
505
506/// Unescape a config value: handle `\"`, `\\`, `\n`, `\t`, and strip
507/// surrounding quotes.
508fn unescape_value(s: &str) -> String {
509    let mut result = String::with_capacity(s.len());
510    let mut chars = s.chars();
511    while let Some(ch) = chars.next() {
512        match ch {
513            '"' => { /* strip quotes */ }
514            '\\' => match chars.next() {
515                Some('n') => result.push('\n'),
516                Some('r') => result.push('\r'),
517                Some('t') => result.push('\t'),
518                Some('\\') => result.push('\\'),
519                Some('"') => result.push('"'),
520                Some(other) => {
521                    result.push('\\');
522                    result.push(other);
523                }
524                None => result.push('\\'),
525            },
526            _ => result.push(ch),
527        }
528    }
529    result
530}
531
532/// Escape a config value for writing back to a file.
533///
534/// Wraps in double quotes if the value contains leading/trailing whitespace,
535/// internal quotes, backslashes, or special characters.
536/// Escape a subsection name for writing in a config section header.
537/// In subsection names, `"` and `\` must be escaped.
538fn escape_subsection(s: &str) -> String {
539    let mut out = String::with_capacity(s.len());
540    for ch in s.chars() {
541        match ch {
542            '"' => out.push_str("\\\""),
543            '\\' => out.push_str("\\\\"),
544            other => out.push(other),
545        }
546    }
547    out
548}
549
550fn escape_value(s: &str) -> String {
551    // Quote leading `-` so values are not mistaken for config options (Git does this for
552    // submodule paths like `-sub` in `.gitmodules`), but leave signed numeric values bare.
553    let leading_dash_needs_quoting = s.starts_with('-') && parse_i64(s).is_err();
554    let needs_quoting = leading_dash_needs_quoting
555        || s.starts_with(' ')
556        || s.starts_with('\t')
557        || s.ends_with(' ')
558        || s.ends_with('\t')
559        || s.contains('"')
560        || s.contains('\\')
561        || s.contains('\n')
562        || s.contains('\r')
563        || s.contains('#')
564        || s.contains(';');
565
566    if !needs_quoting {
567        return s.to_owned();
568    }
569
570    let mut out = String::with_capacity(s.len() + 4);
571    out.push('"');
572    for ch in s.chars() {
573        match ch {
574            '"' => out.push_str("\\\""),
575            '\\' => out.push_str("\\\\"),
576            '\n' => out.push_str("\\n"),
577            '\r' => out.push_str("\\r"),
578            '\t' => out.push_str("\\t"),
579            other => out.push(other),
580        }
581    }
582    out.push('"');
583    out
584}
585
586/// Format a comment suffix for appending to a config value line.
587///
588/// Git's `--comment` flag normalises the comment:
589/// - If the comment already starts with `#` (possibly preceded by whitespace/tab),
590///   it is used as-is.
591/// - Otherwise, ` # ` is prepended.
592fn format_comment_suffix(comment: Option<&str>) -> String {
593    match comment {
594        None => String::new(),
595        Some(c) => {
596            if c.starts_with(' ') || c.starts_with('\t') {
597                // Comment has its own leading whitespace separator
598                c.to_owned()
599            } else if c.starts_with('#') {
600                // Comment starts with #, just prepend a space separator
601                format!(" {c}")
602            } else {
603                // Plain text comment, prepend " # "
604                format!(" # {c}")
605            }
606        }
607    }
608}
609
610impl ConfigFile {
611    /// Parse a config file from its raw text content.
612    ///
613    /// # Parameters
614    ///
615    /// - `path` — the file path (stored for diagnostics and round-trip writes).
616    /// - `content` — the raw text of the file.
617    /// - `scope` — the [`ConfigScope`] this file represents.
618    ///
619    /// # Errors
620    ///
621    /// Returns [`Error::ConfigError`] on malformed input.
622    pub fn parse(path: &Path, content: &str, scope: ConfigScope) -> Result<Self> {
623        let raw_lines: Vec<String> = content
624            .lines()
625            .map(|l| l.strip_suffix('\r').unwrap_or(l))
626            .map(String::from)
627            .collect();
628        let mut entries = Vec::new();
629        let mut parser = Parser::new();
630
631        let mut idx = 0;
632        while idx < raw_lines.len() {
633            let start_idx = idx;
634            let line = &raw_lines[idx];
635            idx += 1;
636
637            // Pure comment lines don't continue even with trailing \
638            let trimmed = line.trim();
639            if trimmed.starts_with('#') || trimmed.starts_with(';') {
640                continue;
641            }
642
643            let mut inline_remainder = None;
644            if parser.try_parse_section_with_remainder(line, &mut inline_remainder) {
645                // Check if there's an inline key=value after the section header
646                if let Some(remainder) = inline_remainder {
647                    if let Some((key, value)) = parser.try_parse_entry(remainder) {
648                        if key == "fetch.negotiationalgorithm" && value.is_none() {
649                            let file_disp = config_error_path_display(path);
650                            return Err(Error::Message(format!(
651                                "error: missing value for 'fetch.negotiationalgorithm'\n\
652fatal: bad config variable 'fetch.negotiationalgorithm' in file '{file_disp}' at line {}",
653                                start_idx + 1
654                            )));
655                        }
656                        entries.push(ConfigEntry {
657                            key,
658                            value,
659                            scope,
660                            file: Some(path.to_path_buf()),
661                            line: start_idx + 1,
662                        });
663                    }
664                }
665                continue;
666            }
667
668            // For entry lines, we need to check continuation.
669            // Build a logical line by joining continuations.
670            let mut logical_line = line.clone();
671            while value_line_continues(&logical_line) && idx < raw_lines.len() {
672                // Remove the trailing backslash
673                let t = logical_line.trim_end();
674                logical_line = t[..t.len() - 1].to_string();
675                // Append next line (trimmed of leading whitespace)
676                let next = raw_lines[idx].trim_start();
677                logical_line.push_str(next);
678                idx += 1;
679            }
680
681            while entry_line_value_has_unclosed_quote(&logical_line) && idx < raw_lines.len() {
682                let next = raw_lines[idx].trim_start();
683                logical_line.push_str(next);
684                idx += 1;
685            }
686            if entry_line_value_has_unclosed_quote(&logical_line) {
687                let file_disp = config_error_path_display(path);
688                return Err(Error::ConfigError(format!(
689                    "bad config line {} in file '{file_disp}'",
690                    start_idx + 1
691                )));
692            }
693
694            if let Some((key, value)) = parser.try_parse_entry(&logical_line) {
695                if key == "fetch.negotiationalgorithm" && value.is_none() {
696                    let file_disp = config_error_path_display(path);
697                    return Err(Error::Message(format!(
698                        "error: missing value for 'fetch.negotiationalgorithm'\n\
699fatal: bad config variable 'fetch.negotiationalgorithm' in file '{file_disp}' at line {}",
700                        start_idx + 1
701                    )));
702                }
703                entries.push(ConfigEntry {
704                    key,
705                    value,
706                    scope,
707                    file: Some(path.to_path_buf()),
708                    line: start_idx + 1,
709                });
710            } else if logical_line.trim().is_empty() {
711                continue;
712            } else {
713                let file_disp = config_error_path_display(path);
714                let location = if path == Path::new("-") {
715                    file_disp
716                } else {
717                    format!("file {file_disp}")
718                };
719                return Err(Error::Message(format!(
720                    "fatal: bad config line {} in {location}",
721                    start_idx + 1
722                )));
723            }
724        }
725
726        Ok(Self {
727            path: path.to_path_buf(),
728            scope,
729            entries,
730            raw_lines,
731            include_origin: ConfigIncludeOrigin::Disk,
732        })
733    }
734
735    /// Like [`Self::parse`] for `.gitmodules`, but on an unclosed-quote / bad line returns entries
736    /// parsed **before** that line plus the one-based line number of the bad logical line.
737    ///
738    /// Git streams config and still applies entries from valid preceding lines; submodule-config
739    /// tests rely on that when a later `.gitmodules` line is malformed.
740    pub fn parse_gitmodules_best_effort(
741        path: &Path,
742        content: &str,
743        scope: ConfigScope,
744    ) -> (Vec<ConfigEntry>, Option<usize>) {
745        let raw_lines: Vec<String> = content
746            .lines()
747            .map(|l| l.strip_suffix('\r').unwrap_or(l))
748            .map(String::from)
749            .collect();
750        let mut entries = Vec::new();
751        let mut parser = Parser::new();
752
753        let mut idx = 0;
754        while idx < raw_lines.len() {
755            let start_idx = idx;
756            let line = &raw_lines[idx];
757            idx += 1;
758
759            let trimmed = line.trim();
760            if trimmed.starts_with('#') || trimmed.starts_with(';') {
761                continue;
762            }
763
764            let mut inline_remainder = None;
765            if parser.try_parse_section_with_remainder(line, &mut inline_remainder) {
766                if let Some(remainder) = inline_remainder {
767                    if let Some((key, value)) = parser.try_parse_entry(remainder) {
768                        entries.push(ConfigEntry {
769                            key,
770                            value,
771                            scope,
772                            file: Some(path.to_path_buf()),
773                            line: start_idx + 1,
774                        });
775                    }
776                }
777                continue;
778            }
779
780            let mut logical_line = line.clone();
781            while value_line_continues(&logical_line) && idx < raw_lines.len() {
782                let t = logical_line.trim_end();
783                logical_line = t[..t.len() - 1].to_string();
784                let next = raw_lines[idx].trim_start();
785                logical_line.push_str(next);
786                idx += 1;
787            }
788
789            while entry_line_value_has_unclosed_quote(&logical_line) && idx < raw_lines.len() {
790                let next = raw_lines[idx].trim_start();
791                logical_line.push_str(next);
792                idx += 1;
793            }
794            if entry_line_value_has_unclosed_quote(&logical_line) {
795                return (entries, Some(start_idx + 1));
796            }
797
798            if let Some((key, value)) = parser.try_parse_entry(&logical_line) {
799                entries.push(ConfigEntry {
800                    key,
801                    value,
802                    scope,
803                    file: Some(path.to_path_buf()),
804                    line: start_idx + 1,
805                });
806            }
807        }
808
809        (entries, None)
810    }
811
812    /// Last value for `key` in this file only (canonical key, case-insensitive section/var like Git).
813    #[must_use]
814    pub fn get(&self, key: &str) -> Option<String> {
815        let canon = canonical_key(key).ok()?;
816        self.entries
817            .iter()
818            .rev()
819            .find(|e| e.key == canon)
820            .map(|e| e.value.clone().unwrap_or_else(|| "true".to_owned()))
821    }
822
823    /// Parse like [`Self::parse`] but record a non-disk include origin (blob, stdin, command line).
824    pub fn parse_with_origin(
825        path: &Path,
826        content: &str,
827        scope: ConfigScope,
828        include_origin: ConfigIncludeOrigin,
829    ) -> Result<Self> {
830        let mut f = Self::parse(path, content, scope)?;
831        f.include_origin = include_origin;
832        Ok(f)
833    }
834
835    /// Build a synthetic [`ConfigFile`] from `GIT_CONFIG_PARAMETERS` / `git -c` payloads.
836    ///
837    /// Unlike [`Self::parse`], this accepts flat `key=value` assignments without `[section]`
838    /// headers, matching how Git injects command-line configuration.
839    pub fn from_git_config_parameters(path: &Path, raw: &str) -> Result<Self> {
840        let mut entries = Vec::new();
841        let pseudo_path = path.to_path_buf();
842        for entry in parse_config_parameters_strict(raw)? {
843            match entry {
844                ConfigParameter::Pair { key, value } => {
845                    let canon = canonical_key(key.trim())?;
846                    entries.push(ConfigEntry {
847                        key: canon,
848                        value,
849                        scope: ConfigScope::Command,
850                        file: Some(pseudo_path.clone()),
851                        line: 0,
852                    });
853                }
854                ConfigParameter::OldStyle(entry) => {
855                    if let Some((key, val)) = entry.split_once('=') {
856                        let canon = canonical_key(key.trim())?;
857                        entries.push(ConfigEntry {
858                            key: canon,
859                            value: Some(val.to_owned()),
860                            scope: ConfigScope::Command,
861                            file: Some(pseudo_path.clone()),
862                            line: 0,
863                        });
864                    } else {
865                        let canon = canonical_key(entry.trim())?;
866                        entries.push(ConfigEntry {
867                            key: canon,
868                            value: None,
869                            scope: ConfigScope::Command,
870                            file: Some(pseudo_path.clone()),
871                            line: 0,
872                        });
873                    }
874                }
875            }
876        }
877        Ok(Self {
878            path: path.to_path_buf(),
879            scope: ConfigScope::Command,
880            entries,
881            raw_lines: Vec::new(),
882            include_origin: ConfigIncludeOrigin::CommandLine,
883        })
884    }
885
886    /// Read and parse a config file from disk.
887    ///
888    /// Returns `Ok(None)` if the file does not exist.
889    ///
890    /// # Errors
891    ///
892    /// Returns [`Error::Io`] on read failure (other than not-found) or
893    /// [`Error::ConfigError`] on parse failure.
894    pub fn from_path(path: &Path, scope: ConfigScope) -> Result<Option<Self>> {
895        match fs::read_to_string(path) {
896            Ok(content) => Ok(Some(Self::parse(path, &content, scope)?)),
897            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
898            Err(e) => Err(Error::Io(e)),
899        }
900    }
901
902    /// Set a value in this config file, creating the section if needed.
903    ///
904    /// If the key already exists, its last occurrence is updated in-place.
905    /// Otherwise a new entry is appended (creating the section header if
906    /// necessary).
907    ///
908    /// # Parameters
909    ///
910    /// - `key` — canonical key (e.g. `core.bare`).
911    /// - `value` — the value to set.
912    pub fn set(&mut self, key: &str, value: &str) -> Result<()> {
913        self.set_with_comment(key, value, None)
914    }
915
916    /// Set a value in this config file, optionally appending an inline comment.
917    pub fn set_with_comment(
918        &mut self,
919        key: &str,
920        value: &str,
921        comment: Option<&str>,
922    ) -> Result<()> {
923        let canon = canonical_key(key)?;
924        let raw_var = raw_variable_name(key);
925        let comment_suffix = format_comment_suffix(comment);
926
927        // Find the last entry with this key to replace in-place.
928        let existing_idx = self.entries.iter().rposition(|e| e.key == canon);
929
930        if let Some(idx) = existing_idx {
931            let line_idx = self.entries[idx].line - 1;
932            let raw_line = &self.raw_lines[line_idx];
933            if is_section_header_with_inline_entry(raw_line) {
934                // Entry is on the same line as a section header — split it
935                let header_only = extract_section_header(raw_line);
936                self.raw_lines[line_idx] = header_only;
937                let new_line = format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
938                self.raw_lines.insert(line_idx + 1, new_line);
939                // Re-parse to fix up entries and line numbers
940                let content = self.raw_lines.join("\n");
941                let reparsed = Self::parse(&self.path, &content, self.scope)?;
942                self.entries = reparsed.entries;
943                self.raw_lines = reparsed.raw_lines;
944            } else {
945                self.raw_lines[line_idx] =
946                    format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
947                self.entries[idx].value = Some(value.to_owned());
948            }
949        } else {
950            // Need to add: find or create the section
951            let (section, subsection, _var) = split_key(&canon)?;
952            let (raw_sec, raw_sub) = raw_section_parts(key);
953            let section_line = self.find_or_create_section_preserving_case(
954                &section,
955                subsection.as_deref(),
956                &raw_sec,
957                raw_sub.as_deref(),
958            );
959            let new_line = format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
960
961            // Insert after the section header (or last entry in section)
962            let insert_at = self.last_line_in_section(section_line) + 1;
963            self.raw_lines.insert(insert_at, new_line);
964
965            // Re-parse to fix up line numbers
966            let content = self.raw_lines.join("\n");
967            let reparsed = Self::parse(&self.path, &content, self.scope)?;
968            self.entries = reparsed.entries;
969            self.raw_lines = reparsed.raw_lines;
970        }
971
972        Ok(())
973    }
974
975    /// Replace ALL occurrences of a key with a new value.
976    ///
977    /// Removes all but the last occurrence from the file, then updates
978    /// the last occurrence with the new value (matching Git behaviour).
979    pub fn replace_all(
980        &mut self,
981        key: &str,
982        value: &str,
983        value_pattern: Option<&str>,
984    ) -> Result<()> {
985        self.replace_all_with_comment(key, value, value_pattern, None)
986    }
987
988    /// Replace all occurrences, optionally appending an inline comment.
989    ///
990    /// Value patterns starting with `!` are treated as negated regex
991    /// (matching values that do NOT match the pattern).
992    pub fn replace_all_with_comment(
993        &mut self,
994        key: &str,
995        value: &str,
996        value_pattern: Option<&str>,
997        comment: Option<&str>,
998    ) -> Result<()> {
999        let canon = canonical_key(key)?;
1000        let comment_suffix = format_comment_suffix(comment);
1001
1002        // Parse optional regex pattern, handling `!` negation
1003        let (re, negated) = match value_pattern {
1004            Some(pat) => {
1005                let (neg, actual_pat) = if let Some(rest) = pat.strip_prefix('!') {
1006                    (true, rest)
1007                } else {
1008                    (false, pat)
1009                };
1010                let compiled = regex::Regex::new(actual_pat)
1011                    .map_err(|e| Error::ConfigError(format!("invalid value-pattern regex: {e}")))?;
1012                (Some(compiled), neg)
1013            }
1014            None => (None, false),
1015        };
1016
1017        // Find all matching entries (by key, and optionally by value pattern)
1018        let matching_indices: Vec<usize> = self
1019            .entries
1020            .iter()
1021            .enumerate()
1022            .filter(|(_, e)| {
1023                if e.key != canon {
1024                    return false;
1025                }
1026                if let Some(ref re) = re {
1027                    let v = e.value.as_deref().unwrap_or("");
1028                    let matched = re.is_match(v);
1029                    if negated {
1030                        !matched
1031                    } else {
1032                        matched
1033                    }
1034                } else {
1035                    true
1036                }
1037            })
1038            .map(|(i, _)| i)
1039            .collect();
1040
1041        if matching_indices.is_empty() {
1042            // No matching entries — add a new one at the end of the section
1043            return self.add_value_with_comment(key, value, comment);
1044        }
1045
1046        let raw_var = raw_variable_name(key);
1047
1048        let target_idx = if value_pattern.is_some() {
1049            matching_indices[0]
1050        } else {
1051            *matching_indices
1052                .last()
1053                .ok_or_else(|| Error::ConfigError("missing config match".to_owned()))?
1054        };
1055        let target_line_idx = self.entries[target_idx].line - 1;
1056        let raw_line = &self.raw_lines[target_line_idx];
1057        if is_section_header_with_inline_entry(raw_line) {
1058            let header = extract_section_header(raw_line);
1059            self.raw_lines[target_line_idx] = header;
1060            let new_line = format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
1061            self.raw_lines.insert(target_line_idx + 1, new_line);
1062        } else {
1063            self.raw_lines[target_line_idx] =
1064                format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
1065        }
1066
1067        for &idx in matching_indices.iter().rev() {
1068            if idx == target_idx {
1069                continue;
1070            }
1071            let line_idx = self.entries[idx].line - 1;
1072            self.remove_entry_line(line_idx);
1073        }
1074
1075        // Re-parse
1076        let content = self.raw_lines.join("\n");
1077        let reparsed = Self::parse(&self.path, &content, self.scope)?;
1078        self.entries = reparsed.entries;
1079        self.raw_lines = reparsed.raw_lines;
1080
1081        Ok(())
1082    }
1083
1084    /// Count how many entries exist for a key.
1085    pub fn count(&self, key: &str) -> Result<usize> {
1086        let canon = canonical_key(key)?;
1087        Ok(self.entries.iter().filter(|e| e.key == canon).count())
1088    }
1089
1090    /// Remove an entry at the given raw line index.
1091    ///
1092    /// If the line is a section header with an inline entry, only the inline
1093    /// portion is removed (the header is kept). Otherwise the entire line is
1094    /// removed. Also removes continuation lines following the entry.
1095    /// Remove an entry at the given raw line index.
1096    ///
1097    /// If the line is a section header with an inline entry, only the inline
1098    /// portion is removed (the header is kept). Otherwise the entire line
1099    /// (and any continuation lines) is removed.
1100    fn remove_entry_line(&mut self, line_idx: usize) {
1101        if is_section_header_with_inline_entry(&self.raw_lines[line_idx]) {
1102            // Keep the section header, strip the inline entry
1103            let header = extract_section_header(&self.raw_lines[line_idx]);
1104            self.raw_lines[line_idx] = header;
1105        } else {
1106            // Check if this line has continuation lines and remove them too
1107            let mut lines_to_remove = 1;
1108            let mut check_line = self.raw_lines[line_idx].clone();
1109            while value_line_continues(&check_line)
1110                && (line_idx + lines_to_remove) < self.raw_lines.len()
1111            {
1112                check_line = self.raw_lines[line_idx + lines_to_remove].clone();
1113                lines_to_remove += 1;
1114            }
1115            for _ in 0..lines_to_remove {
1116                self.raw_lines.remove(line_idx);
1117            }
1118        }
1119    }
1120
1121    /// Unset (remove) only the last occurrence of a key.
1122    ///
1123    /// Returns the number of entries removed (0 or 1).
1124    pub fn unset_last(&mut self, key: &str) -> Result<usize> {
1125        let canon = canonical_key(key)?;
1126        let last_idx = self.entries.iter().rposition(|e| e.key == canon);
1127
1128        if let Some(idx) = last_idx {
1129            let line_idx = self.entries[idx].line - 1;
1130            self.remove_entry_line(line_idx);
1131            let content = self.raw_lines.join("\n");
1132            let reparsed = Self::parse(&self.path, &content, self.scope)?;
1133            self.entries = reparsed.entries;
1134            self.raw_lines = reparsed.raw_lines;
1135            Ok(1)
1136        } else {
1137            Ok(0)
1138        }
1139    }
1140
1141    /// Unset (remove) all occurrences of a key.
1142    ///
1143    /// # Parameters
1144    ///
1145    /// - `key` — canonical key (e.g. `core.bare`).
1146    ///
1147    /// # Returns
1148    ///
1149    /// The number of entries removed.
1150    pub fn unset(&mut self, key: &str) -> Result<usize> {
1151        let canon = canonical_key(key)?;
1152        let line_indices: Vec<usize> = self
1153            .entries
1154            .iter()
1155            .filter(|e| e.key == canon)
1156            .map(|e| e.line - 1)
1157            .collect();
1158
1159        let count = line_indices.len();
1160        // Remove from bottom to top to keep indices valid
1161        for &idx in line_indices.iter().rev() {
1162            self.remove_entry_line(idx);
1163        }
1164
1165        if count > 0 {
1166            let content = self.raw_lines.join("\n");
1167            let reparsed = Self::parse(&self.path, &content, self.scope)?;
1168            self.entries = reparsed.entries;
1169            self.raw_lines = reparsed.raw_lines;
1170        }
1171
1172        Ok(count)
1173    }
1174
1175    /// Unset entries matching a key and optional value-pattern regex.
1176    ///
1177    /// If `value_pattern` is `None`, removes all entries with the given key.
1178    /// If `value_pattern` is `Some(pat)`, only removes entries whose value matches the regex.
1179    ///
1180    /// When `preserve_empty_section_header` is `true`, a section header is kept even if the
1181    /// section has no remaining keys (Git's `config unset --all`). When `false`, empty sections
1182    /// are stripped (`config --unset`, `config --unset-all`, and value-pattern unsets).
1183    pub fn unset_matching(
1184        &mut self,
1185        key: &str,
1186        value_pattern: Option<&str>,
1187        preserve_empty_section_header: bool,
1188    ) -> Result<usize> {
1189        let canon = canonical_key(key)?;
1190        let re = match value_pattern {
1191            Some(pat) => Some(
1192                regex::Regex::new(pat)
1193                    .map_err(|e| Error::ConfigError(format!("invalid value-pattern regex: {e}")))?,
1194            ),
1195            None => None,
1196        };
1197
1198        let line_indices: Vec<usize> = self
1199            .entries
1200            .iter()
1201            .filter(|e| {
1202                if e.key != canon {
1203                    return false;
1204                }
1205                if let Some(ref re) = re {
1206                    let v = e.value.as_deref().unwrap_or("");
1207                    re.is_match(v)
1208                } else {
1209                    true
1210                }
1211            })
1212            .map(|e| e.line - 1)
1213            .collect();
1214
1215        let count = line_indices.len();
1216        for &idx in line_indices.iter().rev() {
1217            self.remove_entry_line(idx);
1218        }
1219
1220        if count > 0 {
1221            if !preserve_empty_section_header {
1222                let (section, subsection, _) = split_key(&canon)?;
1223                self.remove_empty_section_headers_matching(&section, subsection.as_deref());
1224            }
1225
1226            let content = self.raw_lines.join("\n");
1227            let reparsed = Self::parse(&self.path, &content, self.scope)?;
1228            self.entries = reparsed.entries;
1229            self.raw_lines = reparsed.raw_lines;
1230        }
1231
1232        Ok(count)
1233    }
1234
1235    /// Remove an entire section (and all its entries).
1236    ///
1237    /// # Parameters
1238    ///
1239    /// - `section` — section name (e.g. `"core"`, `"remote.origin"`).
1240    pub fn remove_section(&mut self, section: &str) -> Result<bool> {
1241        let (sec_name, sub_name) = parse_section_name(section);
1242        let sec_lower = sec_name.to_lowercase();
1243
1244        let mut remove = vec![false; self.raw_lines.len()];
1245        let mut removing = false;
1246        let mut found = false;
1247        let mut parser = Parser::new();
1248
1249        for (idx, line) in self.raw_lines.iter().enumerate() {
1250            if parser.try_parse_section(line) {
1251                removing = section_matches(&parser, &sec_lower, sub_name);
1252                found |= removing;
1253            }
1254            if removing {
1255                remove[idx] = true;
1256            }
1257        }
1258
1259        if found {
1260            self.raw_lines = self
1261                .raw_lines
1262                .iter()
1263                .enumerate()
1264                .filter_map(|(idx, line)| (!remove[idx]).then_some(line.clone()))
1265                .collect();
1266            let content = self.raw_lines.join("\n");
1267            let reparsed = Self::parse(&self.path, &content, self.scope)?;
1268            self.entries = reparsed.entries;
1269            self.raw_lines = reparsed.raw_lines;
1270            Ok(true)
1271        } else {
1272            Ok(false)
1273        }
1274    }
1275
1276    /// Rename a section.
1277    ///
1278    /// # Parameters
1279    ///
1280    /// - `old_name` — current section name (e.g. `"branch.main"`).
1281    /// - `new_name` — new section name (e.g. `"branch.develop"`).
1282    pub fn rename_section(&mut self, old_name: &str, new_name: &str) -> Result<bool> {
1283        let (old_sec, old_sub) = parse_section_name(old_name);
1284        let (new_sec, new_sub) = parse_section_name(new_name);
1285        validate_section_name(new_sec, new_sub)?;
1286        let old_lower = old_sec.to_lowercase();
1287
1288        let mut found = false;
1289        let mut parser = Parser::new();
1290
1291        let mut idx = 0usize;
1292        while idx < self.raw_lines.len() {
1293            let line = self.raw_lines[idx].clone();
1294            let mut inline_remainder = None;
1295            if parser.try_parse_section_with_remainder(&line, &mut inline_remainder)
1296                && section_matches(&parser, &old_lower, old_sub)
1297            {
1298                // Rewrite the section header
1299                let header = match new_sub {
1300                    Some(sub) => format!("[{} \"{}\"]", new_sec, sub),
1301                    None => format!("[{}]", new_sec),
1302                };
1303                self.raw_lines[idx] = header;
1304                if let Some(remainder) = inline_remainder {
1305                    self.raw_lines
1306                        .insert(idx + 1, format!("\t{}", remainder.trim()));
1307                    idx += 1;
1308                }
1309                found = true;
1310            }
1311            idx += 1;
1312        }
1313
1314        if found {
1315            let content = self.raw_lines.join("\n");
1316            let reparsed = Self::parse(&self.path, &content, self.scope)?;
1317            self.entries = reparsed.entries;
1318            self.raw_lines = reparsed.raw_lines;
1319        }
1320
1321        Ok(found)
1322    }
1323
1324    /// Append a new value for a key without removing existing entries.
1325    ///
1326    /// This is the behaviour of `git config --add section.key value`.
1327    /// If the section doesn't exist, it is created.
1328    pub fn add_value(&mut self, key: &str, value: &str) -> Result<()> {
1329        self.add_value_with_comment(key, value, None)
1330    }
1331
1332    /// Append a new value with an optional inline comment.
1333    pub fn add_value_with_comment(
1334        &mut self,
1335        key: &str,
1336        value: &str,
1337        comment: Option<&str>,
1338    ) -> Result<()> {
1339        let canon = canonical_key(key)?;
1340        let raw_var = raw_variable_name(key);
1341        let comment_suffix = format_comment_suffix(comment);
1342        let (section, subsection, _var) = split_key(&canon)?;
1343        let (raw_sec, raw_sub) = raw_section_parts(key);
1344
1345        let section_line = self.find_or_create_section_preserving_case(
1346            &section,
1347            subsection.as_deref(),
1348            &raw_sec,
1349            raw_sub.as_deref(),
1350        );
1351        let new_line = format!("\t{} = {}{}", raw_var, escape_value(value), comment_suffix);
1352        let insert_at = self.last_line_in_section(section_line) + 1;
1353        self.raw_lines.insert(insert_at, new_line);
1354
1355        // Re-parse to fix up entries and line numbers
1356        let content = self.raw_lines.join("\n");
1357        let reparsed = Self::parse(&self.path, &content, self.scope)?;
1358        self.entries = reparsed.entries;
1359        self.raw_lines = reparsed.raw_lines;
1360
1361        Ok(())
1362    }
1363
1364    /// Write the (possibly modified) config back to disk.
1365    /// Remove section headers that have no remaining entries or comments.
1366    fn remove_empty_section_headers_matching(&mut self, section: &str, subsection: Option<&str>) {
1367        let (Ok(section_re), Ok(comment_re)) = (
1368            regex::Regex::new(r"^\s*\["),
1369            regex::Regex::new(r"^\s*(#|;)"),
1370        ) else {
1371            // Static patterns: compilation cannot fail in practice; bail out safely.
1372            return;
1373        };
1374
1375        let mut to_remove: Vec<usize> = Vec::new();
1376        let len = self.raw_lines.len();
1377        let section_lower = section.to_lowercase();
1378        let mut parser = Parser::new();
1379
1380        for i in 0..len {
1381            let line = &self.raw_lines[i];
1382            if !section_re.is_match(line) {
1383                continue;
1384            }
1385            if !parser.try_parse_section(line)
1386                || !section_matches(&parser, &section_lower, subsection)
1387            {
1388                continue;
1389            }
1390            // Don't remove section headers that have inline key=value entries
1391            if is_section_header_with_inline_entry(line) {
1392                continue;
1393            }
1394            let has_attached_leading_comment = self.raw_lines[..i]
1395                .iter()
1396                .enumerate()
1397                .rev()
1398                .find(|(_, line)| !line.trim().is_empty())
1399                .is_some_and(|(idx, line)| {
1400                    comment_re.is_match(line)
1401                        && idx
1402                            .checked_sub(1)
1403                            .is_none_or(|prev| !value_line_continues(&self.raw_lines[prev]))
1404                });
1405            if has_attached_leading_comment {
1406                continue;
1407            }
1408            // Check if this section header is followed only by blank lines,
1409            // comments, or another section header (or end of file).
1410            let mut has_entries = false;
1411            for j in (i + 1)..len {
1412                let next = self.raw_lines[j].trim();
1413                if next.is_empty() {
1414                    continue;
1415                }
1416                if section_re.is_match(&self.raw_lines[j]) {
1417                    break;
1418                }
1419                if comment_re.is_match(&self.raw_lines[j]) {
1420                    // Has comments — keep the section
1421                    has_entries = true;
1422                    break;
1423                }
1424                // Has a key-value entry
1425                has_entries = true;
1426                break;
1427            }
1428            if !has_entries {
1429                to_remove.push(i);
1430            }
1431        }
1432
1433        // Remove in reverse to preserve indices
1434        for &idx in to_remove.iter().rev() {
1435            self.raw_lines.remove(idx);
1436        }
1437
1438        // Also remove trailing blank lines
1439        while self.raw_lines.last().is_some_and(|l| l.trim().is_empty()) {
1440            self.raw_lines.pop();
1441        }
1442    }
1443
1444    ///
1445    /// # Errors
1446    ///
1447    /// Returns [`Error::Io`] on write failure.
1448    pub fn write(&self) -> Result<()> {
1449        let content = self.raw_lines.join("\n");
1450        let trimmed = content.trim();
1451        if trimmed.is_empty() {
1452            // Write empty file if no content
1453            fs::write(&self.path, "")?;
1454        } else {
1455            // Ensure trailing newline
1456            let content = if content.ends_with('\n') {
1457                content
1458            } else {
1459                format!("{content}\n")
1460            };
1461            fs::write(&self.path, content)?;
1462        }
1463        evict_config_cache_for_path(&self.path);
1464        Ok(())
1465    }
1466
1467    /// Find the line index of a section header, or create one.
1468    #[allow(dead_code)]
1469    fn find_or_create_section(&mut self, section: &str, subsection: Option<&str>) -> usize {
1470        let sec_lower = section.to_lowercase();
1471        let mut parser = Parser::new();
1472
1473        for (idx, line) in self.raw_lines.iter().enumerate() {
1474            if parser.try_parse_section(line) && section_matches(&parser, &sec_lower, subsection) {
1475                return idx;
1476            }
1477        }
1478
1479        // Create new section at end of file
1480        let header = match subsection {
1481            Some(sub) => {
1482                let escaped = escape_subsection(sub);
1483                format!("[{} \"{}\"]", section, escaped)
1484            }
1485            None => format!("[{}]", section),
1486        };
1487        self.raw_lines.push(header);
1488        self.raw_lines.len() - 1
1489    }
1490
1491    /// Find the line index of a section header (case-insensitive match),
1492    /// or create one using the original-case names from user input.
1493    fn find_or_create_section_preserving_case(
1494        &mut self,
1495        section: &str,
1496        subsection: Option<&str>,
1497        raw_section: &str,
1498        raw_subsection: Option<&str>,
1499    ) -> usize {
1500        let sec_lower = section.to_lowercase();
1501        let mut parser = Parser::new();
1502
1503        for (idx, line) in self.raw_lines.iter().enumerate() {
1504            if parser.try_parse_section(line) && section_matches(&parser, &sec_lower, subsection) {
1505                return idx;
1506            }
1507        }
1508
1509        // Create new section at end of file, using original case
1510        let header = match raw_subsection {
1511            Some(sub) => {
1512                let escaped = escape_subsection(sub);
1513                format!("[{} \"{}\"]", raw_section, escaped)
1514            }
1515            None => format!("[{}]", raw_section),
1516        };
1517        self.raw_lines.push(header);
1518        self.raw_lines.len() - 1
1519    }
1520
1521    /// Find the last line that belongs to the section starting at `section_line`.
1522    fn last_line_in_section(&self, section_line: usize) -> usize {
1523        let mut last = section_line;
1524        for idx in (section_line + 1)..self.raw_lines.len() {
1525            let trimmed = self.raw_lines[idx].trim();
1526            if trimmed.starts_with('[') {
1527                break;
1528            }
1529            last = idx;
1530        }
1531        last
1532    }
1533}
1534
1535// ── ConfigSet ───────────────────────────────────────────────────────
1536
1537impl ConfigSet {
1538    /// Create an empty config set.
1539    #[must_use]
1540    pub fn new() -> Self {
1541        Self {
1542            entries: Vec::new(),
1543        }
1544    }
1545
1546    /// All merged entries in load order (for listing keys such as `alias.*`).
1547    #[must_use]
1548    pub fn entries(&self) -> &[ConfigEntry] {
1549        &self.entries
1550    }
1551
1552    /// Merge entries from a [`ConfigFile`] into this set.
1553    ///
1554    /// Entries are appended; later values override earlier ones for
1555    /// single-value lookups.
1556    pub fn merge(&mut self, file: &ConfigFile) {
1557        self.entries.extend(file.entries.iter().cloned());
1558    }
1559
1560    /// Merge another [`ConfigSet`] into this set (entries appended in order).
1561    pub fn merge_set(&mut self, other: &ConfigSet) {
1562        self.entries.extend(other.entries.iter().cloned());
1563    }
1564
1565    /// Add a command-line override (`-c key=value`).
1566    pub fn add_command_override(&mut self, key: &str, value: &str) -> Result<()> {
1567        let canon = canonical_key(key)?;
1568        self.entries.push(ConfigEntry {
1569            key: canon,
1570            value: Some(value.to_owned()),
1571            scope: ConfigScope::Command,
1572            file: None,
1573            line: 0,
1574        });
1575        Ok(())
1576    }
1577
1578    /// Get the last (highest-priority) value for a key.
1579    ///
1580    /// # Parameters
1581    ///
1582    /// - `key` — the key to look up (will be canonicalized).
1583    ///
1584    /// # Returns
1585    ///
1586    /// `Some(value)` for the last matching entry, or `None` if not found.
1587    /// Bare boolean keys return `Some("true")`.
1588    #[must_use]
1589    pub fn get(&self, key: &str) -> Option<String> {
1590        let canon = canonical_key(key).ok()?;
1591        self.entries
1592            .iter()
1593            .rev()
1594            .find(|e| e.key == canon)
1595            .map(|e| e.value.clone().unwrap_or_else(|| "true".to_owned()))
1596    }
1597
1598    /// Last (highest-priority) [`ConfigEntry`] for a key, including origin metadata.
1599    ///
1600    /// Bare boolean keys are returned with [`ConfigEntry::value`] set to `None` (same as `get`,
1601    /// which maps them to `"true"` for string lookups).
1602    #[must_use]
1603    pub fn get_last_entry(&self, key: &str) -> Option<ConfigEntry> {
1604        let canon = canonical_key(key).ok()?;
1605        self.entries.iter().rev().find(|e| e.key == canon).cloned()
1606    }
1607
1608    /// Get all values for a key (multi-valued; in load order).
1609    #[must_use]
1610    pub fn get_all(&self, key: &str) -> Vec<String> {
1611        let canon = match canonical_key(key) {
1612            Ok(c) => c,
1613            Err(_) => return Vec::new(),
1614        };
1615        self.entries
1616            .iter()
1617            .filter(|e| e.key == canon)
1618            .map(|e| e.value.clone().unwrap_or_default())
1619            .collect()
1620    }
1621
1622    /// All raw values for a key in load order, preserving `None` for bare boolean keys.
1623    ///
1624    /// Matches Git's multi-value list where `NULL` means a value-less / boolean-true key.
1625    #[must_use]
1626    pub fn get_all_raw(&self, key: &str) -> Vec<Option<String>> {
1627        let canon = match canonical_key(key) {
1628            Ok(c) => c,
1629            Err(_) => return Vec::new(),
1630        };
1631        self.entries
1632            .iter()
1633            .filter(|e| e.key == canon)
1634            .map(|e| e.value.clone())
1635            .collect()
1636    }
1637
1638    /// True if any config entry uses `key` (after canonicalization), including bare boolean keys.
1639    ///
1640    /// Unlike [`Self::get`], this does not treat a missing value as `"true"` — it reports whether
1641    /// the key appears in the merged config at all (Git `repo_config_get` / `git_configset_get`).
1642    #[must_use]
1643    pub fn has_key(&self, key: &str) -> bool {
1644        let Ok(canon) = canonical_key(key) else {
1645            return false;
1646        };
1647        self.entries.iter().any(|e| e.key == canon)
1648    }
1649
1650    /// Get a boolean value, interpreting `true`/`yes`/`on`/`1` as true and
1651    /// `false`/`no`/`off`/`0` as false.
1652    ///
1653    /// `pack.allowPackReuse` may be `single` or `multi` (Git enum, not a bool). Those values are
1654    /// treated as unset for boolean lookup so `get_bool` does not error during broad config scans.
1655    pub fn get_bool(&self, key: &str) -> Option<std::result::Result<bool, String>> {
1656        let v = self.get(key)?;
1657        if canonical_key(key).ok().as_deref() == Some("pack.allowpackreuse") {
1658            let lower = v.trim().to_ascii_lowercase();
1659            if lower == "single" || lower == "multi" {
1660                return None;
1661            }
1662        }
1663        Some(parse_bool(&v))
1664    }
1665
1666    /// Whether pathnames in human-readable output should fully C-quote non-ASCII bytes as octal.
1667    ///
1668    /// Maps to Git's `quote_path_fully` (`core.quotepath`, default true). When false, UTF-8 and
1669    /// other high bytes are emitted literally; only ASCII specials are escaped. Also honors
1670    /// `core.quotePath` as an alternate spelling.
1671    #[must_use]
1672    pub fn quote_path_fully(&self) -> bool {
1673        let from_key = |key: &str| self.get_bool(key).and_then(|r| r.ok());
1674        from_key("core.quotepath")
1675            .or_else(|| from_key("core.quotePath"))
1676            .unwrap_or(true)
1677    }
1678
1679    /// Default for `pack.writeReverseIndex` / `pack.writereverseindex` (Git default: true).
1680    ///
1681    /// Tests set `GIT_TEST_NO_WRITE_REV_INDEX` to force no `.rev` output.
1682    #[must_use]
1683    pub fn pack_write_reverse_index_default(&self) -> bool {
1684        if std::env::var("GIT_TEST_NO_WRITE_REV_INDEX")
1685            .ok()
1686            .as_deref()
1687            .is_some_and(|v| {
1688                let s = v.trim().to_ascii_lowercase();
1689                matches!(s.as_str(), "1" | "true" | "yes" | "on")
1690            })
1691        {
1692            return false;
1693        }
1694        if self
1695            .get("pack.writereverseindex")
1696            .or_else(|| self.get("pack.writeReverseIndex"))
1697            .is_some_and(|v| v.trim().is_empty())
1698        {
1699            return false;
1700        }
1701        self.get_bool("pack.writereverseindex")
1702            .or_else(|| self.get_bool("pack.writeReverseIndex"))
1703            .and_then(|r| r.ok())
1704            .unwrap_or(true)
1705    }
1706
1707    /// Default for `pack.readReverseIndex` / `pack.readreverseindex` (Git default: true).
1708    #[must_use]
1709    pub fn pack_read_reverse_index_default(&self) -> bool {
1710        self.get_bool("pack.readreverseindex")
1711            .or_else(|| self.get_bool("pack.readReverseIndex"))
1712            .and_then(|r| r.ok())
1713            .unwrap_or(true)
1714    }
1715
1716    /// Resolved `core.logAllRefUpdates` using this merged set (includes `git -c` / env), then Git's
1717    /// bare-repo default when the key is unset everywhere.
1718    #[must_use]
1719    pub fn effective_log_refs_config(&self, git_dir: &Path) -> refs::LogRefsConfig {
1720        if let Some(v) = self.get("core.logAllRefUpdates") {
1721            let lower = v.trim().to_ascii_lowercase();
1722            let parsed = match lower.as_str() {
1723                "always" => Some(refs::LogRefsConfig::Always),
1724                "1" | "true" | "yes" | "on" => Some(refs::LogRefsConfig::Normal),
1725                "0" | "false" | "no" | "off" | "never" => Some(refs::LogRefsConfig::None),
1726                _ => None,
1727            };
1728            if let Some(c) = parsed {
1729                return c;
1730            }
1731        }
1732        refs::effective_log_refs_config(git_dir)
1733    }
1734
1735    /// Get an integer value, supporting Git's `k`/`m`/`g` suffixes.
1736    pub fn get_i64(&self, key: &str) -> Option<std::result::Result<i64, String>> {
1737        self.get(key).map(|v| parse_i64(&v))
1738    }
1739
1740    /// Zlib deflate level for `git pack-objects` (Git's `pack_compression_level`).
1741    ///
1742    /// Entries are applied in [`Self::entries`] order. `core.compression` sets the pack level
1743    /// until a `pack.compression` appears (Git `pack_compression_seen`). `core.loosecompression`
1744    /// is ignored here — it only affects loose-object zlib, not packs.
1745    ///
1746    /// `-1` means zlib default (level 6). Valid values are `-1` or `0..=9`.
1747    pub fn pack_objects_zlib_level(&self) -> Result<i32> {
1748        const Z_DEFAULT_COMPRESSION: i32 = 6;
1749        const Z_BEST_COMPRESSION: i32 = 9;
1750
1751        let parse_compression = |raw: &str| -> Result<i32> {
1752            let v = parse_git_config_int_strict(raw.trim()).map_err(|_| {
1753                Error::ConfigError(format!("bad numeric config value '{raw}' for compression"))
1754            })?;
1755            if v == -1 {
1756                return Ok(Z_DEFAULT_COMPRESSION);
1757            }
1758            if v < 0 || v > i64::from(Z_BEST_COMPRESSION) {
1759                return Err(Error::ConfigError(format!(
1760                    "bad zlib compression level {v}"
1761                )));
1762            }
1763            Ok(v as i32)
1764        };
1765
1766        // `core.loosecompression` affects loose objects only (Git `zlib_compression_level`), not pack.
1767        let mut pack_level = Z_DEFAULT_COMPRESSION;
1768        let mut pack_compression_seen = false;
1769
1770        for e in self.entries() {
1771            match e.key.as_str() {
1772                "core.compression" => {
1773                    let Some(val) = e.value.as_deref() else {
1774                        continue;
1775                    };
1776                    let level = parse_compression(val)?;
1777                    if !pack_compression_seen {
1778                        pack_level = level;
1779                    }
1780                }
1781                "pack.compression" => {
1782                    let Some(val) = e.value.as_deref() else {
1783                        continue;
1784                    };
1785                    pack_level = parse_compression(val)?;
1786                    pack_compression_seen = true;
1787                }
1788                _ => {}
1789            }
1790        }
1791
1792        Ok(pack_level)
1793    }
1794
1795    /// Get all entries matching a key pattern (regex).
1796    ///
1797    /// Used by `git config --get-regexp`. Returns an error if the pattern
1798    /// is not a valid regex.
1799    pub fn get_regexp(&self, pattern: &str) -> std::result::Result<Vec<&ConfigEntry>, String> {
1800        let re = regex::Regex::new(pattern).map_err(|e| format!("invalid key pattern: {e}"))?;
1801        Ok(self
1802            .entries
1803            .iter()
1804            .filter(|e| re.is_match(&e.key))
1805            .collect())
1806    }
1807
1808    /// Load the standard Git configuration file cascade for a repository.
1809    ///
1810    /// # Parameters
1811    ///
1812    /// - `git_dir` — path to the `.git` directory (for local/worktree config).
1813    /// - `include_system` — whether to load system config.
1814    ///
1815    /// # Errors
1816    ///
1817    /// Returns errors from file I/O or parsing.
1818    pub fn load(git_dir: Option<&Path>, include_system: bool) -> Result<Self> {
1819        let mut opts = LoadConfigOptions::default();
1820        opts.include_system = include_system;
1821        opts.include_ctx.git_dir = git_dir.map(PathBuf::from);
1822        Self::load_with_options(git_dir, &opts)
1823    }
1824
1825    /// Load the standard configuration cascade with explicit include and scope control.
1826    ///
1827    /// See [`LoadConfigOptions`] for `GIT_CONFIG_PARAMETERS` / `-c` include behaviour.
1828    ///
1829    /// Results are memoized for the process lifetime and revalidated against
1830    /// the cascade files' stat stamps on every call (see the cache notes near
1831    /// [`ConfigCacheKey`]).
1832    pub fn load_with_options(git_dir: Option<&Path>, opts: &LoadConfigOptions) -> Result<Self> {
1833        let Some(env_fp) = config_env_fingerprint() else {
1834            return Self::load_with_options_uncached(git_dir, opts, &mut Vec::new());
1835        };
1836        let key = ConfigCacheKey::new(git_dir, opts);
1837        let base_stamps = config_file_stamps(git_dir, opts);
1838        if let Some(cached) = config_cache_lookup(&key, &env_fp, &base_stamps) {
1839            return Ok(cached);
1840        }
1841        let mut included_files = Vec::new();
1842        let set = Self::load_with_options_uncached(git_dir, opts, &mut included_files)?;
1843        included_files.sort_unstable();
1844        included_files.dedup();
1845        let extra_stamps = stamp_paths(included_files);
1846        let mut cache = config_cache()
1847            .lock()
1848            .unwrap_or_else(std::sync::PoisonError::into_inner);
1849        cache.insert(
1850            key,
1851            ConfigCacheEntry {
1852                env_fingerprint: env_fp,
1853                base_stamps,
1854                extra_stamps,
1855                set: Arc::new(set.clone()),
1856            },
1857        );
1858        Ok(set)
1859    }
1860
1861    fn load_with_options_uncached(
1862        git_dir: Option<&Path>,
1863        opts: &LoadConfigOptions,
1864        included_files: &mut Vec<PathBuf>,
1865    ) -> Result<Self> {
1866        let mut set = Self::new();
1867        let proc = opts.process_includes;
1868        let ctx = opts.include_ctx.clone();
1869
1870        // System config
1871        if opts.include_system && !git_config_nosystem_enabled() {
1872            let system_path = std::env::var("GIT_CONFIG_SYSTEM")
1873                .map(std::path::PathBuf::from)
1874                .unwrap_or_else(|_| std::path::PathBuf::from("/etc/gitconfig"));
1875            match ConfigFile::from_path(&system_path, ConfigScope::System) {
1876                Ok(Some(f)) => {
1877                    Self::merge_with_includes_collect(&mut set, &f, proc, 0, &ctx, included_files)?
1878                }
1879                Ok(None) => {}
1880                Err(e) => return Err(e),
1881            }
1882        }
1883
1884        // Global config (Git merges every existing file: XDG then ~/.gitconfig).
1885        for path in global_config_paths() {
1886            match ConfigFile::from_path(&path, ConfigScope::Global) {
1887                Ok(Some(f)) => {
1888                    Self::merge_with_includes_collect(&mut set, &f, proc, 0, &ctx, included_files)?
1889                }
1890                Ok(None) => {}
1891                Err(e) => return Err(e),
1892            }
1893        }
1894
1895        // Local config — linked worktrees read `commondir/config`, not the admin `config`.
1896        if let Some(gd) = git_dir {
1897            let common_dir = crate::repo::common_git_dir_for_config(gd);
1898            let local_path = common_dir.join("config");
1899            match ConfigFile::from_path(&local_path, ConfigScope::Local) {
1900                Ok(Some(f)) => {
1901                    Self::merge_with_includes_collect(&mut set, &f, proc, 0, &ctx, included_files)?
1902                }
1903                Ok(None) => {}
1904                Err(e) => return Err(e),
1905            }
1906
1907            // Worktree config — Git only reads `config.worktree` when
1908            // `extensions.worktreeConfig` is enabled in the common repository `config`.
1909            let wt_path = gd.join("config.worktree");
1910            if crate::repo::worktree_config_enabled(&common_dir) {
1911                match ConfigFile::from_path(&wt_path, ConfigScope::Worktree) {
1912                    Ok(Some(f)) => Self::merge_with_includes_collect(
1913                        &mut set,
1914                        &f,
1915                        proc,
1916                        0,
1917                        &ctx,
1918                        included_files,
1919                    )?,
1920                    Ok(None) => {}
1921                    Err(e) => return Err(e),
1922                }
1923            }
1924        }
1925
1926        // Environment overrides: optional file
1927        if let Ok(path) = std::env::var("GIT_CONFIG") {
1928            match ConfigFile::from_path(Path::new(&path), ConfigScope::Command) {
1929                Ok(Some(f)) => {
1930                    if proc {
1931                        Self::merge_with_includes_collect(
1932                            &mut set,
1933                            &f,
1934                            proc,
1935                            0,
1936                            &ctx,
1937                            included_files,
1938                        )?;
1939                    } else {
1940                        set.merge(&f);
1941                    }
1942                }
1943                Ok(None) => {}
1944                Err(e) => return Err(e),
1945            }
1946        }
1947
1948        add_environment_config_pairs(&mut set)?;
1949
1950        // GIT_CONFIG_PARAMETERS — used by `git -c key=value`.
1951        if let Ok(params) = std::env::var("GIT_CONFIG_PARAMETERS") {
1952            if proc && opts.command_includes && !params.trim().is_empty() {
1953                let pseudo = Path::new(":GIT_CONFIG_PARAMETERS");
1954                let cmd_file = ConfigFile::from_git_config_parameters(pseudo, &params)?;
1955                Self::merge_with_includes_collect(
1956                    &mut set,
1957                    &cmd_file,
1958                    proc,
1959                    0,
1960                    &ctx,
1961                    included_files,
1962                )?;
1963            } else if !params.trim().is_empty() {
1964                for entry in parse_config_parameters(&params) {
1965                    if let Some((key, val)) =
1966                        entry.split_once('\u{1}').or_else(|| entry.split_once('='))
1967                    {
1968                        let _ = set.add_command_override(key.trim(), val);
1969                    } else {
1970                        let _ = set.add_command_override(entry.trim(), "true");
1971                    }
1972                }
1973            }
1974        }
1975
1976        Ok(set)
1977    }
1978
1979    /// Read configuration the way Git's `read_early_config` / `do_git_config_sequence` does:
1980    /// system (unless disabled), global files in Git order, optional repository `config` /
1981    /// `config.worktree`, then `GIT_CONFIG_PARAMETERS`.
1982    ///
1983    /// When `git_dir` is `None` (no discovered repository, e.g. `GIT_CEILING_DIRECTORIES`), only
1984    /// non-repo layers are read — matching Git when discovery returns no gitdir (t1309 ceiling #2).
1985    ///
1986    /// Returns all values for `key` in load order (Git's `read_early_config` callback runs once per
1987    /// occurrence).
1988    ///
1989    /// This matches upstream ordering for `test-tool config read_early_config` (t1309, t1305).
1990    pub fn read_early_config(git_dir: Option<&Path>, key: &str) -> Result<Vec<String>> {
1991        let mut set = Self::new();
1992        let ctx = IncludeContext {
1993            git_dir: git_dir.map(PathBuf::from),
1994            command_line_relative_include_is_error: false,
1995        };
1996
1997        // System
1998        if !git_config_nosystem_enabled() {
1999            let system_path = std::env::var("GIT_CONFIG_SYSTEM")
2000                .map(std::path::PathBuf::from)
2001                .unwrap_or_else(|_| std::path::PathBuf::from("/etc/gitconfig"));
2002            if let Ok(Some(f)) = ConfigFile::from_path(&system_path, ConfigScope::System) {
2003                Self::merge_with_includes(&mut set, &f, true, 0, &ctx)?;
2004            }
2005        }
2006
2007        // Global: all existing candidates (Git merges every readable file).
2008        for path in global_config_paths() {
2009            if let Ok(Some(f)) = ConfigFile::from_path(&path, ConfigScope::Global) {
2010                Self::merge_with_includes(&mut set, &f, true, 0, &ctx)?;
2011            }
2012        }
2013
2014        if let Some(gd) = git_dir {
2015            let common_dir = crate::repo::common_git_dir_for_config(gd);
2016            // Local (commondir) — skip when format is newer than supported (t1309).
2017            let local_path = common_dir.join("config");
2018            if let Some(msg) = crate::repo::early_config_ignore_repo_reason(&common_dir) {
2019                eprintln!("warning: ignoring git dir '{}': {}", gd.display(), msg);
2020            } else if let Ok(Some(f)) = ConfigFile::from_path(&local_path, ConfigScope::Local) {
2021                set.merge_file_with_includes(&f, true, &ctx)?;
2022            }
2023
2024            // Worktree-specific config (when enabled for this repo).
2025            let wt_path = gd.join("config.worktree");
2026            if crate::repo::worktree_config_enabled(&common_dir) {
2027                if let Ok(Some(f)) = ConfigFile::from_path(&wt_path, ConfigScope::Worktree) {
2028                    Self::merge_with_includes(&mut set, &f, true, 0, &ctx)?;
2029                }
2030            }
2031        }
2032
2033        // GIT_CONFIG_PARAMETERS — same as full load (`load_with_options` default).
2034        if let Ok(params) = std::env::var("GIT_CONFIG_PARAMETERS") {
2035            if !params.trim().is_empty() {
2036                let pseudo = Path::new(":GIT_CONFIG_PARAMETERS");
2037                let cmd_file = ConfigFile::from_git_config_parameters(pseudo, &params)?;
2038                Self::merge_with_includes(&mut set, &cmd_file, true, 0, &ctx)?;
2039            }
2040        }
2041
2042        Ok(set.get_all(key))
2043    }
2044
2045    /// Merge a single config file, optionally expanding `[include]` / `[includeIf]`.
2046    ///
2047    /// Used by `grit config -f` and scoped reads; [`ConfigSet::load_with_options`] uses the same
2048    /// internal routine for the standard cascade.
2049    pub fn merge_file_with_includes(
2050        &mut self,
2051        file: &ConfigFile,
2052        process_includes: bool,
2053        ctx: &IncludeContext,
2054    ) -> Result<()> {
2055        Self::merge_with_includes(self, file, process_includes, 0, ctx)
2056    }
2057
2058    /// Load only the repository's own `config` file (plus any `[include]` targets).
2059    ///
2060    /// Unlike [`Self::load`], this ignores system/global config and environment
2061    /// overrides. Used for receive-side options (e.g. `transfer.fsckObjects`) so a
2062    /// pusher's global configuration cannot weaken the remote repository's policy.
2063    pub fn load_repo_local_only(git_dir: &Path) -> Result<Self> {
2064        let mut set = Self::new();
2065        let local_path = git_dir.join("config");
2066        let ctx = IncludeContext {
2067            git_dir: Some(git_dir.to_path_buf()),
2068            command_line_relative_include_is_error: false,
2069        };
2070        if let Ok(Some(f)) = ConfigFile::from_path(&local_path, ConfigScope::Local) {
2071            Self::merge_with_includes(&mut set, &f, true, 0, &ctx)?;
2072        }
2073        Ok(set)
2074    }
2075
2076    /// Load configuration the way Git loads **protected** config (e.g. `uploadpack.packObjectsHook`).
2077    ///
2078    /// This matches Git's `read_protected_config`: system (optional), global files only (no
2079    /// repository or worktree `config`), then command-line overrides from `GIT_CONFIG_COUNT` /
2080    /// `GIT_CONFIG_PARAMETERS`. It does **not** read `$GIT_CONFIG` (Git omits that for protected
2081    /// config).
2082    ///
2083    /// Global file order matches Git: XDG `git/config` first (when present), then `~/.gitconfig`,
2084    /// unless `GIT_CONFIG_GLOBAL` is set (single file). When both global files exist, both are
2085    /// merged so later entries win for duplicate keys.
2086    pub fn load_protected(include_system: bool) -> Result<Self> {
2087        let mut set = Self::new();
2088        let ctx = IncludeContext {
2089            git_dir: None,
2090            command_line_relative_include_is_error: false,
2091        };
2092
2093        if include_system && !git_config_nosystem_enabled() {
2094            let system_path = std::env::var("GIT_CONFIG_SYSTEM")
2095                .map(std::path::PathBuf::from)
2096                .unwrap_or_else(|_| std::path::PathBuf::from("/etc/gitconfig"));
2097            if let Ok(Some(f)) = ConfigFile::from_path(&system_path, ConfigScope::System) {
2098                Self::merge_with_includes(&mut set, &f, true, 0, &ctx)?;
2099            }
2100        }
2101
2102        if let Ok(p) = std::env::var("GIT_CONFIG_GLOBAL") {
2103            let path = PathBuf::from(p);
2104            if let Ok(Some(f)) = ConfigFile::from_path(&path, ConfigScope::Global) {
2105                Self::merge_with_includes(&mut set, &f, true, 0, &ctx)?;
2106            }
2107        } else {
2108            let mut global_paths = Vec::new();
2109            if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
2110                global_paths.push(PathBuf::from(xdg).join("git/config"));
2111            } else if let Some(home) = home_dir() {
2112                global_paths.push(home.join(".config/git/config"));
2113            }
2114            if let Some(home) = home_dir() {
2115                global_paths.push(home.join(".gitconfig"));
2116            }
2117            for path in global_paths {
2118                if let Ok(Some(f)) = ConfigFile::from_path(&path, ConfigScope::Global) {
2119                    Self::merge_with_includes(&mut set, &f, true, 0, &ctx)?;
2120                }
2121            }
2122        }
2123
2124        add_environment_config_pairs(&mut set)?;
2125
2126        if let Ok(params) = std::env::var("GIT_CONFIG_PARAMETERS") {
2127            for entry in parse_config_parameters(&params) {
2128                if let Some((key, val)) =
2129                    entry.split_once('\u{1}').or_else(|| entry.split_once('='))
2130                {
2131                    let _ = set.add_command_override(key.trim(), val);
2132                } else {
2133                    let _ = set.add_command_override(entry.trim(), "true");
2134                }
2135            }
2136        }
2137
2138        Ok(set)
2139    }
2140
2141    /// Merge a file, processing `[include]` and `[includeIf]` directives.
2142    fn merge_with_includes(
2143        set: &mut Self,
2144        file: &ConfigFile,
2145        process_includes: bool,
2146        depth: usize,
2147        ctx: &IncludeContext,
2148    ) -> Result<()> {
2149        let mut included_files = Vec::new();
2150        Self::merge_with_includes_collect(
2151            set,
2152            file,
2153            process_includes,
2154            depth,
2155            ctx,
2156            &mut included_files,
2157        )
2158    }
2159
2160    /// [`Self::merge_with_includes`], additionally recording every resolved
2161    /// include target path in `included_files` (whether or not the target
2162    /// exists) so the cascade cache can stamp them.
2163    fn merge_with_includes_collect(
2164        set: &mut Self,
2165        file: &ConfigFile,
2166        process_includes: bool,
2167        depth: usize,
2168        ctx: &IncludeContext,
2169        included_files: &mut Vec<PathBuf>,
2170    ) -> Result<()> {
2171        // Mirror Git behavior and stop runaway include recursion.
2172        // t0017 expects the diagnostic to contain this exact phrase.
2173        const MAX_INCLUDE_DEPTH: usize = 10;
2174        if depth > MAX_INCLUDE_DEPTH {
2175            return Err(Error::ConfigError(
2176                "exceeded maximum include depth".to_owned(),
2177            ));
2178        }
2179        if !process_includes {
2180            set.merge(file);
2181            return Ok(());
2182        }
2183
2184        for entry in &file.entries {
2185            set.entries.push(entry.clone());
2186
2187            let Some((inc_path, condition)) = include_directive_for_entry(entry) else {
2188                continue;
2189            };
2190            let included_by_hasconfig = condition.as_deref().is_some_and(is_hasconfig_remote_url);
2191            if condition.is_some() && !included_by_hasconfig {
2192                let cond = condition.as_deref().unwrap_or_default();
2193                if !evaluate_include_condition(cond, set, file, ctx) {
2194                    continue;
2195                }
2196            }
2197
2198            let resolved = match resolve_include_file_path(&inc_path, file, ctx) {
2199                Ok(p) => p,
2200                Err(Error::ConfigError(msg)) if msg.is_empty() => continue,
2201                Err(e) => return Err(e),
2202            };
2203            included_files.push(resolved.clone());
2204            // Git's `git_config_from_file` surfaces parse errors in an included file as a
2205            // fatal error (t0001 #102 `re-init reads matching includeIf.onbranch`). A missing
2206            // include target is silently skipped (`from_path` -> `Ok(None)`).
2207            let Some(inc_file) = ConfigFile::from_path(&resolved, file.scope)? else {
2208                continue;
2209            };
2210
2211            if included_by_hasconfig {
2212                validate_hasconfig_remote_url_include(&inc_file, process_includes, depth + 1, ctx)?;
2213                let cond = condition.as_deref().unwrap_or_default();
2214                if !evaluate_include_condition(cond, set, file, ctx) {
2215                    continue;
2216                }
2217            }
2218
2219            Self::merge_with_includes_collect(
2220                set,
2221                &inc_file,
2222                process_includes,
2223                depth + 1,
2224                ctx,
2225                included_files,
2226            )?;
2227        }
2228
2229        Ok(())
2230    }
2231}
2232
2233fn include_directive_for_entry(entry: &ConfigEntry) -> Option<(String, Option<String>)> {
2234    let val = entry.value.as_ref()?;
2235    if entry.key == "include.path" {
2236        return Some((val.clone(), None));
2237    }
2238    if entry.key.starts_with("includeif.") && entry.key.ends_with(".path") {
2239        let mid = &entry.key["includeif.".len()..entry.key.len() - ".path".len()];
2240        return Some((val.clone(), Some(mid.to_owned())));
2241    }
2242    None
2243}
2244
2245// ── Process-lifetime config cascade cache ───────────────────────────
2246//
2247// `ConfigSet::load` / `load_with_options` are called per file in hot loops
2248// (grep/diff/add re-load the cascade for every path), so the parsed cascade
2249// is memoized for the lifetime of the process. A cached entry is served only
2250// when every input that fed it is provably unchanged:
2251//
2252// - the stat stamps (mtime + size, or "absent") of every base cascade file,
2253//   recorded *before* the parse, still match. `HEAD` is stamped too so that
2254//   `includeIf.onbranch:` condition flips invalidate;
2255// - every include target the parse resolved (even ones that were missing)
2256//   still carries the stamp it had at parse time;
2257// - the config-relevant environment is byte-identical.
2258//
2259// The remaining `includeIf` condition inputs are covered transitively:
2260// `gitdir:` depends only on the cache key and environment, and
2261// `hasconfig:remote.*.url:` depends only on the (stamped) file contents.
2262//
2263// In-process writers ([`ConfigFile::write`]) additionally evict every entry
2264// whose cascade contains the written path, closing the window where a
2265// rewrite lands within one mtime tick at an unchanged size. External writers
2266// in that same window are not detectable by stat; C git parses the cascade
2267// once per process with no revalidation at all, so serving a stamped copy is
2268// strictly more conservative than upstream.
2269
2270/// Stat snapshot of one cascade file (`None` = file absent).
2271type ConfigFileStamp = (PathBuf, Option<(SystemTime, u64)>);
2272
2273#[derive(PartialEq, Eq, Hash)]
2274struct ConfigCacheKey {
2275    git_dir: Option<PathBuf>,
2276    include_system: bool,
2277    process_includes: bool,
2278    command_includes: bool,
2279    ctx_git_dir: Option<PathBuf>,
2280    ctx_relative_include_is_error: bool,
2281}
2282
2283impl ConfigCacheKey {
2284    fn new(git_dir: Option<&Path>, opts: &LoadConfigOptions) -> Self {
2285        Self {
2286            git_dir: git_dir.map(Path::to_path_buf),
2287            include_system: opts.include_system,
2288            process_includes: opts.process_includes,
2289            command_includes: opts.command_includes,
2290            ctx_git_dir: opts.include_ctx.git_dir.clone(),
2291            ctx_relative_include_is_error: opts.include_ctx.command_line_relative_include_is_error,
2292        }
2293    }
2294}
2295
2296struct ConfigCacheEntry {
2297    env_fingerprint: Vec<(String, Option<String>)>,
2298    /// Stamps of the fixed cascade files (recomputable from key + env).
2299    base_stamps: Vec<ConfigFileStamp>,
2300    /// Stamps of the include targets this parse resolved (revalidated by
2301    /// re-statting each stored path).
2302    extra_stamps: Vec<ConfigFileStamp>,
2303    set: Arc<ConfigSet>,
2304}
2305
2306fn config_cache() -> &'static Mutex<HashMap<ConfigCacheKey, ConfigCacheEntry>> {
2307    static CACHE: OnceLock<Mutex<HashMap<ConfigCacheKey, ConfigCacheEntry>>> = OnceLock::new();
2308    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
2309}
2310
2311/// Environment that feeds the cascade (file paths and synthetic entries).
2312/// `None` means "do not cache this load".
2313fn config_env_fingerprint() -> Option<Vec<(String, Option<String>)>> {
2314    const VARS: [&str; 8] = [
2315        "GIT_CONFIG_NOSYSTEM",
2316        "GIT_CONFIG_SYSTEM",
2317        "GIT_CONFIG_GLOBAL",
2318        "XDG_CONFIG_HOME",
2319        "HOME",
2320        "GIT_CONFIG",
2321        "GIT_CONFIG_PARAMETERS",
2322        "GIT_CONFIG_COUNT",
2323    ];
2324    let mut fp: Vec<(String, Option<String>)> = VARS
2325        .iter()
2326        .map(|name| ((*name).to_owned(), std::env::var(name).ok()))
2327        .collect();
2328    if let Ok(count_str) = std::env::var("GIT_CONFIG_COUNT") {
2329        // Mirror `add_environment_config_pairs`; absurd counts are not worth caching.
2330        const MAX_TRACKED: usize = 256;
2331        match count_str.parse::<usize>() {
2332            Ok(n) if n <= MAX_TRACKED => {
2333                for i in 0..n {
2334                    for var in [
2335                        format!("GIT_CONFIG_KEY_{i}"),
2336                        format!("GIT_CONFIG_VALUE_{i}"),
2337                    ] {
2338                        let val = std::env::var(&var).ok();
2339                        fp.push((var, val));
2340                    }
2341                }
2342            }
2343            Ok(_) => return None,
2344            Err(_) => {}
2345        }
2346    }
2347    Some(fp)
2348}
2349
2350/// The on-disk files [`ConfigSet::load_with_options_uncached`] would consult,
2351/// in cascade order.
2352fn config_cascade_file_paths(git_dir: Option<&Path>, opts: &LoadConfigOptions) -> Vec<PathBuf> {
2353    let mut paths = Vec::new();
2354    if opts.include_system && !git_config_nosystem_enabled() {
2355        paths.push(
2356            std::env::var("GIT_CONFIG_SYSTEM")
2357                .map(PathBuf::from)
2358                .unwrap_or_else(|_| PathBuf::from("/etc/gitconfig")),
2359        );
2360    }
2361    paths.extend(global_config_paths());
2362    if let Some(gd) = git_dir {
2363        let common_dir = crate::repo::common_git_dir_for_config(gd);
2364        paths.push(common_dir.join("config"));
2365        // Stamped unconditionally: whether it is merged depends on
2366        // `extensions.worktreeConfig` in the (stamped) common config.
2367        paths.push(gd.join("config.worktree"));
2368        // `includeIf.onbranch:` conditions read HEAD; stamping it means a
2369        // branch switch invalidates cascades that could depend on it.
2370        paths.push(gd.join("HEAD"));
2371    }
2372    if let Some(cgd) = &opts.include_ctx.git_dir {
2373        if Some(cgd.as_path()) != git_dir {
2374            paths.push(cgd.join("HEAD"));
2375        }
2376    }
2377    if let Ok(p) = std::env::var("GIT_CONFIG") {
2378        paths.push(PathBuf::from(p));
2379    }
2380    paths
2381}
2382
2383fn stamp_for_path(path: &Path) -> Option<(SystemTime, u64)> {
2384    fs::metadata(path)
2385        .ok()
2386        .and_then(|m| Some((m.modified().ok()?, m.len())))
2387}
2388
2389fn stamp_paths(paths: Vec<PathBuf>) -> Vec<ConfigFileStamp> {
2390    paths
2391        .into_iter()
2392        .map(|path| {
2393            let stamp = stamp_for_path(&path);
2394            (path, stamp)
2395        })
2396        .collect()
2397}
2398
2399fn config_file_stamps(git_dir: Option<&Path>, opts: &LoadConfigOptions) -> Vec<ConfigFileStamp> {
2400    stamp_paths(config_cascade_file_paths(git_dir, opts))
2401}
2402
2403fn config_cache_lookup(
2404    key: &ConfigCacheKey,
2405    env_fp: &[(String, Option<String>)],
2406    base_stamps: &[ConfigFileStamp],
2407) -> Option<ConfigSet> {
2408    let cache = config_cache()
2409        .lock()
2410        .unwrap_or_else(std::sync::PoisonError::into_inner);
2411    let entry = cache.get(key)?;
2412    if entry.env_fingerprint.as_slice() != env_fp || entry.base_stamps.as_slice() != base_stamps {
2413        return None;
2414    }
2415    for (path, stamp) in &entry.extra_stamps {
2416        if stamp_for_path(path) != *stamp {
2417            return None;
2418        }
2419    }
2420    Some((*entry.set).clone())
2421}
2422
2423/// Drop every cached cascade that read `path` (called on in-process writes).
2424fn evict_config_cache_for_path(path: &Path) {
2425    let mut cache = config_cache()
2426        .lock()
2427        .unwrap_or_else(std::sync::PoisonError::into_inner);
2428    cache.retain(|_, entry| {
2429        !entry
2430            .base_stamps
2431            .iter()
2432            .chain(&entry.extra_stamps)
2433            .any(|(p, _)| p == path)
2434    });
2435}
2436
2437fn git_config_nosystem_enabled() -> bool {
2438    std::env::var("GIT_CONFIG_NOSYSTEM")
2439        .ok()
2440        .map(|value| parse_bool(&value).unwrap_or(true))
2441        .unwrap_or(false)
2442}
2443
2444fn add_environment_config_pairs(set: &mut ConfigSet) -> Result<()> {
2445    let Ok(count_str) = std::env::var("GIT_CONFIG_COUNT") else {
2446        return Ok(());
2447    };
2448    if count_str.is_empty() {
2449        return Ok(());
2450    }
2451
2452    let count = count_str
2453        .parse::<usize>()
2454        .map_err(|_| Error::ConfigError("bogus count in GIT_CONFIG_COUNT".to_owned()))?;
2455    if count > i32::MAX as usize {
2456        return Err(Error::ConfigError(
2457            "too many entries in GIT_CONFIG_COUNT".to_owned(),
2458        ));
2459    }
2460
2461    for i in 0..count {
2462        let key_var = format!("GIT_CONFIG_KEY_{i}");
2463        let value_var = format!("GIT_CONFIG_VALUE_{i}");
2464        let key = std::env::var(&key_var)
2465            .map_err(|_| Error::ConfigError(format!("missing config key {key_var}")))?;
2466        let value = std::env::var(&value_var)
2467            .map_err(|_| Error::ConfigError(format!("missing config value {value_var}")))?;
2468        set.add_command_override(&key, &value)?;
2469    }
2470
2471    Ok(())
2472}
2473
2474// ── Type coercion helpers ───────────────────────────────────────────
2475
2476/// Parse a Git boolean value.
2477///
2478/// Accepts: `true`, `yes`, `on`, `1` as true.
2479/// Accepts: `false`, `no`, `off`, `0` as false.
2480///
2481/// Note: bare config keys are represented as `None` in [`ConfigEntry`] and
2482/// are normalized to `"true"` by higher-level readers (`ConfigSet::get`).
2483/// An explicit empty assignment (`key =` with no value) is stored as `""` and
2484/// is treated as false for `--bool` / [`parse_bool`]. Bare keys are represented
2485/// as `None` and normalized to `"true"` by callers before reaching this parser.
2486pub fn parse_bool(s: &str) -> std::result::Result<bool, String> {
2487    match s.to_lowercase().as_str() {
2488        "true" | "yes" | "on" => Ok(true),
2489        "" => Ok(false),
2490        "false" | "no" | "off" => Ok(false),
2491        _ => {
2492            // Try parsing as Git's config integer syntax: 0 -> false, non-zero -> true.
2493            if let Ok(n) = parse_i64(s) {
2494                return Ok(n != 0);
2495            }
2496            Err(format!("bad boolean config value '{s}'"))
2497        }
2498    }
2499}
2500
2501/// Parse a Git integer value with optional `k`/`m`/`g` suffix.
2502pub fn parse_i64(s: &str) -> std::result::Result<i64, String> {
2503    let s = s.trim();
2504    if s.is_empty() {
2505        return Err("empty integer value".to_owned());
2506    }
2507
2508    let (num_str, multiplier) = match s.as_bytes().last() {
2509        Some(b'k' | b'K') => (&s[..s.len() - 1], 1024_i64),
2510        Some(b'm' | b'M') => (&s[..s.len() - 1], 1024 * 1024),
2511        Some(b'g' | b'G') => (&s[..s.len() - 1], 1024 * 1024 * 1024),
2512        _ => (s, 1_i64),
2513    };
2514
2515    let base: i64 = num_str
2516        .parse()
2517        .map_err(|_| format!("invalid integer: '{s}'"))?;
2518    base.checked_mul(multiplier)
2519        .ok_or_else(|| format!("integer overflow: '{s}'"))
2520}
2521
2522/// Why [`parse_git_config_int_strict`] failed (mirrors Git `errno` after `git_parse_signed`).
2523#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2524pub enum GitConfigIntStrictError {
2525    /// `EINVAL` — trailing junk, unknown unit suffix, or not a number.
2526    InvalidUnit,
2527    /// `ERANGE` — value does not fit in `i64` after scaling.
2528    OutOfRange,
2529}
2530
2531/// Parse a signed decimal integer with optional `k`/`m`/`g` multiplier suffix, requiring the
2532/// entire input (trimmed) to be consumed — same constraints as Git's `git_parse_signed` used by
2533/// `git_config_int` (so `no` and `1foo` are rejected, unlike [`parse_i64`]).
2534pub fn parse_git_config_int_strict(raw: &str) -> std::result::Result<i64, GitConfigIntStrictError> {
2535    let s = raw.trim();
2536    if s.is_empty() {
2537        return Err(GitConfigIntStrictError::InvalidUnit);
2538    }
2539
2540    let bytes = s.as_bytes();
2541    let mut idx = 0usize;
2542    if matches!(bytes.first(), Some(b'+') | Some(b'-')) {
2543        idx = 1;
2544    }
2545    if idx >= bytes.len() {
2546        return Err(GitConfigIntStrictError::InvalidUnit);
2547    }
2548    let digit_start = idx;
2549    while idx < bytes.len() && bytes[idx].is_ascii_digit() {
2550        idx += 1;
2551    }
2552    if idx == digit_start {
2553        return Err(GitConfigIntStrictError::InvalidUnit);
2554    }
2555
2556    let num_part =
2557        std::str::from_utf8(&bytes[..idx]).map_err(|_| GitConfigIntStrictError::InvalidUnit)?;
2558    let suffix =
2559        std::str::from_utf8(&bytes[idx..]).map_err(|_| GitConfigIntStrictError::InvalidUnit)?;
2560    let mult: i64 = match suffix {
2561        "" => 1,
2562        "k" | "K" => 1024,
2563        "m" | "M" => 1024 * 1024,
2564        "g" | "G" => 1024_i64
2565            .checked_mul(1024)
2566            .and_then(|x| x.checked_mul(1024))
2567            .ok_or(GitConfigIntStrictError::OutOfRange)?,
2568        _ => return Err(GitConfigIntStrictError::InvalidUnit),
2569    };
2570
2571    let val: i64 = num_part
2572        .parse()
2573        .map_err(|_| GitConfigIntStrictError::InvalidUnit)?;
2574    val.checked_mul(mult)
2575        .ok_or(GitConfigIntStrictError::OutOfRange)
2576}
2577
2578const DIFF_CONTEXT_KEY: &str = "diff.context";
2579
2580fn format_bad_numeric_diff_context(
2581    value: &str,
2582    err: GitConfigIntStrictError,
2583    entry: &ConfigEntry,
2584) -> String {
2585    let detail = match err {
2586        GitConfigIntStrictError::InvalidUnit => "invalid unit",
2587        GitConfigIntStrictError::OutOfRange => "out of range",
2588    };
2589    if entry.scope == ConfigScope::Command || entry.file.is_none() {
2590        return format!(
2591            "fatal: bad numeric config value '{value}' for '{DIFF_CONTEXT_KEY}': {detail}"
2592        );
2593    }
2594    let path = entry
2595        .file
2596        .as_deref()
2597        .map(config_error_path_display)
2598        .unwrap_or_default();
2599    format!("fatal: bad numeric config value '{value}' for '{DIFF_CONTEXT_KEY}' in file {path}: {detail}")
2600}
2601
2602fn format_bad_diff_context_variable(entry: &ConfigEntry) -> String {
2603    if entry.scope == ConfigScope::Command || entry.file.is_none() {
2604        return format!("fatal: unable to parse '{DIFF_CONTEXT_KEY}' from command-line config");
2605    }
2606    let path = entry
2607        .file
2608        .as_deref()
2609        .map(config_error_path_display)
2610        .unwrap_or_default();
2611    format!(
2612        "fatal: bad config variable '{DIFF_CONTEXT_KEY}' in file '{path}' at line {}",
2613        entry.line
2614    )
2615}
2616
2617/// Read `diff.context` from a loaded [`ConfigSet`] with Git-compatible validation.
2618///
2619/// Returns `Ok(None)` when the key is unset. When set, the value must be a non-negative integer
2620/// acceptable to Git's diff machinery (same rules as `git diff` / `git log -p`).
2621pub fn resolve_diff_context_lines(cfg: &ConfigSet) -> std::result::Result<Option<usize>, String> {
2622    let Some(entry) = cfg.get_last_entry(DIFF_CONTEXT_KEY) else {
2623        return Ok(None);
2624    };
2625    let value_src = entry.value.as_deref().unwrap_or("").trim();
2626    match parse_git_config_int_strict(value_src) {
2627        Ok(n) if n < 0 => Err(format_bad_diff_context_variable(&entry)),
2628        Ok(n) => Ok(Some(usize::try_from(n).map_err(|_| {
2629            format_bad_numeric_diff_context(value_src, GitConfigIntStrictError::OutOfRange, &entry)
2630        })?)),
2631        Err(e) => Err(format_bad_numeric_diff_context(value_src, e, &entry)),
2632    }
2633}
2634
2635/// Parse a Git color value and return the ANSI escape sequence.
2636///
2637/// Matches Git's `color_parse_mem` (`git/color.c`): whitespace-separated words,
2638/// optional leading `reset`, up to two color tokens (foreground then background),
2639/// then graphic rendition attributes. Attribute codes are accumulated as a
2640/// bitmask keyed by SGR number (so `bold` sets bit 1, `nobold` sets bit 22).
2641pub fn parse_color(s: &str) -> std::result::Result<String, String> {
2642    const COLOR_BACKGROUND_OFFSET: i32 = 10;
2643    const COLOR_FOREGROUND_ANSI: i32 = 30;
2644    const COLOR_FOREGROUND_RGB: i32 = 38;
2645    const COLOR_FOREGROUND_256: i32 = 38;
2646    const COLOR_FOREGROUND_BRIGHT_ANSI: i32 = 90;
2647
2648    #[derive(Clone, Copy, Default)]
2649    struct Color {
2650        kind: u8,
2651        value: u8,
2652        red: u8,
2653        green: u8,
2654        blue: u8,
2655    }
2656
2657    const COLOR_UNSPECIFIED: u8 = 0;
2658    const COLOR_NORMAL: u8 = 1;
2659    const COLOR_ANSI: u8 = 2;
2660    const COLOR_256: u8 = 3;
2661    const COLOR_RGB: u8 = 4;
2662
2663    fn color_empty(c: &Color) -> bool {
2664        c.kind == COLOR_UNSPECIFIED || c.kind == COLOR_NORMAL
2665    }
2666
2667    fn parse_ansi_color(name: &str) -> Option<Color> {
2668        let color_names = [
2669            "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
2670        ];
2671        let color_offset = COLOR_FOREGROUND_ANSI;
2672
2673        if name.eq_ignore_ascii_case("default") {
2674            return Some(Color {
2675                kind: COLOR_ANSI,
2676                value: (9 + color_offset) as u8,
2677                ..Default::default()
2678            });
2679        }
2680
2681        let (name, color_offset) = if name.len() >= 6 && name[..6].eq_ignore_ascii_case("bright") {
2682            (&name[6..], COLOR_FOREGROUND_BRIGHT_ANSI)
2683        } else {
2684            (name, COLOR_FOREGROUND_ANSI)
2685        };
2686
2687        for (i, cn) in color_names.iter().enumerate() {
2688            if name.eq_ignore_ascii_case(cn) {
2689                return Some(Color {
2690                    kind: COLOR_ANSI,
2691                    value: (i as i32 + color_offset) as u8,
2692                    ..Default::default()
2693                });
2694            }
2695        }
2696        None
2697    }
2698
2699    fn hex_val(b: u8) -> Option<u8> {
2700        match b {
2701            b'0'..=b'9' => Some(b - b'0'),
2702            b'a'..=b'f' => Some(b - b'a' + 10),
2703            b'A'..=b'F' => Some(b - b'A' + 10),
2704            _ => None,
2705        }
2706    }
2707
2708    fn get_hex_color(chars: &[u8], width: usize) -> Option<(u8, usize)> {
2709        assert!(width == 1 || width == 2);
2710        if chars.len() < width {
2711            return None;
2712        }
2713        let v = if width == 2 {
2714            let hi = hex_val(chars[0])?;
2715            let lo = hex_val(chars[1])?;
2716            (hi << 4) | lo
2717        } else {
2718            let n = hex_val(chars[0])?;
2719            (n << 4) | n
2720        };
2721        Some((v, width))
2722    }
2723
2724    fn parse_single_color(word: &str) -> Option<Color> {
2725        if word.eq_ignore_ascii_case("normal") {
2726            return Some(Color {
2727                kind: COLOR_NORMAL,
2728                ..Default::default()
2729            });
2730        }
2731
2732        let bytes = word.as_bytes();
2733        if (bytes.len() == 7 || bytes.len() == 4) && bytes.first() == Some(&b'#') {
2734            let width = if bytes.len() == 7 { 2 } else { 1 };
2735            let mut idx = 1;
2736            let (r, n1) = get_hex_color(&bytes[idx..], width)?;
2737            idx += n1;
2738            let (g, n2) = get_hex_color(&bytes[idx..], width)?;
2739            idx += n2;
2740            let (b, n3) = get_hex_color(&bytes[idx..], width)?;
2741            idx += n3;
2742            if idx != bytes.len() {
2743                return None;
2744            }
2745            return Some(Color {
2746                kind: COLOR_RGB,
2747                red: r,
2748                green: g,
2749                blue: b,
2750                ..Default::default()
2751            });
2752        }
2753
2754        if let Some(c) = parse_ansi_color(word) {
2755            return Some(c);
2756        }
2757
2758        let Ok(val) = word.parse::<i64>() else {
2759            return None;
2760        };
2761        if val < -1 {
2762            return None;
2763        }
2764        if val < 0 {
2765            return Some(Color {
2766                kind: COLOR_NORMAL,
2767                ..Default::default()
2768            });
2769        }
2770        if val < 8 {
2771            return Some(Color {
2772                kind: COLOR_ANSI,
2773                value: (val as i32 + COLOR_FOREGROUND_ANSI) as u8,
2774                ..Default::default()
2775            });
2776        }
2777        if val < 16 {
2778            return Some(Color {
2779                kind: COLOR_ANSI,
2780                value: (val as i32 - 8 + COLOR_FOREGROUND_BRIGHT_ANSI) as u8,
2781                ..Default::default()
2782            });
2783        }
2784        if val < 256 {
2785            return Some(Color {
2786                kind: COLOR_256,
2787                value: val as u8,
2788                ..Default::default()
2789            });
2790        }
2791        None
2792    }
2793
2794    fn parse_attr(word: &str) -> Option<u8> {
2795        const ATTRS: [(&str, u8, u8); 8] = [
2796            ("bold", 1, 22),
2797            ("dim", 2, 22),
2798            ("italic", 3, 23),
2799            ("ul", 4, 24),
2800            ("underline", 4, 24),
2801            ("blink", 5, 25),
2802            ("reverse", 7, 27),
2803            ("strike", 9, 29),
2804        ];
2805
2806        let mut negate = false;
2807        let mut rest = word;
2808        if let Some(stripped) = rest.strip_prefix("no") {
2809            negate = true;
2810            rest = stripped;
2811            if let Some(s) = rest.strip_prefix('-') {
2812                rest = s;
2813            }
2814        }
2815
2816        for (name, val, neg) in ATTRS {
2817            if rest == name {
2818                return Some(if negate { neg } else { val });
2819            }
2820        }
2821        None
2822    }
2823
2824    fn append_color_output(out: &mut String, c: &Color, background: bool) {
2825        let offset = if background {
2826            COLOR_BACKGROUND_OFFSET
2827        } else {
2828            0
2829        };
2830        match c.kind {
2831            COLOR_UNSPECIFIED | COLOR_NORMAL => {}
2832            COLOR_ANSI => {
2833                use std::fmt::Write;
2834                let _ = write!(out, "{}", i32::from(c.value) + offset);
2835            }
2836            COLOR_256 => {
2837                use std::fmt::Write;
2838                let _ = write!(out, "{};5;{}", COLOR_FOREGROUND_256 + offset, c.value);
2839            }
2840            COLOR_RGB => {
2841                use std::fmt::Write;
2842                let _ = write!(
2843                    out,
2844                    "{};2;{};{};{}",
2845                    COLOR_FOREGROUND_RGB + offset,
2846                    c.red,
2847                    c.green,
2848                    c.blue
2849                );
2850            }
2851            _ => {}
2852        }
2853    }
2854
2855    let s = s.trim();
2856    if s.is_empty() {
2857        return Ok(String::new());
2858    }
2859
2860    let mut has_reset = false;
2861    let mut attr: u64 = 0;
2862    let mut fg = Color::default();
2863    let mut bg = Color::default();
2864    fg.kind = COLOR_UNSPECIFIED;
2865    bg.kind = COLOR_UNSPECIFIED;
2866
2867    for word in s.split_whitespace() {
2868        if word.eq_ignore_ascii_case("reset") {
2869            has_reset = true;
2870            continue;
2871        }
2872
2873        if let Some(c) = parse_single_color(word) {
2874            if fg.kind == COLOR_UNSPECIFIED {
2875                fg = c;
2876                continue;
2877            }
2878            if bg.kind == COLOR_UNSPECIFIED {
2879                bg = c;
2880                continue;
2881            }
2882            return Err(format!("bad color value '{s}'"));
2883        }
2884
2885        if let Some(code) = parse_attr(word) {
2886            attr |= 1u64 << u64::from(code);
2887            continue;
2888        }
2889
2890        return Err(format!("bad color value '{s}'"));
2891    }
2892
2893    if !has_reset && attr == 0 && color_empty(&fg) && color_empty(&bg) {
2894        return Err(format!("bad color value '{s}'"));
2895    }
2896
2897    let mut out = String::from("\x1b[");
2898    let mut sep = if has_reset { 1u32 } else { 0u32 };
2899
2900    let mut attr_bits = attr;
2901    let mut i = 0u32;
2902    while attr_bits != 0 {
2903        let bit = 1u64 << i;
2904        if attr_bits & bit == 0 {
2905            i += 1;
2906            continue;
2907        }
2908        attr_bits &= !bit;
2909        if sep > 0 {
2910            out.push(';');
2911        }
2912        sep += 1;
2913        use std::fmt::Write;
2914        let _ = write!(out, "{i}");
2915        i += 1;
2916    }
2917
2918    if !color_empty(&fg) {
2919        if sep > 0 {
2920            out.push(';');
2921        }
2922        sep += 1;
2923        append_color_output(&mut out, &fg, false);
2924    }
2925    if !color_empty(&bg) {
2926        if sep > 0 {
2927            out.push(';');
2928        }
2929        append_color_output(&mut out, &bg, true);
2930    }
2931    out.push('m');
2932    Ok(out)
2933}
2934
2935#[derive(Debug, Clone)]
2936struct UrlParts {
2937    scheme: String,
2938    user: Option<String>,
2939    host: String,
2940    port: Option<String>,
2941    path: String,
2942}
2943
2944#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
2945struct UrlMatchScore {
2946    host_len: usize,
2947    path_len: usize,
2948    user_matched: bool,
2949}
2950
2951fn parse_config_url(url: &str) -> Option<UrlParts> {
2952    let (scheme, rest) = url.split_once("://")?;
2953    let (authority, path) = match rest.find('/') {
2954        Some(idx) => (&rest[..idx], &rest[idx..]),
2955        None => (rest, "/"),
2956    };
2957    let (user, host_port) = match authority.rsplit_once('@') {
2958        Some((user, host)) => (Some(user.to_owned()), host),
2959        None => (None, authority),
2960    };
2961    let (host, port) = match host_port.rsplit_once(':') {
2962        Some((host, port)) if !host.contains(']') => (host, Some(port.to_owned())),
2963        _ => (host_port, None),
2964    };
2965    Some(UrlParts {
2966        scheme: scheme.to_lowercase(),
2967        user,
2968        host: host.to_lowercase(),
2969        port,
2970        path: if path.is_empty() {
2971            "/".to_owned()
2972        } else {
2973            path.trim_end_matches('/').to_owned()
2974        },
2975    })
2976}
2977
2978fn host_matches(pattern: &str, target: &str) -> bool {
2979    let pattern_parts: Vec<&str> = pattern.split('.').collect();
2980    let target_parts: Vec<&str> = target.split('.').collect();
2981    pattern_parts.len() == target_parts.len()
2982        && pattern_parts
2983            .iter()
2984            .zip(target_parts)
2985            .all(|(pattern, target)| *pattern == "*" || *pattern == target)
2986}
2987
2988fn path_match_len(pattern: &str, target: &str) -> Option<usize> {
2989    let pattern = if pattern.is_empty() { "/" } else { pattern };
2990    let target = if target.is_empty() { "/" } else { target };
2991    if pattern == "/" {
2992        return Some(1);
2993    }
2994    let pattern = pattern.trim_end_matches('/');
2995    if target == pattern
2996        || target
2997            .strip_prefix(pattern)
2998            .is_some_and(|rest| rest.starts_with('/'))
2999    {
3000        Some(pattern.len() + 1)
3001    } else {
3002        None
3003    }
3004}
3005
3006fn url_match_score(pattern_url: &str, target_url: &str) -> Option<UrlMatchScore> {
3007    let pattern = parse_config_url(pattern_url)?;
3008    let target = parse_config_url(target_url)?;
3009    if pattern.scheme != target.scheme {
3010        return None;
3011    }
3012    let user_matched = match pattern.user.as_deref() {
3013        Some(user) if target.user.as_deref() == Some(user) => true,
3014        Some(_) => return None,
3015        None => false,
3016    };
3017    if !host_matches(&pattern.host, &target.host) || pattern.port != target.port {
3018        return None;
3019    }
3020    let path_len = path_match_len(&pattern.path, &target.path)?;
3021    Some(UrlMatchScore {
3022        host_len: pattern.host.len(),
3023        path_len,
3024        user_matched,
3025    })
3026}
3027
3028/// Match a URL against a URL pattern from config.
3029pub fn url_matches(pattern_url: &str, target_url: &str) -> bool {
3030    url_match_score(pattern_url, target_url).is_some()
3031}
3032
3033/// Get the best URL match for a specific key.
3034pub fn get_urlmatch_entries<'a>(
3035    entries: &'a [ConfigEntry],
3036    section: &str,
3037    variable: &str,
3038    url: &str,
3039) -> Vec<&'a ConfigEntry> {
3040    let section_lower = section.to_lowercase();
3041    let variable_lower = variable.to_lowercase();
3042    let mut matches: Vec<(UrlMatchScore, &'a ConfigEntry)> = Vec::new();
3043
3044    for entry in entries {
3045        let key = &entry.key;
3046        let first_dot = match key.find('.') {
3047            Some(i) => i,
3048            None => continue,
3049        };
3050        let last_dot = match key.rfind('.') {
3051            Some(i) => i,
3052            None => continue,
3053        };
3054        let entry_section = &key[..first_dot];
3055        let entry_variable = &key[last_dot + 1..];
3056        if entry_section.to_lowercase() != section_lower
3057            || entry_variable.to_lowercase() != variable_lower
3058        {
3059            continue;
3060        }
3061        if first_dot == last_dot {
3062            matches.push((
3063                UrlMatchScore {
3064                    host_len: 0,
3065                    path_len: 0,
3066                    user_matched: false,
3067                },
3068                entry,
3069            ));
3070        } else {
3071            let subsection = &key[first_dot + 1..last_dot];
3072            if let Some(score) = url_match_score(subsection, url) {
3073                matches.push((score, entry));
3074            }
3075        }
3076    }
3077    matches.sort_by_key(|a| a.0);
3078    matches.into_iter().map(|(_, e)| e).collect()
3079}
3080
3081/// Get all matching variables in a section for a given URL.
3082pub fn get_urlmatch_all_in_section(
3083    entries: &[ConfigEntry],
3084    section: &str,
3085    url: &str,
3086) -> Vec<(String, String, ConfigScope)> {
3087    let section_lower = section.to_lowercase();
3088    let mut matches: Vec<(String, UrlMatchScore, String, String, ConfigScope)> = Vec::new();
3089
3090    for entry in entries {
3091        let key = &entry.key;
3092        let first_dot = match key.find('.') {
3093            Some(i) => i,
3094            None => continue,
3095        };
3096        let last_dot = match key.rfind('.') {
3097            Some(i) => i,
3098            None => continue,
3099        };
3100        let entry_section = &key[..first_dot];
3101        if entry_section.to_lowercase() != section_lower {
3102            continue;
3103        }
3104        let entry_variable = &key[last_dot + 1..];
3105        let val = entry.value.as_deref().unwrap_or("");
3106        if first_dot == last_dot {
3107            let canonical = format!("{}.{}", section_lower, entry_variable);
3108            matches.push((
3109                entry_variable.to_lowercase(),
3110                UrlMatchScore {
3111                    host_len: 0,
3112                    path_len: 0,
3113                    user_matched: false,
3114                },
3115                val.to_owned(),
3116                canonical,
3117                entry.scope,
3118            ));
3119        } else {
3120            let subsection = &key[first_dot + 1..last_dot];
3121            if let Some(score) = url_match_score(subsection, url) {
3122                let canonical = format!("{}.{}", section_lower, entry_variable);
3123                matches.push((
3124                    entry_variable.to_lowercase(),
3125                    score,
3126                    val.to_owned(),
3127                    canonical,
3128                    entry.scope,
3129                ));
3130            }
3131        }
3132    }
3133
3134    let mut best: std::collections::BTreeMap<String, (UrlMatchScore, String, String, ConfigScope)> =
3135        std::collections::BTreeMap::new();
3136    for (var, specificity, val, canonical, scope) in matches {
3137        let entry = best.entry(var).or_insert((
3138            UrlMatchScore {
3139                host_len: 0,
3140                path_len: 0,
3141                user_matched: false,
3142            },
3143            String::new(),
3144            String::new(),
3145            scope,
3146        ));
3147        if specificity >= entry.0 {
3148            *entry = (specificity, val, canonical, scope);
3149        }
3150    }
3151    best.into_values()
3152        .map(|(_, val, canonical, scope)| (canonical, val, scope))
3153        .collect()
3154}
3155
3156/// Parse a Git path value (expand `~/` to home directory).
3157/// Parse a path value. Returns the resolved path string.
3158/// Does NOT handle :(optional) prefix — use `parse_path_optional` for that.
3159pub fn parse_path(s: &str) -> String {
3160    if let Some(rest) = s.strip_prefix("~/") {
3161        if let Some(home) = home_dir() {
3162            return home.join(rest).to_string_lossy().to_string();
3163        }
3164    }
3165    s.to_owned()
3166}
3167
3168/// Parse a path value that may have an `:(optional)` prefix.
3169///
3170/// Returns `Some(path)` if the path should be used, `None` if the path
3171/// is optional and does not exist (meaning the entry should be skipped).
3172pub fn parse_path_optional(s: &str) -> Option<String> {
3173    if let Some(rest) = s.strip_prefix(":(optional)") {
3174        let resolved = parse_path(rest);
3175        if std::path::Path::new(&resolved).exists() {
3176            Some(resolved)
3177        } else {
3178            None // optional and missing → skip
3179        }
3180    } else {
3181        Some(parse_path(s))
3182    }
3183}
3184
3185// ── Helpers ─────────────────────────────────────────────────────────
3186
3187/// Parse `GIT_CONFIG_PARAMETERS` payloads.
3188///
3189/// We support the common formats seen in tests and wrappers:
3190/// - single-quoted entries: `'key=value'`
3191/// - double-quoted entries: `"key=value"`
3192/// - unquoted `key=value` tokens separated by whitespace
3193///
3194/// Backslash escapes are interpreted minimally inside double quotes.
3195///
3196/// Return the last `key=value` assignment for `key` in a `GIT_CONFIG_PARAMETERS` payload.
3197///
3198/// Matches Git's command-line config layering: later tokens win. Keys are canonicalized the same
3199/// way as file-backed config (`fetch.output` and `FETCH.Output` both match `fetch.output`).
3200#[must_use]
3201pub fn git_config_parameters_last_value(raw: &str, key: &str) -> Option<String> {
3202    let Ok(canon) = canonical_key(key) else {
3203        return None;
3204    };
3205    let mut last: Option<String> = None;
3206    for entry in parse_config_parameters_strict(raw).ok()? {
3207        match entry {
3208            ConfigParameter::Pair { key, value } => {
3209                if canonical_key(key.trim()).ok().as_ref() == Some(&canon) {
3210                    last = Some(value.unwrap_or_else(|| "true".to_owned()));
3211                }
3212            }
3213            ConfigParameter::OldStyle(entry) => {
3214                if let Some((k, v)) = entry.split_once('=') {
3215                    if canonical_key(k.trim()).ok().as_ref() == Some(&canon) {
3216                        last = Some(v.to_owned());
3217                    }
3218                } else if canonical_key(entry.trim()).ok().as_ref() == Some(&canon) {
3219                    last = Some("true".to_owned());
3220                }
3221            }
3222        }
3223    }
3224    last
3225}
3226
3227#[derive(Debug, Clone, PartialEq, Eq)]
3228enum ConfigParameter {
3229    OldStyle(String),
3230    Pair { key: String, value: Option<String> },
3231}
3232
3233pub fn parse_config_parameters(raw: &str) -> Vec<String> {
3234    parse_config_parameters_strict(raw)
3235        .map(|entries| {
3236            entries
3237                .into_iter()
3238                .map(|entry| match entry {
3239                    ConfigParameter::OldStyle(entry) => entry,
3240                    ConfigParameter::Pair {
3241                        key,
3242                        value: Some(value),
3243                    } => format!("{key}\u{1}{value}"),
3244                    ConfigParameter::Pair { key, value: None } => format!("{key}\u{1}"),
3245                })
3246                .collect()
3247        })
3248        .unwrap_or_default()
3249}
3250
3251fn parse_config_parameters_strict(raw: &str) -> Result<Vec<ConfigParameter>> {
3252    let mut out: Vec<ConfigParameter> = Vec::new();
3253    let chars: Vec<char> = raw.chars().collect();
3254    let mut idx = skip_config_parameter_spaces(&chars, 0);
3255
3256    while idx < chars.len() {
3257        let (key, next) = sq_dequote_step_chars(&chars, idx)?;
3258        let Some(next_idx) = next else {
3259            out.push(ConfigParameter::OldStyle(key));
3260            break;
3261        };
3262
3263        if chars[next_idx].is_whitespace() {
3264            out.push(ConfigParameter::OldStyle(key));
3265            idx = skip_config_parameter_spaces(&chars, next_idx);
3266            continue;
3267        }
3268
3269        if chars[next_idx] != '=' {
3270            return Err(Error::ConfigError(
3271                "bogus format in GIT_CONFIG_PARAMETERS".to_owned(),
3272            ));
3273        }
3274
3275        let value_start = next_idx + 1;
3276        if value_start >= chars.len() || chars[value_start].is_whitespace() {
3277            out.push(ConfigParameter::Pair { key, value: None });
3278            idx = skip_config_parameter_spaces(&chars, value_start);
3279            continue;
3280        }
3281
3282        if chars[value_start] != '\'' {
3283            return Err(Error::ConfigError(
3284                "bogus format in GIT_CONFIG_PARAMETERS".to_owned(),
3285            ));
3286        }
3287        let (value, value_next) = sq_dequote_step_chars(&chars, value_start)?;
3288        if let Some(value_next) = value_next {
3289            if !chars[value_next].is_whitespace() {
3290                return Err(Error::ConfigError(
3291                    "bogus format in GIT_CONFIG_PARAMETERS".to_owned(),
3292                ));
3293            }
3294            idx = skip_config_parameter_spaces(&chars, value_next);
3295        } else {
3296            idx = chars.len();
3297        }
3298        out.push(ConfigParameter::Pair {
3299            key,
3300            value: Some(value),
3301        });
3302    }
3303
3304    Ok(out)
3305}
3306
3307fn skip_config_parameter_spaces(chars: &[char], mut idx: usize) -> usize {
3308    while idx < chars.len() && chars[idx].is_whitespace() {
3309        idx += 1;
3310    }
3311    idx
3312}
3313
3314fn sq_dequote_step_chars(chars: &[char], start: usize) -> Result<(String, Option<usize>)> {
3315    if chars.get(start) != Some(&'\'') {
3316        return Err(Error::ConfigError(
3317            "bogus format in GIT_CONFIG_PARAMETERS".to_owned(),
3318        ));
3319    }
3320
3321    let mut out = String::new();
3322    let mut idx = start + 1;
3323    loop {
3324        let Some(&ch) = chars.get(idx) else {
3325            return Err(Error::ConfigError(
3326                "bogus format in GIT_CONFIG_PARAMETERS".to_owned(),
3327            ));
3328        };
3329        if ch != '\'' {
3330            out.push(ch);
3331            idx += 1;
3332            continue;
3333        }
3334
3335        idx += 1;
3336        match chars.get(idx).copied() {
3337            None => return Ok((out, None)),
3338            Some('\\')
3339                if chars
3340                    .get(idx + 1)
3341                    .copied()
3342                    .is_some_and(needs_sq_backslash_quote)
3343                    && chars.get(idx + 2) == Some(&'\'') =>
3344            {
3345                if let Some(escaped) = chars.get(idx + 1) {
3346                    out.push(*escaped);
3347                }
3348                idx += 3;
3349            }
3350            _ => return Ok((out, Some(idx))),
3351        }
3352    }
3353}
3354
3355fn needs_sq_backslash_quote(ch: char) -> bool {
3356    ch == '\'' || ch == '!'
3357}
3358
3359/// Return candidate paths for the global config file, in priority order.
3360/// Public accessor for the ordered list of global config file paths.
3361pub fn global_config_paths_pub() -> Vec<PathBuf> {
3362    global_config_paths()
3363}
3364
3365fn global_config_paths() -> Vec<PathBuf> {
3366    let mut paths = Vec::new();
3367
3368    // $GIT_CONFIG_GLOBAL overrides
3369    if let Ok(p) = std::env::var("GIT_CONFIG_GLOBAL") {
3370        paths.push(PathBuf::from(p));
3371        return paths;
3372    }
3373
3374    // Git order: XDG `git/config` first, then `~/.gitconfig` (see `git_global_config_paths`).
3375    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
3376        paths.push(PathBuf::from(xdg).join("git/config"));
3377    } else if let Some(home) = home_dir() {
3378        paths.push(home.join(".config/git/config"));
3379    }
3380    if let Some(home) = home_dir() {
3381        paths.push(home.join(".gitconfig"));
3382    }
3383
3384    paths
3385}
3386
3387/// Return the user's home directory.
3388///
3389/// `$HOME` is honored on every platform. On Windows, where `$HOME` is usually
3390/// unset outside a Git-for-Windows/MSYS shell, we fall back to the same sources
3391/// Git does: `%HOMEDRIVE%%HOMEPATH%`, then `%USERPROFILE%`. Without this, global
3392/// config (`~/.gitconfig`) would be invisible on a stock Windows shell.
3393fn home_dir() -> Option<PathBuf> {
3394    if let Some(home) = std::env::var_os("HOME").filter(|h| !h.is_empty()) {
3395        return Some(PathBuf::from(home));
3396    }
3397    #[cfg(windows)]
3398    {
3399        if let (Some(drive), Some(path)) = (
3400            std::env::var_os("HOMEDRIVE").filter(|d| !d.is_empty()),
3401            std::env::var_os("HOMEPATH").filter(|p| !p.is_empty()),
3402        ) {
3403            let mut combined = drive;
3404            combined.push(path);
3405            return Some(PathBuf::from(combined));
3406        }
3407        if let Some(profile) = std::env::var_os("USERPROFILE").filter(|p| !p.is_empty()) {
3408            return Some(PathBuf::from(profile));
3409        }
3410    }
3411    None
3412}
3413
3414/// True when Git would treat the config source as `CONFIG_ORIGIN_FILE` for includes.
3415fn include_source_is_disk_file(file: &ConfigFile) -> bool {
3416    file.include_origin == ConfigIncludeOrigin::Disk
3417}
3418
3419/// Resolve an include file path (Git `handle_path_include` semantics).
3420///
3421/// Relative paths are only allowed when the including config came from a real on-disk file.
3422fn resolve_include_file_path(
3423    path: &str,
3424    file: &ConfigFile,
3425    ctx: &IncludeContext,
3426) -> Result<PathBuf> {
3427    let expanded = parse_path(path);
3428    let p = Path::new(&expanded);
3429    if p.is_absolute() {
3430        return Ok(p.to_path_buf());
3431    }
3432    if !include_source_is_disk_file(file) {
3433        if file.include_origin == ConfigIncludeOrigin::CommandLine {
3434            if ctx.command_line_relative_include_is_error {
3435                return Err(Error::ConfigError(
3436                    "relative config includes must come from files".to_owned(),
3437                ));
3438            }
3439            return Err(Error::ConfigError(String::new()));
3440        }
3441        return Err(Error::ConfigError(
3442            "relative config includes must come from files".to_owned(),
3443        ));
3444    }
3445    let base = match file.path.parent() {
3446        Some(p) if !p.as_os_str().is_empty() => p,
3447        Some(_) | None => Path::new("."),
3448    };
3449    Ok(base.join(p))
3450}
3451
3452fn is_dir_sep(b: u8) -> bool {
3453    b == b'/' || b == b'\\'
3454}
3455
3456fn add_trailing_starstar_for_dir(pat: &mut String) {
3457    let bytes = pat.as_bytes();
3458    if bytes.last().is_some_and(|&b| is_dir_sep(b)) {
3459        pat.push_str("**");
3460    }
3461}
3462
3463/// Prepare a `gitdir:` / `gitdir/i:` pattern (Git `prepare_include_condition_pattern`).
3464fn prepare_gitdir_pattern(condition: &str, file: &ConfigFile) -> Result<(String, usize)> {
3465    // Git `interpolate_path`: expand `~/` in the condition before pattern rules.
3466    let mut pat = parse_path(condition);
3467    if pat.starts_with("./") || pat.starts_with(".\\") {
3468        if !include_source_is_disk_file(file) {
3469            return Err(Error::ConfigError(
3470                "relative config include conditionals must come from files".to_owned(),
3471            ));
3472        }
3473        let parent = file.path.parent().ok_or_else(|| {
3474            Error::ConfigError(
3475                "relative config include conditionals must come from files".to_owned(),
3476            )
3477        })?;
3478        let real = parent.canonicalize().map_err(Error::Io)?;
3479        let mut dir = real.to_string_lossy().into_owned();
3480        if !dir.ends_with('/') && !dir.ends_with('\\') {
3481            dir.push('/');
3482        }
3483        let rest = &pat[2..];
3484        pat = format!("{dir}{rest}");
3485        let prefix_len = dir.len();
3486        add_trailing_starstar_for_dir(&mut pat);
3487        return Ok((pat, prefix_len));
3488    }
3489    let p = Path::new(&pat);
3490    if !p.is_absolute() {
3491        pat.insert_str(0, "**/");
3492    }
3493    add_trailing_starstar_for_dir(&mut pat);
3494    Ok((pat, 0))
3495}
3496
3497/// Git `include_by_gitdir` tries `strbuf_realpath` first, then `strbuf_add_absolute_path` if no match.
3498///
3499/// `text_abs` uses `$PWD` (which preserves symlinks) when available, matching Git's
3500/// `strbuf_add_absolute_path` behaviour. This lets `gitdir:bar/` match when `bar` is a symlink.
3501fn git_dir_match_texts(git_dir: &Path) -> (String, String) {
3502    let real = git_dir
3503        .canonicalize()
3504        .map(|p| p.to_string_lossy().into_owned())
3505        .unwrap_or_else(|_| git_dir.to_string_lossy().into_owned());
3506    // Build the non-canonical absolute path using $PWD (symlink-preserving) when available.
3507    // Git C uses `strbuf_add_absolute_path` which prefers $PWD over getcwd() to preserve symlinks.
3508    let abs = if git_dir.is_absolute() {
3509        // If git_dir is already canonical, try to reconstruct the symlink-preserving variant
3510        // by replacing the canonical cwd prefix with $PWD.
3511        let pwd_abs = std::env::var("PWD").ok().and_then(|pwd| {
3512            let pwd_path = std::path::Path::new(&pwd);
3513            if !pwd_path.is_absolute() {
3514                return None;
3515            }
3516            let pwd_canon = pwd_path.canonicalize().ok()?;
3517            let git_dir_str = git_dir.to_string_lossy();
3518            let pwd_canon_str = pwd_canon.to_string_lossy();
3519            // If git_dir starts with the canonical cwd, replace that prefix with $PWD
3520            let suffix = git_dir_str.strip_prefix(pwd_canon_str.as_ref())?;
3521            Some(format!("{pwd}{suffix}"))
3522        });
3523        pwd_abs.unwrap_or_else(|| git_dir.to_string_lossy().into_owned())
3524    } else if let Ok(cwd) = std::env::current_dir() {
3525        cwd.join(git_dir).to_string_lossy().into_owned()
3526    } else {
3527        git_dir.to_string_lossy().into_owned()
3528    };
3529    (real, abs)
3530}
3531
3532fn include_by_gitdir(
3533    condition: &str,
3534    file: &ConfigFile,
3535    ctx: &IncludeContext,
3536    icase: bool,
3537) -> bool {
3538    let Some(git_dir) = ctx.git_dir.as_ref() else {
3539        return false;
3540    };
3541    let (pattern, prefix) = match prepare_gitdir_pattern(condition, file) {
3542        Ok(x) => x,
3543        Err(_) => return false,
3544    };
3545    let flags = WM_PATHNAME | if icase { WM_CASEFOLD } else { 0 };
3546    let (text_real, text_abs) = git_dir_match_texts(git_dir);
3547    let try_match = |text: &str| -> bool {
3548        let t = text.as_bytes();
3549        let p = pattern.as_bytes();
3550        if prefix > 0 {
3551            if t.len() < prefix {
3552                return false;
3553            }
3554            let pre = &p[..prefix];
3555            let te = &t[..prefix];
3556            let ok = if icase {
3557                pre.eq_ignore_ascii_case(te)
3558            } else {
3559                pre == te
3560            };
3561            if !ok {
3562                return false;
3563            }
3564            return wildmatch(&p[prefix..], &t[prefix..], flags);
3565        }
3566        wildmatch(p, t, flags)
3567    };
3568    if try_match(&text_real) {
3569        return true;
3570    }
3571    text_real != text_abs && try_match(&text_abs)
3572}
3573
3574fn current_branch_short_name(git_dir: Option<&Path>) -> Option<String> {
3575    let gd = git_dir?;
3576    let target = refs::read_symbolic_ref(gd, "HEAD").ok()??;
3577    let rest = target.strip_prefix("refs/heads/")?;
3578    Some(rest.to_owned())
3579}
3580
3581fn include_by_onbranch(condition: &str, ctx: &IncludeContext) -> bool {
3582    let Some(short) = current_branch_short_name(ctx.git_dir.as_deref()) else {
3583        return false;
3584    };
3585    let mut pattern = condition.to_owned();
3586    add_trailing_starstar_for_dir(&mut pattern);
3587    wildmatch(pattern.as_bytes(), short.as_bytes(), WM_PATHNAME)
3588}
3589
3590fn is_remote_url_entry(entry: &ConfigEntry) -> bool {
3591    let Ok((section, subsection, variable)) = split_key(&entry.key) else {
3592        return false;
3593    };
3594    section == "remote" && subsection.is_some() && variable == "url"
3595}
3596
3597fn is_hasconfig_remote_url(condition: &str) -> bool {
3598    condition
3599        .strip_prefix("hasconfig:")
3600        .is_some_and(|rest| rest.starts_with("remote.*.url:"))
3601}
3602
3603fn include_by_hasconfig_remote_url(condition: &str, set: &ConfigSet, file: &ConfigFile) -> bool {
3604    let Some(pattern) = condition.strip_prefix("remote.*.url:") else {
3605        return false;
3606    };
3607    set.entries
3608        .iter()
3609        .chain(file.entries.iter())
3610        .filter(|entry| is_remote_url_entry(entry))
3611        .filter_map(|entry| entry.value.as_deref())
3612        .any(|value| wildmatch(pattern.as_bytes(), value.as_bytes(), WM_PATHNAME))
3613}
3614
3615fn validate_hasconfig_remote_url_include(
3616    file: &ConfigFile,
3617    process_includes: bool,
3618    depth: usize,
3619    ctx: &IncludeContext,
3620) -> Result<()> {
3621    const MAX_INCLUDE_DEPTH: usize = 10;
3622    if depth > MAX_INCLUDE_DEPTH {
3623        return Err(Error::ConfigError(
3624            "exceeded maximum include depth".to_owned(),
3625        ));
3626    }
3627    if file.entries.iter().any(is_remote_url_entry) {
3628        return Err(Error::Message(
3629            "fatal: remote URLs cannot be configured in file directly or indirectly included by includeIf.hasconfig:remote.*.url"
3630                .to_owned(),
3631        ));
3632    }
3633    if !process_includes {
3634        return Ok(());
3635    }
3636    for entry in &file.entries {
3637        let Some((inc_path, condition)) = include_directive_for_entry(entry) else {
3638            continue;
3639        };
3640        if let Some(ref cond) = condition {
3641            if !evaluate_include_condition(cond, &ConfigSet::new(), file, ctx) {
3642                continue;
3643            }
3644        }
3645        let resolved = match resolve_include_file_path(&inc_path, file, ctx) {
3646            Ok(p) => p,
3647            Err(Error::ConfigError(msg)) if msg.is_empty() => continue,
3648            Err(e) => return Err(e),
3649        };
3650        if let Some(inc_file) = ConfigFile::from_path(&resolved, file.scope)? {
3651            validate_hasconfig_remote_url_include(&inc_file, process_includes, depth + 1, ctx)?;
3652        }
3653    }
3654    Ok(())
3655}
3656
3657/// Evaluate an `[includeIf]` condition.
3658///
3659/// Supports `gitdir:`, `gitdir/i:`, `onbranch:`, and `hasconfig:remote.*.url:` like Git.
3660/// Unknown prefixes are false.
3661fn evaluate_include_condition(
3662    condition: &str,
3663    set: &ConfigSet,
3664    file: &ConfigFile,
3665    ctx: &IncludeContext,
3666) -> bool {
3667    if let Some(rest) = condition.strip_prefix("gitdir/i:") {
3668        return include_by_gitdir(rest, file, ctx, true);
3669    }
3670    if let Some(rest) = condition.strip_prefix("gitdir:") {
3671        return include_by_gitdir(rest, file, ctx, false);
3672    }
3673    if let Some(rest) = condition.strip_prefix("onbranch:") {
3674        return include_by_onbranch(rest, ctx);
3675    }
3676    if let Some(rest) = condition.strip_prefix("hasconfig:") {
3677        return include_by_hasconfig_remote_url(rest, set, file);
3678    }
3679    false
3680}
3681
3682/// Split a canonical key into (section, subsection, variable).
3683fn split_key(key: &str) -> Result<(String, Option<String>, String)> {
3684    let first_dot = key
3685        .find('.')
3686        .ok_or_else(|| Error::ConfigError(format!("invalid key: '{key}'")))?;
3687    let last_dot = key
3688        .rfind('.')
3689        .ok_or_else(|| Error::ConfigError(format!("invalid key: '{key}'")))?;
3690
3691    let section = key[..first_dot].to_owned();
3692    let variable = key[last_dot + 1..].to_owned();
3693
3694    let subsection = if first_dot == last_dot {
3695        None
3696    } else {
3697        Some(key[first_dot + 1..last_dot].to_owned())
3698    };
3699
3700    Ok((section, subsection, variable))
3701}
3702
3703/// Extract the variable name from a canonical key.
3704#[allow(dead_code)]
3705fn variable_name_from_key(key: &str) -> &str {
3706    match key.rfind('.') {
3707        Some(i) => &key[i + 1..],
3708        None => key,
3709    }
3710}
3711
3712/// Parse a section name that may contain a subsection (e.g. `"remote.origin"`).
3713///
3714/// Returns (section, subsection).
3715fn parse_section_name(name: &str) -> (&str, Option<&str>) {
3716    match name.find('.') {
3717        Some(i) => (&name[..i], Some(&name[i + 1..])),
3718        None => (name, None),
3719    }
3720}
3721
3722fn section_matches(parser: &Parser, section_lower: &str, subsection: Option<&str>) -> bool {
3723    if parser.section.to_lowercase() == section_lower && parser.subsection.as_deref() == subsection
3724    {
3725        return true;
3726    }
3727    let Some(subsection) = subsection else {
3728        return false;
3729    };
3730    parser.subsection.is_none()
3731        && parser.section.to_lowercase() == format!("{section_lower}.{}", subsection.to_lowercase())
3732}
3733
3734fn validate_section_name(section: &str, subsection: Option<&str>) -> Result<()> {
3735    if section.is_empty()
3736        || !section
3737            .chars()
3738            .all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
3739        || subsection.is_some_and(str::is_empty)
3740    {
3741        return Err(Error::ConfigError(format!(
3742            "invalid section name: {section}"
3743        )));
3744    }
3745    Ok(())
3746}
3747
3748/// Extract the original-case variable name from a raw (user-typed) key.
3749///
3750/// E.g. `"Section.Movie"` → `"Movie"`, `"a.b.CamelCase"` → `"CamelCase"`.
3751fn raw_variable_name(raw_key: &str) -> &str {
3752    match raw_key.rfind('.') {
3753        Some(i) => &raw_key[i + 1..],
3754        None => raw_key,
3755    }
3756}
3757
3758/// Extract the original-case section and subsection from a raw (user-typed) key.
3759///
3760/// E.g. `"Section.key"` → `("Section", None)`,
3761///      `"Remote.origin.url"` → `("Remote", Some("origin"))`.
3762fn raw_section_parts(raw_key: &str) -> (String, Option<String>) {
3763    let first_dot = match raw_key.find('.') {
3764        Some(i) => i,
3765        None => return (raw_key.to_owned(), None),
3766    };
3767    // rfind always succeeds here since we already found at least one dot above.
3768    let last_dot = match raw_key.rfind('.') {
3769        Some(i) => i,
3770        None => return (raw_key[..first_dot].to_owned(), None),
3771    };
3772    let section = raw_key[..first_dot].to_owned();
3773    if first_dot == last_dot {
3774        (section, None)
3775    } else {
3776        let subsection = raw_key[first_dot + 1..last_dot].to_owned();
3777        (section, Some(subsection))
3778    }
3779}
3780
3781/// Check if a raw line is a section header that also contains an inline key=value.
3782fn is_section_header_with_inline_entry(line: &str) -> bool {
3783    let trimmed = line.trim();
3784    if !trimmed.starts_with('[') {
3785        return false;
3786    }
3787    let end = match trimmed.find(']') {
3788        Some(i) => i,
3789        None => return false,
3790    };
3791    let after = trimmed[end + 1..].trim();
3792    // Has non-comment content after the ]
3793    !after.is_empty() && !after.starts_with('#') && !after.starts_with(';')
3794}
3795
3796/// Extract just the section header portion (up to and including `]` and any
3797/// comment after it, but not any inline key=value) from a raw line.
3798fn extract_section_header(line: &str) -> String {
3799    let trimmed = line.trim();
3800    let end = match trimmed.find(']') {
3801        Some(i) => i,
3802        None => return line.to_owned(),
3803    };
3804    // Preserve any comment on the section header itself (between ] and key),
3805    // but git doesn't really do this. Just return up to ].
3806    trimmed[..=end].to_owned()
3807}
3808
3809#[cfg(test)]
3810mod get_regexp_tests {
3811    use super::{ConfigFile, ConfigScope, ConfigSet};
3812    use std::path::Path;
3813
3814    fn set_from_snippet(text: &str) -> ConfigSet {
3815        let path = Path::new(".git/config");
3816        let file = ConfigFile::parse(path, text, ConfigScope::Local).expect("parse config snippet");
3817        let mut set = ConfigSet::new();
3818        set.merge(&file);
3819        set
3820    }
3821
3822    #[test]
3823    fn get_regexp_matches_section_prefix_like_git_config() {
3824        let text = r#"
3825[user]
3826    email = alice@example.com
3827    name = Alice
3828[core]
3829    bare = false
3830"#;
3831        let set = set_from_snippet(text);
3832        let keys: Vec<_> = set
3833            .get_regexp("user")
3834            .expect("valid pattern")
3835            .into_iter()
3836            .map(|e| e.key.as_str())
3837            .collect();
3838        assert!(keys.contains(&"user.email"));
3839        assert!(keys.contains(&"user.name"));
3840        assert!(!keys.iter().any(|k| k.starts_with("core.")));
3841    }
3842
3843    #[test]
3844    fn get_regexp_returns_all_multi_value_entries_in_order() {
3845        let text = r#"
3846[remote "origin"]
3847    url = https://example.com/repo.git
3848    fetch = +refs/heads/*:refs/remotes/origin/*
3849    push = +refs/heads/main:refs/heads/main
3850    push = +refs/heads/develop:refs/heads/develop
3851"#;
3852        let set = set_from_snippet(text);
3853        let matches = set.get_regexp("remote.origin").expect("valid pattern");
3854        let push_vals: Vec<_> = matches
3855            .iter()
3856            .filter(|e| e.key == "remote.origin.push")
3857            .map(|e| e.value.as_deref().unwrap_or(""))
3858            .collect();
3859        assert_eq!(push_vals.len(), 2);
3860        assert_eq!(push_vals[0], "+refs/heads/main:refs/heads/main");
3861        assert_eq!(push_vals[1], "+refs/heads/develop:refs/heads/develop");
3862    }
3863
3864    #[test]
3865    fn get_regexp_dot_matches_any_key() {
3866        let text = r#"
3867[a]
3868    x = 1
3869[b]
3870    y = 2
3871"#;
3872        let set = set_from_snippet(text);
3873        let m = set.get_regexp(".").expect("valid pattern");
3874        assert_eq!(m.len(), 2);
3875    }
3876
3877    #[test]
3878    fn get_regexp_no_match_returns_empty_vec() {
3879        let set = set_from_snippet("[user]\n\tname = x\n");
3880        let m = set.get_regexp("zzz").expect("valid pattern");
3881        assert!(m.is_empty());
3882    }
3883
3884    #[test]
3885    fn get_regexp_invalid_pattern_is_error() {
3886        let set = set_from_snippet("[user]\n\tname = x\n");
3887        let err = set.get_regexp("(").expect_err("unclosed group");
3888        assert!(err.contains("invalid key pattern"), "got: {err}");
3889    }
3890}
3891
3892#[cfg(test)]
3893mod pack_compression_tests {
3894    use super::{ConfigFile, ConfigScope, ConfigSet};
3895    use std::path::Path;
3896
3897    fn set_from_snippet(text: &str) -> ConfigSet {
3898        let path = Path::new(".git/config");
3899        let file = ConfigFile::parse(path, text, ConfigScope::Local).expect("parse config snippet");
3900        let mut set = ConfigSet::new();
3901        set.merge(&file);
3902        set
3903    }
3904
3905    #[test]
3906    fn pack_objects_zlib_level_defaults_to_six() {
3907        let set = ConfigSet::new();
3908        assert_eq!(set.pack_objects_zlib_level().unwrap(), 6);
3909    }
3910
3911    #[test]
3912    fn pack_objects_zlib_level_core_compression() {
3913        let set = set_from_snippet("[core]\n\tcompression = 0\n");
3914        assert_eq!(set.pack_objects_zlib_level().unwrap(), 0);
3915        let set = set_from_snippet("[core]\n\tcompression = 9\n");
3916        assert_eq!(set.pack_objects_zlib_level().unwrap(), 9);
3917    }
3918
3919    #[test]
3920    fn pack_objects_zlib_level_pack_overrides_core() {
3921        let set = set_from_snippet("[core]\n\tcompression = 9\n[pack]\n\tcompression = 0\n");
3922        assert_eq!(set.pack_objects_zlib_level().unwrap(), 0);
3923        let set = set_from_snippet("[core]\n\tcompression = 0\n[pack]\n\tcompression = 9\n");
3924        assert_eq!(set.pack_objects_zlib_level().unwrap(), 9);
3925    }
3926
3927    #[test]
3928    fn pack_objects_zlib_level_later_core_does_not_override_earlier_pack() {
3929        let mut set = ConfigSet::new();
3930        set.merge(
3931            &ConfigFile::parse(
3932                Path::new("a"),
3933                "[pack]\n\tcompression = 9\n",
3934                ConfigScope::Local,
3935            )
3936            .unwrap(),
3937        );
3938        set.merge(
3939            &ConfigFile::parse(
3940                Path::new("b"),
3941                "[core]\n\tcompression = 0\n",
3942                ConfigScope::Local,
3943            )
3944            .unwrap(),
3945        );
3946        assert_eq!(set.pack_objects_zlib_level().unwrap(), 9);
3947    }
3948
3949    #[test]
3950    fn pack_objects_zlib_level_loosecompression_does_not_block_core_pack_level() {
3951        let set = set_from_snippet("[core]\n\tloosecompression = 1\n\tcompression = 0\n");
3952        assert_eq!(set.pack_objects_zlib_level().unwrap(), 0);
3953    }
3954
3955    #[test]
3956    fn pack_objects_zlib_level_pack_wins_after_loose_and_core() {
3957        let set = set_from_snippet(
3958            "[core]\n\tloosecompression = 1\n\tcompression = 0\n[pack]\n\tcompression = 9\n",
3959        );
3960        assert_eq!(set.pack_objects_zlib_level().unwrap(), 9);
3961    }
3962}
3963
3964#[cfg(test)]
3965mod config_cache_tests {
3966    use super::*;
3967    use filetime::FileTime;
3968
3969    fn local_opts(git_dir: &Path) -> LoadConfigOptions {
3970        let mut opts = LoadConfigOptions::default();
3971        opts.include_system = false;
3972        opts.include_ctx.git_dir = Some(git_dir.to_path_buf());
3973        opts
3974    }
3975
3976    fn load_value(git_dir: &Path) -> Option<String> {
3977        let opts = local_opts(git_dir);
3978        let set = ConfigSet::load_with_options(Some(git_dir), &opts).expect("load cascade");
3979        set.get("gritcachetest.value")
3980    }
3981
3982    fn mtime_of(path: &Path) -> FileTime {
3983        FileTime::from_last_modification_time(&fs::metadata(path).expect("stat config"))
3984    }
3985
3986    fn restore_mtime(path: &Path, stamp: FileTime) {
3987        filetime::set_file_mtime(path, stamp).expect("restore mtime");
3988    }
3989
3990    #[test]
3991    fn cache_serves_same_stamp_and_config_write_evicts() {
3992        let td = tempfile::tempdir().expect("tempdir");
3993        let gd = td.path();
3994        let cfg = gd.join("config");
3995        fs::write(&cfg, "[gritcachetest]\n\tvalue = aaa\n").expect("write v1");
3996        let t0 = mtime_of(&cfg);
3997        assert_eq!(load_value(gd).as_deref(), Some("aaa"));
3998
3999        // Same size + restored mtime: indistinguishable by stat, so the cache
4000        // serves the old parse. (This is the window `ConfigFile::write`
4001        // eviction closes for in-process writers; C git would not re-read at
4002        // all.) This assertion is what proves the cache is actually used.
4003        fs::write(&cfg, "[gritcachetest]\n\tvalue = bbb\n").expect("write v2");
4004        restore_mtime(&cfg, t0);
4005        assert_eq!(load_value(gd).as_deref(), Some("aaa"));
4006
4007        // An in-process `ConfigFile::write` evicts even with identical stamps.
4008        let mut file = ConfigFile::from_path(&cfg, ConfigScope::Local)
4009            .expect("read config")
4010            .expect("config exists");
4011        file.set("gritcachetest.value", "ccc").expect("set value");
4012        file.write().expect("persist");
4013        restore_mtime(&cfg, t0);
4014        assert_eq!(load_value(gd).as_deref(), Some("ccc"));
4015    }
4016
4017    #[test]
4018    fn cache_invalidates_on_size_or_existence_change() {
4019        let td = tempfile::tempdir().expect("tempdir");
4020        let gd = td.path();
4021        let cfg = gd.join("config");
4022        // Absent local config is cached as "absent"...
4023        assert_eq!(load_value(gd), None);
4024        // ...and creating the file is a stamp change (None -> Some).
4025        fs::write(&cfg, "[gritcachetest]\n\tvalue = first\n").expect("create");
4026        assert_eq!(load_value(gd).as_deref(), Some("first"));
4027        // A size change invalidates even with a restored mtime.
4028        let t0 = mtime_of(&cfg);
4029        fs::write(&cfg, "[gritcachetest]\n\tvalue = second-longer\n").expect("rewrite");
4030        restore_mtime(&cfg, t0);
4031        assert_eq!(load_value(gd).as_deref(), Some("second-longer"));
4032    }
4033
4034    #[test]
4035    fn include_targets_are_stamped_and_invalidate() {
4036        let td = tempfile::tempdir().expect("tempdir");
4037        let gd = td.path();
4038        fs::write(gd.join("config"), "[include]\n\tpath = extra.conf\n").expect("write parent");
4039        let inc = gd.join("extra.conf");
4040        fs::write(&inc, "[gritcachetest]\n\tvalue = one\n").expect("write include v1");
4041        assert_eq!(load_value(gd).as_deref(), Some("one"));
4042
4043        // Same-size + restored-mtime rewrite of only the included file: the
4044        // parse result is served from cache (proving the include target is
4045        // stamped rather than re-read)...
4046        let t0 = mtime_of(&inc);
4047        fs::write(&inc, "[gritcachetest]\n\tvalue = two\n").expect("write include v2");
4048        restore_mtime(&inc, t0);
4049        assert_eq!(load_value(gd).as_deref(), Some("one"));
4050
4051        // ...while a stat-visible change to the included file invalidates.
4052        fs::write(&inc, "[gritcachetest]\n\tvalue = two-longer\n").expect("write include v3");
4053        assert_eq!(load_value(gd).as_deref(), Some("two-longer"));
4054    }
4055
4056    #[test]
4057    fn missing_include_target_is_watched() {
4058        let td = tempfile::tempdir().expect("tempdir");
4059        let gd = td.path();
4060        fs::write(gd.join("config"), "[include]\n\tpath = extra.conf\n").expect("write parent");
4061        assert_eq!(load_value(gd), None);
4062
4063        // The resolved-but-missing target was stamped as absent; creating it
4064        // must invalidate the cached parse.
4065        fs::write(gd.join("extra.conf"), "[gritcachetest]\n\tvalue = born\n").expect("create");
4066        assert_eq!(load_value(gd).as_deref(), Some("born"));
4067    }
4068
4069    #[test]
4070    fn onbranch_condition_follows_head() {
4071        let td = tempfile::tempdir().expect("tempdir");
4072        let gd = td.path();
4073        fs::write(gd.join("HEAD"), "ref: refs/heads/main\n").expect("write HEAD");
4074        fs::write(
4075            gd.join("config"),
4076            "[includeIf \"onbranch:main\"]\n\tpath = branch.conf\n",
4077        )
4078        .expect("write config");
4079        fs::write(
4080            gd.join("branch.conf"),
4081            "[gritcachetest]\n\tvalue = onmain\n",
4082        )
4083        .expect("write branch config");
4084        assert_eq!(load_value(gd).as_deref(), Some("onmain"));
4085
4086        // Switching branches rewrites HEAD; the stamped HEAD must invalidate
4087        // the cached cascade so the condition is re-evaluated.
4088        fs::write(gd.join("HEAD"), "ref: refs/heads/dev\n").expect("rewrite HEAD");
4089        assert_eq!(load_value(gd), None);
4090    }
4091}