Skip to content
Open
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
180 changes: 180 additions & 0 deletions packages/shared/src/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,186 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => {
Effect.provideService(CommandResolutionCache, new Map()),
),
);

// Candidates are checked against the process cwd, so the lookup runs in a child with a temp cwd.
const resolveInChild = `
import { createRequire } from "node:module";
const [shellPath, command, rawPaths, rawGitPaths, gitShim, xcodeGit] = process.argv.slice(1);
const requireFromShell = createRequire(shellPath);
const Effect = await import(requireFromShell.resolve("effect/Effect"));
const { layer } = await import(requireFromShell.resolve("@effect/platform-node/NodeServices"));
const HostProcess = await import(shellPath.replace(/shell\\.ts$/, "HostProcess.ts"));
const Shell = await import(shellPath);
const resolved = {};
const resolvedGit = {};
const defaultResolver = Shell.SpawnExecutableResolution.defaultValue();
const resolveExecutable = (...args) => {
const found = defaultResolver(...args);
return found === gitShim ? "/usr/bin/git" : found;
};
const locateXcodeGit = () => xcodeGit;
await Effect.runPromise(Effect.gen(function* () {
const caches = new Map();
for (const [label, { PATH, cwd, cacheGroup }] of Object.entries(JSON.parse(rawPaths))) {
process.chdir(cwd);
let cache = caches.get(cacheGroup);
if (!cache) caches.set(cacheGroup, cache = new Map());
resolved[label] = {
effect: yield* Shell.resolveCommandPath(command, { env: { PATH } }).pipe(
Effect.provideService(HostProcess.Platform, process.platform),
Effect.provideService(Shell.CommandResolutionCache, cache),
),
spawn: Shell.SpawnExecutableResolution.defaultValue()(command, process.platform, { PATH }) ?? null,
};
}
for (const [label, { PATH, cwd, cacheGroup }] of Object.entries(JSON.parse(rawGitPaths))) {
process.chdir(cwd);
let cache = caches.get(cacheGroup);
if (!cache) caches.set(cacheGroup, cache = new Map());
resolvedGit[label] = yield* Shell.resolveSpawnCommand("git", ["status"], { env: { PATH } }).pipe(
Effect.provideService(HostProcess.Platform, "darwin"),
Effect.provideService(Shell.SpawnExecutableResolution, resolveExecutable),
Effect.provideService(Shell.XcodeGitLocation, locateXcodeGit),
Effect.provideService(Shell.CommandResolutionCache, cache),
);
}
}).pipe(Shell.withPathDirectoryListings, Effect.provide(layer)));
console.log(JSON.stringify({ resolved, resolvedGit }));
`;

it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
"searches the current directory for empty POSIX PATH components across cached lookups",
() =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-empty-path-cwd-" });
const listed = yield* fs.makeTempDirectoryScoped({ prefix: "t3-empty-path-listed-" });
const empty = yield* fs.makeTempDirectoryScoped({ prefix: "t3-empty-path-absent-" });
const command = "t3-empty-path-cli";
const inCwd = path.join(cwd, command);
const inListed = path.join(listed, command);
const gitShim = path.join(listed, "git");
const xcodeGit = path.join(listed, "xcode-git");
yield* fs.writeFileString(inCwd, "#!/bin/sh\n", { mode: 0o755 });
yield* fs.writeFileString(inListed, "#!/bin/sh\n", { mode: 0o755 });
yield* fs.writeFileString(path.join(cwd, "git"), "#!/bin/sh\n", { mode: 0o755 });
yield* fs.writeFileString(gitShim, "#!/bin/sh\n", { mode: 0o755 });
yield* fs.writeFileString(xcodeGit, "#!/bin/sh\n", { mode: 0o755 });
yield* fs.utimes(cwd, 4_102_444_800, 4_102_444_800);
yield* fs.utimes(empty, 4_102_444_800, 4_102_444_800);
const missing = path.join(cwd, "missing");

const searches = {
dot: { PATH: ".", expected: inCwd },
absolute: { PATH: listed, expected: inListed },
leading: { PATH: `:${missing}`, expected: inCwd },
trailing: { PATH: `${missing}:`, expected: inCwd },
interior: { PATH: `${missing}::${missing}-2`, expected: inCwd },
leadingBeforeListed: { PATH: `:${listed}`, expected: inCwd },
listedBeforeTrailing: { PATH: `${listed}:`, expected: inListed },
entirelyEmpty: { PATH: "", expected: null },
whitespaceOnly: { PATH: `${missing}: `, expected: null },
quotedEmpty: { PATH: `${missing}:""`, expected: null },
};

