mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
* Prevent main-process crash on huge git status output git status on a repo with an enormous un-ignored folder could emit a listing large enough that buffering it into one string exceeded V8's max string length, throwing an uncatchable RangeError inside Node's execFile exit handler and crashing the main process (and blocking mobile). The runner also forwarded an undefined maxBuffer straight to execFile, which silently disabled the cap entirely. - runner: apply a non-bypassable default maxBuffer so no git call can buffer without a bound. - status: stream porcelain-v2 output through an incremental parser and kill git the moment the changed-entry count crosses a limit (default 10,000); skip numstat/upstream probe when limited. Apply the same limit to the relay/SSH status path. - UX: surface a 'too many changes' state, pause auto status polling while a repo is over the limit, and offer to .gitignore a detected huge folder. Co-authored-by: Orca <help@stably.ai> * Address review: validate inputs, fix stream decoding, localize copy - huge-folder-ignore: restrict folderName to the known-folder allowlist before writing .gitignore (reject path-sep/newline injection). - gitStreamStdout: decode stdout/stderr with StringDecoder so multibyte UTF-8 split across chunk boundaries isn't corrupted; flush on cleanup. - gitStreamStdout: wrap the onStdout callback in try/catch so a throwing parser rejects the promise instead of crashing the main process. - status/relay: validate the status limit as a non-negative integer. - SourceControl: localize the 'too many changes' banner and the .gitignore warning toast via translate() with placeholder params. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
276 lines
8.6 KiB
TypeScript
276 lines
8.6 KiB
TypeScript
/**
|
|
* Status and conflict-detection operations extracted from git-handler.ts.
|
|
*
|
|
* Why: oxlint max-lines (300) requires splitting large files.
|
|
* These functions are pure data operations on git state — no class coupling.
|
|
*/
|
|
import * as path from 'path'
|
|
import { existsSync } from 'fs'
|
|
import { readFile } from 'fs/promises'
|
|
import { parseUnmergedEntry } from './git-handler-utils'
|
|
import { parseStatusOutput } from './git-status-output-parser'
|
|
import type { GitExec } from './git-handler-ops'
|
|
import type { GitUpstreamStatus } from '../shared/types'
|
|
import {
|
|
getEffectiveGitUpstreamStatus,
|
|
splitRemoteBranchName
|
|
} from '../shared/git-effective-upstream'
|
|
import {
|
|
applyLineStats,
|
|
collectUntrackedAdditions,
|
|
parseNumstat,
|
|
type GitLineStats
|
|
} from '../shared/git-uncommitted-line-stats'
|
|
import { DEFAULT_GIT_STATUS_LIMIT } from '../shared/git-status-limit'
|
|
|
|
export async function resolveGitDir(worktreePath: string): Promise<string> {
|
|
const dotGitPath = path.join(worktreePath, '.git')
|
|
try {
|
|
const contents = await readFile(dotGitPath, 'utf-8')
|
|
const match = contents.match(/^gitdir:\s*(.+)\s*$/m)
|
|
if (match) {
|
|
return path.resolve(worktreePath, match[1])
|
|
}
|
|
} catch {
|
|
// .git is a directory, not a file
|
|
}
|
|
return dotGitPath
|
|
}
|
|
|
|
export async function detectConflictOperation(worktreePath: string): Promise<string> {
|
|
const gitDir = await resolveGitDir(worktreePath)
|
|
try {
|
|
if (existsSync(path.join(gitDir, 'MERGE_HEAD'))) {
|
|
return 'merge'
|
|
}
|
|
if (
|
|
existsSync(path.join(gitDir, 'rebase-merge')) ||
|
|
existsSync(path.join(gitDir, 'rebase-apply'))
|
|
) {
|
|
return 'rebase'
|
|
}
|
|
if (existsSync(path.join(gitDir, 'CHERRY_PICK_HEAD'))) {
|
|
return 'cherry-pick'
|
|
}
|
|
} catch {
|
|
// fs error — treat as no conflict operation
|
|
}
|
|
return 'unknown'
|
|
}
|
|
|
|
export async function getStatusOp(
|
|
git: GitExec,
|
|
params: Record<string, unknown>
|
|
): Promise<{
|
|
entries: Record<string, unknown>[]
|
|
conflictOperation: string
|
|
head?: string
|
|
branch?: string
|
|
upstreamStatus?: GitUpstreamStatus
|
|
ignoredPaths?: string[]
|
|
didHitLimit?: boolean
|
|
statusLength?: number
|
|
}> {
|
|
const worktreePath = params.worktreePath as string
|
|
const includeIgnored = params.includeIgnored === true
|
|
// Why: reject non-finite/negative limits so the cap guard stays reliable
|
|
// (NaN would silently disable capping; negatives would over-truncate).
|
|
const rawLimit = params.limit
|
|
const limit =
|
|
typeof rawLimit === 'number' && Number.isFinite(rawLimit) && rawLimit >= 0
|
|
? Math.floor(rawLimit)
|
|
: DEFAULT_GIT_STATUS_LIMIT
|
|
const conflictOperation = await detectConflictOperation(worktreePath)
|
|
const entries: Record<string, unknown>[] = []
|
|
let head: string | undefined
|
|
let branch: string | undefined
|
|
let upstreamStatus: GitUpstreamStatus | undefined
|
|
let ignoredPaths: string[] = []
|
|
let didHitLimit = false
|
|
let statusLength = 0
|
|
|
|
try {
|
|
// Why: -c core.quotePath=false keeps non-ASCII filenames as raw UTF-8 in
|
|
// git's stdout instead of C-style octal escapes; without it the parsed
|
|
// entry.path renders as gibberish in the source-control sidebar and
|
|
// downstream blob lookups miss.
|
|
const statusArgs = [
|
|
'-c',
|
|
'core.quotePath=false',
|
|
'status',
|
|
'--porcelain=v2',
|
|
'--branch',
|
|
'--untracked-files=all'
|
|
]
|
|
if (includeIgnored) {
|
|
statusArgs.push('--ignored=matching')
|
|
}
|
|
const { stdout } = await git(statusArgs, worktreePath, {
|
|
// Why: status polling is read-like; avoid refreshing the index and racing
|
|
// terminal Git commands on `.git/worktrees/*/index.lock`.
|
|
disableOptionalLocks: true
|
|
})
|
|
const parsed = parseStatusOutput(stdout)
|
|
head = parsed.head
|
|
branch = parsed.branch
|
|
upstreamStatus = parsed.upstreamStatus
|
|
ignoredPaths = parsed.ignoredPaths
|
|
statusLength = parsed.entries.length
|
|
// Why: cap the entry count to match the local path. A repo with an enormous
|
|
// un-ignored folder would otherwise push tens of thousands of rows through
|
|
// every poll; truncating keeps the SCM view (and its "too many changes"
|
|
// state) consistent across local and SSH repos.
|
|
if (limit !== 0 && parsed.entries.length > limit) {
|
|
didHitLimit = true
|
|
for (let i = 0; i < limit; i++) {
|
|
entries.push(parsed.entries[i])
|
|
}
|
|
} else {
|
|
for (const entry of parsed.entries) {
|
|
entries.push(entry)
|
|
}
|
|
}
|
|
|
|
if (!didHitLimit) {
|
|
if (shouldProbeEffectiveUpstreamStatus(branch, upstreamStatus?.upstreamName)) {
|
|
try {
|
|
upstreamStatus = await getEffectiveGitUpstreamStatus((args) => git(args, worktreePath))
|
|
} catch {
|
|
// Why: status polling should keep returning working-tree entries even
|
|
// if the richer upstream probe hits a transient SSH/git ref error.
|
|
}
|
|
}
|
|
|
|
for (const uLine of parsed.unmergedLines) {
|
|
const entry = parseUnmergedEntry(worktreePath, uLine)
|
|
if (entry) {
|
|
entries.push(entry)
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// not a git repo or git not available
|
|
}
|
|
|
|
// Why: attach per-area line counts for the sidebar. Diffs run after status
|
|
// (we need the entry list first) and only for areas that have entries, so a
|
|
// clean tree costs zero extra git calls. Skipped when the limit was hit —
|
|
// running numstat over a huge change set would reintroduce the cost the limit
|
|
// exists to avoid.
|
|
if (!didHitLimit) {
|
|
await attachLineStats(git, worktreePath, entries)
|
|
}
|
|
|
|
return {
|
|
entries,
|
|
conflictOperation,
|
|
head,
|
|
branch,
|
|
upstreamStatus,
|
|
...(includeIgnored ? { ignoredPaths } : {}),
|
|
...(didHitLimit ? { didHitLimit: true, statusLength } : {})
|
|
}
|
|
}
|
|
|
|
async function runNumstat(
|
|
git: GitExec,
|
|
worktreePath: string,
|
|
cached: boolean
|
|
): Promise<Map<string, GitLineStats>> {
|
|
try {
|
|
const { stdout } = await git(
|
|
['-c', 'core.quotePath=false', 'diff', ...(cached ? ['--cached'] : []), '--numstat', '-M'],
|
|
worktreePath,
|
|
{ disableOptionalLocks: true }
|
|
)
|
|
return parseNumstat(stdout)
|
|
} catch {
|
|
// Why: a numstat failure should leave rows without counts rather than break
|
|
// the whole status refresh.
|
|
return new Map()
|
|
}
|
|
}
|
|
|
|
async function attachLineStats(
|
|
git: GitExec,
|
|
worktreePath: string,
|
|
entries: Record<string, unknown>[]
|
|
): Promise<void> {
|
|
if (entries.length === 0) {
|
|
return
|
|
}
|
|
const hasStaged = entries.some((entry) => entry.area === 'staged')
|
|
const hasUnstaged = entries.some((entry) => entry.area === 'unstaged')
|
|
const untrackedPaths = entries
|
|
.filter((entry) => entry.area === 'untracked')
|
|
.map((entry) => entry.path as string)
|
|
const emptyStats = new Map<string, GitLineStats>()
|
|
const [stagedStats, unstagedStats, untrackedStats] = await Promise.all([
|
|
hasStaged ? runNumstat(git, worktreePath, true) : Promise.resolve(emptyStats),
|
|
hasUnstaged ? runNumstat(git, worktreePath, false) : Promise.resolve(emptyStats),
|
|
collectUntrackedAdditions(worktreePath, untrackedPaths)
|
|
])
|
|
for (const entry of entries) {
|
|
const filePath = entry.path as string
|
|
applyLineStats(
|
|
entry as { added?: number; removed?: number },
|
|
entry.area === 'staged'
|
|
? stagedStats.get(filePath)
|
|
: entry.area === 'unstaged'
|
|
? unstagedStats.get(filePath)
|
|
: untrackedStats.get(filePath)
|
|
)
|
|
}
|
|
}
|
|
|
|
function getShortBranchName(branch: string | undefined): string | null {
|
|
const prefix = 'refs/heads/'
|
|
return branch?.startsWith(prefix) ? branch.slice(prefix.length) : null
|
|
}
|
|
|
|
function shouldProbeEffectiveUpstreamStatus(
|
|
branch: string | undefined,
|
|
upstreamName: string | undefined
|
|
): boolean {
|
|
const branchName = getShortBranchName(branch)
|
|
if (!branchName) {
|
|
return false
|
|
}
|
|
if (!upstreamName) {
|
|
return true
|
|
}
|
|
const parsed = splitRemoteBranchName(upstreamName)
|
|
return parsed?.remoteName === 'origin' && parsed.branchName !== branchName
|
|
}
|
|
|
|
function parseCheckIgnoreOutput(stdout: string): string[] {
|
|
return stdout.split(/\r?\n/).filter(Boolean)
|
|
}
|
|
|
|
export async function checkIgnoredPathsOp(
|
|
git: GitExec,
|
|
params: Record<string, unknown>
|
|
): Promise<string[]> {
|
|
const worktreePath = params.worktreePath as string
|
|
const paths = Array.isArray(params.paths)
|
|
? params.paths.filter((path): path is string => typeof path === 'string' && path.length > 0)
|
|
: []
|
|
if (paths.length === 0) {
|
|
return []
|
|
}
|
|
|
|
try {
|
|
const { stdout } = await git(
|
|
['-c', 'core.quotePath=false', 'check-ignore', '--', ...paths],
|
|
worktreePath
|
|
)
|
|
return parseCheckIgnoreOutput(stdout)
|
|
} catch (error) {
|
|
const gitError = error as Error & { code?: number | string; stdout?: string }
|
|
if (gitError.code === 1) {
|
|
return parseCheckIgnoreOutput(gitError.stdout ?? '')
|
|
}
|
|
throw error
|
|
}
|
|
}
|