mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
174 lines
5.2 KiB
TypeScript
174 lines
5.2 KiB
TypeScript
import { execFile } from 'node:child_process'
|
|
import { platform } from 'node:process'
|
|
import type { Store } from './persistence'
|
|
import type {
|
|
WorkspaceSpaceAnalysis,
|
|
WorkspaceSpaceScanProgress
|
|
} from '../shared/workspace-space-types'
|
|
import { mapWithConcurrency } from '../shared/map-with-concurrency'
|
|
import { escapeRegex } from '../shared/string-utils'
|
|
import {
|
|
WorkspaceSpaceScanCancelledError,
|
|
createWorkspaceSpaceScanLimiter,
|
|
throwIfWorkspaceSpaceScanAborted
|
|
} from './workspace-space-scan-control'
|
|
import {
|
|
scanWorkspaceSpaceRepo,
|
|
summarizeWorkspaceSpaceRows,
|
|
type WorkspaceSpaceAnalyzeOptions,
|
|
type WorkspaceSpaceScanLimiters
|
|
} from './workspace-space-repo-scan'
|
|
|
|
const REPO_SCAN_CONCURRENCY = 2
|
|
const LOCAL_WORKTREE_SCAN_CONCURRENCY = 1
|
|
const REMOTE_FALLBACK_SCAN_CONCURRENCY = 2
|
|
const DU_TIMEOUT_MS = 120_000
|
|
const DU_MAX_BUFFER_BYTES = 16 * 1024 * 1024
|
|
|
|
export { WorkspaceSpaceScanCancelledError }
|
|
|
|
function normalizeLocalDuPath(pathValue: string): string {
|
|
const separator = platform === 'win32' ? '\\' : '/'
|
|
const trimmed = pathValue.replace(new RegExp(`${escapeRegex(separator)}+$`), '')
|
|
return trimmed.length > 0 ? trimmed : pathValue
|
|
}
|
|
|
|
function parseWorkspaceSpaceDuOutput(stdout: string): Map<string, number> {
|
|
const sizes = new Map<string, number>()
|
|
for (const line of stdout.split('\n')) {
|
|
const normalizedLine = line.endsWith('\r') ? line.slice(0, -1) : line
|
|
if (!normalizedLine) {
|
|
continue
|
|
}
|
|
const match = /^(\d+)\s+(.+)$/.exec(normalizedLine)
|
|
if (!match) {
|
|
continue
|
|
}
|
|
sizes.set(normalizeLocalDuPath(match[2]), Number(match[1]) * 1024)
|
|
}
|
|
return sizes
|
|
}
|
|
|
|
async function readLocalDuDepthOne(
|
|
rootPath: string,
|
|
signal?: AbortSignal
|
|
): Promise<Map<string, number>> {
|
|
const stdout = await new Promise<string>((resolve, reject) => {
|
|
let settled = false
|
|
let child: ReturnType<typeof execFile> | undefined
|
|
let onAbort: (() => void) | null = null
|
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
const settle = (callback: () => void): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
if (timer) {
|
|
clearTimeout(timer)
|
|
}
|
|
if (onAbort) {
|
|
signal?.removeEventListener('abort', onAbort)
|
|
}
|
|
callback()
|
|
}
|
|
timer = setTimeout(() => {
|
|
settle(() => {
|
|
child?.kill()
|
|
reject(new Error(`du timed out after ${DU_TIMEOUT_MS}ms`))
|
|
})
|
|
}, DU_TIMEOUT_MS)
|
|
onAbort = () => {
|
|
settle(() => {
|
|
child?.kill()
|
|
reject(new Error('Workspace space scan cancelled'))
|
|
})
|
|
}
|
|
signal?.addEventListener('abort', onAbort, { once: true })
|
|
if (signal?.aborted) {
|
|
onAbort()
|
|
return
|
|
}
|
|
try {
|
|
child = execFile(
|
|
'du',
|
|
['-k', '-d', '1', rootPath],
|
|
{ encoding: 'utf8', maxBuffer: DU_MAX_BUFFER_BYTES, signal, timeout: DU_TIMEOUT_MS },
|
|
(error, output) => {
|
|
if (error) {
|
|
settle(() => reject(error))
|
|
return
|
|
}
|
|
settle(() => resolve(String(output)))
|
|
}
|
|
)
|
|
} catch (error) {
|
|
settle(() => reject(error))
|
|
}
|
|
})
|
|
return parseWorkspaceSpaceDuOutput(stdout)
|
|
}
|
|
|
|
export async function analyzeWorkspaceSpace(
|
|
store: Store,
|
|
options: WorkspaceSpaceAnalyzeOptions = {}
|
|
): Promise<WorkspaceSpaceAnalysis> {
|
|
throwIfWorkspaceSpaceScanAborted(options.signal)
|
|
const scannedAt = Date.now()
|
|
const reposToScan = store.getRepos()
|
|
const progress: WorkspaceSpaceScanProgress = {
|
|
scanId: options.scanId ?? String(scannedAt),
|
|
state: 'running',
|
|
startedAt: scannedAt,
|
|
updatedAt: scannedAt,
|
|
totalRepoCount: reposToScan.length,
|
|
scannedRepoCount: 0,
|
|
totalWorktreeCount: 0,
|
|
scannedWorktreeCount: 0,
|
|
currentRepoDisplayName: null,
|
|
currentWorktreeDisplayName: null
|
|
}
|
|
options.onProgress?.({ ...progress })
|
|
const limiters: WorkspaceSpaceScanLimiters = {
|
|
localWorktree: createWorkspaceSpaceScanLimiter(LOCAL_WORKTREE_SCAN_CONCURRENCY, options.signal),
|
|
remoteFallbackTraversal: createWorkspaceSpaceScanLimiter(
|
|
REMOTE_FALLBACK_SCAN_CONCURRENCY,
|
|
options.signal
|
|
)
|
|
}
|
|
const repoResults = await mapWithConcurrency(reposToScan, REPO_SCAN_CONCURRENCY, (repo) =>
|
|
scanWorkspaceSpaceRepo({
|
|
repo,
|
|
scannedAt,
|
|
store,
|
|
limiters,
|
|
progress,
|
|
options,
|
|
readLocalDuDepthOne,
|
|
normalizeLocalDuPath
|
|
})
|
|
)
|
|
throwIfWorkspaceSpaceScanAborted(options.signal)
|
|
const repos = repoResults.map((result) => result.summary)
|
|
const worktrees = repoResults
|
|
.flatMap((result) => result.worktrees)
|
|
.sort((a, b) => b.sizeBytes - a.sizeBytes || a.displayName.localeCompare(b.displayName))
|
|
throwIfWorkspaceSpaceScanAborted(options.signal)
|
|
const summary = summarizeWorkspaceSpaceRows(worktrees)
|
|
let unavailableRepoCount = 0
|
|
for (const repo of repos) {
|
|
if (repo.error !== null) {
|
|
unavailableRepoCount += 1
|
|
}
|
|
}
|
|
return {
|
|
scannedAt,
|
|
totalSizeBytes: summary.totalSizeBytes,
|
|
reclaimableBytes: summary.reclaimableBytes,
|
|
worktreeCount: worktrees.length,
|
|
scannedWorktreeCount: summary.scannedWorktreeCount,
|
|
unavailableWorktreeCount: summary.unavailableWorktreeCount + unavailableRepoCount,
|
|
repos,
|
|
worktrees
|
|
}
|
|
}
|