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