mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
* fix(worktrees): stop surfacing prunable git worktrees as live workspaces A worktree still registered in git but whose directory was deleted (git's `prunable` state) was enumerated as a normal workspace, producing repeated pty:spawn DaemonProtocolError / fs:readDir ENOENT loops and a blank pane. - Parse the `prunable` porcelain field (Git >= 2.36) in both the main and relay worktree-list parsers. - For Git < 2.36 (no `prunable` field), probe each linked worktree path for existence on the fallback line-block path, skipping locked registrations to mirror git's own prunable rules. - Omit prunable worktrees from the detected-workspace enumeration only; removal/cleanup flows keep seeing them. - Extend the real-binary compatibility contract with the 2.36 `prunable` boundary. Fixes #8389 Claude-Session: https://claude.ai/code/session_018Rg1Bpq4GGwmz613hq6RSD * fix(worktrees): pin the prunable/locked porcelain annotations to their real Git 2.31 boundary The prunable and locked annotations landed in Git 2.31, five releases before `worktree list -z` (2.36); only -z defines the capability fallback boundary. Correct the compatibility contract so a future matrix entry in the 2.31-2.35 range passes, and reword the fallback comments: on 2.31-2.35 the annotations still parse and the existence probe is a backstop; only Git <2.31 relies on it outright. * fix(worktrees): omit prunable registrations from the Space scan A prunable registration has no directory to size or reclaim, so Space rendered it as a dead "Missing" row whose checkbox stayed disabled with no prune/remove affordance (reported on macOS after a reboot cleared /private/tmp under 16 registrations). Skip prunable entries in the scan, matching the workspace enumeration; removal flows list worktrees separately and still see them. --------- Co-authored-by: kaynan <kaynan.camargo@terceiro-sky.com.br> Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
256 lines
7.2 KiB
TypeScript
256 lines
7.2 KiB
TypeScript
/**
|
|
* Pure parsing helpers extracted from git-handler.ts.
|
|
*
|
|
* Why: oxlint max-lines requires files to stay under 300 lines.
|
|
* These functions have no side-effects and depend only on their arguments,
|
|
* making them easy to test independently.
|
|
*/
|
|
import { existsSync } from 'node:fs'
|
|
import * as path from 'node:path'
|
|
import { decodeGitCQuotedPath } from '../shared/git-cquoted-path'
|
|
import { isBinaryBuffer } from '../shared/binary-buffer'
|
|
import type { GitLineStats } from '../shared/git-uncommitted-line-stats'
|
|
export { isUnsupportedWorktreeListZError } from '../shared/git-worktree-command-capabilities'
|
|
|
|
export function parseBranchStatusChar(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'
|
|
}
|
|
}
|
|
|
|
export function parseConflictKind(xy: string): string | null {
|
|
switch (xy) {
|
|
case 'UU':
|
|
return 'both_modified'
|
|
case 'AA':
|
|
return 'both_added'
|
|
case 'DD':
|
|
return 'both_deleted'
|
|
case 'AU':
|
|
return 'added_by_us'
|
|
case 'UA':
|
|
return 'added_by_them'
|
|
case 'DU':
|
|
return 'deleted_by_us'
|
|
case 'UD':
|
|
return 'deleted_by_them'
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse a single unmerged entry line from porcelain v2 output.
|
|
* Returns null if the entry should be skipped (e.g. submodule conflicts).
|
|
*/
|
|
export function parseUnmergedEntry(
|
|
worktreePath: string,
|
|
line: string
|
|
): Record<string, unknown> | null {
|
|
const parts = line.split(' ')
|
|
const xy = parts[1]
|
|
const modeStage1 = parts[3]
|
|
const modeStage2 = parts[4]
|
|
const modeStage3 = parts[5]
|
|
const filePath = parts.slice(10).join(' ')
|
|
if (!filePath) {
|
|
return null
|
|
}
|
|
|
|
// Skip submodule conflicts (mode 160000)
|
|
if ([modeStage1, modeStage2, modeStage3].some((m) => m === '160000')) {
|
|
return null
|
|
}
|
|
|
|
const conflictKind = parseConflictKind(xy)
|
|
if (!conflictKind) {
|
|
return null
|
|
}
|
|
|
|
let status: string = 'modified'
|
|
if (conflictKind === 'both_deleted') {
|
|
status = 'deleted'
|
|
} else if (conflictKind !== 'both_modified' && conflictKind !== 'both_added') {
|
|
try {
|
|
status = existsSync(path.join(worktreePath, filePath)) ? 'modified' : 'deleted'
|
|
} catch {
|
|
// Why: defaulting to 'modified' on fs error is the least misleading option
|
|
status = 'modified'
|
|
}
|
|
}
|
|
|
|
return {
|
|
path: filePath,
|
|
area: 'unstaged',
|
|
status,
|
|
conflictKind,
|
|
conflictStatus: 'unresolved'
|
|
}
|
|
}
|
|
|
|
// ─── Branch diff parsing ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* Parse `git diff --name-status` output into structured change entries.
|
|
*/
|
|
export function parseBranchDiff(
|
|
stdout: string,
|
|
statsByPath: Map<string, GitLineStats> = new Map()
|
|
): Record<string, unknown>[] {
|
|
const entries: Record<string, unknown>[] = []
|
|
for (const line of stdout.split(/\r?\n/)) {
|
|
if (!line) {
|
|
continue
|
|
}
|
|
const parts = line.split('\t')
|
|
const rawStatus = parts[0] ?? ''
|
|
const status = parseBranchStatusChar(rawStatus[0] ?? 'M')
|
|
|
|
if (rawStatus.startsWith('R') || rawStatus.startsWith('C')) {
|
|
const oldPath = parts[1]
|
|
const filePath = parts[2]
|
|
if (filePath) {
|
|
entries.push({ path: filePath, oldPath, status, ...statsByPath.get(filePath) })
|
|
}
|
|
} else {
|
|
const filePath = parts[1]
|
|
if (filePath) {
|
|
entries.push({ path: filePath, status, ...statsByPath.get(filePath) })
|
|
}
|
|
}
|
|
}
|
|
return entries
|
|
}
|
|
|
|
// ─── Worktree parsing ────────────────────────────────────────────────
|
|
|
|
export function parseWorktreeList(
|
|
output: string,
|
|
options: { nulDelimited?: boolean } = {}
|
|
): Record<string, unknown>[] {
|
|
const worktrees: Record<string, unknown>[] = []
|
|
const blocks = options.nulDelimited ? splitNulWorktreeList(output) : splitLineWorktreeList(output)
|
|
|
|
for (const lines of blocks) {
|
|
if (lines.length === 0) {
|
|
continue
|
|
}
|
|
let wtPath = ''
|
|
let head = ''
|
|
let branch = ''
|
|
let isBare = false
|
|
let locked = false
|
|
let lockReason = ''
|
|
let prunable = false
|
|
let prunableReason = ''
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('worktree ')) {
|
|
wtPath = line.slice('worktree '.length)
|
|
} else if (line.startsWith('HEAD ')) {
|
|
head = line.slice('HEAD '.length)
|
|
} else if (line.startsWith('branch ')) {
|
|
branch = line.slice('branch '.length)
|
|
} else if (line === 'bare') {
|
|
isBare = true
|
|
} else if (line === 'locked' || line.startsWith('locked ')) {
|
|
locked = true
|
|
const rawReason = line.slice('locked'.length).trim()
|
|
lockReason = options.nulDelimited ? rawReason : decodeGitCQuotedPath(rawReason)
|
|
} else if (line === 'prunable' || line.startsWith('prunable ')) {
|
|
// Why: Git ≥ 2.36 flags registrations whose directory is gone; ignoring
|
|
// it surfaces the stale worktree as a live workspace (issue #8389).
|
|
prunable = true
|
|
const rawReason = line.slice('prunable'.length).trim()
|
|
prunableReason = options.nulDelimited ? rawReason : decodeGitCQuotedPath(rawReason)
|
|
}
|
|
}
|
|
|
|
if (wtPath) {
|
|
worktrees.push({
|
|
path: wtPath,
|
|
head,
|
|
branch,
|
|
isBare,
|
|
...(locked ? { locked: true } : {}),
|
|
...(lockReason ? { lockReason } : {}),
|
|
...(prunable ? { prunable: true } : {}),
|
|
...(prunableReason ? { prunableReason } : {}),
|
|
isMainWorktree: worktrees.length === 0
|
|
})
|
|
}
|
|
}
|
|
return worktrees
|
|
}
|
|
|
|
function splitLineWorktreeList(output: string): string[][] {
|
|
return output
|
|
.trim()
|
|
.split(/\r?\n\r?\n/)
|
|
.map((block) => block.trim().split(/\r?\n/))
|
|
}
|
|
|
|
function splitNulWorktreeList(output: string): string[][] {
|
|
if (!output.includes('\0')) {
|
|
return splitLineWorktreeList(output)
|
|
}
|
|
|
|
const blocks: string[][] = []
|
|
let currentBlock: string[] = []
|
|
|
|
for (const field of output.split('\0')) {
|
|
if (field) {
|
|
currentBlock.push(field)
|
|
continue
|
|
}
|
|
if (currentBlock.length > 0) {
|
|
blocks.push(currentBlock)
|
|
currentBlock = []
|
|
}
|
|
}
|
|
|
|
if (currentBlock.length > 0) {
|
|
blocks.push(currentBlock)
|
|
}
|
|
|
|
return blocks
|
|
}
|
|
|
|
// ─── Binary / blob helpers ───────────────────────────────────────────
|
|
|
|
export const PREVIEWABLE_MIME: Record<string, string> = {
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.gif': 'image/gif',
|
|
'.svg': 'image/svg+xml',
|
|
'.webp': 'image/webp',
|
|
'.bmp': 'image/bmp',
|
|
'.ico': 'image/x-icon',
|
|
'.pdf': 'application/pdf'
|
|
}
|
|
|
|
export function bufferToBlob(
|
|
buffer: Buffer,
|
|
filePath?: string
|
|
): { content: string; isBinary: boolean } {
|
|
const binary = isBinaryBuffer(buffer)
|
|
if (binary) {
|
|
const ext = filePath ? path.extname(filePath).toLowerCase() : ''
|
|
const previewable = !!PREVIEWABLE_MIME[ext]
|
|
return { content: previewable ? buffer.toString('base64') : '', isBinary: true }
|
|
}
|
|
return { content: buffer.toString('utf-8'), isBinary: false }
|
|
}
|