Compare commits

...

5 Commits

Author SHA1 Message Date
Claude Bot
a6d10743df Fix banned word: replace std.StringHashMap with bun.StringHashMap
Use bun.StringHashMap instead of std.StringHashMap for better performance
as it has a faster eql function.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-20 22:43:07 +00:00
autofix-ci[bot]
1aa1cf621d [autofix.ci] apply automated fixes 2025-08-20 22:00:58 +00:00
Claude Bot
3ad7d257d8 Add documentation for bun prune command
Add comprehensive documentation at docs/cli/prune.md covering:
- Usage examples and common scenarios
- Error handling and troubleshooting
- Comparison with npm/pnpm prune behavior
- Technical details and related commands
- When and why to use bun prune

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-20 21:59:10 +00:00
autofix-ci[bot]
e538188efa [autofix.ci] apply automated fixes 2025-08-20 21:58:20 +00:00
Claude Bot
0519821c31 Implement bun prune command
Add `bun prune` command that removes extraneous packages from node_modules
that are not declared as dependencies in package.json or referenced in the lockfile.

This implementation follows the behavior of `npm prune` and `pnpm prune`:
- Removes packages not listed in dependencies/devDependencies
- Handles both regular packages and scoped packages (@scope/package)
- Preserves legitimate packages that are in the lockfile
- Provides helpful error messages for missing package.json/lockfile
- Gracefully handles missing node_modules directory

Features:
- CLI integration with help text via `bun prune --help`
- Progress reporting when removing packages
- Proper error handling and user feedback
- Cross-platform compatibility using Bun's filesystem utilities

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-20 21:55:12 +00:00
5 changed files with 611 additions and 1 deletions

View File

@@ -471,6 +471,7 @@ src/cli/pm_trusted_command.zig
src/cli/pm_version_command.zig
src/cli/pm_view_command.zig
src/cli/pm_why_command.zig
src/cli/prune_command.zig
src/cli/publish_command.zig
src/cli/remove_command.zig
src/cli/run_command.zig

176
docs/cli/prune.md Normal file
View File

