Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/utils/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Fixed

- macOS executable discovery now honors explicit `PATH` and `cwd` lookup overrides instead of silently searching the process environment.

## [0.12.7] - 2026-07-31

## [0.12.6] - 2026-07-31
Expand Down
11 changes: 6 additions & 5 deletions packages/utils/src/which.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,10 @@ export interface WhichOptions extends Bun.WhichOptions {

// Darwin-specific "which" shim: consult Xcode/CLT toolchain directories after $PATH.
// Uses cached directory listings instead of per-command existsSync or xcrun subprocesses.
function darwinWhich(command: string, _options?: Bun.WhichOptions): string | null {
const regular = Bun.which(command);
function darwinWhich(command: string, options?: Bun.WhichOptions): string | null {
const regular = Bun.which(command, options);
if (regular) return regular;
if (options?.PATH !== undefined) return null;
if (isXcodeBin(command)) {
return getMacosToolPaths().get(command) ?? null;
}
Expand All @@ -198,10 +199,10 @@ export const whichFresh = os.platform() === "darwin" ? darwinWhich : Bun.which;
// Derive stable cache key from command and lookup options
function cacheKey(command: string, options?: Bun.WhichOptions): CacheKey {
if (!options) return command;
if (!options.cwd && !options.PATH) return command;
if (options.cwd === undefined && options.PATH === undefined) return command;
let h = Bun.hash(command);
if (options.cwd) h = Bun.hash(options.cwd, h);
if (options.PATH) h = Bun.hash(options.PATH, h);
if (options.cwd !== undefined) h = Bun.hash(`cwd:${options.cwd}`, h);
if (options.PATH !== undefined) h = Bun.hash(`PATH:${options.PATH}`, h);
return h;
}

Expand Down
30 changes: 30 additions & 0 deletions packages/utils/test/which.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from "bun:test";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { TempDir } from "../src/temp";
import { $which, WhichCachePolicy } from "../src/which";

describe.skipIf(process.platform === "win32")("$which lookup options", () => {
it("honors explicit PATH and cwd overrides without cache collisions", async () => {
const tempDir = await TempDir.create();
try {
const command = "gjc-which-option-test";
const executable = tempDir.join(command);
await Bun.write(executable, "#!/bin/sh\nexit 0\n");
await fs.chmod(executable, 0o755);

expect($which(command, { PATH: tempDir.path(), cache: WhichCachePolicy.Fresh })).toBe(executable);
expect($which(command, { PATH: "", cwd: tempDir.path(), cache: WhichCachePolicy.Cached })).toBeNull();
expect(
$which(`.${path.sep}${command}`, {
PATH: "",
cwd: tempDir.path(),
cache: WhichCachePolicy.Bypass,
}),
).toBe(executable);
expect($which("clang", { PATH: tempDir.path(), cache: WhichCachePolicy.Bypass })).toBeNull();
} finally {
await tempDir.remove();
}
});
});
Loading