Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions apps/server/src/git/Layers/GitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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"],
{
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For prepareCommitContext the staged patch is later truncated to 50,000 chars in GitManager.limitContext(...), but this git diff --cached --patch call still captures up to the GitService default maxOutputBytes (1,000,000). Consider passing an explicit smaller maxOutputBytes here (plus a small buffer) to avoid unnecessary read/decoding work on large diffs and to make the intended cap explicit at this call site.

Suggested change
{
{
// Limit output to slightly above the 50,000-character context cap used later.
maxOutputBytes: 60_000,

Copilot uses AI. Check for mistakes.
truncateOutput: true,
},
).pipe(Effect.map((result) => result.stdout));

return {
stagedSummary,
Expand Down
34 changes: 34 additions & 0 deletions apps/server/src/git/Layers/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-");
Expand Down
17 changes: 17 additions & 0 deletions apps/server/src/git/Layers/GitService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test asserts result.stdout.length <= 16, but maxOutputBytes is a byte limit while .length is a UTF-16 code unit count. To make the test accurately reflect the contract (and avoid edge cases if output ever contains non-ASCII), assert against Buffer.byteLength(result.stdout, 'utf8') (or similar) instead of string length.

Suggested change
assert.ok(result.stdout.length <= 16);
assert.ok(Buffer.byteLength(result.stdout, "utf8") <= 16);

Copilot uses AI. Check for mistakes.
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;
Expand Down
44 changes: 36 additions & 8 deletions apps/server/src/git/Layers/GitService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>): string {
return `git ${args.join(" ")}`;
}
Expand All @@ -43,28 +48,44 @@ const collectOutput = Effect.fn(function* <E>(
input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,
stream: Stream.Stream<Uint8Array, E>,
maxOutputBytes: number,
): Effect.fn.Return<string, GitCommandError> {
truncateOutput: boolean,
): Effect.fn.Return<CollectedOutput, GitCommandError> {
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),
cwd: input.cwd,
detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`,
});
Comment on lines 75 to 80
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low Layers/GitService.ts:75

When truncateOutput is false, the code throws an error with the message "output exceeded ${maxOutputBytes} bytes and was truncated." — but the output was not truncated; an error was thrown instead. This is misleading because it contradicts the actual behavior. Consider removing the "and was truncated" clause from the error message when truncation is disabled.

           detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`,
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/git/Layers/GitService.ts around lines 75-80:

When `truncateOutput` is `false`, the code throws an error with the message `"output exceeded ${maxOutputBytes} bytes and was truncated."` — but the output was not truncated; an error was thrown instead. This is misleading because it contradicts the actual behavior. Consider removing the `"and was truncated"` clause from the error message when truncation is disabled.

Evidence trail:
apps/server/src/git/Layers/GitService.ts lines 66-80 at REVIEWED_COMMIT - Line 66-73 shows truncation logic when `truncateOutput` is true. Line 74-80 shows error throwing when `truncateOutput` is false, with the error message at line 78-79 saying `output exceeded ${maxOutputBytes} bytes and was truncated.` despite no truncation occurring.

}
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* () {
Expand All @@ -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
Expand All @@ -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.")),
Expand All @@ -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),
Expand All @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/git/Services/GitService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
Loading