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
10 changes: 8 additions & 2 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3
import * as HostProcess from "@t3tools/shared/HostProcess";
import { compactTraceAttributes } from "@t3tools/shared/observability";
import { decodeJsonResult } from "@t3tools/shared/schemaJson";
import { resolveSpawnCommand } from "@t3tools/shared/shell";
import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile";
import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings";
import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts";
Expand Down Expand Up @@ -977,11 +978,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
...input.env,
...trace2Monitor.env,
};
const spawnEnv = { ...env, ...windowsLongPathConfigEnv(hostPlatform, env) };
const resolved = yield* resolveSpawnCommand("git", [], { env: spawnEnv });
// A git.cmd wrapper would need cmd.exe, which cuts multi-line commit
// messages at the first newline; leave that case to Node's lookup.
const executable = resolved.shell ? "git" : resolved.command;
const child = yield* commandSpawner
.spawn(
ChildProcess.make("git", commandInput.args, {
ChildProcess.make(executable, commandInput.args, {
cwd: commandInput.cwd,
env: { ...env, ...windowsLongPathConfigEnv(hostPlatform, env) },
env: spawnEnv,
}),
)
.pipe(
Expand Down
89 changes: 89 additions & 0 deletions packages/shared/src/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
listLoginShellCandidates,
mergePathEntries,
mergePathValues,
preferGitForWindowsBinary,
readEnvironmentFromLoginShell,
readEnvironmentFromWindowsShell,
readPathFromLaunchctl,
Expand Down Expand Up @@ -511,6 +512,70 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => {
);
});

describe("preferGitForWindowsBinary", () => {
const files =
(...paths: Array<string>) =>
(filePath: string) =>
paths.includes(filePath);

it("runs the git.exe Git for Windows' launcher would start", () => {
// 2.56+ on x64. The PATHEXT scan returns the extension in PATHEXT's case.
expect(
preferGitForWindowsBinary(
"C:\\Program Files\\Git\\cmd\\git.EXE",
{},
files(
"C:\\Program Files\\Git\\ucrt64\\bin\\git.exe",
"C:\\Program Files\\Git\\mingw64\\bin\\git.exe",
),
),
).toBe("C:\\Program Files\\Git\\ucrt64\\bin\\git.exe");
// Before 2.56, and the portable build's bin launcher.
expect(
preferGitForWindowsBinary(
"D:\\PortableGit\\bin\\git.exe",
{},
files("D:\\PortableGit\\mingw64\\bin\\git.exe"),
),
).toBe("D:\\PortableGit\\mingw64\\bin\\git.exe");
expect(
preferGitForWindowsBinary(
"C:\\Program Files\\Git\\cmd\\git.exe",
{},
files("C:\\Program Files\\Git\\clangarm64\\bin\\git.exe"),
),
).toBe("C:\\Program Files\\Git\\clangarm64\\bin\\git.exe");
});

it("keeps the launcher when MSYSTEM is set", () => {
// The real git.exe only adds its own folders to PATH when MSYSTEM is unset;
// without them a `#!/bin/sh` hook cannot start.
expect(
preferGitForWindowsBinary(
"C:\\Program Files\\Git\\cmd\\git.exe",
{ MSYSTEM: "MINGW64" },
files("C:\\Program Files\\Git\\mingw64\\bin\\git.exe"),
),
).toBe("C:\\Program Files\\Git\\cmd\\git.exe");
});

it("keeps the launcher when no git.exe sits beside it", () => {
expect(preferGitForWindowsBinary("C:\\Program Files\\Git\\cmd\\git.exe", {}, () => false)).toBe(
"C:\\Program Files\\Git\\cmd\\git.exe",
);
});

it("leaves other gits and other launchers alone", () => {
const everything = () => true;
expect(preferGitForWindowsBinary("C:\\Users\\me\\scoop\\shims\\git.exe", {}, everything)).toBe(
"C:\\Users\\me\\scoop\\shims\\git.exe",
);
expect(preferGitForWindowsBinary("C:\\Program Files\\Git\\cmd\\gitk.exe", {}, everything)).toBe(
"C:\\Program Files\\Git\\cmd\\gitk.exe",
);
});
});

effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {
it.effect("runs Windows executables directly without a shell", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -627,6 +692,30 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => {
}).pipe(Effect.provideService(CommandResolutionCache, new Map())),
);

it.effect("keeps launcher swaps apart for environments with and without MSYSTEM", () =>
Effect.gen(function* () {
let scans = 0;
const scan: SpawnExecutableResolver = () => {
scans++;
return "C:\\Git\\cmd\\git.exe";
};
const resolve = (env: NodeJS.ProcessEnv) =>
resolveSpawnCommand("git", [], {
env: { PATH: "C:\\Git\\cmd", PATHEXT: ".EXE", ...env },
}).pipe(
Effect.provideService(HostProcess.Platform, "win32"),
Effect.provideService(SpawnExecutableResolution, scan),
);

yield* resolve({});
yield* resolve({});
expect(scans).toBe(1);
// A swap made without MSYSTEM must not reach a child that has it set.
yield* resolve({ MSYSTEM: "MINGW64" });
expect(scans).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
48 changes: 47 additions & 1 deletion packages/shared/src/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,47 @@ export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function
});
});

