diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 74c09da5f9..d8e5e575ad 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -21,6 +21,8 @@ interface ExecuteGitOptions { timeoutMs?: number | undefined; allowNonZeroExit?: boolean | undefined; fallbackErrorMessage?: string | undefined; + maxOutputBytes?: number | undefined; + truncateOutput?: boolean | undefined; } function parseBranchAb(value: string): { ahead: number; behind: number } { @@ -237,6 +239,8 @@ const makeGitCore = Effect.gen(function* () { args, allowNonZeroExit: true, ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), + ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), + ...(options.truncateOutput !== undefined ? { truncateOutput: options.truncateOutput } : {}), }) .pipe( Effect.flatMap((result) => { @@ -793,12 +797,14 @@ const makeGitCore = Effect.gen(function* () { return null; } - const stagedPatch = yield* runGitStdout("GitCore.prepareCommitContext.stagedPatch", cwd, [ - "diff", - "--cached", - "--patch", - "--minimal", - ]); + const stagedPatch = yield* executeGit( + "GitCore.prepareCommitContext.stagedPatch", + cwd, + ["diff", "--cached", "--patch", "--minimal"], + { + truncateOutput: true, + }, + ).pipe(Effect.map((result) => result.stdout)); return { stagedSummary, diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index e76994f853..51f28e8394 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -729,6 +729,40 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("creates a commit when the staged patch exceeds the capture limit", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, "huge.txt"), "line\n".repeat(250_000)); + let generatedPatchLength = 0; + + const { manager } = yield* makeManager({ + textGeneration: { + generateCommitMessage: (input) => + Effect.sync(() => { + generatedPatchLength = input.stagedPatch.length; + return { + subject: "Commit huge patch", + body: "", + }; + }), + }, + }); + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit", + }); + + expect(result.commit.status).toBe("created"); + expect(generatedPatchLength).toBe(50_013); + expect( + yield* runGit(repoDir, ["log", "-1", "--pretty=%s"]).pipe( + Effect.map((gitResult) => gitResult.stdout.trim()), + ), + ).toBe("Commit huge patch"); + }), + ); + it.effect("uses custom commit message when provided", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/Layers/GitService.test.ts b/apps/server/src/git/Layers/GitService.test.ts index 7db468c06c..2013ce6d4a 100644 --- a/apps/server/src/git/Layers/GitService.test.ts +++ b/apps/server/src/git/Layers/GitService.test.ts @@ -37,6 +37,23 @@ layer("GitServiceLive", (it) => { }), ); + it.effect("runGit can truncate oversized output when requested", () => + Effect.gen(function* () { + const gitService = yield* GitService; + const result = yield* gitService.execute({ + operation: "GitProcess.test.truncateOutput", + cwd: process.cwd(), + args: ["--version", "--build-options"], + maxOutputBytes: 16, + truncateOutput: true, + }); + + assert.equal(result.code, 0); + assert.ok(result.stdout.length <= 16); + assert.equal(result.stdoutTruncated, true); + }), + ); + it.effect("runGit fails with GitCommandError when non-zero exits are not allowed", () => Effect.gen(function* () { const gitService = yield* GitService; diff --git a/apps/server/src/git/Layers/GitService.ts b/apps/server/src/git/Layers/GitService.ts index d3f07e3151..40239abb5a 100644 --- a/apps/server/src/git/Layers/GitService.ts +++ b/apps/server/src/git/Layers/GitService.ts @@ -19,6 +19,11 @@ import { const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; +interface CollectedOutput { + text: string; + truncated: boolean; +} + function quoteGitCommand(args: ReadonlyArray): string { return `git ${args.join(" ")}`; } @@ -43,15 +48,30 @@ const collectOutput = Effect.fn(function* ( input: Pick, stream: Stream.Stream, maxOutputBytes: number, -): Effect.fn.Return { + truncateOutput: boolean, +): Effect.fn.Return { const decoder = new TextDecoder(); let bytes = 0; let text = ""; + let truncated = false; yield* Stream.runForEach(stream, (chunk) => Effect.gen(function* () { - bytes += chunk.byteLength; - if (bytes > maxOutputBytes) { + if (truncated) { + return; + } + + const nextBytes = bytes + chunk.byteLength; + if (nextBytes > maxOutputBytes) { + if (truncateOutput) { + const remainingBytes = maxOutputBytes - bytes; + if (remainingBytes > 0) { + text += decoder.decode(chunk.subarray(0, remainingBytes), { stream: true }); + bytes = maxOutputBytes; + } + truncated = true; + return; + } return yield* new GitCommandError({ operation: input.operation, command: quoteGitCommand(input.args), @@ -59,12 +79,13 @@ const collectOutput = Effect.fn(function* ( detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, }); } + bytes = nextBytes; text += decoder.decode(chunk, { stream: true }); }), ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed."))); text += decoder.decode(); - return text; + return { text, truncated }; }); const makeGitService = Effect.gen(function* () { @@ -77,6 +98,7 @@ const makeGitService = Effect.gen(function* () { } as const; const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + const truncateOutput = input.truncateOutput ?? false; const commandEffect = Effect.gen(function* () { const child = yield* commandSpawner @@ -90,8 +112,8 @@ const makeGitService = Effect.gen(function* () { const [stdout, stderr, exitCode] = yield* Effect.all( [ - collectOutput(commandInput, child.stdout, maxOutputBytes), - collectOutput(commandInput, child.stderr, maxOutputBytes), + collectOutput(commandInput, child.stdout, maxOutputBytes, truncateOutput), + collectOutput(commandInput, child.stderr, maxOutputBytes, truncateOutput), child.exitCode.pipe( Effect.map((value) => Number(value)), Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), @@ -101,7 +123,7 @@ const makeGitService = Effect.gen(function* () { ); if (!input.allowNonZeroExit && exitCode !== 0) { - const trimmedStderr = stderr.trim(); + const trimmedStderr = stderr.text.trim(); return yield* new GitCommandError({ operation: commandInput.operation, command: quoteGitCommand(commandInput.args), @@ -113,7 +135,13 @@ const makeGitService = Effect.gen(function* () { }); } - return { code: exitCode, stdout, stderr } satisfies ExecuteGitResult; + return { + code: exitCode, + stdout: stdout.text, + stderr: stderr.text, + stdoutTruncated: stdout.truncated, + stderrTruncated: stderr.truncated, + } satisfies ExecuteGitResult; }); return yield* commandEffect.pipe( diff --git a/apps/server/src/git/Services/GitService.ts b/apps/server/src/git/Services/GitService.ts index f43a4e6dcc..cff725a857 100644 --- a/apps/server/src/git/Services/GitService.ts +++ b/apps/server/src/git/Services/GitService.ts @@ -19,12 +19,15 @@ export interface ExecuteGitInput { readonly allowNonZeroExit?: boolean; readonly timeoutMs?: number; readonly maxOutputBytes?: number; + readonly truncateOutput?: boolean; } export interface ExecuteGitResult { readonly code: number; readonly stdout: string; readonly stderr: string; + readonly stdoutTruncated?: boolean; + readonly stderrTruncated?: boolean; } /**