Compare commits

...

22 Commits

Author SHA1 Message Date
Dylan Conway
16de9262d5 Merge branch 'main' into dylan/nohoist 2025-11-14 16:45:04 -08:00
Dylan Conway
b69bdb4229 Merge branch 'main' into dylan/nohoist 2025-11-12 20:22:30 -08:00
Dylan Conway
9a8cb96d8e comment 2025-11-12 20:00:30 -08:00
autofix-ci[bot]
450bfd7bdf [autofix.ci] apply automated fixes 2025-11-13 03:35:47 +00:00
Dylan Conway
eef5bf275c update 2025-11-12 19:33:45 -08:00
Dylan Conway
08604b7363 Update src/install/lockfile/Package.zig
Co-authored-by: taylor.fish <contact@taylor.fish>
2025-11-12 19:21:20 -08:00
Dylan Conway
92187b9477 Update src/install/lockfile.zig
Co-authored-by: taylor.fish <contact@taylor.fish>
2025-11-12 19:21:11 -08:00
Dylan Conway
57cc6459de Update src/install/lockfile.zig
Co-authored-by: taylor.fish <contact@taylor.fish>
2025-11-12 19:20:53 -08:00
Dylan Conway
e5b196d26d update 2025-11-12 12:44:36 -08:00
Dylan Conway
4376400098 Merge branch 'main' into dylan/nohoist 2025-11-12 10:38:11 -08:00
Dylan Conway
50a64ff507 Merge branch 'main' into dylan/nohoist 2025-11-11 17:56:26 -08:00
autofix-ci[bot]
fad51add15 [autofix.ci] apply automated fixes 2025-11-12 01:51:24 +00:00
Dylan Conway
c2851bf51f add test for cycles 2025-11-11 17:49:31 -08:00
Dylan Conway
21619956e8 not always hoisted 2025-11-11 17:22:20 -08:00
Dylan Conway
031d32929d cycle check 2025-11-11 17:11:08 -08:00
Dylan Conway
484bdec725 workspaces always hoist 2025-11-11 13:26:46 -08:00
Dylan Conway
92163a499c update 2025-11-10 18:10:05 -08:00
autofix-ci[bot]
b91ef9bb97 [autofix.ci] apply automated fixes 2025-11-11 01:49:06 +00:00
Dylan Conway
bf6bdfe51d begin testing 2025-11-10 17:47:26 -08:00
Dylan Conway
d086756e1a Merge branch 'main' into dylan/nohoist 2025-11-10 14:46:28 -08:00
autofix-ci[bot]
6570cb705b [autofix.ci] apply automated fixes 2025-11-08 04:20:29 +00:00
Dylan Conway
2d02d7fc68 update 2025-11-07 20:18:27 -08:00
6 changed files with 202 additions and 15 deletions

View File

