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
27 changes: 0 additions & 27 deletions apps/server/src/testUtils/gitConfig.setup.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
// @effect-diagnostics nodeBuiltinImport:off - runs once at test setup, outside any Effect runtime.
import * as NodeChildProcess from "node:child_process";
import * as NodeFS from "node:fs";
import * as NodePath from "node:path";
import * as HostProcess from "@t3tools/shared/HostProcess";

// Git for Windows installs with core.autocrlf=true, which checks committed LF
// files out as CRLF and breaks every byte-exact content assertion; a signing
// key or a non-default init branch on the developer's machine breaks fixtures
Expand All @@ -24,24 +18,3 @@ entries.forEach(([key, value], index) => {
process.env[`GIT_CONFIG_KEY_${existing + index}`] = key;
process.env[`GIT_CONFIG_VALUE_${existing + index}`] = value;
});

// Without Homebrew git, macOS resolves `git` to /usr/bin/git, an xcrun shim that costs
// about 8 ms per spawn against 3 ms for the real binary. Git-heavy files such as
// GitVcsDriverCore.test.ts and GitManager.test.ts run twice as fast with the real one.
if (HostProcess.Platform.defaultValue() === "darwin") {
const gitOnPath = (process.env.PATH ?? "")
.split(NodePath.delimiter)
.map((dir) => NodePath.join(dir, "git"))
.find((candidate) => NodeFS.existsSync(candidate));
if (gitOnPath === "/usr/bin/git") {
try {
const realGit = NodeChildProcess.execFileSync("xcrun", ["-f", "git"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
if (NodePath.isAbsolute(realGit)) {
process.env.PATH = `${NodePath.dirname(realGit)}${NodePath.delimiter}${process.env.PATH}`;
}
} catch {}
}
}
72 changes: 72 additions & 0 deletions packages/shared/src/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
WindowsShellEnvironment,
withPathDirectoryListings,
type WindowsShellEnvironmentReader,
XcodeGitLocation,
} from "./shell.ts";

const withWindowsEnvironmentMocks = <A, E, R>(
Expand Down Expand Up @@ -634,6 +635,77 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {
}),
);

describe("macOS git", () => {
// Files that exist, the git first on PATH, and what `xcrun --find git` prints.
const setup = (options: {
readonly platform?: NodeJS.Platform;
readonly gitOnPath: string;
readonly xcrunGit: string | undefined;
}) => {
const files = new Set([options.gitOnPath, ...(options.xcrunGit ? [options.xcrunGit] : [])]);
const host = { xcrunGit: options.xcrunGit, xcrunCalls: 0 };
const resolve = resolveSpawnCommand("git", ["status"], { env: { PATH: "/fake/bin" } }).pipe(
Effect.map((resolved) => resolved.command),
Effect.provideService(HostProcess.Platform, options.platform ?? "darwin"),
Effect.provideService(SpawnExecutableResolution, (command) =>
command === "git" ? options.gitOnPath : files.has(command) ? command : undefined,
),
Effect.provideService(XcodeGitLocation, () => {
host.xcrunCalls += 1;
return host.xcrunGit;
}),
);
return { files, host, resolve };
};

const XCODE_GIT = "/Applications/Xcode.app/Contents/Developer/usr/bin/git";

it.effect("runs the git behind the xcrun shim and looks it up once", () =>
Effect.gen(function* () {
const { host, resolve } = setup({ gitOnPath: "/usr/bin/git", xcrunGit: XCODE_GIT });
expect(yield* resolve).toBe(XCODE_GIT);
expect(yield* resolve).toBe(XCODE_GIT);
expect(host.xcrunCalls).toBe(1);
}).pipe(Effect.provideService(CommandResolutionCache, new Map())),
);

it.effect("keeps git when PATH has another git or the host is not macOS", () =>
Effect.gen(function* () {
const homebrew = setup({ gitOnPath: "/opt/homebrew/bin/git", xcrunGit: XCODE_GIT });
expect(yield* homebrew.resolve).toBe("git");
expect(homebrew.host.xcrunCalls).toBe(0);

const linux = setup({ platform: "linux", gitOnPath: "/usr/bin/git", xcrunGit: XCODE_GIT });
expect(yield* linux.resolve).toBe("git");
}).pipe(Effect.provideService(CommandResolutionCache, new Map())),
);

it.effect("keeps git when xcrun fails or names a missing file", () =>
Effect.gen(function* () {
const failed = setup({ gitOnPath: "/usr/bin/git", xcrunGit: undefined });
expect(yield* failed.resolve).toBe("git");

const missing = setup({ gitOnPath: "/usr/bin/git", xcrunGit: XCODE_GIT });
missing.files.delete(XCODE_GIT);
expect(yield* missing.resolve).toBe("git");
}).pipe(Effect.provideService(CommandResolutionCache, new Map())),
);

it.effect("looks again when the resolved git disappears", () =>
Effect.gen(function* () {
const betaGit = "/Applications/Xcode-beta.app/Contents/Developer/usr/bin/git";
const { files, host, resolve } = setup({ gitOnPath: "/usr/bin/git", xcrunGit: XCODE_GIT });
expect(yield* resolve).toBe(XCODE_GIT);

files.delete(XCODE_GIT);
files.add(betaGit);
host.xcrunGit = betaGit;
expect(yield* resolve).toBe(betaGit);
expect(host.xcrunCalls).toBe(2);
}).pipe(Effect.provideService(CommandResolutionCache, new Map())),
);
});

