Files
bun-src/test/cli/install/bun-prune.test.ts
2026-08-27 21:09:14 +00:00

3445 lines
140 KiB
TypeScript

import { file, write } from "bun";
import { afterAll, beforeAll, expect, test } from "bun:test";
import { bunEnv, bunExe, isWindows, normalizeBunSnapshot, runBunInstall, tempDir, VerdaccioRegistry } from "harness";
import {
chmodSync,
closeSync,
copyFileSync,
existsSync,
lstatSync,
mkdirSync,
openSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { basename, join } from "node:path";
import { pathToFileURL } from "node:url";
const registry = new VerdaccioRegistry();
beforeAll(async () => {
await registry.start();
});
afterAll(() => {
registry.stop();
});
// CI exports BUN_INSTALL_CACHE_DIR, which overrides the harness bunfig's per-test `cache`; concurrent cases sharing one cache race on Windows.
const installEnv = (dir: string) => ({ ...bunEnv, BUN_INSTALL_CACHE_DIR: join(dir, ".bun-cache") });
const WARN = (expected: string, kept: string) =>
`warn: ${expected} is not the version bun.lock expects; keeping ${kept}`;
const MISSING_WARN = (expected: string, kept: string) => `warn: ${expected} is missing; keeping ${kept}`;
const NOTE = "note: run 'bun install' first";
const OUT_OF_SYNC = "error: bun.lock does not match package.json, nothing to prune";
const OUT_OF_SYNC_NOTE = "note: run 'bun install' first";
const PRUNED_NOTE = 'note: skipped 1 workspace listed in bun.lock but not on disk: "other"';
const BANNER = "bun prune <version> (<revision>)";
const plural = (n: number, noun: string) => `${n} ${noun}${n === 1 ? "" : "s"}`;
const NOTHING = (packages: number, folders: number) =>
`Done! Checked ${plural(packages, "package")} across ${plural(folders, "folder")} (nothing to prune)`;
const REMOVED = (n: number, checked: number) => `${plural(n, "package")} removed (checked ${checked})`;
const CAN_BE_REMOVED = (n: number, checked: number) => `${plural(n, "package")} can be removed (checked ${checked})`;
// The copy-pasteable line `--dry-run` prints last: the invocation with `--dry-run` taken out.
const APPLY_HINT = (...flags: string[]) => [" bun prune", ...flags].join(" ");
const DURATION = /\) \[\d+(\.\d+)?m?s\]$/m;
const linkers: Linker[] = ["hoisted", "isolated"];
async function prune(where: string | { dir: string; cwd: string }, ...args: string[]) {
const { dir, cwd } = typeof where === "string" ? { dir: where, cwd: where } : where;
await using proc = Bun.spawn({
cmd: [bunExe(), "prune", ...args],
env: installEnv(dir),
cwd,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}
// stdout and stderr share one file so the test sees the order a terminal would.
async function pruneMerged(dir: string, ...args: string[]) {
const log = join(dir, "prune-merged.log");
const fd = openSync(log, "w");
try {
await using proc = Bun.spawn({
cmd: [bunExe(), "prune", ...args],
env: installEnv(dir),
cwd: dir,
stdout: fd,
stderr: fd,
});
const exitCode = await proc.exited;
return { lines: out(readFileSync(log, "utf8")).split("\n"), exitCode };
} finally {
closeSync(fd);
}
}
function out(stdout: string) {
return normalizeBunSnapshot(stdout).replaceAll("\\", "/");
}
function lines(stdout: string) {
return out(stdout).split("\n");
}
function plant(dir: string, rel: string) {
const abs = join(dir, rel);
mkdirSync(abs, { recursive: true });
writeFileSync(join(abs, "package.json"), JSON.stringify({ name: basename(rel) }));
return abs;
}
function lock(dir: string) {
return file(join(dir, "bun.lock")).text();
}
function isSymlink(path: string) {
return lstatSync(path).isSymbolicLink();
}
// On Windows a bin is a `<name>.exe` + `<name>.bunx` shim pair instead of a symlink.
function binFiles(nm: string, name: string) {
const bin = join(nm, ".bin", name);
return isWindows ? [`${bin}.exe`, `${bin}.bunx`] : [bin];
}
function expectBinInstalled(nm: string, name: string) {
for (const path of binFiles(nm, name)) {
expect(existsSync(path)).toBeTrue();
}
}
function expectBinRemoved(nm: string, name: string) {
for (const path of binFiles(nm, name)) {
expect(() => lstatSync(path)).toThrow();
}
}
type BunfigOpts = NonNullable<Parameters<VerdaccioRegistry["createTestDir"]>[0]>["bunfigOpts"];
async function setup(pkgJson: Record<string, unknown>, bunfigOpts?: BunfigOpts) {
const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts });
await write(packageJson, JSON.stringify(pkgJson));
await runBunInstall(installEnv(packageDir), packageDir);
return packageDir;
}
async function install(dir: string, ...args: string[]) {
await using proc = Bun.spawn({
cmd: [bunExe(), "install", ...args],
env: installEnv(dir),
cwd: dir,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).not.toContain("error:");
expect(exitCode).toBe(0);
return stdout;
}
type Linker = "hoisted" | "isolated";
async function setupWithLinker(linker: Linker, pkgJson: Record<string, unknown>, bunfigOpts?: BunfigOpts) {
const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker, ...bunfigOpts } });
await write(packageJson, JSON.stringify(pkgJson));
await install(packageDir, "--linker", linker);
return packageDir;
}
type Workspaces = { root?: Record<string, unknown>; packages: Record<string, Record<string, unknown>> };
function writeWorkspaces(dir: string, packageJson: string, { root, packages }: Workspaces) {
return Promise.all([
write(packageJson, JSON.stringify({ name: "root", workspaces: ["packages/*"], ...root })),
...Object.entries(packages).map(([folder, pkg]) =>
write(join(dir, "packages", folder, "package.json"), JSON.stringify({ name: folder, version: "1.0.0", ...pkg })),
),
]);
}
async function setupWorkspaces(linker: Linker, workspaces: Workspaces) {
const { packageDir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker } });
await writeWorkspaces(packageDir, packageJson, workspaces);
await install(packageDir, "--linker", linker);
return packageDir;
}
function expectRefused({ stdout, stderr, exitCode }: Awaited<ReturnType<typeof prune>>) {
expect(normalizeBunSnapshot(stderr)).toBe(`${OUT_OF_SYNC}\n${OUT_OF_SYNC_NOTE}`);
expect(out(stdout)).toBe(BANNER);
expect(exitCode).toBe(1);
}
async function expectProductionInstallIsNoop(dir: string) {
const { out: installOut } = await runBunInstall(installEnv(dir), dir, { production: true });
expect(installOut).toContain("no changes");
}
function linkOutside(dir: string, rel: string, contents: Record<string, string> = {}) {
const outside = join(dir, "outside", basename(rel));
mkdirSync(outside, { recursive: true });
for (const [name, text] of Object.entries(contents)) {
writeFileSync(join(outside, name), text);
}
const link = join(dir, rel);
mkdirSync(join(link, ".."), { recursive: true });
symlinkSync(outside, link, "junction");
expect(isSymlink(link)).toBeTrue();
return outside;
}
async function bun(where: string | { dir: string; cwd: string }, ...args: string[]) {
const { dir, cwd } = typeof where === "string" ? { dir: where, cwd: where } : where;
await using proc = Bun.spawn({
cmd: [bunExe(), ...args],
env: installEnv(dir),
cwd,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}
// Every global-dir variable is set so an inherited one can never point a test at the developer's real global folder.
const globalEnv = (dir: string, bunInstallDir: string) => ({
...installEnv(dir),
BUN_INSTALL: bunInstallDir,
BUN_INSTALL_GLOBAL_DIR: join(bunInstallDir, "install", "global"),
BUN_INSTALL_BIN: join(bunInstallDir, "bin"),
});
async function run(env: NodeJS.Dict<string>, cwd: string, ...args: string[]) {
await using proc = Bun.spawn({ cmd: [bunExe(), ...args], cwd, env, stdout: "pipe", stderr: "pipe" });
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
}
const gitEnv = {
...bunEnv,
GIT_CONFIG_NOSYSTEM: "1",
GIT_AUTHOR_NAME: "Test",
GIT_AUTHOR_EMAIL: "[email protected]",
GIT_COMMITTER_NAME: "Test",
GIT_COMMITTER_EMAIL: "[email protected]",
};
async function gitDependency(dir: string, name: string) {
const repo = join(dir, "repos", name);
mkdirSync(repo, { recursive: true });
writeFileSync(join(repo, "package.json"), JSON.stringify({ name, version: "1.0.0" }));
for (const args of [
["init", "-q"],
["add", "package.json"],
["commit", "-q", "-m", "init", "--no-gpg-sign"],
]) {
await using proc = Bun.spawn({ cmd: ["git", ...args], cwd: repo, env: gitEnv, stdout: "ignore", stderr: "pipe" });
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(stderr).not.toContain("fatal:");
expect(exitCode).toBe(0);
}
return `git+${pathToFileURL(repo)}`;
}
function copyTarball(dir: string, name: string, version: string) {
const file = `${name}-${version}.tgz`;
copyFileSync(join(registry.packagesPath, name, file), join(dir, file));
return `file:./${file}`;
}
const storeEntries = (dir: string) =>
readdirSync(join(dir, "node_modules", ".bun"))
.filter(name => name !== "node_modules")
.toSorted();
test.concurrent("removes extraneous packages, keeps everything the lockfile installs", async () => {
const dir = await setup({
name: "foo",
dependencies: { "no-deps": "1.0.0", "@scoped/has-bin-entry": "1.0.0" },
});
const nm = join(dir, "node_modules");
const planted = [
plant(dir, "node_modules/junk"),
plant(dir, "node_modules/@scoped/junk"),
plant(dir, "node_modules/@other/thing"),
];
writeFileSync(join(nm, "README.txt"), "");
plant(dir, "node_modules/.cache/x");
const lockBefore = await lock(dir);
const first = await prune(dir);
expect(out(first.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- @other/thing
- @scoped/junk
- junk
3 packages removed (checked 5)"
`);
expect(first.stdout).toMatch(/\(checked 5\) \[\d+(\.\d+)?m?s\]\n?$/);
expect(first.exitCode).toBe(0);
for (const path of planted) {
expect(existsSync(path)).toBeFalse();
}
expect(existsSync(join(nm, "@other"))).toBeFalse();
expect(existsSync(join(nm, "no-deps"))).toBeTrue();
expect(existsSync(join(nm, "@scoped", "has-bin-entry"))).toBeTrue();
expect(existsSync(join(nm, "README.txt"))).toBeTrue();
expect(existsSync(join(nm, ".cache", "x"))).toBeTrue();
expectBinInstalled(nm, "has-bin-entry");
expect(await lock(dir)).toBe(lockBefore);
const second = await prune(dir);
expect(out(second.stdout)).toEndWith(NOTHING(2, 1));
expect(second.stdout).toMatch(/\(nothing to prune\) \[\d+(\.\d+)?m?s\]\n?$/);
expect(second.exitCode).toBe(0);
});
test.concurrent("prunes nested node_modules folders the tree installs into", async () => {
const dir = await setup({ name: "foo", dependencies: { "one-dep": "1.0.0", "no-deps": "2.0.0" } });
const nested = join(dir, "node_modules", "one-dep", "node_modules", "no-deps");
expect(await file(join(nested, "package.json")).json()).toMatchObject({ version: "1.0.1" });
const junk = plant(dir, "node_modules/one-dep/node_modules/junk");
const { stdout, exitCode } = await prune(dir);
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- junk (node_modules/one-dep/node_modules)
1 package removed (checked 4)"
`);
expect(exitCode).toBe(0);
expect(existsSync(junk)).toBeFalse();
expect(existsSync(join(nested, "package.json"))).toBeTrue();
});
test.concurrent.each([["--production"], ["--prod"], ["--omit=dev"]])(
"%s removes packages only reachable through devDependencies",
async (...flags: string[]) => {
const dir = await setup({
name: "foo",
dependencies: { "no-deps": "1.0.0", "@scoped/has-bin-entry": "1.0.0" },
devDependencies: { "one-fixed-dep-bins": "1.0.0", "what-bin": "1.0.0" },
});
const nm = join(dir, "node_modules");
expect(existsSync(join(nm, "no-deps-bins"))).toBeTrue();
expectBinInstalled(nm, "what-bin");
expectBinInstalled(nm, "has-bin-entry");
const lockBefore = await lock(dir);
const { stdout, exitCode } = await prune(dir, ...flags);
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
- [email protected]
- [email protected]
3 packages removed (checked 5)"
`);
expect(exitCode).toBe(0);
expect(existsSync(join(nm, "no-deps"))).toBeTrue();
expect(existsSync(join(nm, "@scoped", "has-bin-entry"))).toBeTrue();
expect(existsSync(join(nm, "no-deps-bins"))).toBeFalse();
expect(existsSync(join(nm, "one-fixed-dep-bins"))).toBeFalse();
expect(existsSync(join(nm, "what-bin"))).toBeFalse();
// pnpm#2326: bins of removed packages are cleaned up, on Windows too (shim files instead of links).
expectBinRemoved(nm, "what-bin");
expectBinInstalled(nm, "has-bin-entry");
await expectProductionInstallIsNoop(dir);
expect(await lock(dir)).toBe(lockBefore);
},
);
test.concurrent("--production keeps a package that prod and dev both need", async () => {
const pkg = {
name: "foo",
dependencies: { "no-deps": "1.0.0" },
devDependencies: { "one-fixed-dep": "1.0.0" },
};
const [dir, plainDir] = await Promise.all([setup(pkg), setup(pkg)]);
const production = await prune(dir, "--production");
expect(out(production.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
1 package removed (checked 2)"
`);
expect(production.exitCode).toBe(0);
expect(existsSync(join(dir, "node_modules", "no-deps"))).toBeTrue();
expect(existsSync(join(dir, "node_modules", "one-fixed-dep"))).toBeFalse();
const plain = await prune(plainDir);
expect(out(plain.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
Done! Checked 2 packages across 1 folder (nothing to prune)"
`);
expect(plain.exitCode).toBe(0);
expect(existsSync(join(plainDir, "node_modules", "one-fixed-dep"))).toBeTrue();
});
test.concurrent("--dry-run prints without deleting; --silent deletes without printing", async () => {
const dir = await setup({ name: "foo", dependencies: { "no-deps": "1.0.0" } });
const junk = plant(dir, "node_modules/junk");
const dryRun = await prune(dir, "--dry-run");
expect(out(dryRun.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- junk
1 package can be removed (checked 2)
bun prune"
`);
expect(dryRun.stdout).toMatch(DURATION);
expect(dryRun.stderr).toBe("");
expect(dryRun.exitCode).toBe(0);
expect(existsSync(junk)).toBeTrue();
const withFlags = await prune(dir, "--dry-run", "--linker", "hoisted", "--dry-run");
expect(lines(withFlags.stdout)).toStrictEqual([
BANNER,
"",
"- junk",
CAN_BE_REMOVED(1, 2),
APPLY_HINT("--linker", "hoisted"),
]);
expect(withFlags.exitCode).toBe(0);
expect(existsSync(junk)).toBeTrue();
const silentDryRun = await prune(dir, "--dry-run", "--silent");
expect(silentDryRun.stdout).toBe("");
expect(silentDryRun.stderr).toBe("");
expect(silentDryRun.exitCode).toBe(0);
expect(existsSync(junk)).toBeTrue();
const silent = await prune(dir, "--silent");
expect(silent.stdout).toBe("");
expect(silent.stderr).toBe("");
expect(silent.exitCode).toBe(0);
expect(existsSync(junk)).toBeFalse();
const clean = await prune(dir, "--dry-run");
expect(lines(clean.stdout)).toStrictEqual([BANNER, "", NOTHING(1, 1)]);
expect(clean.exitCode).toBe(0);
});
test.concurrent("nothing to prune when node_modules is missing or clean", async () => {
const { packageDir, packageJson } = await registry.createTestDir();
await write(packageJson, JSON.stringify({ name: "foo", dependencies: { "no-deps": "1.0.0" } }));
await install(packageDir, "--lockfile-only");
const nm = join(packageDir, "node_modules");
rmSync(nm, { recursive: true, force: true });
expect(existsSync(join(packageDir, "bun.lock"))).toBeTrue();
const missing = await prune(packageDir);
expect(out(missing.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
Done! No node_modules folder (nothing to prune)"
`);
expect(missing.exitCode).toBe(0);
expect(existsSync(nm)).toBeFalse();
const cleanDir = await setup({ name: "foo", dependencies: { "no-deps": "1.0.0" } });
const clean = await prune(cleanDir, "--production");
expect(out(clean.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
Done! Checked 1 package across 1 folder (nothing to prune)"
`);
expect(clean.exitCode).toBe(0);
expect(existsSync(join(cleanDir, "node_modules", "no-deps"))).toBeTrue();
});
test.concurrent("never follows symlinks out of node_modules", async () => {
const dir = await setup({ name: "foo", dependencies: { "no-deps": "1.0.0" } });
const nm = join(dir, "node_modules");
const outside = join(dir, "outside");
mkdirSync(outside);
writeFileSync(join(outside, "keep.txt"), "keep");
const link = join(nm, "linked-junk");
symlinkSync(outside, link, "junction");
expect(isSymlink(link)).toBeTrue();
const { stdout, exitCode } = await prune(dir);
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- linked-junk
1 package removed (checked 2)"
`);
expect(exitCode).toBe(0);
expect(() => lstatSync(link)).toThrow();
expect(existsSync(join(outside, "keep.txt"))).toBeTrue();
expect(existsSync(join(nm, "no-deps"))).toBeTrue();
});
test.concurrent("refuses to run without a lockfile", async () => {
const [{ packageDir: noLockDir, packageJson }, installedDir] = await Promise.all([
registry.createTestDir(),
setup({ name: "foo", dependencies: { "no-deps": "1.0.0" } }),
]);
await write(packageJson, JSON.stringify({ name: "foo", dependencies: { "no-deps": "1.0.0" } }));
const junk = plant(noLockDir, "node_modules/junk");
const noLock = await prune(noLockDir);
expect(normalizeBunSnapshot(noLock.stderr)).toMatchInlineSnapshot(`
"error: missing lockfile, nothing to prune
note: run 'bun install' first"
`);
expect(out(noLock.stdout)).toBe(BANNER);
expect(noLock.exitCode).toBe(1);
expect(existsSync(junk)).toBeTrue();
const silentNoLock = await prune(noLockDir, "--silent");
expect(silentNoLock.stdout).toBe("");
expect(silentNoLock.stderr).toBe("");
expect(silentNoLock.exitCode).toBe(1);
expect(existsSync(junk)).toBeTrue();
// Usage errors are the one class --silent does not suppress.
for (const args of [["foo"], ["foo", "--silent"]]) {
const positional = await prune(installedDir, ...args);
expect(normalizeBunSnapshot(positional.stderr)).toMatchInlineSnapshot(`
"error: bun prune does not take arguments, it always prunes the whole node_modules
note: run 'bun prune --help' for more information"
`);
expect(positional.stdout).toBe("");
expect(positional.exitCode).toBe(1);
}
expect(existsSync(join(installedDir, "node_modules", "no-deps"))).toBeTrue();
const help = await prune(noLockDir, "--help");
expect(help.stdout).toContain("bun prune");
expect(help.stdout).toContain("--production");
expect(help.stdout).toContain("--linker");
expect(help.stdout).toContain("--filter");
expect(help.exitCode).toBe(0);
expect(existsSync(junk)).toBeTrue();
});
// pnpm#9796: hoisted keeps the root's workspace links under --production because they are root->workspace prod edges; the isolated per-importer dev link case is below.
test.concurrent("workspaces: prunes workspace folders, keeps workspace links, runs from the root", async () => {
const { packageDir: dir, packageJson } = await registry.createTestDir();
await Promise.all([
write(
packageJson,
JSON.stringify({ name: "root", workspaces: ["packages/*"], dependencies: { "no-deps": "2.0.0" } }),
),
write(
join(dir, "packages", "a", "package.json"),
JSON.stringify({
name: "a",
version: "1.0.0",
dependencies: { "no-deps": "1.0.0" },
devDependencies: { "a-dep": "1.0.1" },
}),
),
]);
await runBunInstall(installEnv(dir), dir);
const nm = join(dir, "node_modules");
const workspaceNoDeps = join(dir, "packages", "a", "node_modules", "no-deps");
expect(isSymlink(join(nm, "a"))).toBeTrue();
expect(existsSync(workspaceNoDeps)).toBeTrue();
expect(existsSync(join(nm, "a-dep"))).toBeTrue();
const junk = plant(dir, "packages/a/node_modules/junk");
const { stdout, exitCode } = await prune(join(dir, "packages", "a"), "--production");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
- junk (node_modules/a/node_modules)
2 packages removed (checked 5)"
`);
expect(exitCode).toBe(0);
expect(isSymlink(join(nm, "a"))).toBeTrue();
expect(existsSync(workspaceNoDeps)).toBeTrue();
expect(existsSync(junk)).toBeFalse();
expect(existsSync(join(nm, "a-dep"))).toBeFalse();
});
test.concurrent("keeps dependencies bundled inside a package", async () => {
const dir = await setup({ name: "foo", dependencies: { "bundled-transitive": "1.0.0" } });
const bundled = join(dir, "node_modules", "bundled-transitive", "node_modules", "no-deps", "package.json");
expect(existsSync(bundled)).toBeTrue();
const { stdout, exitCode } = await prune(dir);
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
Done! Checked 3 packages across 1 folder (nothing to prune)"
`);
expect(exitCode).toBe(0);
expect(existsSync(bundled)).toBeTrue();
});
// pnpm#881: --production also removes dev-only entries from the store.
test.concurrent("isolated linker: removes unused store entries and their links", async () => {
const dir = await setup(
{ name: "foo", dependencies: { "no-deps": "1.0.0" }, devDependencies: { "one-dep": "1.0.0" } },
{ linker: "isolated" },
);
const nm = join(dir, "node_modules");
const store = join(nm, ".bun");
expect(existsSync(join(store, "[email protected]"))).toBeTrue();
expect(existsSync(join(store, "[email protected]"))).toBeTrue();
expect(existsSync(join(store, "[email protected]"))).toBeTrue();
expect(isSymlink(join(nm, "one-dep"))).toBeTrue();
plant(dir, "node_modules/.bun/[email protected]/node_modules/junk");
const peerVariant = plant(dir, "node_modules/.bun/[email protected]+0123456789abcdef/node_modules/no-deps");
const junkReal = plant(dir, "node_modules/junk-real");
const hiddenHoist = join(store, "node_modules");
mkdirSync(hiddenHoist, { recursive: true });
const zzz = plant(dir, "node_modules/.bun/[email protected]/node_modules/zzz");
symlinkSync(zzz, join(hiddenHoist, "zzz"), "junction");
expect(isSymlink(join(hiddenHoist, "zzz"))).toBeTrue();
const lockBefore = await lock(dir);
const { stdout, exitCode } = await prune(dir, "--production");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
- junk-real
- [email protected]+0123456789abcdef
- [email protected]
- [email protected]
- [email protected]
6 packages removed (checked 9)"
`);
expect(exitCode).toBe(0);
expect(existsSync(join(store, "[email protected]"))).toBeFalse();
expect(existsSync(join(store, "[email protected]"))).toBeFalse();
expect(existsSync(join(store, "[email protected]"))).toBeFalse();
expect(existsSync(join(store, "[email protected]"))).toBeFalse();
expect(existsSync(junkReal)).toBeFalse();
expect(existsSync(join(store, "[email protected]"))).toBeTrue();
expect(existsSync(peerVariant)).toBeFalse();
expect(() => lstatSync(join(nm, "one-dep"))).toThrow();
expect(existsSync(join(nm, "no-deps", "package.json"))).toBeTrue();
expect(existsSync(hiddenHoist)).toBeTrue();
expect(() => lstatSync(join(hiddenHoist, "zzz"))).toThrow();
expect(await lock(dir)).toBe(lockBefore);
await expectProductionInstallIsNoop(dir);
});
test.concurrent("isolated linker: --verbose does not print the store build timings", async () => {
const dir = await setup(
{ name: "foo", dependencies: { "no-deps": "1.0.0" }, devDependencies: { "one-dep": "1.0.0" } },
{ linker: "isolated" },
);
// `--production` makes prune build the store twice (once with every
// dependency type, once with the production set); `bun install --verbose`
// prints a timing line per store build, prune must print none.
const { stdout, stderr, exitCode } = await prune(dir, "--production", "--verbose");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
- [email protected]
2 packages removed (checked 5)"
`);
expect(stderr).not.toContain("Resolved peers");
expect(stderr).not.toContain("Created store");
expect(exitCode).toBe(0);
});
test.concurrent("isolated linker: prune removes the peer-hash variants a peer bump left behind", async () => {
const dir = await setupWithLinker("isolated", {
name: "foo",
dependencies: { "peer-deps-fixed": "1.0.0", "no-deps": "1.0.0" },
});
const store = join(dir, "node_modules", ".bun");
const peerEntries = () => storeEntries(dir).filter(entry => entry.startsWith("peer-deps-fixed@"));
const [before] = peerEntries();
expect(before).toMatch(/^peer-deps-fixed@1\.0\.0\+[0-9a-f]{16}$/);
await write(
join(dir, "package.json"),
JSON.stringify({ name: "foo", dependencies: { "peer-deps-fixed": "1.0.0", "no-deps": "1.0.1" } }),
);
await install(dir, "--linker", "isolated");
const variants = peerEntries();
expect(variants).toHaveLength(2);
expect(variants).toContain(before);
const after = variants.find(entry => entry !== before)!;
expect(existsSync(join(store, "[email protected]"))).toBeTrue();
expect(existsSync(join(store, "[email protected]"))).toBeTrue();
const { stdout, exitCode } = await prune(dir, "--linker", "isolated");
expect(out(stdout).split("\n")).toStrictEqual([
"bun prune <version> (<revision>)",
"",
"- [email protected]",
`- ${before}`,
"2 packages removed (checked 6)",
]);
expect(exitCode).toBe(0);
expect(peerEntries()).toStrictEqual([after]);
expect(existsSync(join(store, "[email protected]"))).toBeFalse();
expect(existsSync(join(store, "[email protected]"))).toBeTrue();
expect(await install(dir, "--linker", "isolated")).toContain("no changes");
});
test.concurrent("isolated linker + global store: unlinks the store link, never deletes the shared entry", async () => {
const dir = await setup(
{ name: "foo", devDependencies: { "one-dep": "1.0.0" } },
{ linker: "isolated", globalStore: true },
);
const storeEntry = join(dir, "node_modules", ".bun", "[email protected]");
expect(isSymlink(storeEntry)).toBeTrue();
const linksDir = join(installEnv(dir).BUN_INSTALL_CACHE_DIR, "links");
const globalEntry = readdirSync(linksDir).find(name => name.startsWith("[email protected]"));
expect(globalEntry).toBeDefined();
const globalPkgJson = join(linksDir, globalEntry!, "node_modules", "one-dep", "package.json");
expect(existsSync(globalPkgJson)).toBeTrue();
const { stdout, exitCode } = await prune(dir, "--production");
expect(out(stdout)).toContain("- [email protected]");
expect(out(stdout)).toEndWith("2 packages removed (checked 3)");
expect(exitCode).toBe(0);
expect(() => lstatSync(storeEntry)).toThrow();
expect(lstatSync(join(linksDir, globalEntry!)).isDirectory()).toBeTrue();
expect(await file(globalPkgJson).json()).toMatchObject({ name: "one-dep", version: "1.0.0" });
});
test.concurrent("isolated linker: bins of removed packages are removed, live ones kept", async () => {
const dir = await setupWithLinker("isolated", {
name: "foo",
dependencies: { "@scoped/has-bin-entry": "1.0.0" },
devDependencies: { "what-bin": "1.0.0" },
});
const nm = join(dir, "node_modules");
expectBinInstalled(nm, "what-bin");
expectBinInstalled(nm, "has-bin-entry");
const { stdout, exitCode } = await prune(dir, "--production", "--linker", "isolated");
expect(out(stdout)).toEndWith("- [email protected]\n1 package removed (checked 4)");
expect(exitCode).toBe(0);
expectBinRemoved(nm, "what-bin");
expectBinInstalled(nm, "has-bin-entry");
});
test.concurrent("hoisted: dot entries and files are never touched even when the lockfile is empty", async () => {
const { packageDir: dir, packageJson } = await registry.createTestDir();
await Promise.all([
write(packageJson, JSON.stringify({ name: "empty" })),
write(
join(dir, "bun.lock"),
`{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "empty",
},
},
"packages": {}
}
`,
),
]);
const nm = join(dir, "node_modules");
mkdirSync(nm);
const junk = plant(dir, "node_modules/junk");
const emptyStore = plant(dir, "node_modules/.bun/node_modules/whatever");
const cache = plant(dir, "node_modules/.cache/[email protected]");
const integrity = join(nm, ".yarn-integrity");
writeFileSync(integrity, "");
const { stdout, stderr, exitCode } = await prune(dir);
expect(stderr).toBe("");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- junk
1 package removed (checked 1)"
`);
expect(exitCode).toBe(0);
expect(existsSync(junk)).toBeFalse();
expect(existsSync(emptyStore)).toBeTrue();
expect(existsSync(cache)).toBeTrue();
expect(existsSync(integrity)).toBeTrue();
});
// A hoisted prune over a tree whose store still holds entries would report the store as checked without looking at it.
test.concurrent(
"hoisted: refuses up front when node_modules/.bun holds store entries, even with nothing to remove",
async () => {
const dir = await setupWithLinker("isolated", { name: "foo", dependencies: { "no-deps": "1.0.0" } });
const nm = join(dir, "node_modules");
expect(storeEntries(dir)).toStrictEqual(["[email protected]"]);
for (const flags of [
["--linker", "hoisted"],
["--linker", "hoisted", "--dry-run"],
["--linker", "hoisted", "--production"],
]) {
const { stdout, stderr, exitCode } = await prune(dir, ...flags);
expect(out(stdout)).toBe(BANNER);
expect(normalizeBunSnapshot(stderr)).toMatchInlineSnapshot(`
"error: node_modules was installed with the isolated linker, but bun prune would use the hoisted linker
note: run 'bun prune --linker isolated' to prune it as-is, or 'bun install' to reinstall with the hoisted linker"
`);
expect(exitCode).toBe(1);
}
const silent = await prune(dir, "--linker", "hoisted", "--silent");
expect(silent.stdout).toBe("");
expect(silent.stderr).toBe("");
expect(silent.exitCode).toBe(1);
expect(storeEntries(dir)).toStrictEqual(["[email protected]"]);
expect(isSymlink(join(nm, "no-deps"))).toBeTrue();
const same = await prune(dir, "--linker", "isolated");
expect(lines(same.stdout)).toStrictEqual([BANNER, "", NOTHING(2, 2)]);
expect(same.exitCode).toBe(0);
},
);
test.concurrent(
"hoisted: a nested tree owned by a package that was replaced with a symlink is not walked",
async () => {
const dir = await setup({ name: "foo", dependencies: { "one-dep": "1.0.0", "no-deps": "2.0.0" } });
const nm = join(dir, "node_modules");
expect(existsSync(join(nm, "one-dep", "node_modules", "no-deps"))).toBeTrue();
rmSync(join(nm, "one-dep"), { recursive: true });
const outside = linkOutside(dir, "node_modules/one-dep", {
"package.json": JSON.stringify({ name: "one-dep", version: "1.0.0" }),
});
plant(outside, "node_modules/no-deps");
const keepMe = plant(outside, "node_modules/keep-me");
const { stdout, exitCode } = await prune(dir);
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
Done! Checked 2 packages across 1 folder (nothing to prune)"
`);
expect(exitCode).toBe(0);
expect(existsSync(keepMe)).toBeTrue();
expect(isSymlink(join(nm, "one-dep"))).toBeTrue();
},
);
test.concurrent("hoisted: a symlinked scope dir is unlinked, not followed", async () => {
const dir = await setup({ name: "foo", dependencies: { "no-deps": "1.0.0" } });
const nm = join(dir, "node_modules");
const outside = linkOutside(dir, "node_modules/@fake");
const inner = plant(outside, "thing");
plant(dir, "node_modules/@real/junk");
const { stdout, exitCode } = await prune(dir);
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- @fake
- @real/junk
2 packages removed (checked 3)"
`);
expect(exitCode).toBe(0);
expect(() => lstatSync(join(nm, "@fake"))).toThrow();
expect(existsSync(inner)).toBeTrue();
expect(existsSync(join(nm, "@real"))).toBeFalse();
});
test.concurrent(
"hoisted: a workspace's nested folder is pruned where bun.lock says the workspace is, not through node_modules/<name>",
async () => {
const dir = await setupWorkspaces("hoisted", {
root: { dependencies: { "no-deps": "2.0.0" } },
packages: { a: { dependencies: { "no-deps": "1.0.0" } } },
});
const link = join(dir, "node_modules", "a");
const workspaceNoDeps = join(dir, "packages", "a", "node_modules", "no-deps", "package.json");
expect(isSymlink(link)).toBeTrue();
expect(existsSync(workspaceNoDeps)).toBeTrue();
const junk = plant(dir, "packages/a/node_modules/junk");
// `bun link a` run from another checkout leaves node_modules/a pointing at that checkout, which has a node_modules of its own.
rmSync(link);
const other = linkOutside(dir, "node_modules/a", {
"package.json": JSON.stringify({ name: "a", version: "1.0.0" }),
});
const victim = plant(other, "node_modules/victim");
const otherNoDeps = plant(other, "node_modules/no-deps");
const dryRun = await prune(dir, "--dry-run", "--linker", "hoisted");
expect(out(dryRun.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- junk (node_modules/a/node_modules)
1 package can be removed (checked 4)
bun prune --linker hoisted"
`);
expect(dryRun.exitCode).toBe(0);
// bun.lock records workspace folders relative to the root; prune chdirs there first, so running it from inside a workspace resolves them the same way.
const { stdout, exitCode } = await prune({ dir, cwd: join(dir, "packages", "a") }, "--linker", "hoisted");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- junk (node_modules/a/node_modules)
1 package removed (checked 4)"
`);
expect(exitCode).toBe(0);
expect(existsSync(junk)).toBeFalse();
expect(existsSync(workspaceNoDeps)).toBeTrue();
expect(existsSync(join(victim, "package.json"))).toBeTrue();
expect(existsSync(join(otherNoDeps, "package.json"))).toBeTrue();
expect(isSymlink(link)).toBeTrue();
},
);
test.concurrent.skipIf(isWindows)("a symlinked .bin directory is never cleaned through", async () => {
const dir = await setup({ name: "foo", dependencies: { "no-deps": "1.0.0" } });
const nm = join(dir, "node_modules");
expect(existsSync(join(nm, ".bin"))).toBeFalse();
const outsideBins = linkOutside(dir, "node_modules/.bin");
const dangling = join(outsideBins, "dangling");
symlinkSync("./does-not-exist", dangling);
const junk = plant(dir, "node_modules/junk");
const { stdout, exitCode } = await prune(dir);
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- junk
1 package removed (checked 2)"
`);
expect(exitCode).toBe(0);
expect(existsSync(junk)).toBeFalse();
expect(isSymlink(dangling)).toBeTrue();
expect(isSymlink(join(nm, ".bin"))).toBeTrue();
});
test.concurrent("isolated: extraneous symlinks are removed even when the store is clean", async () => {
const dir = await setupWithLinker("isolated", { name: "foo", dependencies: { "no-deps": "1.0.0" } });
const nm = join(dir, "node_modules");
const outside = linkOutside(dir, "node_modules/ext", { "keep.txt": "keep" });
const scopedOutside = linkOutside(dir, "node_modules/@ext/thing", { "keep.txt": "keep" });
const first = await prune(dir, "--linker", "isolated");
expect(out(first.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- @ext/thing
- ext
2 packages removed (checked 4)"
`);
expect(first.exitCode).toBe(0);
expect(() => lstatSync(join(nm, "ext"))).toThrow();
expect(existsSync(join(nm, "@ext"))).toBeFalse();
expect(existsSync(join(outside, "keep.txt"))).toBeTrue();
expect(existsSync(join(scopedOutside, "keep.txt"))).toBeTrue();
expect(existsSync(join(nm, "no-deps", "package.json"))).toBeTrue();
const second = await prune(dir, "--linker", "isolated");
expect(out(second.stdout)).toEndWith(NOTHING(2, 2));
expect(second.exitCode).toBe(0);
});
test.concurrent(
"hoisted: lockfile shrinks -> removed packages, their nested deps, scope dir and bin links go away",
async () => {
const dir = await setup({
name: "foo",
dependencies: { "one-dep": "1.0.0", "no-deps": "2.0.0", "@scoped/has-bin-entry": "1.0.0" },
});
const nm = join(dir, "node_modules");
expect(existsSync(join(nm, "one-dep", "node_modules", "no-deps"))).toBeTrue();
await write(join(dir, "package.json"), JSON.stringify({ name: "foo", dependencies: { "no-deps": "2.0.0" } }));
await install(dir, "--lockfile-only");
expect(existsSync(join(nm, "one-dep"))).toBeTrue();
expect(existsSync(join(nm, "@scoped", "has-bin-entry"))).toBeTrue();
const { stdout, exitCode } = await prune(dir);
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- @scoped/[email protected]
- [email protected]
2 packages removed (checked 3)"
`);
expect(exitCode).toBe(0);
expect(existsSync(join(nm, "one-dep"))).toBeFalse();
expect(existsSync(join(nm, "@scoped"))).toBeFalse();
expect(await file(join(nm, "no-deps", "package.json")).json()).toMatchObject({ version: "2.0.0" });
expectBinRemoved(nm, "has-bin-entry");
const { out: installOut } = await runBunInstall(installEnv(dir), dir, { savesLockfile: false });
expect(installOut).toContain("no changes");
},
);
test.concurrent("hoisted: removing only a scoped package also removes its bin link", async () => {
const dir = await setup({
name: "foo",
dependencies: { "no-deps": "1.0.0" },
devDependencies: { "@scoped/has-bin-entry": "1.0.0" },
});
const nm = join(dir, "node_modules");
expectBinInstalled(nm, "has-bin-entry");
const { stdout, exitCode } = await prune(dir, "--production");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- @scoped/[email protected]
1 package removed (checked 2)"
`);
expect(exitCode).toBe(0);
expect(existsSync(join(nm, "@scoped"))).toBeFalse();
expectBinRemoved(nm, "has-bin-entry");
});
test.concurrent(
"isolated: lockfile shrinks -> store entries, alias links and the emptied scope dir go away",
async () => {
const dir = await setupWithLinker("isolated", {
name: "foo",
dependencies: { "one-dep": "1.0.0", "no-deps": "2.0.0", "@scoped/has-bin-entry": "1.0.0" },
});
const nm = join(dir, "node_modules");
const store = join(nm, ".bun");
await write(join(dir, "package.json"), JSON.stringify({ name: "foo", dependencies: { "no-deps": "2.0.0" } }));
await install(dir, "--lockfile-only", "--linker", "isolated");
expect(isSymlink(join(nm, "one-dep"))).toBeTrue();
expect(isSymlink(join(nm, "@scoped", "has-bin-entry"))).toBeTrue();
const { stdout, exitCode } = await prune(dir, "--linker", "isolated");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- @scoped/[email protected]
- [email protected]
- [email protected]
3 packages removed (checked 7)"
`);
expect(exitCode).toBe(0);
expect(() => lstatSync(join(nm, "one-dep"))).toThrow();
expect(existsSync(join(nm, "@scoped"))).toBeFalse();
expect(existsSync(join(store, "[email protected]"))).toBeTrue();
expect(await file(join(nm, "no-deps", "package.json")).json()).toMatchObject({ version: "2.0.0" });
expectBinRemoved(nm, "has-bin-entry");
},
);
test.concurrent("hoisted: --production empties a workspace folder that only held nested devDependencies", async () => {
const { packageDir: dir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker: "hoisted" } });
await Promise.all([
write(
packageJson,
JSON.stringify({ name: "root", workspaces: ["packages/*"], dependencies: { "no-deps": "2.0.0" } }),
),
write(
join(dir, "packages", "a", "package.json"),
JSON.stringify({ name: "a", version: "1.0.0", devDependencies: { "no-deps": "1.0.0" } }),
),
]);
await install(dir, "--linker", "hoisted");
const nm = join(dir, "node_modules");
const nested = join(dir, "packages", "a", "node_modules", "no-deps");
expect(await file(join(nested, "package.json")).json()).toMatchObject({ version: "1.0.0" });
const { stdout, exitCode } = await prune(dir, "--production", "--linker", "hoisted");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected] (packages/a/node_modules)
1 package removed (checked 3)"
`);
expect(exitCode).toBe(0);
expect(existsSync(nested)).toBeFalse();
expect(isSymlink(join(nm, "a"))).toBeTrue();
expect(await file(join(nm, "no-deps", "package.json")).json()).toMatchObject({ version: "2.0.0" });
await expectProductionInstallIsNoop(dir);
});
const appLinksTool = {
packages: {
app: {
dependencies: { lib: "workspace:*", "no-deps": "1.0.0" },
devDependencies: { tool: "workspace:*", "a-dep": "1.0.1" },
},
lib: {},
tool: {},
},
};
test.concurrent(
"isolated + workspaces: --production removes a workspace's registry devDependency and its dev-only workspace link, keeps prod links",
async () => {
const dir = await setupWorkspaces("isolated", appLinksTool);
const app = join(dir, "packages", "app");
const appNm = join(app, "node_modules");
expect(existsSync(join(dir, "node_modules", ".bun"))).toBeTrue();
expect(isSymlink(join(appNm, "a-dep"))).toBeTrue();
expect(isSymlink(join(appNm, "tool"))).toBeTrue();
const plain = await prune(dir, "--linker", "isolated");
expect(out(plain.stdout)).toEndWith(NOTHING(6, 3));
expect(plain.exitCode).toBe(0);
const dryRun = await prune(dir, "--production", "--dry-run", "--linker", "isolated");
expect(out(dryRun.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
- [email protected] (packages/app/node_modules)
2 packages can be removed (checked 6)
bun prune --production --linker isolated"
`);
expect(dryRun.exitCode).toBe(0);
expect(isSymlink(join(appNm, "tool"))).toBeTrue();
const { stdout, exitCode } = await prune({ dir, cwd: app }, "--production", "--linker", "isolated");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
- [email protected] (packages/app/node_modules)
2 packages removed (checked 6)"
`);
expect(exitCode).toBe(0);
expect(() => lstatSync(join(appNm, "a-dep"))).toThrow();
expect(() => lstatSync(join(appNm, "tool"))).toThrow();
expect(existsSync(join(appNm, "no-deps", "package.json"))).toBeTrue();
expect(existsSync(join(appNm, "lib", "package.json"))).toBeTrue();
expect(existsSync(join(dir, "packages", "tool", "package.json"))).toBeTrue();
const again = await prune(dir, "--production", "--linker", "isolated");
expect(out(again.stdout)).toEndWith(NOTHING(3, 3));
expect(again.exitCode).toBe(0);
await expectProductionInstallIsNoop(dir);
expect(() => lstatSync(join(appNm, "tool"))).toThrow();
},
);
test.concurrent("isolated: a real directory named like a workspace is never deleted", async () => {
const dir = await setupWorkspaces("isolated", appLinksTool);
const appNm = join(dir, "packages", "app", "node_modules");
rmSync(join(appNm, "tool"));
const planted = plant(dir, "packages/app/node_modules/tool");
const { stdout, exitCode } = await prune(dir, "--production", "--linker", "isolated");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
1 package removed (checked 6)"
`);
expect(exitCode).toBe(0);
expect(existsSync(join(planted, "package.json"))).toBeTrue();
expect(existsSync(join(appNm, "lib", "package.json"))).toBeTrue();
});
test.concurrent(
"isolated: a scoped dev-only workspace link goes away with its emptied scope dir, the prod scoped link stays",
async () => {
const scoped = (app: Record<string, unknown>) => ({
packages: {
app: { dependencies: { "no-deps": "1.0.0" }, ...app },
lib: { name: "@scope/lib" },
tool: { name: "@scope/tool" },
},
});
const [mixedDir, devOnlyDir] = await Promise.all([
setupWorkspaces(
"isolated",
scoped({
dependencies: { "@scope/lib": "workspace:*", "no-deps": "1.0.0" },
devDependencies: { "@scope/tool": "workspace:*" },
}),
),
setupWorkspaces("isolated", scoped({ devDependencies: { "@scope/tool": "workspace:*" } })),
]);
const mixedScope = join(mixedDir, "packages", "app", "node_modules", "@scope");
const devOnlyScope = join(devOnlyDir, "packages", "app", "node_modules", "@scope");
expect(isSymlink(join(mixedScope, "tool"))).toBeTrue();
expect(isSymlink(join(devOnlyScope, "tool"))).toBeTrue();
const mixed = await prune(mixedDir, "--production", "--linker", "isolated");
expect(out(mixed.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- @scope/[email protected] (packages/app/node_modules)
1 package removed (checked 4)"
`);
expect(mixed.exitCode).toBe(0);
expect(() => lstatSync(join(mixedScope, "tool"))).toThrow();
expect(existsSync(join(mixedScope, "lib", "package.json"))).toBeTrue();
expect(existsSync(join(mixedDir, "packages", "tool", "package.json"))).toBeTrue();
await expectProductionInstallIsNoop(mixedDir);
const devOnly = await prune(devOnlyDir, "--production", "--linker", "isolated");
expect(out(devOnly.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- @scope/[email protected] (packages/app/node_modules)
1 package removed (checked 3)"
`);
expect(devOnly.exitCode).toBe(0);
expect(existsSync(devOnlyScope)).toBeFalse();
expect(existsSync(join(devOnlyDir, "packages", "app", "node_modules", "no-deps", "package.json"))).toBeTrue();
expect(existsSync(join(devOnlyDir, "packages", "tool", "package.json"))).toBeTrue();
await expectProductionInstallIsNoop(devOnlyDir);
},
);
test.concurrent(
"isolated: a workspace that is dev-only for one workspace and a prod dependency of another loses only the dev link",
async () => {
const dir = await setupWorkspaces("isolated", {
packages: {
app: { devDependencies: { tool: "workspace:*" } },
b: { dependencies: { tool: "workspace:*", "no-deps": "1.0.0" } },
tool: {},
},
});
const appTool = join(dir, "packages", "app", "node_modules", "tool");
const bTool = join(dir, "packages", "b", "node_modules", "tool");
expect(isSymlink(appTool)).toBeTrue();
expect(isSymlink(bTool)).toBeTrue();
const { stdout, exitCode } = await prune(dir, "--production", "--linker", "isolated");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected] (packages/app/node_modules)
1 package removed (checked 4)"
`);
expect(exitCode).toBe(0);
expect(() => lstatSync(appTool)).toThrow();
expect(await file(join(bTool, "package.json")).json()).toMatchObject({ name: "tool" });
await expectProductionInstallIsNoop(dir);
expect(() => lstatSync(appTool)).toThrow();
expect(isSymlink(bTool)).toBeTrue();
},
);
test.concurrent.each(linkers)(
"%s: a workspace's own dependencies survive --production even when the root lists the workspace under devDependencies",
async linker => {
const dir = await setupWorkspaces(linker, {
root: { devDependencies: { tool: "workspace:*" } },
packages: {
app: { devDependencies: { tool: "workspace:*" } },
tool: { dependencies: { "no-deps": "1.0.0" } },
},
});
const appTool = join(dir, "packages", "app", "node_modules", "tool");
const rootTool = join(dir, "node_modules", "tool");
expect(isSymlink(rootTool)).toBeTrue();
const toolNoDeps =
linker === "hoisted"
? join(dir, "node_modules", "no-deps", "package.json")
: join(dir, "packages", "tool", "node_modules", "no-deps", "package.json");
expect(existsSync(toolNoDeps)).toBeTrue();
const first = await prune(dir, "--production", "--linker", linker);
if (linker === "hoisted") {
expect(out(first.stdout)).toEndWith(NOTHING(3, 1));
expect(isSymlink(rootTool)).toBeTrue();
} else {
expect(out(first.stdout)).toEndWith(`- [email protected] (packages/app/node_modules)\n${REMOVED(1, 4)}`);
expect(() => lstatSync(appTool)).toThrow();
}
expect(first.exitCode).toBe(0);
expect(existsSync(toolNoDeps)).toBeTrue();
expect(existsSync(join(dir, "packages", "tool", "package.json"))).toBeTrue();
const second = await prune(dir, "--production", "--linker", linker);
expect(out(second.stdout)).toEndWith(linker === "hoisted" ? NOTHING(3, 1) : NOTHING(3, 4));
expect(second.exitCode).toBe(0);
await expectProductionInstallIsNoop(dir);
expect(existsSync(toolNoDeps)).toBeTrue();
},
);
test.concurrent.each(linkers)("%s: refuses when package.json changed since bun.lock was written", async linker => {
const dir = await setupWithLinker(linker, { name: "foo", dependencies: { "no-deps": "1.0.0", "a-dep": "1.0.1" } });
const junk = plant(dir, "node_modules/junk");
const aDep = join(dir, "node_modules", "a-dep");
await write(join(dir, "package.json"), JSON.stringify({ name: "foo", dependencies: { "no-deps": "1.0.0" } }));
const lockBefore = await lock(dir);
expectRefused(await prune(dir, "--linker", linker));
expectRefused(await prune(dir, "--dry-run", "--linker", linker));
expect(existsSync(junk)).toBeTrue();
expect(existsSync(join(aDep, "package.json"))).toBeTrue();
const silent = await prune(dir, "--silent", "--linker", linker);
expect(silent.stdout).toBe("");
expect(silent.stderr).toBe("");
expect(silent.exitCode).toBe(1);
expect(existsSync(junk)).toBeTrue();
expect(await lock(dir)).toBe(lockBefore);
await install(dir, "--lockfile-only", "--linker", linker);
const { stdout, stderr, exitCode } = await prune(dir, "--linker", linker);
// The rows read the same under both linkers; only the store changes what was checked.
expect(lines(stdout)).toStrictEqual([
BANNER,
"",
"- [email protected]",
"- junk",
REMOVED(2, linker === "hoisted" ? 3 : 5),
]);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
expect(existsSync(junk)).toBeFalse();
expect(() => lstatSync(aDep)).toThrow();
});
test.concurrent.each([
["a dependency is added", { dependencies: { "no-deps": "1.0.0" }, devDependencies: { "a-dep": "1.0.1" } }],
["a range changes but still matches the installed version", { dependencies: { "no-deps": "^1.0.0" } }],
["an override is added", { dependencies: { "no-deps": "1.0.0" }, overrides: { "no-deps": "1.0.0" } }],
] as const)("refuses when %s", async (_, edited) => {
const dir = await setup({ name: "foo", dependencies: { "no-deps": "1.0.0" } });
const junk = plant(dir, "node_modules/junk");
const lockBefore = await lock(dir);
await write(join(dir, "package.json"), JSON.stringify({ name: "foo", ...edited }));
expectRefused(await prune(dir));
expect(existsSync(junk)).toBeTrue();
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("refuses when a catalog entry changed", async () => {
const catalogRoot = (version: string) => ({
root: { workspaces: { packages: ["packages/*"], catalog: { "no-deps": version } } },
packages: { a: { dependencies: { "no-deps": "catalog:" } } },
});
const dir = await setupWorkspaces("hoisted", catalogRoot("1.0.0"));
const junk = plant(dir, "node_modules/junk");
const lockBefore = await lock(dir);
await writeWorkspaces(dir, join(dir, "package.json"), catalogRoot("1.0.1"));
expectRefused(await prune(dir, "--linker", "hoisted"));
expect(existsSync(junk)).toBeTrue();
expect(await lock(dir)).toBe(lockBefore);
});
test.concurrent("refuses when a workspace's package.json changed, from any cwd and under --filter", async () => {
const dir = await setupWorkspaces("hoisted", {
root: { dependencies: { "no-deps": "2.0.0" } },
packages: {
a: { dependencies: { "no-deps": "1.0.0" } },
b: { dependencies: { "a-dep": "1.0.1" } },
},
});
const junk = plant(dir, "packages/a/node_modules/junk");
const aDep = join(dir, "node_modules", "a-dep");
expect(existsSync(aDep)).toBeTrue();
await write(join(dir, "packages", "b", "package.json"), JSON.stringify({ name: "b", version: "1.0.0" }));
expectRefused(await prune(dir, "--linker", "hoisted"));
expectRefused(await prune({ dir, cwd: join(dir, "packages", "a") }, "--linker", "hoisted"));
expectRefused(await prune(dir, "--filter", "a", "--linker", "hoisted"));
expect(existsSync(junk)).toBeTrue();
expect(existsSync(aDep)).toBeTrue();
});
test.concurrent.each(linkers)("%s: a workspace lifecycle script is not out of sync", async linker => {
const dir = await setupWorkspaces(linker, {
packages: { a: { dependencies: { "no-deps": "1.0.0" }, scripts: { postinstall: "echo ok" } } },
});
const junk = plant(dir, "node_modules/junk");
const { stdout, stderr, exitCode } = await prune(dir, "--linker", linker);
expect(stderr).not.toContain(OUT_OF_SYNC);
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- junk
1 package removed (checked 3)"
`);
expect(exitCode).toBe(0);
expect(existsSync(junk)).toBeFalse();
});
test.concurrent("trustedDependencies stripped from bun.lock is not out of sync", async () => {
const dir = await setup({ name: "foo", dependencies: { "no-deps": "1.0.0" }, trustedDependencies: ["no-deps"] });
const before = await lock(dir);
expect(before).toContain('"trustedDependencies"');
const stripped = before.replace(/\n "trustedDependencies": \[\n(?: [^\n]*\n)* \],/, "");
expect(stripped).not.toContain('"trustedDependencies"');
await write(join(dir, "bun.lock"), stripped);
const junk = plant(dir, "node_modules/junk");
const { stdout, stderr, exitCode } = await prune(dir);
expect(stderr).not.toContain(OUT_OF_SYNC);
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- junk
1 package removed (checked 2)"
`);
expect(exitCode).toBe(0);
expect(existsSync(junk)).toBeFalse();
});
const prunedCheckout = (app: Record<string, unknown>) => ({
packages: {
app: { dependencies: { "no-deps": "1.0.0", ...app } },
other: { dependencies: { "left-pad": "1.0.0" } },
},
});
test.concurrent("prunes a checkout whose bun.lock lists a workspace that is no longer on disk", async () => {
const dir = await setupWorkspaces("hoisted", prunedCheckout({}));
const nm = join(dir, "node_modules");
rmSync(join(dir, "packages", "other"), { recursive: true });
expect(isSymlink(join(nm, "other"))).toBeTrue();
expect(existsSync(join(nm, "left-pad", "package.json"))).toBeTrue();
expect(existsSync(join(nm, "no-deps", "package.json"))).toBeTrue();
const junk = plant(dir, "node_modules/junk");
const { lines: merged, exitCode } = await pruneMerged(dir, "--linker", "hoisted");
expect(merged).toStrictEqual([BANNER, "", PRUNED_NOTE, "- junk", "- [email protected]", "- other", REMOVED(3, 5)]);
expect(exitCode).toBe(0);
expect(existsSync(junk)).toBeFalse();
expect(existsSync(join(nm, "left-pad"))).toBeFalse();
expect(() => lstatSync(join(nm, "other"))).toThrow();
expect(existsSync(join(nm, "no-deps", "package.json"))).toBeTrue();
expect(isSymlink(join(nm, "app"))).toBeTrue();
await install(dir, "--frozen-lockfile", "--linker", "hoisted");
expect(existsSync(join(nm, "left-pad"))).toBeFalse();
expect(() => lstatSync(join(nm, "other"))).toThrow();
const second = await prune(dir, "--linker", "hoisted");
expect(out(second.stdout)).toEndWith(NOTHING(2, 1));
expect(second.exitCode).toBe(0);
});
test.concurrent("isolated: a workspace missing from disk no longer keeps its store entries", async () => {
const dir = await setupWorkspaces("isolated", prunedCheckout({}));
const store = join(dir, "node_modules", ".bun");
const appNoDeps = join(dir, "packages", "app", "node_modules", "no-deps", "package.json");
rmSync(join(dir, "packages", "other"), { recursive: true });
expect(existsSync(join(store, "[email protected]"))).toBeTrue();
expect(existsSync(join(store, "[email protected]"))).toBeTrue();
const junk = plant(dir, "node_modules/junk");
const { stdout, stderr, exitCode } = await prune(dir, "--linker", "isolated");
expect(stderr).toContain(PRUNED_NOTE);
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- junk
- [email protected]
2 packages removed (checked 4)"
`);
expect(exitCode).toBe(0);
expect(existsSync(junk)).toBeFalse();
expect(existsSync(join(store, "[email protected]"))).toBeFalse();
expect(existsSync(join(store, "[email protected]"))).toBeTrue();
expect(existsSync(appNoDeps)).toBeTrue();
await install(dir, "--frozen-lockfile", "--linker", "isolated");
expect(existsSync(join(store, "[email protected]"))).toBeFalse();
expect(existsSync(appNoDeps)).toBeTrue();
const second = await prune(dir, "--linker", "isolated");
expect(out(second.stdout)).toEndWith(NOTHING(2, 3));
expect(second.exitCode).toBe(0);
});
test.concurrent("hoisted: --filter on a pruned checkout does not protect the missing workspace", async () => {
const dir = await setupWorkspaces("hoisted", prunedCheckout({}));
const nm = join(dir, "node_modules");
rmSync(join(dir, "packages", "other"), { recursive: true });
const { stdout, stderr, exitCode } = await prune(dir, "--filter", "app", "--linker", "hoisted");
expect(stderr).toContain(PRUNED_NOTE);
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
- other
2 packages removed (checked 4)"
`);
expect(exitCode).toBe(0);
expect(existsSync(join(nm, "left-pad"))).toBeFalse();
expect(() => lstatSync(join(nm, "other"))).toThrow();
expect(existsSync(join(nm, "no-deps", "package.json"))).toBeTrue();
expect(isSymlink(join(nm, "app"))).toBeTrue();
});
test.concurrent("a survivor depending on a missing workspace makes prune fail like install does", async () => {
const dir = await setupWorkspaces("hoisted", prunedCheckout({ other: "workspace:*" }));
rmSync(join(dir, "packages", "other"), { recursive: true });
const junk = plant(dir, "node_modules/junk");
const { stdout, stderr, exitCode } = await prune(dir, "--linker", "hoisted");
expect(stderr).toContain(
'workspace "app" depends on workspace "other" (packages/other), which is listed in bun.lock but not on disk',
);
expect(out(stdout)).not.toMatch(/^- /m);
expect(exitCode).toBe(1);
expect(existsSync(junk)).toBeTrue();
});
test.concurrent(
"pin: hoisted --production keeps the workspace links a production install creates and removes a dev-only alias link",
async () => {
const dir = await setupWorkspaces("hoisted", {
packages: {
app: { devDependencies: { shared: "workspace:*", "shared-alias": "workspace:shared@*" } },
shared: {},
},
});
const nm = join(dir, "node_modules");
expect(isSymlink(join(nm, "app"))).toBeTrue();
expect(isSymlink(join(nm, "shared"))).toBeTrue();
expect(isSymlink(join(nm, "shared-alias"))).toBeTrue();
const { stdout, exitCode } = await prune(dir, "--production", "--linker", "hoisted");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
1 package removed (checked 3)"
`);
expect(exitCode).toBe(0);
expect(isSymlink(join(nm, "app"))).toBeTrue();
expect(isSymlink(join(nm, "shared"))).toBeTrue();
expect(() => lstatSync(join(nm, "shared-alias"))).toThrow();
await expectProductionInstallIsNoop(dir);
const second = await prune(dir, "--production", "--linker", "hoisted");
expect(out(second.stdout)).toEndWith(NOTHING(2, 1));
expect(second.exitCode).toBe(0);
},
);
test.concurrent(
"hoisted: --filter prunes only the selected workspaces, the root folder keeps what other workspaces use",
async () => {
const dir = await setupWorkspaces("hoisted", {
root: { dependencies: { "no-deps": "2.0.0" }, devDependencies: { "left-pad": "1.0.0" } },
packages: {
a: { dependencies: { "no-deps": "1.0.0" }, devDependencies: { "a-dep": "1.0.1" } },
b: { dependencies: { "no-deps": "1.0.0" }, devDependencies: { "one-fixed-dep": "1.0.0" } },
},
});
const nm = join(dir, "node_modules");
const aJunk = plant(dir, "packages/a/node_modules/junk");
const bJunk = plant(dir, "packages/b/node_modules/junk");
const stillInstalled = () => {
expect(isSymlink(join(nm, "a"))).toBeTrue();
expect(isSymlink(join(nm, "b"))).toBeTrue();
expect(existsSync(join(dir, "packages", "a", "node_modules", "no-deps", "package.json"))).toBeTrue();
expect(existsSync(join(dir, "packages", "b", "node_modules", "no-deps", "package.json"))).toBeTrue();
};
const onlyA = await prune(dir, "--production", "--filter", "a", "--linker", "hoisted");
expect(out(onlyA.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
- junk (node_modules/a/node_modules)
2 packages removed (checked 8)"
`);
expect(onlyA.exitCode).toBe(0);
expect(existsSync(aJunk)).toBeFalse();
expect(existsSync(bJunk)).toBeTrue();
expect(existsSync(join(nm, "left-pad"))).toBeTrue();
expect(existsSync(join(nm, "one-fixed-dep"))).toBeTrue();
expect(await file(join(nm, "no-deps", "package.json")).json()).toMatchObject({ version: "2.0.0" });
stillInstalled();
const onlyRoot = await prune(dir, "--production", "--filter", "root", "--linker", "hoisted");
expect(out(onlyRoot.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
1 package removed (checked 5)"
`);
expect(onlyRoot.exitCode).toBe(0);
expect(existsSync(bJunk)).toBeTrue();
expect(existsSync(join(nm, "one-fixed-dep"))).toBeTrue();
const everything = await prune(dir, "--production", "--linker", "hoisted");
expect(out(everything.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- junk (node_modules/b/node_modules)
- [email protected]
2 packages removed (checked 7)"
`);
expect(everything.exitCode).toBe(0);
expect(existsSync(bJunk)).toBeFalse();
stillInstalled();
await expectProductionInstallIsNoop(dir);
},
);
test.concurrent(
"isolated: --filter keeps store entries other workspaces link to and drops the selected workspace's exclusive ones",
async () => {
const dir = await setupWorkspaces("isolated", {
packages: {
a: { dependencies: { "no-deps": "1.0.0" }, devDependencies: { "a-dep": "1.0.1", "one-fixed-dep": "1.0.0" } },
b: { devDependencies: { "a-dep": "1.0.1", "left-pad": "1.0.0" } },
},
});
const store = join(dir, "node_modules", ".bun");
const aNm = join(dir, "packages", "a", "node_modules");
const bNm = join(dir, "packages", "b", "node_modules");
const onlyA = await prune(dir, "--production", "--filter", "a", "--linker", "isolated");
expect(out(onlyA.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected] (packages/a/node_modules)
- [email protected]
2 packages removed (checked 7)"
`);
expect(onlyA.exitCode).toBe(0);
expect(existsSync(join(store, "[email protected]"))).toBeTrue();
expect(existsSync(join(store, "[email protected]"))).toBeTrue();
expect(existsSync(join(store, "[email protected]"))).toBeTrue();
expect(existsSync(join(bNm, "a-dep", "package.json"))).toBeTrue();
expect(existsSync(join(bNm, "left-pad", "package.json"))).toBeTrue();
expect(() => lstatSync(join(aNm, "a-dep"))).toThrow();
expect(() => lstatSync(join(aNm, "one-fixed-dep"))).toThrow();
expect(existsSync(join(aNm, "no-deps", "package.json"))).toBeTrue();
const everything = await prune(dir, "--production", "--linker", "isolated");
expect(out(everything.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
- [email protected]
2 packages removed (checked 6)"
`);
expect(everything.exitCode).toBe(0);
expect(() => lstatSync(join(bNm, "a-dep"))).toThrow();
expect(() => lstatSync(join(bNm, "left-pad"))).toThrow();
expect(existsSync(join(aNm, "no-deps", "package.json"))).toBeTrue();
await expectProductionInstallIsNoop(dir);
},
);
test.concurrent(
"isolated: --production --filter unlinks one workspace's dev deps at a time, the store entry waits for an unfiltered run",
async () => {
const pkg = { dependencies: { "no-deps": "1.0.0" }, devDependencies: { "a-dep": "1.0.1" } };
const dir = await setupWorkspaces("isolated", { packages: { selected: pkg, unselected: pkg } });
const storeEntry = join(dir, "node_modules", ".bun", "[email protected]");
const selectedADep = join(dir, "packages", "selected", "node_modules", "a-dep");
const unselectedADep = join(dir, "packages", "unselected", "node_modules", "a-dep");
const first = await prune(dir, "--production", "--filter", "selected", "--linker", "isolated");
expect(out(first.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected] (packages/selected/node_modules)
1 package removed (checked 4)"
`);
expect(first.exitCode).toBe(0);
expect(() => lstatSync(selectedADep)).toThrow();
expect(existsSync(join(unselectedADep, "package.json"))).toBeTrue();
expect(existsSync(storeEntry)).toBeTrue();
const second = await prune(dir, "--production", "--filter", "unselected", "--linker", "isolated");
expect(out(second.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected] (packages/unselected/node_modules)
1 package removed (checked 4)"
`);
expect(second.exitCode).toBe(0);
expect(() => lstatSync(unselectedADep)).toThrow();
expect(existsSync(storeEntry)).toBeTrue();
const everything = await prune(dir, "--production", "--linker", "isolated");
expect(out(everything.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
1 package removed (checked 4)"
`);
expect(everything.exitCode).toBe(0);
expect(existsSync(storeEntry)).toBeFalse();
for (const ws of ["selected", "unselected"]) {
expect(existsSync(join(dir, "packages", ws, "node_modules", "no-deps", "package.json"))).toBeTrue();
}
await expectProductionInstallIsNoop(dir);
},
);
test.concurrent.each(linkers)(
"%s: --filter limits extraneous entries to the selected workspaces' own folders; name, path and glob selectors",
async linker => {
const dir = await setupWorkspaces(linker, {
root: { dependencies: { "no-deps": "2.0.0" } },
packages: {
app: { dependencies: { "no-deps": "1.0.0" } },
lib: { dependencies: { "no-deps": "1.0.0" } },
},
});
const rootJunk = plant(dir, "node_modules/root-junk");
const appJunk = plant(dir, "packages/app/node_modules/app-junk");
const libJunk = plant(dir, "packages/lib/node_modules/lib-junk");
const storeJunk = linker === "isolated" ? plant(dir, "node_modules/.bun/[email protected]/node_modules/junk") : null;
const shown = (ws: string, name: string) =>
linker === "hoisted" ? `- ${name} (node_modules/${ws}/node_modules)` : `- ${name} (packages/${ws}/node_modules)`;
// Rows are listed by package name, wherever the entry lives.
const removed = (rows: string[], checked: number) => [BANNER, "", ...rows, REMOVED(rows.length, checked)];
// The shared area is swept whole-repo under --filter: the isolated store, and under hoisted the root folder itself.
const appRows = [shown("app", "app-junk"), linker === "hoisted" ? "- root-junk" : "- [email protected]"];
// hoisted: root folder (no-deps, app, lib, root-junk) + app folder; isolated: store (2 no-deps, junk) + app folder.
const sharedAndApp = linker === "hoisted" ? 6 : 5;
const dryRun = await prune(dir, "--filter", "app", "--dry-run", "--linker", linker);
expect(lines(dryRun.stdout)).toStrictEqual([
BANNER,
"",
...appRows,
CAN_BE_REMOVED(2, sharedAndApp),
APPLY_HINT("--filter", "app", "--linker", linker),
]);
expect(dryRun.stderr).toBe("");
expect(dryRun.exitCode).toBe(0);
expect(existsSync(appJunk)).toBeTrue();
expect(existsSync(rootJunk)).toBeTrue();
const byPath = await prune(dir, "--filter", "./packages/app", "--linker", linker);
expect(lines(byPath.stdout)).toStrictEqual(removed(appRows, sharedAndApp));
expect(byPath.exitCode).toBe(0);
expect(existsSync(appJunk)).toBeFalse();
expect(existsSync(libJunk)).toBeTrue();
expect(existsSync(rootJunk)).toBe(linker === "isolated");
if (storeJunk) {
expect(existsSync(storeJunk)).toBeFalse();
}
const byGlob = await prune({ dir, cwd: join(dir, "packages", "app") }, "--filter", "li*", "--linker", linker);
expect(lines(byGlob.stdout)).toStrictEqual(removed([shown("lib", "lib-junk")], linker === "hoisted" ? 5 : 4));
expect(byGlob.exitCode).toBe(0);
expect(existsSync(libJunk)).toBeFalse();
if (linker === "isolated") {
const root = await prune(dir, "--filter", "./", "--linker", linker);
expect(lines(root.stdout)).toStrictEqual(removed(["- root-junk"], 4));
expect(root.exitCode).toBe(0);
}
expect(existsSync(rootJunk)).toBeFalse();
for (const ws of ["app", "lib"]) {
expect(existsSync(join(dir, "packages", ws, "node_modules", "no-deps", "package.json"))).toBeTrue();
}
},
);
test.concurrent("hoisted: --filter with no match is an error; path filters resolve against the cwd", async () => {
const dir = await setupWorkspaces("hoisted", {
root: { dependencies: { "no-deps": "2.0.0" } },
packages: { a: { dependencies: { "no-deps": "1.0.0" } } },
});
const junk = plant(dir, "packages/a/node_modules/junk");
const listing = (...flags: string[]) => [
BANNER,
"",
"- junk (node_modules/a/node_modules)",
CAN_BE_REMOVED(1, 4),
APPLY_HINT(...flags, "--linker", "hoisted"),
];
const noMatch = await prune(dir, "--filter", "nope", "--linker", "hoisted");
expect(normalizeBunSnapshot(noMatch.stderr)).toBe('error: No workspace packages matched the filter "nope"');
expect(out(noMatch.stdout)).toBe(BANNER);
expect(noMatch.exitCode).toBe(1);
const noneMatch = await prune(dir, "--filter", "nope", "--filter", "nada", "--linker", "hoisted");
expect(normalizeBunSnapshot(noneMatch.stderr)).toBe(
'error: No workspace packages matched the filters "nope", "nada"',
);
expect(out(noneMatch.stdout)).toBe(BANNER);
expect(noneMatch.exitCode).toBe(1);
const silentNoMatch = await prune(dir, "--filter", "nope", "--silent", "--linker", "hoisted");
expect(silentNoMatch.stdout).toBe("");
expect(silentNoMatch.stderr).toBe("");
expect(silentNoMatch.exitCode).toBe(1);
expect(existsSync(junk)).toBeTrue();
// A typo next to a real name is not fatal, but it is reported before the verdict.
const someMatch = await pruneMerged(dir, "--filter", "a", "--filter", "nope", "--dry-run", "--linker", "hoisted");
expect(someMatch.lines).toStrictEqual([
BANNER,
"",
'warn: No workspace packages matched the filter "nope"',
"- junk (node_modules/a/node_modules)",
CAN_BE_REMOVED(1, 4),
APPLY_HINT("--filter", "a", "--filter", "nope", "--linker", "hoisted"),
]);
expect(someMatch.exitCode).toBe(0);
expect(existsSync(junk)).toBeTrue();
const fromRoot = await prune(dir, "--filter", "./packages/a", "--dry-run", "--linker", "hoisted");
expect(fromRoot.stderr).toBe("");
expect(lines(fromRoot.stdout)).toStrictEqual(listing("--filter", "./packages/a"));
expect(fromRoot.exitCode).toBe(0);
expect(existsSync(junk)).toBeTrue();
const fromInside = await prune(
{ dir, cwd: join(dir, "packages", "a") },
"--filter",
".",
"--dry-run",
"--linker",
"hoisted",
);
expect(fromInside.stderr).toBe("");
expect(lines(fromInside.stdout)).toStrictEqual(listing("--filter", "."));
expect(fromInside.exitCode).toBe(0);
expect(existsSync(junk)).toBeTrue();
const { stdout, stderr, exitCode } = await prune(dir, "--filter", "a", "--linker", "hoisted");
expect(stderr).toBe("");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- junk (node_modules/a/node_modules)
1 package removed (checked 4)"
`);
expect(exitCode).toBe(0);
expect(existsSync(junk)).toBeFalse();
});
test.concurrent.each(linkers)(
"%s: --filter warns about the unmatched pattern and still applies the rest",
async linker => {
const dir = await setupWorkspaces(linker, {
packages: { app: { dependencies: { "no-deps": "1.0.0" } }, lib: {} },
});
const junk = plant(dir, "packages/app/node_modules/junk");
const { stdout, stderr, exitCode } = await prune(dir, "--filter", "app", "--filter", "nope", "--linker", linker);
expect(normalizeBunSnapshot(stderr)).toBe('warn: No workspace packages matched the filter "nope"');
// bun.lock installs nothing there, so the folder is shown by its own path, not through the node_modules/app link.
expect(lines(stdout)).toStrictEqual([
BANNER,
"",
"- junk (packages/app/node_modules)",
REMOVED(1, linker === "hoisted" ? 4 : 3),
]);
expect(exitCode).toBe(0);
expect(existsSync(junk)).toBeFalse();
const silent = await prune(dir, "--filter", "app", "--filter", "nope", "--silent", "--linker", linker);
expect(silent.stdout).toBe("");
expect(silent.stderr).toBe("");
expect(silent.exitCode).toBe(0);
},
);
test.concurrent.each(["hoisted", "isolated"] as Linker[])(
"%s: optionalDependencies survive --production and go away with --omit=optional",
async linker => {
const pkg = {
name: "foo",
dependencies: { "no-deps": "1.0.0" },
optionalDependencies: { "a-dep": "1.0.1" },
devDependencies: { "one-fixed-dep": "1.0.0" },
};
const [prodDir, omitDir] = await Promise.all([setupWithLinker(linker, pkg), setupWithLinker(linker, pkg)]);
const removed = (row: string) => [BANNER, "", row, REMOVED(1, linker === "hoisted" ? 3 : 6)];
const production = await prune(prodDir, "--production", "--linker", linker);
expect(lines(production.stdout)).toStrictEqual(removed("- [email protected]"));
expect(production.exitCode).toBe(0);
expect(existsSync(join(prodDir, "node_modules", "a-dep", "package.json"))).toBeTrue();
expect(existsSync(join(prodDir, "node_modules", "no-deps", "package.json"))).toBeTrue();
const omit = await prune(omitDir, "--omit=optional", "--linker", linker);
expect(lines(omit.stdout)).toStrictEqual(removed("- [email protected]"));
expect(omit.exitCode).toBe(0);
expect(() => lstatSync(join(omitDir, "node_modules", "a-dep"))).toThrow();
expect(existsSync(join(omitDir, "node_modules", "no-deps", "package.json"))).toBeTrue();
expect(existsSync(join(omitDir, "node_modules", "one-fixed-dep", "package.json"))).toBeTrue();
},
);
test.concurrent.each([["--os=aix"], ["--cpu=s390x"]])(
"%s removes packages that are disabled for that platform, plain prune keeps them",
async (flag: string) => {
const dir = await setup({
name: "foo",
dependencies: { "no-deps": "1.0.0", "test-postinstall-skip-native": "1.0.0" },
});
const nm = join(dir, "node_modules");
const native = join(nm, "test-postinstall-skip-native");
expect(existsSync(native)).toBeTrue();
const host = await prune(dir);
expect(out(host.stdout)).toEndWith(NOTHING(2, 1));
expect(host.exitCode).toBe(0);
expect(existsSync(native)).toBeTrue();
const other = await prune(dir, flag);
expect(out(other.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
1 package removed (checked 2)"
`);
expect(other.exitCode).toBe(0);
expect(existsSync(native)).toBeFalse();
expect(existsSync(join(nm, "no-deps"))).toBeTrue();
expect(await install(dir, flag)).toContain("no changes");
},
);
test.concurrent.each(["hoisted", "isolated"] as Linker[])(
"%s: an npm: alias is kept under its alias name",
async linker => {
const dir = await setupWithLinker(linker, {
name: "foo",
dependencies: { "my-alias": "npm:[email protected]", "one-dep": "1.0.0" },
});
const nm = join(dir, "node_modules");
expect(await file(join(nm, "my-alias", "package.json")).json()).toMatchObject({
name: "no-deps",
version: "1.0.0",
});
const junk = plant(dir, "node_modules/junk");
const { stdout, exitCode } = await prune(dir, "--linker", linker);
expect(out(stdout)).toBe(
`bun prune <version> (<revision>)\n\n- junk\n1 package removed (checked ${linker === "hoisted" ? 4 : 6})`,
);
expect(exitCode).toBe(0);
expect(existsSync(junk)).toBeFalse();
expect(await file(join(nm, "my-alias", "package.json")).json()).toMatchObject({
name: "no-deps",
version: "1.0.0",
});
if (linker === "isolated") {
expect(existsSync(join(nm, ".bun", "[email protected]"))).toBeTrue();
expect(existsSync(join(nm, ".bun", "[email protected]"))).toBeTrue();
}
},
);
test.concurrent("isolated + publicHoistPattern: hoisted links follow their store entries", async () => {
const dir = await setupWithLinker(
"isolated",
{ name: "foo", dependencies: { "no-deps": "1.0.0" }, devDependencies: { "one-dep": "1.0.0" } },
{ publicHoistPattern: ["no-deps"], hoistPattern: ["one-dep"] },
);
const nm = join(dir, "node_modules");
const store = join(nm, ".bun");
expect(existsSync(join(nm, "no-deps", "package.json"))).toBeTrue();
expect(isSymlink(join(store, "node_modules", "one-dep"))).toBeTrue();
const clean = await prune(dir, "--linker", "isolated");
expect(out(clean.stdout)).toEndWith(NOTHING(5, 2));
expect(clean.exitCode).toBe(0);
const production = await prune(dir, "--production", "--linker", "isolated");
expect(out(production.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
- [email protected]
2 packages removed (checked 5)"
`);
expect(production.exitCode).toBe(0);
expect(() => lstatSync(join(store, "node_modules", "one-dep"))).toThrow();
expect(() => lstatSync(join(nm, "one-dep"))).toThrow();
expect(await file(join(nm, "no-deps", "package.json")).json()).toMatchObject({ version: "1.0.0" });
});
test.concurrent.skipIf(isWindows || process.getuid?.() === 0)(
"a failed deletion is reported, the rest is removed, exit code 1",
async () => {
const dir = await setup({ name: "foo", dependencies: { "no-deps": "1.0.0" } });
const nm = join(dir, "node_modules");
const junkA = plant(dir, "node_modules/junk-a");
const inner = plant(dir, "node_modules/junk-b/inner");
const junkB = join(nm, "junk-b");
chmodSync(junkB, 0o555);
const failure = /^error: failed to remove node_modules\/junk-b: E[A-Z]+ \(.+\)$/;
try {
// The failure is explained before the verdict, and the verdict counts it.
const { lines: merged, exitCode } = await pruneMerged(dir);
expect(merged).toStrictEqual([
BANNER,
"",
"- junk-a",
expect.stringMatching(failure),
"1 package removed, 1 failed (checked 3)",
]);
expect(exitCode).toBe(1);
expect(existsSync(junkA)).toBeFalse();
expect(existsSync(inner)).toBeTrue();
// --silent still reports what could not be removed; the exit code alone would hide which entry it was.
const silent = await prune(dir, "--silent");
expect(silent.stdout).toBe("");
expect(normalizeBunSnapshot(silent.stderr)).toMatch(failure);
expect(silent.exitCode).toBe(1);
expect(existsSync(inner)).toBeTrue();
} finally {
chmodSync(junkB, 0o755);
}
const { stdout, exitCode } = await prune(dir);
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- junk-b
1 package removed (checked 2)"
`);
expect(exitCode).toBe(0);
expect(existsSync(junkB)).toBeFalse();
},
);
// pnpm#4770 / #5092 / #10275: `prepare` must not run after --production removed the tools it needs.
test.concurrent("never runs the project's lifecycle scripts", async () => {
const dir = await setup({
name: "foo",
dependencies: { "no-deps": "1.0.0" },
devDependencies: { "a-dep": "1.0.1" },
scripts: {
preinstall: "echo PRE >> ran.txt",
postinstall: "echo POST >> ran.txt",
prepare: "echo PREPARE >> ran.txt",
},
});
const ran = join(dir, "ran.txt");
expect(existsSync(ran)).toBeTrue();
rmSync(ran);
const junk = plant(dir, "node_modules/junk");
const plain = await prune(dir);
expect(out(plain.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- junk
1 package removed (checked 3)"
`);
expect(plain.exitCode).toBe(0);
expect(existsSync(junk)).toBeFalse();
expect(existsSync(ran)).toBeFalse();
const production = await prune(dir, "--production");
expect(out(production.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
1 package removed (checked 2)"
`);
expect(production.exitCode).toBe(0);
expect(existsSync(ran)).toBeFalse();
});
test.concurrent.each(["hoisted", "isolated"] as Linker[])(
"%s: --omit=peer removes what bun install --omit=peer would not install",
async linker => {
const pkg = { name: "foo", dependencies: { "peer-deps-fixed": "1.0.0" } };
const [dir, plainDir] = await Promise.all([setupWithLinker(linker, pkg), setupWithLinker(linker, pkg)]);
const nm = join(dir, "node_modules");
const installedNoDeps = () =>
linker === "hoisted"
? existsSync(join(nm, "no-deps"))
: readdirSync(join(nm, ".bun")).some(name => name.startsWith("no-deps@"));
expect(existsSync(join(nm, "peer-deps-fixed", "package.json"))).toBeTrue();
expect(installedNoDeps()).toBeTrue();
const omit = await prune(dir, "--omit=peer", "--linker", linker);
expect(lines(omit.stdout)).toStrictEqual([BANNER, "", "- [email protected]", REMOVED(1, linker === "hoisted" ? 2 : 3)]);
expect(omit.exitCode).toBe(0);
expect(installedNoDeps()).toBeFalse();
expect(() => lstatSync(join(nm, "no-deps"))).toThrow();
expect(existsSync(join(nm, "peer-deps-fixed", "package.json"))).toBeTrue();
if (linker === "hoisted") {
expect(await install(dir, "--omit=peer")).toContain("no changes");
}
const plain = await prune(plainDir, "--linker", linker);
expect(out(plain.stdout)).toEndWith(linker === "hoisted" ? NOTHING(2, 1) : NOTHING(3, 2));
expect(plain.exitCode).toBe(0);
},
);
// The peer edge survives --production, so `bun install --production` still installs the peer into the store; only the root's dev link goes.
test.concurrent.each(["peer-deps-fixed", "optional-peer-deps"])(
"isolated: --production keeps %s's peer-hash entry and the peer a devDependency pulled in, removes the dev link",
async consumer => {
const dir = await setupWithLinker("isolated", {
name: "foo",
dependencies: { [consumer]: "1.0.0" },
devDependencies: { "no-deps": "1.0.0" },
});
const nm = join(dir, "node_modules");
const store = join(nm, ".bun");
const [entry, ...rest] = storeEntries(dir).filter(name => name.startsWith(`${consumer}@`));
expect(rest).toStrictEqual([]);
expect(entry).toMatch(new RegExp(`^${consumer}@1\\.0\\.0\\+[0-9a-f]{16}$`));
expect(storeEntries(dir)).toStrictEqual(["[email protected]", entry]);
expect(isSymlink(join(nm, "no-deps"))).toBeTrue();
const peerLink = join(store, entry, "node_modules", "no-deps", "package.json");
expect(existsSync(peerLink)).toBeTrue();
const { stdout, exitCode } = await prune(dir, "--production", "--linker", "isolated");
expect(out(stdout).split("\n")).toStrictEqual([
"bun prune <version> (<revision>)",
"",
"- [email protected]",
"1 package removed (checked 4)",
]);
expect(exitCode).toBe(0);
expect(storeEntries(dir)).toStrictEqual(["[email protected]", entry]);
expect(() => lstatSync(join(nm, "no-deps"))).toThrow();
expect(existsSync(peerLink)).toBeTrue();
expect(await file(join(nm, consumer, "package.json")).json()).toMatchObject({ name: consumer, version: "1.0.0" });
await expectProductionInstallIsNoop(dir);
expect(storeEntries(dir)).toStrictEqual(["[email protected]", entry]);
expect(() => lstatSync(join(nm, "no-deps"))).toThrow();
const again = await prune(dir, "--production", "--linker", "isolated");
expect(out(again.stdout)).toEndWith(NOTHING(3, 2));
expect(again.exitCode).toBe(0);
},
);
// A --production install hashes peers against the narrowed peer set, so its entries differ from a full install's; both are what some install creates.
test.concurrent(
"isolated: --production keeps the entries `bun install --production` itself created, then only drops dev-only ones after a full install",
async () => {
const { packageDir: dir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
await write(
packageJson,
JSON.stringify({
name: "foo",
dependencies: { "peer-deps-fixed": "1.0.0", "one-dep": "1.0.0" },
devDependencies: { "no-deps": "2.0.0", "a-dep": "1.0.1" },
}),
);
await install(dir, "--lockfile-only", "--linker", "isolated");
await install(dir, "--production", "--linker", "isolated");
const nm = join(dir, "node_modules");
const store = join(nm, ".bun");
const storeTree = () => readdirSync(store, { recursive: true }).map(String).toSorted();
const consumerEntries = () => storeEntries(dir).filter(entry => entry.startsWith("peer-deps-fixed@"));
const [productionEntry, ...rest] = consumerEntries();
expect(rest).toStrictEqual([]);
expect(productionEntry).toMatch(/^peer-deps-fixed@1\.0\.0\+[0-9a-f]{16}$/);
expect(storeEntries(dir)).toStrictEqual(["[email protected]", "[email protected]", productionEntry]);
expect(() => lstatSync(join(nm, "no-deps"))).toThrow();
const productionTree = storeTree();
const noop = await prune(dir, "--production", "--linker", "isolated");
expect(out(noop.stdout)).toEndWith(NOTHING(5, 2));
expect(noop.exitCode).toBe(0);
expect(storeTree()).toStrictEqual(productionTree);
await expectProductionInstallIsNoop(dir);
expect(storeTree()).toStrictEqual(productionTree);
await install(dir, "--linker", "isolated");
const [fullEntry, ...others] = consumerEntries().filter(entry => entry !== productionEntry);
expect(others).toStrictEqual([]);
expect(fullEntry).toMatch(/^peer-deps-fixed@1\.0\.0\+[0-9a-f]{16}$/);
expect(storeEntries(dir)).toStrictEqual(
["[email protected]", "[email protected]", "[email protected]", "[email protected]", productionEntry, fullEntry].toSorted(),
);
expect(isSymlink(join(nm, "no-deps"))).toBeTrue();
expect(isSymlink(join(nm, "a-dep"))).toBeTrue();
const { stdout, exitCode } = await prune(dir, "--production", "--linker", "isolated");
expect(out(stdout).split("\n")).toStrictEqual([
"bun prune <version> (<revision>)",
"",
"- [email protected]",
"- [email protected]",
"2 packages removed (checked 10)",
]);
expect(exitCode).toBe(0);
expect(storeEntries(dir)).toStrictEqual(["[email protected]", "[email protected]", productionEntry, fullEntry].toSorted());
expect(() => lstatSync(join(nm, "no-deps"))).toThrow();
expect(() => lstatSync(join(nm, "a-dep"))).toThrow();
expect(await file(join(nm, "peer-deps-fixed", "package.json")).json()).toMatchObject({ version: "1.0.0" });
await expectProductionInstallIsNoop(dir);
},
);
test.concurrent("isolated: --production removes the stale peer-hash variant and keeps the current one", async () => {
const deps = (noDeps: string) => ({
name: "foo",
dependencies: { "peer-deps-fixed": "1.0.0", "no-deps": noDeps },
devDependencies: { "a-dep": "1.0.1" },
});
const dir = await setupWithLinker("isolated", deps("1.0.0"));
const store = join(dir, "node_modules", ".bun");
const peerEntries = () => storeEntries(dir).filter(entry => entry.startsWith("peer-deps-fixed@"));
const [before] = peerEntries();
expect(before).toMatch(/^peer-deps-fixed@1\.0\.0\+[0-9a-f]{16}$/);
await write(join(dir, "package.json"), JSON.stringify(deps("1.0.1")));
await install(dir, "--linker", "isolated");
const variants = peerEntries();
expect(variants).toHaveLength(2);
const after = variants.find(entry => entry !== before)!;
expect(storeEntries(dir)).toStrictEqual(["[email protected]", "[email protected]", "[email protected]", ...variants].toSorted());
const { stdout, exitCode } = await prune(dir, "--production", "--linker", "isolated");
expect(out(stdout).split("\n")).toStrictEqual([
"bun prune <version> (<revision>)",
"",
"- [email protected]",
"- [email protected]",
`- ${before}`,
"3 packages removed (checked 8)",
]);
expect(exitCode).toBe(0);
expect(storeEntries(dir)).toStrictEqual(["[email protected]", after]);
expect(await file(join(store, "[email protected]", "node_modules", "no-deps", "package.json")).json()).toMatchObject({
version: "1.0.1",
});
await expectProductionInstallIsNoop(dir);
expect(storeEntries(dir)).toStrictEqual(["[email protected]", after]);
});
test.concurrent("keeps dependencies bundled inside a file: dependency", async () => {
const { packageDir: dir, packageJson } = await registry.createTestDir();
await Promise.all([
write(packageJson, JSON.stringify({ name: "foo", dependencies: { local: "file:./local" } })),
write(
join(dir, "local", "package.json"),
JSON.stringify({ name: "local", version: "1.0.0", bundleDependencies: ["inner"] }),
),
write(
join(dir, "local", "node_modules", "inner", "package.json"),
JSON.stringify({ name: "inner", version: "1.0.0" }),
),
]);
await runBunInstall(installEnv(dir), dir);
const inner = join(dir, "node_modules", "local", "node_modules", "inner", "package.json");
expect(existsSync(inner)).toBeTrue();
const junk = plant(dir, "node_modules/junk");
const { stdout, exitCode } = await prune(dir);
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- junk
1 package removed (checked 2)"
`);
expect(exitCode).toBe(0);
expect(existsSync(junk)).toBeFalse();
expect(existsSync(inner)).toBeTrue();
});
// pnpm#13676
test.concurrent(
"hoisted: a nested copy is kept while the root copy is still the old version and removed once bun install replaced it",
async () => {
const dir = await setup({ name: "foo", dependencies: { "one-dep": "1.0.0", "no-deps": "2.0.0" } });
const nm = join(dir, "node_modules");
const nested = join(nm, "one-dep", "node_modules", "no-deps");
const rootPkgJson = join(nm, "no-deps", "package.json");
expect(await file(join(nested, "package.json")).json()).toMatchObject({ version: "1.0.1" });
await write(
join(dir, "package.json"),
JSON.stringify({ name: "foo", dependencies: { "one-dep": "1.0.0", "no-deps": "1.0.1" } }),
);
await install(dir, "--lockfile-only");
expect(existsSync(nested)).toBeTrue();
expect(await file(rootPkgJson).json()).toMatchObject({ version: "2.0.0" });
// The reason the copy was kept comes before the verdict, as it would on a terminal.
const stale = await pruneMerged(dir);
expect(stale.lines).toStrictEqual([
BANNER,
"",
WARN("node_modules/no-deps", "node_modules/one-dep/node_modules/no-deps"),
NOTE,
NOTHING(3, 2),
]);
expect(stale.exitCode).toBe(0);
expect(existsSync(join(nested, "package.json"))).toBeTrue();
expect(await file(rootPkgJson).json()).toMatchObject({ version: "2.0.0" });
await install(dir);
expect(await file(rootPkgJson).json()).toMatchObject({ version: "1.0.1" });
expect(existsSync(nested)).toBeTrue();
const { stdout, stderr, exitCode } = await prune(dir);
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected] (node_modules/one-dep/node_modules)
1 package removed (checked 3)"
`);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
expect(existsSync(nested)).toBeFalse();
expect(existsSync(join(nm, "one-dep", "package.json"))).toBeTrue();
expect(existsSync(rootPkgJson)).toBeTrue();
},
);
test.concurrent(
"hoisted: --production keeps the nested copy a production package resolves to while the root holds the dev version",
async () => {
const pkg = { name: "foo", dependencies: { "one-fixed-dep": "1.0.0" }, devDependencies: { "no-deps": "2.0.0" } };
const [dir, silentDir] = await Promise.all([setup(pkg), setup(pkg)]);
const rootPkgJson = join(dir, "node_modules", "no-deps", "package.json");
const nestedPkgJson = join(dir, "node_modules", "one-fixed-dep", "node_modules", "no-deps", "package.json");
expect(await file(rootPkgJson).json()).toMatchObject({ version: "2.0.0" });
expect(await file(nestedPkgJson).json()).toMatchObject({ version: "1.0.0" });
const { stdout, stderr, exitCode } = await prune(dir, "--production");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
Done! Checked 3 packages across 2 folders (nothing to prune)"
`);
expect(out(stderr)).toBe(
`${WARN("node_modules/no-deps", "node_modules/one-fixed-dep/node_modules/no-deps")}\n${NOTE}`,
);
expect(exitCode).toBe(0);
expect(await file(nestedPkgJson).json()).toMatchObject({ version: "1.0.0" });
expect(await file(rootPkgJson).json()).toMatchObject({ version: "2.0.0" });
await runBunInstall(installEnv(dir), dir, { production: true });
const silent = await prune(silentDir, "--production", "--silent");
expect(silent.stdout).toBe("");
expect(silent.stderr).toBe("");
expect(silent.exitCode).toBe(0);
expect(
existsSync(join(silentDir, "node_modules", "one-fixed-dep", "node_modules", "no-deps", "package.json")),
).toBeTrue();
},
);
test.concurrent(
"hoisted: inside a tree folder, junk is removed but a copy shadowed by a mismatched root package is kept",
async () => {
const dir = await setup({
name: "foo",
dependencies: { "one-dep": "1.0.0", "no-deps": "2.0.0", "a-dep": "1.0.2" },
});
const nm = join(dir, "node_modules");
const nestedNoDeps = join(nm, "one-dep", "node_modules", "no-deps");
expect(await file(join(nestedNoDeps, "package.json")).json()).toMatchObject({ version: "1.0.1" });
expect(await file(join(nm, "a-dep", "package.json")).json()).toMatchObject({ version: "1.0.2" });
await write(
join(dir, "package.json"),
JSON.stringify({ name: "foo", dependencies: { "one-dep": "1.0.0", "no-deps": "2.0.0", "a-dep": "1.0.1" } }),
);
await install(dir, "--lockfile-only");
const shadowed = plant(dir, "node_modules/one-dep/node_modules/a-dep");
const junk = plant(dir, "node_modules/one-dep/node_modules/junk");
const { lines: merged, exitCode } = await pruneMerged(dir);
expect(merged).toStrictEqual([
BANNER,
"",
WARN("node_modules/a-dep", "node_modules/one-dep/node_modules/a-dep"),
NOTE,
"- junk (node_modules/one-dep/node_modules)",
REMOVED(1, 6),
]);
expect(exitCode).toBe(0);
expect(existsSync(junk)).toBeFalse();
expect(existsSync(shadowed)).toBeTrue();
expect(await file(join(nestedNoDeps, "package.json")).json()).toMatchObject({ version: "1.0.1" });
expect(await file(join(nm, "a-dep", "package.json")).json()).toMatchObject({ version: "1.0.2" });
},
);
test.concurrent(
"hoisted + workspaces: --production keeps a workspace's copy while the root still holds the dev version",
async () => {
const { packageDir: dir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker: "hoisted" } });
await Promise.all([
write(
packageJson,
JSON.stringify({ name: "root", workspaces: ["packages/*"], devDependencies: { "no-deps": "2.0.0" } }),
),
write(
join(dir, "packages", "a", "package.json"),
JSON.stringify({ name: "a", version: "1.0.0", dependencies: { "no-deps": "1.0.0" } }),
),
]);
await install(dir, "--linker", "hoisted");
const nm = join(dir, "node_modules");
const rootPkgJson = join(nm, "no-deps", "package.json");
const workspacePkgJson = join(dir, "packages", "a", "node_modules", "no-deps", "package.json");
expect(await file(rootPkgJson).json()).toMatchObject({ version: "2.0.0" });
expect(await file(workspacePkgJson).json()).toMatchObject({ version: "1.0.0" });
const { stdout, stderr, exitCode } = await prune(dir, "--production", "--linker", "hoisted");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
Done! Checked 3 packages across 2 folders (nothing to prune)"
`);
expect(out(stderr)).toBe(`${WARN("node_modules/no-deps", "packages/a/node_modules/no-deps")}\n${NOTE}`);
expect(exitCode).toBe(0);
expect(await file(workspacePkgJson).json()).toMatchObject({ version: "1.0.0" });
expect(isSymlink(join(nm, "a"))).toBeTrue();
expect(await file(rootPkgJson).json()).toMatchObject({ version: "2.0.0" });
},
);
test.concurrent(
"isolated: --production removes a dev-only link and its bin even though production still needs the store entry",
async () => {
// no-deps-bins' tarball lacks its bin file, and bun install skips bin links whose target is missing; what-bin ships one.
const dir = await setupWithLinker("isolated", {
name: "foo",
dependencies: { "uses-what-bin": "1.0.0" },
devDependencies: { "what-bin": "1.0.0" },
});
const nm = join(dir, "node_modules");
const store = join(nm, ".bun");
expect(isSymlink(join(nm, "what-bin"))).toBeTrue();
expect(existsSync(join(store, "[email protected]"))).toBeTrue();
expectBinInstalled(nm, "what-bin");
const dryRun = await prune(dir, "--production", "--dry-run", "--linker", "isolated");
expect(out(dryRun.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
1 package can be removed (checked 4)
bun prune --production --linker isolated"
`);
expect(dryRun.exitCode).toBe(0);
expect(isSymlink(join(nm, "what-bin"))).toBeTrue();
expectBinInstalled(nm, "what-bin");
const { stdout, exitCode } = await prune(dir, "--production", "--linker", "isolated");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
1 package removed (checked 4)"
`);
expect(exitCode).toBe(0);
expect(() => lstatSync(join(nm, "what-bin"))).toThrow();
expect(existsSync(join(store, "[email protected]"))).toBeTrue();
expect(existsSync(join(store, "[email protected]", "node_modules", "what-bin", "package.json"))).toBeTrue();
expect(existsSync(join(nm, "uses-what-bin", "package.json"))).toBeTrue();
expectBinRemoved(nm, "what-bin");
const again = await prune(dir, "--production", "--linker", "isolated");
expect(out(again.stdout)).toEndWith(NOTHING(3, 2));
expect(again.exitCode).toBe(0);
await expectProductionInstallIsNoop(dir);
},
);
// pnpm#13676
test.concurrent("hoisted: nested node_modules of packages without a tree node are pruned", async () => {
const dir = await setup({
name: "foo",
dependencies: { "no-deps": "1.0.0", "@scoped/has-bin-entry": "1.0.0" },
});
const nm = join(dir, "node_modules");
const junk = plant(dir, "node_modules/no-deps/node_modules/junk");
const scopedJunk = plant(dir, "node_modules/@scoped/has-bin-entry/node_modules/@other/thing");
writeFileSync(join(nm, "no-deps", "node_modules", "keep.txt"), "");
const { stdout, exitCode } = await prune(dir);
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- @other/thing (node_modules/@scoped/has-bin-entry/node_modules)
- junk (node_modules/no-deps/node_modules)
2 packages removed (checked 4)"
`);
expect(exitCode).toBe(0);
expect(existsSync(junk)).toBeFalse();
expect(existsSync(scopedJunk)).toBeFalse();
expect(existsSync(join(nm, "@scoped", "has-bin-entry", "node_modules", "@other"))).toBeFalse();
expect(existsSync(join(nm, "no-deps", "node_modules", "keep.txt"))).toBeTrue();
expect(existsSync(join(nm, "no-deps", "package.json"))).toBeTrue();
expect(existsSync(join(nm, "@scoped", "has-bin-entry", "package.json"))).toBeTrue();
});
// pnpm#8307
test.concurrent("refuses to prune a hoisted install with the isolated linker", async () => {
const dir = await setupWithLinker("hoisted", { name: "foo", dependencies: { "one-dep": "1.0.0" } });
const nm = join(dir, "node_modules");
const hoisted = join(nm, "no-deps", "package.json");
expect(existsSync(hoisted)).toBeTrue();
expect(existsSync(join(nm, ".bun"))).toBeFalse();
for (const flags of [
["--linker", "isolated"],
["--linker", "isolated", "--dry-run"],
]) {
const { stdout, stderr, exitCode } = await prune(dir, ...flags);
expect(out(stdout)).toMatchInlineSnapshot(`"bun prune <version> (<revision>)"`);
expect(stderr).toContain("node_modules was installed with the hoisted linker");
expect(stderr).toContain("bun prune --linker hoisted");
expect(exitCode).toBe(1);
expect(existsSync(hoisted)).toBeTrue();
}
const same = await prune(dir, "--linker", "hoisted");
expect(out(same.stdout)).toEndWith(NOTHING(2, 1));
expect(same.exitCode).toBe(0);
});
// pnpm#8307
test.concurrent("refuses to prune an isolated install with the hoisted linker", async () => {
const dir = await setupWithLinker("isolated", { name: "foo", devDependencies: { "one-dep": "1.0.0" } });
const nm = join(dir, "node_modules");
expect(isSymlink(join(nm, "one-dep"))).toBeTrue();
const mismatch = await prune(dir, "--production", "--linker", "hoisted");
expect(out(mismatch.stdout)).toMatchInlineSnapshot(`"bun prune <version> (<revision>)"`);
expect(mismatch.stderr).toContain("node_modules was installed with the isolated linker");
expect(mismatch.stderr).toContain("bun prune --linker isolated");
expect(mismatch.exitCode).toBe(1);
expect(isSymlink(join(nm, "one-dep"))).toBeTrue();
expect(existsSync(join(nm, "one-dep", "package.json"))).toBeTrue();
const same = await prune(dir, "--production", "--linker", "isolated");
expect(out(same.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
- [email protected]
2 packages removed (checked 3)"
`);
expect(same.exitCode).toBe(0);
expect(() => lstatSync(join(nm, "one-dep"))).toThrow();
});
// pnpm#5960
test.concurrent.each(["hoisted", "isolated"] as Linker[])(
"%s: --production keeps an npm: alias whose real name is also a dev-only dependency",
async linker => {
const dir = await setupWithLinker(linker, {
name: "foo",
dependencies: { aliased: "npm:[email protected]" },
devDependencies: { "no-deps": "2.0.0" },
});
const nm = join(dir, "node_modules");
expect(await file(join(nm, "aliased", "package.json")).json()).toMatchObject({ version: "1.0.0" });
expect(await file(join(nm, "no-deps", "package.json")).json()).toMatchObject({ version: "2.0.0" });
const { stdout, exitCode } = await prune(dir, "--production", "--linker", linker);
expect(lines(stdout)).toStrictEqual([BANNER, "", "- [email protected]", REMOVED(1, linker === "hoisted" ? 2 : 4)]);
expect(exitCode).toBe(0);
expect(() => lstatSync(join(nm, "no-deps"))).toThrow();
expect(await file(join(nm, "aliased", "package.json")).json()).toMatchObject({ version: "1.0.0" });
if (linker === "isolated") {
expect(existsSync(join(nm, ".bun", "[email protected]"))).toBeTrue();
}
await expectProductionInstallIsNoop(dir);
},
);
// pnpm#10081
test.concurrent.each(["hoisted", "isolated"] as Linker[])(
"%s: the dangling link of a renamed workspace is removed, the renamed workspace is not",
async linker => {
const { packageDir: dir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker } });
const appJson = (lib: string) =>
JSON.stringify({ name: "app", version: "1.0.0", dependencies: { [lib]: "workspace:*" } });
const libJson = (name: string) => JSON.stringify({ name, version: "1.0.0", dependencies: { "no-deps": "1.0.0" } });
await Promise.all([
write(packageJson, JSON.stringify({ name: "root", workspaces: ["packages/*"] })),
write(join(dir, "packages", "app", "package.json"), appJson("a")),
write(join(dir, "packages", "a", "package.json"), libJson("a")),
]);
await install(dir, "--linker", linker);
// The hoisted linker links every workspace into the root folder; the isolated linker links it where it is depended on.
const linkFolder = linker === "hoisted" ? "node_modules" : "packages/app/node_modules";
const staleLink = join(dir, linkFolder, "a");
expect(isSymlink(staleLink)).toBeTrue();
renameSync(join(dir, "packages", "a"), join(dir, "packages", "b"));
await Promise.all([
write(join(dir, "packages", "b", "package.json"), libJson("b")),
write(join(dir, "packages", "app", "package.json"), appJson("b")),
]);
await install(dir, "--lockfile-only", "--linker", linker);
expect(isSymlink(staleLink)).toBeTrue();
expect(existsSync(staleLink)).toBeFalse();
const { stdout, exitCode } = await prune(dir, "--linker", linker);
// A dangling link has no package.json to read a version from; only a non-root folder is named.
const row = linker === "hoisted" ? "- a" : `- a (${linkFolder})`;
expect(lines(stdout)).toStrictEqual([BANNER, "", row, REMOVED(1, 3)]);
expect(exitCode).toBe(0);
expect(() => lstatSync(staleLink)).toThrow();
expect(existsSync(join(dir, "packages", "b", "package.json"))).toBeTrue();
const noDeps =
linker === "hoisted"
? join(dir, "node_modules", "no-deps")
: join(dir, "packages", "b", "node_modules", "no-deps");
expect(existsSync(join(noDeps, "package.json"))).toBeTrue();
},
);
test.concurrent(
"isolated: store entries of file:, tarball and git dependencies are kept under their non-npm keys",
async () => {
const { packageDir: dir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } });
const gitPkg = await gitDependency(dir, "git-pkg");
await Promise.all([
write(
packageJson,
JSON.stringify({
name: "foo",
dependencies: {
"no-deps": "1.0.0",
"local": "file:./local",
"left-pad": copyTarball(dir, "left-pad", "1.0.0"),
"git-pkg": gitPkg,
},
}),
),
write(join(dir, "local", "package.json"), JSON.stringify({ name: "local", version: "1.0.0" })),
]);
await install(dir, "--linker", "isolated");
const nm = join(dir, "node_modules");
const installed = storeEntries(dir);
expect(installed).toStrictEqual([
expect.stringMatching(/^git-pkg@git\+/),
expect.stringMatching(/^left-pad@/),
"local@file+local",
"[email protected]",
]);
expect(installed[1]).not.toBe("[email protected]");
for (const name of ["no-deps", "local", "left-pad", "git-pkg"]) {
expect(isSymlink(join(nm, name))).toBeTrue();
}
plant(dir, "node_modules/.bun/[email protected]/node_modules/junk");
plant(dir, "node_modules/.bun/local@file+elsewhere/node_modules/local");
plant(dir, "node_modules/.bun/[email protected]/node_modules/git-pkg");
const { stdout, stderr, exitCode } = await prune(dir, "--linker", "isolated");
expect(stderr).not.toContain("warn:");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
- [email protected]
- local@file+elsewhere
3 packages removed (checked 11)"
`);
expect(exitCode).toBe(0);
expect(storeEntries(dir)).toStrictEqual(installed);
for (const name of ["no-deps", "local", "left-pad", "git-pkg"]) {
expect(existsSync(join(nm, name, "package.json"))).toBeTrue();
}
const production = await prune(dir, "--production", "--linker", "isolated");
expect(out(production.stdout)).toEndWith(NOTHING(8, 2));
expect(production.exitCode).toBe(0);
expect(storeEntries(dir)).toStrictEqual(installed);
},
);
test.concurrent(
"workspaces: without --linker, a bun.lock with configVersion 1 is pruned with the isolated linker",
async () => {
const { packageDir: dir, packageJson } = await registry.createTestDir({});
await writeWorkspaces(dir, packageJson, {
packages: { a: { dependencies: { "one-dep": "1.0.0" }, devDependencies: { "a-dep": "1.0.1" } } },
});
await install(dir);
expect(await lock(dir)).toContain('"configVersion": 1,');
const store = join(dir, "node_modules", ".bun");
expect(storeEntries(dir)).toStrictEqual(["[email protected]", "[email protected]", "[email protected]"]);
const junk = plant(dir, "node_modules/.bun/[email protected]/node_modules/junk");
const { stdout, stderr, exitCode } = await prune(dir, "--production");
expect(stderr).not.toContain("linker");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
- [email protected]
2 packages removed (checked 6)"
`);
expect(exitCode).toBe(0);
expect(existsSync(junk)).toBeFalse();
expect(existsSync(join(store, "[email protected]"))).toBeFalse();
expect(existsSync(join(store, "[email protected]"))).toBeTrue();
expect(() => lstatSync(join(dir, "packages", "a", "node_modules", "a-dep"))).toThrow();
expect(existsSync(join(dir, "packages", "a", "node_modules", "one-dep", "package.json"))).toBeTrue();
await expectProductionInstallIsNoop(dir);
},
);
test.concurrent(
"workspaces: without --linker, a bun.lock without configVersion is pruned with the hoisted linker",
async () => {
const { packageDir: dir, packageJson } = await registry.createTestDir({});
await writeWorkspaces(dir, packageJson, {
packages: { a: { dependencies: { "one-dep": "1.0.0" }, devDependencies: { "a-dep": "1.0.1" } } },
});
await install(dir, "--linker", "hoisted");
const before = await lock(dir);
expect(before).toContain('"configVersion": 1,');
await write(join(dir, "bun.lock"), before.replace(' "configVersion": 1,\n', ""));
const nm = join(dir, "node_modules");
expect(existsSync(join(nm, ".bun"))).toBeFalse();
expect(existsSync(join(nm, "no-deps", "package.json"))).toBeTrue();
const junk = plant(dir, "node_modules/junk");
plant(dir, "node_modules/.bun/[email protected]/node_modules/junk");
// The refusal names the linker that was picked.
const withStore = await prune(dir, "--production");
expect(withStore.stderr).toContain("but bun prune would use the hoisted linker");
expect(withStore.exitCode).toBe(1);
expect(existsSync(junk)).toBeTrue();
rmSync(join(nm, ".bun"), { recursive: true });
const { stdout, stderr, exitCode } = await prune(dir, "--production");
expect(stderr).toBe("");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
- junk
2 packages removed (checked 5)"
`);
expect(exitCode).toBe(0);
expect(existsSync(junk)).toBeFalse();
expect(existsSync(join(nm, "a-dep"))).toBeFalse();
expect(existsSync(join(nm, "no-deps", "package.json"))).toBeTrue();
expect(existsSync(join(nm, "one-dep", "package.json"))).toBeTrue();
},
);
test.concurrent("without --linker, a project without workspaces is pruned with the hoisted linker", async () => {
const dir = await setup({ name: "foo", dependencies: { "one-dep": "1.0.0" }, devDependencies: { "a-dep": "1.0.1" } });
expect(await lock(dir)).toContain('"configVersion": 1,');
const nm = join(dir, "node_modules");
expect(existsSync(join(nm, ".bun"))).toBeFalse();
expect(existsSync(join(nm, "no-deps", "package.json"))).toBeTrue();
const junk = plant(dir, "node_modules/junk");
plant(dir, "node_modules/.bun/[email protected]/node_modules/junk");
const withStore = await prune(dir, "--production");
expect(withStore.stderr).toContain("but bun prune would use the hoisted linker");
expect(withStore.exitCode).toBe(1);
expect(existsSync(junk)).toBeTrue();
rmSync(join(nm, ".bun"), { recursive: true });
const { stdout, stderr, exitCode } = await prune(dir, "--production");
expect(stderr).toBe("");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
- junk
2 packages removed (checked 4)"
`);
expect(exitCode).toBe(0);
expect(existsSync(junk)).toBeFalse();
expect(existsSync(join(nm, "a-dep"))).toBeFalse();
expect(existsSync(join(nm, "no-deps", "package.json"))).toBeTrue();
});
test.concurrent.each([["--os=aix"], ["--cpu=s390x"]])(
"isolated: %s removes the store entry and link of a package disabled for that platform, plain prune keeps them",
async (flag: string) => {
const dir = await setupWithLinker("isolated", {
name: "foo",
dependencies: { "no-deps": "1.0.0", "test-postinstall-skip-native": "1.0.0" },
});
const nm = join(dir, "node_modules");
const store = join(nm, ".bun");
expect(isSymlink(join(nm, "test-postinstall-skip-native"))).toBeTrue();
expect(existsSync(join(store, "[email protected]"))).toBeTrue();
const host = await prune(dir, "--linker", "isolated");
expect(out(host.stdout)).toEndWith(NOTHING(4, 2));
expect(host.exitCode).toBe(0);
expect(existsSync(join(store, "[email protected]"))).toBeTrue();
const other = await prune(dir, flag, "--linker", "isolated");
expect(out(other.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
1 package removed (checked 4)"
`);
expect(other.exitCode).toBe(0);
expect(existsSync(join(store, "[email protected]"))).toBeFalse();
expect(() => lstatSync(join(nm, "test-postinstall-skip-native"))).toThrow();
expect(existsSync(join(store, "[email protected]"))).toBeTrue();
expect(existsSync(join(nm, "no-deps", "package.json"))).toBeTrue();
expect(await install(dir, flag, "--linker", "isolated")).toContain("no changes");
},
);
test.concurrent("isolated: keeps dependencies bundled inside a package, with and without --production", async () => {
const dir = await setupWithLinker("isolated", { name: "foo", dependencies: { "bundled-transitive": "1.0.0" } });
const installed = storeEntries(dir);
expect(installed).toStrictEqual(["[email protected]", "[email protected]", "[email protected]"]);
const bundled = join(
dir,
"node_modules",
".bun",
"[email protected]",
"node_modules",
"bundled-transitive",
"node_modules",
"no-deps",
"package.json",
);
expect(existsSync(bundled)).toBeTrue();
for (const flags of [[], ["--production"]]) {
const { stdout, exitCode } = await prune(dir, ...flags, "--linker", "isolated");
expect(out(stdout)).toEndWith(NOTHING(4, 2));
expect(exitCode).toBe(0);
expect(storeEntries(dir)).toStrictEqual(installed);
expect(existsSync(bundled)).toBeTrue();
}
await expectProductionInstallIsNoop(dir);
});
test.concurrent("hoisted: the nested tree of a package with bundled dependencies is not walked", async () => {
const dir = await setup({ name: "foo", dependencies: { "no-deps": "2.0.0", "bundled-transitive": "1.0.0" } });
const nm = join(dir, "node_modules");
const bundledNm = join(nm, "bundled-transitive", "node_modules");
expect(await file(join(bundledNm, "no-deps", "package.json")).json()).toMatchObject({ version: "1.0.0" });
expect(await file(join(nm, "no-deps", "package.json")).json()).toMatchObject({ version: "2.0.0" });
expect(await file(join(nm, "one-dep", "node_modules", "no-deps", "package.json")).json()).toMatchObject({
version: "1.0.1",
});
const junk = plant(dir, "node_modules/bundled-transitive/node_modules/junk");
const oneDepJunk = plant(dir, "node_modules/one-dep/node_modules/junk");
const { stdout, stderr, exitCode } = await prune(dir);
expect(stderr).not.toContain("warn:");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- junk (node_modules/one-dep/node_modules)
1 package removed (checked 5)"
`);
expect(exitCode).toBe(0);
expect(existsSync(junk)).toBeTrue();
expect(existsSync(oneDepJunk)).toBeFalse();
expect(existsSync(join(bundledNm, "no-deps", "package.json"))).toBeTrue();
});
test.concurrent(
"hoisted: a nested copy of a git dependency is removed only once the root copy's .bun-tag matches bun.lock",
async () => {
const { packageDir: dir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker: "hoisted" } });
const gitPkg = await gitDependency(dir, "git-pkg");
await write(packageJson, JSON.stringify({ name: "foo", dependencies: { "no-deps": "1.0.0", "git-pkg": gitPkg } }));
await install(dir, "--linker", "hoisted");
const nm = join(dir, "node_modules");
const rootTag = join(nm, "git-pkg", ".bun-tag");
const tag = await file(rootTag).text();
expect(tag).toMatch(/^[0-9a-f]{40}$/);
expect(await lock(dir)).toContain(tag);
const nested = plant(dir, "node_modules/no-deps/node_modules/git-pkg");
writeFileSync(rootTag, "0000000000000000000000000000000000000000");
const stale = await prune(dir, "--linker", "hoisted");
expect(out(stale.stdout)).toEndWith(NOTHING(3, 2));
expect(out(stale.stderr)).toBe(
`${WARN("node_modules/git-pkg", "node_modules/no-deps/node_modules/git-pkg")}\n${NOTE}`,
);
expect(stale.exitCode).toBe(0);
expect(existsSync(nested)).toBeTrue();
writeFileSync(rootTag, tag);
const { stdout, stderr, exitCode } = await prune(dir, "--linker", "hoisted");
expect(stderr).not.toContain("warn:");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- git-pkg (node_modules/no-deps/node_modules)
1 package removed (checked 3)"
`);
expect(exitCode).toBe(0);
expect(existsSync(nested)).toBeFalse();
expect(existsSync(join(nm, "git-pkg", "package.json"))).toBeTrue();
},
);
test.concurrent(
"hoisted: a nested copy of a local tarball dependency is removed once the root copy is installed",
async () => {
const { packageDir: dir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker: "hoisted" } });
await write(
packageJson,
JSON.stringify({
name: "foo",
dependencies: { "no-deps": "1.0.0", "left-pad": copyTarball(dir, "left-pad", "1.0.0") },
}),
);
await install(dir, "--linker", "hoisted");
const nm = join(dir, "node_modules");
const rootPkgJson = join(nm, "left-pad", "package.json");
expect(await file(rootPkgJson).json()).toMatchObject({ version: "1.0.0" });
const nested = plant(dir, "node_modules/no-deps/node_modules/left-pad");
const junk = plant(dir, "node_modules/no-deps/node_modules/junk");
const kept = "node_modules/no-deps/node_modules/left-pad";
renameSync(join(nm, "left-pad"), join(dir, "left-pad.moved"));
const missing = await prune(dir, "--linker", "hoisted");
expect(lines(missing.stdout)).toStrictEqual([
BANNER,
"",
"- junk (node_modules/no-deps/node_modules)",
REMOVED(1, 3),
]);
expect(out(missing.stderr)).toBe(`${MISSING_WARN("node_modules/left-pad", kept)}\n${NOTE}`);
expect(missing.exitCode).toBe(0);
expect(existsSync(junk)).toBeFalse();
expect(existsSync(nested)).toBeTrue();
renameSync(join(dir, "left-pad.moved"), join(nm, "left-pad"));
renameSync(rootPkgJson, join(nm, "left-pad", "package.json.bak"));
const mismatch = await prune(dir, "--linker", "hoisted");
expect(lines(mismatch.stdout)).toStrictEqual([BANNER, "", NOTHING(3, 2)]);
expect(out(mismatch.stderr)).toBe(`${WARN("node_modules/left-pad", kept)}\n${NOTE}`);
expect(mismatch.exitCode).toBe(0);
expect(existsSync(nested)).toBeTrue();
renameSync(join(nm, "left-pad", "package.json.bak"), rootPkgJson);
const { stdout, stderr, exitCode } = await prune(dir, "--linker", "hoisted");
expect(stderr).toBe("");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- left-pad (node_modules/no-deps/node_modules)
1 package removed (checked 3)"
`);
expect(exitCode).toBe(0);
expect(existsSync(nested)).toBeFalse();
expect(existsSync(rootPkgJson)).toBeTrue();
},
);
test.concurrent("hoisted: a nested copy of a link: dependency is removed when the root entry is the link", async () => {
const { packageDir: dir, packageJson } = await registry.createTestDir({ bunfigOpts: { linker: "hoisted" } });
await Promise.all([
write(join(dir, "linked", "package.json"), JSON.stringify({ name: "linked", version: "1.0.0" })),
write(packageJson, JSON.stringify({ name: "foo", dependencies: { "no-deps": "1.0.0", linked: "link:linked" } })),
]);
const env = globalEnv(dir, join(dir, ".global"));
const link = await run(env, join(dir, "linked"), "link");
expect(link.stderr).not.toContain("error:");
expect(link.stdout).toContain('Success! Registered "linked"');
expect(link.exitCode).toBe(0);
const installed = await run(env, dir, "install", "--linker", "hoisted");
expect(installed.stderr).not.toContain("error:");
expect(installed.exitCode).toBe(0);
const nm = join(dir, "node_modules");
expect(isSymlink(join(nm, "linked"))).toBeTrue();
const nested = plant(dir, "node_modules/no-deps/node_modules/linked");
const { stdout, stderr, exitCode } = await prune(dir, "--linker", "hoisted");
expect(stderr).not.toContain("warn:");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- linked (node_modules/no-deps/node_modules)
1 package removed (checked 3)"
`);
expect(exitCode).toBe(0);
expect(existsSync(nested)).toBeFalse();
expect(isSymlink(join(nm, "linked"))).toBeTrue();
expect(await file(join(dir, "linked", "package.json")).json()).toStrictEqual({ name: "linked", version: "1.0.0" });
});
test.concurrent("hoisted: a nested copy left behind by an override to a tarball is removed", async () => {
const dir = await setup({ name: "foo", dependencies: { "no-deps": "2.0.0", "one-dep": "1.0.0" } });
const nm = join(dir, "node_modules");
const nested = join(nm, "one-dep", "node_modules", "no-deps");
expect(await file(join(nested, "package.json")).json()).toMatchObject({ version: "1.0.1" });
expect(await lock(dir)).toContain('"one-dep/no-deps"');
await write(
join(dir, "package.json"),
JSON.stringify({
name: "foo",
dependencies: { "no-deps": "2.0.0", "one-dep": "1.0.0" },
overrides: { "no-deps": copyTarball(dir, "no-deps", "2.0.0") },
}),
);
await install(dir);
expect(await lock(dir)).not.toContain('"one-dep/no-deps"');
expect(await lock(dir)).toContain("no-deps-2.0.0.tgz");
expect(existsSync(join(nested, "package.json"))).toBeTrue();
const { stdout, stderr, exitCode } = await prune(dir);
expect(stderr).not.toContain("warn:");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected] (node_modules/one-dep/node_modules)
1 package removed (checked 3)"
`);
expect(exitCode).toBe(0);
expect(existsSync(nested)).toBeFalse();
expect(await file(join(nm, "no-deps", "package.json")).json()).toMatchObject({ name: "no-deps" });
});
// https://github.com/oven-sh/bun/issues/13563
test.concurrent(
"hoisted: build metadata in the installed package.json version does not block removing a nested copy",
async () => {
const dir = await setup({ name: "foo", dependencies: { "no-deps": "1.0.0", "no-deps-build-metadata": "1.0.0" } });
const nm = join(dir, "node_modules");
expect(await file(join(nm, "no-deps-build-metadata", "package.json")).json()).toMatchObject({
version: "1.0.0+123",
});
expect(await lock(dir)).toContain('"[email protected]"');
const nested = plant(dir, "node_modules/no-deps/node_modules/no-deps-build-metadata");
const { stdout, stderr, exitCode } = await prune(dir);
expect(stderr).not.toContain("warn:");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- no-deps-build-metadata (node_modules/no-deps/node_modules)
1 package removed (checked 3)"
`);
expect(exitCode).toBe(0);
expect(existsSync(nested)).toBeFalse();
expect(existsSync(join(nm, "no-deps-build-metadata", "package.json"))).toBeTrue();
},
);
test.concurrent(
"isolated + publicHoistPattern: the public link of a transitive dev-only package goes away with its store entry",
async () => {
const dir = await setupWithLinker(
"isolated",
{ name: "foo", dependencies: { "a-dep": "1.0.1" }, devDependencies: { "one-dep": "1.0.0" } },
{ publicHoistPattern: ["no-deps"] },
);
const nm = join(dir, "node_modules");
const store = join(nm, ".bun");
expect(isSymlink(join(nm, "no-deps"))).toBeTrue();
expect(await file(join(nm, "no-deps", "package.json")).json()).toMatchObject({ version: "1.0.1" });
const plain = await prune(dir, "--linker", "isolated");
expect(out(plain.stdout)).toEndWith(NOTHING(6, 2));
expect(plain.exitCode).toBe(0);
expect(isSymlink(join(nm, "no-deps"))).toBeTrue();
const production = await prune(dir, "--production", "--linker", "isolated");
expect(out(production.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
- [email protected]
2 packages removed (checked 6)"
`);
expect(production.exitCode).toBe(0);
expect(existsSync(join(store, "[email protected]"))).toBeFalse();
expect(() => lstatSync(join(nm, "no-deps"))).toThrow();
expect(() => lstatSync(join(nm, "one-dep"))).toThrow();
expect(existsSync(join(nm, "a-dep", "package.json"))).toBeTrue();
await expectProductionInstallIsNoop(dir);
},
);
test.concurrent(
"isolated: an unwanted entry's peer-hash variant and scoped hidden-hoist links follow it out",
async () => {
const dir = await setupWithLinker("isolated", {
name: "foo",
dependencies: { "no-deps": "1.0.0" },
devDependencies: { "one-dep": "1.0.0" },
});
const store = join(dir, "node_modules", ".bun");
const hiddenHoist = join(store, "node_modules");
const variant = plant(dir, "node_modules/.bun/[email protected]+0123456789abcdef/node_modules/one-dep");
const scopedEntry = plant(dir, "node_modules/.bun/@[email protected]/node_modules/@scope/zzz");
const scopedLink = join(hiddenHoist, "@scope", "zzz");
mkdirSync(join(hiddenHoist, "@scope"), { recursive: true });
symlinkSync(scopedEntry, scopedLink, "junction");
const liveLink = join(hiddenHoist, "@scope", "no-deps");
symlinkSync(join(store, "[email protected]", "node_modules", "no-deps"), liveLink, "junction");
expect(isSymlink(scopedLink)).toBeTrue();
expect(existsSync(join(liveLink, "package.json"))).toBeTrue();
const { stdout, exitCode } = await prune(dir, "--production", "--linker", "isolated");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- @scope/[email protected]
- [email protected]
- [email protected]
- [email protected]+0123456789abcdef
4 packages removed (checked 7)"
`);
expect(exitCode).toBe(0);
expect(existsSync(variant)).toBeFalse();
expect(existsSync(join(store, "[email protected]+0123456789abcdef"))).toBeFalse();
expect(existsSync(join(store, "@[email protected]"))).toBeFalse();
expect(() => lstatSync(scopedLink)).toThrow();
expect(existsSync(join(liveLink, "package.json"))).toBeTrue();
expect(existsSync(join(store, "[email protected]"))).toBeTrue();
},
);
test.concurrent("isolated: an emptied scope dir of dangling hidden-hoist links is removed", async () => {
const dir = await setupWithLinker("isolated", {
name: "foo",
dependencies: { "no-deps": "1.0.0" },
devDependencies: { "one-dep": "1.0.0" },
});
const store = join(dir, "node_modules", ".bun");
const scopeDir = join(store, "node_modules", "@scope");
const scopedEntry = plant(dir, "node_modules/.bun/@[email protected]/node_modules/@scope/zzz");
mkdirSync(scopeDir, { recursive: true });
symlinkSync(scopedEntry, join(scopeDir, "zzz"), "junction");
const { stdout, exitCode } = await prune(dir, "--production", "--linker", "isolated");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- @scope/[email protected]
- [email protected]
- [email protected]
3 packages removed (checked 6)"
`);
expect(exitCode).toBe(0);
expect(existsSync(scopeDir)).toBeFalse();
expect(existsSync(join(store, "node_modules"))).toBeTrue();
});
test.concurrent("isolated: --filter on a pruned checkout does not protect the missing workspace", async () => {
const dir = await setupWorkspaces("isolated", prunedCheckout({}));
const store = join(dir, "node_modules", ".bun");
rmSync(join(dir, "packages", "other"), { recursive: true });
expect(existsSync(join(store, "[email protected]"))).toBeTrue();
const { stdout, stderr, exitCode } = await prune(dir, "--filter", "app", "--linker", "isolated");
expect(stderr).toContain(PRUNED_NOTE);
expect(stderr).not.toContain(OUT_OF_SYNC);
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
1 package removed (checked 3)"
`);
expect(exitCode).toBe(0);
expect(existsSync(join(store, "[email protected]"))).toBeFalse();
expect(existsSync(join(store, "[email protected]"))).toBeTrue();
expect(existsSync(join(dir, "packages", "app", "node_modules", "no-deps", "package.json"))).toBeTrue();
await install(dir, "--frozen-lockfile", "--linker", "isolated");
expect(existsSync(join(store, "[email protected]"))).toBeFalse();
});
test.concurrent.each(linkers)(
"%s: a catalog entry only a missing workspace used may be missing from bun.lock",
async linker => {
const dir = await setupWorkspaces(linker, {
root: { workspaces: { packages: ["packages/*"], catalog: { "a-dep": "1.0.1", "left-pad": "1.0.0" } } },
packages: {
app: { dependencies: { "a-dep": "catalog:" } },
other: { dependencies: { "left-pad": "catalog:" } },
},
});
const full = await lock(dir);
const trimmed = full.replace(/^ {4}"left-pad": "1\.0\.0",\n/m, "");
expect(trimmed).not.toBe(full);
expect(trimmed).toContain('"a-dep": "1.0.1",');
await write(join(dir, "bun.lock"), trimmed);
rmSync(join(dir, "packages", "other"), { recursive: true });
const leftPad =
linker === "hoisted"
? join(dir, "node_modules", "left-pad")
: join(dir, "node_modules", ".bun", "[email protected]");
expect(existsSync(leftPad)).toBeTrue();
const { stdout, stderr, exitCode } = await prune(dir, "--linker", linker);
expect(normalizeBunSnapshot(stderr)).toBe(
`${PRUNED_NOTE}\nnote: skipped 1 catalog entry not in bun.lock (unused by the workspaces on disk): "left-pad"`,
);
expect(lines(stdout)).toStrictEqual(
linker === "hoisted"
? [BANNER, "", "- [email protected]", "- other", REMOVED(2, 4)]
: [BANNER, "", "- [email protected]", REMOVED(1, 3)],
);
expect(exitCode).toBe(0);
expect(existsSync(leftPad)).toBeFalse();
expect(await lock(dir)).toBe(trimmed);
await install(dir, "--frozen-lockfile", "--linker", linker);
expect(await lock(dir)).toBe(trimmed);
},
);
test.concurrent("a lockfile that fails to parse is an error and nothing is deleted", async () => {
const dir = await setup({ name: "foo", dependencies: { "no-deps": "1.0.0" } });
const junk = plant(dir, "node_modules/junk");
await write(join(dir, "bun.lock"), "{ this is not a lockfile");
const { stdout, stderr, exitCode } = await prune(dir);
expect(stderr).toContain("error: failed to load lockfile: ");
expect(out(stdout)).toMatchInlineSnapshot(`"bun prune <version> (<revision>)"`);
expect(exitCode).toBe(1);
expect(existsSync(junk)).toBeTrue();
const silent = await prune(dir, "--silent");
expect(silent.stdout).toBe("");
expect(silent.stderr).toBe("");
expect(silent.exitCode).toBe(1);
expect(existsSync(junk)).toBeTrue();
});
test.concurrent("missing package.json is an error; --cwd prunes another directory", async () => {
const dir = await setup({ name: "foo", dependencies: { "no-deps": "1.0.0" } });
using empty = tempDir("prune-empty", {});
const junk = plant(dir, "node_modules/junk");
const missing = await prune(String(empty));
expect(normalizeBunSnapshot(missing.stderr)).toBe("error: missing package.json, nothing to prune");
expect(missing.stdout).toBe("");
expect(missing.exitCode).toBe(1);
// Like the missing-lockfile refusal, this is a state of the project rather than a usage error, so --silent applies.
const silent = await prune(String(empty), "--silent");
expect(silent.stdout).toBe("");
expect(silent.stderr).toBe("");
expect(silent.exitCode).toBe(1);
const { stdout, exitCode } = await prune({ dir, cwd: String(empty) }, "--cwd", dir);
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- junk
1 package removed (checked 2)"
`);
expect(exitCode).toBe(0);
expect(existsSync(junk)).toBeFalse();
expect(existsSync(join(dir, "node_modules", "no-deps", "package.json"))).toBeTrue();
});
test.concurrent("a node_modules that is a file is an error", async () => {
const dir = await setup({ name: "foo", dependencies: { "no-deps": "1.0.0" } });
const nm = join(dir, "node_modules");
rmSync(nm, { recursive: true });
writeFileSync(nm, "not a directory");
const { stdout, stderr, exitCode } = await prune(dir);
expect(stderr).toContain("failed to open node_modules");
expect(out(stdout)).toMatchInlineSnapshot(`"bun prune <version> (<revision>)"`);
expect(exitCode).toBe(1);
const silent = await prune(dir, "--silent");
expect(silent.stdout).toBe("");
expect(silent.stderr).toBe("");
expect(silent.exitCode).toBe(1);
expect(await file(nm).text()).toBe("not a directory");
});
test.concurrent(
"a package.json script named prune does not shadow bun prune; bun run prune still runs it",
async () => {
const dir = await setup({
name: "foo",
dependencies: { "no-deps": "1.0.0" },
scripts: { prune: "echo SCRIPT_RAN" },
});
const junk = plant(dir, "node_modules/junk");
const pruned = await bun(dir, "prune");
expect(pruned.stdout).not.toContain("SCRIPT_RAN");
expect(out(pruned.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- junk
1 package removed (checked 2)"
`);
expect(pruned.exitCode).toBe(0);
expect(existsSync(junk)).toBeFalse();
const script = await bun(dir, "run", "prune");
expect(script.stdout).toContain("SCRIPT_RAN");
expect(script.stdout).not.toContain("Checked");
expect(script.exitCode).toBe(0);
},
);
// The docs point at --omit=optional; like every other flag bun prune does not know, --no-optional is ignored rather than rejected.
test.concurrent("--no-optional is not --omit=optional", async () => {
const dir = await setup({
name: "foo",
dependencies: { "no-deps": "1.0.0" },
optionalDependencies: { "a-dep": "1.0.1" },
});
const noOptional = await prune(dir, "--no-optional", "--dry-run");
expect(noOptional.stderr).toBe("");
expect(out(noOptional.stdout)).toEndWith(NOTHING(2, 1));
expect(noOptional.exitCode).toBe(0);
const omit = await prune(dir, "--omit=optional", "--dry-run");
expect(out(omit.stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- [email protected]
1 package can be removed (checked 2)
bun prune --omit=optional"
`);
expect(omit.exitCode).toBe(0);
expect(existsSync(join(dir, "node_modules", "a-dep", "package.json"))).toBeTrue();
});
test.concurrent("--help lists every flag; -F is --filter, -p is --production", async () => {
const dir = await setupWorkspaces("hoisted", {
root: { dependencies: { "no-deps": "2.0.0" } },
packages: { a: { dependencies: { "no-deps": "1.0.0" } }, b: { dependencies: { "no-deps": "1.0.0" } } },
});
const aJunk = plant(dir, "packages/a/node_modules/junk");
const bJunk = plant(dir, "packages/b/node_modules/junk");
const help = await prune(dir, "--help");
expect(help.stderr).toBe("");
expect(out(help.stdout)).toMatchInlineSnapshot(`
"Usage: bun prune [flags]
Remove packages from node_modules that are not in bun.lock. With --production, also remove packages that are only needed by devDependencies.
Flags:
-p, --production Also remove packages that are only needed by devDependencies (alias: --prod)
--omit=<val> Also remove packages that are only needed by the given dependency types
--dry-run Print what would be removed without deleting anything
--os=<val> Prune for a different operating system than the current one
--cpu=<val> Prune for a different CPU architecture than the current one
--linker=<val> Prune a node_modules installed with the given linker (one of "isolated" or "hoisted")
-F, --filter=<val> Only prune the node_modules folders of the matching workspaces
--silent Don't log anything
--cwd=<val> Set a specific cwd
-h, --help Print this help menu
Examples:
Remove packages that are not in bun.lock from node_modules
bun prune
Also remove devDependencies, e.g. after the build step in a Dockerfile
bun prune --production
Show what would be removed without deleting anything
bun prune --dry-run
Only prune what the app workspace no longer needs
bun prune --production --filter app
Full documentation is available at https://bun.com/docs/pm/cli/prune."
`);
expect(help.exitCode).toBe(0);
const longFlag = await prune(dir, "--filter", "a", "--dry-run", "--linker", "hoisted");
const shortFlag = await prune(dir, "-F", "a", "--dry-run", "--linker", "hoisted");
// The hint echoes the flags as typed, so only the last line differs between the two spellings.
expect(lines(longFlag.stdout)).toStrictEqual([
BANNER,
"",
"- junk (node_modules/a/node_modules)",
CAN_BE_REMOVED(1, 5),
APPLY_HINT("--filter", "a", "--linker", "hoisted"),
]);
expect(lines(shortFlag.stdout)).toStrictEqual([
...lines(longFlag.stdout).slice(0, -1),
APPLY_HINT("-F", "a", "--linker", "hoisted"),
]);
expect(shortFlag.exitCode).toBe(0);
const { stdout, exitCode } = await prune(dir, "-F", "b", "-p", "--linker", "hoisted");
expect(out(stdout)).toMatchInlineSnapshot(`
"bun prune <version> (<revision>)
- junk (node_modules/b/node_modules)
1 package removed (checked 5)"
`);
expect(exitCode).toBe(0);
expect(existsSync(aJunk)).toBeTrue();
expect(existsSync(bJunk)).toBeFalse();
});
test.concurrent(
"--global is rejected before the global folder is touched; bun link registrations survive",
async () => {
using dir = tempDir("prune-global", {
"lib/package.json": JSON.stringify({ name: "lib", version: "1.0.0" }),
"gpkg/package.json": JSON.stringify({ name: "gpkg", version: "1.0.0" }),
});
const root = String(dir);
const bunInstall = join(root, ".global");
const globalDir = join(bunInstall, "install", "global");
const globalNm = join(globalDir, "node_modules");
const env = globalEnv(root, bunInstall);
const link = await run(env, join(root, "lib"), "link");
expect(link.stderr).not.toContain("error:");
expect(link.stdout).toContain('Success! Registered "lib"');
expect(link.exitCode).toBe(0);
const add = await run(env, root, "add", "-g", join(root, "gpkg"));
expect(add.stderr).not.toContain("error:");
expect(add.exitCode).toBe(0);
expect(existsSync(join(globalDir, "bun.lock"))).toBeTrue();
expect(readdirSync(globalNm).sort()).toStrictEqual(["gpkg", "lib"]);
expect(existsSync(join(globalNm, "lib", "package.json"))).toBeTrue();
for (const args of [
["prune", "-g"],
["prune", "--dry-run", "--global"],
["prune", "-g", "--silent"],
]) {
const { stdout, stderr, exitCode } = await run(env, root, ...args);
expect(normalizeBunSnapshot(stderr)).toMatchInlineSnapshot(`
"error: --global cannot be used with bun prune
note: the global folder is also the 'bun link' registry, and bun.lock does not list linked packages"
`);
expect(stdout).toBe("");
expect(exitCode).toBe(1);
}
expect(readdirSync(globalNm).sort()).toStrictEqual(["gpkg", "lib"]);
expect(existsSync(join(globalNm, "lib", "package.json"))).toBeTrue();
const fresh = join(root, ".fresh");
const { stderr, exitCode } = await run(globalEnv(root, fresh), root, "prune", "-g");
expect(stderr).toContain("error: --global cannot be used with bun prune");
expect(exitCode).toBe(1);
expect(existsSync(fresh)).toBeFalse();
},
);