Skip to content
Merged
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
6 changes: 6 additions & 0 deletions apps/pi-extension/server/serverReview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
detectRemoteDefaultInfo,
getFileContentsForDiff as getFileContentsForDiffCore,
getSinceBaseSections,
isBinaryPatchFile,
isSameCwdCommitSwitch,
listPatchFiles,
parseCommitDiffType,
Expand Down Expand Up @@ -2328,6 +2329,11 @@ export async function startReviewServer(options: {
}
}

if (isBinaryPatchFile(currentPatch, filePath)) {
json(res, { oldContent: null, newContent: null });
return;
}

if (workspace) {
try {
const result = await workspace.getFileContents(filePath, oldPath);
Expand Down
2 changes: 1 addition & 1 deletion packages/review-editor/components/AllFilesCodeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1101,7 +1101,7 @@ export const AllFilesCodeView: React.FC<AllFilesCodeViewProps> = ({
newFile:
data.newContent != null ? { name: file.path, contents: data.newContent } : undefined,
});
if (!result) {
if (!result || result.isPartial) {
augmentState.set(itemId, { status: 'done', controller, generation });
return;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/review-editor/components/DiffViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ export const DiffViewer: React.FC<DiffViewerProps> = ({
oldFile: fileContents.old != null ? { name: oldPath || filePath, contents: fileContents.old } : undefined,
newFile: fileContents.new != null ? { name: filePath, contents: fileContents.new } : undefined,
});
return result || fileDiff;
return result && !result.isPartial ? result : fileDiff;
} catch {
return fileDiff;
}
Expand Down
74 changes: 74 additions & 0 deletions packages/server/review-workspace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,80 @@ describe("review-workspace", () => {
});

describe("workspace review server integration", () => {
it("short-circuits binary file expansion before provider content retrieval", async () => {
const root = makeTempDir("plannotator-workspace-binary-content-");
const repo = join(root, "api");
mkdirSync(join(repo, ".git"), { recursive: true });
let fileContentCalls = 0;

const runtime = {
async getVcsContext(cwd?: string): Promise<GitContext> {
return {
vcsType: "git",
currentBranch: "main",
defaultBranch: "main",
cwd: cwd ?? repo,
worktrees: [],
availableBranches: { local: [], remote: [] },
diffOptions: [{ id: "uncommitted", label: "Uncommitted changes" }],
};
},
async runVcsDiff() {
return {
patch: [
"diff --git a/asset.bin b/asset.bin",
"new file mode 100644",
"Binary files /dev/null and b/asset.bin differ",
"",
].join("\n"),
label: "Uncommitted changes",
};
},
async getVcsFileContentsForDiff() {
fileContentCalls += 1;
return { oldContent: "unexpected", newContent: "unexpected" };
},
async getVcsDiffFingerprint() {
return "stable-binary-workspace";
},
async canStageFiles() {
return false;
},
async stageFile() {},
async unstageFile() {},
};

const workspace = await WorkspaceReviewSession.create(runtime, root);
const aggregate = aggregateWorkspacePatch(workspace.repos);
const server = await startReviewServer({
rawPatch: aggregate.rawPatch,
gitRef: aggregate.gitRef,
error: aggregate.errors.join("\n") || undefined,
origin: "claude-code",
workspace,
agentCwd: workspace.root,
htmlContent: "<!doctype html><html><body>review</body></html>",
});

try {
const diffPayload = await fetch(`${server.url}/api/diff`).then((response) =>
response.json()
) as { snapshotId: string };
const response = await fetch(
`${server.url}/api/file-content?path=api/asset.bin&snapshot=${encodeURIComponent(diffPayload.snapshotId)}`,
);

expect(response.status).toBe(200);
expect(await response.json()).toEqual({
oldContent: null,
newContent: null,
});
expect(fileContentCalls).toBe(0);
} finally {
server.stop();
}
});

it("maps one workspace mode across mixed Git and JJ repos", async () => {
const root = makeTempDir("plannotator-workspace-mixed-vcs-");
const gitRepo = join(root, "api");
Expand Down
5 changes: 5 additions & 0 deletions packages/server/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
resolveBaseBranch,
getSinceBaseSections,
detectRemoteDefaultInfo,
isBinaryPatchFile,
listPatchFiles,
type RemoteDefaultInfo,
type SinceBaseSections,
Expand Down Expand Up @@ -2350,6 +2351,10 @@ export async function startReviewServer(
}
}

if (isBinaryPatchFile(currentPatch, filePath)) {
return Response.json({ oldContent: null, newContent: null });
}

if (workspace) {
try {
const result = await workspace.getFileContents(filePath, oldPath);
Expand Down
26 changes: 25 additions & 1 deletion packages/shared/diff-fingerprint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getGitDiffFingerprint, type ReviewGitRuntime } from "./review-core";
import {
getGitDiffFingerprint,
MAX_REVIEW_FILE_CONTENT_BYTES,
type ReviewGitRuntime,
} from "./review-core";

// Real-git runtime against a throwaway repo — fingerprints are only meaningful
// against actual VCS behavior, so no mocks.
Expand Down Expand Up @@ -80,6 +84,26 @@ describe("getGitDiffFingerprint", () => {
expect(edited).not.toBe(created!);
});

test("large untracked files use metadata without entering the JS heap", async () => {
const path = join(repo, "large-untracked.bin");
writeFileSync(path, Buffer.alloc(MAX_REVIEW_FILE_CONTENT_BYTES + 1));
let largeFileReads = 0;
const guardedRuntime: ReviewGitRuntime = {
...runtime,
async readTextFile(requestedPath) {
if (requestedPath === path) largeFileReads++;
return runtime.readTextFile(requestedPath);
},
};

const before = await getGitDiffFingerprint(guardedRuntime, "uncommitted", "main", repo);
writeFileSync(path, Buffer.alloc(MAX_REVIEW_FILE_CONTENT_BYTES + 2, 1));
const after = await getGitDiffFingerprint(guardedRuntime, "uncommitted", "main", repo);

expect(largeFileReads).toBe(0);
expect(after).not.toBe(before);
});

test("uncommitted: changes when a commit lands (HEAD moves)", async () => {
const before = await getGitDiffFingerprint(runtime, "uncommitted", "main", repo);
await git("add", "-A");
Expand Down
112 changes: 111 additions & 1 deletion packages/shared/review-core.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { afterEach, describe, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve as resolvePath } from "node:path";
import {
Expand All @@ -12,8 +19,11 @@ import {
getWorkingTreeDiffFromBase,
gitAddFile,
gitResetFile,
isBinaryPatchFile,
isSameCwdCommitSwitch,
listPatchFiles,
listRecentCommits,
MAX_REVIEW_FILE_CONTENT_BYTES,
parseCommitDiffType,
parseWorktreeDiffType,
prepareGitCommand,
Expand Down Expand Up @@ -235,6 +245,69 @@ describe("review-core", () => {
expect(result.patch).toContain("+++ b/untracked.txt");
});

test("large untracked files stay visible without diffing their contents", async () => {
const repoDir = initRepo();
const runtime = makeRuntime(repoDir);
writeFileSync(
join(repoDir, "large build.bin"),
Buffer.alloc(MAX_REVIEW_FILE_CONTENT_BYTES + 1),
);

const result = await runGitDiff(runtime, "uncommitted", "main");

expect(result.patch).toContain('diff --git "a/large build.bin" "b/large build.bin"');
expect(result.patch).toContain('Binary files /dev/null and "b/large build.bin" differ');
expect(listPatchFiles(result.patch)).toContainEqual({
path: "large build.bin",
additions: 0,
deletions: 0,
});
expect(isBinaryPatchFile(result.patch, "large build.bin")).toBe(true);
});

test("binary patch detection follows rename metadata", () => {
const patch = [
'diff --git "a/old name.bin" "b/new name.bin"',
"similarity index 100%",
"rename from old name.bin",
"rename to new name.bin",
"GIT binary patch",
"",
].join("\n");

expect(isBinaryPatchFile(patch, "new name.bin")).toBe(true);
expect(isBinaryPatchFile(patch, "old name.bin")).toBe(false);
});

test("untracked diff collection caps concurrent git processes", async () => {
const repoDir = initRepo();
const baseRuntime = makeRuntime(repoDir);
for (let index = 0; index < 12; index++) {
writeFileSync(join(repoDir, `untracked-${index}.txt`), `${index}\n`);
}
let active = 0;
let peak = 0;
const runtime: ReviewGitRuntime = {
...baseRuntime,
async runGit(args, options) {
if (!args.includes("--no-index")) return baseRuntime.runGit(args, options);
active++;
peak = Math.max(peak, active);
await new Promise((resolve) => setTimeout(resolve, 10));
try {
return await baseRuntime.runGit(args, options);
} finally {
active--;
}
},
};

await runGitDiff(runtime, "uncommitted", "main");

expect(peak).toBeGreaterThan(1);
expect(peak).toBeLessThanOrEqual(4);
});

test("ordinary working-tree diffs keep tracked changes when an untracked file cannot be read", async () => {
const runtime: ReviewGitRuntime = {
async runGit(args) {
Expand Down Expand Up @@ -515,6 +588,43 @@ describe("review-core", () => {
expect(newFileContents.newContent).toBe("brand new\n");
});

test("file content lookup refuses oversized working-tree files", async () => {
const repoDir = initRepo();
const runtime = makeRuntime(repoDir);
writeFileSync(
join(repoDir, "large-generated.js"),
Buffer.alloc(MAX_REVIEW_FILE_CONTENT_BYTES + 1, 0x20),
);

const contents = await getFileContentsForDiff(
runtime,
"uncommitted",
"main",
"large-generated.js",
);

expect(contents).toEqual({ oldContent: null, newContent: null });
});

test("file content lookup reads a symlink payload without following its target", async () => {
const repoDir = initRepo();
const runtime = makeRuntime(repoDir);
writeFileSync(
join(repoDir, "large-target.bin"),
Buffer.alloc(MAX_REVIEW_FILE_CONTENT_BYTES + 1),
);
symlinkSync("large-target.bin", join(repoDir, "generated-link"));

const contents = await getFileContentsForDiff(
runtime,
"uncommitted",
"main",
"generated-link",
);

expect(contents).toEqual({ oldContent: null, newContent: "large-target.bin" });
});

test("getDefaultBranch falls back to local when origin/HEAD points at an unfetched ref", () => {
// Simulates a narrow / partial clone where origin/HEAD is configured but
// the target ref was never fetched. Before the verify step, the server
Expand Down
Loading