From 585a2f87312af1dd978602589e329e521f3d3816 Mon Sep 17 00:00:00 2001 From: Petr Seifert Date: Wed, 26 Aug 2026 15:46:21 +0200 Subject: [PATCH 1/2] fix(server): stage only changed checkpoint paths --- apps/server/src/vcs/GitVcsDriver.test.ts | 146 ++++++++++++++++++++++- apps/server/src/vcs/GitVcsDriver.ts | 68 +++++++++-- 2 files changed, 204 insertions(+), 10 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 89f7c55d5863..7a8394d5dee5 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -7,7 +7,7 @@ import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; import { assert, it } from "@effect/vitest"; -import { GitCommandError } from "@t3tools/contracts"; +import { CheckpointRef, GitCommandError } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; @@ -108,3 +108,147 @@ it.effect("GitVcsDriver forwards execute env to the VCS process", () => { ), ); }); + +it.effect("GitVcsDriver stages only changed paths when capturing a checkpoint", () => { + const commands: Array = []; + const changedPaths = ["changed.ts", "deleted.ts", "dir/new\nfile.ts", ":literal.ts"]; + + return Effect.gen(function* () { + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + assert.isDefined(driver.checkpoints); + + yield* driver.checkpoints.captureCheckpoint({ + cwd: process.cwd(), + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/test/turn/1"), + }); + + const statusCommand = commands.find((command) => command.args[2] === "status"); + assert.isDefined(statusCommand); + assert.deepStrictEqual(statusCommand.args.slice(2), [ + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + "--no-renames", + "--", + ".", + ]); + assert.strictEqual(statusCommand.env?.GIT_OPTIONAL_LOCKS, "0"); + + const addCommand = commands.find((command) => command.args[3] === "add"); + assert.isDefined(addCommand); + assert.deepStrictEqual(addCommand.args.slice(2), [ + "--literal-pathspecs", + "add", + "-A", + "--pathspec-from-file=-", + "--pathspec-file-nul", + ]); + assert.strictEqual(addCommand.stdin, `${changedPaths.join("\0")}\0`); + }).pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.sync(() => { + commands.push(input); + const args = input.args.slice(2); + const stdout = + args[0] === "status" + ? ` M ${changedPaths[0]}\0 D ${changedPaths[1]}\0?? ${changedPaths[2]}\0?? ${changedPaths[3]}\0` + : args[0] === "rev-parse" && args[1] === "--git-common-dir" + ? ".git\n" + : args[0] === "rev-parse" && args[1] === "--show-toplevel" + ? `${process.cwd()}\n` + : args[0] === "write-tree" + ? "tree-oid\n" + : args[0] === "commit-tree" + ? "commit-oid\n" + : ""; + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; + }), + }), + ), + ), + ); +}); + +it.effect("GitVcsDriver captures scoped working tree changes without changing the real index", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-git-checkpoint-paths-", + }); + const scopeCwd = path.join(cwd, "scope"); + const checkpointRef = CheckpointRef.make("refs/t3/checkpoints/test/turn/1"); + assert.isDefined(driver.checkpoints); + + const git = (gitCwd: string, args: ReadonlyArray, allowNonZeroExit = false) => + driver.execute({ + operation: "GitVcsDriver.test.checkpointPaths", + cwd: gitCwd, + args, + allowNonZeroExit, + timeoutMs: 10_000, + }); + const write = (relativePath: string, contents: string) => + fileSystem.writeFileString(path.join(cwd, relativePath), contents); + + yield* git(cwd, ["init"]); + yield* git(cwd, ["config", "user.email", "test@test.com"]); + yield* git(cwd, ["config", "user.name", "Test"]); + yield* fileSystem.makeDirectory(scopeCwd); + yield* write("scope/changed.txt", "original changed\n"); + yield* write("scope/deleted.txt", "original deleted\n"); + yield* write("scope/staged.txt", "original staged\n"); + yield* write("outside.txt", "original outside\n"); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "initial"]); + + yield* write("scope/staged.txt", "staged version\n"); + yield* git(cwd, ["add", "scope/staged.txt"]); + const indexTreeBefore = (yield* git(cwd, ["write-tree"])).stdout.trim(); + + yield* write("scope/changed.txt", "working changed\n"); + yield* write("scope/staged.txt", "working version\n"); + yield* write("scope/new file [1].txt", "new file\n"); + yield* write("outside.txt", "working outside\n"); + yield* fileSystem.remove(path.join(scopeCwd, "deleted.txt")); + + yield* driver.checkpoints.captureCheckpoint({ cwd: scopeCwd, checkpointRef }); + + const indexTreeAfter = (yield* git(cwd, ["write-tree"])).stdout.trim(); + assert.strictEqual(indexTreeAfter, indexTreeBefore); + assert.strictEqual( + (yield* git(cwd, ["show", `${checkpointRef}:scope/changed.txt`])).stdout, + "working changed\n", + ); + assert.strictEqual( + (yield* git(cwd, ["show", `${checkpointRef}:scope/staged.txt`])).stdout, + "working version\n", + ); + assert.strictEqual( + (yield* git(cwd, ["show", `${checkpointRef}:scope/new file [1].txt`])).stdout, + "new file\n", + ); + assert.strictEqual( + (yield* git(cwd, ["show", `${checkpointRef}:outside.txt`])).stdout, + "original outside\n", + ); + assert.notStrictEqual( + (yield* git(cwd, ["cat-file", "-e", `${checkpointRef}:scope/deleted.txt`], true)).exitCode, + 0, + ); + }).pipe(Effect.provide(GitContractLayer)), + ), +); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 6cf4400c62eb..0eab2313121b 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -358,6 +358,12 @@ function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { return parts.filter((value) => value.length > 0); } +function parseCheckpointStatusPaths(output: string): string[] { + return splitNullSeparatedPaths(output, false).flatMap((entry) => + entry.length > 3 && entry[2] === " " ? [entry.slice(3)] : [], + ); +} + function chunkPathsForGitCheckIgnore(relativePaths: ReadonlyArray): string[][] { const chunks: string[][] = []; let chunk: string[] = []; @@ -708,10 +714,18 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir); }); + const resolveRepositoryRoot = (cwd: string) => + execute({ + operation: "GitVcsDriver.checkpoints.resolveRepositoryRoot", + cwd, + args: ["rev-parse", "--show-toplevel"], + }).pipe(Effect.map((result) => result.stdout.trim())); + const checkpoints: VcsDriver.VcsCheckpointOps = { captureCheckpoint: Effect.fn("GitVcsDriver.checkpoints.captureCheckpoint")(function* (input) { const operation = "GitVcsDriver.checkpoints.captureCheckpoint"; const gitCommonDir = yield* resolveGitCommonDir(input.cwd); + const repositoryRoot = yield* resolveRepositoryRoot(input.cwd); const tempIndexPath = path.join( gitCommonDir, `t3-checkpoint-index-${NodeCrypto.randomUUID()}`, @@ -731,22 +745,58 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( yield* Effect.gen(function* () { const headExists = yield* hasHeadCommit(input.cwd); - if (headExists) { + yield* execute({ + operation, + cwd: input.cwd, + args: headExists ? ["read-tree", "HEAD"] : ["read-tree", "--empty"], + env: commitEnv, + }); + + const statusResult = yield* execute({ + operation, + cwd: input.cwd, + args: [ + "status", + "--porcelain=v1", + "-z", + "--untracked-files=all", + "--no-renames", + "--", + ".", + ], + env: { + ...process.env, + GIT_OPTIONAL_LOCKS: "0", + }, + maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + }); + + if (statusResult.stdoutTruncated) { yield* execute({ operation, cwd: input.cwd, - args: ["read-tree", "HEAD"], + args: ["add", "-A", "--", "."], env: commitEnv, }); + } else { + const changedPaths = parseCheckpointStatusPaths(statusResult.stdout); + if (changedPaths.length > 0) { + yield* execute({ + operation, + cwd: repositoryRoot, + args: [ + "--literal-pathspecs", + "add", + "-A", + "--pathspec-from-file=-", + "--pathspec-file-nul", + ], + stdin: `${changedPaths.join("\0")}\0`, + env: commitEnv, + }); + } } - yield* execute({ - operation, - cwd: input.cwd, - args: ["add", "-A", "--", "."], - env: commitEnv, - }); - const writeTreeResult = yield* execute({ operation, cwd: input.cwd, From 0f8cb9a34be39a9ff1e76e37bf9700d2d32cc80a Mon Sep 17 00:00:00 2001 From: Petr Seifert Date: Wed, 26 Aug 2026 17:58:22 +0200 Subject: [PATCH 2/2] fix(server): preserve checkpoint pathname bytes --- apps/server/src/processRunner.test.ts | 44 +++++++++++++++++++ apps/server/src/processRunner.ts | 28 ++++++++---- .../src/stream/collectUint8StreamText.ts | 6 ++- apps/server/src/vcs/GitVcsDriver.test.ts | 20 ++++++++- apps/server/src/vcs/GitVcsDriver.ts | 43 +++++++++++++----- apps/server/src/vcs/VcsProcess.test.ts | 17 +++++++ apps/server/src/vcs/VcsProcess.ts | 8 +++- 7 files changed, 142 insertions(+), 24 deletions(-) diff --git a/apps/server/src/processRunner.test.ts b/apps/server/src/processRunner.test.ts index e264ba7849da..13566d2b01b1 100644 --- a/apps/server/src/processRunner.test.ts +++ b/apps/server/src/processRunner.test.ts @@ -246,6 +246,24 @@ describe("runProcess", () => { }), ); + it.effect("preserves raw stdout bytes when requested", () => + Effect.gen(function* () { + const stdoutBytes = new Uint8Array([0x66, 0x80, 0x6f]); + const spawner = makeSpawner(() => + Effect.succeed(makeHandle({ stdout: Stream.make(stdoutBytes) })), + ); + + const result = yield* runWith(spawner)({ + command: "fake", + args: ["raw-stdout"], + captureStdoutBytes: true, + }); + + expect(result.stdoutInvalidUtf8).toBe(true); + expect(Array.from(result.stdoutBytes ?? [])).toEqual(Array.from(stdoutBytes)); + }), + ); + it.effect("fails fast on output limit before timeout for long-running output", () => Effect.gen(function* () { const textChunk = "x".repeat(64); @@ -319,6 +337,32 @@ describe("runProcess", () => { }), ); + it.effect("writes raw stdin bytes without UTF-8 encoding", () => + Effect.gen(function* () { + const stdinBytes = new Uint8Array([0x66, 0x80, 0x6f]); + const receivedBytes: number[] = []; + const spawner = makeSpawner(() => + Effect.succeed( + makeHandle({ + stdin: Sink.forEach((chunk: Uint8Array) => + Effect.sync(() => { + receivedBytes.push(...chunk); + }), + ), + }), + ), + ); + + yield* runWith(spawner)({ + command: "fake", + args: ["raw-stdin"], + stdin: stdinBytes, + }); + + expect(receivedBytes).toEqual(Array.from(stdinBytes)); + }), + ); + it.effect("returns output for non-zero exit codes", () => Effect.gen(function* () { const spawner = makeSpawner(() => Effect.succeed(makeHandle({ stderr: "boom", code: 2 }))); diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 16b5625d4690..c951d3ae938e 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -24,7 +24,8 @@ export interface ProcessRunInput { readonly spawnCwd?: string | undefined; readonly timeout?: Duration.Input | undefined; readonly env?: NodeJS.ProcessEnv | undefined; - readonly stdin?: string | undefined; + readonly stdin?: string | Uint8Array | undefined; + readonly captureStdoutBytes?: boolean | undefined; readonly maxOutputBytes?: number | undefined; readonly outputMode?: "error" | "truncate" | undefined; readonly truncatedMarker?: string | undefined; @@ -44,6 +45,7 @@ export interface ProcessRunOutput { readonly stderrTruncated: boolean; readonly stdoutInvalidUtf8: boolean; readonly stderrInvalidUtf8: boolean; + readonly stdoutBytes?: Uint8Array; } const ProcessInvocationFields = { @@ -182,6 +184,7 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: { readonly maxOutputBytes: number; readonly outputMode: "error" | "truncate"; readonly truncatedMarker: string; + readonly preserveBytes: boolean; }) { const stream = input.stream.pipe( Stream.mapError( @@ -202,6 +205,7 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: { stream, maxBytes: input.maxOutputBytes, truncatedMarker: input.truncatedMarker, + preserveBytes: input.preserveBytes, }); } @@ -239,13 +243,15 @@ const collectText = Effect.fn("processRunner.collectText")(function* (input: { }); }, ), - Effect.map( - (state): CollectedUint8StreamText => ({ - ...decodeUtf8(Buffer.concat(state.chunks, state.bytes)), + Effect.map((state): CollectedUint8StreamText => { + const rawBytes = Buffer.concat(state.chunks, state.bytes); + return { + ...decodeUtf8(rawBytes), bytes: state.bytes, truncated: false, - }), - ), + ...(input.preserveBytes ? { rawBytes } : {}), + }; + }), ); }); @@ -335,7 +341,10 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( const writeStdin = stdin === undefined ? Effect.void - : Stream.run(Stream.encodeText(Stream.make(stdin)), child.stdin).pipe( + : Stream.run( + typeof stdin === "string" ? Stream.encodeText(Stream.make(stdin)) : Stream.make(stdin), + child.stdin, + ).pipe( Effect.mapError( (cause) => new ProcessStdinError({ @@ -343,7 +352,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( argumentCount: input.args.length, cwd: input.cwd, spawnCwd: input.spawnCwd, - stdinBytes: Buffer.byteLength(stdin), + stdinBytes: typeof stdin === "string" ? Buffer.byteLength(stdin) : stdin.byteLength, cause, }), ), @@ -361,6 +370,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( maxOutputBytes, outputMode, truncatedMarker, + preserveBytes: input.captureStdoutBytes === true, }), collectText({ command: input.command, @@ -372,6 +382,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( maxOutputBytes, outputMode, truncatedMarker, + preserveBytes: false, }), writeStdin, ], @@ -401,6 +412,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( stderrTruncated: stderr.truncated, stdoutInvalidUtf8: stdout.invalidUtf8, stderrInvalidUtf8: stderr.invalidUtf8, + ...(stdout.rawBytes !== undefined ? { stdoutBytes: stdout.rawBytes } : {}), } satisfies ProcessRunOutput; }); diff --git a/apps/server/src/stream/collectUint8StreamText.ts b/apps/server/src/stream/collectUint8StreamText.ts index 71114e1de1b5..db27d762337e 100644 --- a/apps/server/src/stream/collectUint8StreamText.ts +++ b/apps/server/src/stream/collectUint8StreamText.ts @@ -7,6 +7,7 @@ export interface CollectedUint8StreamText { readonly truncated: boolean; readonly bytes: number; readonly invalidUtf8: boolean; + readonly rawBytes?: Uint8Array; } export const decodeUtf8 = ( @@ -26,6 +27,7 @@ export const collectUint8StreamText = (input: { readonly stream: Stream.Stream; readonly maxBytes?: number | undefined; readonly truncatedMarker?: string | null | undefined; + readonly preserveBytes?: boolean | undefined; }): Effect.Effect => { const maxBytes = input.maxBytes ?? Number.POSITIVE_INFINITY; const truncatedMarker = input.truncatedMarker ?? ""; @@ -68,7 +70,8 @@ export const collectUint8StreamText = (input: { }, ), Effect.map((state): CollectedUint8StreamText => { - const decoded = decodeUtf8(Buffer.concat(state.chunks, state.bytes)); + const rawBytes = Buffer.concat(state.chunks, state.bytes); + const decoded = decodeUtf8(rawBytes); return { text: state.truncated && truncatedMarker.length > 0 @@ -77,6 +80,7 @@ export const collectUint8StreamText = (input: { bytes: state.bytes, truncated: state.truncated, invalidUtf8: decoded.invalidUtf8, + ...(input.preserveBytes ? { rawBytes } : {}), }; }), ); diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 7a8394d5dee5..5e7e4d234629 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -112,6 +112,19 @@ it.effect("GitVcsDriver forwards execute env to the VCS process", () => { it.effect("GitVcsDriver stages only changed paths when capturing a checkpoint", () => { const commands: Array = []; const changedPaths = ["changed.ts", "deleted.ts", "dir/new\nfile.ts", ":literal.ts"]; + const invalidUtf8Path = new Uint8Array([0xff, ...new TextEncoder().encode(".txt")]); + const statusBytes = Buffer.concat([ + Buffer.from( + ` M ${changedPaths[0]}\0 D ${changedPaths[1]}\0?? ${changedPaths[2]}\0?? ${changedPaths[3]}\0?? `, + ), + invalidUtf8Path, + Buffer.from([0]), + ]); + const expectedPathspec = Buffer.concat([ + Buffer.from(`${changedPaths.join("\0")}\0`), + invalidUtf8Path, + Buffer.from([0]), + ]); return Effect.gen(function* () { const driver = yield* GitVcsDriver.makeVcsDriverShape(); @@ -134,6 +147,7 @@ it.effect("GitVcsDriver stages only changed paths when capturing a checkpoint", ".", ]); assert.strictEqual(statusCommand.env?.GIT_OPTIONAL_LOCKS, "0"); + assert.strictEqual(statusCommand.captureStdoutBytes, true); const addCommand = commands.find((command) => command.args[3] === "add"); assert.isDefined(addCommand); @@ -144,7 +158,8 @@ it.effect("GitVcsDriver stages only changed paths when capturing a checkpoint", "--pathspec-from-file=-", "--pathspec-file-nul", ]); - assert.strictEqual(addCommand.stdin, `${changedPaths.join("\0")}\0`); + assert.instanceOf(addCommand.stdin, Uint8Array); + assert.deepStrictEqual(addCommand.stdin, expectedPathspec); }).pipe( Effect.provide( Layer.mergeAll( @@ -156,7 +171,7 @@ it.effect("GitVcsDriver stages only changed paths when capturing a checkpoint", const args = input.args.slice(2); const stdout = args[0] === "status" - ? ` M ${changedPaths[0]}\0 D ${changedPaths[1]}\0?? ${changedPaths[2]}\0?? ${changedPaths[3]}\0` + ? new TextDecoder().decode(statusBytes) : args[0] === "rev-parse" && args[1] === "--git-common-dir" ? ".git\n" : args[0] === "rev-parse" && args[1] === "--show-toplevel" @@ -172,6 +187,7 @@ it.effect("GitVcsDriver stages only changed paths when capturing a checkpoint", stderr: "", stdoutTruncated: false, stderrTruncated: false, + ...(args[0] === "status" ? { stdoutBytes: statusBytes } : {}), }; }), }), diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 0eab2313121b..f92bb521c7cb 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -358,10 +358,19 @@ function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { return parts.filter((value) => value.length > 0); } -function parseCheckpointStatusPaths(output: string): string[] { - return splitNullSeparatedPaths(output, false).flatMap((entry) => - entry.length > 3 && entry[2] === " " ? [entry.slice(3)] : [], - ); +function parseCheckpointStatusPathspec(output: Uint8Array): Uint8Array { + const paths: Uint8Array[] = []; + let recordStart = 0; + for (let index = 0; index < output.byteLength; index += 1) { + if (output[index] !== 0) { + continue; + } + if (index - recordStart > 3 && output[recordStart + 2] === 0x20) { + paths.push(output.subarray(recordStart + 3, index + 1)); + } + recordStart = index + 1; + } + return Buffer.concat(paths); } function chunkPathsForGitCheckIgnore(relativePaths: ReadonlyArray): string[][] { @@ -432,7 +441,8 @@ const gitCommand = ( cwd: string, args: ReadonlyArray, options?: { - readonly stdin?: string; + readonly stdin?: string | Uint8Array; + readonly captureStdoutBytes?: boolean; readonly env?: NodeJS.ProcessEnv; readonly allowNonZeroExit?: boolean; readonly timeoutMs?: number; @@ -447,6 +457,9 @@ const gitCommand = ( cwd, spawnCwd: globalThis.process.cwd(), ...(options?.stdin !== undefined ? { stdin: options.stdin } : {}), + ...(options?.captureStdoutBytes !== undefined + ? { captureStdoutBytes: options.captureStdoutBytes } + : {}), ...(options?.env !== undefined ? { env: options.env } : {}), ...(options?.allowNonZeroExit !== undefined ? { allowNonZeroExit: options.allowNonZeroExit } @@ -487,6 +500,9 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const execute: VcsDriver.VcsDriver["Service"]["execute"] = (input) => gitCommand(vcsProcess, input.operation, input.cwd, input.args, { ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), + ...(input.captureStdoutBytes !== undefined + ? { captureStdoutBytes: input.captureStdoutBytes } + : {}), ...(input.env !== undefined ? { env: input.env } : {}), ...(input.allowNonZeroExit !== undefined ? { allowNonZeroExit: input.allowNonZeroExit } : {}), ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), @@ -714,12 +730,14 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir); }); - const resolveRepositoryRoot = (cwd: string) => - execute({ + const resolveRepositoryRoot = Effect.fn("resolveRepositoryRoot")(function* (cwd: string) { + const result = yield* execute({ operation: "GitVcsDriver.checkpoints.resolveRepositoryRoot", cwd, args: ["rev-parse", "--show-toplevel"], - }).pipe(Effect.map((result) => result.stdout.trim())); + }); + return result.stdout.trim(); + }); const checkpoints: VcsDriver.VcsCheckpointOps = { captureCheckpoint: Effect.fn("GitVcsDriver.checkpoints.captureCheckpoint")(function* (input) { @@ -769,9 +787,10 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( GIT_OPTIONAL_LOCKS: "0", }, maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + captureStdoutBytes: true, }); - if (statusResult.stdoutTruncated) { + if (statusResult.stdoutTruncated || statusResult.stdoutBytes === undefined) { yield* execute({ operation, cwd: input.cwd, @@ -779,8 +798,8 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( env: commitEnv, }); } else { - const changedPaths = parseCheckpointStatusPaths(statusResult.stdout); - if (changedPaths.length > 0) { + const changedPathspec = parseCheckpointStatusPathspec(statusResult.stdoutBytes); + if (changedPathspec.byteLength > 0) { yield* execute({ operation, cwd: repositoryRoot, @@ -791,7 +810,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "--pathspec-from-file=-", "--pathspec-file-nul", ], - stdin: `${changedPaths.join("\0")}\0`, + stdin: changedPathspec, env: commitEnv, }); } diff --git a/apps/server/src/vcs/VcsProcess.test.ts b/apps/server/src/vcs/VcsProcess.test.ts index bd3e5b4cdce2..f8dee3782f25 100644 --- a/apps/server/src/vcs/VcsProcess.test.ts +++ b/apps/server/src/vcs/VcsProcess.test.ts @@ -61,6 +61,23 @@ describe("VcsProcess.run", () => { }).pipe(provideLive), ); + it.effect("round-trips raw process bytes when requested", () => + Effect.gen(function* () { + const rawBytes = new Uint8Array([0x66, 0x80, 0x6f]); + const result = yield* run({ + operation: "test.raw-stdout", + command: "node", + args: ["-e", "process.stdin.on('data', chunk => process.stdout.write(chunk))"], + cwd: process.cwd(), + stdin: rawBytes, + captureStdoutBytes: true, + }); + + expect(result.stdoutInvalidUtf8).toBe(true); + expect(Array.from(result.stdoutBytes ?? [])).toEqual(Array.from(rawBytes)); + }).pipe(provideLive), + ); + it.effect("writes stdin before waiting for exit", () => Effect.gen(function* () { const result = yield* run({ diff --git a/apps/server/src/vcs/VcsProcess.ts b/apps/server/src/vcs/VcsProcess.ts index ec245fa13604..f3d3612bfc15 100644 --- a/apps/server/src/vcs/VcsProcess.ts +++ b/apps/server/src/vcs/VcsProcess.ts @@ -23,7 +23,8 @@ export interface VcsProcessInput { readonly args: ReadonlyArray; readonly cwd: string; readonly spawnCwd?: string; - readonly stdin?: string; + readonly stdin?: string | Uint8Array; + readonly captureStdoutBytes?: boolean; readonly env?: NodeJS.ProcessEnv; readonly allowNonZeroExit?: boolean; readonly timeoutMs?: number; @@ -40,6 +41,7 @@ export interface VcsProcessOutput { /** Present on real process output; optional so narrow test doubles remain lightweight. */ readonly stdoutInvalidUtf8?: boolean; readonly stderrInvalidUtf8?: boolean; + readonly stdoutBytes?: Uint8Array; } export class VcsProcess extends Context.Service< @@ -117,6 +119,9 @@ export const make = Effect.gen(function* () { cwd: input.cwd, ...(input.spawnCwd !== undefined ? { spawnCwd: input.spawnCwd } : {}), ...(input.stdin !== undefined ? { stdin: input.stdin } : {}), + ...(input.captureStdoutBytes !== undefined + ? { captureStdoutBytes: input.captureStdoutBytes } + : {}), ...(input.env !== undefined ? { env: input.env } : {}), timeout: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, maxOutputBytes: input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, @@ -178,6 +183,7 @@ export const make = Effect.gen(function* () { stderrTruncated: result.stderrTruncated, stdoutInvalidUtf8: result.stdoutInvalidUtf8 ?? false, stderrInvalidUtf8: result.stderrInvalidUtf8 ?? false, + ...(result.stdoutBytes !== undefined ? { stdoutBytes: result.stdoutBytes } : {}), } satisfies VcsProcessOutput; });