mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
* Add per-file line counts to the source control sidebar Show +N/-N (green/red) next to each file in the Changes, Untracked, and Committed-on-branch sections so the magnitude of a change is visible at a glance. Counts are computed per staging area via `git diff --numstat`; untracked/new files count their full contents as additions and binary files show no count. Consolidate numstat parsing and binary-buffer detection into shared modules reused by the local status path, the SSH/relay path, and the existing branch-compare code. Include added/removed in the status-entry equality check so the sidebar doesn't re-render on unchanged polls. * fix: harden source control line counts --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
146 lines
4.0 KiB
TypeScript
146 lines
4.0 KiB
TypeScript
export function parseStatusChar(char: string): string {
|
|
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'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse `git status --porcelain=v2` output into structured entries.
|
|
* Does NOT handle unmerged entries (those require worktree access).
|
|
*/
|
|
export function parseStatusOutput(stdout: string): {
|
|
entries: Record<string, unknown>[]
|
|
unmergedLines: string[]
|
|
ignoredPaths: string[]
|
|
head?: string
|
|
branch?: string
|
|
upstreamStatus: {
|
|
hasUpstream: boolean
|
|
upstreamName?: string
|
|
ahead: number
|
|
behind: number
|
|
}
|
|
} {
|
|
const entries: Record<string, unknown>[] = []
|
|
const unmergedLines: string[] = []
|
|
const ignoredPaths: string[] = []
|
|
let head: string | undefined
|
|
let branch: string | undefined
|
|
let upstreamName: string | undefined
|
|
let upstreamAheadBehind: { ahead: number; behind: number } | null = null
|
|
|
|
for (const line of stdout.split(/\r?\n/)) {
|
|
if (!line) {
|
|
continue
|
|
}
|
|
|
|
if (line.startsWith('# branch.oid ')) {
|
|
head = line.slice('# branch.oid '.length).trim()
|
|
continue
|
|
}
|
|
|
|
if (line.startsWith('# branch.head ')) {
|
|
const branchHead = line.slice('# branch.head '.length).trim()
|
|
branch = branchHead && branchHead !== '(detached)' ? `refs/heads/${branchHead}` : ''
|
|
continue
|
|
}
|
|
|
|
if (line.startsWith('# branch.upstream ')) {
|
|
upstreamName = line.slice('# branch.upstream '.length).trim() || undefined
|
|
continue
|
|
}
|
|
|
|
if (line.startsWith('# branch.ab ')) {
|
|
upstreamAheadBehind = parseBranchAheadBehind(line)
|
|
continue
|
|
}
|
|
|
|
if (line.startsWith('1 ') || line.startsWith('2 ')) {
|
|
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 format is `2 XY sub mH mI mW hH hI Xscore path\torigPath`.
|
|
// The new path starts after 9 fixed fields and can contain spaces; origPath follows the tab.
|
|
const tabParts = line.split('\t')
|
|
const filePath = tabParts[0].split(' ').slice(9).join(' ')
|
|
const oldPath = tabParts.slice(1).join('\t')
|
|
if (indexStatus !== '.') {
|
|
entries.push({
|
|
path: filePath,
|
|
status: parseStatusChar(indexStatus),
|
|
area: 'staged',
|
|
oldPath
|
|
})
|
|
}
|
|
if (worktreeStatus !== '.') {
|
|
entries.push({
|
|
path: filePath,
|
|
status: parseStatusChar(worktreeStatus),
|
|
area: 'unstaged',
|
|
oldPath
|
|
})
|
|
}
|
|
} else {
|
|
const filePath = parts.slice(8).join(' ')
|
|
if (indexStatus !== '.') {
|
|
entries.push({ path: filePath, status: parseStatusChar(indexStatus), area: 'staged' })
|
|
}
|
|
if (worktreeStatus !== '.') {
|
|
entries.push({
|
|
path: filePath,
|
|
status: parseStatusChar(worktreeStatus),
|
|
area: 'unstaged'
|
|
})
|
|
}
|
|
}
|
|
} else if (line.startsWith('? ')) {
|
|
entries.push({ path: line.slice(2), status: 'untracked', area: 'untracked' })
|
|
} else if (line.startsWith('! ')) {
|
|
ignoredPaths.push(line.slice(2))
|
|
} else if (line.startsWith('u ')) {
|
|
unmergedLines.push(line)
|
|
}
|
|
}
|
|
|
|
return {
|
|
entries,
|
|
unmergedLines,
|
|
ignoredPaths,
|
|
head,
|
|
branch,
|
|
upstreamStatus: upstreamName
|
|
? {
|
|
hasUpstream: true,
|
|
upstreamName,
|
|
ahead: upstreamAheadBehind?.ahead ?? 0,
|
|
behind: upstreamAheadBehind?.behind ?? 0
|
|
}
|
|
: { hasUpstream: false, ahead: 0, behind: 0 }
|
|
}
|
|
}
|
|
|
|
function parseBranchAheadBehind(line: string): { ahead: number; behind: number } | null {
|
|
const match = line.match(/^# branch\.ab \+(\d+) -(\d+)$/)
|
|
if (!match) {
|
|
return null
|
|
}
|
|
return {
|
|
ahead: Number.parseInt(match[1], 10),
|
|
behind: Number.parseInt(match[2], 10)
|
|
}
|
|
}
|