mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
* fix(source-control): keep huge change sets responsive * Fix cancellation and retry handling for capped status * Harden capped status for conflict-heavy repositories * Harden capped status recovery and cancellation * fix(source-control): preserve capped status correctness * fix(source-control): translate submodule status at render time --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
238 lines
7.3 KiB
TypeScript
238 lines
7.3 KiB
TypeScript
import type { GitStatusEntry } from './git-status-types'
|
|
import { decodeGitCQuotedPath } from './git-cquoted-path'
|
|
|
|
/**
|
|
* Incremental parser for `git status --porcelain=v2 --branch` output.
|
|
*
|
|
* Why incremental: a repo with an enormous un-ignored folder can emit a status
|
|
* listing too large to buffer into one string (it overflows V8's max string
|
|
* length and crashes the process). Feeding chunks here as they arrive lets the
|
|
* caller stop git the moment the changed-entry count crosses a limit, so memory
|
|
* stays bounded. Records are newline-delimited; a partial trailing line is
|
|
* carried across chunks.
|
|
*
|
|
* Sync record types (1/2/?/!) are parsed into `entries`/`ignoredPaths` here.
|
|
* Unmerged (`u`) records need per-file worktree lookups, so their raw lines are
|
|
* collected and resolved by the caller after the stream ends. They still count
|
|
* toward the limit so a conflict-heavy merge cannot bypass the cap.
|
|
*/
|
|
export type BranchMetadata = {
|
|
head?: string
|
|
branch?: string
|
|
upstreamName?: string
|
|
upstreamAheadBehind?: { ahead: number; behind: number }
|
|
}
|
|
|
|
export type StatusPorcelainRecord =
|
|
| { type: 'entry'; entry: GitStatusEntry }
|
|
| { type: 'unmerged'; line: string }
|
|
|
|
export class StatusPorcelainParser {
|
|
private carry = ''
|
|
/** Count of changed-file entries seen — the limit is measured against this. */
|
|
private count = 0
|
|
|
|
readonly entries: GitStatusEntry[] = []
|
|
readonly ignoredPaths: string[] = []
|
|
/** Raw `u ` lines for the caller to resolve asynchronously. */
|
|
readonly unmergedLines: string[] = []
|
|
/** Changed records in Git's output order, including deferred unmerged rows. */
|
|
readonly statusRecords: StatusPorcelainRecord[] = []
|
|
readonly branch: BranchMetadata = {}
|
|
|
|
/** Total changed-file entries observed (including any past the limit). */
|
|
get statusLength(): number {
|
|
return this.count
|
|
}
|
|
|
|
/**
|
|
* Feed one decoded chunk. Returns true once the accumulated changed-entry
|
|
* count exceeds `limit` (limit 0 disables the cap), signaling the caller to
|
|
* stop git. Complete lines are parsed; an incomplete trailing line is carried.
|
|
*/
|
|
update(chunk: string, limit: number): boolean {
|
|
const text = this.carry + chunk
|
|
let start = 0
|
|
while (true) {
|
|
const nl = text.indexOf('\n', start)
|
|
if (nl === -1) {
|
|
break
|
|
}
|
|
// Strip a trailing \r so Windows CRLF output parses cleanly.
|
|
let end = nl
|
|
if (end > start && text.charCodeAt(end - 1) === 13) {
|
|
end -= 1
|
|
}
|
|
this.parseLine(text.slice(start, end))
|
|
start = nl + 1
|
|
if (limit !== 0 && this.count > limit) {
|
|
this.carry = ''
|
|
return true
|
|
}
|
|
}
|
|
this.carry = text.slice(start)
|
|
return false
|
|
}
|
|
|
|
/** Flush a final line with no trailing newline (e.g. when git exits). */
|
|
finish(): void {
|
|
if (this.carry.length > 0) {
|
|
this.parseLine(this.carry)
|
|
this.carry = ''
|
|
}
|
|
}
|
|
|
|
private parseLine(line: string): void {
|
|
if (!line) {
|
|
return
|
|
}
|
|
if (line.startsWith('# branch.oid ')) {
|
|
this.branch.head = line.slice('# branch.oid '.length).trim()
|
|
return
|
|
}
|
|
if (line.startsWith('# branch.head ')) {
|
|
const branchHead = line.slice('# branch.head '.length).trim()
|
|
// Why: undefined (not '') keeps this transport-compatible — the renderer
|
|
// turns "head without branch" into an explicit detached-HEAD clear.
|
|
this.branch.branch =
|
|
branchHead && branchHead !== '(detached)' ? `refs/heads/${branchHead}` : undefined
|
|
return
|
|
}
|
|
if (line.startsWith('# branch.upstream ')) {
|
|
this.branch.upstreamName = line.slice('# branch.upstream '.length).trim() || undefined
|
|
return
|
|
}
|
|
if (line.startsWith('# branch.ab ')) {
|
|
const match = line.match(/^# branch\.ab \+(\d+) -(\d+)$/)
|
|
if (match) {
|
|
this.branch.upstreamAheadBehind = {
|
|
ahead: Number.parseInt(match[1], 10),
|
|
behind: Number.parseInt(match[2], 10)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
if (line.startsWith('1 ') || line.startsWith('2 ')) {
|
|
this.parseChangedEntry(line)
|
|
return
|
|
}
|
|
if (line.startsWith('? ')) {
|
|
this.push({
|
|
path: decodeGitCQuotedPath(line.slice(2)),
|
|
status: 'untracked',
|
|
area: 'untracked'
|
|
})
|
|
return
|
|
}
|
|
if (line.startsWith('! ')) {
|
|
this.ignoredPaths.push(decodeGitCQuotedPath(line.slice(2)))
|
|
return
|
|
}
|
|
if (line.startsWith('u ')) {
|
|
this.count += 1
|
|
this.unmergedLines.push(line)
|
|
this.statusRecords.push({ type: 'unmerged', line })
|
|
}
|
|
}
|
|
|
|
private parseChangedEntry(line: string): void {
|
|
// Changed entries: "1 XY sub mH mI mW hH path" or
|
|
// "2 XY sub mH mI mW hH X<score> path\torigPath"
|
|
const parts = line.split(' ')
|
|
const xy = parts[1]
|
|
const indexStatus = xy[0]
|
|
const worktreeStatus = xy[1]
|
|
|
|
if (line.startsWith('2 ')) {
|
|
// Why: porcelain v2 type-2 records put the new path after 9 fixed
|
|
// space-delimited fields and the old path after the tab. Preserving spaces
|
|
// keeps row actions and numstat counts keyed correctly.
|
|
const tabParts = line.split('\t')
|
|
const path = decodeGitCQuotedPath(tabParts[0].split(' ').slice(9).join(' '))
|
|
const oldPath = decodeGitCQuotedPath(tabParts.slice(1).join('\t'))
|
|
if (indexStatus !== '.') {
|
|
this.push({
|
|
path,
|
|
status: parseStatusChar(indexStatus),
|
|
area: 'staged',
|
|
oldPath,
|
|
...submoduleStatusField(parts[2], indexStatus)
|
|
})
|
|
}
|
|
if (worktreeStatus !== '.') {
|
|
this.push({
|
|
path,
|
|
status: parseStatusChar(worktreeStatus),
|
|
area: 'unstaged',
|
|
oldPath,
|
|
...submoduleStatusField(parts[2], worktreeStatus)
|
|
})
|
|
}
|
|
return
|
|
}
|
|
|
|
const path = decodeGitCQuotedPath(parts.slice(8).join(' '))
|
|
if (indexStatus !== '.') {
|
|
this.push({
|
|
path,
|
|
status: parseStatusChar(indexStatus),
|
|
area: 'staged',
|
|
...submoduleStatusField(parts[2], indexStatus)
|
|
})
|
|
}
|
|
if (worktreeStatus !== '.') {
|
|
this.push({
|
|
path,
|
|
status: parseStatusChar(worktreeStatus),
|
|
area: 'unstaged',
|
|
...submoduleStatusField(parts[2], worktreeStatus)
|
|
})
|
|
}
|
|
}
|
|
|
|
private push(entry: GitStatusEntry): void {
|
|
this.count += 1
|
|
this.entries.push(entry)
|
|
this.statusRecords.push({ type: 'entry', entry })
|
|
}
|
|
}
|
|
|
|
export function parseStatusChar(char: string): GitStatusEntry['status'] {
|
|
switch (char) {
|
|
case 'M':
|
|
return 'modified'
|
|
case 'A':
|
|
return 'added'
|
|
case 'D':
|
|
return 'deleted'
|
|
case 'R':
|
|
return 'renamed'
|
|
case 'C':
|
|
return 'copied'
|
|
default:
|
|
return 'modified'
|
|
}
|
|
}
|
|
|
|
export function parseSubmoduleStatus(
|
|
submoduleField: string | undefined,
|
|
statusChar = '.'
|
|
): GitStatusEntry['submodule'] {
|
|
if (!submoduleField?.startsWith('S')) {
|
|
return undefined
|
|
}
|
|
return {
|
|
commitChanged: submoduleField[1] === 'C' || (submoduleField === 'S...' && statusChar === 'M'),
|
|
trackedChanges: submoduleField[2] === 'M',
|
|
untrackedChanges: submoduleField[3] === 'U'
|
|
}
|
|
}
|
|
|
|
function submoduleStatusField(
|
|
submoduleField: string | undefined,
|
|
statusChar: string
|
|
): { submodule: GitStatusEntry['submodule'] } | {} {
|
|
const submodule = parseSubmoduleStatus(submoduleField, statusChar)
|
|
return submodule ? { submodule } : {}
|
|
}
|