/ utils / git / gitConfigParser.ts
gitConfigParser.ts
  1  /**
  2   * Lightweight parser for .git/config files.
  3   *
  4   * Verified against git's config.c:
  5   *   - Section names: case-insensitive, alphanumeric + hyphen
  6   *   - Subsection names (quoted): case-sensitive, backslash escapes (\\ and \")
  7   *   - Key names: case-insensitive, alphanumeric + hyphen
  8   *   - Values: optional quoting, inline comments (# or ;), backslash escapes
  9   */
 10  
 11  import { readFile } from 'fs/promises'
 12  import { join } from 'path'
 13  
 14  /**
 15   * Parse a single value from .git/config.
 16   * Finds the first matching key under the given section/subsection.
 17   */
 18  export async function parseGitConfigValue(
 19    gitDir: string,
 20    section: string,
 21    subsection: string | null,
 22    key: string,
 23  ): Promise<string | null> {
 24    try {
 25      const config = await readFile(join(gitDir, 'config'), 'utf-8')
 26      return parseConfigString(config, section, subsection, key)
 27    } catch {
 28      return null
 29    }
 30  }
 31  
 32  /**
 33   * Parse a config value from an in-memory config string.
 34   * Exported for testing.
 35   */
 36  export function parseConfigString(
 37    config: string,
 38    section: string,
 39    subsection: string | null,
 40    key: string,
 41  ): string | null {
 42    const lines = config.split('\n')
 43    const sectionLower = section.toLowerCase()
 44    const keyLower = key.toLowerCase()
 45  
 46    let inSection = false
 47    for (const line of lines) {
 48      const trimmed = line.trim()
 49  
 50      // Skip empty lines and comment-only lines
 51      if (trimmed.length === 0 || trimmed[0] === '#' || trimmed[0] === ';') {
 52        continue
 53      }
 54  
 55      // Section header
 56      if (trimmed[0] === '[') {
 57        inSection = matchesSectionHeader(trimmed, sectionLower, subsection)
 58        continue
 59      }
 60  
 61      if (!inSection) {
 62        continue
 63      }
 64  
 65      // Key-value line: find the key name
 66      const parsed = parseKeyValue(trimmed)
 67      if (parsed && parsed.key.toLowerCase() === keyLower) {
 68        return parsed.value
 69      }
 70    }
 71  
 72    return null
 73  }
 74  
 75  /**
 76   * Parse a key = value line. Returns null if the line doesn't contain a valid key.
 77   */
 78  function parseKeyValue(line: string): { key: string; value: string } | null {
 79    // Read key: alphanumeric + hyphen, starting with alpha
 80    let i = 0
 81    while (i < line.length && isKeyChar(line[i]!)) {
 82      i++
 83    }
 84    if (i === 0) {
 85      return null
 86    }
 87    const key = line.slice(0, i)
 88  
 89    // Skip whitespace
 90    while (i < line.length && (line[i] === ' ' || line[i] === '\t')) {
 91      i++
 92    }
 93  
 94    // Must have '='
 95    if (i >= line.length || line[i] !== '=') {
 96      // Boolean key with no value — not relevant for our use cases
 97      return null
 98    }
 99    i++ // skip '='
100  
101    // Skip whitespace after '='
102    while (i < line.length && (line[i] === ' ' || line[i] === '\t')) {
103      i++
104    }
105  
106    const value = parseValue(line, i)
107    return { key, value }
108  }
109  
110  /**
111   * Parse a config value starting at position i.
112   * Handles quoted strings, escape sequences, and inline comments.
113   */
114  function parseValue(line: string, start: number): string {
115    let result = ''
116    let inQuote = false
117    let i = start
118  
119    while (i < line.length) {
120      const ch = line[i]!
121  
122      // Inline comments outside quotes end the value
123      if (!inQuote && (ch === '#' || ch === ';')) {
124        break
125      }
126  
127      if (ch === '"') {
128        inQuote = !inQuote
129        i++
130        continue
131      }
132  
133      if (ch === '\\' && i + 1 < line.length) {
134        const next = line[i + 1]!
135        if (inQuote) {
136          // Inside quotes: recognize escape sequences
137          switch (next) {
138            case 'n':
139              result += '\n'
140              break
141            case 't':
142              result += '\t'
143              break
144            case 'b':
145              result += '\b'
146              break
147            case '"':
148              result += '"'
149              break
150            case '\\':
151              result += '\\'
152              break
153            default:
154              // Git silently drops the backslash for unknown escapes
155              result += next
156              break
157          }
158          i += 2
159          continue
160        }
161        // Outside quotes: backslash at end of line = continuation (we don't
162        // handle multi-line since we split on \n, but handle \\ and others)
163        if (next === '\\') {
164          result += '\\'
165          i += 2
166          continue
167        }
168        // Fallthrough — treat backslash literally outside quotes
169      }
170  
171      result += ch
172      i++
173    }
174  
175    // Trim trailing whitespace from unquoted portions.
176    // Git trims trailing whitespace that isn't inside quotes, but since we
177    // process char-by-char and quotes toggle, the simplest correct approach
178    // for single-line values is to trim the result when not ending in a quote.
179    if (!inQuote) {
180      result = trimTrailingWhitespace(result)
181    }
182  
183    return result
184  }
185  
186  function trimTrailingWhitespace(s: string): string {
187    let end = s.length
188    while (end > 0 && (s[end - 1] === ' ' || s[end - 1] === '\t')) {
189      end--
190    }
191    return s.slice(0, end)
192  }
193  
194  /**
195   * Check if a config line like `[remote "origin"]` matches the given section/subsection.
196   * Section matching is case-insensitive; subsection matching is case-sensitive.
197   */
198  function matchesSectionHeader(
199    line: string,
200    sectionLower: string,
201    subsection: string | null,
202  ): boolean {
203    // line starts with '['
204    let i = 1
205  
206    // Read section name
207    while (
208      i < line.length &&
209      line[i] !== ']' &&
210      line[i] !== ' ' &&
211      line[i] !== '\t' &&
212      line[i] !== '"'
213    ) {
214      i++
215    }
216    const foundSection = line.slice(1, i).toLowerCase()
217  
218    if (foundSection !== sectionLower) {
219      return false
220    }
221  
222    if (subsection === null) {
223      // Simple section: must end with ']'
224      return i < line.length && line[i] === ']'
225    }
226  
227    // Skip whitespace before subsection quote
228    while (i < line.length && (line[i] === ' ' || line[i] === '\t')) {
229      i++
230    }
231  
232    // Must have opening quote
233    if (i >= line.length || line[i] !== '"') {
234      return false
235    }
236    i++ // skip opening quote
237  
238    // Read subsection — case-sensitive, handle \\ and \" escapes
239    let foundSubsection = ''
240    while (i < line.length && line[i] !== '"') {
241      if (line[i] === '\\' && i + 1 < line.length) {
242        const next = line[i + 1]!
243        if (next === '\\' || next === '"') {
244          foundSubsection += next
245          i += 2
246          continue
247        }
248        // Git drops the backslash for other escapes in subsections
249        foundSubsection += next
250        i += 2
251        continue
252      }
253      foundSubsection += line[i]
254      i++
255    }
256  
257    // Must have closing quote followed by ']'
258    if (i >= line.length || line[i] !== '"') {
259      return false
260    }
261    i++ // skip closing quote
262  
263    if (i >= line.length || line[i] !== ']') {
264      return false
265    }
266  
267    return foundSubsection === subsection
268  }
269  
270  function isKeyChar(ch: string): boolean {
271    return (
272      (ch >= 'a' && ch <= 'z') ||
273      (ch >= 'A' && ch <= 'Z') ||
274      (ch >= '0' && ch <= '9') ||
275      ch === '-'
276    )
277  }