| 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 | 32 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 33 | 33 | import { compactTraceAttributes } from "@t3tools/shared/observability"; |
| 34 | 34 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; |
| 35 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; | |
| 35 | 36 | import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; |
| 36 | 37 | import { resolveProjectFileBackedSetting } from "@t3tools/shared/projectSettings"; |
| 37 | 38 | import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; |
| ... | ... | @@ -977,11 +978,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 977 | 978 | ...input.env, |
| 978 | 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 | 986 | const child = yield* commandSpawner |
| 981 | 987 | .spawn( |
| 982 | ChildProcess.make("git", commandInput.args, { | |
| 988 | ChildProcess.make(executable, commandInput.args, { | |
| 983 | 989 | cwd: commandInput.cwd, |
| 984 | env: { ...env, ...windowsLongPathConfigEnv(hostPlatform, env) }, | |
| 990 | env: spawnEnv, | |
| 985 | 991 | }), |
| 986 | 992 | ) |
| 987 | 993 | .pipe( |
packages/shared/src/shell.test.ts+89| ... | ... | @@ -15,6 +15,7 @@ import { |
| 15 | 15 | listLoginShellCandidates, |
| 16 | 16 | mergePathEntries, |
| 17 | 17 | mergePathValues, |
| 18 | preferGitForWindowsBinary, | |
| 18 | 19 | readEnvironmentFromLoginShell, |
| 19 | 20 | readEnvironmentFromWindowsShell, |
| 20 | 21 | readPathFromLaunchctl, |
| ... | ... | @@ -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 | 579 | effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { |
| 515 | 580 | it.effect("runs Windows executables directly without a shell", () => |
| 516 | 581 | Effect.gen(function* () { |
| ... | ... | @@ -627,6 +692,30 @@ effectIt.layer(NodeServices.layer)("resolveSpawnCommand", (it) => { |
| 627 | 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 | 719 | it.effect("does not fall back to a shell for unresolved Windows commands", () => |
| 631 | 720 | Effect.gen(function* () { |
| 632 | 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 | 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 | 737 | // Untraced because it runs before most spawns and returns at once off Windows. |
| 697 | 738 | export const resolveSpawnCommand = Effect.fnUntraced(function* ( |
| 698 | 739 | command: string, |
| ... | ... | @@ -724,6 +765,7 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( |
| 724 | 765 | platform, |
| 725 | 766 | resolvePathEnvironmentVariable(env), |
| 726 | 767 | resolveWindowsPathExtensions(env).join(";"), |
| 768 | env.MSYSTEM ? "msystem" : "", | |
| 727 | 769 | command, |
| 728 | 770 | ].join(COMMAND_RESOLUTION_CACHE_KEY_SEPARATOR); |
| 729 | 771 | const nowNanos = yield* Clock.currentTimeNanos; |
| ... | ... | @@ -732,7 +774,11 @@ export const resolveSpawnCommand = Effect.fnUntraced(function* ( |
| 732 | 774 | if (cached !== undefined && cached.expiresAtNanos > nowNanos) { |
| 733 | 775 | resolvedExecutable = cached.resolvedPath; |
| 734 | 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 | 782 | if (!explicitPath && resolvedExecutable !== null) { |
| 737 | 783 | cacheCommandResolution(cache, cacheKey, resolvedExecutable, nowNanos); |
| 738 | 784 | } |