import { FileEntry, GitStatusEntry } from '../model/status' export type StatusItem = IStatusHeader | IStatusEntry export interface IStatusHeader { readonly kind: 'header' readonly value: string } /** A representation of a parsed status entry from git status */ export interface IStatusEntry { readonly kind: 'entry' /** The path to the file relative to the repository root */ readonly path: string /** The two character long status code */ readonly statusCode: string /** The original path in the case of a renamed file */ readonly oldPath?: string } const ChangedEntryType = '1' const RenamedOrCopiedEntryType = '2' const UnmergedEntryType = 'u' const UntrackedEntryType = '?' const IgnoredEntryType = '!' /** Parses output from git status --porcelain -z into file status entries */ export function parsePorcelainStatus( output: string, limit: number = Number.MAX_SAFE_INTEGER ): { entries: ReadonlyArray, incomplete: boolean } { const entries = new Array() // See https://git-scm.com/docs/git-status // // In the short-format, the status of each path is shown as // XY PATH1 -> PATH2 // // There is also an alternate -z format recommended for machine parsing. In that // format, the status field is the same, but some other things change. First, // the -> is omitted from rename entries and the field order is reversed (e.g // from -> to becomes to from). Second, a NUL (ASCII 0) follows each filename, // replacing space as a field separator and the terminating newline (but a space // still separates the status field from the first filename). Third, filenames // containing special characters are not specially formatted; no quoting or // backslash-escaping is performed. const fields = output.split('\0') let field: string | undefined let limitCounter = 0; let incomplete = false; while ((field = fields.shift())) { if (field.startsWith('# ') && field.length > 2) { entries.push({ kind: 'header', value: field.substr(2) }) continue } if (limitCounter === limit) { incomplete = true; break; } const entryKind = field.substr(0, 1) if (entryKind === ChangedEntryType) { entries.push(parseChangedEntry(field)) } else if (entryKind === RenamedOrCopiedEntryType) { entries.push(parsedRenamedOrCopiedEntry(field, fields.shift())) } else if (entryKind === UnmergedEntryType) { entries.push(parseUnmergedEntry(field)) } else if (entryKind === UntrackedEntryType) { entries.push(parseUntrackedEntry(field)) } else if (entryKind === IgnoredEntryType) { // Ignored, we don't care about these for now } limitCounter++; } return { entries, incomplete }; } // 1 const changedEntryRe = /^1 ([MADRCU?!.]{2}) (N\.\.\.|S[C.][M.][U.]) (\d+) (\d+) (\d+) ([a-f0-9]+) ([a-f0-9]+) (.*?)$/ function parseChangedEntry(field: string): IStatusEntry { const match = changedEntryRe.exec(field) if (!match) { throw new Error(`Failed to parse status line for changed entry: ${field}`) } return { kind: 'entry', statusCode: match[1], path: match[8], } } // 2 const renamedOrCopiedEntryRe = /^2 ([MADRCU?!.]{2}) (N\.\.\.|S[C.][M.][U.]) (\d+) (\d+) (\d+) ([a-f0-9]+) ([a-f0-9]+) ([RC]\d+) (.*?)$/ function parsedRenamedOrCopiedEntry( field: string, oldPath: string | undefined ): IStatusEntry { const match = renamedOrCopiedEntryRe.exec(field) if (!match) { throw new Error( `Failed to parse status line for renamed or copied entry: ${field}` ) } if (!oldPath) { throw new Error( 'Failed to parse renamed or copied entry, could not parse old path' ) } return { kind: 'entry', statusCode: match[1], oldPath, path: match[9], } } // u

const unmergedEntryRe = /^u ([DAU]{2}) (N\.\.\.|S[C.][M.][U.]) (\d+) (\d+) (\d+) (\d+) ([a-f0-9]+) ([a-f0-9]+) ([a-f0-9]+) (.*?)$/ function parseUnmergedEntry(field: string): IStatusEntry { const match = unmergedEntryRe.exec(field) if (!match) { throw new Error(`Failed to parse status line for unmerged entry: ${field}`) } return { kind: 'entry', statusCode: match[1], path: match[10], } } function parseUntrackedEntry(field: string): IStatusEntry { const path = field.substr(2) return { kind: 'entry', // NOTE: We return ?? instead of ? here to play nice with mapStatus, // might want to consider changing this (and mapStatus) in the future. statusCode: '??', path, } } /** * Map the raw status text from Git to a structure we can work with in the app. */ export function mapStatus(status: string): FileEntry { if (status === '??') { return { kind: 'untracked' } } if (status === '.M') { return { kind: 'ordinary', type: 'modified', index: GitStatusEntry.Unchanged, workingTree: GitStatusEntry.Modified, } } if (status === 'M.') { return { kind: 'ordinary', type: 'modified', index: GitStatusEntry.Modified, workingTree: GitStatusEntry.Unchanged, } } if (status === '.A') { return { kind: 'ordinary', type: 'added', index: GitStatusEntry.Unchanged, workingTree: GitStatusEntry.Added, } } if (status === 'A.') { return { kind: 'ordinary', type: 'added', index: GitStatusEntry.Added, workingTree: GitStatusEntry.Unchanged, } } if (status === '.D') { return { kind: 'ordinary', type: 'deleted', index: GitStatusEntry.Unchanged, workingTree: GitStatusEntry.Deleted, } } if (status === 'D.') { return { kind: 'ordinary', type: 'deleted', index: GitStatusEntry.Deleted, workingTree: GitStatusEntry.Unchanged, } } if (status === 'R.') { return { kind: 'renamed', index: GitStatusEntry.Renamed, workingTree: GitStatusEntry.Unchanged, } } if (status === '.R') { return { kind: 'renamed', index: GitStatusEntry.Unchanged, workingTree: GitStatusEntry.Renamed, } } if (status === 'C.') { return { kind: 'copied', index: GitStatusEntry.Copied, workingTree: GitStatusEntry.Unchanged, } } if (status === '.C') { return { kind: 'copied', index: GitStatusEntry.Unchanged, workingTree: GitStatusEntry.Copied, } } if (status === 'AD') { return { kind: 'ordinary', type: 'added', index: GitStatusEntry.Added, workingTree: GitStatusEntry.Deleted, } } if (status === 'AM') { return { kind: 'ordinary', type: 'added', index: GitStatusEntry.Added, workingTree: GitStatusEntry.Modified, } } if (status === 'RM') { return { kind: 'renamed', index: GitStatusEntry.Renamed, workingTree: GitStatusEntry.Modified, } } if (status === 'RD') { return { kind: 'renamed', index: GitStatusEntry.Renamed, workingTree: GitStatusEntry.Deleted, } } if (status === 'DD') { return { kind: 'conflicted', us: GitStatusEntry.Deleted, them: GitStatusEntry.Deleted, } } if (status === 'AU') { return { kind: 'conflicted', us: GitStatusEntry.Added, them: GitStatusEntry.Modified, } } if (status === 'UD') { return { kind: 'conflicted', us: GitStatusEntry.Modified, them: GitStatusEntry.Deleted, } } if (status === 'UA') { return { kind: 'conflicted', us: GitStatusEntry.Modified, them: GitStatusEntry.Added, } } if (status === 'DU') { return { kind: 'conflicted', us: GitStatusEntry.Deleted, them: GitStatusEntry.Modified, } } if (status === 'AA') { return { kind: 'conflicted', us: GitStatusEntry.Added, them: GitStatusEntry.Added, } } if (status === 'UU') { return { kind: 'conflicted', us: GitStatusEntry.Modified, them: GitStatusEntry.Modified, } } // as a fallback, we assume the file is modified in some way return { kind: 'ordinary', type: 'modified', } }