@@ -1,3 +1,5 @@
/// PackageManager is a singleton and is not deinitialized on program exit.
/// Most of these fields (like `nohoist_patterns`) will stay alive forever.
cache_directory_: ?std.fs.Dir = null,
cache_directory_path: stringZ = "",
root_dir: *Fs.FileSystem.DirEntry,
@@ -135,6 +137,8 @@ active_lifecycle_scripts: LifecycleScriptSubprocess.List,
last_reported_slow_lifecycle_script_at: u64 = 0,
cached_tick_for_slow_lifecycle_script_logging: u64 = 0,
nohoist_patterns: bun.collections.ArrayListDefault([]const u8) = .init(),
/// Corresponds to possible commands from the CLI.
pub const Subcommand = enum {
install,

View File

@@ -951,15 +951,20 @@ pub fn hoist(
Tree.root_dep_id,
Tree.invalid_id,
method,
if (comptime method == .filter) .init() else {},
&builder,
);
// This goes breadth-first
while (builder.queue.readItem()) |item| {
var subpath = item.subpath;
defer bun.memory.deinit(&subpath);
try builder.list.items(.tree)[item.tree_id].processSubtree(
item.dependency_id,
item.hoist_root_id,
method,
subpath,
&builder,
);
}

View File

@@ -1578,6 +1578,28 @@ pub fn Package(comptime SemverIntType: type) type {
if (json.get("workspaces")) |workspaces_expr| {
lockfile.catalogs.parseCount(lockfile, workspaces_expr, &string_builder);
if (workspaces_expr.get("nohoist")) |nohoist_expr| {
if (!nohoist_expr.isArray()) {
try log.addError(source, nohoist_expr.loc, "Expected an array of strings");
return error.InvalidPackageJSON;
}
const nohoist_arr = nohoist_expr.data.e_array;
var nohoist_patterns: bun.collections.ArrayListDefault([]const u8) = try .initCapacity(nohoist_arr.items.len);
for (nohoist_arr.items.slice()) |*nohoist_item| {
if (!nohoist_item.isString()) {
try log.addError(source, nohoist_item.loc, "Expected a string pattern");
return error.InvalidPackageJSON;
}
nohoist_patterns.appendAssumeCapacity(try nohoist_item.data.e_string.stringCloned(bun.default_allocator));
}
pm.nohoist_patterns = nohoist_patterns;
}
}
// Count catalog strings in top-level package.json as well, since parseAppend

View File

@@ -249,13 +249,32 @@ pub fn Builder(comptime method: BuilderMethod) type {
lockfile: *const Lockfile,
// unresolved optional peers that might resolve later. if they do we will want to assign
// builder.resolutions[peer.dep_id] to the resolved pkg_id.
pending_optional_peers: std.AutoHashMap(PackageNameHash, bun.collections.ArrayListDefault(DependencyID)),
pending_optional_peers: std.AutoHashMap(PackageNameHash, ArrayListDefault(DependencyID)),
manager: if (method == .filter) *const PackageManager else void,
sort_buf: std.ArrayListUnmanaged(DependencyID) = .{},
workspace_filters: if (method == .filter) []const WorkspaceFilter else void = if (method == .filter) &.{},
install_root_dependencies: if (method == .filter) bool else void,
packages_to_install: if (method == .filter) ?[]const PackageID else void,
pub const FillItem = struct {
tree_id: Tree.Id,
dependency_id: DependencyID,
/// If valid, dependencies will not hoist
/// beyond this tree if they're in a subtree
hoist_root_id: Tree.Id,
/// Relative path of the current tree. Each component is a package
/// name or part of the package name (if the name is scoped) leading
/// to the tree. Example values:
/// "" (the root subpath is empty)
/// "react/loose-envify" (react -> loose-envify)
/// "@types/bun/bun-types/@types/node" (@types/bun -> bun-types -> @types/node)
subpath: Subpath(method),
};
pub const TreeFiller = bun.LinearFifo(FillItem, .Dynamic);
pub fn maybeReportError(this: *@This(), comptime fmt: string, args: anytype) void {
this.log.addErrorFmt(null, logger.Loc.Empty, this.allocator, fmt, args) catch {};
}
@@ -429,7 +448,7 @@ pub fn isFilteredDependencyOrWorkspace(
},
};
switch (bun.glob.match(pattern, name_or_path)) {
switch (glob.match(pattern, name_or_path)) {
.match, .negate_match => workspace_matched = true,
.negate_no_match => {
@@ -447,11 +466,19 @@ pub fn isFilteredDependencyOrWorkspace(
return !workspace_matched;
}
fn Subpath(comptime method: BuilderMethod) type {
return switch (method) {
.filter => ArrayListDefault(u8),
.resolvable => void,
};
}
pub fn processSubtree(
this: *const Tree,
dependency_id: DependencyID,
hoist_root_id: Tree.Id,
comptime method: BuilderMethod,
subpath: Subpath(method),
builder: *Builder(method),
) SubtreeError!void {
const parent_pkg_id = switch (dependency_id) {
@@ -496,8 +523,11 @@ pub fn processSubtree(
);
for (builder.sort_buf.items) |dep_id| {
const dependency = builder.dependencies[dep_id];
const pkg_id = builder.resolutions[dep_id];
var dep_subpath: ArrayListDefault(u8) = .init();
// filter out disabled dependencies
if (comptime method == .filter) {
if (isFilteredDependencyOrWorkspace(
@@ -534,9 +564,48 @@ pub fn processSubtree(
}
}
const dependency = builder.dependencies[dep_id];
const hoisted: HoistDependencyResult = hoisted: {
if (comptime method == .filter) {
// not filtered, but does it match a nohoist pattern?
if (!builder.manager.nohoist_patterns.isEmpty()) try_nohoist: {
const string_buf = builder.lockfile.buffers.string_bytes.items;
try dep_subpath.ensureTotalCapacity(subpath.items().len + @intFromBool(!subpath.isEmpty()) + dependency.name.len());
dep_subpath.appendSliceAssumeCapacity(subpath.items());
if (!subpath.isEmpty()) {
dep_subpath.appendAssumeCapacity('/');
}
dep_subpath.appendSliceAssumeCapacity(dependency.name.slice(string_buf));
if (dependency.version.tag == .workspace) {
break :try_nohoist;
}
for (builder.manager.nohoist_patterns.items()) |nohoist_pattern| {
if (glob.match(nohoist_pattern, dep_subpath.items()).matches()) {
// make sure it's not circular
var curr = trees[next.id].parent;
while (curr != invalid_id) {
const curr_dep_id = trees[curr].dependency_id;
const curr_pkg_id = switch (curr_dep_id) {
root_dep_id => 0,
else => builder.resolutions[curr_dep_id],
};
var curr_resolutions = builder.resolution_lists[curr_pkg_id];
for (curr_resolutions.begin()..curr_resolutions.end()) |tree_dep_id| {
const res_id = builder.resolutions[tree_dep_id];
if (res_id == pkg_id) {
break :try_nohoist;
}
}
curr = trees[curr].parent;
}
break :hoisted .{ .placement = .{ .id = next.id } };
}
}
}
}
// don't hoist if it's a folder dependency or a bundled dependency.
if (dependency.behavior.isBundled()) {
@@ -617,6 +686,7 @@ pub fn processSubtree(
.tree_id = replace.id,
.dependency_id = dep_id,
.hoist_root_id = hoist_root_id,
.subpath = if (comptime method == .filter) dep_subpath else {},
});
}
},
@@ -640,6 +710,7 @@ pub fn processSubtree(
// if it's bundled, start a new hoist root
.hoist_root_id = if (dest.bundled) dest.id else hoist_root_id,
.subpath = if (comptime method == .filter) dep_subpath else {},
});
}
},
@@ -763,17 +834,6 @@ fn hoistDependency(
return .{ .placement = .{ .id = this.id } }; // 2
}
pub const FillItem = struct {
tree_id: Tree.Id,
dependency_id: DependencyID,
/// If valid, dependencies will not hoist
/// beyond this tree if they're in a subtree
hoist_root_id: Tree.Id,
};
pub const TreeFiller = bun.LinearFifo(FillItem, .Dynamic);
const string = []const u8;
const stringZ = [:0]const u8;
@@ -786,7 +846,9 @@ const OOM = bun.OOM;
const Output = bun.Output;
const Path = bun.path;
const assert = bun.assert;
const glob = bun.glob;
const logger = bun.logger;
const ArrayListDefault = bun.collections.ArrayListDefault;
const Bitset = bun.bit_set.DynamicBitSetUnmanaged;
const String = bun.Semver.String;

View File

@@ -0,0 +1,90 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { exists } from "fs/promises";
import { VerdaccioRegistry, bunEnv, readdirSorted, runBunInstall } from "harness";
import { join } from "path";
var registry = new VerdaccioRegistry();
beforeAll(async () => {
await registry.start();
});
afterAll(() => {
registry.stop();
});
describe("workspaces.nohoist", () => {
test("basic", async () => {
const { packageDir } = await registry.createTestDir({
files: {
"package.json": JSON.stringify({
name: "basic-nohoist",
workspaces: {
nohoist: ["one-dep/no-deps"],
},
dependencies: {
"one-dep": "1.0.0",
},
}),
},
});
await runBunInstall(bunEnv, packageDir);
expect(await readdirSorted(join(packageDir, "node_modules"))).toEqual(["one-dep"]);
});
test("can keep package in workspace node_modules", async () => {
const { packageDir } = await registry.createTestDir({
files: {
"package.json": JSON.stringify({
name: "workspace-nohoist",
workspaces: {
packages: ["packages/*"],
nohoist: ["**/one-dep"],
},
dependencies: {
"a-dep": "1.0.1",
},
}),
"packages/pkg1/package.json": JSON.stringify({
name: "pkg1",
dependencies: {
"one-dep": "1.0.0",
},
}),
},
});
await runBunInstall(bunEnv, packageDir, { linker: "hoisted" });
expect(await readdirSorted(join(packageDir, "node_modules"))).toEqual(["a-dep", "no-deps", "pkg1"]);
expect(await readdirSorted(join(packageDir, "packages/pkg1/node_modules"))).toEqual(["one-dep"]);
});
test("handles cycles", async () => {
const { packageDir } = await registry.createTestDir({
files: {
"package.json": JSON.stringify({
name: "cycles",
workspaces: {
nohoist: ["**"],
},
dependencies: {
"a-dep-b": "1.0.0",
},
}),
},
});
await runBunInstall(bunEnv, packageDir, { linker: "hoisted" });
expect(
await Promise.all([
readdirSorted(join(packageDir, "node_modules")),
readdirSorted(join(packageDir, "node_modules/a-dep-b/node_modules")),
exists(join(packageDir, "node_modules/a-dep-b/node_modules/b-dep-a/node_modules")),
]),
).toEqual([["a-dep-b"], ["b-dep-a"], false]);
});
});

View File

@@ -1225,6 +1225,7 @@ export async function runBunInstall(
saveTextLockfile?: boolean;
packages?: string[];
verbose?: boolean;
linker?: "hoisted" | "isolated";
} = {},
) {
const production = options?.production ?? false;
@@ -1235,6 +1236,9 @@ export async function runBunInstall(
if (production) {
args.push("--production");
}
if (options?.linker) {
args.push(`--linker=${options?.linker}`);
}
if (options?.frozenLockfile) {
args.push("--frozen-lockfile");
}