Skip to content
Draft
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
77 changes: 77 additions & 0 deletions apps/server/src/git/Layers/GitCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1594,6 +1594,83 @@ it.layer(TestLayer)("git integration", (it) => {
}),
);

describe("readWorkingTreeDiff", () => {
it.effect("returns an empty diff for a clean repo", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
yield* initRepoWithCommit(tmp);
const diff = yield* (yield* GitCore).readWorkingTreeDiff(tmp);
expect(diff).toEqual({ diff: "" });
}),
);

it.effect("returns a patch for tracked modifications", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
yield* initRepoWithCommit(tmp);

yield* writeTextFile(path.join(tmp, "README.md"), "# test\ntracked change\n");

const diff = yield* (yield* GitCore).readWorkingTreeDiff(tmp);
expect(diff.diff).toContain("diff --git a/README.md b/README.md");
expect(diff.diff).toContain("tracked change");
}),
);

it.effect("returns a new-file patch for untracked files", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
yield* initRepoWithCommit(tmp);

yield* writeTextFile(path.join(tmp, "notes.txt"), "hello\n");

const diff = yield* (yield* GitCore).readWorkingTreeDiff(tmp);
expect(diff.diff).toContain("diff --git a/notes.txt b/notes.txt");
expect(diff.diff).toContain("new file mode");
expect(diff.diff).toContain("+++ b/notes.txt");
}),
);

it.effect("includes combined staged and unstaged changes against HEAD", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
yield* initRepoWithCommit(tmp);

yield* writeTextFile(path.join(tmp, "README.md"), "# test\nstaged change\n");
yield* git(tmp, ["add", "README.md"]);
yield* writeTextFile(
path.join(tmp, "README.md"),
"# test\nstaged change\nunstaged change\n",
);

const diff = yield* (yield* GitCore).readWorkingTreeDiff(tmp);
expect(diff.diff).toContain("diff --git a/README.md b/README.md");
expect(diff.diff).toContain("staged change");
expect(diff.diff).toContain("unstaged change");
}),
);

it.effect("includes staged and untracked changes before the first commit", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
const core = yield* GitCore;
yield* core.initRepo({ cwd: tmp });
yield* git(tmp, ["config", "user.email", "test@test.com"]);
yield* git(tmp, ["config", "user.name", "Test"]);

yield* writeTextFile(path.join(tmp, "staged.txt"), "staged\n");
yield* writeTextFile(path.join(tmp, "untracked.txt"), "untracked\n");
yield* git(tmp, ["add", "staged.txt"]);

const diff = yield* core.readWorkingTreeDiff(tmp);
expect(diff.diff).toContain("diff --git a/staged.txt b/staged.txt");
expect(diff.diff).toContain("diff --git a/untracked.txt b/untracked.txt");
expect(diff.diff).toContain("+++ b/staged.txt");
expect(diff.diff).toContain("+++ b/untracked.txt");
}),
);
});

it.effect("computes ahead count against base branch when no upstream is configured", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
Expand Down
87 changes: 87 additions & 0 deletions apps/server/src/git/Layers/GitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { decodeJsonResult } from "@t3tools/shared/schemaJson";

const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
const LARGE_DIFF_MAX_OUTPUT_BYTES = 5_000_000;
const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]";
const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000;
const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000;
Expand Down Expand Up @@ -187,6 +188,13 @@ function parseBranchLine(line: string): { name: string; current: boolean } | nul
};
}

function parseNullSeparatedLines(stdout: string): string[] {
return stdout
.split("\u0000")
.map((value) => value.trim())
.filter((value) => value.length > 0);
}

function filterBranchesForListQuery(
branches: ReadonlyArray<GitBranch>,
query?: string,
Expand Down Expand Up @@ -1337,6 +1345,84 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
})),
);

