diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 690e6d94523c9..60cbe5ca93a09 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -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; + resolvedGit: Record; + } = 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", () => { diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 73c54d1f8b88d..0170232f2104c 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -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); @@ -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 { const rawValue = env.PATHEXT; const fallback = [".COM", ".EXE", ".BAT", ".CMD"]; @@ -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(); 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; @@ -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);