Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
8f671c6bf0 fix(install): allow --frozen-lockfile on pruned monorepo workspaces
When a monorepo is pruned (e.g. by `turbo prune --docker`), the
resulting directory has fewer workspaces but keeps the original lockfile.
Previously, `bun install --frozen-lockfile` would fail because it
detected removed workspace dependencies as lockfile changes.

The lockfile is a superset of what's needed — all dependency versions
for the remaining workspaces are already pinned. Now, when the only
diffs are removals (no additions, updates, or other changes), the
frozen-lockfile check treats the lockfile as valid.

Closes #26973

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-12 15:56:32 +00:00
3 changed files with 157 additions and 0 deletions

View File

@@ -193,6 +193,14 @@ pub fn installWithManager(
had_any_diffs = manager.summary.hasDiffs();
// When --frozen-lockfile is set and the only diffs are removed
// workspace dependencies (e.g. from `turbo prune`), the lockfile
// is a superset of what's needed. Treat this as no diff so the
// lockfile stays intact and the frozen-lockfile check passes.
if (had_any_diffs and manager.options.enable.frozen_lockfile and manager.summary.hasOnlyRemovals()) {
had_any_diffs = false;
}
if (!had_any_diffs) {
// always grab latest scripts for root package
var builder_ = manager.lockfile.stringBuilder();

View File

@@ -551,6 +551,19 @@ pub fn Package(comptime SemverIntType: type) type {
this.removed_trusted_dependencies.count() > 0 or
this.patched_dependencies_changed;
}
/// Returns true when the only difference is removed dependencies
/// (no additions, updates, or other changes). This is the case when
/// workspaces have been pruned (e.g., by `turbo prune`) — the lockfile
/// is a superset of what's needed and should be accepted by --frozen-lockfile.
pub inline fn hasOnlyRemovals(this: Summary) bool {
return this.remove > 0 and
this.add == 0 and this.update == 0 and
!this.overrides_changed and !this.catalogs_changed and
this.added_trusted_dependencies.count() == 0 and
this.removed_trusted_dependencies.count() == 0 and
!this.patched_dependencies_changed;
}
};
pub fn generate(

View File

@@ -0,0 +1,136 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDirWithFiles } from "harness";
import { join } from "path";
// Test for https://github.com/oven-sh/bun/issues/26973
// `bun install --frozen-lockfile` should succeed on a pruned monorepo
// (e.g. output of `turbo prune --docker`) where some workspaces are removed
// but the lockfile is a superset of what's needed.
test("frozen-lockfile succeeds on pruned monorepo with subset of workspaces", async () => {
// Step 1: Create a full monorepo and generate a lockfile
const fullDir = tempDirWithFiles("full-monorepo", {
"package.json": JSON.stringify({
name: "test-monorepo",
workspaces: ["packages/*", "apps/*"],
}),
"packages/shared/package.json": JSON.stringify({
name: "@test/shared",
version: "1.0.0",
}),
"packages/utils/package.json": JSON.stringify({
name: "@test/utils",
version: "1.0.0",
}),
"apps/web/package.json": JSON.stringify({
name: "@test/web",
version: "1.0.0",
dependencies: {
"@test/shared": "workspace:*",
},
}),
"apps/api/package.json": JSON.stringify({
name: "@test/api",
version: "1.0.0",
dependencies: {
"@test/utils": "workspace:*",
},
}),
});
// Generate a lockfile with the full set of workspaces
const installResult = Bun.spawnSync({
cmd: [bunExe(), "install", "--save-text-lockfile", "--ignore-scripts"],
cwd: fullDir,
env: bunEnv,
});
expect(installResult.exitCode).toBe(0);
const lockfileContent = await Bun.file(join(fullDir, "bun.lock")).text();
// Step 2: Create a pruned monorepo (only @test/web and its dependency @test/shared)
// but use the FULL lockfile from the original monorepo
const prunedDir = tempDirWithFiles("pruned-monorepo", {
"package.json": JSON.stringify({
name: "test-monorepo",
workspaces: ["packages/shared", "apps/web"],
}),
"packages/shared/package.json": JSON.stringify({
name: "@test/shared",
version: "1.0.0",
}),
"apps/web/package.json": JSON.stringify({
name: "@test/web",
version: "1.0.0",
dependencies: {
"@test/shared": "workspace:*",
},
}),
"bun.lock": lockfileContent,
});
// Step 3: Run frozen install on the pruned output — this should succeed
const frozenResult = Bun.spawnSync({
cmd: [bunExe(), "install", "--frozen-lockfile", "--ignore-scripts"],
cwd: prunedDir,
env: bunEnv,
});
const stderr = frozenResult.stderr.toString();
expect(stderr).not.toContain("lockfile had changes, but lockfile is frozen");
expect(frozenResult.exitCode).toBe(0);
});
test("frozen-lockfile still fails when a new workspace is added", async () => {
// This test ensures we don't accidentally make frozen-lockfile too permissive.
// If a workspace is ADDED (not just removed), the frozen lockfile check
// should still fail.
const fullDir = tempDirWithFiles("frozen-fail-monorepo", {
"package.json": JSON.stringify({
name: "test-monorepo",
workspaces: ["packages/*"],
}),
"packages/shared/package.json": JSON.stringify({
name: "@test/shared",
version: "1.0.0",
}),
});
// Generate a lockfile with only @test/shared
const installResult = Bun.spawnSync({
cmd: [bunExe(), "install", "--save-text-lockfile", "--ignore-scripts"],
cwd: fullDir,
env: bunEnv,
});
expect(installResult.exitCode).toBe(0);
const lockfileContent = await Bun.file(join(fullDir, "bun.lock")).text();
// Now create a directory with an ADDITIONAL workspace not in the lockfile
const modifiedDir = tempDirWithFiles("frozen-fail-modified", {
"package.json": JSON.stringify({
name: "test-monorepo",
workspaces: ["packages/*"],
}),
"packages/shared/package.json": JSON.stringify({
name: "@test/shared",
version: "1.0.0",
}),
"packages/extra/package.json": JSON.stringify({
name: "@test/extra",
version: "1.0.0",
}),
"bun.lock": lockfileContent,
});
// This should fail because a new workspace was added that's not in the lockfile
const frozenResult = Bun.spawnSync({
cmd: [bunExe(), "install", "--frozen-lockfile", "--ignore-scripts"],
cwd: modifiedDir,
env: bunEnv,
});
const stderr = frozenResult.stderr.toString();
expect(stderr).toContain("lockfile had changes, but lockfile is frozen");
expect(frozenResult.exitCode).not.toBe(0);
});