| author | |
| committer | |
| log | 9cc57680cb5f762f553acd1fa5f3efcf17d8fc67 |
| tree | 264aeae3e12300cef198a95a930ec787da17c025 |
| parent | c3243f05088c3c20e9439f285fc6d7b0bfe1747d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>3 files changed, 144 insertions(+), 3 deletions(-)
apps/server/src/vcs/GitVcsDriverCore.ts+8-2| ... | @@ -32,6 +32,7 @@ import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3 | ... | @@ -32,6 +32,7 @@ import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3 |
| 32 | import * as HostProcess from "@t3tools/shared/HostProcess"; | 32 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 33 | import { compactTraceAttributes } from "@t3tools/shared/observability"; | 33 | import { compactTraceAttributes } from "@t3tools/shared/observability"; |
| 34 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | 34 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| 35 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | ||
| 35 | import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; | 36 | import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; |
| 36 | import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings"; | 37 | import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings"; |
| 37 | import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; | 38 | import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; |
| ... | @@ -977,11 +978,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* | ... | @@ -977,11 +978,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 977 | ...input.env, | 978 | ...input.env, |
| 978 | ...trace2Monitor.env, | 979 | ...trace2Monitor.env, |
| 979 | }; | 980 | }; |
| 981 | const spawnEnv = { ...env, ...windowsLongPathConfigEnv(hostPlatform, env) }; | ||
| 982 | const resolved = yield* resolveSpawnCommand("git", [], { env: spawnEnv }); | ||
| 983 | // A git.cmd wrapper would need cmd.exe, which cuts multi-line commit | ||
| 984 | // messages at the first newline; leave that case to Node's lookup. | ||
| 985 | const executable = resolved.shell ? "git" : resolved.command; | ||
| 980 | const child = yield* commandSpawner | 986 | const child = yield* commandSpawner |
| 981 | .spawn( | 987 | .spawn( |
| 982 | ChildProcess.make("git", commandInput.args, { | 988 | ChildProcess.make(executable, commandInput.args, { |
| 983 | cwd: commandInput.cwd, | 989 | cwd: commandInput.cwd, |
| 984 | env: { ...env, ...windowsLongPathConfigEnv(hostPlatform, env) }, | 990 | env: spawnEnv, |
| 985 | }), | 991 | }), |
| 986 | ) | 992 | ) |
| 987 | .pipe( | 993 | .pipe( |
packages/shared/src/shell.test.ts+89| ... | @@ -15,6 +15,7 @@ import { | ... | @@ -15,6 +15,7 @@ import { |
| 15 | listLoginShellCandidates, | 15 | listLoginShellCandidates, |
| 16 | mergePathEntries, | 16 | mergePathEntries, |
| 17 | mergePathValues, | 17 | mergePathValues, |
| 18 | preferGitForWindowsBinary, | ||
| 18 | readEnvironmentFromLoginShell, | 19 | readEnvironmentFromLoginShell, |
| 19 | readEnvironmentFromWindowsShell, | 20 | readEnvironmentFromWindowsShell, |
| 20 | readPathFromLaunchctl, | 21 | readPathFromLaunchctl, |
| ... | @@ -511,6 +512,70 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { | ... | @@ -511,6 +512,70 @@ effectIt.layer(NodeServices.layer)("resolveCommandPath", (it) => { |
| 511 | ); | 512 | ); |
| 512 | }); | 513 | }); |
| 513 | 514 | ||
| 515 | describe("preferGitForWindowsBinary", () => { | ||
| 516 | const files = | ||
| 517 | (...paths: Array<string>) => | ||
| 518 | (filePath: string) => | ||
| 519 | paths.includes(filePath); | ||
| 520 | |||
| 521 | it("runs the git.exe Git for Windows' launcher would start", () => { | ||
| 522 | // 2.56+ on x64. The PATHEXT scan returns the extension in PATHEXT's case. | ||
| 523 | expect( | ||
| 524 | preferGitForWindowsBinary( | ||
| 525 | "C:\\Program Files\\Git\\cmd\\git.EXE", | ||
| 526 | {}, | ||
| 527 | files( | ||
| 528 | "C:\\Program Files\\Git\\ucrt64\\bin\\git.exe", | ||
| 529 | "C:\\Program Files\\Git\\mingw64\\bin\\git.exe", | ||
| 530 | ), | ||
| 531 | ), | ||
| 532 | ).toBe("C:\\Program Files\\Git\\ucrt64\\bin\\git.exe"); | ||
| 533 | // Before 2.56, and the portable build's bin launcher. | ||
| 534 | expect( | ||
| 535 | preferGitForWindowsBinary( | ||
| 536 | "D:\\PortableGit\\bin\\git.exe", | ||
| 537 | {}, | ||
| 538 | files("D:\\PortableGit\\mingw64\\bin\\git.exe"), | ||
| 539 | ), | ||
| 540 | ).toBe("D:\\PortableGit\\mingw64\\bin\\git.exe"); | ||
| 541 | expect( | ||
| 542 | preferGitForWindowsBinary( | ||
| 543 | "C:\\Program Files\\Git\\cmd\\git.exe", | ||
| 544 | {}, | ||
| 545 | files("C:\\Program Files\\Git\\clangarm64\\bin\\git.exe"), | ||
| 546 | ), | ||
| 547 | ).toBe("C:\\Program Files\\Git\\clangarm64\\bin\\git.exe"); | ||
| 548 | }); | ||
| 549 | |||
| 550 | it("keeps the launcher when MSYSTEM is set", () => { | ||
| 551 | // The real git.exe only adds its own folders to PATH when MSYSTEM is unset; | ||
| 552 | // without them a `#!/bin/sh` hook cannot start. | ||
| 553 | expect( | ||
| 554 | preferGitForWindowsBinary( | ||
| 555 | "C:\\Program Files\\Git\\cmd\\git.exe", | ||
| 556 | { MSYSTEM: "MINGW64" }, | ||
| 557 | files("C:\\Program Files\\Git\\mingw64\\bin\\git.exe"), | ||
| 558 | ), | ||
| 559 | ).toBe("C:\\Program Files\\Git\\cmd\\git.exe"); | ||
| 560 | }); | ||
| 561 | |||
| 562 | it("keeps the launcher when no git.exe sits beside it", () => { | ||
| 563 | expect(preferGitForWindowsBinary("C:\\Program Files\\Git\\cmd\\git.exe", {}, () => false)).toBe( | ||
| 564 | "C:\\Program Files\\Git\\cmd\\git.exe", | ||
| 565 | ); | ||
| 566 | }); | ||
| 567 | |||
| 568 | it("leaves other gits and other launchers alone", () => { | ||
| 569 | const everything = () => true; | ||
| 570 | expect(preferGitForWindowsBinary("C:\\Users\\me\\scoop\\shims\\git.exe", {}, everything)).toBe( | ||
| 571 | "C:\\Users\\me\\scoop\\shims\\git.exe", | ||
| 572 | ); | ||
| 573 | expect(preferGitForWindowsBinary("C:\\Program Files\\Git\\cmd\\gitk.exe", {}, everything)).toBe( | ||
| 574 | "C:\\Program Files\\Git\\cmd\\gitk.exe", | ||
| 575 | ); | ||
| 576 | }); | ||
| 577 | }); | ||
| 578 | |||
| 514 | effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { | 579 | effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { |
| 515 | it.effect("runs Windows executables directly without a shell", () => | 580 | it.effect("runs Windows executables directly without a shell", () => |
| 516 | Effect.gen(function* () { | 581 | Effect.gen(function* () { |
| ... | @@ -627,6 +692,30 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { | ... | @@ -627,6 +692,30 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { |
| 627 | }).pipe(Effect.provideService(CommandResolutionCache, new Map())), | 692 | }).pipe(Effect.provideService(CommandResolutionCache, new Map())), |
| 628 | ); | 693 | ); |
| 629 | 694 | ||
| 695 | it.effect("keeps launcher swaps apart for environments with and without MSYSTEM", () => | ||
| 696 | Effect.gen(function* () { | ||
| 697 | let scans = 0; | ||
| 698 | const scan: SpawnExecutableResolver = () => { | ||
| 699 | scans++; | ||
| 700 | return "C:\\Git\\cmd\\git.exe"; | ||
| 701 | }; | ||
| 702 | const resolve = (env: NodeJS.ProcessEnv) => | ||
| 703 | resolveSpawnCommand("git", [], { | ||
| 704 | env: { PATH: "C:\\Git\\cmd", PATHEXT: ".EXE", ...env }, | ||
| 705 | }).pipe( | ||
| 706 | Effect.provideService(HostProcess.Platform, "win32"), | ||
| 707 | Effect.provideService(SpawnExecutableResolution, scan), | ||
| 708 | ); | ||
| 709 | |||
| 710 | yield* resolve({}); | ||
| 711 | yield* resolve({}); | ||
| 712 | expect(scans).toBe(1); | ||
| 713 | // A swap made without MSYSTEM must not reach a child that has it set. | ||
| 714 | yield* resolve({ MSYSTEM: "MINGW64" }); | ||
| 715 | expect(scans).toBe(2); | ||
| 716 | }).pipe(Effect.provideService(CommandResolutionCache, new Map())), | ||
| 717 | ); | ||
| 718 | |||
| 630 | it.effect("does not fall back to a shell for unresolved Windows commands", () => | 719 | it.effect("does not fall back to a shell for unresolved Windows commands", () => |
| 631 | Effect.gen(function* () { | 720 | Effect.gen(function* () { |
| 632 | const command = yield* resolveSpawnCommand("missing & calc", ["unsafe & value"], { | 721 | const command = yield* resolveSpawnCommand("missing & calc", ["unsafe & value"], { |
packages/shared/src/shell.ts+47-1| ... | @@ -693,6 +693,47 @@ export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function | ... | @@ -693,6 +693,47 @@ export const resolveCommandPath = Effect.fn("shell.resolveCommandPath")(function |
| 693 | }); | 693 | }); |
| 694 | }); | 694 | }); |
| 695 | 695 | ||
| 696 | // Git for Windows 2.56 moved x64 builds from mingw64 to ucrt64; ARM64 builds | ||
| 697 | // live in clangarm64 and 32-bit ones in mingw32. | ||
| 698 | const GIT_FOR_WINDOWS_BUILDS = ["ucrt64", "clangarm64", "mingw64", "mingw32"] as const; | ||
| 699 | |||
| 700 | function isFileSync(filePath: string): boolean { | ||
| 701 | try { | ||
| 702 | return NodeFS.statSync(filePath).isFile(); | ||
| 703 | } catch { | ||
| 704 | return false; | ||
| 705 | } | ||
| 706 | } | ||
| 707 | |||
| 708 | /** | ||
| 709 | * Swaps Git for Windows' launcher (`<Git>\cmd\git.exe`, the only git its | ||
| 710 | * installer puts on PATH, or the portable build's `<Git>\bin\git.exe`) for the | ||
| 711 | * git.exe it starts. The launcher costs a second process on every git command, | ||
| 712 | * and each launch leaks a kernel token reference that slows process creation | ||
| 713 | * machine-wide until reboot. The real binary sets HOME itself, but adds its own | ||
| 714 | * folders to PATH for hooks, ssh and credential helpers only when MSYSTEM is | ||
| 715 | * unset, so the launcher stays when MSYSTEM is set. Anything else is returned | ||
| 716 | * as is. | ||
| 717 | */ | ||
| 718 | export function preferGitForWindowsBinary( | ||
| 719 | executable: string, | ||
| 720 | env: NodeJS.ProcessEnv, | ||
| 721 | isFile: (filePath: string) => boolean = isFileSync, | ||
| 722 | ): string { | ||
| 723 | if (env.MSYSTEM) return executable; | ||
| 724 | const path = NodePath.win32; | ||
| 725 | if (path.basename(executable).toLowerCase() !== "git.exe") return executable; | ||
| 726 | const launcherDirectory = path.dirname(executable); | ||
| 727 | const launcherFolder = path.basename(launcherDirectory).toLowerCase(); | ||
| 728 | if (launcherFolder !== "cmd" && launcherFolder !== "bin") return executable; | ||
| 729 | const installRoot = path.dirname(launcherDirectory); | ||
| 730 | for (const build of GIT_FOR_WINDOWS_BUILDS) { | ||
| 731 | const candidate = path.join(installRoot, build, "bin", "git.exe"); | ||
| 732 | if (isFile(candidate)) return candidate; | ||
| 733 | } | ||
| 734 | return executable; | ||
| 735 | } | ||
| 736 | |||
| 696 | // Untraced because it runs before most spawns and returns at once off Windows. | 737 | // Untraced because it runs before most spawns and returns at once off Windows. |
| 697 | export const resolveSpawnCommand = Effect.fnUntraced(function* ( | 738 | export const resolveSpawnCommand = Effect.fnUntraced(function* ( |
| 698 | command: string, | 739 | command: string, |
| ... | @@ -724,6 +765,7 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( | ... | @@ -724,6 +765,7 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( |
| 724 | platform, | 765 | platform, |
| 725 | resolvePathEnvironmentVariable(env), | 766 | resolvePathEnvironmentVariable(env), |
| 726 | resolveWindowsPathExtensions(env).join(";"), | 767 | resolveWindowsPathExtensions(env).join(";"), |
| 768 | env.MSYSTEM ? "msystem" : "", | ||
| 727 | command, | 769 | command, |
| 728 | ].join(COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR); | 770 | ].join(COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR); |
| 729 | const nowNanos = yield* Clock.currentTimeNanos; | 771 | const nowNanos = yield* Clock.currentTimeNanos; |
| ... | @@ -732,7 +774,11 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( | ... | @@ -732,7 +774,11 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( |
| 732 | if (cached !== undefined && cached.expiresAtNanos > nowNanos) { | 774 | if (cached !== undefined && cached.expiresAtNanos > nowNanos) { |
| 733 | resolvedExecutable = cached.resolvedPath; | 775 | resolvedExecutable = cached.resolvedPath; |
| 734 | } else { | 776 | } else { |
| 735 | resolvedExecutable = resolveExecutable(command, platform, env) ?? null; | 777 | // Cached with the scan: its file checks would otherwise run before every |
| 778 | // git launch. A Git upgrade that moves the real binary can fail git for up | ||
| 779 | // to the cache lifetime. | ||
| 780 | const found = resolveExecutable(command, platform, env); | ||
| 781 | resolvedExecutable = found === undefined ? null : preferGitForWindowsBinary(found, env); | ||
| 736 | if (!explicitPath && resolvedExecutable !== null) { | 782 | if (!explicitPath && resolvedExecutable !== null) { |
| 737 | cacheCommandResolution(cache, cacheKey, resolvedExecutable, nowNanos); | 783 | cacheCommandResolution(cache, cacheKey, resolvedExecutable, nowNanos); |
| 738 | } | 784 | } |