diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 53d031a7413e8..92926daef17cd 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1579,8 +1579,8 @@ it.effect("cancels a slow submodule checkout without reporting best-effort succe const spawner = ChildProcessSpawner.make((command) => { if ( ChildProcess.isStandardCommand(command) && - command.args[0] === "submodule" && - command.args[1] === "update" + command.args.includes("submodule") && + command.args.includes("update") ) { return Effect.acquireRelease( Effect.succeed( @@ -4560,6 +4560,84 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect.each([false, true])( + "checks out submodules in a new worktree with borrowing failure %s", + (failBorrowing) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + + // Git blocks file submodule transports (CVE-2022-39253) despite repo config; local fixtures need env permission. + const previousAllowedProtocol = process.env.GIT_ALLOW_PROTOCOL; + process.env.GIT_ALLOW_PROTOCOL = "file"; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + if (previousAllowedProtocol === undefined) { + delete process.env.GIT_ALLOW_PROTOCOL; + } else { + process.env.GIT_ALLOW_PROTOCOL = previousAllowedProtocol; + } + }), + ); + + const submoduleRepo = yield* makeTmpDir("git-submodule-"); + yield* initRepoWithCommit(submoduleRepo); + yield* writeTextFile(submoduleRepo, "SHARED.md", "# shared\n"); + yield* git(submoduleRepo, ["add", "."]); + yield* git(submoduleRepo, ["commit", "-m", "shared"]); + + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(cwd, ["submodule", "add", submoduleRepo, "shared"]); + yield* git(cwd, ["commit", "-m", "add submodule"]); + // The remote still advertises its refs but can no longer serve objects, so the + // worktree can only populate the submodule from the source's `.git/modules`. + const remoteObjects = pathService.join(submoduleRepo, ".git", "objects"); + if (!failBorrowing) { + yield* fileSystem.remove(remoteObjects, { recursive: true }); + yield* fileSystem.makeDirectory(pathService.join(remoteObjects, "pack"), { + recursive: true, + }); + } + + const worktreePath = pathService.join( + yield* makeTmpDir("git-worktrees-"), + "submodule-worktree", + ); + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + let borrowingFailed = false; + const spawner = ChildProcessSpawner.make((command) => { + if ( + failBorrowing && + ChildProcess.isStandardCommand(command) && + command.args.includes("--dissociate") + ) { + borrowingFailed = true; + return Effect.succeed(makeFailedHandle("error: unknown option 'dissociate'")); + } + return delegate.spawn(command); + }); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/submodules", + }); + + assert.equal( + yield* fileSystem.exists(pathService.join(worktreePath, "shared", "SHARED.md")), + true, + ); + assert.equal(borrowingFailed, failBorrowing); + // The borrowed objects were copied, so the worktree survives the source losing them. + yield* fileSystem.remove(pathService.join(cwd, ".git", "modules"), { recursive: true }); + yield* git(pathService.join(worktreePath, "shared"), ["fsck", "--no-progress"]); + }), + ); + it.effect.each([ ["recursive", false], ["recursive", true], @@ -4605,8 +4683,8 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { Effect.gen(function* () { if ( ChildProcess.isStandardCommand(command) && - command.args[0] === "submodule" && - command.args[1] === "update" + command.args.includes("submodule") && + command.args.includes("update") ) { yield* Deferred.succeed(updateStarted, undefined); yield* Deferred.await(releaseUpdate); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 4ace91f8017d2..6ae7de507fa08 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -4285,6 +4285,47 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* yield* progress.onSubmodulesStarted(); } const onSubmoduleLine = progress?.onSubmoduleLine; + const updateArgs = ["submodule", "update", "--init"]; + if (submoduleMode.value === "recursive") updateArgs.push("--recursive"); + const updateSubmodules = (args: ReadonlyArray, env?: NodeJS.ProcessEnv) => + runGit("GitVcsDriver.createWorktree.updateSubmodules", worktreePath, args, { + // Cloning scales with remote size; let it finish or be cancelled, like worktree add. + timeoutMs: null, + env: onSubmoduleLine ? { ...env, LANGUAGE: "C" } : env, + ...(onSubmoduleLine + ? { progress: { onStdoutLine: onSubmoduleLine, onStderrLine: onSubmoduleLine } } + : {}), + }); + const borrowSubmodules = Effect.gen(function* () { + // Linked worktrees share the source object store; expose it as an alternate so Git + // can borrow from its modules. An empty primary store makes Git inspect the alternate; + // --dissociate copies borrowed objects before that temporary store is removed. + const sharedObjects = yield* runGitStdout( + "GitVcsDriver.createWorktree.objectsPath", + worktreePath, + ["rev-parse", "--git-path", "objects"], + ); + const emptyObjects = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-submodule-objects-", + }); + yield* updateSubmodules( + [ + "-c", + "submodule.alternateLocation=superproject", + "-c", + "submodule.alternateErrorStrategy=ignore", + ...updateArgs, + "--dissociate", + ], + { + GIT_OBJECT_DIRECTORY: emptyObjects, + GIT_ALTERNATE_OBJECT_DIRECTORIES: path.resolve(worktreePath, sharedObjects.trim()), + }, + ); + }).pipe( + Effect.scoped, + Effect.catch(() => updateSubmodules(updateArgs)), + ); // Registering submodule URLs writes the shared config; the clones that follow do not. yield* runGit( "GitVcsDriver.createWorktree.initSubmodules", @@ -4292,25 +4333,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ["submodule", "init"], { writesSharedConfig: true }, ).pipe( - Effect.andThen( - runGit( - "GitVcsDriver.createWorktree.updateSubmodules", - worktreePath, - submoduleMode.value === "recursive" - ? ["submodule", "update", "--init", "--recursive"] - : ["submodule", "update", "--init"], - { - // Cloning scales with remote size; let it finish or be cancelled, like worktree add. - timeoutMs: null, - ...(onSubmoduleLine - ? { - env: { LANGUAGE: "C" }, - progress: { onStdoutLine: onSubmoduleLine, onStderrLine: onSubmoduleLine }, - } - : {}), - }, - ), - ), + Effect.andThen(borrowSubmodules), Effect.matchEffect({ onFailure: (cause) => Effect.logWarning("worktree submodule checkout failed; submodules may be incomplete", {