278 lines
6.5 KiB
TypeScript
278 lines
6.5 KiB
TypeScript
/**
|
|
* Lightweight parser for .git/config files.
|
|
*
|
|
* Verified against git's config.c:
|
|
* - Section names: case-insensitive, alphanumeric + hyphen
|
|
* - Subsection names (quoted): case-sensitive, backslash escapes (\\ and \")
|
|
* - Key names: case-insensitive, alphanumeric + hyphen
|
|
* - Values: optional quoting, inline comments (# or ;), backslash escapes
|
|
*/
|
|
|
|
import { readFile } from 'fs/promises'
|
|
import { join } from 'path'
|
|
|
|
/**
|
|
* Parse a single value from .git/config.
|
|
* Finds the first matching key under the given section/subsection.
|
|
*/
|
|
export async function parseGitConfigValue(
|
|
gitDir: string,
|
|
section: string,
|
|
subsection: string | null,
|
|
key: string,
|
|
): Promise<string | null> {
|
|
try {
|
|
const config = await readFile(join(gitDir, 'config'), 'utf-8')
|
|
return parseConfigString(config, section, subsection, key)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse a config value from an in-memory config string.
|
|
* Exported for testing.
|
|
*/
|
|
export function parseConfigString(
|
|
config: string,
|
|
section: string,
|
|
subsection: string | null,
|
|
key: string,
|
|
): string | null {
|
|
const lines = config.split('\n')
|
|
const sectionLower = section.toLowerCase()
|
|
const keyLower = key.toLowerCase()
|
|
|
|
let inSection = false
|
|
for (const line of lines) {
|
|
const trimmed = line.trim()
|
|
|
|
// Skip empty lines and comment-only lines
|
|
if (trimmed.length === 0 || trimmed[0] === '#' || trimmed[0] === ';') {
|
|
continue
|
|
}
|
|
|
|
// Section header
|
|
if (trimmed[0] === '[') {
|
|
inSection = matchesSectionHeader(trimmed, sectionLower, subsection)
|
|
continue
|
|
}
|
|
|
|
if (!inSection) {
|
|
continue
|
|
}
|
|
|
|
// Key-value line: find the key name
|
|
const parsed = parseKeyValue(trimmed)
|
|
if (parsed && parsed.key.toLowerCase() === keyLower) {
|
|
return parsed.value
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Parse a key = value line. Returns null if the line doesn't contain a valid key.
|
|
*/
|
|
function parseKeyValue(line: string): { key: string; value: string } | null {
|
|
// Read key: alphanumeric + hyphen, starting with alpha
|
|
let i = 0
|
|
while (i < line.length && isKeyChar(line[i]!)) {
|
|
i++
|
|
}
|
|
if (i === 0) {
|
|
return null
|
|
}
|
|
const key = line.slice(0, i)
|
|
|
|
// Skip whitespace
|
|
while (i < line.length && (line[i] === ' ' || line[i] === '\t')) {
|
|
i++
|
|
}
|
|
|
|
// Must have '='
|
|
if (i >= line.length || line[i] !== '=') {
|
|
// Boolean key with no value — not relevant for our use cases
|
|
return null
|
|
}
|
|
i++ // skip '='
|
|
|
|
// Skip whitespace after '='
|
|
while (i < line.length && (line[i] === ' ' || line[i] === '\t')) {
|
|
i++
|
|
}
|
|
|
|
const value = parseValue(line, i)
|
|
return { key, value }
|
|
}
|
|
|
|
/**
|
|
* Parse a config value starting at position i.
|
|
* Handles quoted strings, escape sequences, and inline comments.
|
|
*/
|
|
function parseValue(line: string, start: number): string {
|
|
let result = ''
|
|
let inQuote = false
|
|
let i = start
|
|
|
|
while (i < line.length) {
|
|
const ch = line[i]!
|
|
|
|
// Inline comments outside quotes end the value
|
|
if (!inQuote && (ch === '#' || ch === ';')) {
|
|
break
|
|
}
|
|
|
|
if (ch === '"') {
|
|
inQuote = !inQuote
|
|
i++
|
|
continue
|
|
}
|
|
|
|
if (ch === '\\' && i + 1 < line.length) {
|
|
const next = line[i + 1]!
|
|
if (inQuote) {
|
|
// Inside quotes: recognize escape sequences
|
|
switch (next) {
|
|
case 'n':
|
|
result += '\n'
|
|
break
|
|
case 't':
|
|
result += '\t'
|
|
break
|
|
case 'b':
|
|
result += '\b'
|
|
break
|
|
case '"':
|
|
result += '"'
|
|
break
|
|
case '\\':
|
|
result += '\\'
|
|
break
|
|
default:
|
|
// Git silently drops the backslash for unknown escapes
|
|
result += next
|
|
break
|
|
}
|
|
i += 2
|
|
continue
|
|
}
|
|
// Outside quotes: backslash at end of line = continuation (we don't
|
|
// handle multi-line since we split on \n, but handle \\ and others)
|
|
if (next === '\\') {
|
|
result += '\\'
|
|
i += 2
|
|
continue
|
|
}
|
|
// Fallthrough — treat backslash literally outside quotes
|
|
}
|
|
|
|
result += ch
|
|
i++
|
|
}
|
|
|
|
// Trim trailing whitespace from unquoted portions.
|
|
// Git trims trailing whitespace that isn't inside quotes, but since we
|
|
// process char-by-char and quotes toggle, the simplest correct approach
|
|
// for single-line values is to trim the result when not ending in a quote.
|
|
if (!inQuote) {
|
|
result = trimTrailingWhitespace(result)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
function trimTrailingWhitespace(s: string): string {
|
|
let end = s.length
|
|
while (end > 0 && (s[end - 1] === ' ' || s[end - 1] === '\t')) {
|
|
end--
|
|
}
|
|
return s.slice(0, end)
|
|
}
|
|
|
|
/**
|
|
* Check if a config line like `[remote "origin"]` matches the given section/subsection.
|
|
* Section matching is case-insensitive; subsection matching is case-sensitive.
|
|
*/
|
|
function matchesSectionHeader(
|
|
line: string,
|
|
sectionLower: string,
|
|
subsection: string | null,
|
|
): boolean {
|
|
// line starts with '['
|
|
let i = 1
|
|
|
|
// Read section name
|
|
while (
|
|
i < line.length &&
|
|
line[i] !== ']' &&
|
|
line[i] !== ' ' &&
|
|
line[i] !== '\t' &&
|
|
line[i] !== '"'
|
|
) {
|
|
i++
|
|
}
|
|
const foundSection = line.slice(1, i).toLowerCase()
|
|
|
|
if (foundSection !== sectionLower) {
|
|
return false
|
|
}
|
|
|
|
if (subsection === null) {
|
|
// Simple section: must end with ']'
|
|
return i < line.length && line[i] === ']'
|
|
}
|
|
|
|
// Skip whitespace before subsection quote
|
|
while (i < line.length && (line[i] === ' ' || line[i] === '\t')) {
|
|
i++
|
|
}
|
|
|
|
// Must have opening quote
|
|
if (i >= line.length || line[i] !== '"') {
|
|
return false
|
|
}
|
|
i++ // skip opening quote
|
|
|
|
// Read subsection — case-sensitive, handle \\ and \" escapes
|
|
let foundSubsection = ''
|
|
while (i < line.length && line[i] !== '"') {
|
|
if (line[i] === '\\' && i + 1 < line.length) {
|
|
const next = line[i + 1]!
|
|
if (next === '\\' || next === '"') {
|
|
foundSubsection += next
|
|
i += 2
|
|
continue
|
|
}
|
|
// Git drops the backslash for other escapes in subsections
|
|
foundSubsection += next
|
|
i += 2
|
|
continue
|
|
}
|
|
foundSubsection += line[i]
|
|
i++
|
|
}
|
|
|
|
// Must have closing quote followed by ']'
|
|
if (i >= line.length || line[i] !== '"') {
|
|
return false
|
|
}
|
|
i++ // skip closing quote
|
|
|
|
if (i >= line.length || line[i] !== ']') {
|
|
return false
|
|
}
|
|
|
|
return foundSubsection === subsection
|
|
}
|
|
|
|
function isKeyChar(ch: string): boolean {
|
|
return (
|
|
(ch >= 'a' && ch <= 'z') ||
|
|
(ch >= 'A' && ch <= 'Z') ||
|
|
(ch >= '0' && ch <= '9') ||
|
|
ch === '-'
|
|
)
|
|
}
|