const cachedSearches = {
cachedMiss: { PATH: `:${missing}`, cwd: empty, cacheGroup: "miss", expected: null },
afterMiss: { PATH: `:${missing}`, cwd, cacheGroup: "miss", expected: inCwd },
cachedHit: { PATH: `:${missing}`, cwd, cacheGroup: "hit", expected: inCwd },
afterHit: { PATH: `:${missing}`, cwd: empty, cacheGroup: "hit", expected: null },
cachedListed: {
PATH: `:${listed}`,
cwd: empty,
cacheGroup: "listed",
expected: inListed,
},
beforeListed: { PATH: `:${listed}`, cwd, cacheGroup: "listed", expected: inCwd },
cachedLocal: { PATH: `:${listed}`, cwd, cacheGroup: "local", expected: inCwd },
afterLocal: { PATH: `:${listed}`, cwd: empty, cacheGroup: "local", expected: inListed },
};
const entries = [
...Object.entries(cachedSearches),
...Object.entries(searches).map(
([label, row]) => [label, { ...row, cwd, cacheGroup: label }] as const,
),
];
const gitEntries = ["", "."].flatMap((entry) => {
const PATH = `${entry}:${listed}`;
return [
{ PATH, cwd: empty, cacheGroup: `xcode-first-${entry}`, expected: xcodeGit },
{ PATH, cwd, cacheGroup: `xcode-first-${entry}`, expected: "git" },
{ PATH, cwd, cacheGroup: `local-first-${entry}`, expected: "git" },
{ PATH, cwd: empty, cacheGroup: `local-first-${entry}`, expected: xcodeGit },
];
});
const stdout = NodeChildProcess.execFileSync(
process.execPath,
[
"--input-type=module",
"-e",
resolveInChild,
path.join(import.meta.dirname, "shell.ts"),
command,
JSON.stringify(
Object.fromEntries(
entries.map(([label, row]) => [
label,
{
PATH: row.PATH,
cwd: row.cwd,
cacheGroup: row.cacheGroup,
},
]),
),
),
JSON.stringify(gitEntries),
gitShim,
xcodeGit,
],
{ cwd, encoding: "utf8", env: {} },
);
const {
resolved,
resolvedGit,
}: {
resolved: Record<string, { effect: string | null; spawn: string | null }>;
resolvedGit: Record<string, { command: string; args: string[]; shell: boolean }>;
} = JSON.parse(stdout);

// A relative result is relative to the child's cwd.
const anchored = (found: string | null, directory: string) =>
found === null ? null : path.resolve(directory, found);
const directories = Object.fromEntries(entries.map(([label, row]) => [label, row.cwd]));
expect(
Object.fromEntries(
Object.entries(resolved).map(([label, found]) => {
const directory = directories[label];
if (directory === undefined) throw new Error(`Unexpected resolver case: ${label}`);
return [
label,
{
effect: anchored(found.effect, directory),
spawn: anchored(found.spawn, directory),
},
];
}),
),
).toEqual(
Object.fromEntries(
entries.map(([label, row]) => [label, { effect: row.expected, spawn: row.expected }]),
),
);
expect(resolvedGit).toEqual(
Object.fromEntries(
gitEntries.map((row, index) => [
String(index),
{ command: row.expected, args: ["status"], shell: false },
]),
),
);
}),
);
});

