diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 031055a3b6cb..7caf2ae49f74 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -5,9 +5,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 { GitCommandError } from "@t3tools/contracts"; +import { CheckpointRef, GitCommandError } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; @@ -84,7 +85,7 @@ it.effect("GitVcsDriver forwards execute env to the VCS process", () => { outputMode: "error", }); - assert.deepStrictEqual(observedEnv, { + assert.deepInclude(observedEnv, { GIT_INDEX_FILE: "/tmp/t3-index", }); assert.strictEqual(observedAppendTruncationMarker, true); @@ -112,3 +113,70 @@ it.effect("GitVcsDriver forwards execute env to the VCS process", () => { ), ); }); + +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/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index f1e48a24d6fe..681074be7aec 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -30,7 +30,9 @@ import { type VcsStatusInput, type VcsStatusResult, } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { + gitCommandEnv, makeGitVcsDriverCore, PATCH_RENDER_PREFIX_ARGS, splitNullSeparatedGitStdoutPaths, @@ -420,7 +422,7 @@ function parseGitRemoteVerboseOutput( return remotes; } -const gitCommand = ( +const gitCommand = Effect.fn("GitVcsDriver.gitCommand")(function* ( process: VcsProcess.VcsProcess["Service"], operation: string, cwd: string, @@ -434,15 +436,20 @@ const gitCommand = ( readonly outputMode?: VcsProcess.VcsProcessInput["outputMode"]; readonly appendTruncationMarker?: boolean; }, -) => - process.run({ +) { + const platform = yield* HostProcessPlatform; + let env = options?.env; + if (platform === "win32") { + env = gitCommandEnv(platform, globalThis.process.env, env); + } + return yield* process.run({ operation, command: "git", args: ["-C", cwd, ...args], cwd, spawnCwd: globalThis.process.cwd(), ...(options?.stdin !== undefined ? { stdin: options.stdin } : {}), - ...(options?.env !== undefined ? { env: options.env } : {}), + ...(env !== undefined ? { env } : {}), ...(options?.allowNonZeroExit !== undefined ? { allowNonZeroExit: options.allowNonZeroExit } : {}), @@ -453,6 +460,7 @@ const gitCommand = ( ? { appendTruncationMarker: options.appendTruncationMarker } : {}), }); +}); export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index fb741b28ee7c..16df73bcf0d8 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -20,7 +20,12 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, type ReviewDiffFileContentsInput } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; -import { makeGitVcsDriverCore, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; +import { + gitCommandEnv, + makeGitVcsDriverCore, + splitNullSeparatedGitStdoutPaths, + windowsLongPathConfigEnv, +} from "./GitVcsDriverCore.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { @@ -2110,3 +2115,176 @@ 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("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("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", "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 3c7e018ddea7..39a6f6904876 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -27,6 +27,7 @@ import { type VcsRef, } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { compactTraceAttributes } from "@t3tools/shared/observability"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../observability/Metrics.ts"; @@ -108,6 +109,68 @@ const NON_REPOSITORY_REMOTE_STATUS_DETAILS = Object.freeze { + 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+/.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", + }; +}; + +/** Merge in precedence order so a caller's count wins regardless of casing on Windows. */ +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[key.toUpperCase() === "GIT_CONFIG_COUNT" ? "GIT_CONFIG_COUNT" : key] = value; + } + } + return { ...env, ...windowsLongPathConfigEnv(platform, env) }; +}; + type TraceTailState = { processedChars: number; remainder: string; @@ -727,6 +790,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) { @@ -755,11 +819,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(