diff --git a/src/main/ipc/workspace-space.test.ts b/src/main/ipc/workspace-space.test.ts index 21e599439..c6605c73c 100644 --- a/src/main/ipc/workspace-space.test.ts +++ b/src/main/ipc/workspace-space.test.ts @@ -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) => { 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) - }) }) diff --git a/src/main/ipc/workspace-space.ts b/src/main/ipc/workspace-space.ts index 680aefd34..d4449f3db 100644 --- a/src/main/ipc/workspace-space.ts +++ b/src/main/ipc/workspace-space.ts @@ -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 => { 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 => runPackageManagerCacheCleanup(request) - ) } diff --git a/src/main/workspace-package-manager-cache-cleanup.test.ts b/src/main/workspace-package-manager-cache-cleanup.test.ts deleted file mode 100644 index 6acca242b..000000000 --- a/src/main/workspace-package-manager-cache-cleanup.test.ts +++ /dev/null @@ -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 { - for (let i = 0; i < 20; i++) { - if (mock.mock.calls.length >= count) { - return - } - await new Promise((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(() => { - // 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() - }) -}) diff --git a/src/main/workspace-package-manager-cache-cleanup.ts b/src/main/workspace-package-manager-cache-cleanup.ts deleted file mode 100644 index 2d27b3532..000000000 --- a/src/main/workspace-package-manager-cache-cleanup.ts +++ /dev/null @@ -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 - cwd: string - cachePath: string | null - cliAvailable: boolean - lockfiles: Set - detectedWorktrees: Map> - worktreePaths: Set -} - -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 - 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( - promise: Promise, - signal: AbortSignal | undefined, - onAbort?: () => void | Promise -): Promise { - if (!signal) { - return promise - } - if (signal.aborted) { - await onAbort?.() - throw createAbortError() - } - let abortHandler: (() => void) | null = null - const abortPromise = new Promise((_, 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 { - 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 { - 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 { - const pendingPaths = [targetPath] - let totalSize = 0 - while (pendingPaths.length > 0) { - const currentPath = pendingPaths.pop() - if (!currentPath) { - continue - } - let stats: Awaited> - 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 { - 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 { - 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 { - 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 { - 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 { - const perCwdSeeds = new Map() - 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(), - cwd: detection.worktreePath, - cachePath: null, - cliAvailable: false, - lockfiles: new Set(), - detectedWorktrees: new Map>(), - worktreePaths: new Set() - } 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() - 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() - 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(), - cwd: seed.cwd, - cachePath: seed.cachePath, - cliAvailable: false, - lockfiles: new Set(), - detectedWorktrees: new Map>(), - worktreePaths: new Set() - } 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() - 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> -): 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 { - 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) } - } -} diff --git a/src/main/workspace-space-analysis.test.ts b/src/main/workspace-space-analysis.test.ts index f7a74f5e6..d6b24b3df 100644 --- a/src/main/workspace-space-analysis.test.ts +++ b/src/main/workspace-space-analysis.test.ts @@ -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) }) diff --git a/src/main/workspace-space-analysis.ts b/src/main/workspace-space-analysis.ts index c29083340..6cf197215 100644 --- a/src/main/workspace-space-analysis.ts +++ b/src/main/workspace-space-analysis.ts @@ -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 { - 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 { - 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 } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index ae78cc3f6..50429406e 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -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 cancel: () => Promise - cleanupPackageManagerCache: ( - request: WorkspacePackageManagerCacheCleanupRequest - ) => Promise onProgress: (callback: (progress: WorkspaceSpaceScanProgress) => void) => () => void } workspacePorts: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 15b66240f..adecf4c38 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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 => ipcRenderer.invoke('workspaceSpace:analyze'), cancel: (): Promise => ipcRenderer.invoke('workspaceSpace:cancel'), - cleanupPackageManagerCache: ( - request: WorkspacePackageManagerCacheCleanupRequest - ): Promise => - ipcRenderer.invoke('workspaceSpace:cleanupPackageManagerCache', request), onProgress: (callback: (progress: WorkspaceSpaceScanProgress) => void): (() => void) => { const listener = ( _event: Electron.IpcRendererEvent, diff --git a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx index 1e9bde922..adf5a28f2 100644 --- a/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx +++ b/src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx @@ -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 ( -
- {target.cleanupActions.map((action) => { - const isRunning = runningActionId === action.id - return ( - - ) - })} -
- ) -} - -function PackageManagerCacheSection({ - targets, - stateByTargetId, - onRun -}: { - targets: WorkspacePackageManagerCacheTarget[] - stateByTargetId: Record - onRun: ( - target: WorkspacePackageManagerCacheTarget, - action: WorkspacePackageManagerCacheCleanupAction - ) => void -}): React.JSX.Element | null { - if (targets.length === 0) { - return null - } - - return ( -
-
-
- -
-
Package Manager Caches
-
- Detected from lockfiles. Cleanup runs only when you choose an action. -
-
-
-
- -
- {targets.map((target) => { - const state = stateByTargetId[target.id] - const lastOutput = state?.lastOutput?.trim() ?? '' - return ( -
-
-
- - {getPackageManagerLabel(target.packageManager)} - - - {target.cliAvailable ? 'CLI found' : 'CLI missing'} - - {target.isRemote ? ( - - - SSH - - ) : null} -
-
- {target.targetLabel} · {target.detectedWorktreeCount}{' '} - {target.detectedWorktreeCount === 1 ? 'workspace' : 'workspaces'} ·{' '} - {target.detectedLockfiles.join(', ')} -
-
- - - {target.cleanupActions.map((action) => action.command).join(' · ')} - -
- {target.unavailableReason ? ( -
{target.unavailableReason}
- ) : null} - {state?.error ? ( -
- - {state.error} -
- ) : lastOutput ? ( -
- {lastOutput} -
- ) : null} -
- -
-
- {target.cleanupActions.map((action) => ( - - {getPackageManagerCacheSafetyCopy(action.safety)} - - ))} -
- -
-
- ) - })} -
-
- ) -} - function WorkspaceRow({ worktree, maxSize, @@ -1276,9 +1089,6 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element { const [selectedIds, setSelectedIds] = useState>(() => new Set()) const [inspectedWorktreeId, setInspectedWorktreeId] = useState(null) const [treemapZoomWorktreeId, setTreemapZoomWorktreeId] = useState(null) - const [cacheCleanupStateByTargetId, setCacheCleanupStateByTargetId] = useState< - Record - >({}) const [gitRefreshStateByWorktreeId, setGitRefreshStateByWorktreeId] = useState< Record >({}) @@ -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 (
@@ -1788,12 +1531,6 @@ export function WorkspaceSpaceManagerPanel(): React.JSX.Element {
) : null} - - {hasRows || isInitialScan ? (
{ - 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 + }) }) }) diff --git a/src/renderer/src/store/slices/workspace-space.ts b/src/renderer/src/store/slices/workspace-space.ts index ae008ae4a..3f2ae9ca5 100644 --- a/src/renderer/src/store/slices/workspace-space.ts +++ b/src/renderer/src/store/slices/workspace-space.ts @@ -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 | 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 } diff --git a/src/shared/package-manager-cache-cleanup.test.ts b/src/shared/package-manager-cache-cleanup.test.ts deleted file mode 100644 index 0326b9a82..000000000 --- a/src/shared/package-manager-cache-cleanup.test.ts +++ /dev/null @@ -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') - }) -}) diff --git a/src/shared/package-manager-cache-cleanup.ts b/src/shared/package-manager-cache-cleanup.ts deleted file mode 100644 index 140f946c0..000000000 --- a/src/shared/package-manager-cache-cleanup.ts +++ /dev/null @@ -1,159 +0,0 @@ -import type { - WorkspacePackageManager, - WorkspacePackageManagerCacheCleanupAction, - WorkspacePackageManagerCacheCleanupSafety -} from './workspace-space-types' - -type PackageManagerCacheDefinition = { - packageManager: WorkspacePackageManager - lockfiles: readonly string[] - actions: readonly Omit[] -} - -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 { - const names = new Set(filenames) - const detected = new Map() - 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 -} diff --git a/src/shared/workspace-space-types.ts b/src/shared/workspace-space-types.ts index ff62bd23f..bfd173db4 100644 --- a/src/shared/workspace-space-types.ts +++ b/src/shared/workspace-space-types.ts @@ -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[] } diff --git a/tests/e2e/workspace-space-git-status.spec.ts b/tests/e2e/workspace-space-git-status.spec.ts index 3712e476a..5a2eca8c6 100644 --- a/tests/e2e/workspace-space-git-status.spec.ts +++ b/tests/e2e/workspace-space-git-status.spec.ts @@ -90,7 +90,6 @@ test.describe('Workspace Space git status checks', () => { worktreeCount: rows.length, scannedWorktreeCount: rows.length, unavailableWorktreeCount: 0, - packageManagerCaches: [], repos: [ { repoId: repo.id,