// Git for Windows 2.56 moved x64 builds from mingw64 to ucrt64; ARM64 builds
// live in clangarm64 and 32-bit ones in mingw32.
const GIT_FOR_WINDOWS_BUILDS = ["ucrt64", "clangarm64", "mingw64", "mingw32"] as const;

function isFileSync(filePath: string): boolean {
try {
return NodeFS.statSync(filePath).isFile();
} catch {
return false;
}
}

/**
* Swaps Git for Windows' launcher (`<Git>\cmd\git.exe`, the only git its
* installer puts on PATH, or the portable build's `<Git>\bin\git.exe`) for the
* git.exe it starts. The launcher costs a second process on every git command,
* and each launch leaks a kernel token reference that slows process creation
* machine-wide until reboot. The real binary sets HOME itself, but adds its own
* folders to PATH for hooks, ssh and credential helpers only when MSYSTEM is
* unset, so the launcher stays when MSYSTEM is set. Anything else is returned
* as is.
*/
export function preferGitForWindowsBinary(
executable: string,
env: NodeJS.ProcessEnv,
isFile: (filePath: string) => boolean = isFileSync,
): string {
if (env.MSYSTEM) return executable;
const path = NodePath.win32;
if (path.basename(executable).toLowerCase() !== "git.exe") return executable;
const launcherDirectory = path.dirname(executable);
const launcherFolder = path.basename(launcherDirectory).toLowerCase();
if (launcherFolder !== "cmd" && launcherFolder !== "bin") return executable;
const installRoot = path.dirname(launcherDirectory);
for (const build of GIT_FOR_WINDOWS_BUILDS) {
const candidate = path.join(installRoot, build, "bin", "git.exe");
if (isFile(candidate)) return candidate;
}
return executable;
}

// Untraced because it runs before most spawns and returns at once off Windows.
export const resolveSpawnCommand = Effect.fnUntraced(function* (
command: string,
Expand Down Expand Up @@ -724,6 +765,7 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* (
platform,
resolvePathEnvironmentVariable(env),
resolveWindowsPathExtensions(env).join(";"),
env.MSYSTEM ? "msystem" : "",
command,
].join(COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR);
const nowNanos = yield* Clock.currentTimeNanos;
Expand All @@ -732,7 +774,11 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* (
if (cached !== undefined && cached.expiresAtNanos > nowNanos) {
resolvedExecutable = cached.resolvedPath;
} else {
resolvedExecutable = resolveExecutable(command, platform, env) ?? null;
// Cached with the scan: its file checks would otherwise run before every
// git launch. A Git upgrade that moves the real binary can fail git for up
// to the cache lifetime.
const found = resolveExecutable(command, platform, env);
resolvedExecutable = found === undefined ? null : preferGitForWindowsBinary(found, env);
if (!explicitPath && resolvedExecutable !== null) {
cacheCommandResolution(cache, cacheKey, resolvedExecutable, nowNanos);
}
Expand Down
Loading