Remove package manager cache cleanup (#2550)
This commit is contained in:
parent
889c7d7621
commit
79137885c0
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,7 +90,6 @@ test.describe('Workspace Space git status checks', () => {
|
|||
worktreeCount: rows.length,
|
||||
scannedWorktreeCount: rows.length,
|
||||
unavailableWorktreeCount: 0,
|
||||
packageManagerCaches: [],
|
||||
repos: [
|
||||
{
|
||||
repoId: repo.id,
|
||||
|
|
|
|||
Loading…
Reference in New Issue