Remove package manager cache cleanup (#2550)

This commit is contained in:
Jinjing 2026-05-21 13:22:37 -07:00 committed by GitHub
parent 889c7d7621
commit 79137885c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 12 additions and 1807 deletions

View File

@ -9,7 +9,6 @@ import type { Store } from '../persistence'
const {
handlers,
analyzeWorkspaceSpaceMock,
runPackageManagerCacheCleanupMock,
removeHandlerMock,
handleMock,
WorkspaceSpaceScanCancelledErrorMock
@ -18,7 +17,6 @@ const {
return {
handlers,
analyzeWorkspaceSpaceMock: vi.fn(),
runPackageManagerCacheCleanupMock: vi.fn(),
removeHandlerMock: vi.fn(),
handleMock: vi.fn((channel: string, handler: (...args: unknown[]) => Promise<unknown>) => {
handlers.set(channel, handler)
@ -39,10 +37,6 @@ vi.mock('../workspace-space-analysis', () => ({
analyzeWorkspaceSpace: analyzeWorkspaceSpaceMock
}))
vi.mock('../workspace-package-manager-cache-cleanup', () => ({
runPackageManagerCacheCleanup: runPackageManagerCacheCleanupMock
}))
import { registerWorkspaceSpaceHandlers } from './workspace-space'
function createAnalysis(scannedAt: number): WorkspaceSpaceAnalysis {
@ -53,7 +47,6 @@ function createAnalysis(scannedAt: number): WorkspaceSpaceAnalysis {
worktreeCount: 0,
scannedWorktreeCount: 0,
unavailableWorktreeCount: 0,
packageManagerCaches: [],
repos: [],
worktrees: []
}
@ -75,7 +68,6 @@ function createEvent() {
describe('registerWorkspaceSpaceHandlers', () => {
beforeEach(() => {
analyzeWorkspaceSpaceMock.mockReset()
runPackageManagerCacheCleanupMock.mockReset()
})
it('shares an in-flight analysis request', async () => {
@ -173,46 +165,4 @@ describe('registerWorkspaceSpaceHandlers', () => {
await expect(analyzeHandler!(createEvent())).resolves.toEqual({ ok: false, cancelled: true })
})
it('runs package-manager cache cleanup only through the explicit cleanup handler', async () => {
const store = {} as Store
const cleanupResult = {
ok: true,
action: {
id: 'npm-cache-verify',
packageManager: 'npm',
safety: 'safe',
binary: 'npm',
args: ['cache', 'verify'],
command: 'npm cache verify',
label: 'Verify npm cache',
description: 'Verifies cache integrity and garbage-collects unneeded npm cache data.'
},
stdout: 'verified\n',
stderr: '',
cachePath: '/home/alice/.npm',
cacheSizeBeforeBytes: 4096,
cacheSizeAfterBytes: 1024,
reclaimedBytes: 3072
}
runPackageManagerCacheCleanupMock.mockResolvedValueOnce(cleanupResult)
registerWorkspaceSpaceHandlers(store)
const analyzeHandler = handlers.get('workspaceSpace:analyze')
const cleanupHandler = handlers.get('workspaceSpace:cleanupPackageManagerCache')
analyzeWorkspaceSpaceMock.mockResolvedValueOnce(createAnalysis(1))
await expect(analyzeHandler!(createEvent())).resolves.toEqual(createAnalyzeResult(1))
expect(runPackageManagerCacheCleanupMock).not.toHaveBeenCalled()
const request = {
targetId: 'local:npm:%2Frepo',
actionId: 'npm-cache-verify',
packageManager: 'npm',
connectionId: null,
cwd: '/repo'
}
await expect(cleanupHandler!(createEvent(), request)).resolves.toEqual(cleanupResult)
expect(runPackageManagerCacheCleanupMock).toHaveBeenCalledWith(request)
})
})

View File

@ -1,8 +1,6 @@
import { ipcMain } from 'electron'
import type { Store } from '../persistence'
import type {
WorkspacePackageManagerCacheCleanupRequest,
WorkspacePackageManagerCacheCleanupResult,
WorkspaceSpaceAnalyzeResult,
WorkspaceSpaceScanProgress
} from '../../shared/workspace-space-types'
@ -10,7 +8,6 @@ import {
analyzeWorkspaceSpace,
WorkspaceSpaceScanCancelledError
} from '../workspace-space-analysis'
import { runPackageManagerCacheCleanup } from '../workspace-package-manager-cache-cleanup'
const PROGRESS_EMIT_INTERVAL_MS = 100
@ -25,7 +22,6 @@ export function registerWorkspaceSpaceHandlers(store: Store): void {
let inFlightScan: InFlightWorkspaceSpaceScan | null = null
ipcMain.removeHandler('workspaceSpace:cancel')
ipcMain.removeHandler('workspaceSpace:analyze')
ipcMain.removeHandler('workspaceSpace:cleanupPackageManagerCache')
ipcMain.handle('workspaceSpace:analyze', async (event): Promise<WorkspaceSpaceAnalyzeResult> => {
if (!inFlightScan) {
const controller = new AbortController()
@ -108,12 +104,4 @@ export function registerWorkspaceSpaceHandlers(store: Store): void {
}
return true
})
ipcMain.handle(
'workspaceSpace:cleanupPackageManagerCache',
async (
_event,
request: WorkspacePackageManagerCacheCleanupRequest
): Promise<WorkspacePackageManagerCacheCleanupResult> => runPackageManagerCacheCleanup(request)
)
}

View File

@ -1,375 +0,0 @@
/* eslint-disable max-lines -- Why: cache detection and cleanup regressions share one mocked SSH/runner setup. */
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { IFilesystemProvider } from './providers/types'
const { commandExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({
commandExecFileAsyncMock: vi.fn(),
getSshGitProviderMock: vi.fn()
}))
vi.mock('./git/runner', () => ({
commandExecFileAsync: commandExecFileAsyncMock
}))
vi.mock('./providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock
}))
import {
buildPackageManagerCacheTargets,
detectPackageManagersForDirectoryEntries,
detectRemotePackageManagers,
runPackageManagerCacheCleanup
} from './workspace-package-manager-cache-cleanup'
async function waitForCallCount(
mock: { mock: { calls: unknown[] } },
count: number
): Promise<void> {
for (let i = 0; i < 20; i++) {
if (mock.mock.calls.length >= count) {
return
}
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
}
describe('workspace package-manager cache cleanup', () => {
let tempDir: string | null = null
beforeEach(() => {
commandExecFileAsyncMock.mockReset()
getSshGitProviderMock.mockReset()
tempDir = null
})
afterEach(async () => {
if (tempDir) {
await rm(tempDir, { recursive: true, force: true })
tempDir = null
}
})
it('groups lockfile detections by target and CLI availability', async () => {
commandExecFileAsyncMock.mockResolvedValue({ stdout: '10.0.0\n', stderr: '' })
const detections = detectPackageManagersForDirectoryEntries({
entryNames: ['package.json', 'pnpm-lock.yaml', 'package-lock.json'],
connectionId: null,
isRemote: false,
repoDisplayName: 'orca',
worktreeId: 'repo-1::/repo',
worktreePath: '/repo'
})
const targets = await buildPackageManagerCacheTargets(detections)
expect(targets).toHaveLength(2)
expect(targets.map((target) => target.packageManager).sort()).toEqual(['npm', 'pnpm'])
expect(targets.every((target) => target.cliAvailable)).toBe(true)
expect(
targets.map((target) => ({
packageManager: target.packageManager,
detectedWorktrees: target.detectedWorktrees
}))
).toEqual([
{
packageManager: 'npm',
detectedWorktrees: [{ worktreeId: 'repo-1::/repo', lockfiles: ['package-lock.json'] }]
},
{
packageManager: 'pnpm',
detectedWorktrees: [{ worktreeId: 'repo-1::/repo', lockfiles: ['pnpm-lock.yaml'] }]
}
])
expect(commandExecFileAsyncMock).toHaveBeenCalledWith(
'pnpm',
['--version'],
expect.objectContaining({ cwd: '/repo' })
)
expect(commandExecFileAsyncMock).toHaveBeenCalledWith(
'npm',
['--version'],
expect.objectContaining({ cwd: '/repo' })
)
expect(commandExecFileAsyncMock).not.toHaveBeenCalledWith(
'pnpm',
['store', 'prune'],
expect.anything()
)
expect(commandExecFileAsyncMock).not.toHaveBeenCalledWith(
'npm',
['cache', 'clean', '--force'],
expect.anything()
)
})
it('marks detected package managers unavailable when the CLI is missing', async () => {
commandExecFileAsyncMock.mockRejectedValue(
Object.assign(new Error('spawn pnpm ENOENT'), {
code: 'ENOENT'
})
)
const targets = await buildPackageManagerCacheTargets([
{
packageManager: 'pnpm',
connectionId: null,
isRemote: false,
repoDisplayName: 'orca',
worktreeId: 'repo-1::/repo',
worktreePath: '/repo',
lockfiles: ['pnpm-lock.yaml']
}
])
expect(targets[0]).toMatchObject({
packageManager: 'pnpm',
cliAvailable: false,
unavailableReason:
'pnpm was detected by lockfile, but its CLI was not available on this target.'
})
})
it('keeps project-specific npm cache paths as separate cleanup targets', async () => {
commandExecFileAsyncMock.mockImplementation(
async (binary: string, args: string[], options: { cwd?: string }) => {
if (binary === 'npm' && args.join(' ') === '--version') {
return { stdout: '10.0.0\n', stderr: '' }
}
if (binary === 'npm' && args.join(' ') === 'config get cache') {
return {
stdout: options.cwd === '/repo-a' ? '/cache/a\n' : '/cache/b\n',
stderr: ''
}
}
throw new Error(`unexpected command ${binary} ${args.join(' ')}`)
}
)
const targets = await buildPackageManagerCacheTargets([
{
packageManager: 'npm',
connectionId: null,
isRemote: false,
repoDisplayName: 'repo-a',
worktreeId: 'repo-a::/repo-a',
worktreePath: '/repo-a',
lockfiles: ['package-lock.json']
},
{
packageManager: 'npm',
connectionId: null,
isRemote: false,
repoDisplayName: 'repo-b',
worktreeId: 'repo-b::/repo-b',
worktreePath: '/repo-b',
lockfiles: ['package-lock.json']
}
])
expect(targets).toHaveLength(2)
expect(targets.map((target) => target.cachePath).sort()).toEqual(['/cache/a', '/cache/b'])
expect(targets.map((target) => target.cwd).sort()).toEqual(['/repo-a', '/repo-b'])
})
it('propagates scan cancellation while checking local CLI availability', async () => {
const signal = new AbortController().signal
const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' })
commandExecFileAsyncMock.mockRejectedValue(abortError)
await expect(
buildPackageManagerCacheTargets(
[
{
packageManager: 'pnpm',
connectionId: null,
isRemote: false,
repoDisplayName: 'orca',
worktreeId: 'repo-1::/repo',
worktreePath: '/repo',
lockfiles: ['pnpm-lock.yaml']
}
],
{ signal }
)
).rejects.toBe(abortError)
expect(commandExecFileAsyncMock).toHaveBeenCalledWith(
'pnpm',
['--version'],
expect.objectContaining({ cwd: '/repo', signal })
)
})
it('detects remote lockfiles and checks the remote CLI through SSH', async () => {
const provider = {
readDir: vi.fn().mockResolvedValue([{ name: 'bun.lock' }])
} as unknown as IFilesystemProvider
const execNonInteractive = vi.fn().mockResolvedValue({
stdout: '1.3.0\n',
stderr: '',
exitCode: 0,
timedOut: false
})
getSshGitProviderMock.mockReturnValue({ execNonInteractive })
const detections = await detectRemotePackageManagers({
provider,
connectionId: 'ssh-1',
repoDisplayName: 'remote',
worktreeId: 'repo-remote::/remote/repo',
worktreePath: '/remote/repo'
})
const targets = await buildPackageManagerCacheTargets(detections)
expect(targets[0]).toMatchObject({
packageManager: 'bun',
connectionId: 'ssh-1',
isRemote: true,
cliAvailable: true
})
expect(execNonInteractive).toHaveBeenCalledWith(
'bun',
['--version'],
'/remote/repo',
8000,
undefined
)
})
it('cancels remote CLI availability checks through the SSH relay', async () => {
const controller = new AbortController()
const execNonInteractive = vi.fn(
() =>
new Promise<never>(() => {
// Keep the relay request pending until the scan cancellation wins.
})
)
const cancelNonInteractiveExec = vi.fn().mockResolvedValue(undefined)
getSshGitProviderMock.mockReturnValue({ execNonInteractive, cancelNonInteractiveExec })
const promise = buildPackageManagerCacheTargets(
[
{
packageManager: 'pnpm',
connectionId: 'ssh-1',
isRemote: true,
repoDisplayName: 'remote',
worktreeId: 'repo-remote::/remote/repo',
worktreePath: '/remote/repo',
lockfiles: ['pnpm-lock.yaml']
}
],
{ signal: controller.signal }
)
await waitForCallCount(execNonInteractive, 1)
controller.abort()
await expect(promise).rejects.toMatchObject({ name: 'AbortError' })
expect(execNonInteractive).toHaveBeenCalledWith(
'pnpm',
['--version'],
'/remote/repo',
8000,
controller.signal
)
expect(cancelNonInteractiveExec).not.toHaveBeenCalled()
})
it('runs only known cleanup commands from a validated action id', async () => {
commandExecFileAsyncMock.mockResolvedValue({
stdout: 'Removed 1.2 GB\n',
stderr: ''
})
const result = await runPackageManagerCacheCleanup({
targetId: 'local:pnpm:%2Frepo',
actionId: 'pnpm-store-prune',
packageManager: 'pnpm',
connectionId: null,
cwd: '/repo'
})
expect(result).toMatchObject({ ok: true })
expect(commandExecFileAsyncMock).toHaveBeenCalledWith(
'pnpm',
['store', 'prune'],
expect.objectContaining({ cwd: '/repo' })
)
await expect(
runPackageManagerCacheCleanup({
targetId: 'local:pnpm:%2Frepo',
actionId: 'rm-rf-cache',
packageManager: 'pnpm',
connectionId: null,
cwd: '/repo'
})
).resolves.toEqual({
ok: false,
error: 'Unknown package-manager cache cleanup action.'
})
})
it('measures pnpm store size before and after cleanup when possible', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'orca-pnpm-store-'))
const storePath = join(tempDir, 'store')
const packagePath = join(storePath, 'v10', 'files')
const packageFile = join(packagePath, 'pkg.tgz')
await mkdir(packagePath, { recursive: true })
await writeFile(packageFile, Buffer.alloc(4096, 1))
commandExecFileAsyncMock.mockImplementation(async (binary: string, args: string[]) => {
if (binary === 'pnpm' && args.join(' ') === 'store path') {
return { stdout: `${storePath}\n`, stderr: '' }
}
if (binary === 'pnpm' && args.join(' ') === 'store prune') {
await rm(packageFile, { force: true })
return { stdout: 'Removed cached package\n', stderr: '' }
}
throw new Error(`unexpected command ${binary} ${args.join(' ')}`)
})
const result = await runPackageManagerCacheCleanup({
targetId: 'local:pnpm:%2Frepo',
actionId: 'pnpm-store-prune',
packageManager: 'pnpm',
connectionId: null,
cwd: '/repo'
})
expect(result).toMatchObject({
ok: true,
cachePath: storePath
})
expect(result.ok && result.cacheSizeBeforeBytes).toBeGreaterThan(0)
expect(result.ok && result.cacheSizeAfterBytes).toBeGreaterThanOrEqual(0)
expect(result.ok && result.reclaimedBytes).toBeGreaterThan(0)
})
it('rejects malformed runtime cleanup requests before spawning commands', async () => {
await expect(runPackageManagerCacheCleanup(undefined as never)).resolves.toEqual({
ok: false,
error: 'Invalid package-manager cache cleanup request.'
})
await expect(runPackageManagerCacheCleanup(null as never)).resolves.toEqual({
ok: false,
error: 'Invalid package-manager cache cleanup request.'
})
await expect(
runPackageManagerCacheCleanup({
targetId: 'local:pnpm:%2Frepo',
actionId: 'pnpm-store-prune',
packageManager: 'pnpm',
connectionId: null,
cwd: ''
})
).resolves.toEqual({
ok: false,
error: 'Invalid package-manager cache cleanup request.'
})
expect(commandExecFileAsyncMock).not.toHaveBeenCalled()
})
})

View File

@ -1,631 +0,0 @@
/* eslint-disable max-lines -- Why: package-manager detection, target building,
and fixed-command execution share one safety boundary. */
import { lstat, readdir } from 'node:fs/promises'
import { join } from 'node:path'
import {
createPackageManagerCacheTargetId,
detectPackageManagersFromFilenames,
getPackageManagerCacheCleanupAction,
getPackageManagerCacheCleanupActions,
getPackageManagerLabel
} from '../shared/package-manager-cache-cleanup'
import type {
WorkspacePackageManager,
WorkspacePackageManagerCacheCleanupRequest,
WorkspacePackageManagerCacheCleanupResult,
WorkspacePackageManagerCacheTarget,
WorkspacePackageManagerCacheTargetWorktree
} from '../shared/workspace-space-types'
import type { IFilesystemProvider } from './providers/types'
import { getSshGitProvider } from './providers/ssh-git-dispatch'
import { commandExecFileAsync } from './git/runner'
const CLI_CHECK_TIMEOUT_MS = 8_000
const CACHE_PATH_TIMEOUT_MS = 8_000
const CACHE_SIZE_TIMEOUT_MS = 30_000
const CLEANUP_TIMEOUT_MS = 120_000
const CLEANUP_MAX_BUFFER_BYTES = 4 * 1024 * 1024
export type WorkspacePackageManagerDetection = {
packageManager: WorkspacePackageManager
connectionId: string | null
isRemote: boolean
repoDisplayName: string
worktreeId: string
worktreePath: string
lockfiles: string[]
}
type PackageManagerTargetSeed = {
packageManager: WorkspacePackageManager
connectionId: string | null
isRemote: boolean
repoDisplayNames: Set<string>
cwd: string
cachePath: string | null
cliAvailable: boolean
lockfiles: Set<string>
detectedWorktrees: Map<string, Set<string>>
worktreePaths: Set<string>
}
type ExecResult = {
stdout: string
stderr: string
}
type CacheSizeSnapshot = {
path: string
sizeBytes: number | null
}
function createTargetLabel(seed: PackageManagerTargetSeed): string {
const manager = getPackageManagerLabel(seed.packageManager)
const cachePath = seed.cachePath ? ` cache at ${seed.cachePath}` : ''
if (!seed.isRemote) {
return `${manager}${cachePath || ' on this computer'}`
}
const repoNames = [...seed.repoDisplayNames].sort((a, b) => a.localeCompare(b))
return `${manager}${cachePath || ` on ${repoNames[0] ?? seed.connectionId ?? 'SSH target'}`}`
}
function isKnownPackageManager(value: unknown): value is WorkspacePackageManager {
return value === 'npm' || value === 'pnpm' || value === 'yarn' || value === 'bun'
}
function normalizeConnectionId(value: unknown): string | null | undefined {
if (value === null) {
return null
}
if (typeof value === 'string' && value.length > 0) {
return value
}
return undefined
}
function validateCleanupRequest(request: unknown):
| {
ok: true
value: {
targetId: string
actionId: string
packageManager: WorkspacePackageManager
connectionId: string | null
cwd: string
}
}
| { ok: false; error: string } {
if (!request || typeof request !== 'object') {
return { ok: false, error: 'Invalid package-manager cache cleanup request.' }
}
const candidate = request as Partial<WorkspacePackageManagerCacheCleanupRequest>
const connectionId = normalizeConnectionId(candidate.connectionId)
if (
typeof candidate.targetId !== 'string' ||
candidate.targetId.length === 0 ||
typeof candidate.actionId !== 'string' ||
candidate.actionId.length === 0 ||
!isKnownPackageManager(candidate.packageManager) ||
connectionId === undefined ||
typeof candidate.cwd !== 'string' ||
candidate.cwd.length === 0
) {
return { ok: false, error: 'Invalid package-manager cache cleanup request.' }
}
return {
ok: true,
value: {
targetId: candidate.targetId,
actionId: candidate.actionId,
packageManager: candidate.packageManager,
connectionId,
cwd: candidate.cwd
}
}
}
function toErrorMessage(error: unknown): string {
if (error && typeof error === 'object') {
const spawnError = (error as { spawnError?: unknown }).spawnError
if (typeof spawnError === 'string' && spawnError.trim()) {
return spawnError
}
}
return error instanceof Error ? error.message : String(error)
}
function didExecSucceed(result: {
exitCode?: number | null
timedOut?: boolean
spawnError?: string
}): boolean {
return (
!result.spawnError &&
!result.timedOut &&
(result.exitCode === undefined || result.exitCode === 0)
)
}
function isAbortError(error: unknown): boolean {
return Boolean(
error && typeof error === 'object' && (error as { name?: unknown }).name === 'AbortError'
)
}
function createAbortError(): Error {
const error = new Error('The operation was aborted.')
error.name = 'AbortError'
return error
}
async function awaitWithAbort<T>(
promise: Promise<T>,
signal: AbortSignal | undefined,
onAbort?: () => void | Promise<void>
): Promise<T> {
if (!signal) {
return promise
}
if (signal.aborted) {
await onAbort?.()
throw createAbortError()
}
let abortHandler: (() => void) | null = null
const abortPromise = new Promise<never>((_, reject) => {
abortHandler = () => {
void onAbort?.()
reject(createAbortError())
}
signal.addEventListener('abort', abortHandler, { once: true })
})
try {
return await Promise.race([promise, abortPromise])
} finally {
if (abortHandler) {
signal.removeEventListener('abort', abortHandler)
}
}
}
async function execPackageManagerCommand(args: {
connectionId: string | null
cwd: string
binary: string
commandArgs: string[]
timeoutMs: number
signal?: AbortSignal
}): Promise<ExecResult> {
if (args.connectionId) {
const provider = getSshGitProvider(args.connectionId)
if (!provider) {
throw new Error(`SSH connection "${args.connectionId}" is not connected.`)
}
if (args.signal?.aborted) {
throw createAbortError()
}
const result = await awaitWithAbort(
provider.execNonInteractive(
args.binary,
args.commandArgs,
args.cwd,
args.timeoutMs,
args.signal
),
args.signal
)
if (result.canceled) {
throw createAbortError()
}
if (!didExecSucceed(result)) {
throw new Error(
result.timedOut
? `${args.binary} timed out.`
: result.spawnError ||
result.stderr.trim() ||
`${args.binary} exited with ${result.exitCode}.`
)
}
return { stdout: result.stdout, stderr: result.stderr }
}
return commandExecFileAsync(args.binary, args.commandArgs, {
cwd: args.cwd,
timeout: args.timeoutMs,
maxBuffer: CLEANUP_MAX_BUFFER_BYTES,
signal: args.signal
})
}
function parseFirstOutputLine(stdout: string): string | null {
return (
stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? null
)
}
async function resolveCachePath(args: {
packageManager: WorkspacePackageManager
connectionId: string | null
cwd: string
signal?: AbortSignal
}): Promise<string | null> {
const command =
args.packageManager === 'pnpm'
? { binary: 'pnpm', commandArgs: ['store', 'path'] }
: args.packageManager === 'npm'
? { binary: 'npm', commandArgs: ['config', 'get', 'cache'] }
: null
if (!command) {
return null
}
try {
const result = await execPackageManagerCommand({
connectionId: args.connectionId,
cwd: args.cwd,
binary: command.binary,
commandArgs: command.commandArgs,
timeoutMs: CACHE_PATH_TIMEOUT_MS,
signal: args.signal
})
return parseFirstOutputLine(result.stdout)
} catch (error) {
if (isAbortError(error)) {
throw error
}
return null
}
}
async function measureLocalDirectorySize(targetPath: string): Promise<number | null> {
const pendingPaths = [targetPath]
let totalSize = 0
while (pendingPaths.length > 0) {
const currentPath = pendingPaths.pop()
if (!currentPath) {
continue
}
let stats: Awaited<ReturnType<typeof lstat>>
try {
stats = await lstat(currentPath)
} catch (error) {
const code =
error && typeof error === 'object' && 'code' in error
? String((error as { code?: unknown }).code)
: ''
if (code === 'ENOENT' || code === 'ENOTDIR') {
continue
}
return null
}
totalSize += stats.size
if (stats.isSymbolicLink() || !stats.isDirectory()) {
continue
}
try {
const entries = await readdir(currentPath, { withFileTypes: true })
for (const entry of entries) {
pendingPaths.push(join(currentPath, entry.name))
}
} catch (error) {
const code =
error && typeof error === 'object' && 'code' in error
? String((error as { code?: unknown }).code)
: ''
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
return null
}
}
}
return totalSize
}
function parseDuSizeBytes(stdout: string): number | null {
const match = /^(\d+)\s+/.exec(stdout.trim())
return match ? Number(match[1]) * 1024 : null
}
async function measureRemoteDirectorySize(args: {
connectionId: string
cwd: string
path: string
}): Promise<number | null> {
try {
const result = await execPackageManagerCommand({
connectionId: args.connectionId,
cwd: args.cwd,
binary: 'du',
commandArgs: ['-sk', args.path],
timeoutMs: CACHE_SIZE_TIMEOUT_MS
})
return parseDuSizeBytes(result.stdout)
} catch {
return null
}
}
async function measurePackageManagerCache(args: {
packageManager: WorkspacePackageManager
connectionId: string | null
cwd: string
cachePath?: string | null
}): Promise<CacheSizeSnapshot | null> {
const cachePath =
args.cachePath ??
(await resolveCachePath({
packageManager: args.packageManager,
connectionId: args.connectionId,
cwd: args.cwd
}))
if (!cachePath) {
return null
}
const sizeBytes = args.connectionId
? await measureRemoteDirectorySize({
connectionId: args.connectionId,
cwd: args.cwd,
path: cachePath
})
: await measureLocalDirectorySize(cachePath)
return { path: cachePath, sizeBytes }
}
async function isPackageManagerCliAvailable(args: {
packageManager: WorkspacePackageManager
connectionId: string | null
cwd: string
signal?: AbortSignal
}): Promise<boolean> {
const action = getPackageManagerCacheCleanupActions(args.packageManager)[0]
if (!action) {
return false
}
try {
await execPackageManagerCommand({
...args,
binary: action.binary,
commandArgs: ['--version'],
timeoutMs: CLI_CHECK_TIMEOUT_MS,
signal: args.signal
})
return true
} catch (error) {
if (isAbortError(error)) {
throw error
}
return false
}
}
export function detectPackageManagersForDirectoryEntries(args: {
entryNames: readonly string[]
connectionId: string | null
isRemote: boolean
repoDisplayName: string
worktreeId: string
worktreePath: string
}): WorkspacePackageManagerDetection[] {
return [...detectPackageManagersFromFilenames(args.entryNames)].map(
([packageManager, lockfiles]) => ({
packageManager,
connectionId: args.connectionId,
isRemote: args.isRemote,
repoDisplayName: args.repoDisplayName,
worktreeId: args.worktreeId,
worktreePath: args.worktreePath,
lockfiles
})
)
}
export async function detectRemotePackageManagers(args: {
provider: IFilesystemProvider
connectionId: string
repoDisplayName: string
worktreeId: string
worktreePath: string
}): Promise<WorkspacePackageManagerDetection[]> {
const entries = await args.provider.readDir(args.worktreePath)
return detectPackageManagersForDirectoryEntries({
entryNames: entries.map((entry) => entry.name),
connectionId: args.connectionId,
isRemote: true,
repoDisplayName: args.repoDisplayName,
worktreeId: args.worktreeId,
worktreePath: args.worktreePath
})
}
export async function buildPackageManagerCacheTargets(
detections: readonly WorkspacePackageManagerDetection[],
options: { signal?: AbortSignal } = {}
): Promise<WorkspacePackageManagerCacheTarget[]> {
const perCwdSeeds = new Map<string, PackageManagerTargetSeed>()
for (const detection of detections) {
const id = createPackageManagerCacheTargetId(
detection.connectionId,
detection.packageManager,
detection.worktreePath
)
const seed =
perCwdSeeds.get(id) ??
({
packageManager: detection.packageManager,
connectionId: detection.connectionId,
isRemote: detection.isRemote,
repoDisplayNames: new Set<string>(),
cwd: detection.worktreePath,
cachePath: null,
cliAvailable: false,
lockfiles: new Set<string>(),
detectedWorktrees: new Map<string, Set<string>>(),
worktreePaths: new Set<string>()
} satisfies PackageManagerTargetSeed)
seed.repoDisplayNames.add(detection.repoDisplayName)
seed.worktreePaths.add(detection.worktreePath)
for (const lockfile of detection.lockfiles) {
seed.lockfiles.add(lockfile)
}
const worktreeLockfiles = seed.detectedWorktrees.get(detection.worktreeId) ?? new Set<string>()
for (const lockfile of detection.lockfiles) {
worktreeLockfiles.add(lockfile)
}
seed.detectedWorktrees.set(detection.worktreeId, worktreeLockfiles)
perCwdSeeds.set(id, seed)
}
const resolvedSeeds = await Promise.all(
[...perCwdSeeds.values()].map(async (seed) => {
seed.cliAvailable = await isPackageManagerCliAvailable({
packageManager: seed.packageManager,
connectionId: seed.connectionId,
cwd: seed.cwd,
signal: options.signal
})
seed.cachePath = seed.cliAvailable
? await resolveCachePath({
packageManager: seed.packageManager,
connectionId: seed.connectionId,
cwd: seed.cwd,
signal: options.signal
})
: null
return seed
})
)
const targetSeeds = new Map<string, PackageManagerTargetSeed>()
for (const seed of resolvedSeeds) {
const cacheScope = seed.cachePath ? `cache:${seed.cachePath}` : `cwd:${seed.cwd}`
const key = `${seed.connectionId ?? 'local'}\0${seed.packageManager}\0${cacheScope}`
const targetSeed =
targetSeeds.get(key) ??
({
packageManager: seed.packageManager,
connectionId: seed.connectionId,
isRemote: seed.isRemote,
repoDisplayNames: new Set<string>(),
cwd: seed.cwd,
cachePath: seed.cachePath,
cliAvailable: false,
lockfiles: new Set<string>(),
detectedWorktrees: new Map<string, Set<string>>(),
worktreePaths: new Set<string>()
} satisfies PackageManagerTargetSeed)
targetSeed.cliAvailable ||= seed.cliAvailable
for (const repoDisplayName of seed.repoDisplayNames) {
targetSeed.repoDisplayNames.add(repoDisplayName)
}
for (const lockfile of seed.lockfiles) {
targetSeed.lockfiles.add(lockfile)
}
for (const worktreePath of seed.worktreePaths) {
targetSeed.worktreePaths.add(worktreePath)
}
for (const [worktreeId, lockfiles] of seed.detectedWorktrees) {
const targetLockfiles = targetSeed.detectedWorktrees.get(worktreeId) ?? new Set<string>()
for (const lockfile of lockfiles) {
targetLockfiles.add(lockfile)
}
targetSeed.detectedWorktrees.set(worktreeId, targetLockfiles)
}
targetSeeds.set(key, targetSeed)
}
const targets = [...targetSeeds.values()].map((seed) => ({
id: createPackageManagerCacheTargetId(seed.connectionId, seed.packageManager, seed.cwd),
packageManager: seed.packageManager,
connectionId: seed.connectionId,
isRemote: seed.isRemote,
targetLabel: createTargetLabel(seed),
cwd: seed.cwd,
cachePath: seed.cachePath,
detectedWorktreeCount: seed.worktreePaths.size,
detectedWorktrees: toDetectedWorktrees(seed.detectedWorktrees),
detectedLockfiles: [...seed.lockfiles].sort((a, b) => a.localeCompare(b)),
cliAvailable: seed.cliAvailable,
unavailableReason: seed.cliAvailable
? null
: `${getPackageManagerLabel(seed.packageManager)} was detected by lockfile, but its CLI was not available on this target.`,
cleanupActions: getPackageManagerCacheCleanupActions(seed.packageManager)
}))
return targets.sort(
(a, b) =>
Number(a.isRemote) - Number(b.isRemote) ||
a.targetLabel.localeCompare(b.targetLabel) ||
a.packageManager.localeCompare(b.packageManager)
)
}
function toDetectedWorktrees(
detectedWorktrees: Map<string, Set<string>>
): WorkspacePackageManagerCacheTargetWorktree[] {
return [...detectedWorktrees.entries()]
.map(([worktreeId, lockfiles]) => ({
worktreeId,
lockfiles: [...lockfiles].sort((a, b) => a.localeCompare(b))
}))
.sort((a, b) => a.worktreeId.localeCompare(b.worktreeId))
}
export async function runPackageManagerCacheCleanup(
request: WorkspacePackageManagerCacheCleanupRequest
): Promise<WorkspacePackageManagerCacheCleanupResult> {
const validated = validateCleanupRequest(request)
if (!validated.ok) {
return { ok: false, error: validated.error }
}
const { value } = validated
const action = getPackageManagerCacheCleanupAction(value.packageManager, value.actionId)
if (!action) {
return { ok: false, error: 'Unknown package-manager cache cleanup action.' }
}
if (
value.targetId !==
createPackageManagerCacheTargetId(value.connectionId, value.packageManager, value.cwd)
) {
return { ok: false, error: 'Package-manager cache cleanup target did not match the request.' }
}
try {
const before = await measurePackageManagerCache({
packageManager: value.packageManager,
connectionId: value.connectionId,
cwd: value.cwd
})
const result = await execPackageManagerCommand({
connectionId: value.connectionId,
cwd: value.cwd,
binary: action.binary,
commandArgs: action.args,
timeoutMs: CLEANUP_TIMEOUT_MS
})
const after = before
? await measurePackageManagerCache({
packageManager: value.packageManager,
connectionId: value.connectionId,
cwd: value.cwd,
cachePath: before.path
})
: null
const cacheSizeBeforeBytes = before?.sizeBytes ?? null
const cacheSizeAfterBytes = after?.sizeBytes ?? null
const reclaimedBytes =
cacheSizeBeforeBytes !== null && cacheSizeAfterBytes !== null
? Math.max(0, cacheSizeBeforeBytes - cacheSizeAfterBytes)
: null
return {
ok: true,
action,
stdout: result.stdout,
stderr: result.stderr,
cachePath: before?.path ?? null,
cacheSizeBeforeBytes,
cacheSizeAfterBytes,
reclaimedBytes
}
} catch (error) {
return { ok: false, error: toErrorMessage(error) }
}
}

View File

@ -328,7 +328,7 @@ describe('analyzeWorkspaceSpace', () => {
'/remote/feature',
expect.objectContaining({ signal: undefined })
)
expect(readDir).toHaveBeenCalledWith('/remote/feature')
expect(readDir).not.toHaveBeenCalled()
expect(stat).not.toHaveBeenCalled()
expect(result.worktrees[0]?.sizeBytes).toBe(4096)
})

View File

@ -20,12 +20,6 @@ import type {
WorkspaceSpaceWorktree
} from '../shared/workspace-space-types'
import { compactWorkspaceSpaceItems } from '../shared/workspace-space-compaction'
import {
buildPackageManagerCacheTargets,
detectPackageManagersForDirectoryEntries,
detectRemotePackageManagers,
type WorkspacePackageManagerDetection
} from './workspace-package-manager-cache-cleanup'
import type { IFilesystemProvider } from './providers/types'
import { getSshFilesystemProvider } from './providers/ssh-filesystem-dispatch'
import { getSshGitProvider } from './providers/ssh-git-dispatch'
@ -57,12 +51,6 @@ type WorktreeListResult =
type RepoScanResult = {
summary: WorkspaceSpaceRepoSummary
worktrees: WorkspaceSpaceWorktree[]
packageManagerDetections: WorkspacePackageManagerDetection[]
}
type WorktreeScanResult = {
row: WorkspaceSpaceWorktree
packageManagerDetections: WorkspacePackageManagerDetection[]
}
type WorkspaceSpaceAnalyzeOptions = {
@ -722,54 +710,6 @@ async function scanRemoteWorktree(
}
}
async function detectLocalPackageManagers(
repo: Repo,
worktree: Worktree,
row: WorkspaceSpaceWorktree
): Promise<WorkspacePackageManagerDetection[]> {
try {
const entries = await readdir(worktree.path, { withFileTypes: true })
return detectPackageManagersForDirectoryEntries({
entryNames: entries.map((entry) => entry.name),
connectionId: null,
isRemote: false,
repoDisplayName: repo.displayName,
worktreeId: row.worktreeId,
worktreePath: worktree.path
})
} catch {
return []
}
}
async function detectWorktreePackageManagers(
repo: Repo,
worktree: Worktree,
row: WorkspaceSpaceWorktree,
remoteProvider: IFilesystemProvider | undefined
): Promise<WorkspacePackageManagerDetection[]> {
if (row.status !== 'ok') {
return []
}
if (repo.connectionId) {
if (!remoteProvider) {
return []
}
try {
return await detectRemotePackageManagers({
provider: remoteProvider,
connectionId: repo.connectionId,
repoDisplayName: repo.displayName,
worktreeId: row.worktreeId,
worktreePath: worktree.path
})
} catch {
return []
}
}
return detectLocalPackageManagers(repo, worktree, row)
}
async function listWorktreesForSpaceScan(
repo: Repo,
signal?: AbortSignal
@ -842,7 +782,6 @@ async function scanRepo(
options.onProgress
)
return {
packageManagerDetections: [],
worktrees: [],
summary: {
repoId: repo.id,
@ -868,7 +807,7 @@ async function scanRepo(
options.onProgress
)
const remoteProvider = repo.connectionId ? getSshFilesystemProvider(repo.connectionId) : undefined
const scanResults = await mapLimit(worktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => {
const rows = await mapLimit(worktrees, WORKTREE_SCAN_CONCURRENCY, async (worktree) => {
throwIfAborted(options.signal)
reportProgress(
progress,
@ -894,15 +833,8 @@ async function scanRepo(
{ scannedWorktreeCount: progress.scannedWorktreeCount + 1 },
options.onProgress
)
const packageManagerDetections = await detectWorktreePackageManagers(
repo,
worktree,
row,
remoteProvider
)
return { row, packageManagerDetections } satisfies WorktreeScanResult
return row
})
const rows = scanResults.map((result) => result.row)
reportProgress(
progress,
{
@ -915,7 +847,6 @@ async function scanRepo(
return {
worktrees: rows,
packageManagerDetections: scanResults.flatMap((result) => result.packageManagerDetections),
summary: {
repoId: repo.id,
displayName: repo.displayName,
@ -959,16 +890,6 @@ export async function analyzeWorkspaceSpace(
const worktrees = repoResults
.flatMap((result) => result.worktrees)
.sort((a, b) => b.sizeBytes - a.sizeBytes || a.displayName.localeCompare(b.displayName))
let packageManagerCaches: WorkspaceSpaceAnalysis['packageManagerCaches']
try {
packageManagerCaches = await buildPackageManagerCacheTargets(
repoResults.flatMap((result) => result.packageManagerDetections),
{ signal: options.signal }
)
} catch (error) {
throwIfAborted(options.signal)
throw error
}
throwIfAborted(options.signal)
return {
@ -980,7 +901,6 @@ export async function analyzeWorkspaceSpace(
unavailableWorktreeCount:
worktrees.filter((row) => row.status !== 'ok').length +
repos.filter((repo) => repo.error !== null).length,
packageManagerCaches,
repos,
worktrees
}

View File

@ -226,8 +226,6 @@ import type {
SpeechTranscriptEvent
} from '../shared/speech-types'
import type {
WorkspacePackageManagerCacheCleanupRequest,
WorkspacePackageManagerCacheCleanupResult,
WorkspaceSpaceAnalyzeResult,
WorkspaceSpaceScanProgress
} from '../shared/workspace-space-types'
@ -711,9 +709,6 @@ export type PreloadApi = {
workspaceSpace: {
analyze: () => Promise<WorkspaceSpaceAnalyzeResult>
cancel: () => Promise<boolean>
cleanupPackageManagerCache: (
request: WorkspacePackageManagerCacheCleanupRequest
) => Promise<WorkspacePackageManagerCacheCleanupResult>
onProgress: (callback: (progress: WorkspaceSpaceScanProgress) => void) => () => void
}
workspacePorts: {

View File

@ -56,8 +56,6 @@ import type {
} from '../shared/mobile-markdown-document'
import type { RateLimitState } from '../shared/rate-limit-types'
import type {
WorkspacePackageManagerCacheCleanupRequest,
WorkspacePackageManagerCacheCleanupResult,
WorkspaceSpaceAnalyzeResult,
WorkspaceSpaceScanProgress
} from '../shared/workspace-space-types'
@ -564,10 +562,6 @@ const api = {
analyze: (): Promise<WorkspaceSpaceAnalyzeResult> =>
ipcRenderer.invoke('workspaceSpace:analyze'),
cancel: (): Promise<boolean> => ipcRenderer.invoke('workspaceSpace:cancel'),
cleanupPackageManagerCache: (
request: WorkspacePackageManagerCacheCleanupRequest
): Promise<WorkspacePackageManagerCacheCleanupResult> =>
ipcRenderer.invoke('workspaceSpace:cleanupPackageManagerCache', request),
onProgress: (callback: (progress: WorkspaceSpaceScanProgress) => void): (() => void) => {
const listener = (
_event: Electron.IpcRendererEvent,

View File

@ -15,11 +15,9 @@ import {
HardDrive,
Loader2,
Minus,
Package,
RefreshCw,
Search,
Server,
ShieldCheck,
Terminal,
Trash2,
ZoomIn,
@ -32,16 +30,9 @@ import type {
} from '../../../../shared/agent-status-types'
import type { GitStatusResult, TerminalTab, Worktree } from '../../../../shared/types'
import type {
WorkspacePackageManagerCacheCleanupAction,
WorkspacePackageManagerCacheCleanupResult,
WorkspacePackageManagerCacheTarget,
WorkspaceSpaceItem,
WorkspaceSpaceWorktree
} from '../../../../shared/workspace-space-types'
import {
getPackageManagerCacheSafetyCopy,
getPackageManagerLabel
} from '../../../../shared/package-manager-cache-cleanup'
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
@ -99,22 +90,11 @@ type WorkspaceSpaceDeleteState = {
canForceDelete: boolean
}
type PackageManagerCacheCleanupState = {
runningActionId: string | null
error: string | null
lastOutput: string | null
}
type WorkspaceGitRefreshState = {
isRefreshing: boolean
error: string | null
}
type PackageManagerCacheCleanupSuccess = Extract<
WorkspacePackageManagerCacheCleanupResult,
{ ok: true }
>
type WorkspaceDecisionDetails = {
isActive: boolean
canOpenWorkspace: boolean
@ -278,33 +258,6 @@ function getTreemapFill(rect: TreemapRect, selected: boolean): string {
return TREEMAP_FILLS[rect.index % TREEMAP_FILLS.length]
}
function getPackageManagerCleanupSuccessCopy(result: PackageManagerCacheCleanupSuccess): {
output: string
toastDescription: string
} {
const commandOutput = [result.stdout, result.stderr].filter((part) => part.trim()).join('\n')
if (result.reclaimedBytes === null) {
return {
output: commandOutput || `${result.action.command} completed.`,
toastDescription: result.action.command
}
}
const summary =
result.reclaimedBytes > 0
? `Freed ${formatBytes(result.reclaimedBytes)}`
: 'Cleanup completed; measured cache size was unchanged'
const measured =
result.cacheSizeBeforeBytes !== null && result.cacheSizeAfterBytes !== null
? `${summary} (${formatBytes(result.cacheSizeBeforeBytes)} -> ${formatBytes(result.cacheSizeAfterBytes)}).`
: `${summary}.`
return {
output: commandOutput ? `${measured}\n${commandOutput}` : measured,
toastDescription: measured
}
}
function Metric({
label,
value,
@ -941,146 +894,6 @@ function BreakdownRow({
)
}
function PackageManagerCacheActions({
target,
state,
onRun
}: {
target: WorkspacePackageManagerCacheTarget
state?: PackageManagerCacheCleanupState
onRun: (
target: WorkspacePackageManagerCacheTarget,
action: WorkspacePackageManagerCacheCleanupAction
) => void
}): React.JSX.Element {
const runningActionId = state?.runningActionId ?? null
return (
<div className="flex flex-wrap items-center gap-2">
{target.cleanupActions.map((action) => {
const isRunning = runningActionId === action.id
return (
<Button
key={action.id}
variant={action.safety === 'aggressive' ? 'destructive' : 'outline'}
size="sm"
onClick={() => onRun(target, action)}
disabled={!target.cliAvailable || runningActionId !== null}
className="gap-1.5"
title={action.description}
>
{isRunning ? (
<Loader2 className="size-3.5 animate-spin" />
) : action.safety === 'safe' ? (
<ShieldCheck className="size-3.5" />
) : (
<Trash2 className="size-3.5" />
)}
{isRunning ? 'Running' : action.label}
</Button>
)
})}
</div>
)
}
function PackageManagerCacheSection({
targets,
stateByTargetId,
onRun
}: {
targets: WorkspacePackageManagerCacheTarget[]
stateByTargetId: Record<string, PackageManagerCacheCleanupState>
onRun: (
target: WorkspacePackageManagerCacheTarget,
action: WorkspacePackageManagerCacheCleanupAction
) => void
}): React.JSX.Element | null {
if (targets.length === 0) {
return null
}
return (
<div className="rounded-lg border border-border/70 bg-background/30">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/60 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<Package className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="text-sm font-semibold">Package Manager Caches</div>
<div className="text-xs text-muted-foreground">
Detected from lockfiles. Cleanup runs only when you choose an action.
</div>
</div>
</div>
</div>
<div className="divide-y divide-border/50">
{targets.map((target) => {
const state = stateByTargetId[target.id]
const lastOutput = state?.lastOutput?.trim() ?? ''
return (
<div key={target.id} className="grid gap-3 px-4 py-3 md:grid-cols-[minmax(0,1fr)_auto]">
<div className="min-w-0 space-y-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="font-medium">
{getPackageManagerLabel(target.packageManager)}
</span>
<Badge variant={target.cliAvailable ? 'secondary' : 'outline'}>
{target.cliAvailable ? 'CLI found' : 'CLI missing'}
</Badge>
{target.isRemote ? (
<Badge variant="outline" className="gap-1">
<Server className="size-3" />
SSH
</Badge>
) : null}
</div>
<div className="truncate text-xs text-muted-foreground">
{target.targetLabel} · {target.detectedWorktreeCount}{' '}
{target.detectedWorktreeCount === 1 ? 'workspace' : 'workspaces'} ·{' '}
{target.detectedLockfiles.join(', ')}
</div>
<div className="flex min-w-0 items-center gap-1.5 text-[11px] text-muted-foreground">
<Terminal className="size-3 shrink-0" />
<span className="truncate font-mono">
{target.cleanupActions.map((action) => action.command).join(' · ')}
</span>
</div>
{target.unavailableReason ? (
<div className="text-xs text-muted-foreground">{target.unavailableReason}</div>
) : null}
{state?.error ? (
<div className="flex items-start gap-2 rounded-md border border-destructive/35 bg-destructive/8 px-2 py-1.5 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 break-words">{state.error}</span>
</div>
) : lastOutput ? (
<div className="truncate rounded-md border border-border/60 bg-muted/20 px-2 py-1.5 font-mono text-[11px] text-muted-foreground">
{lastOutput}
</div>
) : null}
</div>
<div className="flex min-w-0 flex-col items-start gap-2 md:items-end">
<div className="flex flex-wrap justify-start gap-1 md:justify-end">
{target.cleanupActions.map((action) => (
<Badge
key={action.id}
variant={action.safety === 'safe' ? 'secondary' : 'outline'}
>
{getPackageManagerCacheSafetyCopy(action.safety)}
</Badge>
))}
</div>
<PackageManagerCacheActions target={target} state={state} onRun={onRun} />
</div>
</div>
)
})}
</div>
</div>
)
}
function WorkspaceRow({
worktree,
maxSize,
@ -1276,9 +1089,6 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element {
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set())
const [inspectedWorktreeId, setInspectedWorktreeId] = useState<string | null>(null)
const [treemapZoomWorktreeId, setTreemapZoomWorktreeId] = useState<string | null>(null)
const [cacheCleanupStateByTargetId, setCacheCleanupStateByTargetId] = useState<
Record<string, PackageManagerCacheCleanupState>
>({})
const [gitRefreshStateByWorktreeId, setGitRefreshStateByWorktreeId] = useState<
Record<string, WorkspaceGitRefreshState>
>({})
@ -1295,10 +1105,6 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element {
}, [cancelWorkspaceSpaceScan])
const sourceRows = useMemo(() => analysis?.worktrees ?? [], [analysis?.worktrees])
const packageManagerCaches = useMemo(
() => analysis?.packageManagerCaches ?? [],
[analysis?.packageManagerCaches]
)
const decisionDetailsByWorktreeId = useMemo(() => {
// Why: active-agent freshness is time-based. The epoch bumps when fresh
// hook entries cross the stale boundary so delete readiness recomputes.
@ -1638,69 +1444,6 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element {
deleteWorktrees(selectedDeletableIds)
}
const runPackageManagerCacheCleanup = useCallback(
(
target: WorkspacePackageManagerCacheTarget,
action: WorkspacePackageManagerCacheCleanupAction
): void => {
setCacheCleanupStateByTargetId((current) => ({
...current,
[target.id]: {
runningActionId: action.id,
error: null,
lastOutput: current[target.id]?.lastOutput ?? null
}
}))
void window.api.workspaceSpace
.cleanupPackageManagerCache({
targetId: target.id,
actionId: action.id,
packageManager: target.packageManager,
connectionId: target.connectionId,
cwd: target.cwd
})
.then((result) => {
if (!result.ok) {
setCacheCleanupStateByTargetId((current) => ({
...current,
[target.id]: {
runningActionId: null,
error: result.error,
lastOutput: current[target.id]?.lastOutput ?? null
}
}))
toast.error('Cache cleanup failed', { description: result.error })
return
}
const copy = getPackageManagerCleanupSuccessCopy(result)
setCacheCleanupStateByTargetId((current) => ({
...current,
[target.id]: {
runningActionId: null,
error: null,
lastOutput: copy.output
}
}))
toast.success('Cache cleanup completed', {
description: copy.toastDescription
})
})
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error)
setCacheCleanupStateByTargetId((current) => ({
...current,
[target.id]: {
runningActionId: null,
error: message,
lastOutput: current[target.id]?.lastOutput ?? null
}
}))
toast.error('Cache cleanup failed', { description: message })
})
},
[]
)
return (
<div className="space-y-5">
<div className="grid overflow-hidden rounded-lg border border-border/65 bg-background/35 md:grid-cols-4 md:divide-x md:divide-border/60">
@ -1788,12 +1531,6 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element {
</div>
) : null}
<PackageManagerCacheSection
targets={packageManagerCaches}
stateByTargetId={cacheCleanupStateByTargetId}
onRun={runPackageManagerCacheCleanup}
/>
{hasRows || isInitialScan ? (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.4fr)_minmax(20rem,0.6fr)]">
<WorkspaceTreemap

View File

@ -21,43 +21,6 @@ function makeAnalysis(): WorkspaceSpaceAnalysis {
worktreeCount: 2,
scannedWorktreeCount: 2,
unavailableWorktreeCount: 0,
packageManagerCaches: [
{
id: 'local:pnpm:%2Frepo%2Fmain',
packageManager: 'pnpm',
connectionId: null,
isRemote: false,
targetLabel: 'pnpm on this computer',
cwd: '/repo/main',
cachePath: null,
detectedWorktreeCount: 2,
detectedWorktrees: [
{ worktreeId: 'repo-1::/repo/main', lockfiles: ['pnpm-lock.yaml'] },
{ worktreeId: 'repo-1::/repo/feature', lockfiles: ['pnpm-lock.yaml'] }
],
detectedLockfiles: ['pnpm-lock.yaml'],
cliAvailable: true,
unavailableReason: null,
cleanupActions: []
},
{
id: 'local:npm:%2Frepo%2Ffeature',
packageManager: 'npm',
connectionId: null,
isRemote: false,
targetLabel: 'npm on this computer',
cwd: '/repo/feature',
cachePath: null,
detectedWorktreeCount: 1,
detectedWorktrees: [
{ worktreeId: 'repo-1::/repo/feature', lockfiles: ['package-lock.json'] }
],
detectedLockfiles: ['package-lock.json'],
cliAvailable: true,
unavailableReason: null,
cleanupActions: []
}
],
repos: [
{
repoId: 'repo-1',
@ -124,7 +87,7 @@ function makeAnalysis(): WorkspaceSpaceAnalysis {
}
describe('workspace space slice', () => {
it('removes stale package-manager cache detections when workspaces are removed', () => {
it('removes deleted worktrees from cached analysis totals', () => {
const store = createWorkspaceSpaceTestStore()
store.setState({ workspaceSpaceAnalysis: makeAnalysis() })
@ -132,39 +95,13 @@ describe('workspace space slice', () => {
const analysis = store.getState().workspaceSpaceAnalysis
expect(analysis?.worktreeCount).toBe(1)
expect(analysis?.packageManagerCaches).toEqual([
expect.objectContaining({
id: 'local:pnpm:%2Frepo%2Fmain',
cwd: '/repo/main',
detectedWorktreeCount: 1,
detectedWorktrees: [{ worktreeId: 'repo-1::/repo/main', lockfiles: ['pnpm-lock.yaml'] }],
detectedLockfiles: ['pnpm-lock.yaml']
})
])
})
it('moves a package-manager cleanup target to a surviving cwd', () => {
const store = createWorkspaceSpaceTestStore()
store.setState({ workspaceSpaceAnalysis: makeAnalysis() })
store.getState().removeWorkspaceSpaceWorktrees(['repo-1::/repo/main'])
const analysis = store.getState().workspaceSpaceAnalysis
expect(analysis?.packageManagerCaches).toEqual([
expect.objectContaining({
id: 'local:pnpm:%2Frepo%2Ffeature',
cwd: '/repo/feature',
detectedWorktreeCount: 1,
detectedWorktrees: [{ worktreeId: 'repo-1::/repo/feature', lockfiles: ['pnpm-lock.yaml'] }]
}),
expect.objectContaining({
id: 'local:npm:%2Frepo%2Ffeature',
cwd: '/repo/feature',
detectedWorktreeCount: 1,
detectedWorktrees: [
{ worktreeId: 'repo-1::/repo/feature', lockfiles: ['package-lock.json'] }
]
})
])
expect(analysis?.totalSizeBytes).toBe(100)
expect(analysis?.reclaimableBytes).toBe(0)
expect(analysis?.repos[0]).toMatchObject({
worktreeCount: 1,
scannedWorktreeCount: 1,
totalSizeBytes: 100,
reclaimableBytes: 0
})
})
})

View File

@ -1,10 +1,8 @@
import type { StateCreator } from 'zustand'
import type {
WorkspaceSpaceAnalysis,
WorkspacePackageManagerCacheTarget,
WorkspaceSpaceScanProgress
} from '../../../../shared/workspace-space-types'
import { createPackageManagerCacheTargetId } from '../../../../shared/package-manager-cache-cleanup'
import type { AppState } from '../types'
let inFlightScan: Promise<WorkspaceSpaceAnalysis> | null = null
@ -32,9 +30,6 @@ function removeDeletedWorktreesFromAnalysis(
repoRows.push(worktree)
rowsByRepoId.set(worktree.repoId, repoRows)
}
const worktreePathById = new Map(
worktrees.map((worktree) => [worktree.worktreeId, worktree.path])
)
const repos = analysis.repos.map((repo) => {
const repoRows = rowsByRepoId.get(repo.repoId) ?? []
return {
@ -46,35 +41,6 @@ function removeDeletedWorktreesFromAnalysis(
reclaimableBytes: repoRows.reduce((sum, row) => sum + row.reclaimableBytes, 0)
}
})
const packageManagerCaches = analysis.packageManagerCaches.flatMap((target) => {
const detectedWorktrees = target.detectedWorktrees.filter(
(worktree) => !deletedSet.has(worktree.worktreeId)
)
if (detectedWorktrees.length === 0) {
return []
}
const survivingCwds = detectedWorktrees
.map((worktree) => worktreePathById.get(worktree.worktreeId))
.filter((path): path is string => typeof path === 'string' && path.length > 0)
const cwd = survivingCwds.includes(target.cwd) ? target.cwd : survivingCwds[0]
if (!cwd) {
return []
}
const detectedLockfiles = [
...new Set(detectedWorktrees.flatMap((worktree) => worktree.lockfiles))
].sort((a, b) => a.localeCompare(b))
return [
{
...target,
id: createPackageManagerCacheTargetId(target.connectionId, target.packageManager, cwd),
cwd,
detectedWorktreeCount: detectedWorktrees.length,
detectedWorktrees,
detectedLockfiles
} satisfies WorkspacePackageManagerCacheTarget
]
})
return {
...analysis,
totalSizeBytes: worktrees.reduce((sum, row) => sum + row.sizeBytes, 0),
@ -84,7 +50,6 @@ function removeDeletedWorktreesFromAnalysis(
unavailableWorktreeCount:
worktrees.filter((row) => row.status !== 'ok').length +
repos.filter((repo) => repo.error !== null).length,
packageManagerCaches,
repos,
worktrees
}

View File

@ -1,57 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
detectPackageManagersFromFilenames,
getPackageManagerCacheCleanupAction,
getPackageManagerCacheCleanupActions,
getPackageManagerCacheSafetyCopy
} from './package-manager-cache-cleanup'
describe('package manager cache cleanup metadata', () => {
it('detects package managers from top-level lockfiles', () => {
const detected = detectPackageManagersFromFilenames([
'package.json',
'pnpm-lock.yaml',
'package-lock.json',
'bun.lockb',
'src'
])
expect(detected.get('pnpm')).toEqual(['pnpm-lock.yaml'])
expect(detected.get('npm')).toEqual(['package-lock.json'])
expect(detected.get('bun')).toEqual(['bun.lockb'])
expect(detected.has('yarn')).toBe(false)
})
it('uses conservative commands for safe defaults and explicit aggressive cleanup', () => {
expect(getPackageManagerCacheCleanupAction('pnpm', 'pnpm-store-prune')).toMatchObject({
binary: 'pnpm',
args: ['store', 'prune'],
safety: 'safe'
})
expect(getPackageManagerCacheCleanupAction('npm', 'npm-cache-verify')).toMatchObject({
binary: 'npm',
args: ['cache', 'verify'],
safety: 'safe'
})
expect(getPackageManagerCacheCleanupAction('npm', 'npm-cache-clean-force')).toMatchObject({
binary: 'npm',
args: ['cache', 'clean', '--force'],
safety: 'aggressive'
})
expect(getPackageManagerCacheCleanupActions('yarn')[0]).toMatchObject({
binary: 'yarn',
args: ['cache', 'clean'],
safety: 'aggressive'
})
expect(getPackageManagerCacheCleanupActions('bun')[0]).toMatchObject({
binary: 'bun',
args: ['pm', 'cache', 'rm'],
safety: 'aggressive'
})
})
it('keeps safety copy explicit', () => {
expect(getPackageManagerCacheSafetyCopy('safe')).toBe('Safe default')
expect(getPackageManagerCacheSafetyCopy('aggressive')).toBe('Aggressive')
})
})

View File

@ -1,159 +0,0 @@
import type {
WorkspacePackageManager,
WorkspacePackageManagerCacheCleanupAction,
WorkspacePackageManagerCacheCleanupSafety
} from './workspace-space-types'
type PackageManagerCacheDefinition = {
packageManager: WorkspacePackageManager
lockfiles: readonly string[]
actions: readonly Omit<WorkspacePackageManagerCacheCleanupAction, 'packageManager' | 'command'>[]
}
const DEFINITIONS: readonly PackageManagerCacheDefinition[] = [
{
packageManager: 'pnpm',
lockfiles: ['pnpm-lock.yaml'],
actions: [
{
id: 'pnpm-store-prune',
safety: 'safe',
binary: 'pnpm',
args: ['store', 'prune'],
label: 'Prune pnpm store',
description:
'Removes unreferenced packages from the pnpm store without editing projects or lockfiles.'
}
]
},
{
packageManager: 'npm',
lockfiles: ['package-lock.json', 'npm-shrinkwrap.json'],
actions: [
{
id: 'npm-cache-verify',
safety: 'safe',
binary: 'npm',
args: ['cache', 'verify'],
label: 'Verify npm cache',
description: 'Verifies cache integrity and garbage-collects unneeded npm cache data.'
},
{
id: 'npm-cache-clean-force',
safety: 'aggressive',
binary: 'npm',
args: ['cache', 'clean', '--force'],
label: 'Clean npm cache',
description: 'Deletes npm cache data. Future installs may need to download packages again.'
}
]
},
{
packageManager: 'yarn',
lockfiles: ['yarn.lock'],
actions: [
{
id: 'yarn-cache-clean',
safety: 'aggressive',
binary: 'yarn',
args: ['cache', 'clean'],
label: 'Clean Yarn cache',
description:
'Removes Yarn shared cache files. Future installs may need to download packages again.'
}
]
},
{
packageManager: 'bun',
lockfiles: ['bun.lock', 'bun.lockb'],
actions: [
{
id: 'bun-pm-cache-rm',
safety: 'aggressive',
binary: 'bun',
args: ['pm', 'cache', 'rm'],
label: 'Clean Bun cache',
description:
'Removes Bun global module cache data. Future installs may need to download packages again.'
}
]
}
]
const DEFINITIONS_BY_MANAGER = new Map(
DEFINITIONS.map((definition) => [definition.packageManager, definition])
)
export function formatPackageManagerCacheCommand(binary: string, args: readonly string[]): string {
return [binary, ...args].join(' ')
}
export function createPackageManagerCacheTargetId(
connectionId: string | null,
packageManager: WorkspacePackageManager,
cwd: string
): string {
return `${connectionId ? `ssh:${encodeURIComponent(connectionId)}` : 'local'}:${packageManager}:${encodeURIComponent(cwd)}`
}
export function getPackageManagerLabel(packageManager: WorkspacePackageManager): string {
switch (packageManager) {
case 'npm':
return 'npm'
case 'pnpm':
return 'pnpm'
case 'yarn':
return 'Yarn'
case 'bun':
return 'Bun'
}
}
export function getPackageManagerCacheCleanupActions(
packageManager: WorkspacePackageManager
): WorkspacePackageManagerCacheCleanupAction[] {
const definition = DEFINITIONS_BY_MANAGER.get(packageManager)
if (!definition) {
return []
}
return definition.actions.map((action) => ({
...action,
packageManager,
command: formatPackageManagerCacheCommand(action.binary, action.args)
}))
}
export function getPackageManagerCacheCleanupAction(
packageManager: WorkspacePackageManager,
actionId: string
): WorkspacePackageManagerCacheCleanupAction | null {
return (
getPackageManagerCacheCleanupActions(packageManager).find((action) => action.id === actionId) ??
null
)
}
export function getPackageManagerCacheSafetyCopy(
safety: WorkspacePackageManagerCacheCleanupSafety
): string {
switch (safety) {
case 'safe':
return 'Safe default'
case 'aggressive':
return 'Aggressive'
}
}
export function detectPackageManagersFromFilenames(
filenames: readonly string[]
): Map<WorkspacePackageManager, string[]> {
const names = new Set(filenames)
const detected = new Map<WorkspacePackageManager, string[]>()
for (const definition of DEFINITIONS) {
const lockfiles = definition.lockfiles.filter((lockfile) => names.has(lockfile))
if (lockfiles.length > 0) {
detected.set(definition.packageManager, lockfiles)
}
}
return detected
}

View File

@ -51,63 +51,6 @@ export type WorkspaceSpaceRepoSummary = {
error: string | null
}
export type WorkspacePackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'
export type WorkspacePackageManagerCacheCleanupSafety = 'safe' | 'aggressive'
export type WorkspacePackageManagerCacheCleanupAction = {
id: string
packageManager: WorkspacePackageManager
safety: WorkspacePackageManagerCacheCleanupSafety
binary: string
args: string[]
command: string
label: string
description: string
}
export type WorkspacePackageManagerCacheTargetWorktree = {
worktreeId: string
lockfiles: string[]
}
export type WorkspacePackageManagerCacheTarget = {
id: string
packageManager: WorkspacePackageManager
connectionId: string | null
isRemote: boolean
targetLabel: string
cwd: string
cachePath: string | null
detectedWorktreeCount: number
detectedWorktrees: WorkspacePackageManagerCacheTargetWorktree[]
detectedLockfiles: string[]
cliAvailable: boolean
unavailableReason: string | null
cleanupActions: WorkspacePackageManagerCacheCleanupAction[]
}
export type WorkspacePackageManagerCacheCleanupRequest = {
targetId: string
actionId: string
packageManager: WorkspacePackageManager
connectionId: string | null
cwd: string
}
export type WorkspacePackageManagerCacheCleanupResult =
| {
ok: true
action: WorkspacePackageManagerCacheCleanupAction
stdout: string
stderr: string
cachePath: string | null
cacheSizeBeforeBytes: number | null
cacheSizeAfterBytes: number | null
reclaimedBytes: number | null
}
| { ok: false; error: string }
export type WorkspaceSpaceAnalysis = {
scannedAt: number
totalSizeBytes: number
@ -115,7 +58,6 @@ export type WorkspaceSpaceAnalysis = {
worktreeCount: number
scannedWorktreeCount: number
unavailableWorktreeCount: number
packageManagerCaches: WorkspacePackageManagerCacheTarget[]
repos: WorkspaceSpaceRepoSummary[]
worktrees: WorkspaceSpaceWorktree[]
}

View File

@ -90,7 +90,6 @@ test.describe('Workspace Space git status checks', () => {
worktreeCount: rows.length,
scannedWorktreeCount: rows.length,
unavailableWorktreeCount: 0,
packageManagerCaches: [],
repos: [
{
repoId: repo.id,