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