describe("preferGitForWindowsBinary", () => {
Expand Down
61 changes: 43 additions & 18 deletions packages/shared/src/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,13 @@ function resolveSpawnExecutableWithNode(
return candidates.find(isExecutable);
}

for (const pathEntry of (readEnvPath(env) ?? "").split(pathDelimiterForPlatform(platform))) {
const normalizedPathEntry = stripWrappingQuotes(pathEntry.trim());
const pathValue = readEnvPath(env) ?? "";
for (const pathEntry of pathValue.split(pathDelimiterForPlatform(platform))) {
// A raw empty component of a nonempty POSIX PATH is the current directory.
const normalizedPathEntry =
pathEntry === "" && pathValue !== "" && platform !== "win32"
? "."
: stripWrappingQuotes(pathEntry.trim());
if (normalizedPathEntry.length === 0) continue;
for (const candidate of candidates) {
const candidatePath = path.join(normalizedPathEntry, candidate);
Expand Down Expand Up @@ -444,6 +449,16 @@ function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string {
return readEnvPath(env) ?? "";
}

function pathSearchCwd(pathValue: string, platform: NodeJS.Platform): string | undefined {
if (platform === "win32" || pathValue.length === 0) return undefined;
return pathValue.split(POSIX_PATH_DELIMITER).some((entry) => {
const directory = entry === "" ? "." : stripWrappingQuotes(entry.trim());
return directory.length > 0 && !NodePath.posix.isAbsolute(directory);
})
? process.cwd()
: undefined;
}

function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray<string> {
const rawValue = env.PATHEXT;
const fallback = [".COM", ".EXE", ".BAT", ".CMD"];
Expand Down Expand Up @@ -622,33 +637,40 @@ const resolveCommandPathForPlatform = Effect.fn("shell.resolveCommandPathForPlat
return null;
}

const cacheKey = [platform, pathValue, windowsPathExtensions.join(";"), command].join(
COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR,
);
const cache = yield* CommandResolutionCache;
const nowNanos = yield* Clock.monotonicTimeNanos;
const cached = cache.get(cacheKey);
if (cached !== undefined && cached.expiresAtNanos > nowNanos) {
return cached.resolvedPath;
}

// Keep case variants: Windows can make PATH directories case-sensitive.
const pathEntries: string[] = [];
const seenPathEntries = new Set<string>();
for (const entry of pathValue.split(pathDelimiterForPlatform(platform))) {
const pathEntry = stripWrappingQuotes(entry.trim());
const pathEntry =
entry === "" && platform !== "win32" ? "." : stripWrappingQuotes(entry.trim());
if (pathEntry.length === 0 || seenPathEntries.has(pathEntry)) continue;

seenPathEntries.add(pathEntry);
pathEntries.push(pathEntry);
}

const cwd = pathSearchCwd(pathValue, platform);
const cacheKey = [
platform,
pathValue,
windowsPathExtensions.join(";"),
command,
...(cwd === undefined ? [] : [cwd]),
].join(COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR);
const cache = yield* CommandResolutionCache;
const nowNanos = yield* Clock.monotonicTimeNanos;
const cached = cache.get(cacheKey);
if (cached !== undefined && cached.expiresAtNanos > nowNanos) {
return cached.resolvedPath;
}

const listings = yield* PathDirectoryListings;
for (const pathEntry of pathEntries) {
let listing = listings && (yield* Cache.get(listings, pathEntry));
if (listings && listing?.names && listing.modified !== (yield* directoryMtime(pathEntry))) {
yield* Cache.invalidate(listings, pathEntry);
listing = yield* Cache.get(listings, pathEntry);
const directory = cwd === undefined ? pathEntry : path.resolve(cwd, pathEntry);
let listing = listings && (yield* Cache.get(listings, directory));
if (listings && listing?.names && listing.modified !== (yield* directoryMtime(directory))) {
yield* Cache.invalidate(listings, directory);
listing = yield* Cache.get(listings, directory);
}
for (const candidate of commandCandidates) {
if (listing?.names && !listing.names.has(candidate.toLowerCase())) continue;
Expand Down Expand Up @@ -781,12 +803,15 @@ const resolveMacosGit = Effect.fnUntraced(function* (
) {
const locateXcodeGit = yield* XcodeGitLocation;
const cache = yield* CommandResolutionCache;
const pathValue = resolvePathEnvironmentVariable(env);
const cwd = pathSearchCwd(pathValue, "darwin");
const cacheKey = [
"xcode-git",
String(spawnResolverCacheId(resolveExecutable)),
String(spawnResolverCacheId(locateXcodeGit)),
resolvePathEnvironmentVariable(env),
pathValue,
env.DEVELOPER_DIR ?? "",
...(cwd === undefined ? [] : [cwd]),
].join(COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR);
const nowNanos = yield* Clock.currentTimeNanos;
const cached = cache.get(cacheKey);
Expand Down
Loading