diff --git a/.changeset/store-prune-size.md b/.changeset/store-prune-size.md new file mode 100644 index 0000000000..9b08e40df1 --- /dev/null +++ b/.changeset/store-prune-size.md @@ -0,0 +1,6 @@ +--- +"@pnpm/store.controller": patch +"pnpm": patch +--- + +`pnpm store prune` now displays the total size of removed files. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf6f60a372..1ea3c2be4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8373,6 +8373,9 @@ importers: is-subdir: specifier: 'catalog:' version: 2.0.0 + pretty-bytes: + specifier: 'catalog:' + version: 7.1.0 ramda: specifier: 'catalog:' version: '@pnpm/ramda@0.28.1' diff --git a/store/commands/test/store/storePrune.ts b/store/commands/test/store/storePrune.ts index 8560cb95e5..e2221543a7 100644 --- a/store/commands/test/store/storePrune.ts +++ b/store/commands/test/store/storePrune.ts @@ -92,6 +92,55 @@ test('remove unreferenced packages', async () => { expect(fs.readdirSync(cacheDir)).toStrictEqual([]) }) +test('prune outputs total size of removed files', async () => { + const project = prepare() + const cacheDir = path.resolve('cache') + const storeDir = path.resolve('store') + + await execa('node', [ + pnpmBin, + 'add', + 'is-negative@^2.1.0', + `--store-dir=${storeDir}`, + `--cache-dir=${cacheDir}`, + `--registry=${REGISTRY}`]) + await execa('node', [ + pnpmBin, + 'remove', + 'is-negative', + `--store-dir=${storeDir}`, + `--cache-dir=${cacheDir}`, + '--config.modules-cache-max-age=0', + ], { env: { npm_config_registry: REGISTRY } }) + + project.storeHas('is-negative', '2.1.0') + + const reporter = jest.fn() + await store.handler({ + cacheDir, + dir: process.cwd(), + pnpmHomeDir: '', + rawConfig: { + registry: REGISTRY, + }, + registries: { default: REGISTRY }, + reporter, + storeDir, + userConfig: {}, + dlxCacheMaxAge: Infinity, + virtualStoreDirMaxLength: process.platform === 'win32' ? 60 : 120, + }, ['prune']) + + // Check that the message includes file count and size information + // The message should match pattern like "Removed X files (Y B)" or "Removed 1 file (Y B)" + expect(reporter).toHaveBeenCalledWith( + expect.objectContaining({ + level: 'info', + message: expect.stringMatching(/Removed \d+ files? \(\d+(\.\d+)?\s+\S+\)/), + }) + ) +}) + test('remove packages that are used by project that no longer exist', async () => { prepare() const cacheDir = path.resolve('cache') @@ -130,7 +179,7 @@ test('remove packages that are used by project that no longer exist', async () = expect(reporter).toHaveBeenCalledWith( expect.objectContaining({ level: 'info', - message: 'Removed 4 files', + message: expect.stringMatching(/Removed 4 files \(\d+(\.\d+)?\s+\S+\)/), }) ) @@ -318,7 +367,7 @@ describe('prune when store directory is not properly configured', () => { expect(reporter).toHaveBeenCalledWith( expect.objectContaining({ level: 'info', - message: 'Removed 0 files', + message: expect.stringMatching(/Removed 0 files \(\d+(\.\d+)?\s+\S+\)/), }) ) diff --git a/store/controller/package.json b/store/controller/package.json index 6c39fba757..e031ca2c24 100644 --- a/store/controller/package.json +++ b/store/controller/package.json @@ -57,6 +57,7 @@ "@pnpm/types": "workspace:*", "@zkochan/rimraf": "catalog:", "is-subdir": "catalog:", + "pretty-bytes": "catalog:", "ramda": "catalog:", "ssri": "catalog:", "symlink-dir": "catalog:" diff --git a/store/controller/src/storeController/prune.ts b/store/controller/src/storeController/prune.ts index 8b163f1e0f..a5130023f2 100644 --- a/store/controller/src/storeController/prune.ts +++ b/store/controller/src/storeController/prune.ts @@ -6,6 +6,7 @@ import { globalInfo, globalWarn } from '@pnpm/logger' import type { PackageFilesIndex } from '@pnpm/store.cafs' import type { StoreIndex } from '@pnpm/store.index' import { rimraf } from '@zkochan/rimraf' +import prettyBytes from 'pretty-bytes' import { pruneGlobalVirtualStore } from './pruneGlobalVirtualStore.js' @@ -43,6 +44,7 @@ export async function prune ({ cacheDir, storeDir, storeIndex }: PruneOptions, r const removedHashes = new Set() const dirs = await getSubdirsSafely(cafsDir) let fileCounter = 0 + let totalSize = 0 await Promise.all(dirs.map(async (dir) => { const subdir = path.join(cafsDir, dir) await Promise.all((await fs.readdir(subdir)).map(async (fileName) => { @@ -60,6 +62,7 @@ export async function prune ({ cacheDir, storeDir, storeIndex }: PruneOptions, r } } if (stat.nlink === 1 || stat.nlink === BIG_ONE) { + totalSize += stat.size await fs.unlink(filePath) fileCounter++ // Store the hex digest, which matches the format stored in PackageFileInfo.digest @@ -68,7 +71,7 @@ export async function prune ({ cacheDir, storeDir, storeIndex }: PruneOptions, r } })) })) - globalInfo(`Removed ${fileCounter} file${fileCounter === 1 ? '' : 's'}`) + globalInfo(`Removed ${fileCounter} file${fileCounter === 1 ? '' : 's'} (${prettyBytes(totalSize)})`) // 4. Clean up orphaned package index entries let pkgCounter = 0