fix(install): make negative workspace patterns work (#23229)

### What does this PR do?
It's common for monorepos to exclude portions of a large glob

```json
"workspaces": [
  "packages/**",
  "!packages/**/test/**",
  "!packages/**/template/**"
],
```

closes #4621 (note: patterns like `"packages/!(*-standalone)"` will need
to be written `"!packages/*-standalone"`)
### How did you verify your code works?
Manually tested https://github.com/opentiny/tiny-engine, and added a new
workspace test.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Dylan Conway
2025-10-04 00:31:47 -07:00
committed by GitHub
parent d8350c2c59
commit 8d28289407
17 changed files with 129 additions and 80 deletions

View File

@@ -371,7 +371,7 @@ pub fn match(this: *Glob, globalThis: *JSGlobalObject, callframe: *jsc.CallFrame
var str = try str_arg.toSlice(globalThis, arena.allocator());
defer str.deinit();
return jsc.JSValue.jsBoolean(globImpl.match(arena.allocator(), this.pattern, str.slice()).matches());
return jsc.JSValue.jsBoolean(bun.glob.match(this.pattern, str.slice()).matches());
}
pub fn convertUtf8(codepoints: *std.ArrayList(u32), pattern: []const u8) !void {
@@ -390,12 +390,10 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const Arena = std.heap.ArenaAllocator;
const globImpl = @import("../../glob.zig");
const GlobWalker = globImpl.BunGlobWalker;
const bun = @import("bun");
const BunString = bun.String;
const CodepointIterator = bun.strings.UnsignedCodepointIterator;
const GlobWalker = bun.glob.BunGlobWalker;
const jsc = bun.jsc;
const JSGlobalObject = jsc.JSGlobalObject;

View File

@@ -127,9 +127,8 @@ pub const TestRunner = struct {
const file_path = this.files.items(.source)[file_id].path.text;
// Check if the file path matches any of the glob patterns
const glob = @import("../../glob.zig");
for (glob_patterns) |pattern| {
const result = glob.match(this.allocator, pattern, file_path);
const result = bun.glob.match(pattern, file_path);
if (result == .match) return true;
}
return false;

View File

@@ -22,7 +22,7 @@ fn globIgnoreFn(val: []const u8) bool {
return false;
}
const GlobWalker = Glob.GlobWalker(globIgnoreFn, Glob.walk.DirEntryAccessor, false);
const GlobWalker = glob.GlobWalker(globIgnoreFn, glob.walk.DirEntryAccessor, false);
pub fn getCandidatePackagePatterns(allocator: std.mem.Allocator, log: *bun.logger.Log, out_patterns: *std.ArrayList([]u8), workdir_: []const u8, root_buf: *bun.PathBuffer) ![]const u8 {
bun.ast.Expr.Data.Store.create();
@@ -177,7 +177,7 @@ pub const FilterSet = struct {
pub fn matchesPath(self: *const FilterSet, path: []const u8) bool {
for (self.filters) |filter| {
if (Glob.walk.matchImpl(self.allocator, filter.pattern, path).matches()) {
if (glob.match(filter.pattern, path).matches()) {
return true;
}
}
@@ -190,7 +190,7 @@ pub const FilterSet = struct {
.name => name,
.path => path,
};
if (Glob.walk.matchImpl(self.allocator, filter.pattern, target).matches()) {
if (glob.match(filter.pattern, target).matches()) {
return true;
}
}
@@ -275,11 +275,11 @@ pub const PackageFilterIterator = struct {
const string = []const u8;
const Glob = @import("../glob.zig");
const std = @import("std");
const bun = @import("bun");
const Global = bun.Global;
const JSON = bun.json;
const Output = bun.Output;
const glob = bun.glob;
const strings = bun.strings;

View File

@@ -190,14 +190,14 @@ pub const OutdatedCommand = struct {
const abs_res_path = path.joinAbsStringBuf(FileSystem.instance.top_level_dir, &path_buf, &[_]string{res_path}, .posix);
if (!glob.walk.matchImpl(allocator, pattern, strings.withoutTrailingSlash(abs_res_path)).matches()) {
if (!glob.match(pattern, strings.withoutTrailingSlash(abs_res_path)).matches()) {
break :matched false;
}
},
.name => |pattern| {
const name = pkg_names[workspace_pkg_id].slice(string_buf);
if (!glob.walk.matchImpl(allocator, pattern, name).matches()) {
if (!glob.match(pattern, name).matches()) {
break :matched false;
}
},
@@ -403,7 +403,7 @@ pub const OutdatedCommand = struct {
.path => unreachable,
.name => |name_pattern| {
if (name_pattern.len == 0) continue;
if (!glob.walk.matchImpl(bun.default_allocator, name_pattern, dep.name.slice(string_buf)).matches()) {
if (!glob.match(name_pattern, dep.name.slice(string_buf)).matches()) {
break :match false;
}
},

View File

@@ -293,7 +293,7 @@ pub const PackCommand = struct {
// normally the behavior of `index.js` and `**/index.js` are the same,
// but includes require `**/`
const match_path = if (include.flags.@"leading **/") entry_name else entry_subpath;
switch (glob.walk.matchImpl(allocator, include.glob.slice(), match_path)) {
switch (glob.match(include.glob.slice(), match_path)) {
.match => included = true,
.negate_no_match, .negate_match => unreachable,
else => {},
@@ -310,7 +310,7 @@ pub const PackCommand = struct {
const match_path = if (exclude.flags.@"leading **/") entry_name else entry_subpath;
// NOTE: These patterns have `!` so `.match` logic is
// inverted here
switch (glob.walk.matchImpl(allocator, exclude.glob.slice(), match_path)) {
switch (glob.match(exclude.glob.slice(), match_path)) {
.negate_no_match => included = false,
else => {},
}
@@ -1034,7 +1034,7 @@ pub const PackCommand = struct {
// check default ignores that only apply to the root project directory
for (root_default_ignore_patterns) |pattern| {
switch (glob.walk.matchImpl(bun.default_allocator, pattern, entry_name)) {
switch (glob.match(pattern, entry_name)) {
.match => {
// cannot be reversed
return .{
@@ -1061,7 +1061,7 @@ pub const PackCommand = struct {
for (default_ignore_patterns) |pattern_info| {
const pattern, const can_override = pattern_info;
switch (glob.walk.matchImpl(bun.default_allocator, pattern, entry_name)) {
switch (glob.match(pattern, entry_name)) {
.match => {
if (can_override) {
ignored = true;
@@ -1103,7 +1103,7 @@ pub const PackCommand = struct {
if (pattern.flags.dirs_only and entry.kind != .directory) continue;
const match_path = if (pattern.flags.rel_path) rel else entry_name;
switch (glob.walk.matchImpl(bun.default_allocator, pattern.glob.slice(), match_path)) {
switch (glob.match(pattern.glob.slice(), match_path)) {
.match => {
ignored = true;
ignore_pattern = pattern.glob.slice();

View File

@@ -1014,7 +1014,7 @@ pub const CommandLineReporter = struct {
if (opts.ignore_patterns.len > 0) {
var should_ignore = false;
for (opts.ignore_patterns) |pattern| {
if (bun.glob.match(bun.default_allocator, pattern, relative_path).matches()) {
if (bun.glob.match(pattern, relative_path).matches()) {
should_ignore = true;
break;
}
@@ -1134,7 +1134,7 @@ pub const CommandLineReporter = struct {
var should_ignore = false;
for (opts.ignore_patterns) |pattern| {
if (bun.glob.match(bun.default_allocator, pattern, relative_path).matches()) {
if (bun.glob.match(pattern, relative_path).matches()) {
should_ignore = true;
break;
}

View File

@@ -602,14 +602,14 @@ pub const UpdateInteractiveCommand = struct {
const abs_res_path = path.joinAbsStringBuf(FileSystem.instance.top_level_dir, &path_buf, &[_]string{res_path}, .posix);
if (!glob.walk.matchImpl(allocator, pattern, strings.withoutTrailingSlash(abs_res_path)).matches()) {
if (!glob.match(pattern, strings.withoutTrailingSlash(abs_res_path)).matches()) {
break :matched false;
}
},
.name => |pattern| {
const name = pkg_names[workspace_pkg_id].slice(string_buf);
if (!glob.walk.matchImpl(allocator, pattern, name).matches()) {
if (!glob.match(pattern, name).matches()) {
break :matched false;
}
},

View File

@@ -1,8 +1,40 @@
pub const match = @import("./glob/match.zig").match;
pub const walk = @import("./glob/GlobWalker.zig");
pub const match_impl = @import("./glob/match.zig");
pub const match = match_impl.match;
pub const detectGlobSyntax = match_impl.detectGlobSyntax;
pub const GlobWalker = walk.GlobWalker_;
pub const BunGlobWalker = GlobWalker(null, walk.SyscallAccessor, false);
pub const BunGlobWalkerZ = GlobWalker(null, walk.SyscallAccessor, true);
/// Returns true if the given string contains glob syntax,
/// excluding those escaped with backslashes
/// TODO: this doesn't play nicely with Windows directory separator and
/// backslashing, should we just require the user to supply posix filepaths?
pub fn detectGlobSyntax(potential_pattern: []const u8) bool {
// Negation only allowed in the beginning of the pattern
if (potential_pattern.len > 0 and potential_pattern[0] == '!') return true;
// In descending order of how popular the token is
const SPECIAL_SYNTAX: [4]u8 = comptime [_]u8{ '*', '{', '[', '?' };
inline for (SPECIAL_SYNTAX) |token| {
var slice = potential_pattern[0..];
while (slice.len > 0) {
if (std.mem.indexOfScalar(u8, slice, token)) |idx| {
// Check for even number of backslashes preceding the
// token to know that it's not escaped
var i = idx;
var backslash_count: u16 = 0;
while (i > 0 and potential_pattern[i - 1] == '\\') : (i -= 1) {
backslash_count += 1;
}
if (backslash_count % 2 == 0) return true;
slice = slice[idx + 1 ..];
} else break;
}
}
return false;
}
const std = @import("std");

View File

@@ -1324,8 +1324,7 @@ pub fn GlobWalker_(
}
fn matchPatternSlow(this: *GlobWalker, pattern_component: *Component, filepath: []const u8) bool {
return match(
this.arena.allocator(),
return bun.glob.match(
pattern_component.patternSlice(this.pattern),
filepath,
).matches();
@@ -1686,11 +1685,8 @@ pub fn matchWildcardLiteral(literal: []const u8, path: []const u8) bool {
return std.mem.eql(u8, literal, path);
}
pub const matchImpl = match;
const DirIterator = @import("../bun.js/node/dir_iterator.zig");
const ResolvePath = @import("../resolver/resolve_path.zig");
const match = @import("./match.zig").match;
const bun = @import("bun");
const BunString = bun.String;

View File

@@ -33,7 +33,7 @@ const Brace = struct {
};
const BraceStack = bun.BoundedArray(Brace, 10);
pub const MatchResult = enum {
const MatchResult = enum {
no_match,
match,
@@ -116,7 +116,7 @@ const Wildcard = struct {
/// Used to escape any of the special characters above.
// TODO: consider just taking arena and resetting to initial state,
// all usages of this function pass in Arena.allocator()
pub fn match(_: Allocator, glob: []const u8, path: []const u8) MatchResult {
pub fn match(glob: []const u8, path: []const u8) MatchResult {
var state = State{};
var negated = false;
@@ -486,39 +486,6 @@ inline fn skipGlobstars(glob: []const u8, glob_index: *u32) void {
glob_index.* -= 2;
}
/// Returns true if the given string contains glob syntax,
/// excluding those escaped with backslashes
/// TODO: this doesn't play nicely with Windows directory separator and
/// backslashing, should we just require the user to supply posix filepaths?
pub fn detectGlobSyntax(potential_pattern: []const u8) bool {
// Negation only allowed in the beginning of the pattern
if (potential_pattern.len > 0 and potential_pattern[0] == '!') return true;
// In descending order of how popular the token is
const SPECIAL_SYNTAX: [4]u8 = comptime [_]u8{ '*', '{', '[', '?' };
inline for (SPECIAL_SYNTAX) |token| {
var slice = potential_pattern[0..];
while (slice.len > 0) {
if (std.mem.indexOfScalar(u8, slice, token)) |idx| {
// Check for even number of backslashes preceding the
// token to know that it's not escaped
var i = idx;
var backslash_count: u16 = 0;
while (i > 0 and potential_pattern[i - 1] == '\\') : (i -= 1) {
backslash_count += 1;
}
if (backslash_count % 2 == 0) return true;
slice = slice[idx + 1 ..];
} else break;
}
}
return false;
}
const BraceIndex = struct {
start: u32 = 0,
end: u32 = 0,
@@ -526,4 +493,3 @@ const BraceIndex = struct {
const bun = @import("bun");
const std = @import("std");
const Allocator = std.mem.Allocator;

View File

@@ -1064,7 +1064,7 @@ pub fn getWorkspaceFilters(manager: *PackageManager, original_cwd: []const u8) !
},
};
switch (bun.glob.walk.matchImpl(manager.allocator, pattern, path_or_name)) {
switch (bun.glob.match(pattern, path_or_name)) {
.match, .negate_match => install_root_dependencies = true,
.negate_no_match => {

View File

@@ -130,7 +130,7 @@ pub fn processNamesArray(
if (input_path.len == 0 or input_path.len == 1 and input_path[0] == '.') continue;
if (Glob.detectGlobSyntax(input_path)) {
if (glob.detectGlobSyntax(input_path)) {
bun.handleOom(workspace_globs.append(input_path));
continue;
}
@@ -215,7 +215,7 @@ pub fn processNamesArray(
if (workspace_globs.items.len > 0) {
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
for (workspace_globs.items) |user_pattern| {
for (workspace_globs.items, 0..) |user_pattern, i| {
defer _ = arena.reset(.retain_capacity);
const glob_pattern = if (user_pattern.len == 0) "package.json" else brk: {
@@ -253,7 +253,7 @@ pub fn processNamesArray(
return error.GlobError;
}
while (switch (try iter.next()) {
next_match: while (switch (try iter.next()) {
.result => |r| r,
.err => |e| {
log.addErrorFmt(
@@ -271,6 +271,28 @@ pub fn processNamesArray(
// skip root package.json
if (strings.eqlComptime(matched_path, "package.json")) continue;
{
const matched_path_without_package_json = strings.withoutTrailingSlash(strings.withoutSuffixComptime(matched_path, "package.json"));
// check if it's negated by any remaining patterns
for (workspace_globs.items[i + 1 ..]) |next_pattern| {
switch (bun.glob.match(next_pattern, matched_path_without_package_json)) {
.no_match,
.match,
.negate_match,
=> {},
.negate_no_match => {
debug("skipping negated path: {s}, {s}\n", .{
matched_path_without_package_json,
next_pattern,
});
continue :next_match;
},
}
}
}
debug("matched path: {s}, dirname: {s}\n", .{ matched_path, entry_dir });
const abs_package_json_path = Path.joinAbsStringBufZ(
@@ -375,7 +397,7 @@ fn ignoredWorkspacePaths(path: []const u8) bool {
}
return false;
}
const GlobWalker = Glob.GlobWalker(ignoredWorkspacePaths, Glob.walk.SyscallAccessor, false);
const GlobWalker = glob.GlobWalker(ignoredWorkspacePaths, glob.walk.SyscallAccessor, false);
const string = []const u8;
const debug = Output.scoped(.Lockfile, .hidden);
@@ -386,10 +408,10 @@ const Allocator = std.mem.Allocator;
const bun = @import("bun");
const Environment = bun.Environment;
const Glob = bun.glob;
const JSAst = bun.ast;
const Output = bun.Output;
const Path = bun.path;
const glob = bun.glob;
const logger = bun.logger;
const strings = bun.strings;

View File

@@ -408,7 +408,7 @@ pub fn isFilteredDependencyOrWorkspace(
},
};
switch (bun.glob.match(undefined, pattern, name_or_path)) {
switch (bun.glob.match(pattern, name_or_path)) {
.match, .negate_match => workspace_matched = true,
.negate_no_match => {

View File

@@ -150,7 +150,7 @@ pub const PackageJSON = struct {
defer bun.default_allocator.free(normalized_path);
for (glob_list.items) |pattern| {
if (glob.match(bun.default_allocator, pattern, normalized_path).matches()) {
if (glob.match(pattern, normalized_path).matches()) {
return true;
}
}
@@ -166,7 +166,7 @@ pub const PackageJSON = struct {
defer bun.default_allocator.free(normalized_path);
for (mixed.globs.items) |pattern| {
if (glob.match(bun.default_allocator, pattern, normalized_path).matches()) {
if (glob.match(pattern, normalized_path).matches()) {
return true;
}
}

View File

@@ -67,7 +67,7 @@ pub const WorkPool = jsc.WorkPool;
pub const Pipe = [2]bun.FileDescriptor;
pub const SmolList = shell.SmolList;
pub const GlobWalker = Glob.BunGlobWalkerZ;
pub const GlobWalker = bun.glob.BunGlobWalkerZ;
pub const stdin_no = 0;
pub const stdout_no = 1;
@@ -1957,7 +1957,6 @@ pub fn unreachableState(context: []const u8, state: []const u8) noreturn {
return bun.Output.panic("Bun shell has reached an unreachable state \"{s}\" in the {s} context. This indicates a bug, please open a GitHub issue.", .{ state, context });
}
const Glob = @import("../glob.zig");
const builtin = @import("builtin");
const WTFStringImplStruct = @import("../string.zig").WTFStringImplStruct;

View File

@@ -17,7 +17,7 @@ pub const IOReader = Interpreter.IOReader;
pub const Yield = @import("./Yield.zig").Yield;
pub const unreachableState = interpret.unreachableState;
const GlobWalker = Glob.GlobWalker_(null, true);
const GlobWalker = bun.glob.GlobWalker(null, true);
// const GlobWalker = Glob.BunGlobWalker;
pub const SUBSHELL_TODO_ERROR = "Subshells are not implemented, please open GitHub issue!";
@@ -4429,7 +4429,6 @@ pub const TestingAPIs = struct {
pub const ShellSubprocess = @import("./subproc.zig").ShellSubprocess;
const Glob = @import("../glob.zig");
const Syscall = @import("../sys.zig");
const builtin = @import("builtin");

View File

@@ -136,6 +136,44 @@ test("dependency on workspace without version in package.json", async () => {
}
}, 20_000);
test("allowing negative workspace patterns", async () => {
await Promise.all([
write(
join(packageDir, "package.json"),
JSON.stringify({
name: "root",
workspaces: ["packages/*", "!packages/pkg2"],
}),
),
write(
join(packageDir, "packages", "pkg1", "package.json"),
JSON.stringify({
name: "pkg1",
dependencies: {
"no-deps": "1.0.0",
},
}),
),
write(
join(packageDir, "packages", "pkg2", "package.json"),
JSON.stringify({
name: "pkg2",
dependencies: {
"doesnt-exist-oops": "1.2.3",
},
}),
),
]);
const { exited } = await runBunInstall(env, packageDir);
expect(await exited).toBe(0);
expect(await file(join(packageDir, "node_modules", "no-deps", "package.json")).json()).toEqual({
name: "no-deps",
version: "1.0.0",
});
});
test("dependency on same name as workspace and dist-tag", async () => {
await Promise.all([
write(