diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 4df6d0eefc5c..45e3f97470a3 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -12,9 +12,10 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { assert, it } from "@effect/vitest"; +import { assert, it, vi } from "@effect/vitest"; import { CheckpointRef, GitCommandError, VcsProcessExitError } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as ServerConfig from "../config.ts"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -1116,3 +1117,70 @@ it.effect("GitVcsDriver flushes checkpoint objects and refs to disk before publi ), ); }); + +for (const [platform, countKey] of [ + ["win32", "GIT_CONFIG_COUNT"], + ["win32", "git_config_count"], + ["linux", "GIT_CONFIG_COUNT"], +] as const) { + it.effect(`GitVcsDriver applies long path configuration for ${platform} with ${countKey}`, () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped(); + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const readConfig = Effect.fn("readConfig")(function* (key: string) { + const result = yield* driver.execute({ + operation: "GitVcsDriver.test.longpaths", + cwd, + args: ["config", "--get", key], + env: { + [countKey]: "2", + GIT_CONFIG_KEY_0: "user.name", + GIT_CONFIG_VALUE_0: "inherited-name", + GIT_CONFIG_KEY_1: "core.longpaths", + GIT_CONFIG_VALUE_1: "false", + GIT_CONFIG_KEY_2: "user.name", + GIT_CONFIG_VALUE_2: "outside-count", + }, + }); + return result.stdout.trim(); + }); + + assert.equal(yield* readConfig("core.longpaths"), platform === "win32" ? "true" : "false"); + assert.equal(yield* readConfig("user.name"), "inherited-name"); + }).pipe(Effect.provide(GitContractLayer), Effect.provideService(HostProcessPlatform, platform)), + ); +} + +it.effect("captures and restores checkpoints with paths beyond MAX_PATH", () => + Effect.gen(function* () { + yield* Effect.acquireRelease( + Effect.sync(() => { + const count = Number(process.env.GIT_CONFIG_COUNT ?? "0"); + vi.stubEnv(`GIT_CONFIG_KEY_${count}`, "core.longpaths"); + vi.stubEnv(`GIT_CONFIG_VALUE_${count}`, "false"); + vi.stubEnv("GIT_CONFIG_COUNT", String(count + 1)); + }), + () => Effect.sync(() => vi.unstubAllEnvs()), + ); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped(); + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + yield* driver.initRepository({ cwd }); + const filePath = path.join( + cwd, + ...Array.from({ length: 6 }, () => "nested-".repeat(6)), + "file.txt", + ); + assert.isAbove(filePath.length, 260); + yield* fileSystem.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fileSystem.writeFileString(filePath, "checkpoint content\n"); + + const input = { cwd, checkpointRef: CheckpointRef.make("refs/t3/checkpoints/longpaths") }; + yield* driver.checkpoints.captureCheckpoint(input); + yield* fileSystem.writeFileString(filePath, "changed content\n"); + assert.isTrue(yield* driver.checkpoints.restoreCheckpoint(input)); + assert.equal(yield* fileSystem.readFileString(filePath), "checkpoint content\n"); + }).pipe(Effect.provide(GitContractLayer), Effect.provideService(HostProcessPlatform, "win32")), +); diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 85c4d1a59d1d..f46175c9baea 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -36,6 +36,7 @@ import { splitNullSeparatedGitStdoutPaths, } from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; +import { gitCommandEnv, windowsLongPathConfigEnv } from "./VcsProcess.ts"; const encodeGitCommandError = Schema.encodeEffect(Schema.fromJsonString(GitCommandError)); @@ -3263,3 +3264,208 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { ); }); }); + +describe("Windows long path configuration", () => { + const readGitConfig = Effect.fn("readGitConfig")(function* ( + platform: NodeJS.Platform, + key: string, + countKey = "GIT_CONFIG_COUNT", + ) { + const layer = GitVcsDriver.layer.pipe( + Layer.provide(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), + Layer.provide(Layer.succeed(HostProcessPlatform, platform)), + ); + + return yield* Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const cwd = yield* makeTmpDir("git-longpath-test-"); + const result = yield* driver.execute({ + operation: "GitVcsDriverTest.readGitConfig", + cwd, + args: ["config", "--get", key], + // Override the suite's longpaths=true so it cannot mask missing injection. + env: { + [countKey]: "2", + GIT_CONFIG_KEY_0: "user.name", + GIT_CONFIG_VALUE_0: "inherited-name", + GIT_CONFIG_KEY_1: "core.longpaths", + GIT_CONFIG_VALUE_1: "false", + GIT_CONFIG_KEY_2: "user.name", + GIT_CONFIG_VALUE_2: "outside-count", + }, + }); + return result.stdout.trim(); + }).pipe(Effect.provide(layer)); + }); + + it.effect("enables long paths in Git on Windows while preserving inherited config", () => + Effect.gen(function* () { + assert.equal(yield* readGitConfig("win32", "core.longpaths"), "true"); + assert.equal(yield* readGitConfig("win32", "user.name"), "inherited-name"); + }), + ); + + it.effect("preserves inherited Git config on platforms without MAX_PATH", () => + Effect.gen(function* () { + assert.equal(yield* readGitConfig("linux", "core.longpaths"), "false"); + assert.equal(yield* readGitConfig("linux", "user.name"), "inherited-name"); + }), + ); + + it.effect("honors a caller count with different casing from the host", () => + Effect.gen(function* () { + assert.equal( + yield* readGitConfig("win32", "user.name", "git_config_count"), + "inherited-name", + ); + assert.equal(yield* readGitConfig("win32", "core.longpaths", "git_config_count"), "true"); + }), + ); +}); + +describe("gitCommandEnv", () => { + it("merges count overrides regardless of casing without changing the sources", () => { + for (const [hostKey, callerKey] of [ + ["GIT_CONFIG_COUNT", "git_config_count"], + ["git_config_count", "GIT_CONFIG_COUNT"], + ] as const) { + const host = Object.freeze({ + [hostKey]: "3", + GIT_CONFIG_KEY_0: "user.name", + GIT_CONFIG_VALUE_0: "inherited-name", + }); + const caller = Object.freeze({ [callerKey]: "1" }); + assert.deepStrictEqual(gitCommandEnv("win32", host, caller), { + GIT_CONFIG_COUNT: "2", + GIT_CONFIG_KEY_0: "user.name", + GIT_CONFIG_VALUE_0: "inherited-name", + GIT_CONFIG_KEY_1: "core.longpaths", + GIT_CONFIG_VALUE_1: "true", + }); + } + }); + + it("lets caller config entries replace inherited ones regardless of casing", () => { + const host = { + GIT_CONFIG_COUNT: "2", + GIT_CONFIG_KEY_0: "user.name", + GIT_CONFIG_VALUE_0: "host-name", + GIT_CONFIG_KEY_1: "user.email", + GIT_CONFIG_VALUE_1: "host@example.com", + }; + const caller = { + git_config_count: "1", + git_config_key_0: "user.name", + git_config_value_0: "caller-name", + }; + assert.deepStrictEqual(gitCommandEnv("win32", host, caller), { + GIT_CONFIG_COUNT: "2", + GIT_CONFIG_KEY_0: "user.name", + GIT_CONFIG_VALUE_0: "caller-name", + GIT_CONFIG_KEY_1: "core.longpaths", + GIT_CONFIG_VALUE_1: "true", + }); + }); + + it("preserves a malformed caller override for Git to reject", () => { + assert.deepStrictEqual( + gitCommandEnv("win32", { GIT_CONFIG_COUNT: "2" }, { git_config_count: "nope" }), + { GIT_CONFIG_COUNT: "nope" }, + ); + }); + + it("keeps non-Windows environment variable names case-sensitive", () => { + assert.deepStrictEqual( + gitCommandEnv("linux", { GIT_CONFIG_COUNT: "2" }, undefined, { git_config_count: "1" }), + { GIT_CONFIG_COUNT: "2", git_config_count: "1" }, + ); + }); +}); + +describe("windowsLongPathConfigEnv", () => { + it("enables core.longpaths on Windows", () => { + assert.deepStrictEqual(windowsLongPathConfigEnv("win32", {}), { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "core.longpaths", + GIT_CONFIG_VALUE_0: "true", + }); + }); + + it("adds nothing on platforms without MAX_PATH", () => { + assert.deepStrictEqual(windowsLongPathConfigEnv("linux", {}), {}); + assert.deepStrictEqual(windowsLongPathConfigEnv("darwin", { GIT_CONFIG_COUNT: "2" }), {}); + }); + + it("appends after inherited entries instead of overwriting them", () => { + assert.deepStrictEqual(windowsLongPathConfigEnv("win32", { GIT_CONFIG_COUNT: "2" }), { + GIT_CONFIG_COUNT: "3", + GIT_CONFIG_KEY_2: "core.longpaths", + GIT_CONFIG_VALUE_2: "true", + }); + }); + + it("treats an absent or empty count as no inherited entries", () => { + const expected = { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "core.longpaths", + GIT_CONFIG_VALUE_0: "true", + }; + assert.deepStrictEqual(windowsLongPathConfigEnv("win32", { GIT_CONFIG_COUNT: "" }), expected); + assert.deepStrictEqual(windowsLongPathConfigEnv("win32", { GIT_CONFIG_COUNT: "0" }), expected); + }); + + it("accepts Git's leading whitespace and optional plus sign in a count", () => { + for (const count of [" 2", "+2", "\t+2", "02"]) { + assert.deepStrictEqual(windowsLongPathConfigEnv("win32", { GIT_CONFIG_COUNT: count }), { + GIT_CONFIG_COUNT: "3", + GIT_CONFIG_KEY_2: "core.longpaths", + GIT_CONFIG_VALUE_2: "true", + }); + } + }); + + it("accepts a negative zero count, which Git parses as zero", () => { + for (const count of ["-0", "-00", " -0"]) { + assert.deepStrictEqual(windowsLongPathConfigEnv("win32", { GIT_CONFIG_COUNT: count }), { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "core.longpaths", + GIT_CONFIG_VALUE_0: "true", + }); + } + }); + + it("reuses an inherited count whose name differs only in case", () => { + const result = windowsLongPathConfigEnv("win32", { git_config_count: "2" }); + + assert.deepStrictEqual(result, { + git_config_count: "3", + GIT_CONFIG_KEY_2: "core.longpaths", + GIT_CONFIG_VALUE_2: "true", + }); + // Two entries differing only in case collapse on spawn, and the survivor + // would decide whether the caller's config or this one is honoured. + assert.equal( + Object.keys(result).filter((key) => key.toUpperCase() === "GIT_CONFIG_COUNT").length, + 1, + ); + }); + + it("leaves a malformed count alone regardless of its casing", () => { + assert.deepStrictEqual(windowsLongPathConfigEnv("win32", { git_config_count: "nope" }), {}); + }); + + it("leaves a malformed count alone so git still reports it", () => { + for (const malformed of ["not-a-number", "2x", "-1", "-01", "1.5", " ", "1 ", "1\n"]) { + assert.deepStrictEqual( + windowsLongPathConfigEnv("win32", { + GIT_CONFIG_COUNT: malformed, + GIT_CONFIG_KEY_0: "user.name", + GIT_CONFIG_VALUE_0: "inherited", + }), + {}, + `expected no injection for GIT_CONFIG_COUNT=${malformed}`, + ); + } + }); +}); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 0dd73af687f9..c0a0f0e287f1 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -41,6 +41,7 @@ import { parseRemoteRefWithRemoteNames, } from "../git/remoteRefs.ts"; import { ServerConfig } from "../config.ts"; +import { gitCommandEnv } from "./VcsProcess.ts"; const DEFAULT_TIMEOUT_MS = 30_000; const gitProcesses = Semaphore.makeUnsafe(8); @@ -830,6 +831,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const { worktreesDir } = yield* ServerConfig; const crypto = yield* Crypto.Crypto; + const hostPlatform = yield* HostProcessPlatform; const executeRaw: GitVcsDriver.GitVcsDriver["Service"]["execute"] = Effect.fnUntraced( function* (input) { @@ -858,11 +860,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* .spawn( ChildProcess.make("git", commandInput.args, { cwd: commandInput.cwd, - env: { - ...process.env, - ...input.env, - ...trace2Monitor.env, - }, + env: gitCommandEnv(hostPlatform, process.env, input.env, trace2Monitor.env), }), ) .pipe( diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index 169383d2cbf8..08f92da52330 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -1,6 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, expect, it } from "@effect/vitest"; -import { HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess"; +import { HostProcessPlatform, HostProcessWorkingDirectory } from "@t3tools/shared/hostProcess"; import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -292,6 +292,26 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); + it.effect.each(["win32", "linux"] as const)( + "enables Git long paths only on Windows hosts (%s)", + (platform) => + Effect.gen(function* () { + const result = yield* run({ + operation: "test.longpaths", + command: "git", + args: ["config", "--get", "core.longpaths"], + cwd: process.cwd(), + env: { + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "core.longpaths", + GIT_CONFIG_VALUE_0: "false", + }, + }); + + expect(result.stdout.trim()).toBe(platform === "win32" ? "true" : "false"); + }).pipe(provideLive, Effect.provideService(HostProcessPlatform, platform)), + ); + it.effect("writes stdin before waiting for exit", () => Effect.gen(function* () { const result = yield* run({ diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index 25f3a23c368a..cadb29aab76f 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -18,6 +18,7 @@ import { VcsProcessStdinWriteError, VcsProcessTimeoutError, } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as ProcessRunner from "../processRunner.ts"; export interface VcsProcessInput { @@ -113,6 +114,75 @@ const isTransientGitExit = (stderr: string) => /unable to create [^\n]*\.lock['"]?: file exists/i.test(stderr) || /(?:unable to stat|lstat\(|error: open\()[^\n]+: no such file or directory/i.test(stderr); +/** + * Git on Windows refuses to create or delete a path longer than MAX_PATH (260) + * unless `core.longpaths` is set. The OS-level LongPathsEnabled setting does not + * cover it: git opts in per repository, and it is off by default. + * + * Worktrees are where this bites. A worktree base path is longer than the + * repository root, so a repository that clones fine can still fail to check out + * into a worktree, and fail to be removed afterwards. A failed removal can drop + * the administrative record and leave files that `git worktree list` cannot see. + * + * Passed per invocation through `GIT_CONFIG_*` rather than argv so the config + * reaches every git subcommand without changing the command line, and nothing is + * written to the user's config. Appended after any entries the caller already + * set so an inherited `GIT_CONFIG_COUNT` keeps working. + * + * A `GIT_CONFIG_COUNT` that is not a count is left alone. Git rejects a bogus + * value itself, and appending to it would overwrite the caller's first entry and + * turn that loud failure into a silently different config. + */ +export const windowsLongPathConfigEnv = ( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv => { + if (platform !== "win32") { + return {}; + } + const countKey = + Object.keys(env).find((key) => key.toUpperCase() === "GIT_CONFIG_COUNT") ?? "GIT_CONFIG_COUNT"; + const inherited = env[countKey]; + if ( + inherited !== undefined && + inherited !== "" && + /^[ \t\r\n\v\f]*(?:\+?\d+|-0+)/.exec(inherited)?.[0] !== inherited + ) { + return {}; + } + const count = inherited === undefined || inherited === "" ? 0 : Number.parseInt(inherited, 10); + return { + [countKey]: String(count + 1), + [`GIT_CONFIG_KEY_${count}`]: "core.longpaths", + [`GIT_CONFIG_VALUE_${count}`]: "true", + }; +}; + +const GIT_CONFIG_ENV_KEY = /^GIT_CONFIG_(?:COUNT|KEY_\d+|VALUE_\d+)$/i; + +/** + * Merge env sources in precedence order for a git spawn. On Windows, env names + * are case-insensitive and spawn keeps only one spelling of each, so `GIT_CONFIG_*` + * names are uppercased first and a later source's entry replaces an earlier one + * regardless of casing. + */ +export const gitCommandEnv = ( + platform: NodeJS.Platform, + ...sources: ReadonlyArray +): NodeJS.ProcessEnv => { + const env: NodeJS.ProcessEnv = {}; + for (const source of sources) { + if (platform !== "win32") { + Object.assign(env, source); + continue; + } + for (const [key, value] of Object.entries(source ?? {})) { + env[GIT_CONFIG_ENV_KEY.test(key) ? key.toUpperCase() : key] = value; + } + } + return { ...env, ...windowsLongPathConfigEnv(platform, env) }; +}; + export const make = Effect.gen(function* () { const processRunner = yield* ProcessRunner.ProcessRunner; const vcsProcesses = yield* Semaphore.make(VCS_PROCESS_CONCURRENCY); @@ -125,6 +195,11 @@ export const make = Effect.gen(function* () { cwd: input.cwd, argumentCount: input.args.length, }; + const platform = yield* HostProcessPlatform; + const env = + input.command === "git" && platform === "win32" + ? gitCommandEnv(platform, process.env, input.env) + : input.env; const result = yield* processRunner .run({ @@ -134,7 +209,7 @@ export const make = Effect.gen(function* () { ...(input.spawnCwd !== undefined ? { spawnCwd: input.spawnCwd } : {}), ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), ...(input.onStdoutChunk !== undefined ? { onStdoutChunk: input.onStdoutChunk } : {}), - ...(input.env !== undefined ? { env: input.env } : {}), + ...(env !== undefined ? { env } : {}), timeout: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, maxOutputBytes: input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, outputMode: input.outputMode ?? "truncate",