it.effect("does not fall back to a shell for unresolved Windows commands", () =>
Effect.gen(function* () {
const command = yield* resolveSpawnCommand("missing & calc", ["unsafe & value"], {
Expand Down
81 changes: 77 additions & 4 deletions packages/shared/src/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,11 +549,11 @@ export const withPathDirectoryListings =
return yield* effect.pipe(Effect.provideService(PathDirectoryListings, listings));
});

// An injected resolver may answer differently for the same search, so its
// An injected resolver or locator may answer differently for the same search, so its
// entries are kept apart from every other resolver's.
let spawnResolverCacheIdCount = 0;
const spawnResolverCacheIds = new WeakMap<SpawnExecutableResolver, number>();
function spawnResolverCacheId(resolver: SpawnExecutableResolver): number {
const spawnResolverCacheIds = new WeakMap<SpawnExecutableResolver | XcodeGitLocator, number>();
function spawnResolverCacheId(resolver: SpawnExecutableResolver | XcodeGitLocator): number {
const known = spawnResolverCacheIds.get(resolver);
if (known !== undefined) return known;
const id = spawnResolverCacheIdCount++;
Expand Down Expand Up @@ -765,6 +765,71 @@ export function npmShimExecutable(
return isFile(executable) ? executable : undefined;
}

export type XcodeGitLocator = (env: NodeJS.ProcessEnv) => string | undefined;

// xcrun honors DEVELOPER_DIR and the `xcode-select` choice, as the shim does.
function locateXcodeGitWithXcrun(env: NodeJS.ProcessEnv): string | undefined {
try {
const located = NodeChildProcess.execFileSync("/usr/bin/xcrun", ["--find", "git"], {
encoding: "utf8",
env,
timeout: 5_000,
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return NodePath.isAbsolute(located) ? located : undefined;
} catch {
return undefined;
}
}

export const XcodeGitLocation = Context.Reference<XcodeGitLocator>(
"@t3tools/shared/shell/XcodeGitLocation",
{ defaultValue: () => locateXcodeGitWithXcrun },
);

const MACOS_GIT_SHIM = "/usr/bin/git";

/**
* /usr/bin/git is an xcrun shim that finds the active developer directory on
* every run: about 8 ms per spawn, against under 3 ms for the git behind it.
* Only the shim is replaced, so a git the user put first on PATH (Homebrew,
* Nix) still wins, and git's own children keep the same PATH. Any lookup
* failure keeps plain `git`.
*/
const resolveMacosGit = Effect.fnUntraced(function* (
env: NodeJS.ProcessEnv,
resolveExecutable: SpawnExecutableResolver,
) {
const locateXcodeGit = yield* XcodeGitLocation;
const cache = yield* CommandResolutionCache;
const cacheKey = [
"xcode-git",
String(spawnResolverCacheId(resolveExecutable)),
String(spawnResolverCacheId(locateXcodeGit)),
resolvePathEnvironmentVariable(env),
env.DEVELOPER_DIR ?? "",
].join(COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR);
const nowNanos = yield* Clock.currentTimeNanos;
const cached = cache.get(cacheKey);
// An Xcode update or removal deletes the binary; look again instead of failing the spawn.
if (
cached !== undefined &&
cached.expiresAtNanos > nowNanos &&
(cached.resolvedPath === null ||
resolveExecutable(cached.resolvedPath, "darwin", env) !== undefined)
) {
return cached.resolvedPath ?? "git";
}
const located =
resolveExecutable("git", "darwin", env) === MACOS_GIT_SHIM ? locateXcodeGit(env) : undefined;
const realGit =
located !== undefined && resolveExecutable(located, "darwin", env) !== undefined
? located
: null;
cacheCommandResolution(cache, cacheKey, realGit, nowNanos);
return realGit ?? "git";
});

export interface SpawnCommandOptions extends CommandAvailabilityOptions {
/** Run the executable behind an npm cmd-shim directly (see `npmShimExecutable`). */
readonly bypassNpmShim?: boolean;
Expand All @@ -776,7 +841,8 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* (
options: SpawnCommandOptions = {},
): Effect.fn.Return<ResolvedSpawnCommand> {
const platform = yield* HostProcess.Platform;
if (platform !== "win32") {
const isMacosGit = platform === "darwin" && command === "git";
if (platform !== "win32" && !isMacosGit) {
return { command, args: [...args], shell: false };
}

Expand All @@ -788,6 +854,13 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* (
? { ...hostEnvironment, ...options.env }
: options.env;
const resolveExecutable = yield* SpawnExecutableResolution;
if (isMacosGit) {
return {
command: yield* resolveMacosGit(env, resolveExecutable),
args: [...args],
shell: false,
};
}
// The scan is synchronous and runs before every child process, so it shares
// the PATH scan cache above. Explicit paths stay uncached for the same reason,
// and so do misses: a failed spawn is how providers report "not installed",
Expand Down
Loading