diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 51c1f7b29468..53e7f6418058 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -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"; @@ -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( diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 428d0d5951f5..5f493c8044d3 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -15,6 +15,7 @@ import { listLoginShellCandidates, mergePathEntries, mergePathValues, + preferGitForWindowsBinary, readEnvironmentFromLoginShell, readEnvironmentFromWindowsShell, readPathFromLaunchctl, @@ -511,6 +512,70 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { ); }); +describe("preferGitForWindowsBinary", () => { + const files = + (...paths: Array) => + (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* () { @@ -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"], { diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index b24c998b1785..4d29841bfa8b 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -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 (`\cmd\git.exe`, the only git its + * installer puts on PATH, or the portable build's `\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, @@ -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; @@ -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); }