@@ -0,0 +1,176 @@
---
name: bun prune
---
The `bun prune` command removes extraneous packages from your project's `node_modules` directory. Extraneous packages are those that are installed but not declared as dependencies in your `package.json` or referenced in your lockfile.
```bash
$ bun prune
```
This command is useful for:
- Cleaning up packages that were manually installed but never added to `package.json`
- Removing leftover packages after removing dependencies
- Ensuring your `node_modules` directory only contains necessary packages
- Reducing disk space usage by removing unused packages
## How it works
`bun prune` analyzes your project's lockfile (`bun.lockb` or `bun.lock`) to determine which packages should be present in `node_modules`, then removes any packages that aren't referenced.
The command:
1. Reads your project's lockfile to identify legitimate packages
2. Scans the `node_modules` directory for installed packages
3. Removes any packages not found in the lockfile
4. Handles both regular packages (`package-name`) and scoped packages (`@scope/package-name`)
5. Reports the number of packages removed
## Usage
```bash
# Remove extraneous packages
$ bun prune
# Show help
$ bun prune --help
```
## Examples
### Basic usage
```bash
$ bun prune
bun prune v1.2.21
Removing lodash
Removing @types/unused-package
Removed 2 extraneous packages
Pruned extraneous packages
```
### No extraneous packages
```bash
$ bun prune
bun prune v1.2.21
Pruned extraneous packages
```
### Missing lockfile
```bash
$ bun prune
bun prune v1.2.21
error: Lockfile not found
```
## When to use `bun prune`
### After removing dependencies
When you remove a dependency with `bun remove`, the package is removed from `package.json` and the lockfile, but may still exist in `node_modules`. Use `bun prune` to clean it up:
```bash
$ bun remove lodash
$ bun prune # Remove lodash from node_modules if still present
```
### After manual package installation
If you manually installed packages without adding them to `package.json`:
```bash
$ cd node_modules && npm install some-package # Manual install
$ bun prune # Will remove some-package since it's not in package.json
```
### Before deployment
Clean up your dependencies before deploying to ensure only necessary packages are included:
```bash
$ bun install
$ bun prune
$ bun run build
```
### Disk space cleanup
Remove unused packages to free up disk space:
```bash
$ bun prune
Removed 15 extraneous packages
# Freed up several MB of disk space
```
## Error handling
### Missing package.json
```bash
$ bun prune
error: No package.json was found for directory "/path/to/project"
Note: Run "bun init" to initialize a project
```
### Missing lockfile
```bash
$ bun prune
error: Lockfile not found
```
Run `bun install` first to generate a lockfile:
```bash
$ bun install
$ bun prune
```
### No node_modules directory
If `node_modules` doesn't exist, `bun prune` succeeds without doing anything:
```bash
$ rm -rf node_modules
$ bun prune
bun prune v1.2.21
Pruned extraneous packages
```
## Comparison with other package managers
| Command | Behavior |
| ------------ | --------------------------------------------------------------- |
| `npm prune` | Removes extraneous packages not in `package.json` dependencies |
| `pnpm prune` | Removes orphaned packages not referenced by any dependency tree |
| `bun prune` | Removes packages not referenced in the lockfile |
`bun prune` is most similar to `npm prune` but uses Bun's lockfile as the source of truth rather than just `package.json`.
## Flags
Currently, `bun prune` doesn't support additional flags beyond `--help`. The command operates on the current working directory and uses the default log level for output.
## Technical details
- **Lockfile dependency**: Requires a valid lockfile (`bun.lockb` or `bun.lock`)
- **Package detection**: Scans `node_modules` directory structure
- **Scope handling**: Properly handles scoped packages under `@scope/` directories
- **Safety**: Only removes packages not found in the lockfile
- **Performance**: Efficiently processes large `node_modules` directories
## Related commands
- [`bun install`](/docs/cli/install) - Install dependencies and generate lockfile
- [`bun remove`](/docs/cli/remove) - Remove dependencies from package.json
- [`bun add`](/docs/cli/add) - Add dependencies to package.json
- [`bun pm cache rm`](/docs/cli/pm#bun-pm-cache-rm) - Clear the global package cache

View File

@@ -91,6 +91,7 @@ pub const PackCommand = @import("./cli/pack_command.zig").PackCommand;
pub const AuditCommand = @import("./cli/audit_command.zig").AuditCommand;
pub const InitCommand = @import("./cli/init_command.zig").InitCommand;
pub const WhyCommand = @import("./cli/why_command.zig").WhyCommand;
pub const PruneCommand = @import("./cli/prune_command.zig").PruneCommand;
pub const Arguments = @import("./cli/Arguments.zig");
@@ -177,6 +178,7 @@ pub const HelpCommand = struct {
\\ <b><blue>outdated<r> Display latest versions of outdated dependencies
\\ <b><blue>link<r> <d>[\<package\>]<r> Register or link a local npm package
\\ <b><blue>unlink<r> Unregister a local npm package
\\ <b><blue>prune<r> Remove extraneous packages from node_modules
\\ <b><blue>publish<r> Publish a package to the npm registry
\\ <b><blue>patch <d>\<pkg\><r> Prepare a package for patching
\\ <b><blue>pm <d>\<subcommand\><r> Additional package management utilities
@@ -585,7 +587,7 @@ pub const Command = struct {
RootCommandMatcher.case("login") => .ReservedCommand,
RootCommandMatcher.case("logout") => .ReservedCommand,
RootCommandMatcher.case("whoami") => .ReservedCommand,
RootCommandMatcher.case("prune") => .ReservedCommand,
RootCommandMatcher.case("prune") => .PruneCommand,
RootCommandMatcher.case("list") => .ReservedCommand,
RootCommandMatcher.case("why") => .WhyCommand,
@@ -752,6 +754,11 @@ pub const Command = struct {
try WhyCommand.exec(ctx);
return;
},
.PruneCommand => {
const ctx = try Command.init(allocator, log, .PruneCommand);
try PruneCommand.exec(ctx);
return;
},
.BunxCommand => {
const ctx = try Command.init(allocator, log, .BunxCommand);
@@ -925,6 +932,7 @@ pub const Command = struct {
PublishCommand,
AuditCommand,
WhyCommand,
PruneCommand,
/// Used by crash reports.
///
@@ -962,6 +970,7 @@ pub const Command = struct {
.PublishCommand => 'k',
.AuditCommand => 'A',
.WhyCommand => 'W',
.PruneCommand => 'X',
};
}
@@ -1274,6 +1283,21 @@ pub const Command = struct {
Output.pretty(intro_text, .{});
Output.flush();
},
.PruneCommand => {
const intro_text =
\\<b>Usage<r>: <b><green>bun prune<r> <cyan>[flags]<r>
\\Remove extraneous packages from node_modules that are not declared as dependencies
\\
\\<b>Examples:<r>
\\ <d>$<r> <b><green>bun prune<r>
\\
\\Full documentation is available at <magenta>https://bun.sh/docs/cli/prune<r>
\\
;
Output.pretty(intro_text, .{});
Output.flush();
},
else => {
HelpCommand.printWithReason(.explicit);
},

174
src/cli/prune_command.zig Normal file
View File

@@ -0,0 +1,174 @@
pub const PruneCommand = struct {
pub fn exec(ctx: Command.Context) !void {
const cli = try PackageManager.CommandLineArguments.parse(ctx.allocator, .install);
var manager, const original_cwd = PackageManager.init(ctx, cli, .install) catch |err| {
if (err == error.MissingPackageJSON) {
var cwd_buf: bun.PathBuffer = undefined;
if (bun.getcwd(&cwd_buf)) |cwd| {
Output.errGeneric("No package.json was found for directory \"{s}\"", .{cwd});
} else |_| {
Output.errGeneric("No package.json was found", .{});
}
Output.note("Run \"bun init\" to initialize a project", .{});
Global.exit(1);
}
return err;
};
defer ctx.allocator.free(original_cwd);
if (manager.options.shouldPrintCommandName()) {
Output.prettyln("<r><b>bun prune <r><d>v" ++ Global.package_json_version_with_sha ++ "<r>\n", .{});
Output.flush();
}
// Load the lockfile to understand which packages should be kept
const load_lockfile = manager.lockfile.loadFromCwd(manager, ctx.allocator, ctx.log, true);
if (load_lockfile == .not_found) {
if (manager.options.log_level != .silent) {
Output.errGeneric("Lockfile not found", .{});
}
Global.exit(1);
}
if (load_lockfile == .err) {
if (manager.options.log_level != .silent) {
Output.errGeneric("Error loading lockfile: {s}", .{@errorName(load_lockfile.err.value)});
}
Global.exit(1);
}
const lockfile = load_lockfile.ok.lockfile;
try pruneNodeModules(ctx.allocator, manager, lockfile);
if (manager.options.log_level != .silent) {
Output.prettyln("<r><green>Pruned extraneous packages<r>", .{});
Output.flush();
}
}
fn pruneNodeModules(allocator: std.mem.Allocator, manager: *PackageManager, lockfile: *Lockfile) !void {
// Get the current working directory
var cwd_buf: bun.PathBuffer = undefined;
const cwd = bun.getcwd(&cwd_buf) catch {
Output.prettyErrorln("<r><red>error<r>: Could not get current working directory", .{});
Global.exit(1);
};
// Construct node_modules path
var node_modules_buf: bun.PathBuffer = undefined;
const node_modules_path = try std.fmt.bufPrint(&node_modules_buf, "{s}/node_modules", .{cwd});
// Get the list of packages that should exist according to the lockfile
var expected_packages = bun.StringHashMap(void).init(allocator);
defer expected_packages.deinit();
// Add all packages that are actually used/installed
// We'll iterate through the lockfile packages to find what should be installed
const dependencies = lockfile.buffers.dependencies.items;
const string_bytes = lockfile.buffers.string_bytes.items;
const packages_slice = lockfile.packages.slice();
const package_names = packages_slice.items(.name);
// Add all packages that are in the lockfile
for (package_names) |package_name_string| {
const package_name = package_name_string.slice(string_bytes);
try expected_packages.put(package_name, {});
}
// Also check for any hoisted dependencies
for (lockfile.buffers.hoisted_dependencies.items) |hoisted_dep| {
if (hoisted_dep < dependencies.len) {
const dep = dependencies[hoisted_dep];
const package_name = dep.name.slice(string_bytes);
try expected_packages.put(package_name, {});
}
}
// Open node_modules directory
var node_modules_dir = std.fs.openDirAbsolute(node_modules_path, .{ .iterate = true }) catch |err| switch (err) {
error.FileNotFound => {
// No node_modules directory, nothing to prune
return;
},
else => return err,
};
defer node_modules_dir.close();
var iterator = node_modules_dir.iterate();
var pruned_count: u32 = 0;
while (try iterator.next()) |entry| {
if (entry.kind != .directory) continue;
const dirname = entry.name;
// Skip .bin directory and other dot directories
if (strings.hasPrefix(dirname, ".")) continue;
// Handle scoped packages (@scope/package)
if (strings.hasPrefix(dirname, "@")) {
// This is a scope directory, iterate through it
var scope_dir = node_modules_dir.openDir(dirname, .{ .iterate = true }) catch continue;
defer scope_dir.close();
var scope_iterator = scope_dir.iterate();
while (try scope_iterator.next()) |scope_entry| {
if (scope_entry.kind != .directory) continue;
const scoped_package_name = try std.fmt.allocPrint(allocator, "{s}/{s}", .{ dirname, scope_entry.name });
defer allocator.free(scoped_package_name);
if (!expected_packages.contains(scoped_package_name)) {
// This package should be removed
if (manager.options.log_level.showProgress()) {
Output.prettyln("<r><d>Removing<r> {s}", .{scoped_package_name});
}
scope_dir.deleteTree(scope_entry.name) catch |err| {
if (manager.options.log_level != .silent) {
Output.err(err, "Failed to remove {s}", .{scoped_package_name});
}
continue;
};
pruned_count += 1;
}
}
} else {
// Regular package
if (!expected_packages.contains(dirname)) {
// This package should be removed
if (manager.options.log_level.showProgress()) {
Output.prettyln("<r><d>Removing<r> {s}", .{dirname});
}
node_modules_dir.deleteTree(dirname) catch |err| {
if (manager.options.log_level != .silent) {
Output.err(err, "Failed to remove {s}", .{dirname});
}
continue;
};
pruned_count += 1;
}
}
}
if (manager.options.log_level != .silent and pruned_count > 0) {
Output.prettyln("<r><green>Removed {d} extraneous package{s}<r>", .{ pruned_count, if (pruned_count == 1) "" else "s" });
}
}
};
const Lockfile = @import("../install/lockfile.zig");
const fs = @import("../fs.zig");
const std = @import("std");
const Command = @import("../cli.zig").Command;
const Install = @import("../install/install.zig");
const PackageManager = Install.PackageManager;
const bun = @import("bun");
const Global = bun.Global;
const Output = bun.Output;
const strings = bun.strings;

235
test/cli/prune.test.ts Normal file
View File

@@ -0,0 +1,235 @@
import { expect, test } from "bun:test";
import { mkdirSync, rmSync, writeFileSync } from "fs";
import { bunEnv, bunExe, tempDirWithFiles } from "harness";
import { join } from "path";
test("bun prune removes extraneous packages", async () => {
const tempDir = tempDirWithFiles("prune-test", {
"package.json": JSON.stringify({
name: "test-project",
version: "1.0.0",
dependencies: {
"react": "^18.0.0",
},
}),
});
// First, install dependencies to create a valid lockfile
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: tempDir,
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
// Manually create an extraneous package in node_modules
const nodeModulesPath = join(tempDir, "node_modules");
const extraneousPackagePath = join(nodeModulesPath, "extraneous-package");
mkdirSync(extraneousPackagePath, { recursive: true });
writeFileSync(
join(extraneousPackagePath, "package.json"),
JSON.stringify({
name: "extraneous-package",
version: "1.0.0",
}),
);
// Create a scoped extraneous package
const scopedPath = join(nodeModulesPath, "@scope");
mkdirSync(scopedPath, { recursive: true });
const scopedPackagePath = join(scopedPath, "extraneous");
mkdirSync(scopedPackagePath, { recursive: true });
writeFileSync(
join(scopedPackagePath, "package.json"),
JSON.stringify({
name: "@scope/extraneous",
version: "1.0.0",
}),
);
// Run bun prune
await using pruneProc = Bun.spawn({
cmd: [bunExe(), "prune"],
cwd: tempDir,
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [pruneStdout, pruneStderr, pruneExitCode] = await Promise.all([
pruneProc.stdout.text(),
pruneProc.stderr.text(),
pruneProc.exited,
]);
expect(pruneExitCode).toBe(0);
expect(pruneStdout).toMatch(/Removed \d+ extraneous package/);
// Verify the extraneous packages were removed
const extraneousExists = await Bun.file(join(extraneousPackagePath, "package.json")).exists();
const scopedExists = await Bun.file(join(scopedPackagePath, "package.json")).exists();
expect(extraneousExists).toBe(false);
expect(scopedExists).toBe(false);
// Verify legitimate packages remain
const reactExists = await Bun.file(join(nodeModulesPath, "react", "package.json")).exists();
expect(reactExists).toBe(true);
});
test("bun prune with no extraneous packages", async () => {
const tempDir = tempDirWithFiles("prune-test-no-extra", {
"package.json": JSON.stringify({
name: "test-project",
version: "1.0.0",
dependencies: {
"react": "^18.0.0",
},
}),
});
// Install dependencies
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: tempDir,
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
await proc.exited;
// Run bun prune - should succeed with no changes
await using pruneProc = Bun.spawn({
cmd: [bunExe(), "prune"],
cwd: tempDir,
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [pruneStdout, pruneStderr, pruneExitCode] = await Promise.all([
pruneProc.stdout.text(),
pruneProc.stderr.text(),
pruneProc.exited,
]);
expect(pruneExitCode).toBe(0);
expect(pruneStdout).toMatch(/Pruned extraneous packages/);
});
test("bun prune without lockfile fails", async () => {
const tempDir = tempDirWithFiles("prune-test-no-lockfile", {
"package.json": JSON.stringify({
name: "test-project",
version: "1.0.0",
dependencies: {
"react": "^18.0.0",
},
}),
});
// Run bun prune without installing first (no lockfile)
await using pruneProc = Bun.spawn({
cmd: [bunExe(), "prune"],
cwd: tempDir,
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [pruneStdout, pruneStderr, pruneExitCode] = await Promise.all([
pruneProc.stdout.text(),
pruneProc.stderr.text(),
pruneProc.exited,
]);
expect(pruneExitCode).toBe(1);
expect(pruneStderr).toMatch(/Lockfile not found/);
});
test("bun prune without package.json fails", async () => {
const tempDir = tempDirWithFiles("prune-test-no-package-json", {});
// Run bun prune without package.json
await using pruneProc = Bun.spawn({
cmd: [bunExe(), "prune"],
cwd: tempDir,
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [pruneStdout, pruneStderr, pruneExitCode] = await Promise.all([
pruneProc.stdout.text(),
pruneProc.stderr.text(),
pruneProc.exited,
]);
expect(pruneExitCode).toBe(1);
expect(pruneStderr).toMatch(/No package\.json was found/);
});
test("bun prune --help shows help text", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "prune", "--help"],
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
expect(stdout).toMatch(/Usage.*bun prune/);
expect(stdout).toMatch(/Remove extraneous packages/);
});
test("bun prune with no node_modules directory", async () => {
const tempDir = tempDirWithFiles("prune-test-no-node-modules", {
"package.json": JSON.stringify({
name: "test-project",
version: "1.0.0",
dependencies: {
"react": "^18.0.0",
},
}),
});
// Install first to create a valid lockfile
await using installProc = Bun.spawn({
cmd: [bunExe(), "install"],
cwd: tempDir,
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
await installProc.exited;
// Remove node_modules directory after installing but keep lockfile
rmSync(join(tempDir, "node_modules"), { recursive: true, force: true });
// Run bun prune
await using pruneProc = Bun.spawn({
cmd: [bunExe(), "prune"],
cwd: tempDir,
env: bunEnv,
stderr: "pipe",
stdout: "pipe",
});
const [pruneStdout, pruneStderr, pruneExitCode] = await Promise.all([
pruneProc.stdout.text(),
pruneProc.stderr.text(),
pruneProc.exited,
]);
// Should succeed even with no node_modules
expect(pruneExitCode).toBe(0);
});