960 lines
26 KiB
TypeScript
960 lines
26 KiB
TypeScript
/* eslint-disable max-lines -- Why: this module keeps local and SSH directory-walk
|
|
semantics paired so reclaimable-byte, symlink, and partial-failure behavior cannot drift. */
|
|
import { lstat, readdir } from 'node:fs/promises'
|
|
import { execFile } from 'node:child_process'
|
|
import { posix, win32 } from 'node:path'
|
|
import { platform } from 'node:process'
|
|
import type { Dirent } from 'node:fs'
|
|
import type { Store } from './persistence'
|
|
import { isFolderRepo } from '../shared/repo-kind'
|
|
import type { GitWorktreeInfo, Repo, Worktree } from '../shared/types'
|
|
import type {
|
|
WorkspaceSpaceAnalysis,
|
|
WorkspaceSpaceDirectoryScanResult,
|
|
WorkspaceSpaceItem,
|
|
WorkspaceSpaceItemKind,
|
|
WorkspaceSpaceRepoSummary,
|
|
WorkspaceSpaceScanProgress,
|
|
WorkspaceSpaceScanStatus,
|
|
WorkspaceSpaceWorktree
|
|
} from '../shared/workspace-space-types'
|
|
import { compactWorkspaceSpaceItems } from '../shared/workspace-space-compaction'
|
|
import type { IFilesystemProvider } from './providers/types'
|
|
import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch'
|
|
import { getSshGitProvider } from './providers/ssh-git-dispatch'
|
|
import { createFolderWorktree, listRepoWorktrees } from './repo-worktrees'
|
|
import { mergeWorktree } from './ipc/worktree-logic'
|
|
|
|
const WORKTREE_SCAN_CONCURRENCY = 3
|
|
const LOCAL_FS_CONCURRENCY = 48
|
|
const REMOTE_FS_CONCURRENCY = 10
|
|
const DU_TIMEOUT_MS = 120_000
|
|
const DU_MAX_BUFFER_BYTES = 16 * 1024 * 1024
|
|
|
|
type AsyncLimiter = <T>(task: () => Promise<T>) => Promise<T>
|
|
|
|
type ScanStats = {
|
|
name: string
|
|
path: string
|
|
kind: WorkspaceSpaceItemKind
|
|
sizeBytes: number
|
|
skippedEntryCount: number
|
|
children?: ScanStats[]
|
|
}
|
|
|
|
type WorktreeListResult =
|
|
| { ok: true; worktrees: GitWorktreeInfo[] }
|
|
| { ok: false; status: Exclude<WorkspaceSpaceScanStatus, 'ok'>; error: string }
|
|
|
|
type RepoScanResult = {
|
|
summary: WorkspaceSpaceRepoSummary
|
|
worktrees: WorkspaceSpaceWorktree[]
|
|
}
|
|
|
|
type WorkspaceSpaceAnalyzeOptions = {
|
|
signal?: AbortSignal
|
|
scanId?: string
|
|
onProgress?: (progress: WorkspaceSpaceScanProgress) => void
|
|
}
|
|
|
|
type WorkspaceSpaceProgressState = WorkspaceSpaceScanProgress
|
|
|
|
export class WorkspaceSpaceScanCancelledError extends Error {
|
|
constructor() {
|
|
super('Workspace space scan cancelled')
|
|
this.name = 'WorkspaceSpaceScanCancelledError'
|
|
}
|
|
}
|
|
|
|
function throwIfAborted(signal: AbortSignal | undefined): void {
|
|
if (signal?.aborted) {
|
|
throw new WorkspaceSpaceScanCancelledError()
|
|
}
|
|
}
|
|
|
|
function isAbortError(error: unknown): boolean {
|
|
if (!error || typeof error !== 'object') {
|
|
return false
|
|
}
|
|
return (error as { name?: unknown }).name === 'AbortError'
|
|
}
|
|
|
|
function isRelayMethodNotFoundError(error: unknown): boolean {
|
|
if (!error || typeof error !== 'object') {
|
|
return false
|
|
}
|
|
return (error as { code?: unknown }).code === -32601
|
|
}
|
|
|
|
function createAsyncLimiter(maxConcurrent: number, signal?: AbortSignal): AsyncLimiter {
|
|
let active = 0
|
|
const queue: { resolve: () => void; reject: (error: Error) => void }[] = []
|
|
|
|
const acquire = async (): Promise<void> => {
|
|
throwIfAborted(signal)
|
|
if (active < maxConcurrent) {
|
|
active += 1
|
|
return
|
|
}
|
|
await new Promise<void>((resolve, reject) => {
|
|
let onAbort: (() => void) | null = null
|
|
const waiter = {
|
|
resolve: () => {
|
|
if (onAbort) {
|
|
signal?.removeEventListener('abort', onAbort)
|
|
}
|
|
resolve()
|
|
},
|
|
reject
|
|
}
|
|
onAbort = () => {
|
|
const index = queue.indexOf(waiter)
|
|
if (index !== -1) {
|
|
queue.splice(index, 1)
|
|
}
|
|
reject(new WorkspaceSpaceScanCancelledError())
|
|
}
|
|
queue.push(waiter)
|
|
if (signal) {
|
|
signal.addEventListener('abort', onAbort, { once: true })
|
|
if (signal.aborted) {
|
|
onAbort()
|
|
}
|
|
}
|
|
})
|
|
throwIfAborted(signal)
|
|
active += 1
|
|
}
|
|
|
|
return async <T>(task: () => Promise<T>): Promise<T> => {
|
|
await acquire()
|
|
try {
|
|
return await task()
|
|
} finally {
|
|
active -= 1
|
|
const next = queue.shift()
|
|
next?.resolve()
|
|
}
|
|
}
|
|
}
|
|
|
|
async function mapLimit<T, R>(
|
|
items: readonly T[],
|
|
maxConcurrent: number,
|
|
mapper: (item: T) => Promise<R>
|
|
): Promise<R[]> {
|
|
const limit = createAsyncLimiter(maxConcurrent)
|
|
return Promise.all(items.map((item) => limit(() => mapper(item))))
|
|
}
|
|
|
|
function looksLikeWindowsPath(pathValue: string): boolean {
|
|
return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\')
|
|
}
|
|
|
|
function basenameFilesystemPath(pathValue: string): string {
|
|
return looksLikeWindowsPath(pathValue) ? win32.basename(pathValue) : posix.basename(pathValue)
|
|
}
|
|
|
|
function joinFilesystemPath(parent: string, child: string): string {
|
|
return looksLikeWindowsPath(parent) ? win32.join(parent, child) : posix.join(parent, child)
|
|
}
|
|
|
|
function escapeRegExp(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
}
|
|
|
|
function normalizeLocalDuPath(pathValue: string): string {
|
|
const separator = platform === 'win32' ? '\\' : '/'
|
|
const trimmed = pathValue.replace(new RegExp(`${escapeRegExp(separator)}+$`), '')
|
|
return trimmed.length > 0 ? trimmed : pathValue
|
|
}
|
|
|
|
function parseDuDepthOneOutput(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
|
|
}
|
|
|
|
// Why: execFile's timeout only signals `du`; a wedged child that never
|
|
// calls back must not block the Space scan or its portable fallback.
|
|
try {
|
|
child = execFile(
|
|
'du',
|
|
['-k', '-d', '1', rootPath],
|
|
{
|
|
encoding: 'utf8',
|
|
maxBuffer: DU_MAX_BUFFER_BYTES,
|
|
signal,
|
|
timeout: DU_TIMEOUT_MS
|
|
},
|
|
(error, stdout) => {
|
|
if (error) {
|
|
settle(() => reject(error))
|
|
return
|
|
}
|
|
settle(() => resolve(String(stdout)))
|
|
}
|
|
)
|
|
} catch (error) {
|
|
settle(() => reject(error))
|
|
}
|
|
})
|
|
return parseDuDepthOneOutput(stdout)
|
|
}
|
|
|
|
function classifyError(error: unknown): {
|
|
status: Exclude<WorkspaceSpaceScanStatus, 'ok'>
|
|
message: string
|
|
} {
|
|
const code =
|
|
error && typeof error === 'object' && 'code' in error
|
|
? String((error as { code?: unknown }).code)
|
|
: ''
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
|
|
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
|
return { status: 'missing', message }
|
|
}
|
|
if (code === 'EACCES' || code === 'EPERM') {
|
|
return { status: 'permission-denied', message }
|
|
}
|
|
return { status: 'error', message }
|
|
}
|
|
|
|
function toWorkspaceSpaceItem(stats: ScanStats): WorkspaceSpaceItem {
|
|
return {
|
|
name: stats.name,
|
|
path: stats.path,
|
|
kind: stats.kind,
|
|
sizeBytes: stats.sizeBytes
|
|
}
|
|
}
|
|
|
|
function createBaseWorktreeRow(
|
|
repo: Repo,
|
|
worktree: Worktree,
|
|
scannedAt: number
|
|
): Omit<
|
|
WorkspaceSpaceWorktree,
|
|
| 'status'
|
|
| 'error'
|
|
| 'sizeBytes'
|
|
| 'reclaimableBytes'
|
|
| 'skippedEntryCount'
|
|
| 'topLevelItems'
|
|
| 'omittedTopLevelItemCount'
|
|
| 'omittedTopLevelSizeBytes'
|
|
> {
|
|
const canDelete = !worktree.isMainWorktree
|
|
return {
|
|
worktreeId: worktree.id,
|
|
repoId: repo.id,
|
|
repoDisplayName: repo.displayName,
|
|
repoPath: repo.path,
|
|
displayName: worktree.displayName,
|
|
path: worktree.path,
|
|
branch: worktree.branch,
|
|
isMainWorktree: worktree.isMainWorktree,
|
|
isRemote: Boolean(repo.connectionId),
|
|
isSparse: worktree.isSparse === true,
|
|
canDelete,
|
|
lastActivityAt: worktree.lastActivityAt,
|
|
scannedAt
|
|
}
|
|
}
|
|
|
|
function createUnavailableWorktreeRow(
|
|
repo: Repo,
|
|
worktree: Worktree,
|
|
scannedAt: number,
|
|
status: Exclude<WorkspaceSpaceScanStatus, 'ok'>,
|
|
error: string
|
|
): WorkspaceSpaceWorktree {
|
|
return {
|
|
...createBaseWorktreeRow(repo, worktree, scannedAt),
|
|
status,
|
|
error,
|
|
sizeBytes: 0,
|
|
reclaimableBytes: 0,
|
|
skippedEntryCount: 0,
|
|
topLevelItems: [],
|
|
omittedTopLevelItemCount: 0,
|
|
omittedTopLevelSizeBytes: 0
|
|
}
|
|
}
|
|
|
|
function createScannedWorktreeRow(
|
|
repo: Repo,
|
|
worktree: Worktree,
|
|
scannedAt: number,
|
|
scan: WorkspaceSpaceDirectoryScanResult
|
|
): WorkspaceSpaceWorktree {
|
|
return {
|
|
...createBaseWorktreeRow(repo, worktree, scannedAt),
|
|
status: 'ok',
|
|
error: null,
|
|
sizeBytes: scan.sizeBytes,
|
|
reclaimableBytes: worktree.isMainWorktree ? 0 : scan.sizeBytes,
|
|
skippedEntryCount: scan.skippedEntryCount,
|
|
topLevelItems: scan.topLevelItems,
|
|
omittedTopLevelItemCount: scan.omittedTopLevelItemCount,
|
|
omittedTopLevelSizeBytes: scan.omittedTopLevelSizeBytes
|
|
}
|
|
}
|
|
|
|
async function scanLocalEntry(
|
|
entryPath: string,
|
|
name: string,
|
|
limit: AsyncLimiter,
|
|
signal?: AbortSignal
|
|
): Promise<ScanStats> {
|
|
throwIfAborted(signal)
|
|
const stats = await limit(() => lstat(entryPath))
|
|
throwIfAborted(signal)
|
|
|
|
if (stats.isSymbolicLink()) {
|
|
return {
|
|
name,
|
|
path: entryPath,
|
|
kind: 'symlink',
|
|
// Why: symlink targets may be shared outside the worktree. Counting the
|
|
// link itself reflects what deleting this worktree can actually reclaim.
|
|
sizeBytes: stats.size,
|
|
skippedEntryCount: 0
|
|
}
|
|
}
|
|
|
|
if (!stats.isDirectory()) {
|
|
return {
|
|
name,
|
|
path: entryPath,
|
|
kind: 'file',
|
|
sizeBytes: stats.size,
|
|
skippedEntryCount: 0
|
|
}
|
|
}
|
|
|
|
let entries: Dirent[]
|
|
try {
|
|
entries = await limit(() => readdir(entryPath, { withFileTypes: true }))
|
|
} catch {
|
|
return {
|
|
name,
|
|
path: entryPath,
|
|
kind: 'directory',
|
|
sizeBytes: stats.size,
|
|
skippedEntryCount: 1
|
|
}
|
|
}
|
|
|
|
const childStats = await Promise.all(
|
|
entries.map(async (entry): Promise<ScanStats | null> => {
|
|
try {
|
|
return await scanLocalEntry(
|
|
joinFilesystemPath(entryPath, entry.name),
|
|
entry.name,
|
|
limit,
|
|
signal
|
|
)
|
|
} catch (error) {
|
|
if (error instanceof WorkspaceSpaceScanCancelledError) {
|
|
throw error
|
|
}
|
|
return null
|
|
}
|
|
})
|
|
)
|
|
|
|
let sizeBytes = stats.size
|
|
let skippedEntryCount = 0
|
|
for (const child of childStats) {
|
|
if (!child) {
|
|
skippedEntryCount += 1
|
|
continue
|
|
}
|
|
sizeBytes += child.sizeBytes
|
|
skippedEntryCount += child.skippedEntryCount
|
|
}
|
|
|
|
return {
|
|
name,
|
|
path: entryPath,
|
|
kind: 'directory',
|
|
sizeBytes,
|
|
skippedEntryCount,
|
|
children: childStats.filter((child): child is ScanStats => child !== null)
|
|
}
|
|
}
|
|
|
|
async function scanRemoteEntry(
|
|
entryPath: string,
|
|
name: string,
|
|
provider: IFilesystemProvider,
|
|
limit: AsyncLimiter,
|
|
signal?: AbortSignal,
|
|
knownSymlink = false
|
|
): Promise<ScanStats> {
|
|
throwIfAborted(signal)
|
|
if (knownSymlink) {
|
|
return {
|
|
name,
|
|
path: entryPath,
|
|
kind: 'symlink',
|
|
sizeBytes: 0,
|
|
skippedEntryCount: 0
|
|
}
|
|
}
|
|
|
|
const stats = await limit(() => provider.stat(entryPath))
|
|
throwIfAborted(signal)
|
|
if (stats.type === 'symlink') {
|
|
return {
|
|
name,
|
|
path: entryPath,
|
|
kind: 'symlink',
|
|
sizeBytes: stats.size,
|
|
skippedEntryCount: 0
|
|
}
|
|
}
|
|
|
|
if (stats.type !== 'directory') {
|
|
return {
|
|
name,
|
|
path: entryPath,
|
|
kind: 'file',
|
|
sizeBytes: stats.size,
|
|
skippedEntryCount: 0
|
|
}
|
|
}
|
|
|
|
let entries
|
|
try {
|
|
entries = await limit(() => provider.readDir(entryPath))
|
|
throwIfAborted(signal)
|
|
} catch (error) {
|
|
if (error instanceof WorkspaceSpaceScanCancelledError) {
|
|
throw error
|
|
}
|
|
return {
|
|
name,
|
|
path: entryPath,
|
|
kind: 'directory',
|
|
sizeBytes: stats.size,
|
|
skippedEntryCount: 1
|
|
}
|
|
}
|
|
|
|
const childStats = await Promise.all(
|
|
entries.map(async (entry): Promise<ScanStats | null> => {
|
|
try {
|
|
return await scanRemoteEntry(
|
|
joinFilesystemPath(entryPath, entry.name),
|
|
entry.name,
|
|
provider,
|
|
limit,
|
|
signal,
|
|
entry.isSymlink
|
|
)
|
|
} catch (error) {
|
|
if (error instanceof WorkspaceSpaceScanCancelledError) {
|
|
throw error
|
|
}
|
|
return null
|
|
}
|
|
})
|
|
)
|
|
|
|
let sizeBytes = stats.size
|
|
let skippedEntryCount = 0
|
|
for (const child of childStats) {
|
|
if (!child) {
|
|
skippedEntryCount += 1
|
|
continue
|
|
}
|
|
sizeBytes += child.sizeBytes
|
|
skippedEntryCount += child.skippedEntryCount
|
|
}
|
|
|
|
return {
|
|
name,
|
|
path: entryPath,
|
|
kind: 'directory',
|
|
sizeBytes,
|
|
skippedEntryCount,
|
|
children: childStats.filter((child): child is ScanStats => child !== null)
|
|
}
|
|
}
|
|
|
|
async function scanLocalTopLevelEntry(
|
|
entryPath: string,
|
|
name: string,
|
|
duSizes: Map<string, number>,
|
|
limit: AsyncLimiter,
|
|
signal?: AbortSignal
|
|
): Promise<ScanStats> {
|
|
throwIfAborted(signal)
|
|
const stats = await limit(() => lstat(entryPath))
|
|
throwIfAborted(signal)
|
|
|
|
if (stats.isSymbolicLink()) {
|
|
return {
|
|
name,
|
|
path: entryPath,
|
|
kind: 'symlink',
|
|
sizeBytes: stats.size,
|
|
skippedEntryCount: 0
|
|
}
|
|
}
|
|
|
|
if (!stats.isDirectory()) {
|
|
return {
|
|
name,
|
|
path: entryPath,
|
|
kind: 'file',
|
|
sizeBytes: stats.size,
|
|
skippedEntryCount: 0
|
|
}
|
|
}
|
|
|
|
return {
|
|
name,
|
|
path: entryPath,
|
|
kind: 'directory',
|
|
sizeBytes: duSizes.get(normalizeLocalDuPath(entryPath)) ?? stats.size,
|
|
skippedEntryCount: 0
|
|
}
|
|
}
|
|
|
|
async function scanLocalWorktreeWithDu(
|
|
repo: Repo,
|
|
worktree: Worktree,
|
|
scannedAt: number,
|
|
signal?: AbortSignal
|
|
): Promise<WorkspaceSpaceWorktree> {
|
|
throwIfAborted(signal)
|
|
const rootStats = await lstat(worktree.path)
|
|
if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) {
|
|
const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY, signal)
|
|
const root = await scanLocalEntry(
|
|
worktree.path,
|
|
basenameFilesystemPath(worktree.path),
|
|
limit,
|
|
signal
|
|
)
|
|
const compact = compactWorkspaceSpaceItems((root.children ?? []).map(toWorkspaceSpaceItem))
|
|
return {
|
|
...createBaseWorktreeRow(repo, worktree, scannedAt),
|
|
status: 'ok',
|
|
error: null,
|
|
sizeBytes: root.sizeBytes,
|
|
reclaimableBytes: worktree.isMainWorktree ? 0 : root.sizeBytes,
|
|
skippedEntryCount: root.skippedEntryCount,
|
|
...compact
|
|
}
|
|
}
|
|
|
|
const [entries, duSizes] = await Promise.all([
|
|
readdir(worktree.path, { withFileTypes: true }),
|
|
readLocalDuDepthOne(worktree.path, signal)
|
|
])
|
|
throwIfAborted(signal)
|
|
const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY, signal)
|
|
const childStats = await Promise.all(
|
|
entries.map(async (entry): Promise<ScanStats | null> => {
|
|
try {
|
|
return await scanLocalTopLevelEntry(
|
|
joinFilesystemPath(worktree.path, entry.name),
|
|
entry.name,
|
|
duSizes,
|
|
limit,
|
|
signal
|
|
)
|
|
} catch (error) {
|
|
if (error instanceof WorkspaceSpaceScanCancelledError) {
|
|
throw error
|
|
}
|
|
return null
|
|
}
|
|
})
|
|
)
|
|
const children = childStats.filter((child): child is ScanStats => child !== null)
|
|
const skippedEntryCount = childStats.length - children.length
|
|
const rootSize =
|
|
duSizes.get(normalizeLocalDuPath(worktree.path)) ??
|
|
rootStats.size + children.reduce((sum, child) => sum + child.sizeBytes, 0)
|
|
const compact = compactWorkspaceSpaceItems(children.map(toWorkspaceSpaceItem))
|
|
|
|
return {
|
|
...createBaseWorktreeRow(repo, worktree, scannedAt),
|
|
status: 'ok',
|
|
error: null,
|
|
sizeBytes: rootSize,
|
|
reclaimableBytes: worktree.isMainWorktree ? 0 : rootSize,
|
|
skippedEntryCount,
|
|
...compact
|
|
}
|
|
}
|
|
|
|
async function scanLocalWorktreeWithNode(
|
|
repo: Repo,
|
|
worktree: Worktree,
|
|
scannedAt: number,
|
|
signal?: AbortSignal
|
|
): Promise<WorkspaceSpaceWorktree> {
|
|
try {
|
|
const limit = createAsyncLimiter(LOCAL_FS_CONCURRENCY, signal)
|
|
const root = await scanLocalEntry(
|
|
worktree.path,
|
|
basenameFilesystemPath(worktree.path),
|
|
limit,
|
|
signal
|
|
)
|
|
const compact = compactWorkspaceSpaceItems((root.children ?? []).map(toWorkspaceSpaceItem))
|
|
return {
|
|
...createBaseWorktreeRow(repo, worktree, scannedAt),
|
|
status: 'ok',
|
|
error: null,
|
|
sizeBytes: root.sizeBytes,
|
|
reclaimableBytes: worktree.isMainWorktree ? 0 : root.sizeBytes,
|
|
skippedEntryCount: root.skippedEntryCount,
|
|
...compact
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof WorkspaceSpaceScanCancelledError) {
|
|
throw error
|
|
}
|
|
const classified = classifyError(error)
|
|
return createUnavailableWorktreeRow(
|
|
repo,
|
|
worktree,
|
|
scannedAt,
|
|
classified.status,
|
|
classified.message
|
|
)
|
|
}
|
|
}
|
|
|
|
async function scanLocalWorktree(
|
|
repo: Repo,
|
|
worktree: Worktree,
|
|
scannedAt: number,
|
|
signal?: AbortSignal
|
|
): Promise<WorkspaceSpaceWorktree> {
|
|
throwIfAborted(signal)
|
|
if (platform !== 'win32') {
|
|
try {
|
|
// Why: JS per-file stats are too slow for large local workspace fleets;
|
|
// POSIX du gives bounded top-level sizing without following symlinks.
|
|
return await scanLocalWorktreeWithDu(repo, worktree, scannedAt, signal)
|
|
} catch (error) {
|
|
throwIfAborted(signal)
|
|
if (error instanceof WorkspaceSpaceScanCancelledError) {
|
|
throw error
|
|
}
|
|
// Fall through to the portable scanner so unsupported du variants or
|
|
// permission edge cases still produce partial rows instead of failing.
|
|
}
|
|
}
|
|
return scanLocalWorktreeWithNode(repo, worktree, scannedAt, signal)
|
|
}
|
|
|
|
async function scanRemoteWorktree(
|
|
repo: Repo,
|
|
worktree: Worktree,
|
|
scannedAt: number,
|
|
provider: IFilesystemProvider,
|
|
signal?: AbortSignal
|
|
): Promise<WorkspaceSpaceWorktree> {
|
|
try {
|
|
if (provider.scanWorkspaceSpace) {
|
|
try {
|
|
const scan = await provider.scanWorkspaceSpace(worktree.path, { signal })
|
|
return createScannedWorktreeRow(repo, worktree, scannedAt, scan)
|
|
} catch (error) {
|
|
if (isAbortError(error)) {
|
|
throw new WorkspaceSpaceScanCancelledError()
|
|
}
|
|
if (!isRelayMethodNotFoundError(error)) {
|
|
throw error
|
|
}
|
|
// Why: old SSH relays do not know the bulk Space scan method. Fall
|
|
// back to the request-by-request walker instead of marking SSH rows
|
|
// unavailable after an app upgrade.
|
|
}
|
|
}
|
|
|
|
const limit = createAsyncLimiter(REMOTE_FS_CONCURRENCY, signal)
|
|
const root = await scanRemoteEntry(
|
|
worktree.path,
|
|
basenameFilesystemPath(worktree.path),
|
|
provider,
|
|
limit,
|
|
signal
|
|
)
|
|
const compact = compactWorkspaceSpaceItems((root.children ?? []).map(toWorkspaceSpaceItem))
|
|
return createScannedWorktreeRow(repo, worktree, scannedAt, {
|
|
sizeBytes: root.sizeBytes,
|
|
skippedEntryCount: root.skippedEntryCount,
|
|
...compact
|
|
})
|
|
} catch (error) {
|
|
if (error instanceof WorkspaceSpaceScanCancelledError) {
|
|
throw error
|
|
}
|
|
const classified = classifyError(error)
|
|
return createUnavailableWorktreeRow(
|
|
repo,
|
|
worktree,
|
|
scannedAt,
|
|
classified.status,
|
|
classified.message
|
|
)
|
|
}
|
|
}
|
|
|
|
async function listWorktreesForSpaceScan(
|
|
repo: Repo,
|
|
signal?: AbortSignal
|
|
): Promise<WorktreeListResult> {
|
|
try {
|
|
throwIfAborted(signal)
|
|
if (isFolderRepo(repo)) {
|
|
return { ok: true, worktrees: [createFolderWorktree(repo)] }
|
|
}
|
|
if (repo.connectionId) {
|
|
const provider = getSshGitProvider(repo.connectionId)
|
|
if (!provider) {
|
|
return {
|
|
ok: false,
|
|
status: 'unavailable',
|
|
error: `SSH connection "${repo.connectionId}" is not connected.`
|
|
}
|
|
}
|
|
const worktrees = await provider.listWorktrees(repo.path, { signal })
|
|
throwIfAborted(signal)
|
|
return { ok: true, worktrees }
|
|
}
|
|
const worktrees = await listRepoWorktrees(repo)
|
|
throwIfAborted(signal)
|
|
return { ok: true, worktrees }
|
|
} catch (error) {
|
|
if (error instanceof WorkspaceSpaceScanCancelledError) {
|
|
throw error
|
|
}
|
|
const classified = classifyError(error)
|
|
return { ok: false, status: classified.status, error: classified.message }
|
|
}
|
|
}
|
|
|
|
function mergeForSpaceScan(repo: Repo, gitWorktree: GitWorktreeInfo, store: Store): Worktree {
|
|
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
|
return mergeWorktree(repo.id, gitWorktree, store.getWorktreeMeta(worktreeId), repo.displayName)
|
|
}
|
|
|
|
function reportProgress(
|
|
progress: WorkspaceSpaceProgressState,
|
|
updates: Partial<WorkspaceSpaceProgressState>,
|
|
onProgress: WorkspaceSpaceAnalyzeOptions['onProgress']
|
|
): void {
|
|
Object.assign(progress, updates, { updatedAt: Date.now() })
|
|
onProgress?.({ ...progress })
|
|
}
|
|
|
|
async function scanRepo(
|
|
repo: Repo,
|
|
scannedAt: number,
|
|
store: Store,
|
|
progress: WorkspaceSpaceProgressState,
|
|
options: WorkspaceSpaceAnalyzeOptions
|
|
): Promise<RepoScanResult> {
|
|
throwIfAborted(options.signal)
|
|
reportProgress(
|
|
progress,
|
|
{
|
|
currentRepoDisplayName: repo.displayName,
|
|
currentWorktreeDisplayName: null
|
|
},
|
|
options.onProgress
|
|
)
|
|
const listed = await listWorktreesForSpaceScan(repo, options.signal)
|
|
if (!listed.ok) {
|
|
reportProgress(
|
|
progress,
|
|
{ scannedRepoCount: progress.scannedRepoCount + 1 },
|
|
options.onProgress
|
|
)
|
|
return {
|
|
worktrees: [],
|
|
summary: {
|
|
repoId: repo.id,
|
|
displayName: repo.displayName,
|
|
path: repo.path,
|
|
isRemote: Boolean(repo.connectionId),
|
|
worktreeCount: 0,
|
|
scannedWorktreeCount: 0,
|
|
unavailableWorktreeCount: 1,
|
|
totalSizeBytes: 0,
|
|
reclaimableBytes: 0,
|
|
error: listed.error
|
|
}
|
|
}
|
|
}
|
|
|
|
const worktrees = listed.worktrees.map((gitWorktree) =>
|
|
mergeForSpaceScan(repo, gitWorktree, store)
|
|
)
|
|
reportProgress(
|
|
progress,
|
|
{ totalWorktreeCount: progress.totalWorktreeCount + worktrees.length },
|
|
options.onProgress
|
|
)
|
|
const remoteProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : undefined
|
|
const rows = await mapLimit(worktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => {
|
|
throwIfAborted(options.signal)
|
|
reportProgress(
|
|
progress,
|
|
{
|
|
currentRepoDisplayName: repo.displayName,
|
|
currentWorktreeDisplayName: worktree.displayName
|
|
},
|
|
options.onProgress
|
|
)
|
|
const row: WorkspaceSpaceWorktree = repo.connectionId
|
|
? remoteProvider
|
|
? await scanRemoteWorktree(repo, worktree, scannedAt, remoteProvider, options.signal)
|
|
: createUnavailableWorktreeRow(
|
|
repo,
|
|
worktree,
|
|
scannedAt,
|
|
'unavailable',
|
|
`SSH filesystem for "${repo.connectionId}" is not connected.`
|
|
)
|
|
: await scanLocalWorktree(repo, worktree, scannedAt, options.signal)
|
|
reportProgress(
|
|
progress,
|
|
{ scannedWorktreeCount: progress.scannedWorktreeCount + 1 },
|
|
options.onProgress
|
|
)
|
|
return row
|
|
})
|
|
reportProgress(
|
|
progress,
|
|
{
|
|
scannedRepoCount: progress.scannedRepoCount + 1,
|
|
currentRepoDisplayName: repo.displayName,
|
|
currentWorktreeDisplayName: null
|
|
},
|
|
options.onProgress
|
|
)
|
|
|
|
return {
|
|
worktrees: rows,
|
|
summary: {
|
|
repoId: repo.id,
|
|
displayName: repo.displayName,
|
|
path: repo.path,
|
|
isRemote: Boolean(repo.connectionId),
|
|
worktreeCount: rows.length,
|
|
scannedWorktreeCount: rows.filter((row) => row.status === 'ok').length,
|
|
unavailableWorktreeCount: rows.filter((row) => row.status !== 'ok').length,
|
|
totalSizeBytes: rows.reduce((sum, row) => sum + row.sizeBytes, 0),
|
|
reclaimableBytes: rows.reduce((sum, row) => sum + row.reclaimableBytes, 0),
|
|
error: null
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function analyzeWorkspaceSpace(
|
|
store: Store,
|
|
options: WorkspaceSpaceAnalyzeOptions = {}
|
|
): Promise<WorkspaceSpaceAnalysis> {
|
|
throwIfAborted(options.signal)
|
|
const scannedAt = Date.now()
|
|
const reposToScan = store.getRepos()
|
|
const progress: WorkspaceSpaceProgressState = {
|
|
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 repoResults = await mapLimit(reposToScan, 2, (repo) =>
|
|
scanRepo(repo, scannedAt, store, progress, options)
|
|
)
|
|
throwIfAborted(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))
|
|
throwIfAborted(options.signal)
|
|
|
|
return {
|
|
scannedAt,
|
|
totalSizeBytes: worktrees.reduce((sum, row) => sum + row.sizeBytes, 0),
|
|
reclaimableBytes: worktrees.reduce((sum, row) => sum + row.reclaimableBytes, 0),
|
|
worktreeCount: worktrees.length,
|
|
scannedWorktreeCount: worktrees.filter((row) => row.status === 'ok').length,
|
|
unavailableWorktreeCount:
|
|
worktrees.filter((row) => row.status !== 'ok').length +
|
|
repos.filter((repo) => repo.error !== null).length,
|
|
repos,
|
|
worktrees
|
|
}
|
|
}
|