authorgravatar for americanemt@gmail.comRoger Maxwell <americanemt@gmail.com> 2026-10-07 12:27:53-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 10:27:53-07:00
log4ce6a51a4c4bbc9a2fc9e371ecb446851f953482
tree533d607ba683b99320b4186b5a7b7afddbda7d0e
parente0201d3ab51b4593eff3c41d03ab9c9b2dc07471
signature Signed by PGP key B5690EEEBB952194

fix(server): Windows worktrees with long paths no longer fail or strand (#14917)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

2 files changed, 141 insertions(+), 8 deletions(-)

apps/server/src/vcs/GitVcsDriverCore.test.ts+92
...@@ -35,6 +35,7 @@ import {...@@ -35,6 +35,7 @@ import {
35 makeGitVcsDriverCore,35 makeGitVcsDriverCore,
36 parseGitCheckoutProgressLine,36 parseGitCheckoutProgressLine,
37 splitNullSeparatedGitStdoutPaths,37 splitNullSeparatedGitStdoutPaths,
38 windowsLongPathConfigEnv,
38} from "./GitVcsDriverCore.ts";39} from "./GitVcsDriverCore.ts";
39import * as GitVcsDriver from "./GitVcsDriver.ts";40import * as GitVcsDriver from "./GitVcsDriver.ts";
4041
...@@ -3955,3 +3956,94 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {...@@ -3955,3 +3956,94 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
3955 );3956 );
3956 });3957 });
3957});3958});
3959
3960describe("Windows long path configuration", () => {
3961 const readGitConfig = Effect.fn("readGitConfig")(function* (
3962 platform: NodeJS.Platform,
3963 key: string,
3964 ) {
3965 const layer = GitVcsDriver.layer.pipe(
3966 Layer.provide(layerServerConfig),
3967 Layer.provideMerge(NodeServices.layer),
3968 Layer.provide(Layer.succeed(HostProcessPlatform, platform)),
3969 );
3970 return yield* Effect.gen(function* () {
3971 const driver = yield* GitVcsDriver.GitVcsDriver;
3972 const cwd = yield* makeTmpDir("git-longpath-test-");
3973 const result = yield* driver.execute({
3974 operation: "GitVcsDriverTest.readGitConfig",
3975 cwd,
3976 args: ["config", "--get", key],
3977 // Replaces the suite's own core.longpaths=true so it cannot mask a
3978 // missing injection. Entry 2 sits past the count and must stay unread.
3979 env: {
3980 GIT_CONFIG_COUNT: "2",
3981 GIT_CONFIG_KEY_0: "user.name",
3982 GIT_CONFIG_VALUE_0: "inherited-name",
3983 GIT_CONFIG_KEY_1: "core.longpaths",
3984 GIT_CONFIG_VALUE_1: "false",
3985 GIT_CONFIG_KEY_2: "user.name",
3986 GIT_CONFIG_VALUE_2: "outside-count",
3987 },
3988 });
3989 return result.stdout.trim();
3990 }).pipe(Effect.provide(layer), Effect.scoped);
3991 });
3992
3993 it.effect("enables long paths for every Git command on Windows", () =>
3994 Effect.gen(function* () {
3995 assert.equal(yield* readGitConfig("win32", "core.longpaths"), "true");
3996 assert.equal(yield* readGitConfig("win32", "user.name"), "inherited-name");
3997 }),
3998 );
3999
4000 it.effect("leaves Git config untouched on other platforms", () =>
4001 Effect.gen(function* () {
4002 assert.equal(yield* readGitConfig("linux", "core.longpaths"), "false");
4003 assert.equal(yield* readGitConfig("linux", "user.name"), "inherited-name");
4004 }),
4005 );
4006
4007 it("extends an inherited count spelled in a different case", () => {
4008 // Two spellings collapse to one on spawn (the uppercase one wins), so the
4009 // count must be bumped under the spelling that is already there.
4010 assert.deepStrictEqual(windowsLongPathConfigEnv("win32", { git_config_count: "2" }), {
4011 git_config_count: "3",
4012 GIT_CONFIG_KEY_2: "core.longpaths",
4013 GIT_CONFIG_VALUE_2: "true",
4014 });
4015 });
4016
4017 it("appends after inherited entries, parsing the count as Git does", () => {
4018 for (const [count, next] of [
4019 [undefined, 0],
4020 ["", 0],
4021 ["0", 0],
4022 ["-0", 0],
4023 ["2", 2],
4024 [" 2", 2],
4025 ["+2", 2],
4026 ["02", 2],
4027 ] as const) {
4028 assert.deepStrictEqual(
4029 windowsLongPathConfigEnv("win32", count === undefined ? {} : { GIT_CONFIG_COUNT: count }),
4030 {
4031 GIT_CONFIG_COUNT: String(next + 1),
4032 [`GIT_CONFIG_KEY_${next}`]: "core.longpaths",
4033 [`GIT_CONFIG_VALUE_${next}`]: "true",
4034 },
4035 `GIT_CONFIG_COUNT=${JSON.stringify(count)}`,
4036 );
4037 }
4038 });
4039
4040 it("leaves a count Git would reject for Git to report", () => {
4041 for (const count of ["nope", "2x", "-1", "1.5", " ", "1 "]) {
4042 assert.deepStrictEqual(
4043 windowsLongPathConfigEnv("win32", { GIT_CONFIG_COUNT: count }),
4044 {},
4045 `GIT_CONFIG_COUNT=${JSON.stringify(count)}`,
4046 );
4047 }
4048 });
4049});
apps/server/src/vcs/GitVcsDriverCore.ts+49-8
...@@ -899,12 +899,52 @@ const collectOutput = Effect.fnUntraced(function* (...@@ -899,12 +899,52 @@ const collectOutput = Effect.fnUntraced(function* (
899 };899 };
900});900});
901901
902/**
903 * Git on Windows cannot create or delete a path longer than MAX_PATH (260)
904 * unless `core.longpaths` is set. The OS-level LongPathsEnabled setting does not
905 * cover it, and git leaves it off by default, so deep worktrees fail to check
906 * out and fail midway through removal.
907 *
908 * Passed through `GIT_CONFIG_*` rather than `-c` so argv stays the same on every
909 * platform and nothing is written to the user's config. The entry is appended
910 * after any inherited ones. Windows env names are case-insensitive, so the
911 * inherited count is found regardless of casing and updated under its own name.
912 * A count git would reject is left alone so git still reports it.
913 */
914export const windowsLongPathConfigEnv = (
915 platform: NodeJS.Platform,
916 env: NodeJS.ProcessEnv,
917): NodeJS.ProcessEnv => {
918 if (platform !== "win32") {
919 return {};
920 }
921 const countKey =
922 Object.keys(env).find((key) => key.toUpperCase() === "GIT_CONFIG_COUNT") ?? "GIT_CONFIG_COUNT";
923 const inherited = env[countKey];
924 if (
925 inherited !== undefined &&
926 inherited !== "" &&
927 // Git parses the count with strtoul: leading whitespace, an optional sign
928 // (only -0 is non-negative), digits, and nothing after them.
929 /^[ \t\r\n\v\f]*(?:\+?\d+|-0+)/.exec(inherited)?.[0] !== inherited
930 ) {
931 return {};
932 }
933 const count = inherited === undefined || inherited === "" ? 0 : Number.parseInt(inherited, 10);
934 return {
935 [countKey]: String(count + 1),
936 [`GIT_CONFIG_KEY_${count}`]: "core.longpaths",
937 [`GIT_CONFIG_VALUE_${count}`]: "true",
938 };
939};
940
902export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* () {941export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* () {
903 const fileSystem = yield* FileSystem.FileSystem;942 const fileSystem = yield* FileSystem.FileSystem;
904 const path = yield* Path.Path;943 const path = yield* Path.Path;
905 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;944 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
906 const { worktreesDir } = yield* ServerConfig.ServerConfig;945 const { worktreesDir } = yield* ServerConfig.ServerConfig;
907 const crypto = yield* Crypto.Crypto;946 const crypto = yield* Crypto.Crypto;
947 const hostPlatform = yield* HostProcessPlatform;
908948
909 const executeRaw: GitVcsDriver.GitVcsDriver["Service"]["execute"] = Effect.fnUntraced(949 const executeRaw: GitVcsDriver.GitVcsDriver["Service"]["execute"] = Effect.fnUntraced(
910 function* (input) {950 function* (input) {
...@@ -929,18 +969,19 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -929,18 +969,19 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
929 }),969 }),
930 ),970 ),
931 );971 );
972 const env = {
973 ...process.env,
974 // Status polling runs beside the user's own git commands; without this,
975 // `git status` takes index.lock to save its refreshed index.
976 GIT_OPTIONAL_LOCKS: "0",
977 ...input.env,
978 ...trace2Monitor.env,
979 };
932 const child = yield* commandSpawner980 const child = yield* commandSpawner
933 .spawn(981 .spawn(
934 ChildProcess.make("git", commandInput.args, {982 ChildProcess.make("git", commandInput.args, {
935 cwd: commandInput.cwd,983 cwd: commandInput.cwd,
936 env: {984 env: { ...env, ...windowsLongPathConfigEnv(hostPlatform, env) },
937 ...process.env,
938 // Status polling runs beside the user's own git commands; without this,
939 // `git status` takes index.lock to save its refreshed index.
940 GIT_OPTIONAL_LOCKS: "0",
941 ...input.env,
942 ...trace2Monitor.env,
943 },
944 }),985 }),
945 )986 )
946 .pipe(987 .pipe(