diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 4df6d0eefc5c..c0ff1cfe2016 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -15,6 +15,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { assert, it } 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,45 @@ it.effect("GitVcsDriver flushes checkpoint objects and refs to disk before publi ), ); }); + +it.effect.each(["win32", "linux"] as const)( + "checkpoint working-tree commands enable Git long paths only on Windows (%s)", + (platform) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const liveProcess = yield* VcsProcess.VcsProcess; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-long-paths-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + // An unborn nested repository fails the first `add`, so recovery's listing runs too. + yield* git(["init", "empty"]); + const observedArgs: ReadonlyArray[] = []; + const platformDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(HostProcessPlatform, platform), + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => { + observedArgs.push(input.args); + return liveProcess.run(input); + }, + }), + ); + + yield* platformDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + yield* fs.writeFileString(path.join(cwd, "file.txt"), "changed\n"); + assert.isTrue(yield* platformDriver.checkpoints.restoreCheckpoint({ cwd, checkpointRef })); + + assert.strictEqual(yield* fs.readFileString(path.join(cwd, "file.txt")), "unstaged\n"); + assert.strictEqual((yield* git(["ls-tree", checkpointRef, "--", "empty"])).stdout, ""); + const worktreeCommands = observedArgs.filter((args) => + ["add", "--others", "restore", "clean", "reset"].some((command) => args.includes(command)), + ); + // Failed add, recovery listing, retried add, then restore, clean and reset. + assert.strictEqual(worktreeCommands.length, 6); + for (const args of worktreeCommands) { + const setting = args.indexOf("core.longpaths=true"); + if (platform === "win32") assert.strictEqual(args[setting - 1], "-c", args.join(" ")); + else assert.strictEqual(setting, -1, args.join(" ")); + } + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index f450eef0dc24..57e0b2be5f82 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -32,6 +32,7 @@ import { type VcsStatusResult, type WorktreeSubmodules, } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { makeGitVcsDriverCore, PATCH_RENDER_PREFIX_ARGS, @@ -775,6 +776,13 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "core.fsyncMethod=fsync", ] as const; + // Git for Windows cannot open paths longer than MAX_PATH (260 characters) unless + // core.longpaths is on, and it is off by default. Without it, checkpoint `git add -A` exits + // 128 with "Filename too long" in any workspace holding such a path, and restore cannot write + // or clean those files. Checkpoints only snapshot and restore paths that already exist, so + // commands that touch the working tree turn it on even if the user's config says false. + const longPaths = (yield* HostProcessPlatform) === "win32" ? ["-c", "core.longpaths=true"] : []; + const checkpoints: VcsDriver.VcsCheckpointOps = { captureCheckpoint: Effect.fn("GitVcsDriver.checkpoints.captureCheckpoint")(function* (input) { const operation = VcsProcess.CHECKPOINT_CAPTURE_OPERATION; @@ -939,6 +947,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( args: [ ...indexConfig, ...durableWrite, + ...longPaths, "add", ...(sparseCheckout ? ["--sparse"] : []), "-A", @@ -957,7 +966,15 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const untracked = yield* execute({ operation, cwd: input.cwd, - args: ["ls-files", "--others", "--exclude-standard", "-z", "--", "."], + args: [ + ...longPaths, + "ls-files", + "--others", + "--exclude-standard", + "-z", + "--", + ".", + ], env: commitEnv, maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, }); @@ -1072,7 +1089,16 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( yield* execute({ operation, cwd: input.cwd, - args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."], + args: [ + ...longPaths, + "restore", + "--source", + commitOid, + "--worktree", + "--staged", + "--", + ".", + ], }); } // Restoring away the last tracked file can remove a nested workspace directory. @@ -1091,7 +1117,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const cleaned = yield* execute({ operation, cwd: input.cwd, - args: ["clean", "-fd", "--", "."], + args: [...longPaths, "clean", "-fd", "--", "."], allowNonZeroExit: true, }); if (cleaned.exitCode !== 0) { @@ -1118,7 +1144,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( yield* execute({ operation, cwd: input.cwd, - args: ["reset", "--quiet", "--", "."], + args: [...longPaths, "reset", "--quiet", "--", "."], }); }