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)) => Self::merge_with_includes_collect(&mut set, &f, proc, 0, &ctx, included_files)?,
1877                Ok(None) => {}
1878                Err(e) => return Err(e),
1879            }
1880        }
1881
1882        // Global config (Git merges every existing file: XDG then ~/.gitconfig).
1883        for path in global_config_paths() {
1884            match ConfigFile::from_path(&path, ConfigScope::Global) {
1885                Ok(Some(f)) => Self::merge_with_includes_collect(&mut set, &f, proc, 0, &ctx, included_files)?,
1886                Ok(None) => {}
1887                Err(e) => return Err(e),
1888            }
1889        }
1890
1891        // Local config — linked worktrees read `commondir/config`, not the admin `config`.
1892        if let Some(gd) = git_dir {
1893            let common_dir = crate::repo::common_git_dir_for_config(gd);
1894            let local_path = common_dir.join("config");
1895            match ConfigFile::from_path(&local_path, ConfigScope::Local) {
1896                Ok(Some(f)) => Self::merge_with_includes_collect(&mut set, &f, proc, 0, &ctx, included_files)?,
1897                Ok(None) => {}
1898                Err(e) => return Err(e),
1899            }
1900
1901            // Worktree config — Git only reads `config.worktree` when
1902            // `extensions.worktreeConfig` is enabled in the common repository `config`.
1903            let wt_path = gd.join("config.worktree");
1904            if crate::repo::worktree_config_enabled(&common_dir) {
1905                match ConfigFile::from_path(&wt_path, ConfigScope::Worktree) {
1906                    Ok(Some(f)) => Self::merge_with_includes_collect(&mut set, &f, proc, 0, &ctx, included_files)?,
1907                    Ok(None) => {}
1908                    Err(e) => return Err(e),
1909                }
1910            }
1911        }
1912
1913        // Environment overrides: optional file
1914        if let Ok(path) = std::env::var("GIT_CONFIG") {
1915            match ConfigFile::from_path(Path::new(&path), ConfigScope::Command) {
1916                Ok(Some(f)) => {
1917                    if proc {
1918                        Self::merge_with_includes_collect(&mut set, &f, proc, 0, &ctx, included_files)?;
1919                    } else {
1920                        set.merge(&f);
1921                    }
1922                }
1923                Ok(None) => {}
1924                Err(e) => return Err(e),
1925            }
1926        }
1927
1928        add_environment_config_pairs(&mut set)?;
1929
1930        // GIT_CONFIG_PARAMETERS — used by `git -c key=value`.
1931        if let Ok(params) = std::env::var("GIT_CONFIG_PARAMETERS") {
1932            if proc && opts.command_includes && !params.trim().is_empty() {
1933                let pseudo = Path::new(":GIT_CONFIG_PARAMETERS");
1934                let cmd_file = ConfigFile::from_git_config_parameters(pseudo, &params)?;
1935                Self::merge_with_includes_collect(&mut set, &cmd_file, proc, 0, &ctx, included_files)?;
1936            } else if !params.trim().is_empty() {
1937                for entry in parse_config_parameters(&params) {
1938                    if let Some((key, val)) =
1939                        entry.split_once('\u{1}').or_else(|| entry.split_once('='))
1940                    {
1941                        let _ = set.add_command_override(key.trim(), val);
1942                    } else {
1943                        let _ = set.add_command_override(entry.trim(), "true");
1944                    }
1945                }
1946            }
1947        }
1948
1949        Ok(set)
1950    }
1951
1952    /// Read configuration the way Git's `read_early_config` / `do_git_config_sequence` does:
1953    /// system (unless disabled), global files in Git order, optional repository `config` /
1954    /// `config.worktree`, then `GIT_CONFIG_PARAMETERS`.
1955    ///
1956    /// When `git_dir` is `None` (no discovered repository, e.g. `GIT_CEILING_DIRECTORIES`), only
1957    /// non-repo layers are read — matching Git when discovery returns no gitdir (t1309 ceiling #2).
1958    ///
1959    /// Returns all values for `key` in load order (Git's `read_early_config` callback runs once per
1960    /// occurrence).
1961    ///
1962    /// This matches upstream ordering for `test-tool config read_early_config` (t1309, t1305).
1963    pub fn read_early_config(git_dir: Option<&Path>, key: &str) -> Result<Vec<String>> {
1964        let mut set = Self::new();
1965        let ctx = IncludeContext {
1966            git_dir: git_dir.map(PathBuf::from),
1967            command_line_relative_include_is_error: false,
1968        };
1969
1970        // System
1971        if !git_config_nosystem_enabled() {
1972            let system_path = std::env::var("GIT_CONFIG_SYSTEM")
1973                .map(std::path::PathBuf::from)
1974                .unwrap_or_else(|_| std::path::PathBuf::from("/etc/gitconfig"));
1975            if let Ok(Some(f)) = ConfigFile::from_path(&system_path, ConfigScope::System) {
1976                Self::merge_with_includes(&mut set, &f, true, 0, &ctx)?;
1977            }
1978        }
1979
1980        // Global: all existing candidates (Git merges every readable file).
1981        for path in global_config_paths() {
1982            if let Ok(Some(f)) = ConfigFile::from_path(&path, ConfigScope::Global) {
1983                Self::merge_with_includes(&mut set, &f, true, 0, &ctx)?;
1984            }
1985        }
1986
1987        if let Some(gd) = git_dir {
1988            let common_dir = crate::repo::common_git_dir_for_config(gd);
1989            // Local (commondir) — skip when format is newer than supported (t1309).
1990            let local_path = common_dir.join("config");
1991            if let Some(msg) = crate::repo::early_config_ignore_repo_reason(&common_dir) {
1992                eprintln!("warning: ignoring git dir '{}': {}", gd.display(), msg);
1993            } else if let Ok(Some(f)) = ConfigFile::from_path(&local_path, ConfigScope::Local) {
1994                set.merge_file_with_includes(&f, true, &ctx)?;
1995            }
1996
1997            // Worktree-specific config (when enabled for this repo).
1998            let wt_path = gd.join("config.worktree");
1999            if crate::repo::worktree_config_enabled(&common_dir) {
2000                if let Ok(Some(f)) = ConfigFile::from_path(&wt_path, ConfigScope::Worktree) {
2001                    Self::merge_with_includes(&mut set, &f, true, 0, &ctx)?;
2002                }
2003            }
2004        }
2005
2006        // GIT_CONFIG_PARAMETERS — same as full load (`load_with_options` default).
2007        if let Ok(params) = std::env::var("GIT_CONFIG_PARAMETERS") {
2008            if !params.trim().is_empty() {
2009                let pseudo = Path::new(":GIT_CONFIG_PARAMETERS");
2010                let cmd_file = ConfigFile::from_git_config_parameters(pseudo, &params)?;
2011                Self::merge_with_includes(&mut set, &cmd_file, true, 0, &ctx)?;
2012            }
2013        }
2014
2015        Ok(set.get_all(key))
2016    }
2017
2018    /// Merge a single config file, optionally expanding `[include]` / `[includeIf]`.
2019    ///
2020    /// Used by `grit config -f` and scoped reads; [`ConfigSet::load_with_options`] uses the same
2021    /// internal routine for the standard cascade.
2022    pub fn merge_file_with_includes(
2023        &mut self,
2024        file: &ConfigFile,
2025        process_includes: bool,
2026        ctx: &IncludeContext,
2027    ) -> Result<()> {
2028        Self::merge_with_includes(self, file, process_includes, 0, ctx)
2029    }
2030
2031    /// Load only the repository's own `config` file (plus any `[include]` targets).
2032    ///
2033    /// Unlike [`Self::load`], this ignores system/global config and environment
2034    /// overrides. Used for receive-side options (e.g. `transfer.fsckObjects`) so a
2035    /// pusher's global configuration cannot weaken the remote repository's policy.
2036    pub fn load_repo_local_only(git_dir: &Path) -> Result<Self> {
2037        let mut set = Self::new();
2038        let local_path = git_dir.join("config");
2039        let ctx = IncludeContext {
2040            git_dir: Some(git_dir.to_path_buf()),
2041            command_line_relative_include_is_error: false,
2042        };
2043        if let Ok(Some(f)) = ConfigFile::from_path(&local_path, ConfigScope::Local) {
2044            Self::merge_with_includes(&mut set, &f, true, 0, &ctx)?;
2045        }
2046        Ok(set)
2047    }
2048
2049    /// Load configuration the way Git loads **protected** config (e.g. `uploadpack.packObjectsHook`).
2050    ///
2051    /// This matches Git's `read_protected_config`: system (optional), global files only (no
2052    /// repository or worktree `config`), then command-line overrides from `GIT_CONFIG_COUNT` /
2053    /// `GIT_CONFIG_PARAMETERS`. It does **not** read `$GIT_CONFIG` (Git omits that for protected
2054    /// config).
2055    ///
2056    /// Global file order matches Git: XDG `git/config` first (when present), then `~/.gitconfig`,
2057    /// unless `GIT_CONFIG_GLOBAL` is set (single file). When both global files exist, both are
2058    /// merged so later entries win for duplicate keys.
2059    pub fn load_protected(include_system: bool) -> Result<Self> {
2060        let mut set = Self::new();
2061        let ctx = IncludeContext {
2062            git_dir: None,
2063            command_line_relative_include_is_error: false,
2064        };
2065
2066        if include_system && !git_config_nosystem_enabled() {
2067            let system_path = std::env::var("GIT_CONFIG_SYSTEM")
2068                .map(std::path::PathBuf::from)
2069                .unwrap_or_else(|_| std::path::PathBuf::from("/etc/gitconfig"));
2070            if let Ok(Some(f)) = ConfigFile::from_path(&system_path, ConfigScope::System) {
2071                Self::merge_with_includes(&mut set, &f, true, 0, &ctx)?;
2072            }
2073        }
2074
2075        if let Ok(p) = std::env::var("GIT_CONFIG_GLOBAL") {
2076            let path = PathBuf::from(p);
2077            if let Ok(Some(f)) = ConfigFile::from_path(&path, ConfigScope::Global) {
2078                Self::merge_with_includes(&mut set, &f, true, 0, &ctx)?;
2079            }
2080        } else {
2081            let mut global_paths = Vec::new();
2082            if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
2083                global_paths.push(PathBuf::from(xdg).join("git/config"));
2084            } else if let Some(home) = home_dir() {
2085                global_paths.push(home.join(".config/git/config"));
2086            }
2087            if let Some(home) = home_dir() {
2088                global_paths.push(home.join(".gitconfig"));
2089            }
2090            for path in global_paths {
2091                if let Ok(Some(f)) = ConfigFile::from_path(&path, ConfigScope::Global) {
2092                    Self::merge_with_includes(&mut set, &f, true, 0, &ctx)?;
2093                }
2094            }
2095        }
2096
2097        add_environment_config_pairs(&mut set)?;
2098
2099        if let Ok(params) = std::env::var("GIT_CONFIG_PARAMETERS") {
2100            for entry in parse_config_parameters(&params) {
2101                if let Some((key, val)) =
2102                    entry.split_once('\u{1}').or_else(|| entry.split_once('='))
2103                {
2104                    let _ = set.add_command_override(key.trim(), val);
2105                } else {
2106                    let _ = set.add_command_override(entry.trim(), "true");
2107                }
2108            }
2109        }
2110
2111        Ok(set)
2112    }
2113
2114    /// Merge a file, processing `[include]` and `[includeIf]` directives.
2115    fn merge_with_includes(
2116        set: &mut Self,
2117        file: &ConfigFile,
2118        process_includes: bool,
2119        depth: usize,
2120        ctx: &IncludeContext,
2121    ) -> Result<()> {
2122        let mut included_files = Vec::new();
2123        Self::merge_with_includes_collect(
2124            set,
2125            file,
2126            process_includes,
2127            depth,
2128            ctx,
2129            &mut included_files,
2130        )
2131    }
2132
2133    /// [`Self::merge_with_includes`], additionally recording every resolved
2134    /// include target path in `included_files` (whether or not the target
2135    /// exists) so the cascade cache can stamp them.
2136    fn merge_with_includes_collect(
2137        set: &mut Self,
2138        file: &ConfigFile,
2139        process_includes: bool,
2140        depth: usize,
2141        ctx: &IncludeContext,
2142        included_files: &mut Vec<PathBuf>,
2143    ) -> Result<()> {
2144        // Mirror Git behavior and stop runaway include recursion.
2145        // t0017 expects the diagnostic to contain this exact phrase.
2146        const MAX_INCLUDE_DEPTH: usize = 10;
2147        if depth > MAX_INCLUDE_DEPTH {
2148            return Err(Error::ConfigError(
2149                "exceeded maximum include depth".to_owned(),
2150            ));
2151        }
2152        if !process_includes {
2153            set.merge(file);
2154            return Ok(());
2155        }
2156
2157        for entry in &file.entries {
2158            set.entries.push(entry.clone());
2159
2160            let Some((inc_path, condition)) = include_directive_for_entry(entry) else {
2161                continue;
2162            };
2163            let included_by_hasconfig = condition.as_deref().is_some_and(is_hasconfig_remote_url);
2164            if condition.is_some() && !included_by_hasconfig {
2165                let cond = condition.as_deref().unwrap_or_default();
2166                if !evaluate_include_condition(cond, set, file, ctx) {
2167                    continue;
2168                }
2169            }
2170
2171            let resolved = match resolve_include_file_path(&inc_path, file, ctx) {
2172                Ok(p) => p,
2173                Err(Error::ConfigError(msg)) if msg.is_empty() => continue,
2174                Err(e) => return Err(e),
2175            };
2176            included_files.push(resolved.clone());
2177            // Git's `git_config_from_file` surfaces parse errors in an included file as a
2178            // fatal error (t0001 #102 `re-init reads matching includeIf.onbranch`). A missing
2179            // include target is silently skipped (`from_path` -> `Ok(None)`).
2180            let Some(inc_file) = ConfigFile::from_path(&resolved, file.scope)? else {
2181                continue;
2182            };
2183
2184            if included_by_hasconfig {
2185                validate_hasconfig_remote_url_include(&inc_file, process_includes, depth + 1, ctx)?;
2186                let cond = condition.as_deref().unwrap_or_default();
2187                if !evaluate_include_condition(cond, set, file, ctx) {
2188                    continue;
2189                }
2190            }
2191
2192            Self::merge_with_includes_collect(
2193                set,
2194                &inc_file,
2195                process_includes,
2196                depth + 1,
2197                ctx,
2198                included_files,
2199            )?;
2200        }
2201
2202        Ok(())
2203    }
2204}
2205
2206fn include_directive_for_entry(entry: &ConfigEntry) -> Option<(String, Option<String>)> {
2207    let val = entry.value.as_ref()?;
2208    if entry.key == "include.path" {
2209        return Some((val.clone(), None));
2210    }
2211    if entry.key.starts_with("includeif.") && entry.key.ends_with(".path") {
2212        let mid = &entry.key["includeif.".len()..entry.key.len() - ".path".len()];
2213        return Some((val.clone(), Some(mid.to_owned())));
2214    }
2215    None
2216}
2217
2218// ── Process-lifetime config cascade cache ───────────────────────────
2219//
2220// `ConfigSet::load` / `load_with_options` are called per file in hot loops
2221// (grep/diff/add re-load the cascade for every path), so the parsed cascade
2222// is memoized for the lifetime of the process. A cached entry is served only
2223// when every input that fed it is provably unchanged:
2224//
2225// - the stat stamps (mtime + size, or "absent") of every base cascade file,
2226//   recorded *before* the parse, still match. `HEAD` is stamped too so that
2227//   `includeIf.onbranch:` condition flips invalidate;
2228// - every include target the parse resolved (even ones that were missing)
2229//   still carries the stamp it had at parse time;
2230// - the config-relevant environment is byte-identical.
2231//
2232// The remaining `includeIf` condition inputs are covered transitively:
2233// `gitdir:` depends only on the cache key and environment, and
2234// `hasconfig:remote.*.url:` depends only on the (stamped) file contents.
2235//
2236// In-process writers ([`ConfigFile::write`]) additionally evict every entry
2237// whose cascade contains the written path, closing the window where a
2238// rewrite lands within one mtime tick at an unchanged size. External writers
2239// in that same window are not detectable by stat; C git parses the cascade
2240// once per process with no revalidation at all, so serving a stamped copy is
2241// strictly more conservative than upstream.
2242
2243/// Stat snapshot of one cascade file (`None` = file absent).
2244type ConfigFileStamp = (PathBuf, Option<(SystemTime, u64)>);
2245
2246#[derive(PartialEq, Eq, Hash)]
2247struct ConfigCacheKey {
2248    git_dir: Option<PathBuf>,
2249    include_system: bool,
2250    process_includes: bool,
2251    command_includes: bool,
2252    ctx_git_dir: Option<PathBuf>,
2253    ctx_relative_include_is_error: bool,
2254}
2255
2256impl ConfigCacheKey {
2257    fn new(git_dir: Option<&Path>, opts: &LoadConfigOptions) -> Self {
2258        Self {
2259            git_dir: git_dir.map(Path::to_path_buf),
2260            include_system: opts.include_system,
2261            process_includes: opts.process_includes,
2262            command_includes: opts.command_includes,
2263            ctx_git_dir: opts.include_ctx.git_dir.clone(),
2264            ctx_relative_include_is_error: opts.include_ctx.command_line_relative_include_is_error,
2265        }
2266    }
2267}
2268
2269struct ConfigCacheEntry {
2270    env_fingerprint: Vec<(String, Option<String>)>,
2271    /// Stamps of the fixed cascade files (recomputable from key + env).
2272    base_stamps: Vec<ConfigFileStamp>,
2273    /// Stamps of the include targets this parse resolved (revalidated by
2274    /// re-statting each stored path).
2275    extra_stamps: Vec<ConfigFileStamp>,
2276    set: Arc<ConfigSet>,
2277}
2278
2279fn config_cache() -> &'static Mutex<HashMap<ConfigCacheKey, ConfigCacheEntry>> {
2280    static CACHE: OnceLock<Mutex<HashMap<ConfigCacheKey, ConfigCacheEntry>>> = OnceLock::new();
2281    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
2282}
2283
2284/// Environment that feeds the cascade (file paths and synthetic entries).
2285/// `None` means "do not cache this load".
2286fn config_env_fingerprint() -> Option<Vec<(String, Option<String>)>> {
2287    const VARS: [&str; 8] = [
2288        "GIT_CONFIG_NOSYSTEM",
2289        "GIT_CONFIG_SYSTEM",
2290        "GIT_CONFIG_GLOBAL",
2291        "XDG_CONFIG_HOME",
2292        "HOME",
2293        "GIT_CONFIG",
2294        "GIT_CONFIG_PARAMETERS",
2295        "GIT_CONFIG_COUNT",
2296    ];
2297    let mut fp: Vec<(String, Option<String>)> = VARS
2298        .iter()
2299        .map(|name| ((*name).to_owned(), std::env::var(name).ok()))
2300        .collect();
2301    if let Ok(count_str) = std::env::var("GIT_CONFIG_COUNT") {
2302        // Mirror `add_environment_config_pairs`; absurd counts are not worth caching.
2303        const MAX_TRACKED: usize = 256;
2304        match count_str.parse::<usize>() {
2305            Ok(n) if n <= MAX_TRACKED => {
2306                for i in 0..n {
2307                    for var in [format!("GIT_CONFIG_KEY_{i}"), format!("GIT_CONFIG_VALUE_{i}")] {
2308                        let val = std::env::var(&var).ok();
2309                        fp.push((var, val));
2310                    }
2311                }
2312            }
2313            Ok(_) => return None,
2314            Err(_) => {}
2315        }
2316    }
2317    Some(fp)
2318}
2319
2320/// The on-disk files [`ConfigSet::load_with_options_uncached`] would consult,
2321/// in cascade order.
2322fn config_cascade_file_paths(git_dir: Option<&Path>, opts: &LoadConfigOptions) -> Vec<PathBuf> {
2323    let mut paths = Vec::new();
2324    if opts.include_system && !git_config_nosystem_enabled() {
2325        paths.push(
2326            std::env::var("GIT_CONFIG_SYSTEM")
2327                .map(PathBuf::from)
2328                .unwrap_or_else(|_| PathBuf::from("/etc/gitconfig")),
2329        );
2330    }
2331    paths.extend(global_config_paths());
2332    if let Some(gd) = git_dir {
2333        let common_dir = crate::repo::common_git_dir_for_config(gd);
2334        paths.push(common_dir.join("config"));
2335        // Stamped unconditionally: whether it is merged depends on
2336        // `extensions.worktreeConfig` in the (stamped) common config.
2337        paths.push(gd.join("config.worktree"));
2338        // `includeIf.onbranch:` conditions read HEAD; stamping it means a
2339        // branch switch invalidates cascades that could depend on it.
2340        paths.push(gd.join("HEAD"));
2341    }
2342    if let Some(cgd) = &opts.include_ctx.git_dir {
2343        if Some(cgd.as_path()) != git_dir {
2344            paths.push(cgd.join("HEAD"));
2345        }
2346    }
2347    if let Ok(p) = std::env::var("GIT_CONFIG") {
2348        paths.push(PathBuf::from(p));
2349    }
2350    paths
2351}
2352
2353fn stamp_for_path(path: &Path) -> Option<(SystemTime, u64)> {
2354    fs::metadata(path)
2355        .ok()
2356        .and_then(|m| Some((m.modified().ok()?, m.len())))
2357}
2358
2359fn stamp_paths(paths: Vec<PathBuf>) -> Vec<ConfigFileStamp> {
2360    paths
2361        .into_iter()
2362        .map(|path| {
2363            let stamp = stamp_for_path(&path);
2364            (path, stamp)
2365        })
2366        .collect()
2367}
2368
2369fn config_file_stamps(git_dir: Option<&Path>, opts: &LoadConfigOptions) -> Vec<ConfigFileStamp> {
2370    stamp_paths(config_cascade_file_paths(git_dir, opts))
2371}
2372
2373fn config_cache_lookup(
2374    key: &ConfigCacheKey,
2375    env_fp: &[(String, Option<String>)],
2376    base_stamps: &[ConfigFileStamp],
2377) -> Option<ConfigSet> {
2378    let cache = config_cache()
2379        .lock()
2380        .unwrap_or_else(std::sync::PoisonError::into_inner);
2381    let entry = cache.get(key)?;
2382    if entry.env_fingerprint.as_slice() != env_fp || entry.base_stamps.as_slice() != base_stamps {
2383        return None;
2384    }
2385    for (path, stamp) in &entry.extra_stamps {
2386        if stamp_for_path(path) != *stamp {
2387            return None;
2388        }
2389    }
2390    Some((*entry.set).clone())
2391}
2392
2393/// Drop every cached cascade that read `path` (called on in-process writes).
2394fn evict_config_cache_for_path(path: &Path) {
2395    let mut cache = config_cache()
2396        .lock()
2397        .unwrap_or_else(std::sync::PoisonError::into_inner);
2398    cache.retain(|_, entry| {
2399        !entry
2400            .base_stamps
2401            .iter()
2402            .chain(&entry.extra_stamps)
2403            .any(|(p, _)| p == path)
2404    });
2405}
2406
2407fn git_config_nosystem_enabled() -> bool {
2408    std::env::var("GIT_CONFIG_NOSYSTEM")
2409        .ok()
2410        .map(|value| parse_bool(&value).unwrap_or(true))
2411        .unwrap_or(false)
2412}
2413
2414fn add_environment_config_pairs(set: &mut ConfigSet) -> Result<()> {
2415    let Ok(count_str) = std::env::var("GIT_CONFIG_COUNT") else {
2416        return Ok(());
2417    };
2418    if count_str.is_empty() {
2419        return Ok(());
2420    }
2421
2422    let count = count_str
2423        .parse::<usize>()
2424        .map_err(|_| Error::ConfigError("bogus count in GIT_CONFIG_COUNT".to_owned()))?;
2425    if count > i32::MAX as usize {
2426        return Err(Error::ConfigError(
2427            "too many entries in GIT_CONFIG_COUNT".to_owned(),
2428        ));
2429    }
2430
2431    for i in 0..count {
2432        let key_var = format!("GIT_CONFIG_KEY_{i}");
2433        let value_var = format!("GIT_CONFIG_VALUE_{i}");
2434        let key = std::env::var(&key_var)
2435            .map_err(|_| Error::ConfigError(format!("missing config key {key_var}")))?;
2436        let value = std::env::var(&value_var)
2437            .map_err(|_| Error::ConfigError(format!("missing config value {value_var}")))?;
2438        set.add_command_override(&key, &value)?;
2439    }
2440
2441    Ok(())
2442}
2443
2444// ── Type coercion helpers ───────────────────────────────────────────
2445
2446/// Parse a Git boolean value.
2447///
2448/// Accepts: `true`, `yes`, `on`, `1` as true.
2449/// Accepts: `false`, `no`, `off`, `0` as false.
2450///
2451/// Note: bare config keys are represented as `None` in [`ConfigEntry`] and
2452/// are normalized to `"true"` by higher-level readers (`ConfigSet::get`).
2453/// An explicit empty assignment (`key =` with no value) is stored as `""` and
2454/// is treated as false for `--bool` / [`parse_bool`]. Bare keys are represented
2455/// as `None` and normalized to `"true"` by callers before reaching this parser.
2456pub fn parse_bool(s: &str) -> std::result::Result<bool, String> {
2457    match s.to_lowercase().as_str() {
2458        "true" | "yes" | "on" => Ok(true),
2459        "" => Ok(false),
2460        "false" | "no" | "off" => Ok(false),
2461        _ => {
2462            // Try parsing as Git's config integer syntax: 0 -> false, non-zero -> true.
2463            if let Ok(n) = parse_i64(s) {
2464                return Ok(n != 0);
2465            }
2466            Err(format!("bad boolean config value '{s}'"))
2467        }
2468    }
2469}
2470
2471/// Parse a Git integer value with optional `k`/`m`/`g` suffix.
2472pub fn parse_i64(s: &str) -> std::result::Result<i64, String> {
2473    let s = s.trim();
2474    if s.is_empty() {
2475        return Err("empty integer value".to_owned());
2476    }
2477
2478    let (num_str, multiplier) = match s.as_bytes().last() {
2479        Some(b'k' | b'K') => (&s[..s.len() - 1], 1024_i64),
2480        Some(b'm' | b'M') => (&s[..s.len() - 1], 1024 * 1024),
2481        Some(b'g' | b'G') => (&s[..s.len() - 1], 1024 * 1024 * 1024),
2482        _ => (s, 1_i64),
2483    };
2484
2485    let base: i64 = num_str
2486        .parse()
2487        .map_err(|_| format!("invalid integer: '{s}'"))?;
2488    base.checked_mul(multiplier)
2489        .ok_or_else(|| format!("integer overflow: '{s}'"))
2490}
2491
2492/// Why [`parse_git_config_int_strict`] failed (mirrors Git `errno` after `git_parse_signed`).
2493#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2494pub enum GitConfigIntStrictError {
2495    /// `EINVAL` — trailing junk, unknown unit suffix, or not a number.
2496    InvalidUnit,
2497    /// `ERANGE` — value does not fit in `i64` after scaling.
2498    OutOfRange,
2499}
2500
2501/// Parse a signed decimal integer with optional `k`/`m`/`g` multiplier suffix, requiring the
2502/// entire input (trimmed) to be consumed — same constraints as Git's `git_parse_signed` used by
2503/// `git_config_int` (so `no` and `1foo` are rejected, unlike [`parse_i64`]).
2504pub fn parse_git_config_int_strict(raw: &str) -> std::result::Result<i64, GitConfigIntStrictError> {
2505    let s = raw.trim();
2506    if s.is_empty() {
2507        return Err(GitConfigIntStrictError::InvalidUnit);
2508    }
2509
2510    let bytes = s.as_bytes();
2511    let mut idx = 0usize;
2512    if matches!(bytes.first(), Some(b'+') | Some(b'-')) {
2513        idx = 1;
2514    }
2515    if idx >= bytes.len() {
2516        return Err(GitConfigIntStrictError::InvalidUnit);
2517    }
2518    let digit_start = idx;
2519    while idx < bytes.len() && bytes[idx].is_ascii_digit() {
2520        idx += 1;
2521    }
2522    if idx == digit_start {
2523        return Err(GitConfigIntStrictError::InvalidUnit);
2524    }
2525
2526    let num_part =
2527        std::str::from_utf8(&bytes[..idx]).map_err(|_| GitConfigIntStrictError::InvalidUnit)?;
2528    let suffix =
2529        std::str::from_utf8(&bytes[idx..]).map_err(|_| GitConfigIntStrictError::InvalidUnit)?;
2530    let mult: i64 = match suffix {
2531        "" => 1,
2532        "k" | "K" => 1024,
2533        "m" | "M" => 1024 * 1024,
2534        "g" | "G" => 1024_i64
2535            .checked_mul(1024)
2536            .and_then(|x| x.checked_mul(1024))
2537            .ok_or(GitConfigIntStrictError::OutOfRange)?,
2538        _ => return Err(GitConfigIntStrictError::InvalidUnit),
2539    };
2540
2541    let val: i64 = num_part
2542        .parse()
2543        .map_err(|_| GitConfigIntStrictError::InvalidUnit)?;
2544    val.checked_mul(mult)
2545        .ok_or(GitConfigIntStrictError::OutOfRange)
2546}
2547
2548const DIFF_CONTEXT_KEY: &str = "diff.context";
2549
2550fn format_bad_numeric_diff_context(
2551    value: &str,
2552    err: GitConfigIntStrictError,
2553    entry: &ConfigEntry,
2554) -> String {
2555    let detail = match err {
2556        GitConfigIntStrictError::InvalidUnit => "invalid unit",
2557        GitConfigIntStrictError::OutOfRange => "out of range",
2558    };
2559    if entry.scope == ConfigScope::Command || entry.file.is_none() {
2560        return format!(
2561            "fatal: bad numeric config value '{value}' for '{DIFF_CONTEXT_KEY}': {detail}"
2562        );
2563    }
2564    let path = entry
2565        .file
2566        .as_deref()
2567        .map(config_error_path_display)
2568        .unwrap_or_default();
2569    format!("fatal: bad numeric config value '{value}' for '{DIFF_CONTEXT_KEY}' in file {path}: {detail}")
2570}
2571
2572fn format_bad_diff_context_variable(entry: &ConfigEntry) -> String {
2573    if entry.scope == ConfigScope::Command || entry.file.is_none() {
2574        return format!("fatal: unable to parse '{DIFF_CONTEXT_KEY}' from command-line config");
2575    }
2576    let path = entry
2577        .file
2578        .as_deref()
2579        .map(config_error_path_display)
2580        .unwrap_or_default();
2581    format!(
2582        "fatal: bad config variable '{DIFF_CONTEXT_KEY}' in file '{path}' at line {}",
2583        entry.line
2584    )
2585}
2586
2587/// Read `diff.context` from a loaded [`ConfigSet`] with Git-compatible validation.
2588///
2589/// Returns `Ok(None)` when the key is unset. When set, the value must be a non-negative integer
2590/// acceptable to Git's diff machinery (same rules as `git diff` / `git log -p`).
2591pub fn resolve_diff_context_lines(cfg: &ConfigSet) -> std::result::Result<Option<usize>, String> {
2592    let Some(entry) = cfg.get_last_entry(DIFF_CONTEXT_KEY) else {
2593        return Ok(None);
2594    };
2595    let value_src = entry.value.as_deref().unwrap_or("").trim();
2596    match parse_git_config_int_strict(value_src) {
2597        Ok(n) if n < 0 => Err(format_bad_diff_context_variable(&entry)),
2598        Ok(n) => Ok(Some(usize::try_from(n).map_err(|_| {
2599            format_bad_numeric_diff_context(value_src, GitConfigIntStrictError::OutOfRange, &entry)
2600        })?)),
2601        Err(e) => Err(format_bad_numeric_diff_context(value_src, e, &entry)),
2602    }
2603}
2604
2605/// Parse a Git color value and return the ANSI escape sequence.
2606///
2607/// Matches Git's `color_parse_mem` (`git/color.c`): whitespace-separated words,
2608/// optional leading `reset`, up to two color tokens (foreground then background),
2609/// then graphic rendition attributes. Attribute codes are accumulated as a
2610/// bitmask keyed by SGR number (so `bold` sets bit 1, `nobold` sets bit 22).
2611pub fn parse_color(s: &str) -> std::result::Result<String, String> {
2612    const COLOR_BACKGROUND_OFFSET: i32 = 10;
2613    const COLOR_FOREGROUND_ANSI: i32 = 30;
2614    const COLOR_FOREGROUND_RGB: i32 = 38;
2615    const COLOR_FOREGROUND_256: i32 = 38;
2616    const COLOR_FOREGROUND_BRIGHT_ANSI: i32 = 90;
2617
2618    #[derive(Clone, Copy, Default)]
2619    struct Color {
2620        kind: u8,
2621        value: u8,
2622        red: u8,
2623        green: u8,
2624        blue: u8,
2625    }
2626
2627    const COLOR_UNSPECIFIED: u8 = 0;
2628    const COLOR_NORMAL: u8 = 1;
2629    const COLOR_ANSI: u8 = 2;
2630    const COLOR_256: u8 = 3;
2631    const COLOR_RGB: u8 = 4;
2632
2633    fn color_empty(c: &Color) -> bool {
2634        c.kind == COLOR_UNSPECIFIED || c.kind == COLOR_NORMAL
2635    }
2636
2637    fn parse_ansi_color(name: &str) -> Option<Color> {
2638        let color_names = [
2639            "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
2640        ];
2641        let color_offset = COLOR_FOREGROUND_ANSI;
2642
2643        if name.eq_ignore_ascii_case("default") {
2644            return Some(Color {
2645                kind: COLOR_ANSI,
2646                value: (9 + color_offset) as u8,
2647                ..Default::default()
2648            });
2649        }
2650
2651        let (name, color_offset) = if name.len() >= 6 && name[..6].eq_ignore_ascii_case("bright") {
2652            (&name[6..], COLOR_FOREGROUND_BRIGHT_ANSI)
2653        } else {
2654            (name, COLOR_FOREGROUND_ANSI)
2655        };
2656
2657        for (i, cn) in color_names.iter().enumerate() {
2658            if name.eq_ignore_ascii_case(cn) {
2659                return Some(Color {
2660                    kind: COLOR_ANSI,
2661                    value: (i as i32 + color_offset) as u8,
2662                    ..Default::default()
2663                });
2664            }
2665        }
2666        None
2667    }
2668
2669    fn hex_val(b: u8) -> Option<u8> {
2670        match b {
2671            b'0'..=b'9' => Some(b - b'0'),
2672            b'a'..=b'f' => Some(b - b'a' + 10),
2673            b'A'..=b'F' => Some(b - b'A' + 10),
2674            _ => None,
2675        }
2676    }
2677
2678    fn get_hex_color(chars: &[u8], width: usize) -> Option<(u8, usize)> {
2679        assert!(width == 1 || width == 2);
2680        if chars.len() < width {
2681            return None;
2682        }
2683        let v = if width == 2 {
2684            let hi = hex_val(chars[0])?;
2685            let lo = hex_val(chars[1])?;
2686            (hi << 4) | lo
2687        } else {
2688            let n = hex_val(chars[0])?;
2689            (n << 4) | n
2690        };
2691        Some((v, width))
2692    }
2693
2694    fn parse_single_color(word: &str) -> Option<Color> {
2695        if word.eq_ignore_ascii_case("normal") {
2696            return Some(Color {
2697                kind: COLOR_NORMAL,
2698                ..Default::default()
2699            });
2700        }
2701
2702        let bytes = word.as_bytes();
2703        if (bytes.len() == 7 || bytes.len() == 4) && bytes.first() == Some(&b'#') {
2704            let width = if bytes.len() == 7 { 2 } else { 1 };
2705            let mut idx = 1;
2706            let (r, n1) = get_hex_color(&bytes[idx..], width)?;
2707            idx += n1;
2708            let (g, n2) = get_hex_color(&bytes[idx..], width)?;
2709            idx += n2;
2710            let (b, n3) = get_hex_color(&bytes[idx..], width)?;
2711            idx += n3;
2712            if idx != bytes.len() {
2713                return None;
2714            }
2715            return Some(Color {
2716                kind: COLOR_RGB,
2717                red: r,
2718                green: g,
2719                blue: b,
2720                ..Default::default()
2721            });
2722        }
2723
2724        if let Some(c) = parse_ansi_color(word) {
2725            return Some(c);
2726        }
2727
2728        let Ok(val) = word.parse::<i64>() else {
2729            return None;
2730        };
2731        if val < -1 {
2732            return None;
2733        }
2734        if val < 0 {
2735            return Some(Color {
2736                kind: COLOR_NORMAL,
2737                ..Default::default()
2738            });
2739        }
2740        if val < 8 {
2741            return Some(Color {
2742                kind: COLOR_ANSI,
2743                value: (val as i32 + COLOR_FOREGROUND_ANSI) as u8,
2744                ..Default::default()
2745            });
2746        }
2747        if val < 16 {
2748            return Some(Color {
2749                kind: COLOR_ANSI,
2750                value: (val as i32 - 8 + COLOR_FOREGROUND_BRIGHT_ANSI) as u8,
2751                ..Default::default()
2752            });
2753        }
2754        if val < 256 {
2755            return Some(Color {
2756                kind: COLOR_256,
2757                value: val as u8,
2758                ..Default::default()
2759            });
2760        }
2761        None
2762    }
2763
2764    fn parse_attr(word: &str) -> Option<u8> {
2765        const ATTRS: [(&str, u8, u8); 8] = [
2766            ("bold", 1, 22),
2767            ("dim", 2, 22),
2768            ("italic", 3, 23),
2769            ("ul", 4, 24),
2770            ("underline", 4, 24),
2771            ("blink", 5, 25),
2772            ("reverse", 7, 27),
2773            ("strike", 9, 29),
2774        ];
2775
2776        let mut negate = false;
2777        let mut rest = word;
2778        if let Some(stripped) = rest.strip_prefix("no") {
2779            negate = true;
2780            rest = stripped;
2781            if let Some(s) = rest.strip_prefix('-') {
2782                rest = s;
2783            }
2784        }
2785
2786        for (name, val, neg) in ATTRS {
2787            if rest == name {
2788                return Some(if negate { neg } else { val });
2789            }
2790        }
2791        None
2792    }
2793
2794    fn append_color_output(out: &mut String, c: &Color, background: bool) {
2795        let offset = if background {
2796            COLOR_BACKGROUND_OFFSET
2797        } else {
2798            0
2799        };
2800        match c.kind {
2801            COLOR_UNSPECIFIED | COLOR_NORMAL => {}
2802            COLOR_ANSI => {
2803                use std::fmt::Write;
2804                let _ = write!(out, "{}", i32::from(c.value) + offset);
2805            }
2806            COLOR_256 => {
2807                use std::fmt::Write;
2808                let _ = write!(out, "{};5;{}", COLOR_FOREGROUND_256 + offset, c.value);
2809            }
2810            COLOR_RGB => {
2811                use std::fmt::Write;
2812                let _ = write!(
2813                    out,
2814                    "{};2;{};{};{}",
2815                    COLOR_FOREGROUND_RGB + offset,
2816                    c.red,
2817                    c.green,
2818                    c.blue
2819                );
2820            }
2821            _ => {}
2822        }
2823    }
2824
2825    let s = s.trim();
2826    if s.is_empty() {
2827        return Ok(String::new());
2828    }
2829
2830    let mut has_reset = false;
2831    let mut attr: u64 = 0;
2832    let mut fg = Color::default();
2833    let mut bg = Color::default();
2834    fg.kind = COLOR_UNSPECIFIED;
2835    bg.kind = COLOR_UNSPECIFIED;
2836
2837    for word in s.split_whitespace() {
2838        if word.eq_ignore_ascii_case("reset") {
2839            has_reset = true;
2840            continue;
2841        }
2842
2843        if let Some(c) = parse_single_color(word) {
2844            if fg.kind == COLOR_UNSPECIFIED {
2845                fg = c;
2846                continue;
2847            }
2848            if bg.kind == COLOR_UNSPECIFIED {
2849                bg = c;
2850                continue;
2851            }
2852            return Err(format!("bad color value '{s}'"));
2853        }
2854
2855        if let Some(code) = parse_attr(word) {
2856            attr |= 1u64 << u64::from(code);
2857            continue;
2858        }
2859
2860        return Err(format!("bad color value '{s}'"));
2861    }
2862
2863    if !has_reset && attr == 0 && color_empty(&fg) && color_empty(&bg) {
2864        return Err(format!("bad color value '{s}'"));
2865    }
2866
2867    let mut out = String::from("\x1b[");
2868    let mut sep = if has_reset { 1u32 } else { 0u32 };
2869
2870    let mut attr_bits = attr;
2871    let mut i = 0u32;
2872    while attr_bits != 0 {
2873        let bit = 1u64 << i;
2874        if attr_bits & bit == 0 {
2875            i += 1;
2876            continue;
2877        }
2878        attr_bits &= !bit;
2879        if sep > 0 {
2880            out.push(';');
2881        }
2882        sep += 1;
2883        use std::fmt::Write;
2884        let _ = write!(out, "{i}");
2885        i += 1;
2886    }
2887
2888    if !color_empty(&fg) {
2889        if sep > 0 {
2890            out.push(';');
2891        }
2892        sep += 1;
2893        append_color_output(&mut out, &fg, false);
2894    }
2895    if !color_empty(&bg) {
2896        if sep > 0 {
2897            out.push(';');
2898        }
2899        append_color_output(&mut out, &bg, true);
2900    }
2901    out.push('m');
2902    Ok(out)
2903}
2904
2905#[derive(Debug, Clone)]
2906struct UrlParts {
2907    scheme: String,
2908    user: Option<String>,
2909    host: String,
2910    port: Option<String>,
2911    path: String,
2912}
2913
2914#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
2915struct UrlMatchScore {
2916    host_len: usize,
2917    path_len: usize,
2918    user_matched: bool,
2919}
2920
2921fn parse_config_url(url: &str) -> Option<UrlParts> {
2922    let (scheme, rest) = url.split_once("://")?;
2923    let (authority, path) = match rest.find('/') {
2924        Some(idx) => (&rest[..idx], &rest[idx..]),
2925        None => (rest, "/"),
2926    };
2927    let (user, host_port) = match authority.rsplit_once('@') {
2928        Some((user, host)) => (Some(user.to_owned()), host),
2929        None => (None, authority),
2930    };
2931    let (host, port) = match host_port.rsplit_once(':') {
2932        Some((host, port)) if !host.contains(']') => (host, Some(port.to_owned())),
2933        _ => (host_port, None),
2934    };
2935    Some(UrlParts {
2936        scheme: scheme.to_lowercase(),
2937        user,
2938        host: host.to_lowercase(),
2939        port,
2940        path: if path.is_empty() {
2941            "/".to_owned()
2942        } else {
2943            path.trim_end_matches('/').to_owned()
2944        },
2945    })
2946}
2947
2948fn host_matches(pattern: &str, target: &str) -> bool {
2949    let pattern_parts: Vec<&str> = pattern.split('.').collect();
2950    let target_parts: Vec<&str> = target.split('.').collect();
2951    pattern_parts.len() == target_parts.len()
2952        && pattern_parts
2953            .iter()
2954            .zip(target_parts)
2955            .all(|(pattern, target)| *pattern == "*" || *pattern == target)
2956}
2957
2958fn path_match_len(pattern: &str, target: &str) -> Option<usize> {
2959    let pattern = if pattern.is_empty() { "/" } else { pattern };
2960    let target = if target.is_empty() { "/" } else { target };
2961    if pattern == "/" {
2962        return Some(1);
2963    }
2964    let pattern = pattern.trim_end_matches('/');
2965    if target == pattern
2966        || target
2967            .strip_prefix(pattern)
2968            .is_some_and(|rest| rest.starts_with('/'))
2969    {
2970        Some(pattern.len() + 1)
2971    } else {
2972        None
2973    }
2974}
2975
2976fn url_match_score(pattern_url: &str, target_url: &str) -> Option<UrlMatchScore> {
2977    let pattern = parse_config_url(pattern_url)?;
2978    let target = parse_config_url(target_url)?;
2979    if pattern.scheme != target.scheme {
2980        return None;
2981    }
2982    let user_matched = match pattern.user.as_deref() {
2983        Some(user) if target.user.as_deref() == Some(user) => true,
2984        Some(_) => return None,
2985        None => false,
2986    };
2987    if !host_matches(&pattern.host, &target.host) || pattern.port != target.port {
2988        return None;
2989    }
2990    let path_len = path_match_len(&pattern.path, &target.path)?;
2991    Some(UrlMatchScore {
2992        host_len: pattern.host.len(),
2993        path_len,
2994        user_matched,
2995    })
2996}
2997
2998/// Match a URL against a URL pattern from config.
2999pub fn url_matches(pattern_url: &str, target_url: &str) -> bool {
3000    url_match_score(pattern_url, target_url).is_some()
3001}
3002
3003/// Get the best URL match for a specific key.
3004pub fn get_urlmatch_entries<'a>(
3005    entries: &'a [ConfigEntry],
3006    section: &str,
3007    variable: &str,
3008    url: &str,
3009) -> Vec<&'a ConfigEntry> {
3010    let section_lower = section.to_lowercase();
3011    let variable_lower = variable.to_lowercase();
3012    let mut matches: Vec<(UrlMatchScore, &'a ConfigEntry)> = Vec::new();
3013
3014    for entry in entries {
3015        let key = &entry.key;
3016        let first_dot = match key.find('.') {
3017            Some(i) => i,
3018            None => continue,
3019        };
3020        let last_dot = match key.rfind('.') {
3021            Some(i) => i,
3022            None => continue,
3023        };
3024        let entry_section = &key[..first_dot];
3025        let entry_variable = &key[last_dot + 1..];
3026        if entry_section.to_lowercase() != section_lower
3027            || entry_variable.to_lowercase() != variable_lower
3028        {
3029            continue;
3030        }
3031        if first_dot == last_dot {
3032            matches.push((
3033                UrlMatchScore {
3034                    host_len: 0,
3035                    path_len: 0,
3036                    user_matched: false,
3037                },
3038                entry,
3039            ));
3040        } else {
3041            let subsection = &key[first_dot + 1..last_dot];
3042            if let Some(score) = url_match_score(subsection, url) {
3043                matches.push((score, entry));
3044            }
3045        }
3046    }
3047    matches.sort_by_key(|a| a.0);
3048    matches.into_iter().map(|(_, e)| e).collect()
3049}
3050
3051/// Get all matching variables in a section for a given URL.
3052pub fn get_urlmatch_all_in_section(
3053    entries: &[ConfigEntry],
3054    section: &str,
3055    url: &str,
3056) -> Vec<(String, String, ConfigScope)> {
3057    let section_lower = section.to_lowercase();
3058    let mut matches: Vec<(String, UrlMatchScore, String, String, ConfigScope)> = Vec::new();
3059
3060    for entry in entries {
3061        let key = &entry.key;
3062        let first_dot = match key.find('.') {
3063            Some(i) => i,
3064            None => continue,
3065        };
3066        let last_dot = match key.rfind('.') {
3067            Some(i) => i,
3068            None => continue,
3069        };
3070        let entry_section = &key[..first_dot];
3071        if entry_section.to_lowercase() != section_lower {
3072            continue;
3073        }
3074        let entry_variable = &key[last_dot + 1..];
3075        let val = entry.value.as_deref().unwrap_or("");
3076        if first_dot == last_dot {
3077            let canonical = format!("{}.{}", section_lower, entry_variable);
3078            matches.push((
3079                entry_variable.to_lowercase(),
3080                UrlMatchScore {
3081                    host_len: 0,
3082                    path_len: 0,
3083                    user_matched: false,
3084                },
3085                val.to_owned(),
3086                canonical,
3087                entry.scope,
3088            ));
3089        } else {
3090            let subsection = &key[first_dot + 1..last_dot];
3091            if let Some(score) = url_match_score(subsection, url) {
3092                let canonical = format!("{}.{}", section_lower, entry_variable);
3093                matches.push((
3094                    entry_variable.to_lowercase(),
3095                    score,
3096                    val.to_owned(),
3097                    canonical,
3098                    entry.scope,
3099                ));
3100            }
3101        }
3102    }
3103
3104    let mut best: std::collections::BTreeMap<String, (UrlMatchScore, String, String, ConfigScope)> =
3105        std::collections::BTreeMap::new();
3106    for (var, specificity, val, canonical, scope) in matches {
3107        let entry = best.entry(var).or_insert((
3108            UrlMatchScore {
3109                host_len: 0,
3110                path_len: 0,
3111                user_matched: false,
3112            },
3113            String::new(),
3114            String::new(),
3115            scope,
3116        ));
3117        if specificity >= entry.0 {
3118            *entry = (specificity, val, canonical, scope);
3119        }
3120    }
3121    best.into_values()
3122        .map(|(_, val, canonical, scope)| (canonical, val, scope))
3123        .collect()
3124}
3125
3126/// Parse a Git path value (expand `~/` to home directory).
3127/// Parse a path value. Returns the resolved path string.
3128/// Does NOT handle :(optional) prefix — use `parse_path_optional` for that.
3129pub fn parse_path(s: &str) -> String {
3130    if let Some(rest) = s.strip_prefix("~/") {
3131        if let Some(home) = home_dir() {
3132            return home.join(rest).to_string_lossy().to_string();
3133        }
3134    }
3135    s.to_owned()
3136}
3137
3138/// Parse a path value that may have an `:(optional)` prefix.
3139///
3140/// Returns `Some(path)` if the path should be used, `None` if the path
3141/// is optional and does not exist (meaning the entry should be skipped).
3142pub fn parse_path_optional(s: &str) -> Option<String> {
3143    if let Some(rest) = s.strip_prefix(":(optional)") {
3144        let resolved = parse_path(rest);
3145        if std::path::Path::new(&resolved).exists() {
3146            Some(resolved)
3147        } else {
3148            None // optional and missing → skip
3149        }
3150    } else {
3151        Some(parse_path(s))
3152    }
3153}
3154
3155// ── Helpers ─────────────────────────────────────────────────────────
3156
3157/// Parse `GIT_CONFIG_PARAMETERS` payloads.
3158///
3159/// We support the common formats seen in tests and wrappers:
3160/// - single-quoted entries: `'key=value'`
3161/// - double-quoted entries: `"key=value"`
3162/// - unquoted `key=value` tokens separated by whitespace
3163///
3164/// Backslash escapes are interpreted minimally inside double quotes.
3165///
3166/// Return the last `key=value` assignment for `key` in a `GIT_CONFIG_PARAMETERS` payload.
3167///
3168/// Matches Git's command-line config layering: later tokens win. Keys are canonicalized the same
3169/// way as file-backed config (`fetch.output` and `FETCH.Output` both match `fetch.output`).
3170#[must_use]
3171pub fn git_config_parameters_last_value(raw: &str, key: &str) -> Option<String> {
3172    let Ok(canon) = canonical_key(key) else {
3173        return None;
3174    };
3175    let mut last: Option<String> = None;
3176    for entry in parse_config_parameters_strict(raw).ok()? {
3177        match entry {
3178            ConfigParameter::Pair { key, value } => {
3179                if canonical_key(key.trim()).ok().as_ref() == Some(&canon) {
3180                    last = Some(value.unwrap_or_else(|| "true".to_owned()));
3181                }
3182            }
3183            ConfigParameter::OldStyle(entry) => {
3184                if let Some((k, v)) = entry.split_once('=') {
3185                    if canonical_key(k.trim()).ok().as_ref() == Some(&canon) {
3186                        last = Some(v.to_owned());
3187                    }
3188                } else if canonical_key(entry.trim()).ok().as_ref() == Some(&canon) {
3189                    last = Some("true".to_owned());
3190                }
3191            }
3192        }
3193    }
3194    last
3195}
3196
3197#[derive(Debug, Clone, PartialEq, Eq)]
3198enum ConfigParameter {
3199    OldStyle(String),
3200    Pair { key: String, value: Option<String> },
3201}
3202
3203pub fn parse_config_parameters(raw: &str) -> Vec<String> {
3204    parse_config_parameters_strict(raw)
3205        .map(|entries| {
3206            entries
3207                .into_iter()
3208                .map(|entry| match entry {
3209                    ConfigParameter::OldStyle(entry) => entry,
3210                    ConfigParameter::Pair {
3211                        key,
3212                        value: Some(value),
3213                    } => format!("{key}\u{1}{value}"),
3214                    ConfigParameter::Pair { key, value: None } => format!("{key}\u{1}"),
3215                })
3216                .collect()
3217        })
3218        .unwrap_or_default()
3219}
3220
3221fn parse_config_parameters_strict(raw: &str) -> Result<Vec<ConfigParameter>> {
3222    let mut out: Vec<ConfigParameter> = Vec::new();
3223    let chars: Vec<char> = raw.chars().collect();
3224    let mut idx = skip_config_parameter_spaces(&chars, 0);
3225
3226    while idx < chars.len() {
3227        let (key, next) = sq_dequote_step_chars(&chars, idx)?;
3228        let Some(next_idx) = next else {
3229            out.push(ConfigParameter::OldStyle(key));
3230            break;
3231        };
3232
3233        if chars[next_idx].is_whitespace() {
3234            out.push(ConfigParameter::OldStyle(key));
3235            idx = skip_config_parameter_spaces(&chars, next_idx);
3236            continue;
3237        }
3238
3239        if chars[next_idx] != '=' {
3240            return Err(Error::ConfigError(
3241                "bogus format in GIT_CONFIG_PARAMETERS".to_owned(),
3242            ));
3243        }
3244
3245        let value_start = next_idx + 1;
3246        if value_start >= chars.len() || chars[value_start].is_whitespace() {
3247            out.push(ConfigParameter::Pair { key, value: None });
3248            idx = skip_config_parameter_spaces(&chars, value_start);
3249            continue;
3250        }
3251
3252        if chars[value_start] != '\'' {
3253            return Err(Error::ConfigError(
3254                "bogus format in GIT_CONFIG_PARAMETERS".to_owned(),
3255            ));
3256        }
3257        let (value, value_next) = sq_dequote_step_chars(&chars, value_start)?;
3258        if let Some(value_next) = value_next {
3259            if !chars[value_next].is_whitespace() {
3260                return Err(Error::ConfigError(
3261                    "bogus format in GIT_CONFIG_PARAMETERS".to_owned(),
3262                ));
3263            }
3264            idx = skip_config_parameter_spaces(&chars, value_next);
3265        } else {
3266            idx = chars.len();
3267        }
3268        out.push(ConfigParameter::Pair {
3269            key,
3270            value: Some(value),
3271        });
3272    }
3273
3274    Ok(out)
3275}
3276
3277fn skip_config_parameter_spaces(chars: &[char], mut idx: usize) -> usize {
3278    while idx < chars.len() && chars[idx].is_whitespace() {
3279        idx += 1;
3280    }
3281    idx
3282}
3283
3284fn sq_dequote_step_chars(chars: &[char], start: usize) -> Result<(String, Option<usize>)> {
3285    if chars.get(start) != Some(&'\'') {
3286        return Err(Error::ConfigError(
3287            "bogus format in GIT_CONFIG_PARAMETERS".to_owned(),
3288        ));
3289    }
3290
3291    let mut out = String::new();
3292    let mut idx = start + 1;
3293    loop {
3294        let Some(&ch) = chars.get(idx) else {
3295            return Err(Error::ConfigError(
3296                "bogus format in GIT_CONFIG_PARAMETERS".to_owned(),
3297            ));
3298        };
3299        if ch != '\'' {
3300            out.push(ch);
3301            idx += 1;
3302            continue;
3303        }
3304
3305        idx += 1;
3306        match chars.get(idx).copied() {
3307            None => return Ok((out, None)),
3308            Some('\\')
3309                if chars
3310                    .get(idx + 1)
3311                    .copied()
3312                    .is_some_and(needs_sq_backslash_quote)
3313                    && chars.get(idx + 2) == Some(&'\'') =>
3314            {
3315                if let Some(escaped) = chars.get(idx + 1) {
3316                    out.push(*escaped);
3317                }
3318                idx += 3;
3319            }
3320            _ => return Ok((out, Some(idx))),
3321        }
3322    }
3323}
3324
3325fn needs_sq_backslash_quote(ch: char) -> bool {
3326    ch == '\'' || ch == '!'
3327}
3328
3329/// Return candidate paths for the global config file, in priority order.
3330/// Public accessor for the ordered list of global config file paths.
3331pub fn global_config_paths_pub() -> Vec<PathBuf> {
3332    global_config_paths()
3333}
3334
3335fn global_config_paths() -> Vec<PathBuf> {
3336    let mut paths = Vec::new();
3337
3338    // $GIT_CONFIG_GLOBAL overrides
3339    if let Ok(p) = std::env::var("GIT_CONFIG_GLOBAL") {
3340        paths.push(PathBuf::from(p));
3341        return paths;
3342    }
3343
3344    // Git order: XDG `git/config` first, then `~/.gitconfig` (see `git_global_config_paths`).
3345    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
3346        paths.push(PathBuf::from(xdg).join("git/config"));
3347    } else if let Some(home) = home_dir() {
3348        paths.push(home.join(".config/git/config"));
3349    }
3350    if let Some(home) = home_dir() {
3351        paths.push(home.join(".gitconfig"));
3352    }
3353
3354    paths
3355}
3356
3357/// Return the user's home directory.
3358fn home_dir() -> Option<PathBuf> {
3359    std::env::var("HOME").ok().map(PathBuf::from)
3360}
3361
3362/// True when Git would treat the config source as `CONFIG_ORIGIN_FILE` for includes.
3363fn include_source_is_disk_file(file: &ConfigFile) -> bool {
3364    file.include_origin == ConfigIncludeOrigin::Disk
3365}
3366
3367/// Resolve an include file path (Git `handle_path_include` semantics).
3368///
3369/// Relative paths are only allowed when the including config came from a real on-disk file.
3370fn resolve_include_file_path(
3371    path: &str,
3372    file: &ConfigFile,
3373    ctx: &IncludeContext,
3374) -> Result<PathBuf> {
3375    let expanded = parse_path(path);
3376    let p = Path::new(&expanded);
3377    if p.is_absolute() {
3378        return Ok(p.to_path_buf());
3379    }
3380    if !include_source_is_disk_file(file) {
3381        if file.include_origin == ConfigIncludeOrigin::CommandLine {
3382            if ctx.command_line_relative_include_is_error {
3383                return Err(Error::ConfigError(
3384                    "relative config includes must come from files".to_owned(),
3385                ));
3386            }
3387            return Err(Error::ConfigError(String::new()));
3388        }
3389        return Err(Error::ConfigError(
3390            "relative config includes must come from files".to_owned(),
3391        ));
3392    }
3393    let base = match file.path.parent() {
3394        Some(p) if !p.as_os_str().is_empty() => p,
3395        Some(_) | None => Path::new("."),
3396    };
3397    Ok(base.join(p))
3398}
3399
3400fn is_dir_sep(b: u8) -> bool {
3401    b == b'/' || b == b'\\'
3402}
3403
3404fn add_trailing_starstar_for_dir(pat: &mut String) {
3405    let bytes = pat.as_bytes();
3406    if bytes.last().is_some_and(|&b| is_dir_sep(b)) {
3407        pat.push_str("**");
3408    }
3409}
3410
3411/// Prepare a `gitdir:` / `gitdir/i:` pattern (Git `prepare_include_condition_pattern`).
3412fn prepare_gitdir_pattern(condition: &str, file: &ConfigFile) -> Result<(String, usize)> {
3413    // Git `interpolate_path`: expand `~/` in the condition before pattern rules.
3414    let mut pat = parse_path(condition);
3415    if pat.starts_with("./") || pat.starts_with(".\\") {
3416        if !include_source_is_disk_file(file) {
3417            return Err(Error::ConfigError(
3418                "relative config include conditionals must come from files".to_owned(),
3419            ));
3420        }
3421        let parent = file.path.parent().ok_or_else(|| {
3422            Error::ConfigError(
3423                "relative config include conditionals must come from files".to_owned(),
3424            )
3425        })?;
3426        let real = parent.canonicalize().map_err(Error::Io)?;
3427        let mut dir = real.to_string_lossy().into_owned();
3428        if !dir.ends_with('/') && !dir.ends_with('\\') {
3429            dir.push('/');
3430        }
3431        let rest = &pat[2..];
3432        pat = format!("{dir}{rest}");
3433        let prefix_len = dir.len();
3434        add_trailing_starstar_for_dir(&mut pat);
3435        return Ok((pat, prefix_len));
3436    }
3437    let p = Path::new(&pat);
3438    if !p.is_absolute() {
3439        pat.insert_str(0, "**/");
3440    }
3441    add_trailing_starstar_for_dir(&mut pat);
3442    Ok((pat, 0))
3443}
3444
3445/// Git `include_by_gitdir` tries `strbuf_realpath` first, then `strbuf_add_absolute_path` if no match.
3446///
3447/// `text_abs` uses `$PWD` (which preserves symlinks) when available, matching Git's
3448/// `strbuf_add_absolute_path` behaviour. This lets `gitdir:bar/` match when `bar` is a symlink.
3449fn git_dir_match_texts(git_dir: &Path) -> (String, String) {
3450    let real = git_dir
3451        .canonicalize()
3452        .map(|p| p.to_string_lossy().into_owned())
3453        .unwrap_or_else(|_| git_dir.to_string_lossy().into_owned());
3454    // Build the non-canonical absolute path using $PWD (symlink-preserving) when available.
3455    // Git C uses `strbuf_add_absolute_path` which prefers $PWD over getcwd() to preserve symlinks.
3456    let abs = if git_dir.is_absolute() {
3457        // If git_dir is already canonical, try to reconstruct the symlink-preserving variant
3458        // by replacing the canonical cwd prefix with $PWD.
3459        let pwd_abs = std::env::var("PWD").ok().and_then(|pwd| {
3460            let pwd_path = std::path::Path::new(&pwd);
3461            if !pwd_path.is_absolute() {
3462                return None;
3463            }
3464            let pwd_canon = pwd_path.canonicalize().ok()?;
3465            let git_dir_str = git_dir.to_string_lossy();
3466            let pwd_canon_str = pwd_canon.to_string_lossy();
3467            // If git_dir starts with the canonical cwd, replace that prefix with $PWD
3468            let suffix = git_dir_str.strip_prefix(pwd_canon_str.as_ref())?;
3469            Some(format!("{pwd}{suffix}"))
3470        });
3471        pwd_abs.unwrap_or_else(|| git_dir.to_string_lossy().into_owned())
3472    } else if let Ok(cwd) = std::env::current_dir() {
3473        cwd.join(git_dir).to_string_lossy().into_owned()
3474    } else {
3475        git_dir.to_string_lossy().into_owned()
3476    };
3477    (real, abs)
3478}
3479
3480fn include_by_gitdir(
3481    condition: &str,
3482    file: &ConfigFile,
3483    ctx: &IncludeContext,
3484    icase: bool,
3485) -> bool {
3486    let Some(git_dir) = ctx.git_dir.as_ref() else {
3487        return false;
3488    };
3489    let (pattern, prefix) = match prepare_gitdir_pattern(condition, file) {
3490        Ok(x) => x,
3491        Err(_) => return false,
3492    };
3493    let flags = WM_PATHNAME | if icase { WM_CASEFOLD } else { 0 };
3494    let (text_real, text_abs) = git_dir_match_texts(git_dir);
3495    let try_match = |text: &str| -> bool {
3496        let t = text.as_bytes();
3497        let p = pattern.as_bytes();
3498        if prefix > 0 {
3499            if t.len() < prefix {
3500                return false;
3501            }
3502            let pre = &p[..prefix];
3503            let te = &t[..prefix];
3504            let ok = if icase {
3505                pre.eq_ignore_ascii_case(te)
3506            } else {
3507                pre == te
3508            };
3509            if !ok {
3510                return false;
3511            }
3512            return wildmatch(&p[prefix..], &t[prefix..], flags);
3513        }
3514        wildmatch(p, t, flags)
3515    };
3516    if try_match(&text_real) {
3517        return true;
3518    }
3519    text_real != text_abs && try_match(&text_abs)
3520}
3521
3522fn current_branch_short_name(git_dir: Option<&Path>) -> Option<String> {
3523    let gd = git_dir?;
3524    let target = refs::read_symbolic_ref(gd, "HEAD").ok()??;
3525    let rest = target.strip_prefix("refs/heads/")?;
3526    Some(rest.to_owned())
3527}
3528
3529fn include_by_onbranch(condition: &str, ctx: &IncludeContext) -> bool {
3530    let Some(short) = current_branch_short_name(ctx.git_dir.as_deref()) else {
3531        return false;
3532    };
3533    let mut pattern = condition.to_owned();
3534    add_trailing_starstar_for_dir(&mut pattern);
3535    wildmatch(pattern.as_bytes(), short.as_bytes(), WM_PATHNAME)
3536}
3537
3538fn is_remote_url_entry(entry: &ConfigEntry) -> bool {
3539    let Ok((section, subsection, variable)) = split_key(&entry.key) else {
3540        return false;
3541    };
3542    section == "remote" && subsection.is_some() && variable == "url"
3543}
3544
3545fn is_hasconfig_remote_url(condition: &str) -> bool {
3546    condition
3547        .strip_prefix("hasconfig:")
3548        .is_some_and(|rest| rest.starts_with("remote.*.url:"))
3549}
3550
3551fn include_by_hasconfig_remote_url(condition: &str, set: &ConfigSet, file: &ConfigFile) -> bool {
3552    let Some(pattern) = condition.strip_prefix("remote.*.url:") else {
3553        return false;
3554    };
3555    set.entries
3556        .iter()
3557        .chain(file.entries.iter())
3558        .filter(|entry| is_remote_url_entry(entry))
3559        .filter_map(|entry| entry.value.as_deref())
3560        .any(|value| wildmatch(pattern.as_bytes(), value.as_bytes(), WM_PATHNAME))
3561}
3562
3563fn validate_hasconfig_remote_url_include(
3564    file: &ConfigFile,
3565    process_includes: bool,
3566    depth: usize,
3567    ctx: &IncludeContext,
3568) -> Result<()> {
3569    const MAX_INCLUDE_DEPTH: usize = 10;
3570    if depth > MAX_INCLUDE_DEPTH {
3571        return Err(Error::ConfigError(
3572            "exceeded maximum include depth".to_owned(),
3573        ));
3574    }
3575    if file.entries.iter().any(is_remote_url_entry) {
3576        return Err(Error::Message(
3577            "fatal: remote URLs cannot be configured in file directly or indirectly included by includeIf.hasconfig:remote.*.url"
3578                .to_owned(),
3579        ));
3580    }
3581    if !process_includes {
3582        return Ok(());
3583    }
3584    for entry in &file.entries {
3585        let Some((inc_path, condition)) = include_directive_for_entry(entry) else {
3586            continue;
3587        };
3588        if let Some(ref cond) = condition {
3589            if !evaluate_include_condition(cond, &ConfigSet::new(), file, ctx) {
3590                continue;
3591            }
3592        }
3593        let resolved = match resolve_include_file_path(&inc_path, file, ctx) {
3594            Ok(p) => p,
3595            Err(Error::ConfigError(msg)) if msg.is_empty() => continue,
3596            Err(e) => return Err(e),
3597        };
3598        if let Some(inc_file) = ConfigFile::from_path(&resolved, file.scope)? {
3599            validate_hasconfig_remote_url_include(&inc_file, process_includes, depth + 1, ctx)?;
3600        }
3601    }
3602    Ok(())
3603}
3604
3605/// Evaluate an `[includeIf]` condition.
3606///
3607/// Supports `gitdir:`, `gitdir/i:`, `onbranch:`, and `hasconfig:remote.*.url:` like Git.
3608/// Unknown prefixes are false.
3609fn evaluate_include_condition(
3610    condition: &str,
3611    set: &ConfigSet,
3612    file: &ConfigFile,
3613    ctx: &IncludeContext,
3614) -> bool {
3615    if let Some(rest) = condition.strip_prefix("gitdir/i:") {
3616        return include_by_gitdir(rest, file, ctx, true);
3617    }
3618    if let Some(rest) = condition.strip_prefix("gitdir:") {
3619        return include_by_gitdir(rest, file, ctx, false);
3620    }
3621    if let Some(rest) = condition.strip_prefix("onbranch:") {
3622        return include_by_onbranch(rest, ctx);
3623    }
3624    if let Some(rest) = condition.strip_prefix("hasconfig:") {
3625        return include_by_hasconfig_remote_url(rest, set, file);
3626    }
3627    false
3628}
3629
3630/// Split a canonical key into (section, subsection, variable).
3631fn split_key(key: &str) -> Result<(String, Option<String>, String)> {
3632    let first_dot = key
3633        .find('.')
3634        .ok_or_else(|| Error::ConfigError(format!("invalid key: '{key}'")))?;
3635    let last_dot = key
3636        .rfind('.')
3637        .ok_or_else(|| Error::ConfigError(format!("invalid key: '{key}'")))?;
3638
3639    let section = key[..first_dot].to_owned();
3640    let variable = key[last_dot + 1..].to_owned();
3641
3642    let subsection = if first_dot == last_dot {
3643        None
3644    } else {
3645        Some(key[first_dot + 1..last_dot].to_owned())
3646    };
3647
3648    Ok((section, subsection, variable))
3649}
3650
3651/// Extract the variable name from a canonical key.
3652#[allow(dead_code)]
3653fn variable_name_from_key(key: &str) -> &str {
3654    match key.rfind('.') {
3655        Some(i) => &key[i + 1..],
3656        None => key,
3657    }
3658}
3659
3660/// Parse a section name that may contain a subsection (e.g. `"remote.origin"`).
3661///
3662/// Returns (section, subsection).
3663fn parse_section_name(name: &str) -> (&str, Option<&str>) {
3664    match name.find('.') {
3665        Some(i) => (&name[..i], Some(&name[i + 1..])),
3666        None => (name, None),
3667    }
3668}
3669
3670fn section_matches(parser: &Parser, section_lower: &str, subsection: Option<&str>) -> bool {
3671    if parser.section.to_lowercase() == section_lower && parser.subsection.as_deref() == subsection
3672    {
3673        return true;
3674    }
3675    let Some(subsection) = subsection else {
3676        return false;
3677    };
3678    parser.subsection.is_none()
3679        && parser.section.to_lowercase() == format!("{section_lower}.{}", subsection.to_lowercase())
3680}
3681
3682fn validate_section_name(section: &str, subsection: Option<&str>) -> Result<()> {
3683    if section.is_empty()
3684        || !section
3685            .chars()
3686            .all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
3687        || subsection.is_some_and(str::is_empty)
3688    {
3689        return Err(Error::ConfigError(format!(
3690            "invalid section name: {section}"
3691        )));
3692    }
3693    Ok(())
3694}
3695
3696/// Extract the original-case variable name from a raw (user-typed) key.
3697///
3698/// E.g. `"Section.Movie"` → `"Movie"`, `"a.b.CamelCase"` → `"CamelCase"`.
3699fn raw_variable_name(raw_key: &str) -> &str {
3700    match raw_key.rfind('.') {
3701        Some(i) => &raw_key[i + 1..],
3702        None => raw_key,
3703    }
3704}
3705
3706/// Extract the original-case section and subsection from a raw (user-typed) key.
3707///
3708/// E.g. `"Section.key"` → `("Section", None)`,
3709///      `"Remote.origin.url"` → `("Remote", Some("origin"))`.
3710fn raw_section_parts(raw_key: &str) -> (String, Option<String>) {
3711    let first_dot = match raw_key.find('.') {
3712        Some(i) => i,
3713        None => return (raw_key.to_owned(), None),
3714    };
3715    // rfind always succeeds here since we already found at least one dot above.
3716    let last_dot = match raw_key.rfind('.') {
3717        Some(i) => i,
3718        None => return (raw_key[..first_dot].to_owned(), None),
3719    };
3720    let section = raw_key[..first_dot].to_owned();
3721    if first_dot == last_dot {
3722        (section, None)
3723    } else {
3724        let subsection = raw_key[first_dot + 1..last_dot].to_owned();
3725        (section, Some(subsection))
3726    }
3727}
3728
3729/// Check if a raw line is a section header that also contains an inline key=value.
3730fn is_section_header_with_inline_entry(line: &str) -> bool {
3731    let trimmed = line.trim();
3732    if !trimmed.starts_with('[') {
3733        return false;
3734    }
3735    let end = match trimmed.find(']') {
3736        Some(i) => i,
3737        None => return false,
3738    };
3739    let after = trimmed[end + 1..].trim();
3740    // Has non-comment content after the ]
3741    !after.is_empty() && !after.starts_with('#') && !after.starts_with(';')
3742}
3743
3744/// Extract just the section header portion (up to and including `]` and any
3745/// comment after it, but not any inline key=value) from a raw line.
3746fn extract_section_header(line: &str) -> String {
3747    let trimmed = line.trim();
3748    let end = match trimmed.find(']') {
3749        Some(i) => i,
3750        None => return line.to_owned(),
3751    };
3752    // Preserve any comment on the section header itself (between ] and key),
3753    // but git doesn't really do this. Just return up to ].
3754    trimmed[..=end].to_owned()
3755}
3756
3757#[cfg(test)]
3758mod get_regexp_tests {
3759    use super::{ConfigFile, ConfigScope, ConfigSet};
3760    use std::path::Path;
3761
3762    fn set_from_snippet(text: &str) -> ConfigSet {
3763        let path = Path::new(".git/config");
3764        let file = ConfigFile::parse(path, text, ConfigScope::Local).expect("parse config snippet");
3765        let mut set = ConfigSet::new();
3766        set.merge(&file);
3767        set
3768    }
3769
3770    #[test]
3771    fn get_regexp_matches_section_prefix_like_git_config() {
3772        let text = r#"
3773[user]
3774    email = alice@example.com
3775    name = Alice
3776[core]
3777    bare = false
3778"#;
3779        let set = set_from_snippet(text);
3780        let keys: Vec<_> = set
3781            .get_regexp("user")
3782            .expect("valid pattern")
3783            .into_iter()
3784            .map(|e| e.key.as_str())
3785            .collect();
3786        assert!(keys.contains(&"user.email"));
3787        assert!(keys.contains(&"user.name"));
3788        assert!(!keys.iter().any(|k| k.starts_with("core.")));
3789    }
3790
3791    #[test]
3792    fn get_regexp_returns_all_multi_value_entries_in_order() {
3793        let text = r#"
3794[remote "origin"]
3795    url = https://example.com/repo.git
3796    fetch = +refs/heads/*:refs/remotes/origin/*
3797    push = +refs/heads/main:refs/heads/main
3798    push = +refs/heads/develop:refs/heads/develop
3799"#;
3800        let set = set_from_snippet(text);
3801        let matches = set.get_regexp("remote.origin").expect("valid pattern");
3802        let push_vals: Vec<_> = matches
3803            .iter()
3804            .filter(|e| e.key == "remote.origin.push")
3805            .map(|e| e.value.as_deref().unwrap_or(""))
3806            .collect();
3807        assert_eq!(push_vals.len(), 2);
3808        assert_eq!(push_vals[0], "+refs/heads/main:refs/heads/main");
3809        assert_eq!(push_vals[1], "+refs/heads/develop:refs/heads/develop");
3810    }
3811
3812    #[test]
3813    fn get_regexp_dot_matches_any_key() {
3814        let text = r#"
3815[a]
3816    x = 1
3817[b]
3818    y = 2
3819"#;
3820        let set = set_from_snippet(text);
3821        let m = set.get_regexp(".").expect("valid pattern");
3822        assert_eq!(m.len(), 2);
3823    }
3824
3825    #[test]
3826    fn get_regexp_no_match_returns_empty_vec() {
3827        let set = set_from_snippet("[user]\n\tname = x\n");
3828        let m = set.get_regexp("zzz").expect("valid pattern");
3829        assert!(m.is_empty());
3830    }
3831
3832    #[test]
3833    fn get_regexp_invalid_pattern_is_error() {
3834        let set = set_from_snippet("[user]\n\tname = x\n");
3835        let err = set.get_regexp("(").expect_err("unclosed group");
3836        assert!(err.contains("invalid key pattern"), "got: {err}");
3837    }
3838}
3839
3840#[cfg(test)]
3841mod pack_compression_tests {
3842    use super::{ConfigFile, ConfigScope, ConfigSet};
3843    use std::path::Path;
3844
3845    fn set_from_snippet(text: &str) -> ConfigSet {
3846        let path = Path::new(".git/config");
3847        let file = ConfigFile::parse(path, text, ConfigScope::Local).expect("parse config snippet");
3848        let mut set = ConfigSet::new();
3849        set.merge(&file);
3850        set
3851    }
3852
3853    #[test]
3854    fn pack_objects_zlib_level_defaults_to_six() {
3855        let set = ConfigSet::new();
3856        assert_eq!(set.pack_objects_zlib_level().unwrap(), 6);
3857    }
3858
3859    #[test]
3860    fn pack_objects_zlib_level_core_compression() {
3861        let set = set_from_snippet("[core]\n\tcompression = 0\n");
3862        assert_eq!(set.pack_objects_zlib_level().unwrap(), 0);
3863        let set = set_from_snippet("[core]\n\tcompression = 9\n");
3864        assert_eq!(set.pack_objects_zlib_level().unwrap(), 9);
3865    }
3866
3867    #[test]
3868    fn pack_objects_zlib_level_pack_overrides_core() {
3869        let set = set_from_snippet("[core]\n\tcompression = 9\n[pack]\n\tcompression = 0\n");
3870        assert_eq!(set.pack_objects_zlib_level().unwrap(), 0);
3871        let set = set_from_snippet("[core]\n\tcompression = 0\n[pack]\n\tcompression = 9\n");
3872        assert_eq!(set.pack_objects_zlib_level().unwrap(), 9);
3873    }
3874
3875    #[test]
3876    fn pack_objects_zlib_level_later_core_does_not_override_earlier_pack() {
3877        let mut set = ConfigSet::new();
3878        set.merge(
3879            &ConfigFile::parse(
3880                Path::new("a"),
3881                "[pack]\n\tcompression = 9\n",
3882                ConfigScope::Local,
3883            )
3884            .unwrap(),
3885        );
3886        set.merge(
3887            &ConfigFile::parse(
3888                Path::new("b"),
3889                "[core]\n\tcompression = 0\n",
3890                ConfigScope::Local,
3891            )
3892            .unwrap(),
3893        );
3894        assert_eq!(set.pack_objects_zlib_level().unwrap(), 9);
3895    }
3896
3897    #[test]
3898    fn pack_objects_zlib_level_loosecompression_does_not_block_core_pack_level() {
3899        let set = set_from_snippet("[core]\n\tloosecompression = 1\n\tcompression = 0\n");
3900        assert_eq!(set.pack_objects_zlib_level().unwrap(), 0);
3901    }
3902
3903    #[test]
3904    fn pack_objects_zlib_level_pack_wins_after_loose_and_core() {
3905        let set = set_from_snippet(
3906            "[core]\n\tloosecompression = 1\n\tcompression = 0\n[pack]\n\tcompression = 9\n",
3907        );
3908        assert_eq!(set.pack_objects_zlib_level().unwrap(), 9);
3909    }
3910}
3911
3912#[cfg(test)]
3913mod config_cache_tests {
3914    use super::*;
3915    use filetime::FileTime;
3916
3917    fn local_opts(git_dir: &Path) -> LoadConfigOptions {
3918        let mut opts = LoadConfigOptions::default();
3919        opts.include_system = false;
3920        opts.include_ctx.git_dir = Some(git_dir.to_path_buf());
3921        opts
3922    }
3923
3924    fn load_value(git_dir: &Path) -> Option<String> {
3925        let opts = local_opts(git_dir);
3926        let set = ConfigSet::load_with_options(Some(git_dir), &opts).expect("load cascade");
3927        set.get("gritcachetest.value")
3928    }
3929
3930    fn mtime_of(path: &Path) -> FileTime {
3931        FileTime::from_last_modification_time(&fs::metadata(path).expect("stat config"))
3932    }
3933
3934    fn restore_mtime(path: &Path, stamp: FileTime) {
3935        filetime::set_file_mtime(path, stamp).expect("restore mtime");
3936    }
3937
3938    #[test]
3939    fn cache_serves_same_stamp_and_config_write_evicts() {
3940        let td = tempfile::tempdir().expect("tempdir");
3941        let gd = td.path();
3942        let cfg = gd.join("config");
3943        fs::write(&cfg, "[gritcachetest]\n\tvalue = aaa\n").expect("write v1");
3944        let t0 = mtime_of(&cfg);
3945        assert_eq!(load_value(gd).as_deref(), Some("aaa"));
3946
3947        // Same size + restored mtime: indistinguishable by stat, so the cache
3948        // serves the old parse. (This is the window `ConfigFile::write`
3949        // eviction closes for in-process writers; C git would not re-read at
3950        // all.) This assertion is what proves the cache is actually used.
3951        fs::write(&cfg, "[gritcachetest]\n\tvalue = bbb\n").expect("write v2");
3952        restore_mtime(&cfg, t0);
3953        assert_eq!(load_value(gd).as_deref(), Some("aaa"));
3954
3955        // An in-process `ConfigFile::write` evicts even with identical stamps.
3956        let mut file = ConfigFile::from_path(&cfg, ConfigScope::Local)
3957            .expect("read config")
3958            .expect("config exists");
3959        file.set("gritcachetest.value", "ccc").expect("set value");
3960        file.write().expect("persist");
3961        restore_mtime(&cfg, t0);
3962        assert_eq!(load_value(gd).as_deref(), Some("ccc"));
3963    }
3964
3965    #[test]
3966    fn cache_invalidates_on_size_or_existence_change() {
3967        let td = tempfile::tempdir().expect("tempdir");
3968        let gd = td.path();
3969        let cfg = gd.join("config");
3970        // Absent local config is cached as "absent"...
3971        assert_eq!(load_value(gd), None);
3972        // ...and creating the file is a stamp change (None -> Some).
3973        fs::write(&cfg, "[gritcachetest]\n\tvalue = first\n").expect("create");
3974        assert_eq!(load_value(gd).as_deref(), Some("first"));
3975        // A size change invalidates even with a restored mtime.
3976        let t0 = mtime_of(&cfg);
3977        fs::write(&cfg, "[gritcachetest]\n\tvalue = second-longer\n").expect("rewrite");
3978        restore_mtime(&cfg, t0);
3979        assert_eq!(load_value(gd).as_deref(), Some("second-longer"));
3980    }
3981
3982    #[test]
3983    fn include_targets_are_stamped_and_invalidate() {
3984        let td = tempfile::tempdir().expect("tempdir");
3985        let gd = td.path();
3986        fs::write(gd.join("config"), "[include]\n\tpath = extra.conf\n").expect("write parent");
3987        let inc = gd.join("extra.conf");
3988        fs::write(&inc, "[gritcachetest]\n\tvalue = one\n").expect("write include v1");
3989        assert_eq!(load_value(gd).as_deref(), Some("one"));
3990
3991        // Same-size + restored-mtime rewrite of only the included file: the
3992        // parse result is served from cache (proving the include target is
3993        // stamped rather than re-read)...
3994        let t0 = mtime_of(&inc);
3995        fs::write(&inc, "[gritcachetest]\n\tvalue = two\n").expect("write include v2");
3996        restore_mtime(&inc, t0);
3997        assert_eq!(load_value(gd).as_deref(), Some("one"));
3998
3999        // ...while a stat-visible change to the included file invalidates.
4000        fs::write(&inc, "[gritcachetest]\n\tvalue = two-longer\n").expect("write include v3");
4001        assert_eq!(load_value(gd).as_deref(), Some("two-longer"));
4002    }
4003
4004    #[test]
4005    fn missing_include_target_is_watched() {
4006        let td = tempfile::tempdir().expect("tempdir");
4007        let gd = td.path();
4008        fs::write(gd.join("config"), "[include]\n\tpath = extra.conf\n").expect("write parent");
4009        assert_eq!(load_value(gd), None);
4010
4011        // The resolved-but-missing target was stamped as absent; creating it
4012        // must invalidate the cached parse.
4013        fs::write(gd.join("extra.conf"), "[gritcachetest]\n\tvalue = born\n").expect("create");
4014        assert_eq!(load_value(gd).as_deref(), Some("born"));
4015    }
4016
4017    #[test]
4018    fn onbranch_condition_follows_head() {
4019        let td = tempfile::tempdir().expect("tempdir");
4020        let gd = td.path();
4021        fs::write(gd.join("HEAD"), "ref: refs/heads/main\n").expect("write HEAD");
4022        fs::write(
4023            gd.join("config"),
4024            "[includeIf \"onbranch:main\"]\n\tpath = branch.conf\n",
4025        )
4026        .expect("write config");
4027        fs::write(gd.join("branch.conf"), "[gritcachetest]\n\tvalue = onmain\n")
4028            .expect("write branch config");
4029        assert_eq!(load_value(gd).as_deref(), Some("onmain"));
4030
4031        // Switching branches rewrites HEAD; the stamped HEAD must invalidate
4032        // the cached cascade so the condition is re-evaluated.
4033        fs::write(gd.join("HEAD"), "ref: refs/heads/dev\n").expect("rewrite HEAD");
4034        assert_eq!(load_value(gd), None);
4035    }
4036}