diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index d9a5e7d2b9fde..062ca5980351a 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1659,6 +1659,77 @@ it.effect("lets a worktree checkout that is still working run past five minutes" ), ); +it.effect.each([{ exitCode: 0 }, { exitCode: 1 }])( + "finishes a noisy worktree checkout using its exit status (%j)", + ({ exitCode }) => + Effect.gen(function* () { + const updates: Array<{ percent: number; completed: number; total: number }> = []; + const outputDrained = yield* Deferred.make(); + const noise = `${"x".repeat(1_000_001)}\n`; + const spawner = ChildProcessSpawner.make((command) => { + if (!ChildProcess.isStandardCommand(command) || !command.args.includes("worktree")) { + return Effect.succeed(makeSuccessfulHandle("")); + } + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + ...makeSuccessfulHandle(""), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + stdout: Stream.encodeText( + Stream.concat( + Stream.make(noise), + Stream.fromEffect( + Deferred.succeed(outputDrained, undefined).pipe(Effect.as("hook finished\n")), + ), + ), + ), + stderr: Stream.encodeText(Stream.make(noise, "Updating files: 100% (10/10), done.\r")), + }), + ); + }); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + const path = yield* Path.Path; + const cwd = yield* makeTmpDir(); + const worktreePath = path.join(cwd, "worktree"); + + const result = yield* driver + .createWorktree( + { cwd, refName: "main", path: worktreePath }, + { + progress: { + onCheckoutProgress: (update) => + Effect.sync(() => { + updates.push(update); + }), + }, + }, + ) + .pipe(Effect.result); + + assert.isTrue(yield* Deferred.isDone(outputDrained)); + assert.deepStrictEqual(updates, [{ percent: 100, completed: 10, total: 10 }]); + if (exitCode === 0) { + assert.isTrue(Result.isSuccess(result)); + if (Result.isSuccess(result)) assert.equal(result.success.worktree.path, worktreePath); + } else { + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.equal(result.failure.exitCode, exitCode); + assert.equal(result.failure.detail, "git worktree add failed"); + assert.equal(result.failure.stdoutLength, 1_000_000); + assert.equal(result.failure.stderrLength, 1_000_000); + } + } + }).pipe( + Effect.provide( + layerServerConfig.pipe( + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ), + ), +); + it.effect("cancels a slow submodule checkout without reporting best-effort success", () => Effect.gen(function* () { const updateStarted = yield* Deferred.make(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 4dabf3b493ebe..6af35b582eeee 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -194,6 +194,7 @@ interface ExecuteGitOptions { env?: NodeJS.ProcessEnv | undefined; maxOutputBytes?: number | undefined; appendTruncationMarker?: boolean | undefined; + keepLineCallbacksAfterTruncation?: boolean | undefined; progress?: GitVcsDriver.ExecuteGitProgress | undefined; writesSharedConfig?: boolean | undefined; } @@ -1170,6 +1171,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...(options.appendTruncationMarker !== undefined ? { appendTruncationMarker: options.appendTruncationMarker } : {}), + ...(options.keepLineCallbacksAfterTruncation !== undefined + ? { keepLineCallbacksAfterTruncation: options.keepLineCallbacksAfterTruncation } + : {}), ...(options.progress ? { progress: options.progress } : {}), ...(options.writesSharedConfig ? { writesSharedConfig: true } : {}), }).pipe( @@ -4322,6 +4326,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ], { fallbackErrorDetail: "git worktree add failed", + // Hook output is diagnostic; reaching the capture limit must not abort the checkout. + appendTruncationMarker: true, + keepLineCallbacksAfterTruncation: true, // Large checkouts and post-checkout hooks can legitimately run for many // minutes; a fixed deadline kills them and leaves an orphaned worktree. timeoutMs: null,