import type { FsGitStatus, FsGitStatusResponse, FsPullRequest } from './git'; export function parsePorcelain( stdout: string, filter: ReadonlySet | undefined, ): FsGitStatusResponse { const records = stdout.split('\0'); let branch = ''; let ahead = 0; let behind = 0; const entries: Record = {}; for (let i = 0; i < records.length; i++) { const record = records[i]!; if (record.length === 0) continue; if (record.startsWith('## ')) { const parsed = parseBranchHeader(record.slice(3)); branch = parsed.branch; ahead = parsed.ahead; behind = parsed.behind; continue; } if (record.length < 4) continue; const xy = record.slice(0, 2); const wirePath = record.slice(3); if (xy.startsWith('R') || xy.startsWith('C')) { i++; } if (filter !== undefined && !filter.has(wirePath)) continue; const status = collapseXY(xy); entries[wirePath] = status; } return { branch, ahead, behind, entries, additions: 0, deletions: 0, pullRequest: null }; } export function parseNumstat(stdout: string): { additions: number; deletions: number; } { let additions = 0; let deletions = 0; for (const line of stdout.split('\n')) { if (line.length === 0) continue; const [addedText, deletedText] = line.split('\t'); additions += parseNumstatCount(addedText); deletions += parseNumstatCount(deletedText); } return { additions, deletions }; } function parseNumstatCount(value: string | undefined): number { if (value === undefined || value === '-') return 0; const n = Number.parseInt(value, 10); return Number.isFinite(n) && n > 0 ? n : 0; } function parseBranchHeader(rest: string): { branch: string; ahead: number; behind: number; } { if (rest.startsWith('HEAD (no branch)')) { return { branch: '', ahead: 0, behind: 0 }; } if (rest.startsWith('No commits yet on ')) { return { branch: rest.slice('No commits yet on '.length), ahead: 0, behind: 0 }; } let branch = rest; let ahead = 0; let behind = 0; const bracket = rest.indexOf(' ['); if (bracket >= 0) { branch = rest.slice(0, bracket); const sliced = rest.slice(bracket + 2, rest.length - 1); const aheadMatch = sliced.match(/ahead (\d+)/); const behindMatch = sliced.match(/behind (\d+)/); if (aheadMatch !== null) ahead = Number.parseInt(aheadMatch[1] ?? '0', 10) || 0; if (behindMatch !== null) behind = Number.parseInt(behindMatch[1] ?? '0', 10) || 0; } const dots = branch.indexOf('...'); if (dots >= 0) branch = branch.slice(0, dots); return { branch, ahead, behind }; } function collapseXY(xy: string): FsGitStatus { if (xy === '??') return 'untracked'; if (xy === '!!') return 'ignored'; const x = xy.charAt(0); const y = xy.charAt(1); const set = new Set([x, y]); if ( xy === 'DD' || xy === 'AU' || xy === 'UD' || xy === 'UA' || xy === 'DU' || xy === 'AA' || xy === 'UU' ) { return 'conflicted'; } if (set.has('D')) return 'deleted'; if (set.has('M') || set.has('T')) return 'modified'; if (set.has('R')) return 'renamed'; if (set.has('C')) return 'renamed'; if (set.has('A')) return 'added'; return 'clean'; } export function parsePullRequest(stdout: string): FsPullRequest | null { let raw: unknown; try { raw = JSON.parse(stdout); } catch { return null; } if (typeof raw !== 'object' || raw === null) return null; const record = raw as Record; const number = record['number']; const url = record['url']; const state = record['state']; if (typeof number !== 'number' || !Number.isInteger(number) || number <= 0) return null; if (typeof url !== 'string' || !isSafeHttpUrl(url)) return null; if (typeof state !== 'string') return null; const normalized = state.toLowerCase(); if (normalized !== 'open' && normalized !== 'merged' && normalized !== 'closed') return null; return { number, state: normalized, url }; } function isSafeHttpUrl(value: string): boolean { if (hasControlChars(value)) return false; try { const url = new URL(value); return url.protocol === 'https:' || url.protocol === 'http:'; } catch { return false; } } function hasControlChars(value: string): boolean { for (const char of value) { const code = char.codePointAt(0) ?? 0; if (code <= 0x1f || code === 0x7f) return true; } return false; }