const readWorkingTreeDiff: GitCoreShape["readWorkingTreeDiff"] = Effect.fn("readWorkingTreeDiff")(
function* (cwd) {
const headResult = yield* executeGit(
"GitCore.readWorkingTreeDiff.verifyHead",
cwd,
["rev-parse", "--verify", "HEAD"],
{
allowNonZeroExit: true,
},
);
const headExists = headResult.code === 0;

const trackedPatchSegments = headExists
? [
yield* runGitStdoutWithOptions(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium Layers/GitCore.ts:1362

readWorkingTreeDiff passes maxOutputBytes: LARGE_DIFF_MAX_OUTPUT_BYTES but leaves truncateOutputAtMaxBytes as the default false. When the diff exceeds 5MB, collectOutput throws a GitCommandError instead of truncating. This is inconsistent with prepareCommitContext and readRangeContext, which both set truncateOutputAtMaxBytes: true to handle large outputs gracefully. Consider adding truncateOutputAtMaxBytes: true to all three runGitStdoutWithOptions calls in this function.

🤖 Copy this AI Prompt to have your agent fix this:
In file apps/server/src/git/Layers/GitCore.ts around line 1362:

`readWorkingTreeDiff` passes `maxOutputBytes: LARGE_DIFF_MAX_OUTPUT_BYTES` but leaves `truncateOutputAtMaxBytes` as the default `false`. When the diff exceeds 5MB, `collectOutput` throws a `GitCommandError` instead of truncating. This is inconsistent with `prepareCommitContext` and `readRangeContext`, which both set `truncateOutputAtMaxBytes: true` to handle large outputs gracefully. Consider adding `truncateOutputAtMaxBytes: true` to all three `runGitStdoutWithOptions` calls in this function.

Evidence trail:
apps/server/src/git/Layers/GitCore.ts lines 1360-1391 (REVIEWED_COMMIT): three runGitStdoutWithOptions calls in readWorkingTreeDiff with only `maxOutputBytes` set. apps/server/src/git/Layers/GitCore.ts lines 600-607: collectOutput throws GitCommandError when truncateOutputAtMaxBytes is false and max exceeded. apps/server/src/git/Layers/GitCore.ts lines 1452-1460 (prepareCommitContext) and 1652-1676 (readRangeContext): all set `truncateOutputAtMaxBytes: true`.

"GitCore.readWorkingTreeDiff.trackedPatch",
cwd,
["diff", "HEAD", "--patch", "--minimal"],
{
maxOutputBytes: LARGE_DIFF_MAX_OUTPUT_BYTES,
},
),
]
: yield* Effect.all(
[
runGitStdoutWithOptions(
"GitCore.readWorkingTreeDiff.cachedRootPatch",
cwd,
["diff", "--cached", "--patch", "--minimal", "--root"],
{
maxOutputBytes: LARGE_DIFF_MAX_OUTPUT_BYTES,
},
),
runGitStdoutWithOptions(
"GitCore.readWorkingTreeDiff.workingTreePatch",
cwd,
["diff", "--patch", "--minimal"],
{
maxOutputBytes: LARGE_DIFF_MAX_OUTPUT_BYTES,
},
),
],
{ concurrency: "unbounded" },
);

const untrackedPaths = parseNullSeparatedLines(
yield* runGitStdout("GitCore.readWorkingTreeDiff.untrackedFiles", cwd, [
"ls-files",
"--others",
"--exclude-standard",
"-z",
]),
).toSorted((left, right) => left.localeCompare(right));

const untrackedPatches = yield* Effect.forEach(
untrackedPaths,
(relativePath) =>
runGitStdoutWithOptions(
"GitCore.readWorkingTreeDiff.untrackedPatch",
cwd,
["diff", "--no-index", "--patch", "--minimal", "--", "/dev/null", relativePath],
{
allowNonZeroExit: true,
maxOutputBytes: LARGE_DIFF_MAX_OUTPUT_BYTES,
},
).pipe(Effect.map((patch) => patch.trim())),
{ concurrency: "unbounded" },
);

const diff = [...trackedPatchSegments, ...untrackedPatches]
.map((segment) => segment.trim())
.filter((segment) => segment.length > 0)
.join("\n\n");

return { diff };
},
);

const prepareCommitContext: GitCoreShape["prepareCommitContext"] = Effect.fn(
"prepareCommitContext",
)(function* (cwd, filePaths) {
Expand Down Expand Up @@ -2127,6 +2213,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: {
return {
execute,
status,
readWorkingTreeDiff,
statusDetails,
statusDetailsLocal,
prepareCommitContext,
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/git/Services/GitCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
GitCreateBranchResult,
GitCreateWorktreeInput,
GitCreateWorktreeResult,
GitDiffResult,
GitInitInput,
GitListBranchesInput,
GitListBranchesResult,
Expand Down Expand Up @@ -153,6 +154,11 @@ export interface GitCoreShape {
*/
readonly status: (input: GitStatusInput) => Effect.Effect<GitStatusResult, GitCommandError>;

/**
* Read a patch for all working tree changes, including untracked files.
*/
readonly readWorkingTreeDiff: (cwd: string) => Effect.Effect<GitDiffResult, GitCommandError>;

/**
* Read detailed working tree / branch status for a repository.
*/
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,10 @@ const WsRpcLayer = WsRpcGroup.toLayer(
),
{ "rpc.aggregate": "git" },
),
[WS_METHODS.gitDiff]: (input) =>
observeRpcEffect(WS_METHODS.gitDiff, git.readWorkingTreeDiff(input.cwd), {
"rpc.aggregate": "git",
}),
[WS_METHODS.gitRunStackedAction]: (input) =>
observeRpcStream(
WS_METHODS.gitRunStackedAction,
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3877,8 +3877,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
search: (previous) => {
const rest = stripDiffSearchParams(previous);
return filePath
? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath }
: { ...rest, diff: "1", diffTurnId: turnId };
? {
...rest,
diff: "1",
diffScope: "session",
diffTurnId: turnId,
diffFilePath: filePath,
}
: { ...rest, diff: "1", diffScope: "session", diffTurnId: turnId };
},
});
},
Expand Down
Loading
Loading