| author | |
| committer | |
| log | 4ce6a51a4c4bbc9a2fc9e371ecb446851f953482 |
| tree | 533d607ba683b99320b4186b5a7b7afddbda7d0e |
| parent | e0201d3ab51b4593eff3c41d03ab9c9b2dc07471 |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 39 | import * as GitVcsDriver from "./GitVcsDriver.ts"; | 40 | import * as GitVcsDriver from "./GitVcsDriver.ts"; |
| 40 | 41 | ||
| ... | @@ -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 | |||
| 3960 | describe("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 | }); |
| 901 | 901 | ||
| 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 | */ | ||
| 914 | export 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 | |||
| 902 | export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* () { | 941 | export 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; | ||
| 908 | 948 | ||
| 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* commandSpawner | 980 | 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( |